From e12ed7663c05a8f1620ea68a787b243340ecb871 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:20:57 -0500 Subject: [PATCH 1/8] =?UTF-8?q?feat(decision-brief):=20wire=20the=20decisi?= =?UTF-8?q?on-brief=20stage=20(Mary=20=E2=86=92=20ideas//brief.md=20?= =?UTF-8?q?at=20Ripe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements combsmith/incubator#3. Before it, a human applied idea:approved on one feature-ideation enhancement comment and a triage score; nothing produced a decision brief. This adds the stage, mirroring idea-triage / initiative-planner: - .github/workflows/decision-brief.yml — central org-private run. Mary (the BMAD Analyst, vendored skills read by path through prompts/incubation/) fills the TARGET repo's own ideas/_TEMPLATE/{brief,market-research,ost}.md for one Ideas Discussion: problem + JTBD with the evidence, cited market context, OST, ICE by default (RICE only with a cited Reach), a riskiest-assumption + cheapest-test table, and a go / no-go / park recommendation naming the idea:approved gate. Triggers: needs-brief label (redispatch bridge — claude-code-action aborts on discussion events) or from_queue=true (brief every Ripe idea in the target's Idea Promotion Queue without a brief, capped). AGENTS_PAUSED-gated. - Provenance guard (#1160): scripts/decision-brief/validate-package.sh runs as its own step after the model. Any quantified market claim without a URL / footnote / [ASSUMPTION] / "unknown — needs discovery", a missing score or Confidence, an empty assumptions table, a positionless recommendation or a leftover placeholder fails the run; the package stays an artifact, never a PR. - scripts/decision-brief/open-pr.sh lands the package on incubate/ in the target via the Git Data API (one commit, persona author), opens or refreshes the PR, links it from the Discussion. Never applies idea:approved. - .github/workflows/decision-brief-reusable.yml — the fleet dispatch bridge callers pin at @decision-brief/v1-stable (hosted here, like dev-lead-reusable); trusted-actor guard on the label path; schedule/manual → sweep. - standards/workflows/decision-brief.yml — the thin caller stub the incubator adds (staging copy; to be promoted to combsmith/.github/standards/workflows/). - Tests: tests/test_decision_brief.bats (tooling, gh mocked, 34 cases) and tests/test_decision_brief_workflow.bats (workflow/reusable/stub invariants, 21 cases), wired into lint.yml. Fixtures under tests/fixtures/decision-brief/. - Wiring: fleet-pause registry row (+ regenerated AGENTS.md table), §4 row and exclusion row in docs/agentic-interaction-model.md, cut-release valid_agent, versioning table, pipeline doc, READMEs. Refs combsmith/incubator#3 (does not close it: the incubator still needs its caller stub, ideas/_TEMPLATE is there already, and the needs-brief + idea:approved labels). Co-Authored-By: Claude Fable 5.1 --- .github/workflows/decision-brief-reusable.yml | 143 ++++++ .github/workflows/decision-brief.yml | 388 ++++++++++++++ .github/workflows/lint.yml | 2 + AGENTS.md | 20 + README.md | 1 + docs/agentic-interaction-model.md | 2 + .../idea-to-initiative-pipeline.md | 48 +- docs/release/versioning.md | 3 +- profile/README.md | 1 + prompts/incubation/README.md | 21 +- prompts/incubation/decision-brief-stage.md | 160 ++++++ prompts/incubation/producer-ost.md | 40 ++ scripts/cut-release.sh | 2 +- scripts/decision-brief/README.md | 46 ++ scripts/decision-brief/gather-context.sh | 185 +++++++ scripts/decision-brief/lib/slug.sh | 52 ++ scripts/decision-brief/open-pr.sh | 195 +++++++ scripts/decision-brief/redispatch.sh | 70 +++ scripts/decision-brief/ripe-from-queue.sh | 131 +++++ scripts/decision-brief/validate-package.sh | 341 ++++++++++++ scripts/lib/fleet-pause-registry.tsv | 1 + standards/workflows/decision-brief.yml | 77 +++ tests/fixtures/decision-brief/good/brief.md | 53 ++ .../decision-brief/good/market-research.md | 27 + tests/fixtures/decision-brief/good/ost.md | 20 + tests/fixtures/decision-brief/queue-body.md | 18 + .../fixtures/decision-brief/template/brief.md | 57 +++ .../template/market-research.md | 31 ++ tests/fixtures/decision-brief/template/ost.md | 37 ++ tests/test_decision_brief.bats | 484 ++++++++++++++++++ tests/test_decision_brief_workflow.bats | 196 +++++++ tests/test_fleet_pause_coverage.bats | 1 + 32 files changed, 2843 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/decision-brief-reusable.yml create mode 100644 .github/workflows/decision-brief.yml create mode 100644 prompts/incubation/decision-brief-stage.md create mode 100644 prompts/incubation/producer-ost.md create mode 100644 scripts/decision-brief/README.md create mode 100644 scripts/decision-brief/gather-context.sh create mode 100644 scripts/decision-brief/lib/slug.sh create mode 100644 scripts/decision-brief/open-pr.sh create mode 100644 scripts/decision-brief/redispatch.sh create mode 100644 scripts/decision-brief/ripe-from-queue.sh create mode 100644 scripts/decision-brief/validate-package.sh create mode 100644 standards/workflows/decision-brief.yml create mode 100644 tests/fixtures/decision-brief/good/brief.md create mode 100644 tests/fixtures/decision-brief/good/market-research.md create mode 100644 tests/fixtures/decision-brief/good/ost.md create mode 100644 tests/fixtures/decision-brief/queue-body.md create mode 100644 tests/fixtures/decision-brief/template/brief.md create mode 100644 tests/fixtures/decision-brief/template/market-research.md create mode 100644 tests/fixtures/decision-brief/template/ost.md create mode 100644 tests/test_decision_brief.bats create mode 100644 tests/test_decision_brief_workflow.bats diff --git a/.github/workflows/decision-brief-reusable.yml b/.github/workflows/decision-brief-reusable.yml new file mode 100644 index 000000000..b3810e578 --- /dev/null +++ b/.github/workflows/decision-brief-reusable.yml @@ -0,0 +1,143 @@ +# Reusable Decision Brief — fleet dispatch workflow. +# Single source of truth for the org: the dispatch logic lives here. Repo-level +# decision-brief.yml files are thin caller stubs +# (standards/workflows/decision-brief.yml in this repo until it is promoted to +# combsmith/.github/standards/workflows/). +# +# Two signals from the CALLER repo reach the CENTRAL decision-brief workflow in +# combsmith/.github-private, which runs Mary (the BMAD Analyst) there with the +# caller as `target_repo` — the brief PR is opened in the caller: +# - `needs-brief` added to an Ideas Discussion -> one brief for that idea +# - the caller's schedule / manual dispatch -> sweep: one brief per idea +# idea-triage ranked "Ripe" in the caller's Idea Promotion Queue that has no +# brief yet (capped per sweep) +# +# It does NOT draft inline: the vendored BMAD frameworks and the provenance +# guard live centrally, and claude-code-action aborts on `discussion` event +# contexts ("Unsupported event type: discussion"). The `needs-brief` label stays +# the human request; the dispatch is an invisible bridge (same pattern as +# initiative-planner-reusable.yml / idea-triage-reusable.yml in combsmith/.github). +# +# IMPORTANT — the dispatch requires a PAT, not GITHUB_TOKEN: +# GitHub does not start new workflow runs from a workflow_dispatch fired with +# the default GITHUB_TOKEN (loop prevention). The dispatch MUST use a PAT with +# workflow scope (GH_PAT_DON_PETRY, or the legacy GH_PAT_WORKFLOWS). +# +# Requires (org secrets): GH_PAT_DON_PETRY (or GH_PAT_WORKFLOWS). +name: Decision Brief — Fleet Dispatch (Reusable) + +on: + workflow_call: + inputs: + central_repo: + description: "owner/repo hosting the central decision-brief.yml workflow." + required: false + default: "combsmith/.github-private" + type: string + discussion: + description: "Ideas Discussion number to brief (manual dispatch from the caller). Empty on label/schedule events." + required: false + default: "" + type: string + label: + description: "Discussion label that requests a brief." + required: false + default: "needs-brief" + type: string + max_briefs: + description: "Sweep cap: how many Ripe ideas one sweep may dispatch briefs for." + required: false + default: "3" + type: string + secrets: + GH_PAT_DON_PETRY: + description: "Canonical org PAT — preferred over GH_PAT_WORKFLOWS when present" + required: false + GH_PAT_WORKFLOWS: + description: "Classic PAT with repo + workflow scope used to dispatch the central decision-brief (GITHUB_TOKEN cannot start a workflow_dispatch run)." + required: false + +permissions: {} # no default permissions; each job grants only what it needs + +jobs: + dispatch: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + # Fire on: the configured label added to a Discussion; a schedule or manual + # dispatch of the caller (sweep, or a single idea when `discussion` is set). + # Any other caller event is ignored. + if: >- + (github.event_name == 'discussion' && github.event.action == 'labeled' && github.event.label.name == inputs.label) || + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} + CENTRAL_REPO: ${{ inputs.central_repo }} + TARGET_REPO: ${{ github.repository }} + steps: + - name: Guard — PAT present + run: | + if [ -z "${GH_TOKEN}" ]; then + echo "::error::GH_PAT_DON_PETRY (or legacy GH_PAT_WORKFLOWS) is required — a workflow_dispatch fired with GITHUB_TOKEN will not start a run." + exit 1 + fi + + - name: Guard — trusted actor + id: trust + # Only admin/write collaborators may spend brief budget by labelling a + # Discussion. The discussion `labeled` payload carries no + # author_association, so query the collaborator permission API. + # Schedule and manual dispatch are trusted by construction. + env: + REPO: ${{ github.repository }} + ACTOR: ${{ github.event.sender.login || github.actor }} + EVENT: ${{ github.event_name }} + run: | + if [ "$EVENT" != "discussion" ]; then + echo "trusted=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + PERM=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" --jq '.permission' 2>/dev/null || echo "none") + case "$PERM" in + admin|write) + echo "Actor @${ACTOR} has permission ${PERM} — trusted." + echo "trusted=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "::warning::Actor @${ACTOR} has permission ${PERM} — not trusted; not dispatching the brief." + echo "trusted=false" >> "$GITHUB_OUTPUT" + ;; + esac + + - name: Dispatch central decision-brief for this repo + if: steps.trust.outputs.trusted == 'true' + env: + EVENT: ${{ github.event_name }} + # The label event carries the Discussion number; a manual dispatch may + # pass one through `discussion`; a schedule never has one (sweep). + DISCUSSION_NUMBER: ${{ github.event.discussion.number || inputs.discussion }} + MAX_BRIEFS: ${{ inputs.max_briefs }} + run: | + if [ -n "${DISCUSSION_NUMBER}" ]; then + # Validate the discussion number before it reaches the gh command line. + if ! [[ "${DISCUSSION_NUMBER}" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::discussion number must be a positive integer, got: '${DISCUSSION_NUMBER}'" + exit 1 + fi + echo "${EVENT} on ${TARGET_REPO} discussion #${DISCUSSION_NUMBER} → dispatching central decision-brief in ${CENTRAL_REPO}." + gh workflow run decision-brief.yml \ + --repo "${CENTRAL_REPO}" \ + -f discussion="${DISCUSSION_NUMBER}" \ + -f target_repo="${TARGET_REPO}" \ + -f dry_run=false + echo "Dispatched decision-brief.yml (discussion=${DISCUSSION_NUMBER}, target_repo=${TARGET_REPO}, dry_run=false)." + else + echo "${EVENT} on ${TARGET_REPO} → dispatching central decision-brief SWEEP (Ripe ideas without a brief) in ${CENTRAL_REPO}." + gh workflow run decision-brief.yml \ + --repo "${CENTRAL_REPO}" \ + -f from_queue=true \ + -f target_repo="${TARGET_REPO}" \ + -f dry_run=false + echo "Dispatched decision-brief.yml (from_queue=true, target_repo=${TARGET_REPO}, dry_run=false)." + fi diff --git a/.github/workflows/decision-brief.yml b/.github/workflows/decision-brief.yml new file mode 100644 index 000000000..ba56c7dc1 --- /dev/null +++ b/.github/workflows/decision-brief.yml @@ -0,0 +1,388 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Decision Brief — BMAD Analyst (Mary) +# +# Turns ONE ripe idea (an Ideas Discussion) into the decision-grade artifact a +# human approves `idea:approved` on: ideas//brief.md (+ market-research.md +# and ost.md), filled from the TARGET repo's own ideas/_TEMPLATE/, scored with +# RICE or ICE, with a riskiest-assumption + cheapest-test table and a go/no-go +# recommendation — opened as a PR in the target repo. It NEVER applies +# `idea:approved`: the brief informs the go/no-go, the human makes it. +# +# Pipeline position (closes the gap named in combsmith/incubator#3): +# feature-ideation (Discussions) -> idea-triage (shortlist: Ripe) +# -> THIS (brief PR) -> ★ human adds idea:approved +# -> initiative-planner (epic + DAG) -> ★ human adds initiative:auto +# -> initiative-driver -> dev-lead +# +# Triggers: +# workflow_dispatch discussion=N (+ target_repo) — the only path the model +# step runs on (claude-code-action aborts on `discussion` +# events). Fleet callers reach it through +# decision-brief-reusable.yml; a human can run it by hand. +# workflow_dispatch from_queue=true — brief every Ripe idea in the target's +# Idea Promotion Queue that has no brief yet (one dispatch +# of this same workflow per idea, capped). +# discussion:labeled `needs-brief` on THIS repo's Discussions (dogfood path) +# — bridged to workflow_dispatch by the redispatch job. +# +# Provenance guard (.github-private#1160): every market claim in the package +# cites a source, is tagged [ASSUMPTION], or says "unknown — needs discovery". +# scripts/decision-brief/validate-package.sh enforces this as its own step AFTER +# the model step, so a package that fabricates never becomes a PR. +# +# This is org-private automation (like initiative-planner.yml), not a thin +# caller. All tooling + the vendored BMAD skills live in this repo and are +# unit-tested (scripts/decision-brief/, prompts/incubation/). +# ───────────────────────────────────────────────────────────────────────────── +name: Decision Brief (BMAD Analyst) +# readme-agent: Decision-brief agent (BMAD Analyst) — drafts the go/no-go brief for a ripe idea as a PR in the target repo. + +on: + workflow_dispatch: + inputs: + discussion: + description: 'Ideas Discussion number to brief. Leave empty with from_queue=true to brief every Ripe idea in the target Idea Promotion Queue.' + required: false + default: '' + type: string + target_repo: + description: 'Target repo (owner/repo) to brief FOR — read its Discussion + ideas/_TEMPLATE/, open the PR there. Empty => this repo (the dogfood/self path).' + required: false + default: '' + type: string + from_queue: + description: 'Sweep mode: read the target Idea Promotion Queue (idea-triage) and dispatch one brief run per Ripe idea that has no brief yet.' + required: false + default: false + type: boolean + dry_run: + description: 'Draft and validate the package but log the intended PR to an artifact instead of opening it (sweep mode: log the intended dispatches).' + required: false + default: true + type: boolean + model: + description: 'Model family (opus|sonnet|haiku) for Mary, resolved to the current id at run time; a concrete claude-* id is honoured as-is' + required: false + default: 'opus' + type: string + # Ask for a brief by labelling an Ideas Discussion `needs-brief`; that fires here. + discussion: + types: [labeled] + +permissions: {} + +concurrency: + # One brief per (target repo, discussion) at a time; never cancel an in-flight + # draft. A sweep run has no discussion number and takes the `queue` lane. + group: decision-brief-${{ github.event.inputs.target_repo || github.repository }}-${{ github.event.inputs.discussion || github.event.discussion.number || 'queue' }} + cancel-in-progress: false + +jobs: + # ── discussion [labeled] bridge ─────────────────────────────────────────────── + # claude-code-action aborts on `discussion` event contexts ("Unsupported event + # type: discussion"), so we never brief inline on a discussion event. The + # `needs-brief` label fires this bridge job, which resolves the discussion + # number and re-invokes THIS workflow via workflow_dispatch — under which the + # action runs. The label stays the human request; the re-dispatch is an + # invisible bridge (same shape as initiative-planner.yml, #618). + redispatch: + if: github.event_name == 'discussion' && github.event.label.name == 'needs-brief' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read # actions/checkout; the workflow_dispatch is fired with the PAT + steps: + - name: Checkout agent repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Guard — PAT present + # PAT required: a workflow_dispatch fired with GITHUB_TOKEN is accepted but + # NEVER starts a run (loop prevention) — the brief would silently no-op. + env: + GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} + run: | + if [ -z "${GH_TOKEN}" ]; then + echo "::error::GH_PAT_DON_PETRY (or legacy GH_PAT_WORKFLOWS) is required — a workflow_dispatch fired with GITHUB_TOKEN will not start a run." + exit 1 + fi + + - name: Re-dispatch under workflow_dispatch + env: + GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} + REPO: ${{ github.repository }} + # Dogfood path: this repo briefs its own discussions, so the target is + # self. Fleet callers live in their own repos and set their host via + # decision-brief-reusable.yml. + TARGET_REPO: ${{ github.repository }} + DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + run: bash scripts/decision-brief/redispatch.sh + + # ── sweep mode: Ripe in idea-triage → one brief run per idea ───────────────── + # No model call here: it reads the queue issue and dispatches `brief` runs of + # this same workflow (one per idea, each with its own concurrency lane). + sweep: + if: github.event_name == 'workflow_dispatch' && inputs.from_queue == true + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read # checkout + env: + REPO: ${{ github.event.inputs.target_repo || github.repository }} + CENTRAL_REPO: ${{ github.repository }} + DRY_RUN: ${{ inputs.dry_run == true && '1' || '0' }} + # Reads (queue issue, discussions, contents, PRs) on the TARGET + the + # dispatch of this workflow: a PAT is needed for the dispatch regardless + # of target (GITHUB_TOKEN never starts a workflow_dispatch run). + GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} + steps: + - name: Checkout agent repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Guard — PAT present + run: | + if [ -z "${GH_TOKEN}" ]; then + echo "::error::GH_PAT_DON_PETRY (or legacy GH_PAT_WORKFLOWS) is required to dispatch the per-idea brief runs." + exit 1 + fi + + - name: Dispatch a brief run per Ripe idea without a brief + run: | + echo "DRY_RUN_LOG=${{ runner.temp }}/dry-run.jsonl" >> "$GITHUB_ENV" + DRY_RUN_LOG="${{ runner.temp }}/dry-run.jsonl" bash scripts/decision-brief/ripe-from-queue.sh + + - name: Upload dry-run dispatch log + if: ${{ env.DRY_RUN == '1' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: decision-brief-sweep-dry-run + path: ${{ runner.temp }}/dry-run.jsonl + if-no-files-found: warn + + brief: + # AGENTS_PAUSED (#1525, #2249): with the fleet paused this job is skipped + # (neutral, not red) and `fleet-paused` below reports why. + if: vars.AGENTS_PAUSED != 'true' && github.event_name == 'workflow_dispatch' && inputs.from_queue != true + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read # checkout, read prompts/ frameworks/ scripts/ + issues: read # the Idea Promotion Queue excerpt (self path) + pull-requests: write # open the brief PR (self path) + discussions: write # link the PR from the Discussion (self path) + id-token: write # claude-code-action OIDC + env: + # This job only runs on workflow_dispatch (the discussion path re-dispatches + # here via the `redispatch` job), so inputs are always present. + # REPO is the TARGET repo to brief for; empty target_repo => this repo (self). + REPO: ${{ github.event.inputs.target_repo || github.repository }} + # Token for every READ of and WRITE to the TARGET repo (gather-context, the + # PR, the Discussion back-link). GITHUB_TOKEN is scoped only to + # .github-private, so it is valid for the self path only; cross-repo runs + # use GH_PAT_DON_PETRY (same pattern as initiative-planner.yml / idea-triage.yml). + TARGET_GH_TOKEN: ${{ (github.event.inputs.target_repo == '' || github.event.inputs.target_repo == github.repository) && secrets.GITHUB_TOKEN || secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} + DISCUSSION_NUMBER: ${{ github.event.inputs.discussion }} + DRY_RUN: ${{ inputs.dry_run == true && '1' || '0' }} + TOOLING_DIR: ${{ github.workspace }}/scripts/decision-brief + steps: + - name: Guard — discussion number present + run: | + if ! [[ "${DISCUSSION_NUMBER}" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::discussion must be a positive integer (got '${DISCUSSION_NUMBER}'). Pass discussion=N, or from_queue=true to sweep the Idea Promotion Queue." + exit 1 + fi + + - name: Checkout agent repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set temp paths + run: | + echo "DRY_RUN_LOG=${{ runner.temp }}/dry-run.jsonl" >> "$GITHUB_ENV" + echo "CONTEXT_PATH=${{ runner.temp }}/brief-context.json" >> "$GITHUB_ENV" + echo "STATUS_PATH=${{ runner.temp }}/brief-status.json" >> "$GITHUB_ENV" + echo "BRIEF_ROOT=${{ runner.temp }}/package" >> "$GITHUB_ENV" + mkdir -p "${{ runner.temp }}/package" + + - name: Gather brief context + env: + # Reads the TARGET repo's Discussion, template, package spec and queue. + GH_TOKEN: ${{ env.TARGET_GH_TOKEN }} + run: bash scripts/decision-brief/gather-context.sh + + # Resolve the model FAMILY (opus|sonnet|haiku) to the current concrete id via + # the single-source-of-truth resolver (#1979). A concrete claude-* id an + # operator supplied is passed through unchanged. + - name: Resolve model + id: model + env: + # Read the dispatch input via env, never interpolated into the shell + # source, so a quote in the value can't break out and inject commands. + REQUESTED_MODEL: ${{ github.event.inputs.model }} + # Operator model override for the family resolver (#1979). + AI_MODELS_CLAUDE: ${{ vars.AI_MODELS_CLAUDE || '' }} + run: | + source scripts/lib/engine-models.sh + want="$REQUESTED_MODEL" + [ -n "$want" ] || want=opus + case "$want" in + opus|sonnet|haiku) model="$(ai_model_for_family "$want")" ;; + claude-*) model="$want" ;; + *) echo "::error::invalid model: $want (expected opus|sonnet|haiku, or a concrete claude-* id)" >&2; exit 1 ;; + esac + # The id is spliced into claude_args unquoted below, so it must be a + # single safe token (no whitespace, not option-like) or it would split + # into unintended CLI arguments. + if [[ ! "$model" =~ ^[A-Za-z0-9][A-Za-z0-9._:/@-]*$ ]]; then + echo "::error::invalid model id: $model" >&2 + exit 1 + fi + echo "model=$model" >> "$GITHUB_OUTPUT" + + - name: Draft the decision brief (BMAD Analyst) + id: mary + env: + # Mary needs no GitHub write here: the package is written to + # $BRIEF_ROOT and landed by open-pr.sh in the next steps. The read token + # lets her `gh` into the target for extra grounding (issues, files). + GH_TOKEN: ${{ env.TARGET_GH_TOKEN }} + uses: anthropics/claude-code-action@6c690188da61cf16ee7debb28383ba67f02abb4c # v1.0.242 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ env.TARGET_GH_TOKEN }} + claude_args: | + --model ${{ steps.model.outputs.model }} + --allowedTools Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch + prompt: | + You are **Mary** 📊, the BMAD Business Analyst, running as an automated, + non-interactive decision-brief producer for the ${{ env.REPO }} repo. + Never ask, never greet, never wait (headless contract). + + ## Method — follow the stage overlay (by path) + + Read the orchestration overlay and follow every step. It binds the + vendored BMAD analysis skills (`frameworks/bmad-method/src/bmm-skills/1-analysis/`) + to this stage's output contract — the TARGET repo's own template, + RICE/ICE with evidence-tied Confidence, the riskiest-assumption + + cheapest-test table, the go/no-go — and to the provenance rules: + + ```bash + cat prompts/incubation/shared.md + cat prompts/incubation/decision-brief-stage.md + ``` + + ## Your input + + Everything you may treat as grounded fact is in `$CONTEXT_PATH` + (JSON): the Discussion (title, body, comments), the feature-ideation + enhancement comment (if any), the idea-triage queue verdict (if any), + the target repo's `ideas/_TEMPLATE/` files you must fill, its + `package-spec.json`, anything already under `ideas//`, and the + issues the thread references: + + ```bash + cat "$CONTEXT_PATH" | jq . + ``` + + The slug is `$SLUG`; the package dir you write is + `$BRIEF_ROOT/ideas/$SLUG/`. Web research (WebSearch/WebFetch) is + allowed and encouraged for the market sections — **cite every figure + you bring in with its URL**. If the tools are unavailable or a search + finds nothing, write "unknown — needs discovery" — never a plausible + number. + + ## Your output + + 1. `$BRIEF_ROOT/ideas/$SLUG/brief.md` — frontmatter `artifact: brief`, + `status: final`; every section of the template filled, no + ``; §5 shows the RICE or ICE arithmetic with + Confidence stated and tied to the evidence; §6 table has ≥1 full + row (most dangerous assumption first, cheapest test named); §7 is a + position — **go / no-go / park** — with what a POC must prove and + what would make the decider say no, and names the human gate + (`idea:approved`). + 2. `$BRIEF_ROOT/ideas/$SLUG/market-research.md` — `artifact: + market-research`; the detail behind brief §3; ends with a + `## Sources` section listing every URL you cited (or the sentence + "No external sources were consulted — every market claim above is + marked unknown — needs discovery."). + 3. `$BRIEF_ROOT/ideas/$SLUG/ost.md` — `artifact: ost`; the Mermaid + opportunity-solution tree (outcome → opportunities → solutions → + cheapest assumption tests), consistent with brief §4 and §6. + 4. `$STATUS_PATH` — the `create` JSON status from `shared.md`, plus + `"recommendation"` (go|no-go|park), `"score"` (e.g. "ICE 3×1×2=6"), + and `"sources"` (array of URLs cited). + + ## Validate before you finish + + ```bash + bash scripts/decision-brief/validate-package.sh "$BRIEF_ROOT/ideas/$SLUG" \ + --slug "$SLUG" --discussion "$DISCUSSION_NUMBER" + ``` + + Fix every FAIL line and re-run until it passes. The workflow runs the + same check again after you; a failing package is never opened as a PR. + + ## Hard constraints + + - **No fabrication.** Do not invent market sizes, competitor prices, + user counts, quotes or metrics. Cite, tag `[ASSUMPTION]`, or write + "unknown — needs discovery". An honestly thin brief that recommends + *park: buy evidence first* is a correct output. + - Do not apply labels, open PRs, or comment on the Discussion yourself + — open-pr.sh does that, DRY_RUN-aware. Do not modify this repo. + - Keep the brief to a page or two; detail goes to market-research.md. + - Finish with a 3–5 line summary: the recommendation, the score and + its Confidence, the riskiest assumption, and the open questions. + + - name: Provenance + structure gate + # The deterministic half of the guard: re-run the validator the model + # already ran. A package that fails here stays an artifact, never a PR. + run: | + bash scripts/decision-brief/validate-package.sh "$BRIEF_ROOT/ideas/$SLUG" \ + --slug "$SLUG" --discussion "$DISCUSSION_NUMBER" + + - name: Open (or refresh) the brief PR in the target repo + id: pr + env: + GH_TOKEN: ${{ env.TARGET_GH_TOKEN }} + PACKAGE_DIR: ${{ env.BRIEF_ROOT }}/ideas/${{ env.SLUG }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: bash scripts/decision-brief/open-pr.sh + + - name: Upload the drafted package + # Always: in dry-run it is the only output; live, it is the audit copy. + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: decision-brief-${{ env.SLUG || 'package' }} + path: | + ${{ runner.temp }}/package + ${{ runner.temp }}/brief-status.json + ${{ runner.temp }}/dry-run.jsonl + if-no-files-found: warn + + # Fleet pause notice (#2249). With AGENTS_PAUSED=true the `brief` job above is + # skipped by its `if:`, and a skipped job cannot log. This job runs instead, on + # the same events, and says why in one line. Inventory and rules: AGENTS.md + # "Pausing the agent fleet" and scripts/lib/fleet-pause-registry.tsv. + fleet-paused: + if: >- + vars.AGENTS_PAUSED == 'true' && + github.event_name == 'workflow_dispatch' && inputs.from_queue != true + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: {} + env: + PAUSE_SOURCE: ${{ vars.AGENTS_PAUSE_SOURCE }} + PAUSE_UNTIL: ${{ vars.AGENTS_PAUSE_UNTIL }} + steps: + - name: Report the fleet pause + run: | + echo "::notice title=Agent fleet paused::Decision Brief: not run, the agent fleet is paused (AGENTS_PAUSED=true)." \ + "Pause source: ${PAUSE_SOURCE:-not set (paused by hand)}." \ + "Paused until: ${PAUSE_UNTIL:-not set}." diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dde222108..3a34195b9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -176,6 +176,8 @@ jobs: tests/test_initiative_planner_redispatch.bats \ tests/test_initiative_reconcile.bats \ tests/test_idea_triage.bats \ + tests/test_decision_brief.bats \ + tests/test_decision_brief_workflow.bats \ tests/test_oversized_pr.bats \ tests/test_dependabot.bats \ tests/dependency_advisory.bats \ diff --git a/AGENTS.md b/AGENTS.md index e88533514..e72cc9ec8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -327,6 +327,25 @@ calling it a regression, and treat CI as authoritative.** `scripts/auto_rebase_health.sh`, tests in `tests/auto_rebase_health.bats`) and runs on `github.token` only — no cross-org PAT. It must not be removed by template syncs. If the org template gains an auto-rebase instrumentation equivalent, remove this exception and defer to the template instead. +- **Exception:** `decision-brief.yml` (combsmith/incubator#3) is a documented repo-specific workflow with + no corresponding org template in `standards/workflows/`. It is org-private automation in the same class as + `initiative-planner.yml` / `idea-triage.yml`: the BMAD Analyst (Mary) drafts `ideas//brief.md` + (+ `market-research.md`, `ost.md`) for ONE ripe Ideas Discussion of a `target_repo` from that repo's own + `ideas/_TEMPLATE/`, scored RICE/ICE with a riskiest-assumption + cheapest-test table and a go/no-go, and + opens the package as a PR there. Its `needs-brief` `discussion:[labeled]` trigger is bridged to + `workflow_dispatch` by a `redispatch` job (claude-code-action aborts on `discussion` contexts); its + `from_queue` sweep briefs every idea idea-triage ranked Ripe that has no brief yet. The **provenance + guard** (`scripts/decision-brief/validate-package.sh`, #1160) is a separate step after the model: an + unsourced market figure, a missing score/Confidence, an empty assumptions table or a positionless + recommendation fails the run before any PR exists. It never applies `idea:approved`. Tooling in + `scripts/decision-brief/` (tests: `tests/test_decision_brief.bats`, + `tests/test_decision_brief_workflow.bats`). Its companion **`decision-brief-reusable.yml`** is a + first-party reusable HOSTED HERE (like `dev-lead-reusable.yml`): the fleet dispatch bridge that caller + stubs pin at `@decision-brief/v1-stable` (cut with `scripts/cut-release.sh decision-brief …`; the + `release-channel-tags` ruleset must gain the `decision-brief/**` namespace — an admin step). The thin + caller stub lives at `standards/workflows/decision-brief.yml` as a **staging copy** until it is promoted + to `combsmith/.github/standards/workflows/` (org standards belong there; this repo only hosts it so + the stub and the reusable ship in one reviewed change). Neither file must be removed by template syncs. - **Exception:** `initiative-planner-canary.yml` (#822, closes #655 item 3) is a documented repo-specific workflow with no corresponding org template in `standards/workflows/`. It is a daily (≤1/day) + `workflow_dispatch` post-merge canary that fires a **dry-run** dispatch of `initiative-planner.yml` @@ -932,6 +951,7 @@ gated (with the one upstream exception above), and a test keeps it that way. | `issue-triage-runner.yml` | Job condition | `triage` | The apply job needs triage, so it is skipped with it. | | `initiative-planner.yml` | Job condition | `plan` | The redispatch bridge makes no model call and still runs; the run it starts is the one that reports the pause. | | `idea-triage.yml` | Job condition | `triage` | | +| `decision-brief.yml` | Job condition | `brief` | The redispatch bridge and the queue sweep make no model call and still run; the brief run they start is the one that reports the pause. | | `spec-drift.yml` | Job condition, no notice | `spec-drift` | Opt-in and off by default. No notice job: its contract test requires every job to hold issues: write and the workflow to cost nothing when it does not run. | | `dependency-advisory.yml` | Job condition | `advisory` | | | `docs-health-check.yml` | Job condition | `health-check` | | diff --git a/README.md b/README.md index 133c0264b..71250f637 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Autonomous agents that run as GitHub Actions, triggered by events or schedules a | [`ci-failure-analyst-reusable.yml`](.github/workflows/ci-failure-analyst-reusable.yml) | CI failure analyst — diagnoses failed CI/Lint/Test runs and comments root-cause analysis | | [`feature-ideation.yml`](.github/workflows/feature-ideation.yml) | Feature research & ideation agent (BMAD Analyst) — turns sources into scoped feature ideas | | [`idea-triage.yml`](.github/workflows/idea-triage.yml) | Idea triage agent — scores queued ideas and promotes them into the backlog | +| [`decision-brief.yml`](.github/workflows/decision-brief.yml) | Decision-brief agent (BMAD Analyst) — drafts the go/no-go brief for a ripe idea as a PR in the target repo | | [`initiative-driver.yml`](.github/workflows/initiative-driver.yml) | Initiative driver — auto-releases an epic's ready sub-issues to Dev-Lead | | [`initiative-planner.yml`](.github/workflows/initiative-planner.yml) | Initiative planner (BMAD Scrum Master) — breaks an approved initiative into an epic + sub-issues | | [`issue-triage-runner.yml`](.github/workflows/issue-triage-runner.yml) | Issue triage agent — labels and routes newly opened issues | diff --git a/docs/agentic-interaction-model.md b/docs/agentic-interaction-model.md index 26487c8e7..b7734a360 100644 --- a/docs/agentic-interaction-model.md +++ b/docs/agentic-interaction-model.md @@ -198,6 +198,7 @@ keeps its own file) carries **no** job qualifier and remains exactly one row. | `.github/workflows/gh-aw-cross-org.yml` | 1 | — | `repository_dispatch:[gh-aw-issue-triage, gh-aw-ci-failure]` — PAT-backed cross-org dispatch receiver; no schedule. | | `.github/workflows/persona-runner.yml` | 1 | — | `repository_dispatch:[persona-mention]` — PAT-backed dispatch receiver for persona invocations; no schedule. | | `.github/workflows/initiative-planner.yml` | 1 | — | `workflow_dispatch` only — human-explicit bridge for the `discussion:[labeled]` signal that cannot be subscribed to inline; §2 Class 1 by declaration (no schedule, no `timer_role`). | +| `.github/workflows/decision-brief.yml` | 1 | — | `workflow_dispatch` only — human-explicit bridge for the `discussion:[labeled]` (`needs-brief`) signal that cannot be subscribed to inline, plus the `from_queue` sweep a caller's own timer dispatches; §2 Class 1 by declaration (no schedule, no `timer_role`). | | `.github/workflows/pr-auto-review.yml` | 1 | — | `workflow_run:[completed]`, `check_suite:[completed]`, `pull_request_review:[submitted, dismissed]`, `pull_request:[opened, reopened, synchronize, ready_for_review]` — multi-event readiness gate; no schedule. | | `.github/workflows/pr-review-delayed-retry.yml` | 1 | — | `workflow_dispatch` only — armed on demand by `pr-review-sweep.yml` to retry a rate-limited PR review near its `reset` without depending on the best-effort cron (#1994). §2 Class 1 by declaration (no schedule, no `timer_role`); the in-run sleep is a deterministic armed delay anchored to `not_before`, not a scheduled trigger — the fix for the convergence-clock pattern, not an instance of it. | | `.github/workflows/pr-review-sweep.yml` | 2 | backstop | `schedule: '2,17,32,47 * * * *'` backstop **plus** `workflow_run:[completed]` fast path (#898) scoped to the completing CI run's PR(s); idempotent, per-branch `cancel-in-progress`. | @@ -249,6 +250,7 @@ keeps its own file) carries **no** job qualifier and remains exactly one row. > | `.github/workflows/dev-lead-reusable.yml` | Reusable workflow — invoked via `workflow_call`, not a direct entrypoint | > | `.github/workflows/ci-failure-analyst-reusable.yml` | Reusable workflow — invoked via `workflow_call`, not a direct entrypoint | > | `.github/workflows/persona-runner-reusable.yml` | Reusable workflow — invoked via `workflow_call`, not a direct entrypoint | +> | `.github/workflows/decision-brief-reusable.yml` | Reusable workflow — invoked via `workflow_call`, not a direct entrypoint | > | `.github/workflows/pr-review.yml` | Reusable workflow — invoked via `workflow_call`, not a direct entrypoint | > | `.github/workflows/repair-pr-approvals.yml` | Manual admin tool — `workflow_dispatch` only, no automated agentic trigger | > | `.github/workflows/model-ab.yml` | Manual maintainer eval tool — `workflow_dispatch` only, no schedule/automated trigger, no agent→agent interaction (#1950) | diff --git a/docs/initiatives/idea-to-initiative-pipeline.md b/docs/initiatives/idea-to-initiative-pipeline.md index 27e2b23db..921241945 100644 --- a/docs/initiatives/idea-to-initiative-pipeline.md +++ b/docs/initiatives/idea-to-initiative-pipeline.md @@ -10,7 +10,14 @@ feature-ideation.yml (weekly, automated) ──> Ideas DISCUSSIONS idea-triage.yml (weekly, automated) ──> "🧭 Idea Promotion Queue" ranks ripeness; never approves tracking issue (shortlist) │ - ★ HUMAN — add `idea:approved` to a Discussion + ▼ +decision-brief.yml (Ripe in the queue, or `needs-brief`; automated) + BMAD Analyst "Mary" ──> PR: ideas//{brief,market-research,ost}.md + (RICE/ICE, riskiest assumption + cheapest test, + go/no-go; every market claim cited or + "unknown — needs discovery") + │ + ★ HUMAN — read the brief, then add `idea:approved` to the Discussion │ ▼ initiative-planner.yml (on approval / dispatch, automated) @@ -151,6 +158,35 @@ inputs. idea. It **never** applies `idea:approved` — that's the human's pick. - Tooling: `scripts/idea-triage/{gather-ideas,upsert-queue}.sh`. +### `decision-brief.yml` — BMAD Analyst "Mary" (the go/no-go brief) +- Closes the gap named in [combsmith/incubator#3](https://github.com/combsmith/incubator/issues/3): + before it, a human applied `idea:approved` on one enhancement comment and a + triage score. Now a **decision brief** is the artifact they approve on. +- Trigger: an Ideas Discussion gets the `needs-brief` label (bridged to + `workflow_dispatch` by the `redispatch` job — claude-code-action aborts on + `discussion` events), or a `from_queue=true` dispatch that briefs every idea + idea-triage ranked **Ripe** in the target's Idea Promotion Queue and that has + no brief yet (capped per sweep). Manual dispatch defaults `dry_run: true`. +- Mary fills the **target repo's own** `ideas/_TEMPLATE/{brief,market-research,ost}.md` + (`prompts/incubation/decision-brief-stage.md` + the per-artifact producers, + wrapping the vendored BMAD analysis skills by path): problem + JTBD with the + evidence, cited market context, OST, **ICE by default** (RICE only when a Reach + figure is cited; Confidence tied to the evidence rung), a **riskiest-assumption + and cheapest-test** table, and a **go / no-go / park** recommendation that says + what a POC must prove and what would make the decider say no. +- **Provenance guard (#1160).** `scripts/decision-brief/validate-package.sh` + runs as its own step after the model: any quantified market claim without a + URL / `[^n]` footnote / `[ASSUMPTION]` tag / "unknown — needs discovery", a + missing score or Confidence, an empty assumptions table, a recommendation that + takes no position, or a leftover template placeholder fails the run — the + package stays an artifact and never becomes a PR. +- `open-pr.sh` lands the package on `incubate/` in the target (one commit + via the Git Data API, author = the business-analyst persona's runtime account), + opens or refreshes the PR, and links it from the Discussion. **It never applies + `idea:approved`.** +- Tooling: `scripts/decision-brief/` (tests: `tests/test_decision_brief.bats`, + `tests/test_decision_brief_workflow.bats`). + ### `initiative-planner.yml` — BMAD Scrum Master "Bob" - Trigger: a Discussion gets labelled `idea:approved`, or manual `workflow_dispatch` with a discussion number (`dry_run` defaults to **true**). @@ -193,6 +229,8 @@ inputs. |-------|----|---------| | `idea:approved` | Discussion | Human blessed this idea for planning → fires the planner | | `idea-triage` | Issue | Marks the Idea Promotion Queue tracking issue | +| `needs-brief` | Discussion | Human asks Mary for a decision brief → fires decision-brief | +| `decision-brief` | PR | The brief package PR Mary opened in the target repo | | `initiative` | Epic + stories | Part of a tracked initiative | | `initiative:auto` | Epic | Human activated auto-implementation (driver may release) | | `initiative:hold` / `dev-lead:hands-off` | Story | Never auto-released; needs a human | @@ -250,6 +288,14 @@ inert epic + DAG in the fleet repo; the **driver** leg releases that epic's ready stories to `dev-lead` cross-repo once a human arms it. Both ship only a thin per-repo stub and dispatch the central workflow with `target_repo`. +- **decision-brief leg (hosted here).** `standards/workflows/decision-brief.yml` + (staging copy; to be promoted to `combsmith/.github/standards/workflows/`) is the + thin caller a fleet repo adds. It pins + `combsmith/.github-private/.github/workflows/decision-brief-reusable.yml@decision-brief/v1-stable`, + which forwards `needs-brief` label events and the weekly post-triage sweep to the + central `decision-brief.yml` with `target_repo=`; the brief PR lands + THERE. The fleet repo needs `ideas/_TEMPLATE/`, the `needs-brief` and + `idea:approved` labels, and the org PAT. - **`target_repo`** parameterizes the central planner/triage/enhancer **and the driver**; empty ⇒ the dogfood/self path (`.github-private` plans + drives its own issues), non-empty ⇒ the named fleet repo. diff --git a/docs/release/versioning.md b/docs/release/versioning.md index 5446913e7..33b43800a 100644 --- a/docs/release/versioning.md +++ b/docs/release/versioning.md @@ -20,10 +20,11 @@ together, so a version is a single repo commit that contains a known-good combin | `pr-review` | `.github/workflows/pr-review.yml` | `scripts/review-one-pr.sh`, `scripts/review-batch.sh`, `scripts/post-pr-review.sh`, `scripts/engine.sh`, `scripts/lib/*` | | `dev-lead` | `.github/workflows/dev-lead-reusable.yml` | `scripts/dev-lead-*.sh`, `scripts/engine.sh`, `scripts/lib/*` | | `ci-failure-analyst` | `.github/workflows/ci-failure-analyst-reusable.yml` | reusable-owned (inline `gh api` orchestration + the Claude Code action; no separate `scripts/`) | +| `decision-brief` | `.github/workflows/decision-brief-reusable.yml` (the fleet dispatch bridge callers pin) + `.github/workflows/decision-brief.yml` (the central Mary run it dispatches) | `scripts/decision-brief/*`, `prompts/incubation/*` | | `feature-ideation` | `combsmith/.github` → `.github/workflows/feature-ideation-reusable.yml` (**cross-repo**) | reusable-owned (lives in the public repo; this repo holds only the thin caller `.github/workflows/feature-ideation.yml`) | | `agent-shield`, `auto-rebase`, `dependency-audit`, `dependabot-automerge`, `dependabot-rebase`, `pr-review-mention` (the six #482 reusables) | `combsmith/.github` → `.github/workflows/-reusable.yml` (**cross-repo**) | reusable-owned (live in the public repo; this repo holds only the thin caller stubs) | -`pr-review`, `dev-lead`, and `ci-failure-analyst` all live in this repo, so a release tag points at a +`pr-review`, `dev-lead`, `ci-failure-analyst` and `decision-brief` all live in this repo, so a release tag points at a whole-repo commit; the tag *name* scopes it to one reusable so each can be released and promoted independently. diff --git a/profile/README.md b/profile/README.md index b50a7c090..7adabd14b 100644 --- a/profile/README.md +++ b/profile/README.md @@ -68,6 +68,7 @@ These are distinct from the `@mention` Copilot agents above. | [`ci-failure-analyst-reusable.yml`](https://github.com/combsmith/.github-private/blob/main/.github/workflows/ci-failure-analyst-reusable.yml) | CI failure analyst — diagnoses failed CI/Lint/Test runs and comments root-cause analysis | | [`feature-ideation.yml`](https://github.com/combsmith/.github-private/blob/main/.github/workflows/feature-ideation.yml) | Feature research & ideation agent (BMAD Analyst) — turns sources into scoped feature ideas | | [`idea-triage.yml`](https://github.com/combsmith/.github-private/blob/main/.github/workflows/idea-triage.yml) | Idea triage agent — scores queued ideas and promotes them into the backlog | +| [`decision-brief.yml`](https://github.com/combsmith/.github-private/blob/main/.github/workflows/decision-brief.yml) | Decision-brief agent (BMAD Analyst) — drafts the go/no-go brief for a ripe idea as a PR in the target repo | | [`initiative-driver.yml`](https://github.com/combsmith/.github-private/blob/main/.github/workflows/initiative-driver.yml) | Initiative driver — auto-releases an epic's ready sub-issues to Dev-Lead | | [`initiative-planner.yml`](https://github.com/combsmith/.github-private/blob/main/.github/workflows/initiative-planner.yml) | Initiative planner (BMAD Scrum Master) — breaks an approved initiative into an epic + sub-issues | | [`issue-triage-runner.yml`](https://github.com/combsmith/.github-private/blob/main/.github/workflows/issue-triage-runner.yml) | Issue triage agent — labels and routes newly opened issues | diff --git a/prompts/incubation/README.md b/prompts/incubation/README.md index dd757eb6d..377839229 100644 --- a/prompts/incubation/README.md +++ b/prompts/incubation/README.md @@ -20,6 +20,7 @@ The **authoritative contract** is `ideas/package-spec.json` in the incubator rep | Brainstorm | `brainstorm.md` | Mary (Analyst) | `producer-brainstorm.md` | `core-skills/bmad-brainstorming` | | Market research | `market-research.md` | Mary (Analyst) | `producer-market-research.md` | `bmm-skills/1-analysis/research/bmad-market-research` | | Decision brief | `brief.md` | Mary (Analyst) | `producer-brief.md` | `bmm-skills/1-analysis/bmad-product-brief` (already headless — preserve) | +| Opportunity–solution tree | `ost.md` (supporting, not gate-required) | Mary (Analyst) | `producer-ost.md` | — (Teresa Torres OST; grounded in the brief) | | PRD | `prd.md` | John (PM) | `producer-prd.md` | `bmm-skills/2-plan-workflows/bmad-prd` (already headless — preserve) | Each artifact also has a `validate-.md` overlay (Ask 2) that emits a @@ -40,10 +41,16 @@ brief; the PRD is John's. Both are governed by [`shared.md`](shared.md) — the headless contract (inputs, output frontmatter, no-fabrication rule, JSON schemas). Read it first. -## How these are invoked (Ask 3 — ships with the incubator wiring) - -A Discussion-mention/label-triggered workflow (redispatch bridge, since -`claude-code-action` aborts on `discussion` contexts) runs the relevant overlay -via `claude-code-action` and commits the artifact to the idea's incubation PR. -That workflow's caller stub lives in `incubator`; it is added once incubator -PR #8 (the gate) merges. These overlays are the reusable content it drives. +## How these are invoked (Ask 3 — the decision-brief stage) + +`.github/workflows/decision-brief.yml` (the central run; Mary) and +`decision-brief-reusable.yml` (the fleet dispatch bridge a thin caller stub +pins) wire the brief-stage producers end to end: a `needs-brief` label on an +Ideas Discussion — or an idea ranked **Ripe** by idea-triage — bridges through +`workflow_dispatch` (claude-code-action aborts on `discussion` contexts), runs +[`decision-brief-stage.md`](decision-brief-stage.md) → `producer-market-research.md` +→ `producer-ost.md` → `producer-brief.md`, enforces the provenance guard with +`scripts/decision-brief/validate-package.sh`, and opens the package as a PR on +`incubate/` in the target repo (combsmith/incubator#3). The brainstorm and +PRD producers are not wired by that stage: brainstorm stays human-facilitated, +and the PRD is John's, after `idea:approved`. diff --git a/prompts/incubation/decision-brief-stage.md b/prompts/incubation/decision-brief-stage.md new file mode 100644 index 000000000..8829e777a --- /dev/null +++ b/prompts/incubation/decision-brief-stage.md @@ -0,0 +1,160 @@ +# Decision-brief stage — Mary, headless (orchestration overlay) + +This is the orchestration wrapper `.github/workflows/decision-brief.yml` hands +to **Mary** 📊 (the BMAD Analyst) for one ripe idea. It binds the per-artifact +producers in this directory — [`producer-market-research.md`](producer-market-research.md), +[`producer-brief.md`](producer-brief.md), [`producer-ost.md`](producer-ost.md) — +to the stage's inputs and to the decision-grade requirements of +[combsmith/incubator#3](https://github.com/combsmith/incubator/issues/3). Read +[`shared.md`](shared.md) first: headless, no fabrication, the JSON status. + +Same shape as [`../bmad/scrum-master.md`](../bmad/README.md) for the planner: +run the vendored BMAD skills **by path**, emit *our* artifacts instead of the +skills' interactive output. + +## 1. What you are producing, and for whom + +A human is about to decide `idea:approved` (go) or not on this idea. Today they +decide on one enhancement comment and a triage score. You give them the four +things the decision needs (the business-analyst advisory on incubator#3): + +| # | The decider needs | Comes from | Your job | +|---|---|---|---| +| 1 | The problem and who has it, **with evidence** | Discussion + enhancement comment (`$CONTEXT_PATH`) | Sharpen; mark what is asserted vs evidenced | +| 2 | Alternatives and the gap they leave, **with cited sources** | Your research (WebSearch/WebFetch) | `market-research.md` — cite or say unknown | +| 3 | The **riskiest assumption** and the **cheapest test** for it | Nothing supplies this today | The brief's main new contribution (§6) | +| 4 | Fit and cost | idea-triage verdict (`queue_excerpt`), enhancement impact/effort | §5 score with the arithmetic shown | + +The recommendation (§7) is built from these four. It is a position, not a +menu — and it is **not** the decision. State up front what would make the +decider say **no**. + +## 2. Inputs — what counts as grounded + +`$CONTEXT_PATH` (JSON) is the whole set of facts you may state without a +citation: + +- `discussion` — title, body, comments (the human⇄agent workspace; primary truth) +- `enhancement` — the feature-ideation enhancement comment (sharpened problem, + repo + market context, impact/effort, suggested ACs) or `null` +- `queue_excerpt` — the idea-triage lines about this idea (Ripe/Soon verdict and + its promotion note) or `null` +- `template` — the TARGET repo's `ideas/_TEMPLATE/{brief,market-research,ost}.md`. + **Fill these, not a shape of your own**: the headers are gate-enforced. +- `package_spec` — `ideas/package-spec.json` (required sections) or `null` +- `existing_package` — files already under `ideas//`, with `brief.md`'s + content when it exists → this is an **update**: keep what is grounded, revise + what the thread has moved on from, never silently contradict it +- `referenced_issues` — issues/PRs the thread links + +Anything else is either **researched and cited** (a URL you actually fetched), +**tagged** `[ASSUMPTION]`, or written as **"unknown — needs discovery"**. +A `brainstorm.md` is human-facilitated and may not exist: proceed without it and +say so in §8 Open questions — a missing brainstorm never blocks a brief. + +## 3. Method — the vendored skills, by path + +```bash +cat frameworks/bmad-method/src/bmm-skills/1-analysis/bmad-agent-analyst/SKILL.md +cat frameworks/bmad-method/src/bmm-skills/1-analysis/bmad-product-brief/SKILL.md +cat frameworks/bmad-method/src/bmm-skills/1-analysis/bmad-product-brief/assets/brief-template.md +cat frameworks/bmad-method/src/bmm-skills/1-analysis/research/bmad-market-research/SKILL.md +``` + +Use their **method** (discovery posture; how-it's-solved-today → gap → signal → +competition; honest briefs that "surface what is unknown alongside what is +known"). Adaptations, in order of precedence: + +1. The **template in `$CONTEXT_PATH`** is the output shape, not the skills' + templates — the incubation gate matches its headers. +2. **Headless**: no greeting, no questions, no `bmad-help`, no `.decision-log.md` + workspace. Where the skill would elicit, infer from the inputs and tag. +3. The skills' **Fast path** is the one you are on: `[ASSUMPTION]` tags where you + inferred, for the decider to correct. + +## 4. Produce, in this order + +1. **`market-research.md`** — follow [`producer-market-research.md`](producer-market-research.md). + Research first (WebSearch/WebFetch), then write. Every figure, price, named + competitor claim and trend carries its URL inline or as a `[^n]` footnote. + End the file with `## Sources` listing every URL cited, one per line. If you + could not research at all, that section holds exactly: *No external sources + were consulted — every market claim above is marked unknown — needs discovery.* +2. **`ost.md`** — follow [`producer-ost.md`](producer-ost.md). One outcome + (a metric, not a feature) → opportunities from the problem/JTBD → the + solution(s) under consideration → the cheapest assumption test under each. +3. **`brief.md`** — follow [`producer-brief.md`](producer-brief.md), then the + stage rules below. It summarises the research takeaway in §3 and links + `market-research.md` and `ost.md` for the detail. +4. **`$STATUS_PATH`** — the `create` JSON from `shared.md` with three extra keys: + `"recommendation": "go" | "no-go" | "park"`, `"score": " = "`, + `"sources": ["", …]`. + +## 5. Stage rules for `brief.md` (enforced by `validate-package.sh`) + +- **Frontmatter** `artifact: brief`, `status: final`. The brief is final even + when the evidence is thin — thinness is reported in §5 Confidence and §7, not + by leaving it a draft. +- **Identity lines** — `**Slug:** \`\``, the Source Discussion URL, + `**Last updated:**` today's date, `**Status:** \`ready-for-decision\``. +- **§5 Prioritization score** — pick **ICE** by default: Reach and Confidence + numbers are not measured anywhere in the org, so RICE's Reach would be + invented. Use **RICE only** when `market-research.md` cites a Reach figure. + Show the arithmetic on one line, e.g. `ICE = 3 × 1 × 2 = 6 / 125`, and define + each factor in a bullet. **Confidence is tied to the evidence**: 1 = one + comment / one-line description, no citations; 2 = thread + cited market + context but no user signal; 3 = cited demand signal (search, community, + adjacent traction); 4 = direct user evidence (interviews, waitlist, usage); + 5 = measured. Say which rung and why. +- **§6 Riskiest assumptions & cheapest test** — most dangerous first; ≥ 1 full + row (`#`, Assumption, If wrong…, Cheapest test, Result = `—`). The cheapest + test is a concrete artifact (five problem interviews, a fake door, a 1-day + spike), not "more research". +- **§7 Recommendation (go / no-go)** — one bold position: **go**, **no-go** or + **park** (buy evidence first), the reasoning, *what a POC would need to + prove* to graduate, and *what would make the decider say no*. State that the + decision is the human's, made by applying `idea:approved`. +- **§8 Open questions** — everything unresolved that the decider should know is + unresolved, including a missing brainstorm or a template section you could + only fill with "unknown — needs discovery". +- **No placeholders** — every `` hint from the template is + replaced or removed; no empty table rows. + +## 6. Provenance guard (hard rule — .github-private#1160) + +`scripts/decision-brief/validate-package.sh` fails the package, and the +workflow refuses to open the PR, when a line in brief §1–§3 or anywhere in +market-research.md (outside Sources) states a quantified market claim — a +number with `%`, `$`, million/billion, users, customers, downloads, stars, +companies… — without one of: + +- an `http(s)://` URL or a `[^n]` footnote on the same line, +- the literal phrase **unknown — needs discovery**, or +- an explicit **[ASSUMPTION]** tag. + +Run it yourself before you finish and fix every `FAIL|` line: + +```bash +bash scripts/decision-brief/validate-package.sh "$BRIEF_ROOT/ideas/$SLUG" \ + --slug "$SLUG" --discussion "$DISCUSSION_NUMBER" +``` + +A brief that honestly says *"market size: unknown — needs discovery; Confidence +1/5; recommendation: park, run five problem interviews first"* is a **good** +output of this stage. A brief with a confident `$2.4B TAM` and no URL is a +failure, however plausible the number. + +## 7. Scoring rubric self-check (think, do not print) + +Before writing `$STATUS_PATH`, answer each with "yes" or route the gap to +`open_questions[]`: + +1. Could the decider approve or reject from `brief.md` alone? +2. Is the problem specific to a named user, not a category? +3. Does every market figure trace to a URL in Sources, an assumption tag, or + "unknown — needs discovery"? +4. Is the riskiest assumption the one that, if wrong, kills the idea — and is + its test the cheapest one that could invalidate it? +5. Does §7 say what would make the decider say no? +6. Does the brief agree with `existing_package` / the thread, or say where and + why it departs? diff --git a/prompts/incubation/producer-ost.md b/prompts/incubation/producer-ost.md new file mode 100644 index 000000000..af71af0ee --- /dev/null +++ b/prompts/incubation/producer-ost.md @@ -0,0 +1,40 @@ +# Producer — Opportunity–solution tree (Mary, headless) + +Headless producer for `ideas//ost.md`, the **supporting** artifact next to +the decision brief (not gate-required; `ideas/package-spec.json` lists it +nowhere, the `_TEMPLATE/` ships it). Read [`shared.md`](shared.md) first. + +## Step 1 — Become Mary (Analyst) + +You are **Mary** 📊. The tree is Teresa Torres's opportunity-solution tree: one +desired **outcome** (a metric that moves if the idea works, never a feature) → +the **opportunity** space (customer needs / pains, from the problem and JTBD) → +the **solution** space (the concept(s) under consideration) → an **assumption +test** under each solution (the cheapest experiment that could invalidate it). +Breadth in the opportunity space before committing to a solution. + +## Step 2 — Ground it + +The outcome, opportunities and solutions come from `idea_context`, +`discussion_thread`, `prior_artifacts` (the brief's §4 and §6 when it exists) +— not from invention. If the thread names no outcome metric, pick the most +defensible one and tag it `[ASSUMPTION]` in the Notes. + +## Step 3 — Emit `ideas//ost.md` + +Fill the TARGET repo's `ideas/_TEMPLATE/ost.md`: frontmatter +`{ artifact: ost, status: final }`, the `**Slug:**` and `**Last updated:**` +lines, a ```` ```mermaid ```` `flowchart TD` block (GitHub renders it natively), +and the Notes. Node labels are short; quote them (`O["🎯 Outcome: …"]`) so +punctuation does not break Mermaid. Every `` in the template is +replaced; keep the three-layer shape even when a layer has one node. + +The tree must agree with the brief: its outcome is brief §4's desired outcome, +its assumption tests are brief §6's cheapest tests. + +## End with the JSON status + +```json +{ "status": "complete", "intent": "create", "artifact": "ost", + "path": "ideas//ost.md", "assumptions": [], "open_questions": [] } +``` diff --git a/scripts/cut-release.sh b/scripts/cut-release.sh index 77bdc285d..01a2549a5 100755 --- a/scripts/cut-release.sh +++ b/scripts/cut-release.sh @@ -69,7 +69,7 @@ CROSS_REPO_TARGET="combsmith/.github" # six #482 reusables brought under the ring model in #870). valid_agent() { case "$1" in - pr-review | dev-lead | ci-failure-analyst) return 0 ;; + pr-review | dev-lead | ci-failure-analyst | decision-brief) return 0 ;; *) cross_repo_agent "$1" ;; esac } diff --git a/scripts/decision-brief/README.md b/scripts/decision-brief/README.md new file mode 100644 index 000000000..0f996d7c3 --- /dev/null +++ b/scripts/decision-brief/README.md @@ -0,0 +1,46 @@ +# decision-brief tooling + +Backs `.github/workflows/decision-brief.yml` — the BMAD Analyst ("Mary") that +turns one ripe idea (an Ideas Discussion) into the decision brief a human +approves `idea:approved` on: `ideas//{brief,market-research,ost}.md`, +opened as a PR in the target repo. Closes the gap named in +[combsmith/incubator#3](https://github.com/combsmith/incubator/issues/3); the +full pipeline is in +[`docs/initiatives/idea-to-initiative-pipeline.md`](../../docs/initiatives/idea-to-initiative-pipeline.md). + +Mary follows the **vendored BMAD analysis skills by path** (`frameworks/bmad-method/…/1-analysis/`) +through the headless overlays in [`prompts/incubation/`](../../prompts/incubation/README.md) +(`decision-brief-stage.md` orchestrates `producer-market-research.md`, +`producer-ost.md`, `producer-brief.md` under the `shared.md` contract). + +| File | Role | +|------|------| +| `redispatch.sh` | Bridge the `discussion [labeled needs-brief]` trigger to `workflow_dispatch` (claude-code-action rejects `discussion` event contexts). Fired with a PAT so the dispatch actually starts a run. | +| `gather-context.sh` | Fetch the Discussion + comments, the feature-ideation enhancement comment, the idea-triage queue verdict, the TARGET repo's `ideas/_TEMPLATE/*` and `package-spec.json`, anything already under `ideas//`, and referenced issues → `$CONTEXT_PATH`; export `DISCUSSION_NODE_ID`, `SLUG`, `IDEA_TITLE`. | +| `lib/slug.sh` | Pure, deterministic slug from the Discussion title (`💡 DemandRadar — …` → `demand-radar`), so a re-brief lands in the same folder and branch. | +| `validate-package.sh` | The **provenance + structure gate** (#1160): frontmatter, required sections (gate-compatible prefix match, plus `package-spec.json`), no placeholders, a RICE/ICE line with Confidence, a filled riskiest-assumption + cheapest-test row, a go/no-go that names `idea:approved`, and — the hard rule — every quantified market claim cited (URL / `[^n]`), tagged `[ASSUMPTION]`, or "unknown — needs discovery"; `market-research.md` ends with `## Sources`. Mary runs it; the workflow runs it again as its own step. | +| `open-pr.sh` | Land the package on `incubate/` in the target via the Git Data API (one commit, author = the business-analyst persona's runtime account), open or refresh the PR (label `decision-brief`), link it from the Discussion. Idempotent; `DRY_RUN=1` logs instead. **Never applies `idea:approved`.** | +| `ripe-from-queue.sh` | The sweep: read the target's "🧭 Idea Promotion Queue" Ripe table and dispatch one central brief run per idea that has no brief / open brief PR yet (skips `idea:approved`; capped by `MAX_BRIEFS`). | + +Tests: `tests/test_decision_brief.bats` (tooling, gh mocked) and +`tests/test_decision_brief_workflow.bats` (workflow, reusable and stub +invariants), both run via `lint.yml`. Fixtures: `tests/fixtures/decision-brief/` +(a passing package, the untouched incubator template, a queue body). + +## Fleet wiring + +``` +fleet repo: ★ needs-brief on an Ideas Discussion · weekly post-triage schedule + │ + ▼ +decision-brief.yml (stub, standards/workflows/) ──> decision-brief-reusable.yml (@decision-brief/v1-stable, hosted here) + │ trusted-actor guard; re-DISPATCHES the central workflow (PAT) with target_repo= + ▼ +combsmith/.github-private decision-brief.yml -f discussion=N -f target_repo= [-f from_queue=true] + │ gather → Mary (claude-code-action) → validate-package.sh → open-pr.sh + ▼ +PR in the fleet repo: incubate/ with ideas//{brief,market-research,ost}.md + │ + ▼ +fleet repo: ★ human reads the brief, adds idea:approved to the Discussion → initiative-planner +``` diff --git a/scripts/decision-brief/gather-context.sh b/scripts/decision-brief/gather-context.sh new file mode 100644 index 000000000..8143b1d69 --- /dev/null +++ b/scripts/decision-brief/gather-context.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# gather-context.sh — assemble everything Mary may ground a decision brief in. +# +# Discussions and the target repo's incubation template are not part of this +# repo's checkout, so we fetch them here and hand the agent ONE JSON file. The +# provenance guard (shared.md, .github-private#1160) only works if the agent's +# grounded inputs are explicit: everything in $CONTEXT_PATH is "real input"; +# anything else Mary states must be cited or marked "unknown — needs discovery". +# +# What goes in: +# discussion title / body / url / labels / first 50 comments +# enhancement the feature-ideation enhancement comment, if one exists +# (marker or the legacy +# ) — the sharpened problem, +# impact/effort and suggested ACs it carries are the brief's +# first-class input (see the business-analyst advisory on +# combsmith/incubator#3) +# queue_excerpt the lines of the target's "Idea Promotion Queue" tracking +# issue (label idea-triage) that mention this discussion — +# the idea-triage ripeness verdict and its promotion note +# template ideas/_TEMPLATE/{brief,market-research,ost}.md from the +# TARGET repo's default branch — Mary fills THESE, so each +# repo controls its own brief shape +# package_spec ideas/package-spec.json (the gate's required sections), or +# null when the target has none +# existing_package files already under ideas// on the default branch +# (with brief.md's content, so a re-brief updates rather +# than overwrites blind) +# referenced_issues issues / PRs the Discussion thread links by #N +# +# Emits: +# - $CONTEXT_PATH (JSON) +# - DISCUSSION_NODE_ID, SLUG, IDEA_TITLE -> $GITHUB_ENV +# +# Env: REPO, DISCUSSION_NUMBER, CONTEXT_PATH (required); GH_TOKEN; +# SLUG (optional override — otherwise derived from the title); +# TEMPLATE_DIR (default ideas/_TEMPLATE); IDEAS_DIR (default ideas). +set -euo pipefail + +REPO="${REPO:?REPO required}" +DISCUSSION_NUMBER="${DISCUSSION_NUMBER:?DISCUSSION_NUMBER required}" +CONTEXT_PATH="${CONTEXT_PATH:?CONTEXT_PATH required}" +TEMPLATE_DIR="${TEMPLATE_DIR:-ideas/_TEMPLATE}" +IDEAS_DIR="${IDEAS_DIR:-ideas}" + +[[ "$DISCUSSION_NUMBER" =~ ^[1-9][0-9]*$ ]] || { + echo "::error::DISCUSSION_NUMBER must be a positive integer, got: '$DISCUSSION_NUMBER'" >&2 + exit 1 +} + +# shellcheck source=scripts/decision-brief/lib/slug.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib/slug.sh" + +owner="${REPO%/*}" +name="${REPO#*/}" + +# ── the idea (GraphQL: discussions are not in the REST issues space) ────────── +disc="$(gh api graphql -f query=' + query($owner:String!,$name:String!,$num:Int!){ + repository(owner:$owner,name:$name){ + discussion(number:$num){ + id title body url createdAt updatedAt + author{ login } + category{ name slug } + labels(first:20){ nodes{ name } } + comments(first:50){ nodes{ author{login} body createdAt url } } + } + } + }' -F owner="$owner" -F name="$name" -F num="$DISCUSSION_NUMBER")" + +node_id="$(printf '%s' "$disc" | jq -r '.data.repository.discussion.id // empty')" +if [[ -z $node_id ]]; then + echo "::error::discussion #${DISCUSSION_NUMBER} not found in ${REPO}" >&2 + exit 1 +fi +category="$(printf '%s' "$disc" | jq -r '.data.repository.discussion.category.name // ""')" +if [[ $category != "Ideas" ]]; then + echo "::warning::discussion #${DISCUSSION_NUMBER} is in category '${category}', not 'Ideas' — briefing anyway." +fi +title="$(printf '%s' "$disc" | jq -r '.data.repository.discussion.title // ""')" + +# ── slug ────────────────────────────────────────────────────────────────────── +slug="${SLUG:-}" +[[ -n $slug ]] || slug="$(db_slug_from_title "$title")" +if ! db_slug_is_valid "$slug"; then + echo "::error::could not derive a valid slug from discussion #${DISCUSSION_NUMBER} title '${title}' (got '${slug}'); dispatch again with an explicit slug" >&2 + exit 1 +fi + +# ── the enhancement comment (feature-ideation's single enhancer) ───────────── +enhancement="$(printf '%s' "$disc" | jq -c ' + [.data.repository.discussion.comments.nodes[] + | select((.body // "") | test("")) + | {author: (.author.login // null), body, createdAt, url}] + | first // null')" + +# ── the Idea Promotion Queue verdict for this idea (if triage runs here) ────── +queue_excerpt='null' +if queue_body="$(gh issue list --repo "$REPO" --label idea-triage --state open \ + --json number,title,body --jq 'map(select(.title | test("Idea Promotion Queue"))) | .[0] // empty' 2>/dev/null)" \ + && [[ -n $queue_body ]]; then + queue_excerpt="$(printf '%s' "$queue_body" | jq -c --arg n "#${DISCUSSION_NUMBER}" ' + {number, title, + lines: ((.body // "") | split("\n") | map(select(contains($n))))}')" +fi + +# ── the target repo's template + package spec (default branch) ─────────────── +_fetch_file() { # — prints decoded content, or nothing when absent + gh api "repos/${REPO}/contents/${1}" --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true +} +tmpl_brief="$(_fetch_file "${TEMPLATE_DIR}/brief.md")" +tmpl_mr="$(_fetch_file "${TEMPLATE_DIR}/market-research.md")" +tmpl_ost="$(_fetch_file "${TEMPLATE_DIR}/ost.md")" +if [[ -z $tmpl_brief ]]; then + echo "::error::${REPO} has no ${TEMPLATE_DIR}/brief.md on its default branch — the decision-brief stage fills the TARGET repo's own template (copy ideas/_TEMPLATE/ from combsmith/incubator first)" >&2 + exit 1 +fi +spec_raw="$(_fetch_file "${IDEAS_DIR}/package-spec.json")" +package_spec='null' +if [[ -n $spec_raw ]] && printf '%s' "$spec_raw" | jq -e . >/dev/null 2>&1; then + package_spec="$(printf '%s' "$spec_raw" | jq -c .)" +fi + +# ── anything already under ideas// (re-brief / update mode) ──────────── +existing_files="$(gh api "repos/${REPO}/contents/${IDEAS_DIR}/${slug}" --jq '[.[] | select(.type=="file") | .name]' 2>/dev/null || echo '[]')" +printf '%s' "$existing_files" | jq -e 'type=="array"' >/dev/null 2>&1 || existing_files='[]' +existing_brief="" +if printf '%s' "$existing_files" | jq -e 'index("brief.md") != null' >/dev/null; then + existing_brief="$(_fetch_file "${IDEAS_DIR}/${slug}/brief.md")" +fi + +# ── issues / PRs the thread references (#N) ─────────────────────────────────── +refs="$(printf '%s' "$disc" \ + | jq -r '.data.repository.discussion | ((.body // "") + " " + ([.comments.nodes[].body // empty] | join(" ")))' \ + | grep -oE '#[0-9]+' | tr -d '#' | sort -un | head -30 || true)" +ref_json='[]' +if [[ -n $refs ]]; then + ref_json="$(while IFS= read -r n; do + [[ -n $n ]] || continue + [[ "$n" == "$DISCUSSION_NUMBER" ]] && continue + gh api "repos/${REPO}/issues/${n}" --jq '{number, title, state, url: .html_url}' 2>/dev/null \ + || echo "::notice::skipping referenced #${n} (not a fetchable issue/PR — discussion/self-ref)" >&2 + done <<<"$refs" | jq -sc '.')" +fi + +# ── assemble ────────────────────────────────────────────────────────────────── +jq -n \ + --arg repo "$REPO" \ + --arg slug "$slug" \ + --arg ideas_dir "$IDEAS_DIR" \ + --argjson num "$DISCUSSION_NUMBER" \ + --argjson disc "$disc" \ + --argjson enhancement "$enhancement" \ + --argjson queue "$queue_excerpt" \ + --arg tb "$tmpl_brief" --arg tm "$tmpl_mr" --arg to "$tmpl_ost" \ + --argjson spec "$package_spec" \ + --argjson existing "$existing_files" \ + --arg existing_brief "$existing_brief" \ + --argjson refs "$ref_json" \ + '{ + repo: $repo, + slug: $slug, + package_dir: ($ideas_dir + "/" + $slug), + discussion: ($disc.data.repository.discussion + | {number: $num, title, body, url, createdAt, updatedAt, + author: (.author.login // null), + category: .category.name, + labels: [.labels.nodes[].name], + comments: [.comments.nodes[] | {author: (.author.login // null), body, createdAt, url}]}), + enhancement: $enhancement, + queue_excerpt: $queue, + template: {"brief.md": $tb, "market-research.md": $tm, "ost.md": $to}, + package_spec: $spec, + existing_package: {files: $existing, "brief.md": (if $existing_brief == "" then null else $existing_brief end)}, + referenced_issues: $refs + }' >"$CONTEXT_PATH" + +{ + printf 'DISCUSSION_NODE_ID=%s\n' "$node_id" + printf 'SLUG=%s\n' "$slug" + # Multi-line safe: titles are single-line, but guard anyway. + printf 'IDEA_TITLE<<__EOT__\n%s\n__EOT__\n' "$title" +} >>"${GITHUB_ENV:-/dev/null}" + +echo "context written to ${CONTEXT_PATH} (repo=${REPO}, discussion=#${DISCUSSION_NUMBER}, slug=${slug}, enhancement=$(if [[ $enhancement != "null" ]]; then echo yes; else echo no; fi), existing=$(printf '%s' "$existing_files" | jq 'length'), refs=$(printf '%s' "$ref_json" | jq 'length'))" diff --git a/scripts/decision-brief/lib/slug.sh b/scripts/decision-brief/lib/slug.sh new file mode 100644 index 000000000..287480543 --- /dev/null +++ b/scripts/decision-brief/lib/slug.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# slug.sh — derive the stable `ideas//` folder name for an Ideas Discussion. +# +# The incubator convention (ideas/README.md): a short, kebab-case, stable slug +# that matches the source Discussion where practical. Mary never invents one at +# run time — the same title always yields the same slug, so a re-brief of the +# same idea lands in the same folder and on the same `incubate/` branch. +# +# Sourceable (pure functions, no network). Tests: tests/test_decision_brief.bats. + +# db_slug_from_title — print the slug. +# 1. strip a leading emoji / decoration and a trailing " — <subtitle>" (the +# feature-ideation titles read "💡 Name — one-line pitch"; the Name is the +# slug, the pitch is not); +# 2. lowercase; split CamelCase ("DemandRadar" → "demand-radar"); +# 3. non-alphanumerics → "-", squeeze, trim; +# 4. cap at 48 characters on a word boundary. +db_slug_from_title() { + local title="${1:-}" s + s="$title" + # Keep only the part before an em-dash / en-dash / " - " / ":" subtitle separator. + s="${s%%—*}" + s="${s%%–*}" + s="${s%% - *}" + s="${s%%:*}" + # Split CamelCase before lowercasing: a lower→Upper boundary ("DemandRadar"), + # and an Upper-run followed by a capitalised word ("A2AProbe" → "A2A-Probe", + # "MCPServerCard" → "MCP-Server-Card"). A digit→Upper boundary is NOT split, + # so acronyms with digits stay whole. + s="$(printf '%s' "$s" | sed -E 's/([a-z])([A-Z])/\1-\2/g; s/([A-Z0-9])([A-Z][a-z])/\1-\2/g')" + s="$(printf '%s' "$s" | tr '[:upper:]' '[:lower:]')" + # Everything that is not a-z0-9 becomes a hyphen (bytes of emoji/punctuation + # included), then squeeze and trim. + s="$(printf '%s' "$s" | LC_ALL=C sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + if [ "${#s}" -gt 48 ]; then + s="${s:0:48}" + s="${s%-*}" + fi + printf '%s\n' "$s" +} + +# db_slug_is_valid <slug> — 0 iff the slug is non-empty kebab-case +# ([a-z0-9] with single hyphens, no leading/trailing hyphen) and ≤ 48 chars. +db_slug_is_valid() { + local s="${1:-}" + [ -n "$s" ] || return 1 + [ "${#s}" -le 48 ] || return 1 + case "$s" in + _template | _TEMPLATE) return 1 ;; + esac + printf '%s' "$s" | LC_ALL=C grep -Eq '^[a-z0-9]+(-[a-z0-9]+)*$' +} diff --git a/scripts/decision-brief/open-pr.sh b/scripts/decision-brief/open-pr.sh new file mode 100644 index 000000000..2b5dc486a --- /dev/null +++ b/scripts/decision-brief/open-pr.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# open-pr.sh — land a validated brief package in the TARGET repo as a PR. +# +# Writes ideas/<slug>/{brief.md,market-research.md,ost.md} to the branch +# `incubate/<slug>` of the target repo (the incubator's own incubation-PR +# naming, ideas/PACKAGE.md) and opens — or refreshes — the PR for it, then +# links the PR from the source Discussion. Nothing is merged and no label is +# applied to the Discussion: the human still decides by adding `idea:approved`. +# +# Everything goes through the GitHub API (`gh api`, Git Data endpoints), not a +# clone, so there is no checkout of the target repo, no credential on disk, one +# commit per run, and every call is mockable in tests the way the rest of this +# repo's tooling is. +# +# Idempotent: +# - the branch already exists -> the new commit is appended to it +# - an open PR for the branch exists -> no second PR; a refresh comment is posted +# - DRY_RUN=1 -> every intended mutation is logged to +# $DRY_RUN_LOG as JSONL, nothing is written +# +# Env: +# REPO owner/repo of the TARGET (required) +# SLUG idea slug (required) +# PACKAGE_DIR local dir holding the drafted files (required) +# IDEAS_DIR target path prefix (default: ideas) +# DISCUSSION_NUMBER source Discussion number (required) +# DISCUSSION_NODE_ID GraphQL node id of the Discussion (optional; enables the +# back-link comment) +# IDEA_TITLE Discussion title (optional, for the PR title) +# STATUS_PATH Mary's JSON status (optional; summarised in the PR body) +# BRANCH_PREFIX default: incubate +# PR_LABEL label applied to the PR (default: decision-brief; created +# on first use) +# GIT_USER_NAME / GIT_USER_EMAIL +# commit author (default: the business-analyst persona's +# runtime identity, via scripts/lib/resolve-persona-identity.sh) +# RUN_URL link to this Actions run (optional, for provenance) +# DRY_RUN / DRY_RUN_LOG +# GH_TOKEN token with contents+pull-requests(+discussions) write on +# the target (required when not DRY_RUN) +set -euo pipefail + +REPO="${REPO:?REPO required}" +SLUG="${SLUG:?SLUG required}" +PACKAGE_DIR="${PACKAGE_DIR:?PACKAGE_DIR required}" +DISCUSSION_NUMBER="${DISCUSSION_NUMBER:?DISCUSSION_NUMBER required}" +IDEAS_DIR="${IDEAS_DIR:-ideas}" +BRANCH_PREFIX="${BRANCH_PREFIX:-incubate}" +PR_LABEL="${PR_LABEL:-decision-brief}" +IDEA_TITLE="${IDEA_TITLE:-discussion #${DISCUSSION_NUMBER}}" + +[[ "$DISCUSSION_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "::error::DISCUSSION_NUMBER must be a positive integer" >&2; exit 1; } +[[ "$SLUG" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || { echo "::error::SLUG must be kebab-case, got '$SLUG'" >&2; exit 1; } +[[ -d "$PACKAGE_DIR" ]] || { echo "::error::PACKAGE_DIR '$PACKAGE_DIR' is not a directory" >&2; exit 1; } + +_is_dry_run() { [[ "${DRY_RUN:-0}" == "1" ]]; } +_dry_log() { printf '%s\n' "$1" >>"${DRY_RUN_LOG:-./dry-run.jsonl}"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# ── commit identity: the persona's declared runtime account, never a shared var ─ +if [[ -z "${GIT_USER_NAME:-}" ]]; then + GIT_USER_NAME="$(bash "$ROOT/scripts/lib/resolve-persona-identity.sh" business-analyst "$ROOT/personas" account 2>/dev/null || true)" +fi +GIT_USER_NAME="${GIT_USER_NAME:-decision-brief}" +GIT_USER_EMAIL="${GIT_USER_EMAIL:-${GIT_USER_NAME}@users.noreply.github.com}" + +# ── the files ───────────────────────────────────────────────────────────────── +files=() +while IFS= read -r f; do files+=("$f"); done < <(cd "$PACKAGE_DIR" && find . -type f ! -name '.*' | sed 's#^\./##' | sort) +[[ ${#files[@]} -gt 0 ]] || { echo "::error::PACKAGE_DIR '$PACKAGE_DIR' holds no files" >&2; exit 1; } +[[ -f "$PACKAGE_DIR/brief.md" ]] || { echo "::error::PACKAGE_DIR has no brief.md" >&2; exit 1; } + +branch="${BRANCH_PREFIX}/${SLUG}" +dest_prefix="${IDEAS_DIR}/${SLUG}" +disc_url="https://github.com/${REPO}/discussions/${DISCUSSION_NUMBER}" +title="brief(${SLUG}): decision brief for \"${IDEA_TITLE}\" (discussion #${DISCUSSION_NUMBER})" + +# ── PR body ─────────────────────────────────────────────────────────────────── +status_summary="" +if [[ -n "${STATUS_PATH:-}" && -s "${STATUS_PATH}" ]] && jq -e . "$STATUS_PATH" >/dev/null 2>&1; then + status_summary="$(jq -r ' + def list(x): if (x|length)==0 then "_none_" else (x|map("- " + (if type=="string" then . else tojson end))|join("\n")) end; + "**Recommendation:** " + ((.recommendation // "see brief") | tostring) + "\n" + + "**Score:** " + ((.score // "see brief") | tostring) + "\n" + + "**Status:** `" + ((.status // "unknown") | tostring) + "`\n\n" + + "**Assumptions tagged in the brief:**\n" + list(.assumptions // []) + "\n\n" + + "**Open questions for the decider:**\n" + list(.open_questions // []) + "\n\n" + + "**Sources cited:** " + ((.sources // []) | length | tostring) + ' "$STATUS_PATH")" +fi +file_list="$(printf -- '- `%s/%s`\n' "$dest_prefix" "${files[@]}")" +body="$(cat <<EOF +<!-- decision-brief:slug=${SLUG} discussion=${DISCUSSION_NUMBER} --> +## Decision brief — ${IDEA_TITLE} + +Drafted by **Mary (Analyst)** via the org decision-brief stage for ${disc_url}. +This brief **informs** the go/no-go; it does not make it. Approve the idea by adding +\`idea:approved\` to the source Discussion, or decline it there — not by merging this PR +(merging records the package; the incubation gate may stay red until the rest of the +package — brainstorm, PRD — exists). + +### Files +${file_list} + +### Mary's status +${status_summary:-_no status file attached_} + +### Provenance +Every market claim in the package either cites a source, is tagged \`[ASSUMPTION]\`, or says +"unknown — needs discovery" (enforced by \`scripts/decision-brief/validate-package.sh\` in +combsmith/.github-private before this PR was opened). Nothing here is fabricated to fill a section. +${RUN_URL:+ +Run: ${RUN_URL}} +EOF +)" + +if _is_dry_run; then + _dry_log "$(jq -nc --arg op open_pr --arg repo "$REPO" --arg branch "$branch" --arg title "$title" \ + --arg body "$body" --arg author "$GIT_USER_NAME" \ + --argjson files "$(printf '%s\n' "${files[@]}" | jq -R . | jq -sc --arg p "$dest_prefix" 'map($p + "/" + .)')" \ + '{op:$op, repo:$repo, branch:$branch, title:$title, files:$files, author:$author, body_len:($body|length)}')" + if [[ -n "${DISCUSSION_NODE_ID:-}" ]]; then + _dry_log "$(jq -nc --arg op discussion_comment --arg repo "$REPO" --argjson n "$DISCUSSION_NUMBER" \ + '{op:$op, repo:$repo, discussion:$n}')" + fi + echo "[dry-run] would push ${#files[@]} file(s) to ${REPO}@${branch} under ${dest_prefix}/ and open PR '${title}'" + exit 0 +fi + +# ── base + branch state ─────────────────────────────────────────────────────── +default_branch="$(gh api "repos/${REPO}" --jq '.default_branch')" +base_sha="$(gh api "repos/${REPO}/git/ref/heads/${default_branch}" --jq '.object.sha')" +parent_sha="$(gh api "repos/${REPO}/git/ref/heads/${branch}" --jq '.object.sha' 2>/dev/null || true)" +branch_exists=0 +if [[ -n "$parent_sha" ]]; then branch_exists=1; else parent_sha="$base_sha"; fi +base_tree="$(gh api "repos/${REPO}/git/commits/${parent_sha}" --jq '.tree.sha')" + +# ── one commit: blobs → tree → commit → ref ─────────────────────────────────── +tree_entries='[]' +for f in "${files[@]}"; do + blob_sha="$(jq -n --rawfile c "$PACKAGE_DIR/$f" '{content:$c, encoding:"utf-8"}' \ + | gh api "repos/${REPO}/git/blobs" -X POST --input - --jq '.sha')" + tree_entries="$(jq -c --arg p "${dest_prefix}/${f}" --arg s "$blob_sha" \ + '. + [{path:$p, mode:"100644", type:"blob", sha:$s}]' <<<"$tree_entries")" +done +tree_sha="$(jq -nc --arg base "$base_tree" --argjson t "$tree_entries" '{base_tree:$base, tree:$t}' \ + | gh api "repos/${REPO}/git/trees" -X POST --input - --jq '.sha')" +message="$(printf 'brief(%s): decision brief for discussion #%s\n\n%s\n\nDrafted by the decision-brief stage (Mary, Analyst). Source: %s' \ + "$SLUG" "$DISCUSSION_NUMBER" "$IDEA_TITLE" "$disc_url")" +commit_sha="$(jq -nc --arg m "$message" --arg t "$tree_sha" --arg p "$parent_sha" \ + --arg n "$GIT_USER_NAME" --arg e "$GIT_USER_EMAIL" \ + '{message:$m, tree:$t, parents:[$p], author:{name:$n, email:$e}, committer:{name:$n, email:$e}}' \ + | gh api "repos/${REPO}/git/commits" -X POST --input - --jq '.sha')" +if [[ "$branch_exists" == "1" ]]; then + gh api "repos/${REPO}/git/refs/heads/${branch}" -X PATCH -f sha="$commit_sha" >/dev/null + echo "appended ${commit_sha:0:7} to existing ${branch}" +else + gh api "repos/${REPO}/git/refs" -X POST -f ref="refs/heads/${branch}" -f sha="$commit_sha" >/dev/null + echo "created ${branch} at ${commit_sha:0:7}" +fi + +# ── the PR (find-or-create) ─────────────────────────────────────────────────── +existing_pr="$(gh pr list --repo "$REPO" --head "$branch" --state open --json number,url \ + --jq '.[0].url // empty' 2>/dev/null || true)" +if [[ -n "$existing_pr" ]]; then + gh pr comment "$existing_pr" --repo "$REPO" \ + --body "$(printf '<!-- decision-brief:refresh -->\nRefreshed by the decision-brief stage: commit %s updates `%s/`.%s' \ + "${commit_sha:0:7}" "$dest_prefix" "${RUN_URL:+ Run: ${RUN_URL}}")" >/dev/null + pr_url="$existing_pr" + echo "refreshed existing PR ${pr_url}" +else + gh label create "$PR_LABEL" --repo "$REPO" \ + --description "Decision brief drafted by the org decision-brief stage (Mary)" --color 5319e7 2>/dev/null || true + body_file="$(mktemp)" + printf '%s\n' "$body" >"$body_file" + pr_url="$(gh pr create --repo "$REPO" --base "$default_branch" --head "$branch" \ + --title "$title" --body-file "$body_file" --label "$PR_LABEL")" + rm -f "$body_file" + echo "opened PR ${pr_url}" +fi +[[ -n "${GITHUB_OUTPUT:-}" ]] && printf 'pr_url=%s\n' "$pr_url" >>"$GITHUB_OUTPUT" + +# ── back-link from the Discussion (best effort) ─────────────────────────────── +if [[ -n "${DISCUSSION_NODE_ID:-}" ]]; then + comment="$(printf '<!-- decision-brief:pr -->\n## Decision brief drafted\n\nMary drafted `%s/brief.md` (+ market-research.md, ost.md): %s\n\nRead the brief, then decide here: add `idea:approved` to this Discussion to approve, or say why not. The brief informs the call; it does not make it.' \ + "$dest_prefix" "$pr_url")" + if ! gh api graphql -f query='mutation($id:ID!,$body:String!){ addDiscussionComment(input:{discussionId:$id, body:$body}){ comment{ url } } }' \ + -F id="$DISCUSSION_NODE_ID" -F body="$comment" >/dev/null; then + echo "::warning::could not post the PR link back to discussion #${DISCUSSION_NUMBER} (needs discussions:write on ${REPO}); the PR is open: ${pr_url}" + else + echo "linked PR from discussion #${DISCUSSION_NUMBER}" + fi +fi diff --git a/scripts/decision-brief/redispatch.sh b/scripts/decision-brief/redispatch.sh new file mode 100644 index 000000000..03acf5e83 --- /dev/null +++ b/scripts/decision-brief/redispatch.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# redispatch.sh — bridge a `discussion [labeled]` event to a supported event. +# +# The decision-brief stage's documented trigger is a maintainer adding +# `needs-brief` to an Ideas Discussion, which fires `on: discussion [labeled]`. +# But the "Draft the decision brief" step uses claude-code-action, which only +# recognizes issues / pull_request / issue_comment / workflow_dispatch event +# contexts and aborts on a `discussion` event: +# +# Action failed with error: Unsupported event type: discussion +# +# So on the discussion event we DON'T brief inline; we resolve the discussion +# number and re-invoke the SAME workflow via workflow_dispatch, under which the +# action runs cleanly. This is the same bridge initiative-planner.yml uses +# (scripts/initiative-planner/redispatch.sh); it is a separate script only +# because the two workflows declare different dispatch inputs, and `gh workflow +# run` rejects an input the target workflow does not declare. +# +# IMPORTANT — the re-dispatch requires a PAT, not GITHUB_TOKEN: +# GitHub does not start new workflow runs from events created with the default +# GITHUB_TOKEN (loop prevention). A workflow_dispatch fired with GITHUB_TOKEN +# would be accepted but would NEVER start a run, so the caller MUST provide a +# PAT (GH_PAT_DON_PETRY or GH_PAT_WORKFLOWS) as GH_TOKEN. +# +# Env: +# REPO owner/repo to DISPATCH the brief workflow in (required). +# For the dogfood path this is .github-private itself; for +# fleet callers it is the central repo. +# TARGET_REPO owner/repo the brief is FOR (default: REPO) — where the +# Discussion lives and where the PR is opened. +# DISCUSSION_NUMBER Ideas Discussion number to brief (required) +# WORKFLOW_FILE workflow to dispatch (default: decision-brief.yml) +# DRY_RUN "1"/"true" => dispatch dry_run=true (default: false) +# GH_TOKEN a PAT with workflow scope (required at the call site) +set -euo pipefail + +REPO="${REPO:?REPO required}" +TARGET_REPO="${TARGET_REPO:-$REPO}" +DISCUSSION_NUMBER="${DISCUSSION_NUMBER:?DISCUSSION_NUMBER required}" +WORKFLOW_FILE="${WORKFLOW_FILE:-decision-brief.yml}" + +# Validate before the value reaches the gh command line. +[[ "$DISCUSSION_NUMBER" =~ ^[1-9][0-9]*$ ]] || { + echo "::error::DISCUSSION_NUMBER must be a positive integer, got: '$DISCUSSION_NUMBER'" >&2 + exit 1 +} +[[ "$TARGET_REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { + echo "::error::TARGET_REPO must be owner/repo, got: '$TARGET_REPO'" >&2 + exit 1 +} + +_dry_run="${DRY_RUN:-}" +if [[ "$_dry_run" == "1" || "${_dry_run,,}" == "true" ]]; then + DISPATCH_DRY_RUN="true" +else + DISPATCH_DRY_RUN="false" +fi + +# Use GITHUB_REF if it is a branch or tag to support testing on feature branches. +REF_FLAGS=() +if [[ -n "${GITHUB_REF:-}" && ( "$GITHUB_REF" == refs/heads/* || "$GITHUB_REF" == refs/tags/* ) ]]; then + REF_FLAGS=(--ref "$GITHUB_REF") +fi + +echo "Discussion event → re-dispatching ${WORKFLOW_FILE} for discussion #${DISCUSSION_NUMBER} (target ${TARGET_REPO}) via workflow_dispatch." +gh workflow run "$WORKFLOW_FILE" --repo "$REPO" "${REF_FLAGS[@]}" \ + -f discussion="$DISCUSSION_NUMBER" \ + -f target_repo="$TARGET_REPO" \ + -f dry_run="$DISPATCH_DRY_RUN" +echo "Dispatched ${WORKFLOW_FILE} (discussion=${DISCUSSION_NUMBER}, target_repo=${TARGET_REPO}, dry_run=${DISPATCH_DRY_RUN})." diff --git a/scripts/decision-brief/ripe-from-queue.sh b/scripts/decision-brief/ripe-from-queue.sh new file mode 100644 index 000000000..822c5b24f --- /dev/null +++ b/scripts/decision-brief/ripe-from-queue.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# ripe-from-queue.sh — brief the ideas idea-triage just ranked "Ripe". +# +# idea-triage rewrites one "🧭 Idea Promotion Queue" tracking issue per repo +# (label `idea-triage`) with a "🟢 Ripe to promote" table. That table is the +# triage verdict, but nothing acted on it: a human had to hand-copy the +# template. This sweep reads the Ripe section, and for every Discussion it +# names that has no brief yet, dispatches ONE central decision-brief run +# (`gh workflow run decision-brief.yml -f discussion=N`) — so each idea gets its +# own run, its own concurrency lane and its own PR, and the `needs-brief` label +# path and this path converge on the same workflow. +# +# Skips (idempotency, cheapest first): +# - a Discussion already labelled `idea:approved` (past the decision) +# - a Discussion not in the Ideas category +# - ideas/<slug>/brief.md already on the default branch +# - an open PR from incubate/<slug> (a brief is in flight) +# and caps the dispatches per sweep (MAX_BRIEFS, default 3) so one triage pass +# cannot spend an unbounded model budget. +# +# The dispatch needs a PAT (a workflow_dispatch fired with GITHUB_TOKEN never +# starts a run). DRY_RUN=1 logs each intended dispatch to $DRY_RUN_LOG instead. +# +# Env: +# REPO owner/repo whose queue to read and whose ideas to brief (required) +# CENTRAL_REPO owner/repo hosting decision-brief.yml (default: REPO) +# WORKFLOW_FILE default decision-brief.yml +# MAX_BRIEFS default 3 +# IDEAS_DIR default ideas +# BRANCH_PREFIX default incubate +# QUEUE_LABEL default idea-triage +# DRY_RUN / DRY_RUN_LOG +# GH_TOKEN required (reads + the dispatch) +# Helpers are sourceable (tests/test_decision_brief.bats); strict mode is set +# only when executed. + +# dbq_ripe_section <text-on-stdin> — the lines of the first section whose +# header mentions "ripe", up to the next header. The queue body is +# model-written Markdown, so only the header word is relied on. +dbq_ripe_section() { + awk ' + /^#{1,6}[[:space:]]/ { + if (insec) exit + if (tolower($0) ~ /ripe/) { insec=1; next } + } + insec { print } + ' +} + +# dbq_discussion_numbers <text-on-stdin> — every distinct #N (and +# /discussions/N) reference, in first-seen order, one per line. +dbq_discussion_numbers() { + grep -oE '(#|/discussions/)[0-9]+' | grep -oE '[0-9]+' | awk '!seen[$0]++' +} + +dbq_main() { + REPO="${REPO:?REPO required}" + CENTRAL_REPO="${CENTRAL_REPO:-$REPO}" + WORKFLOW_FILE="${WORKFLOW_FILE:-decision-brief.yml}" + MAX_BRIEFS="${MAX_BRIEFS:-3}" + IDEAS_DIR="${IDEAS_DIR:-ideas}" + BRANCH_PREFIX="${BRANCH_PREFIX:-incubate}" + QUEUE_LABEL="${QUEUE_LABEL:-idea-triage}" + [[ "$MAX_BRIEFS" =~ ^[0-9]+$ ]] || { echo "::error::MAX_BRIEFS must be an integer" >&2; return 1; } + + # shellcheck source=scripts/decision-brief/lib/slug.sh + source "$(dirname "${BASH_SOURCE[0]}")/lib/slug.sh" + local is_dry=0; [[ "${DRY_RUN:-0}" == "1" ]] && is_dry=1 + local owner="${REPO%/*}" name="${REPO#*/}" + + local queue_body + queue_body="$(gh issue list --repo "$REPO" --label "$QUEUE_LABEL" --state open \ + --json number,title,body --jq 'map(select(.title | test("Idea Promotion Queue"))) | .[0].body // empty')" + if [[ -z "$queue_body" ]]; then + echo "::notice::${REPO} has no open Idea Promotion Queue issue (label ${QUEUE_LABEL}) — nothing ripe to brief. Adopt idea-triage first, or label a Discussion needs-brief." + return 0 + fi + + local numbers + numbers="$(printf '%s\n' "$queue_body" | dbq_ripe_section | dbq_discussion_numbers)" + if [[ -z "$numbers" ]]; then + echo "queue has no Ripe ideas this week — nothing to brief." + return 0 + fi + + local n dispatched=0 skipped=0 disc title labels category slug + while IFS= read -r n; do + [[ -n "$n" ]] || continue + if [[ "$dispatched" -ge "$MAX_BRIEFS" ]]; then + echo "::notice::cap reached (MAX_BRIEFS=${MAX_BRIEFS}); #${n} and later wait for the next sweep" + break + fi + disc="$(gh api graphql -f query=' + query($owner:String!,$name:String!,$num:Int!){ + repository(owner:$owner,name:$name){ + discussion(number:$num){ title category{ name } labels(first:20){ nodes{ name } } } + } + }' -F owner="$owner" -F name="$name" -F num="$n" 2>/dev/null || true)" + title="$(printf '%s' "$disc" | jq -r '.data.repository.discussion.title // empty')" + if [[ -z "$title" ]]; then echo "skip #${n}: not a readable Discussion"; skipped=$((skipped+1)); continue; fi + category="$(printf '%s' "$disc" | jq -r '.data.repository.discussion.category.name // ""')" + labels="$(printf '%s' "$disc" | jq -r '[.data.repository.discussion.labels.nodes[].name] | join(",")')" + if [[ "$category" != "Ideas" ]]; then echo "skip #${n}: category '${category}'"; skipped=$((skipped+1)); continue; fi + if [[ ",${labels}," == *",idea:approved,"* ]]; then echo "skip #${n}: already idea:approved"; skipped=$((skipped+1)); continue; fi + slug="$(db_slug_from_title "$title")" + if ! db_slug_is_valid "$slug"; then echo "skip #${n}: no valid slug from '${title}'"; skipped=$((skipped+1)); continue; fi + if gh api "repos/${REPO}/contents/${IDEAS_DIR}/${slug}/brief.md" --jq '.sha' >/dev/null 2>&1; then + echo "skip #${n}: ${IDEAS_DIR}/${slug}/brief.md already exists"; skipped=$((skipped+1)); continue + fi + if [[ -n "$(gh pr list --repo "$REPO" --head "${BRANCH_PREFIX}/${slug}" --state open --json number --jq '.[0].number // empty' 2>/dev/null || true)" ]]; then + echo "skip #${n}: a brief PR from ${BRANCH_PREFIX}/${slug} is already open"; skipped=$((skipped+1)); continue + fi + if [[ "$is_dry" == "1" ]]; then + printf '%s\n' "$(jq -nc --arg op dispatch_brief --arg central "$CENTRAL_REPO" --arg target "$REPO" \ + --argjson n "$n" --arg slug "$slug" '{op:$op, central_repo:$central, target_repo:$target, discussion:$n, slug:$slug}')" \ + >>"${DRY_RUN_LOG:-./dry-run.jsonl}" + echo "[dry-run] would dispatch ${WORKFLOW_FILE} in ${CENTRAL_REPO} for #${n} (${slug})" + else + gh workflow run "$WORKFLOW_FILE" --repo "$CENTRAL_REPO" \ + -f discussion="$n" -f target_repo="$REPO" -f dry_run=false + echo "dispatched ${WORKFLOW_FILE} for #${n} (${slug})" + fi + dispatched=$((dispatched+1)) + done <<<"$numbers" + echo "ripe-from-queue: dispatched=${dispatched} skipped=${skipped} (cap ${MAX_BRIEFS})" +} + +if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then + set -euo pipefail + dbq_main "$@" +fi diff --git a/scripts/decision-brief/validate-package.sh b/scripts/decision-brief/validate-package.sh new file mode 100644 index 000000000..20ff22830 --- /dev/null +++ b/scripts/decision-brief/validate-package.sh @@ -0,0 +1,341 @@ +#!/usr/bin/env bash +# validate-package.sh — structural + provenance gate for a drafted brief package. +# +# Runs over the `ideas/<slug>/` directory Mary wrote (brief.md, +# market-research.md, ost.md) BEFORE anything reaches the target repo. It is the +# deterministic half of the provenance guard (.github-private#1160, +# prompts/incubation/shared.md): the model is told the rules, and this script +# enforces them, so a brief that fabricates a market figure or leaves the +# riskiest-assumption table empty never becomes a PR. The decision-brief +# workflow runs it as its own step after the model step; Mary also runs it +# herself and fixes findings before finishing (the same pattern as +# initiative-planner's validate-plan.py). +# +# The rules mirror the incubator's gate (scripts/incubation-gate.sh in +# combsmith/incubator: frontmatter `status`, required section headers by +# case-insensitive prefix, no template placeholders) and add the stage's own +# decision-grade requirements from combsmith/incubator#3: +# +# score brief.md shows a RICE or ICE score line WITH its factors +# (Confidence must be stated — the BA advisory ties it to how +# strong the cited evidence is) +# assumptions the riskiest-assumptions table has a "Cheapest test" column +# and at least one fully-filled row +# decision the Recommendation section takes a position (go / no-go / +# park) and names the human gate (`idea:approved`) +# provenance in the Problem / Target user / Market sections of brief.md and +# every section of market-research.md except Sources, any line +# that states a quantified market claim (a number with %, $, +# million, users, customers, downloads, …) must carry a source +# (http(s) URL or [^n] footnote), the literal +# "unknown — needs discovery", or an explicit [ASSUMPTION] tag; +# market-research.md must end with a `## Sources` section that +# lists at least one URL or states that no external sources +# were consulted +# +# Usage: +# validate-package.sh <package-dir> [--slug <slug>] [--discussion <n>] +# [--spec <package-spec.json>] +# Output: one `PASS|<file>|<rule>` or `FAIL|<file>|<rule>|<detail>` line per +# check; exit 1 when any FAIL. Helpers are sourceable for tests +# (tests/test_decision_brief.bats): strict mode is set only when executed. + +# ── text helpers (pure) ────────────────────────────────────────────────────── + +# vp_fm_value <file> <key> — the frontmatter value of <key> (empty if none). +vp_fm_value() { + local f="$1" key="$2" + [[ -f "$f" ]] || return 0 + awk -v key="$key" ' + NR==1 && $0=="---" { infm=1; next } + infm && $0=="---" { exit } + infm && index($0, key ":")==1 { v=$0; sub("^" key ":[[:space:]]*", "", v); sub(/[[:space:]]*#.*$/, "", v); gsub(/["'\''` ]/, "", v); print v; exit } + ' "$f" + return 0 +} + +# vp_has_section <file> <needle> — 0 iff a markdown header line contains +# <needle> (case-insensitive, fixed string — same rule as the incubation gate). +vp_has_section() { + local file="$1" needle="$2" + grep -E '^#{1,6}[[:space:]]' "$file" 2>/dev/null | grep -qiF -- "$needle" +} + +# vp_section_body <file> <needle> — the lines of the first section whose header +# contains <needle>, up to (not including) the next header of any level. The +# header line itself is excluded. Fenced code blocks are passed through. +vp_section_body() { + local file="$1" needle="$2" + awk -v needle="$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]')" ' + /^#{1,6}[[:space:]]/ { + if (insec) exit + h=tolower($0) + if (index(h, needle) > 0) { insec=1; next } + } + insec { print } + ' "$file" +} + +# vp_strip_comments <file> — the file with HTML comments removed (multi-line). +vp_strip_comments() { + perl -0pe 's/<!--.*?-->//gs' "$1" +} + +# vp_placeholders <file> — print offending lines (template placeholders left +# behind, or an empty table row). Mirrors the gate's list and adds the generic +# `<angle-bracket placeholder>` the _TEMPLATE files use, minus real HTML tags, +# autolinks and e-mail addresses. +vp_placeholders() { + local f="$1" + vp_strip_comments "$f" \ + | grep -nE 'TODO — needs discovery|TODO: needs discovery|<Idea Title>|<slug>|<YYYY-MM-DD>|<who>|<link>|<fill[: ]|<[A-Za-z][^<>]*>|^\|([[:space:]]*\|)+[[:space:]]*$' \ + | grep -vE '<https?://[^>]*>' \ + | grep -vE '<[^<>@[:space:]]+@[^<>@[:space:]]+>' \ + | grep -vE '</?(br|hr|details|summary|sub|sup|kbd|code|b|i|em|strong|p|a|img|table|tr|td|th|thead|tbody|ul|ol|li|div|span|pre|blockquote|h[1-6])([[:space:]][^>]*)?/?>' \ + || true +} + +# A quantified market claim: a number next to a unit that reads as market size, +# price, share, adoption or demand. +VP_QUANT_RE='([0-9][0-9,.]*[[:space:]]*(%|percent|million|billion|trillion|thousand|[kKMB]\+?([^A-Za-z]|$)|users|customers|companies|orgs|organizations|organisations|downloads|stars|installs|teams|developers|repos|subscribers|seats|businesses|nonprofits|non-profits|SMBs)|\$[[:space:]]*[0-9]|€[[:space:]]*[0-9]|£[[:space:]]*[0-9]|[0-9][0-9,.]*[[:space:]]*(USD|EUR|GBP)([^A-Za-z]|$))' +# What makes such a line honest: a source, the explicit unknown, or a tagged assumption. +VP_CITED_RE='(https?://|\[\^[0-9]+\]|unknown — needs discovery|unknown - needs discovery|\[ASSUMPTION|\(assumption)' + +# vp_unsourced_claims <text-on-stdin> — print each line that quantifies a +# market claim without a citation / unknown / assumption tag. Table separator +# rows and headers are skipped; fenced code is skipped. +vp_unsourced_claims() { + awk ' + /^```/ { fence = !fence; next } + fence { next } + /^#{1,6}[[:space:]]/ { next } + /^\|[[:space:]]*-+/ { next } + { print } + ' | grep -iE -- "$VP_QUANT_RE" | grep -viE -- "$VP_CITED_RE" || true +} + +# vp_table_data_rows <text-on-stdin> <min-cells> — print table rows (not the +# header, not the separator) that have at least <min-cells> non-empty cells. +vp_table_data_rows() { + local min="${1:-4}" + awk -v min="$min" ' + /^\|/ { + if ($0 ~ /^\|[[:space:]]*-+/) { sep_seen = 1; next } # separator row + if (!sep_seen) next # header row(s) + n = split($0, c, "|"); filled = 0 + for (i = 2; i < n; i++) { v = c[i]; gsub(/^[[:space:]]+|[[:space:]]+$/, "", v); if (v != "") filled++ } + if (filled >= min) print + } + ' +} + +# ── the checks ─────────────────────────────────────────────────────────────── + +VP_FAILS=0 +vp_pass() { printf 'PASS|%s|%s\n' "$1" "$2"; } +vp_fail() { printf 'FAIL|%s|%s|%s\n' "$1" "$2" "$3"; VP_FAILS=$((VP_FAILS + 1)); } + +# vp_check_frontmatter <dir> <file> <artifact> <allowed-status-regex> +vp_check_frontmatter() { + local dir="$1" file="$2" artifact="$3" allowed="$4" path a s + path="$dir/$file" + if [[ ! -f "$path" ]]; then vp_fail "$file" present "missing"; return 0; fi + vp_pass "$file" present + a="$(vp_fm_value "$path" artifact)" + s="$(vp_fm_value "$path" status)" + if [[ "$a" != "$artifact" ]]; then vp_fail "$file" frontmatter "artifact='${a:-none}' (need '$artifact')"; else vp_pass "$file" frontmatter-artifact; fi + if [[ ! "$s" =~ ^($allowed)$ ]]; then vp_fail "$file" frontmatter "status='${s:-none}' (need $allowed)"; else vp_pass "$file" frontmatter-status; fi +} + +# vp_check_sections <dir> <file> <needle>... +vp_check_sections() { + local dir="$1" file="$2"; shift 2 + local path="$dir/$file" needle missing=() + [[ -f "$path" ]] || return 0 + for needle in "$@"; do + vp_has_section "$path" "$needle" || missing+=("$needle") + done + if [[ ${#missing[@]} -gt 0 ]]; then vp_fail "$file" sections "missing section(s): $(IFS=';'; echo "${missing[*]}")"; else vp_pass "$file" sections; fi +} + +vp_check_placeholders() { + local dir="$1" file="$2" hits + local path="$dir/$file" + [[ -f "$path" ]] || return 0 + hits="$(vp_placeholders "$path")" + if [[ -n "$hits" ]]; then + vp_fail "$file" placeholders "template placeholders or empty table rows remain: $(printf '%s' "$hits" | head -3 | tr '\n' ' ' | cut -c1-200)" + else + vp_pass "$file" placeholders + fi +} + +vp_check_score() { + local dir="$1" body + local path="$dir/brief.md" + [[ -f "$path" ]] || return 0 + body="$(vp_section_body "$path" "value")" + [[ -n "$body" ]] || body="$(vp_section_body "$path" "prioriti")" + if ! printf '%s\n' "$body" | grep -qE '\b(RICE|ICE)\b.*=.*[0-9]'; then + vp_fail brief.md score "no RICE/ICE score line (expected e.g. 'ICE = 3 × 1 × 2 = 6') in the Value & feasibility section" + elif ! printf '%s\n' "$body" | grep -qi 'confidence'; then + vp_fail brief.md score "score shown without its Confidence factor (state it and tie it to the evidence strength)" + else + vp_pass brief.md score + fi +} + +vp_check_assumptions() { + local dir="$1" body rows + local path="$dir/brief.md" + [[ -f "$path" ]] || return 0 + body="$(vp_section_body "$path" "riskiest assumption")" + if ! printf '%s\n' "$body" | grep -qi 'cheapest test'; then + vp_fail brief.md assumptions "riskiest-assumptions table has no 'Cheapest test' column" + return 0 + fi + rows="$(printf '%s\n' "$body" | vp_table_data_rows 4)" + if [[ -z "$rows" ]]; then + vp_fail brief.md assumptions "riskiest-assumptions table has no fully-filled row (assumption, if wrong, cheapest test)" + else + vp_pass brief.md assumptions + fi +} + +vp_check_decision() { + local dir="$1" body + local path="$dir/brief.md" + [[ -f "$path" ]] || return 0 + body="$(vp_section_body "$path" "recommendation")" + if ! printf '%s\n' "$body" | grep -qiE '\b(go|no-go|no go|park)\b'; then + vp_fail brief.md decision "Recommendation section takes no position (expected go / no-go / park)" + elif ! printf '%s\n' "$body" | grep -q 'idea:approved'; then + vp_fail brief.md decision "Recommendation section must name the human gate: the decision is made by applying \`idea:approved\`" + else + vp_pass brief.md decision + fi +} + +vp_check_provenance() { + local dir="$1" path hits sec + # brief.md: the evidence sections. + path="$dir/brief.md" + if [[ -f "$path" ]]; then + hits="" + for sec in problem "target user" market; do + hits+="$(vp_section_body "$path" "$sec" | vp_unsourced_claims)" + done + if [[ -n "$hits" ]]; then + vp_fail brief.md provenance "quantified market claim without a source / 'unknown — needs discovery' / [ASSUMPTION]: $(printf '%s' "$hits" | head -2 | tr '\n' ' ' | cut -c1-220)" + else + vp_pass brief.md provenance + fi + fi + # market-research.md: everything but Sources. + path="$dir/market-research.md" + if [[ -f "$path" ]]; then + hits="$(awk ' + /^#{1,6}[[:space:]]/ { insrc = (tolower($0) ~ /sources/) } + !insrc { print } + ' "$path" | vp_unsourced_claims)" + if [[ -n "$hits" ]]; then + vp_fail market-research.md provenance "quantified market claim without a source / 'unknown — needs discovery' / [ASSUMPTION]: $(printf '%s' "$hits" | head -2 | tr '\n' ' ' | cut -c1-220)" + else + vp_pass market-research.md provenance + fi + local src + src="$(vp_section_body "$path" "sources")" + if ! vp_has_section "$path" "Sources"; then + vp_fail market-research.md sources "no '## Sources' section" + elif ! printf '%s\n' "$src" | grep -qE 'https?://' && ! printf '%s\n' "$src" | grep -qi 'no external sources were consulted'; then + vp_fail market-research.md sources "Sources lists no URL and does not state 'No external sources were consulted'" + else + vp_pass market-research.md sources + fi + fi +} + +vp_check_identity() { + local dir="$1" slug="$2" discussion="$3" path f + for f in brief.md market-research.md ost.md; do + path="$dir/$f" + [[ -f "$path" ]] || continue + if [[ -n "$slug" ]] && ! grep -qE "\*\*Slug:\*\*[[:space:]]*\`${slug}\`" "$path"; then + vp_fail "$f" identity "does not carry '**Slug:** \`${slug}\`'" + else + vp_pass "$f" identity-slug + fi + done + path="$dir/brief.md" + if [[ -f "$path" && -n "$discussion" ]]; then + if grep -qE "/discussions/${discussion}([^0-9]|$)" "$path"; then vp_pass brief.md identity-discussion + else vp_fail brief.md identity "Source Discussion link to /discussions/${discussion} missing"; fi + fi + if [[ -f "$path" ]]; then + if grep -q 'market-research.md' "$path" && grep -q 'ost.md' "$path"; then vp_pass brief.md links + else vp_fail brief.md links "brief must reference market-research.md and ost.md"; fi + fi +} + +vp_check_ost() { + local dir="$1" + local path="$dir/ost.md" + [[ -f "$path" ]] || return 0 + if grep -q '^```mermaid' "$path"; then vp_pass ost.md mermaid + else vp_fail ost.md mermaid "no \`\`\`mermaid block (the tree is rendered as Mermaid)"; fi +} + +# vp_spec_sections <spec> <file> — the gate's required_sections for <file>, one per line. +vp_spec_sections() { + local spec="$1" file="$2" + [[ -n "$spec" && -f "$spec" ]] || return 0 + jq -r --arg f "$file" '.required_artifacts[]? | select(.file == $f) | .required_sections[]?' "$spec" 2>/dev/null || true +} + +vp_main() { + local dir="" slug="" discussion="" spec="" + while [[ $# -gt 0 ]]; do + case "$1" in + --slug) slug="${2:-}"; shift 2 ;; + --discussion) discussion="${2:-}"; shift 2 ;; + --spec) spec="${2:-}"; shift 2 ;; + -h|--help) sed -n '2,40p' "${BASH_SOURCE[0]}"; return 0 ;; + *) dir="$1"; shift ;; + esac + done + [[ -n "$dir" ]] || { echo "usage: validate-package.sh <package-dir> [--slug s] [--discussion n] [--spec file]" >&2; return 2; } + [[ -d "$dir" ]] || { echo "::error::package dir '$dir' does not exist" >&2; return 2; } + + local -a brief_secs mr_secs + brief_secs=("Problem" "Target user" "Market" "Opportunity" "Value" "Riskiest assumption" "Recommendation" "Open questions") + mr_secs=("How it's solved today" "The gap" "Market signal" "Risks" "Sources") + local s + while IFS= read -r s; do [[ -n "$s" ]] && brief_secs+=("$s"); done < <(vp_spec_sections "$spec" brief.md) + while IFS= read -r s; do [[ -n "$s" ]] && mr_secs+=("$s"); done < <(vp_spec_sections "$spec" market-research.md) + + vp_check_frontmatter "$dir" brief.md brief 'final' + vp_check_frontmatter "$dir" market-research.md market-research 'final|draft' + vp_check_frontmatter "$dir" ost.md ost 'final|draft' + vp_check_sections "$dir" brief.md "${brief_secs[@]}" + vp_check_sections "$dir" market-research.md "${mr_secs[@]}" + vp_check_placeholders "$dir" brief.md + vp_check_placeholders "$dir" market-research.md + vp_check_placeholders "$dir" ost.md + vp_check_score "$dir" + vp_check_assumptions "$dir" + vp_check_decision "$dir" + vp_check_provenance "$dir" + vp_check_identity "$dir" "$slug" "$discussion" + vp_check_ost "$dir" + + if [[ "$VP_FAILS" -gt 0 ]]; then + echo "validate-package: ${VP_FAILS} finding(s) in ${dir} — fix them before the package can become a PR" >&2 + return 1 + fi + echo "validate-package: ${dir} passes" +} + +if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then + set -euo pipefail + vp_main "$@" +fi diff --git a/scripts/lib/fleet-pause-registry.tsv b/scripts/lib/fleet-pause-registry.tsv index b805a091a..9be8164a8 100644 --- a/scripts/lib/fleet-pause-registry.tsv +++ b/scripts/lib/fleet-pause-registry.tsv @@ -80,6 +80,7 @@ gh-aw-cross-org.yml job-gate dispatch - issue-triage-runner.yml job-gate triage The apply job needs triage, so it is skipped with it. initiative-planner.yml job-gate plan The redispatch bridge makes no model call and still runs; the run it starts is the one that reports the pause. idea-triage.yml job-gate triage - +decision-brief.yml job-gate brief The redispatch bridge and the queue sweep make no model call and still run; the brief run they start is the one that reports the pause. spec-drift.yml job-gate-silent spec-drift Opt-in and off by default. No notice job: its contract test requires every job to hold issues: write and the workflow to cost nothing when it does not run. dependency-advisory.yml job-gate advisory - docs-health-check.yml job-gate health-check - diff --git a/standards/workflows/decision-brief.yml b/standards/workflows/decision-brief.yml new file mode 100644 index 000000000..6c2399cc4 --- /dev/null +++ b/standards/workflows/decision-brief.yml @@ -0,0 +1,77 @@ +# ───────────────────────────────────────────────────────────────────────────── +# SOURCE OF TRUTH: combsmith/.github-private/standards/workflows/decision-brief.yml +# (staging copy — to be promoted to +# combsmith/.github/standards/workflows/decision-brief.yml, the +# home of every other caller stub, in a follow-up standards PR) +# Reusable: combsmith/.github-private/.github/workflows/decision-brief-reusable.yml +# Central: combsmith/.github-private/.github/workflows/decision-brief.yml +# +# AGENTS — READ BEFORE EDITING: +# • This file is a THIN CALLER STUB. All dispatch logic lives in the reusable +# above; Mary (the BMAD Analyst), the vendored BMAD skills and the provenance +# guard live in the central workflow. +# • You MUST NOT change: the `uses:` ref — it is pinned to the +# `decision-brief/v1-stable` channel, a moving tag advanced centrally. Never +# repoint it to `@main`, a SHA, or a frozen `@vX`. Do not change the +# `secrets:` block or the trigger event shape. +# • You MAY change: the cron schedule (keep it after your idea-triage run and +# off minute 0), and the `max_briefs` sweep cap. +# • If you need different behaviour, open a PR against the reusable in the +# central repo. A new release is rolled out by moving the channel tag +# centrally — never by editing callers. +# ───────────────────────────────────────────────────────────────────────────── +# +# Decision Brief — Mary drafts ideas/<slug>/brief.md for a ripe idea, as a PR here. +# +# Two ways a brief is requested: +# 1. A maintainer adds the `needs-brief` label to an Ideas Discussion. +# 2. Weekly, after idea-triage refreshes this repo's "🧭 Idea Promotion Queue", +# every idea in its "Ripe" table that has no ideas/<slug>/brief.md yet gets +# one (capped by max_briefs per sweep). +# Either way the central workflow in combsmith/.github-private runs Mary, fills +# THIS repo's ideas/_TEMPLATE/{brief,market-research,ost}.md, enforces the +# provenance guard (every market claim cited, tagged [ASSUMPTION], or +# "unknown — needs discovery"), and opens a PR from `incubate/<slug>` with the +# package. It never applies `idea:approved` — read the brief, then decide on +# the Discussion. +# +# To adopt: +# 1. Copy this file to .github/workflows/decision-brief.yml in your repo. +# 2. Ensure GitHub Discussions is enabled with an "Ideas" category, and that +# ideas/_TEMPLATE/{brief,market-research,ost}.md exist (copy them from +# combsmith/incubator). +# 3. Create the labels `needs-brief` (request a brief) and `idea:approved` +# (the go decision; fires initiative-planner). +# 4. Confirm the org-level secret GH_PAT_DON_PETRY (or GH_PAT_WORKFLOWS) is +# accessible. +# 5. (Optional) Adjust the schedule cron and max_briefs. +name: Decision Brief — Draft the go/no-go brief for a ripe idea + +on: + # Request a brief for one idea by labelling its Discussion `needs-brief`. + discussion: + types: [labeled] + # Sweep: brief every Ripe idea in the Idea Promotion Queue (after Monday's + # idea-triage run) that has no brief yet. + schedule: + - cron: '43 14 * * 1' # Monday 14:43 UTC (idea-triage stubs run 13:30) + workflow_dispatch: + inputs: + discussion: + description: 'Ideas Discussion number to brief now. Empty => sweep the Idea Promotion Queue.' + required: false + default: '' + type: string + +permissions: {} + +jobs: + decision-brief: + uses: combsmith/.github-private/.github/workflows/decision-brief-reusable.yml@decision-brief/v1-stable # NOSONAR(githubactions:S7637) first-party channel ref + with: + # `github.event.inputs.*` (not the `inputs` context) so this evaluates to + # '' on the discussion/schedule events instead of failing at workflow + # setup (the #571 class). + discussion: ${{ github.event.inputs.discussion }} + max_briefs: '3' + secrets: inherit # NOSONAR(githubactions:S7635) first-party trusted reusable diff --git a/tests/fixtures/decision-brief/good/brief.md b/tests/fixtures/decision-brief/good/brief.md new file mode 100644 index 000000000..5ee1d279f --- /dev/null +++ b/tests/fixtures/decision-brief/good/brief.md @@ -0,0 +1,53 @@ +--- +artifact: brief +status: final +--- + +# Decision Brief — WidgetLint + +- **Slug:** `widget-lint` +- **Source Discussion:** https://github.com/acme/widgets/discussions/413 +- **Author / owner:** Mary (Analyst), decision-brief stage +- **Status:** `ready-for-decision` +- **Last updated:** 2026-10-19 + +## 1. Problem +Teams that ship widgets have no lint for widget manifests, so malformed manifests +reach production. The Discussion thread reports three incidents in Q3; the exact +rate across the org is unknown — needs discovery. + +## 2. Target user & JTBD +- **Who:** maintainers of repos that publish widget manifests (the thread names two). +- **Job to be done:** When _I open a PR that touches a manifest_, I want to _see + schema errors before review_, so I can _fix them without a production incident_. + +## 3. Market & competitive context +Generic JSON-schema linters cover the shape but not the org's naming rules. +Market size for this niche is unknown — needs discovery. See `market-research.md`. + +## 4. Opportunity → solution +See `ost.md`. +- **Desired outcome (metric):** manifest-caused incidents per quarter → 0 +- **Opportunity addressed:** errors found only after merge +- **Proposed solution(s):** a CI lint step reusing the existing schema + +## 5. Value & feasibility (rough) +- **Impact:** medium — three incidents in a quarter, per the thread. +- **Effort:** small — one reusable workflow step. +- **Prioritization score** — ICE: + - Impact = 3/5, Confidence = 2/5 (thread evidence, no measured rate), Ease = 4/5 + - ICE = 3 × 2 × 4 = 24 / 125 + +## 6. Riskiest assumptions & cheapest test +| # | Assumption | If wrong… | Cheapest test | Result | +|---|-----------|-----------|---------------|--------| +| 1 | The three incidents were manifest-shape errors a lint would catch | the lint prevents nothing | re-read the three incident postmortems (1 hour) | — | + +## 7. Recommendation (go / no-go) +**Recommendation: go** — small, grounded, reversible. A POC would need to prove the +lint catches the Q3 incident class. The decider should say no if the postmortems show +the errors were semantic, not structural. The decision is the human's, made by applying +`idea:approved` on the Discussion. + +## 8. Open questions +- No brainstorm.md exists for this idea; the brief proceeded from the thread alone. diff --git a/tests/fixtures/decision-brief/good/market-research.md b/tests/fixtures/decision-brief/good/market-research.md new file mode 100644 index 000000000..bf60255cd --- /dev/null +++ b/tests/fixtures/decision-brief/good/market-research.md @@ -0,0 +1,27 @@ +--- +artifact: market-research +status: final +--- + +# Market Research — WidgetLint + +- **Slug:** `widget-lint` +- **Last updated:** 2026-10-19 + +## How it's solved today +| Alternative | What it does well | Where it falls short | Pricing / model | +|-------------|-------------------|----------------------|-----------------| +| ajv-cli | fast schema validation | no org naming rules | free, MIT (https://github.com/ajv-validator/ajv-cli) | + +## The gap / wedge +Nothing enforces the org's naming rules; the schema alone is not enough. + +## Market signal +Internal only. Demand outside the org is unknown — needs discovery. +ajv has about 100 million weekly downloads [^1]. + +## Risks & unknowns +The schema may drift from the rules faster than the lint is updated. + +## Sources +[^1]: https://www.npmjs.com/package/ajv diff --git a/tests/fixtures/decision-brief/good/ost.md b/tests/fixtures/decision-brief/good/ost.md new file mode 100644 index 000000000..4aa237672 --- /dev/null +++ b/tests/fixtures/decision-brief/good/ost.md @@ -0,0 +1,20 @@ +--- +artifact: ost +status: final +--- + +# Opportunity–Solution Tree — WidgetLint + +- **Slug:** `widget-lint` +- **Last updated:** 2026-10-19 + +```mermaid +flowchart TD + O["🎯 Outcome: manifest incidents per quarter → 0"] + O --> OPP1["Opportunity: errors found only after merge"] + OPP1 --> S1["Solution: CI lint step"] + S1 --> A1["Assumption test: re-read the three postmortems"] +``` + +## Notes +- Outcome is a metric, not a feature. diff --git a/tests/fixtures/decision-brief/queue-body.md b/tests/fixtures/decision-brief/queue-body.md new file mode 100644 index 000000000..6a3600e05 --- /dev/null +++ b/tests/fixtures/decision-brief/queue-body.md @@ -0,0 +1,18 @@ +# 🧭 Idea Promotion Queue + +Generated 2026-10-19. Legend: 🟢 ripe · 🟡 soon · ⚪ not yet. + +## 🟢 Ripe to promote +| Idea | Why now | Promotion note | +|------|---------|----------------| +| #413 WidgetLint | three incidents | a CI lint step | +| [#44](https://github.com/acme/widgets/discussions/44) DemandRadar | demand data | see /discussions/44 | +| #413 WidgetLint (dup) | listed twice | — | + +## 🟡 Soon +| Idea | What's missing | +|------|----------------| +| #500 Something | evidence | + +## ⚪ Not yet / covered +- #600 covered by epic #7 diff --git a/tests/fixtures/decision-brief/template/brief.md b/tests/fixtures/decision-brief/template/brief.md new file mode 100644 index 000000000..7d73d2e8d --- /dev/null +++ b/tests/fixtures/decision-brief/template/brief.md @@ -0,0 +1,57 @@ +--- +artifact: brief +status: draft # draft | final — the incubation gate requires `final` +--- + +# Decision Brief — <Idea Title> + +<!-- +The decision brief / PRD-lite. This is the artifact a human decides `idea:approved` +on. It exists to INFORM the go/no-go with discovery evidence — it does not replace +discovery, and it is not the full PRD (that gets written in the product repo after +graduation). Owner: Mary (Analyst). Keep it tight — a page or two. +--> + +- **Slug:** `<slug>` +- **Source Discussion:** <link> +- **Author / owner:** <who> +- **Status:** `draft` → `ready-for-decision` → `approved` | `declined` | `parked` +- **Last updated:** <YYYY-MM-DD> + +## 1. Problem +What pain, for whom, and why now? One paragraph. Evidence, not assertion. + +## 2. Target user & JTBD +- **Who:** <primary user / segment> +- **Job to be done:** When _[situation]_, I want to _[motivation]_, so I can _[outcome]_. + +## 3. Market & competitive context +How is this solved today (alternatives, competitors, workarounds)? What's the gap? +Link `market-research.md` for the detail; summarize the takeaway here. + +## 4. Opportunity → solution +The bet, mapped onto the opportunity-solution-tree (`ost.md`): +- **Desired outcome (metric):** <what moves if this works> +- **Opportunity addressed:** <the customer need/pain this targets> +- **Proposed solution(s):** <the concept(s) under consideration> + +## 5. Value & feasibility (rough) +- **Impact:** <low / med / high — and why> +- **Effort:** <t-shirt or rough weeks> +- **Prioritization score** — pick one and show the numbers: + - RICE = (Reach × Impact × Confidence) / Effort = **<score>** + - or ICE = (Impact × Confidence × Ease) / … = **<score>** + +## 6. Riskiest assumptions & cheapest test +List the assumptions that would kill this if wrong, most-dangerous first. For the +top one or two, name the *cheapest* test (a spike, a fake-door, five interviews). +| # | Assumption | If wrong… | Cheapest test | Result | +|---|-----------|-----------|---------------|--------| +| 1 | | | | | + +## 7. Recommendation (go / no-go) +The brief's own recommendation and *why* — but the decision is the human's, made by +applying `idea:approved`. State what a **POC would need to prove** to graduate. + +## 8. Open questions +Anything unresolved that the decision-maker should know is unresolved. diff --git a/tests/fixtures/decision-brief/template/market-research.md b/tests/fixtures/decision-brief/template/market-research.md new file mode 100644 index 000000000..e437e7cfa --- /dev/null +++ b/tests/fixtures/decision-brief/template/market-research.md @@ -0,0 +1,31 @@ +--- +artifact: market-research +status: draft # draft | final — the incubation gate requires `final` +--- + +# Market Research — <Idea Title> + +<!-- +Competitive / market context feeding the decision brief. Owner: Mary (Analyst). +Summarize the takeaway into brief.md §3 — keep the detail here. +--> + +- **Slug:** `<slug>` +- **Last updated:** <YYYY-MM-DD> + +## How it's solved today +Existing products, competitors, and manual workarounds users rely on now. + +| Alternative | What it does well | Where it falls short | Pricing / model | +|-------------|-------------------|----------------------|-----------------| +| | | | | + +## The gap / wedge +What's underserved, and why we could do it better or differently. + +## Market signal +Size, trend, demand evidence (searches, communities, adjacent-product traction). +Cite sources. Flag anything that's an estimate rather than a measured figure. + +## Risks & unknowns +Regulatory, platform-dependency, incumbency, or timing risks worth naming early. diff --git a/tests/fixtures/decision-brief/template/ost.md b/tests/fixtures/decision-brief/template/ost.md new file mode 100644 index 000000000..c6b184391 --- /dev/null +++ b/tests/fixtures/decision-brief/template/ost.md @@ -0,0 +1,37 @@ +--- +artifact: ost +status: draft # draft | final — supporting artifact (not gate-required) +--- + +# Opportunity–Solution Tree — <Idea Title> + +<!-- +Teresa Torres's opportunity-solution-tree, rendered as Mermaid (GitHub renders it +natively — no whiteboard tool needed). Structure: one desired OUTCOME → the +OPPORTUNITY space (customer needs/pains) → the SOLUTION space → ASSUMPTION tests. +A living document — revisit as you learn. Owner: Mary (Analyst). +--> + +- **Slug:** `<slug>` +- **Last updated:** <YYYY-MM-DD> + +```mermaid +flowchart TD + O["🎯 Outcome: <metric that moves if this works>"] + + O --> OPP1["Opportunity: <customer need / pain #1>"] + O --> OPP2["Opportunity: <customer need / pain #2>"] + + OPP1 --> S1["Solution: <concept A>"] + OPP1 --> S2["Solution: <concept B>"] + OPP2 --> S3["Solution: <concept C>"] + + S1 --> A1["Assumption test: <cheapest experiment>"] + S2 --> A2["Assumption test: <cheapest experiment>"] + S3 --> A3["Assumption test: <cheapest experiment>"] +``` + +## Notes +- **Outcome** is a metric, not a feature. Everything below serves it. +- Prefer breadth in the opportunity space before committing to a solution. +- Each solution should bottom out in the *cheapest* test that could invalidate it. diff --git a/tests/test_decision_brief.bats b/tests/test_decision_brief.bats new file mode 100644 index 000000000..7df066f4c --- /dev/null +++ b/tests/test_decision_brief.bats @@ -0,0 +1,484 @@ +#!/usr/bin/env bats +# Unit tests for the decision-brief stage tooling (scripts/decision-brief/): +# the slug derivation, the discussion→dispatch bridge, the provenance + +# structure validator, the PR opener and the Ripe-from-queue sweep. gh is +# mocked everywhere — no network. Fixtures: tests/fixtures/decision-brief/. +# +# Run with: bats tests/test_decision_brief.bats + +bats_require_minimum_version 1.5.0 + +ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" +DB_DIR="$ROOT/scripts/decision-brief" +FIX="$ROOT/tests/fixtures/decision-brief" + +setup() { + TMP="$(mktemp -d)" + MOCK_BIN="$TMP/bin" + mkdir -p "$MOCK_BIN" + GH_LOG="$TMP/gh.log" + export GH_LOG + export PATH="$MOCK_BIN:$PATH" + export GH_TOKEN="pat" + unset DRY_RUN DRY_RUN_LOG TARGET_REPO WORKFLOW_FILE GITHUB_REF +} + +teardown() { rm -rf "$TMP"; } + +# mock_gh <script-body> — install a gh stub that logs every invocation. +mock_gh() { + { printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >> "$GH_LOG"\n'; printf '%s\n' "$1"; } >"$MOCK_BIN/gh" + chmod +x "$MOCK_BIN/gh" +} + +copy_good() { cp -R "$FIX/good" "$TMP/pkg"; echo "$TMP/pkg"; } + +validate() { run bash "$DB_DIR/validate-package.sh" "$@"; } + +# ── slug ────────────────────────────────────────────────────────────────────── + +@test "slug: ideation title with emoji and em-dash pitch → the Name, CamelCase split" { + source "$DB_DIR/lib/slug.sh" + [ "$(db_slug_from_title '💡 DemandRadar — multi-source demand-discovery engine')" = "demand-radar" ] + [ "$(db_slug_from_title 'ContentTwin')" = "content-twin" ] + [ "$(db_slug_from_title '💡 A2AProbe — conformance suite')" = "a2a-probe" ] + [ "$(db_slug_from_title '💡 MCPServerCard — metadata generator')" = "mcp-server-card" ] +} + +@test "slug: plain titles are kebab-cased, squeezed and capped at 48 chars on a word boundary" { + source "$DB_DIR/lib/slug.sh" + [ "$(db_slug_from_title 'Wire the decision-brief agent stage (Mary → brief.md at Ripe)')" = "wire-the-decision-brief-agent-stage-mary-brief" ] + [ "$(db_slug_from_title ' Foo__bar!! baz ')" = "foo-bar-baz" ] + s="$(db_slug_from_title 'aaaaaaaaaa bbbbbbbbbb cccccccccc dddddddddd eeeeeeeeee ffffffffff')" + [ "${#s}" -le 48 ] + db_slug_is_valid "$s" +} + +@test "slug: the same title always yields the same slug (stable folder, stable branch)" { + source "$DB_DIR/lib/slug.sh" + [ "$(db_slug_from_title '💡 WidgetLint — a lint')" = "$(db_slug_from_title '💡 WidgetLint — a different pitch')" ] +} + +@test "slug: validity rejects empty, uppercase, the _TEMPLATE folder and stray hyphens" { + source "$DB_DIR/lib/slug.sh" + db_slug_is_valid "widget-lint" + run ! db_slug_is_valid "" + run ! db_slug_is_valid "Widget-Lint" + run ! db_slug_is_valid "_TEMPLATE" + run ! db_slug_is_valid "-leading" + run ! db_slug_is_valid "double--hyphen" +} + +# ── redispatch bridge ───────────────────────────────────────────────────────── + +@test "redispatch: re-dispatches decision-brief.yml with discussion, target_repo and dry_run=false" { + mock_gh 'exit 0' + REPO="combsmith/.github-private" DISCUSSION_NUMBER=572 run bash "$DB_DIR/redispatch.sh" + [ "$status" -eq 0 ] + grep -qF "workflow run decision-brief.yml" "$GH_LOG" + grep -qF -- "--repo combsmith/.github-private" "$GH_LOG" + grep -qF -- "-f discussion=572" "$GH_LOG" + grep -qF -- "-f target_repo=combsmith/.github-private" "$GH_LOG" + grep -qF -- "-f dry_run=false" "$GH_LOG" + # Only inputs decision-brief.yml declares: gh rejects an undeclared input. + ! grep -qE -- "-f (force_replan|reconcile)=" "$GH_LOG" || false +} + +@test "redispatch: honors TARGET_REPO (fleet host) and DRY_RUN" { + mock_gh 'exit 0' + REPO="combsmith/.github-private" TARGET_REPO="acme/widgets" DISCUSSION_NUMBER=7 DRY_RUN=true \ + run bash "$DB_DIR/redispatch.sh" + [ "$status" -eq 0 ] + grep -qF -- "-f target_repo=acme/widgets" "$GH_LOG" + grep -qF -- "-f dry_run=true" "$GH_LOG" +} + +@test "redispatch: rejects a non-numeric discussion and a malformed target repo without dispatching" { + mock_gh 'exit 0' + REPO="combsmith/.github-private" DISCUSSION_NUMBER="572; rm -rf /" run bash "$DB_DIR/redispatch.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"positive integer"* ]] + [ ! -s "$GH_LOG" ] + REPO="combsmith/.github-private" TARGET_REPO="acme/widgets;x" DISCUSSION_NUMBER=5 run bash "$DB_DIR/redispatch.sh" + [ "$status" -ne 0 ] + [ ! -s "$GH_LOG" ] +} + +@test "redispatch: passes --ref for a branch GITHUB_REF, not for a PR merge ref" { + mock_gh 'exit 0' + REPO=r/r DISCUSSION_NUMBER=1 GITHUB_REF=refs/heads/feature run bash "$DB_DIR/redispatch.sh" + grep -qF -- "--ref refs/heads/feature" "$GH_LOG" + : >"$GH_LOG" + REPO=r/r DISCUSSION_NUMBER=1 GITHUB_REF=refs/pull/9/merge run bash "$DB_DIR/redispatch.sh" + ! grep -qF -- "--ref" "$GH_LOG" || false +} + +# ── validate-package: structure ─────────────────────────────────────────────── + +@test "validate: the good package passes every check" { + pkg="$(copy_good)" + validate "$pkg" --slug widget-lint --discussion 413 + [ "$status" -eq 0 ] + ! grep -q '^FAIL' <<<"$output" || false +} + +@test "validate: the untouched incubator _TEMPLATE fails (draft status, placeholders, empty rows)" { + validate "$FIX/template" + [ "$status" -ne 0 ] + grep -q 'FAIL|brief.md|frontmatter|status=.draft' <<<"$output" + grep -q 'FAIL|brief.md|placeholders' <<<"$output" + grep -q 'FAIL|market-research.md|placeholders' <<<"$output" +} + +@test "validate: a missing file and a missing required section are reported" { + pkg="$(copy_good)"; rm "$pkg/ost.md" + sed -i.bak 's/^## 6\. Riskiest assumptions.*/## 6. Things/' "$pkg/brief.md" + validate "$pkg" + [ "$status" -ne 0 ] + grep -q 'FAIL|ost.md|present|missing' <<<"$output" + grep -q 'FAIL|brief.md|sections|missing section(s): Riskiest assumption' <<<"$output" +} + +@test "validate: honours extra required_sections from a package-spec.json" { + pkg="$(copy_good)" + printf '{"required_artifacts":[{"file":"brief.md","required_sections":["Problem","Budget envelope"]}]}' >"$TMP/spec.json" + validate "$pkg" --spec "$TMP/spec.json" + [ "$status" -ne 0 ] + grep -q 'missing section(s): Budget envelope' <<<"$output" +} + +@test "validate: the score must be a RICE/ICE line with its arithmetic AND a stated Confidence" { + pkg="$(copy_good)" + sed -i.bak 's/ICE = 3 × 2 × 4 = 24 \/ 125/we think it is worth doing/' "$pkg/brief.md" + validate "$pkg" + grep -q 'FAIL|brief.md|score|no RICE/ICE score line' <<<"$output" + pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" + sed -i.bak 's/Confidence = 2\/5 (thread evidence, no measured rate)/Conf. 2/' "$pkg2/brief.md" + validate "$pkg2" + grep -q 'FAIL|brief.md|score|.*Confidence' <<<"$output" +} + +@test "validate: the riskiest-assumptions table needs a Cheapest-test column and one full row" { + pkg="$(copy_good)" + sed -i.bak 's/^| 1 | The three incidents.*$/| 1 | | | | |/' "$pkg/brief.md" + validate "$pkg" + [ "$status" -ne 0 ] + grep -q 'FAIL|brief.md|assumptions|.*no fully-filled row' <<<"$output" + pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" + sed -i.bak 's/Cheapest test/Next step/' "$pkg2/brief.md" + validate "$pkg2" + grep -q "FAIL|brief.md|assumptions|.*no 'Cheapest test' column" <<<"$output" +} + +@test "validate: the recommendation must take a position and name the idea:approved gate" { + pkg="$(copy_good)" + sed -i.bak 's/\*\*Recommendation: go\*\*/**Recommendation: it depends**/; s/say no if/say maybe if/' "$pkg/brief.md" + validate "$pkg" + grep -q 'FAIL|brief.md|decision|.*takes no position' <<<"$output" + pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" + sed -i.bak 's/`idea:approved`/the label/' "$pkg2/brief.md" + validate "$pkg2" + grep -q 'FAIL|brief.md|decision|.*idea:approved' <<<"$output" +} + +@test "validate: identity — slug line, source-discussion link and the links to the other artifacts" { + pkg="$(copy_good)" + validate "$pkg" --slug other-slug --discussion 999 + [ "$status" -ne 0 ] + grep -q 'FAIL|brief.md|identity|does not carry' <<<"$output" + grep -q 'FAIL|brief.md|identity|Source Discussion link to /discussions/999 missing' <<<"$output" + pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" + sed -i.bak 's/See `ost.md`\./See the tree./' "$pkg2/brief.md" + validate "$pkg2" + grep -q 'FAIL|brief.md|links' <<<"$output" +} + +@test "validate: ost.md must carry a mermaid block; brief.md must be status final" { + pkg="$(copy_good)" + sed -i.bak 's/^```mermaid$/```text/' "$pkg/ost.md" + sed -i.bak 's/^status: final$/status: draft/' "$pkg/brief.md" + validate "$pkg" + grep -q 'FAIL|ost.md|mermaid' <<<"$output" + grep -q "FAIL|brief.md|frontmatter|status='draft'" <<<"$output" +} + +# ── validate-package: provenance guard (#1160) ──────────────────────────────── + +@test "provenance: an unsourced market figure in market-research.md fails the package" { + pkg="$(copy_good)" + printf '\nThe addressable market is $2.4B and growing 30%% a year.\n' >>"$pkg/market-research.md" + # Appended after Sources — move it into Market signal instead. + sed -i.bak '/^## Market signal$/a\ +The addressable market is $2.4B and growing 30% a year. +' "$pkg/market-research.md" + validate "$pkg" + [ "$status" -ne 0 ] + grep -q 'FAIL|market-research.md|provenance|.*2.4B' <<<"$output" +} + +@test "provenance: an unsourced figure in brief.md Problem / Market sections fails; §5 arithmetic does not" { + pkg="$(copy_good)" + sed -i.bak '/^## 1\. Problem$/a\ +About 40% of maintainers hit this every month. +' "$pkg/brief.md" + validate "$pkg" + [ "$status" -ne 0 ] + grep -q 'FAIL|brief.md|provenance|.*40%' <<<"$output" + # The ICE arithmetic in §5 (3 × 2 × 4 = 24 / 125) is not a market claim. + ! grep -q 'FAIL|brief.md|provenance|.*24 / 125' <<<"$output" || false +} + +@test "provenance: a figure with a URL, a [^n] footnote, [ASSUMPTION] or 'unknown — needs discovery' passes" { + pkg="$(copy_good)" + sed -i.bak '/^## Market signal$/a\ +Buffer lists 140k customers (https://buffer.com/about).\ +Hootsuite claims 18 million users [^2].\ +[ASSUMPTION] roughly 500 small orgs in the first segment — not confirmed in the thread.\ +Willingness to pay per seat: unknown — needs discovery. +' "$pkg/market-research.md" + printf '[^2]: https://www.hootsuite.com/about\n' >>"$pkg/market-research.md" + validate "$pkg" + [ "$status" -eq 0 ] +} + +@test "provenance: market-research.md needs a Sources section with a URL or the no-sources sentence" { + pkg="$(copy_good)" + sed -i.bak 's/^## Sources$/## Appendix/' "$pkg/market-research.md" + validate "$pkg" + grep -q 'FAIL|market-research.md|sources|no' <<<"$output" + pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" + sed -i.bak 's#^\[\^1\]: https://www.npmjs.com/package/ajv$#none#; s/ \[\^1\]\./ (unknown — needs discovery)./' "$pkg2/market-research.md" + validate "$pkg2" + grep -q 'FAIL|market-research.md|sources|Sources lists no URL' <<<"$output" + pkg3="$TMP/pkg3"; cp -R "$FIX/good" "$pkg3" + sed -i.bak 's#^\[\^1\]: https://www.npmjs.com/package/ajv$#No external sources were consulted — every market claim above is marked unknown — needs discovery.#; s/ \[\^1\]\./ (unknown — needs discovery)./' "$pkg3/market-research.md" + validate "$pkg3" + [ "$status" -eq 0 ] +} + +@test "provenance: lines inside the Sources section and fenced code are not scanned" { + pkg="$(copy_good)" + printf '%s\n' '[^3]: https://example.com/report — "$5B market, 20% CAGR"' >>"$pkg/market-research.md" + sed -i.bak '/^## Risks & unknowns$/a\ +```\ +$9B figure inside a code fence is not a claim\ +``` +' "$pkg/market-research.md" + validate "$pkg" + [ "$status" -eq 0 ] +} + +# ── open-pr ─────────────────────────────────────────────────────────────────── + +@test "open-pr (DRY_RUN): logs the intended PR with the files under ideas/<slug>/ and the back-link, touches nothing" { + pkg="$(copy_good)" + mock_gh 'echo "gh must not be called in dry-run" >&2; exit 99' + DRY_RUN=1 DRY_RUN_LOG="$TMP/dry.jsonl" REPO="acme/widgets" SLUG="widget-lint" PACKAGE_DIR="$pkg" \ + DISCUSSION_NUMBER=413 DISCUSSION_NODE_ID="D_x" IDEA_TITLE="WidgetLint" GIT_USER_NAME=tester \ + run bash "$DB_DIR/open-pr.sh" + [ "$status" -eq 0 ] + [ ! -s "$GH_LOG" ] + [ "$(jq -r 'select(.op=="open_pr") | .branch' "$TMP/dry.jsonl")" = "incubate/widget-lint" ] + [ "$(jq -r 'select(.op=="open_pr") | .files | join(",")' "$TMP/dry.jsonl")" = "ideas/widget-lint/brief.md,ideas/widget-lint/market-research.md,ideas/widget-lint/ost.md" ] + [ "$(jq -r 'select(.op=="open_pr") | .author' "$TMP/dry.jsonl")" = "tester" ] + grep -q '"op":"discussion_comment"' "$TMP/dry.jsonl" +} + +@test "open-pr: rejects a bad slug and a package without brief.md before any call" { + mock_gh 'exit 0' + pkg="$(copy_good)" + REPO=a/b SLUG="Bad_Slug" PACKAGE_DIR="$pkg" DISCUSSION_NUMBER=1 run bash "$DB_DIR/open-pr.sh" + [ "$status" -ne 0 ]; [ ! -s "$GH_LOG" ] + rm "$pkg/brief.md" + REPO=a/b SLUG="ok" PACKAGE_DIR="$pkg" DISCUSSION_NUMBER=1 run bash "$DB_DIR/open-pr.sh" + [ "$status" -ne 0 ]; [[ "$output" == *"no brief.md"* ]]; [ ! -s "$GH_LOG" ] +} + +# A gh stub for the live path: Git Data API + pr list/create + graphql, keyed on +# the call shape. BRANCH_EXISTS / PR_EXISTS switch the idempotent branches. +mock_gh_live() { + mock_gh ' +case "$*" in + "api repos/acme/widgets --jq .default_branch") echo main ;; + "api repos/acme/widgets/git/ref/heads/main --jq .object.sha") echo base000 ;; + "api repos/acme/widgets/git/ref/heads/incubate/widget-lint --jq .object.sha") + if [ "${BRANCH_EXISTS:-0}" = 1 ]; then echo head111; else echo "not found" >&2; exit 1; fi ;; + api\ repos/acme/widgets/git/commits/*) echo tree222 ;; + "api repos/acme/widgets/git/blobs -X POST --input - --jq .sha") cat >>"$GH_LOG.blobs"; echo blob333 ;; + "api repos/acme/widgets/git/trees -X POST --input - --jq .sha") cat >>"$GH_LOG.tree"; echo tree444 ;; + "api repos/acme/widgets/git/commits -X POST --input - --jq .sha") cat >>"$GH_LOG.commit"; echo commit555 ;; + *"pr list --repo acme/widgets --head incubate/widget-lint"*) if [ "${PR_EXISTS:-0}" = 1 ]; then echo "https://github.com/acme/widgets/pull/9"; fi ;; + *"pr create"*) echo "https://github.com/acme/widgets/pull/10" ;; + *) : ;; +esac +exit 0' +} + +@test "open-pr (live, new branch): blobs → tree → commit (persona author) → new ref → PR with label → discussion back-link" { + pkg="$(copy_good)" + mock_gh_live + REPO="acme/widgets" SLUG="widget-lint" PACKAGE_DIR="$pkg" DISCUSSION_NUMBER=413 \ + DISCUSSION_NODE_ID="D_x" IDEA_TITLE="WidgetLint" GIT_USER_NAME="don-petry" GITHUB_OUTPUT="$TMP/out" \ + run bash "$DB_DIR/open-pr.sh" + [ "$status" -eq 0 ] + [ "$(grep -c 'git/blobs -X POST' "$GH_LOG")" -eq 3 ] + grep -q '"base_tree":"tree222"' "$GH_LOG.tree" + grep -q '"path":"ideas/widget-lint/brief.md"' "$GH_LOG.tree" + grep -q '"parents":\["base000"\]' "$GH_LOG.commit" + grep -q '"author":{"name":"don-petry","email":"don-petry@users.noreply.github.com"}' "$GH_LOG.commit" + grep -qF 'api repos/acme/widgets/git/refs -X POST -f ref=refs/heads/incubate/widget-lint -f sha=commit555' "$GH_LOG" + grep -q 'pr create --repo acme/widgets --base main --head incubate/widget-lint' "$GH_LOG" + grep -q -- '--label decision-brief' "$GH_LOG" + grep -q 'addDiscussionComment' "$GH_LOG" + grep -q 'pr_url=https://github.com/acme/widgets/pull/10' "$TMP/out" + ! grep -qF '.github-private' "$GH_LOG" || false +} + +@test "open-pr (live, branch + PR exist): appends to the branch, comments on the PR, opens no second PR" { + pkg="$(copy_good)" + mock_gh_live + BRANCH_EXISTS=1 PR_EXISTS=1 REPO="acme/widgets" SLUG="widget-lint" PACKAGE_DIR="$pkg" DISCUSSION_NUMBER=413 \ + GIT_USER_NAME=t run bash "$DB_DIR/open-pr.sh" + [ "$status" -eq 0 ] + grep -q '"parents":\["head111"\]' "$GH_LOG.commit" + grep -qF 'api repos/acme/widgets/git/refs/heads/incubate/widget-lint -X PATCH -f sha=commit555' "$GH_LOG" + ! grep -q 'pr create' "$GH_LOG" || false + grep -q 'pr comment https://github.com/acme/widgets/pull/9' "$GH_LOG" + [[ "$output" == *"refreshed existing PR"* ]] +} + +@test "open-pr: the commit author defaults to the business-analyst persona's runtime account" { + pkg="$(copy_good)" + mock_gh_live + REPO="acme/widgets" SLUG="widget-lint" PACKAGE_DIR="$pkg" DISCUSSION_NUMBER=413 \ + run bash "$DB_DIR/open-pr.sh" + [ "$status" -eq 0 ] + expected="$(bash "$ROOT/scripts/lib/resolve-persona-identity.sh" business-analyst "$ROOT/personas" account)" + grep -q "\"author\":{\"name\":\"${expected}\"" "$GH_LOG.commit" +} + +# ── ripe-from-queue ─────────────────────────────────────────────────────────── + +@test "ripe-from-queue helpers: only the Ripe section is read; numbers are distinct and in order" { + source "$DB_DIR/ripe-from-queue.sh" + run bash -c "source '$DB_DIR/ripe-from-queue.sh'; dbq_ripe_section < '$FIX/queue-body.md' | dbq_discussion_numbers | paste -sd, -" + [ "$output" = "413,44" ] +} + +# gh stub for the sweep: queue issue, per-discussion GraphQL, contents/PR probes. +mock_gh_sweep() { + mock_gh ' +case "$*" in + *"issue list --repo acme/widgets --label idea-triage"*) jq -Rs "{title:\"🧭 Idea Promotion Queue\", body:.}" < "$QUEUE_FILE" | jq -r ".body" ;; + *graphql*) + case "$*" in + *"num=413"*) echo "{\"data\":{\"repository\":{\"discussion\":{\"title\":\"💡 WidgetLint — a lint\",\"category\":{\"name\":\"Ideas\"},\"labels\":{\"nodes\":[${LABELS_413:-}]}}}}}" ;; + *"num=44"*) echo "{\"data\":{\"repository\":{\"discussion\":{\"title\":\"DemandRadar\",\"category\":{\"name\":\"Ideas\"},\"labels\":{\"nodes\":[]}}}}}" ;; + *) echo "{}" ;; + esac ;; + *"contents/ideas/widget-lint/brief.md"*) if [ "${BRIEF_EXISTS:-0}" = 1 ]; then echo sha; else exit 1; fi ;; + *"contents/"*) exit 1 ;; + *"pr list"*) : ;; + *) : ;; +esac +exit 0' +} + +@test "ripe-from-queue: dispatches one central brief run per Ripe idea with target_repo and dry_run=false" { + export QUEUE_FILE="$FIX/queue-body.md" + mock_gh_sweep + REPO="acme/widgets" CENTRAL_REPO="combsmith/.github-private" run bash "$DB_DIR/ripe-from-queue.sh" + [ "$status" -eq 0 ] + grep -qF -- "workflow run decision-brief.yml --repo combsmith/.github-private -f discussion=413 -f target_repo=acme/widgets -f dry_run=false" "$GH_LOG" + grep -qF -- "-f discussion=44 -f target_repo=acme/widgets" "$GH_LOG" + ! grep -qF -- "-f discussion=500" "$GH_LOG" || false + [[ "$output" == *"dispatched=2 skipped=0"* ]] +} + +@test "ripe-from-queue: skips an idea that already has a brief, one already idea:approved, and honours the cap" { + export QUEUE_FILE="$FIX/queue-body.md" + mock_gh_sweep + BRIEF_EXISTS=1 REPO="acme/widgets" run bash "$DB_DIR/ripe-from-queue.sh" + [ "$status" -eq 0 ] + ! grep -qF -- "-f discussion=413" "$GH_LOG" || false + grep -qF -- "-f discussion=44" "$GH_LOG" + : >"$GH_LOG" + LABELS_413='{"name":"idea:approved"}' REPO="acme/widgets" run bash "$DB_DIR/ripe-from-queue.sh" + [[ "$output" == *"skip #413: already idea:approved"* ]] + ! grep -qF -- "-f discussion=413" "$GH_LOG" || false + : >"$GH_LOG" + MAX_BRIEFS=1 REPO="acme/widgets" run bash "$DB_DIR/ripe-from-queue.sh" + [ "$(grep -c 'workflow run' "$GH_LOG")" -eq 1 ] + [[ "$output" == *"cap reached"* ]] +} + +@test "ripe-from-queue: DRY_RUN logs the intended dispatches; no queue issue is a clean no-op" { + export QUEUE_FILE="$FIX/queue-body.md" + mock_gh_sweep + DRY_RUN=1 DRY_RUN_LOG="$TMP/dry.jsonl" REPO="acme/widgets" run bash "$DB_DIR/ripe-from-queue.sh" + [ "$status" -eq 0 ] + ! grep -q 'workflow run' "$GH_LOG" || false + [ "$(jq -r 'select(.discussion==413) | .slug' "$TMP/dry.jsonl")" = "widget-lint" ] + mock_gh 'exit 0' + REPO="acme/widgets" run bash "$DB_DIR/ripe-from-queue.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"no open Idea Promotion Queue"* ]] +} + +# ── gather-context ──────────────────────────────────────────────────────────── + +mock_gh_gather() { + mock_gh ' +case "$*" in + *graphql*) echo "{\"data\":{\"repository\":{\"discussion\":{\"id\":\"D_target\",\"title\":\"💡 WidgetLint — a lint for manifests\",\"body\":\"see #12\",\"url\":\"https://github.com/acme/widgets/discussions/413\",\"createdAt\":\"t\",\"updatedAt\":\"t\",\"author\":{\"login\":\"don\"},\"category\":{\"name\":\"Ideas\",\"slug\":\"ideas\"},\"labels\":{\"nodes\":[{\"name\":\"needs-brief\"}]},\"comments\":{\"nodes\":[{\"author\":{\"login\":\"bot\"},\"body\":\"<!-- feature-ideation:enhanced -->\\n## Enhancement\",\"createdAt\":\"t\",\"url\":\"u\"}]}}}}}" ;; + *"issue list --repo acme/widgets --label idea-triage"*) echo "" ;; + *"contents/ideas/_TEMPLATE/brief.md"*) if [ "${NO_TEMPLATE:-0}" = 1 ]; then exit 1; fi; base64 < "'"$FIX"'/template/brief.md" | tr -d "\n" ;; + *"contents/ideas/_TEMPLATE/market-research.md"*) base64 < "'"$FIX"'/template/market-research.md" | tr -d "\n" ;; + *"contents/ideas/_TEMPLATE/ost.md"*) base64 < "'"$FIX"'/template/ost.md" | tr -d "\n" ;; + *"contents/ideas/package-spec.json"*) printf "{\"required_artifacts\":[]}" | base64 | tr -d "\n" ;; + *"contents/ideas/widget-lint"*) exit 1 ;; + "api repos/acme/widgets/issues/12 --jq "*) echo "{\"number\":12,\"title\":\"prereq\",\"state\":\"open\",\"url\":\"u\"}" ;; + *) echo "{}" ;; +esac +exit 0' +} + +@test "gather-context: reads the TARGET repo only, derives the slug, finds the enhancement comment and the template" { + mock_gh_gather + CTX="$TMP/ctx.json" + GITHUB_ENV="$TMP/env" REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$CTX" \ + run bash "$DB_DIR/gather-context.sh" + [ "$status" -eq 0 ] + grep -qF -- "owner=acme" "$GH_LOG" + grep -qF -- "name=widgets" "$GH_LOG" + ! grep -qF -- ".github-private" "$GH_LOG" || false + [ "$(jq -r '.repo' "$CTX")" = "acme/widgets" ] + [ "$(jq -r '.slug' "$CTX")" = "widget-lint" ] + [ "$(jq -r '.package_dir' "$CTX")" = "ideas/widget-lint" ] + [ "$(jq -r '.discussion.number' "$CTX")" = "413" ] + [ "$(jq -r '.enhancement.author' "$CTX")" = "bot" ] + jq -e '.template["brief.md"] | contains("Decision Brief")' "$CTX" >/dev/null + jq -e '.package_spec.required_artifacts' "$CTX" >/dev/null + [ "$(jq -r '.referenced_issues[0].number' "$CTX")" = "12" ] + grep -q '^DISCUSSION_NODE_ID=D_target$' "$TMP/env" + grep -q '^SLUG=widget-lint$' "$TMP/env" +} + +@test "gather-context: fails loud when the target has no ideas/_TEMPLATE/brief.md" { + NO_TEMPLATE=1 mock_gh_gather + export NO_TEMPLATE=1 + REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"has no ideas/_TEMPLATE/brief.md"* ]] +} + +@test "gather-context: an explicit SLUG override wins; an invalid one is rejected" { + mock_gh_gather + SLUG="custom-name" REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" \ + run bash "$DB_DIR/gather-context.sh" + [ "$status" -eq 0 ] + [ "$(jq -r '.slug' "$TMP/ctx.json")" = "custom-name" ] + SLUG="Not Valid" REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx2.json" \ + run bash "$DB_DIR/gather-context.sh" + [ "$status" -ne 0 ] +} diff --git a/tests/test_decision_brief_workflow.bats b/tests/test_decision_brief_workflow.bats new file mode 100644 index 000000000..75c9e6ef2 --- /dev/null +++ b/tests/test_decision_brief_workflow.bats @@ -0,0 +1,196 @@ +#!/usr/bin/env bats +# Invariants of the decision-brief stage's three workflow files +# (combsmith/incubator#3): the central .github/workflows/decision-brief.yml, +# the hosted fleet-dispatch reusable decision-brief-reusable.yml, and the thin +# caller stub standards/workflows/decision-brief.yml. Mirrors +# tests/test_feature_ideation.bats: structural greps, no network. +# +# Run with: bats tests/test_decision_brief_workflow.bats + +ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" +CENTRAL="$ROOT/.github/workflows/decision-brief.yml" +REUSABLE="$ROOT/.github/workflows/decision-brief-reusable.yml" +STUB="$ROOT/standards/workflows/decision-brief.yml" +REUSABLE_REF="combsmith/.github-private/.github/workflows/decision-brief-reusable.yml" +CHANNEL="decision-brief/v1-stable" + +setup() { cd "$ROOT"; } + +# ── the central workflow ────────────────────────────────────────────────────── + +@test "central: exists, is org-private automation (claude-code-action pinned to a SHA), and resolves a model FAMILY" { + [ -f "$CENTRAL" ] + grep -qE 'uses: anthropics/claude-code-action@[0-9a-f]{40}' "$CENTRAL" + grep -q 'source scripts/lib/engine-models.sh' "$CENTRAL" + grep -q 'ai_model_for_family' "$CENTRAL" + # No concrete model id anywhere in the file (#1979). + ! grep -qE 'claude-(opus|sonnet|haiku)-[0-9]' "$CENTRAL" || false +} + +@test "central: the needs-brief label fires a redispatch bridge that runs only on the discussion event" { + grep -qE '^[[:space:]]+redispatch:' "$CENTRAL" + grep -qE "github\.event_name[[:space:]]*==[[:space:]]*'discussion'[[:space:]]*&&[[:space:]]*github\.event\.label\.name[[:space:]]*==[[:space:]]*'needs-brief'" "$CENTRAL" + grep -q 'run: bash scripts/decision-brief/redispatch.sh' "$CENTRAL" + # The bridge needs a PAT — GITHUB_TOKEN never starts a workflow_dispatch run. + grep -q 'secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS' "$CENTRAL" +} + +@test "central: the model job runs only under workflow_dispatch, never inline on the discussion event" { + # brief job: paused-first `if:` then the workflow_dispatch filter (#2249 shape). + grep -qE "if: vars\.AGENTS_PAUSED != 'true' && github\.event_name == 'workflow_dispatch' && inputs\.from_queue != true" "$CENTRAL" + # The notice job is the exact complement. + grep -qE "^[[:space:]]+fleet-paused:" "$CENTRAL" + grep -qE "vars\.AGENTS_PAUSED == 'true' &&" "$CENTRAL" +} + +@test "central: the provenance gate is its own step AFTER the model step and BEFORE the PR step" { + action_line="$(grep -n 'uses: anthropics/claude-code-action' "$CENTRAL" | cut -d: -f1)" + gate_line="$(grep -n 'name: Provenance + structure gate' "$CENTRAL" | cut -d: -f1)" + pr_line="$(grep -n 'run: bash scripts/decision-brief/open-pr.sh' "$CENTRAL" | cut -d: -f1)" + [ -n "$action_line" ] && [ -n "$gate_line" ] && [ -n "$pr_line" ] + [ "$action_line" -lt "$gate_line" ] + [ "$gate_line" -lt "$pr_line" ] + # The gate step runs the validator the model is also told to run. + grep -qc 'scripts/decision-brief/validate-package.sh' "$CENTRAL" + [ "$(grep -c 'scripts/decision-brief/validate-package.sh' "$CENTRAL")" -ge 2 ] +} + +@test "central: Mary is told the provenance rule and the overlay, and may research the web" { + grep -q 'cat prompts/incubation/shared.md' "$CENTRAL" + grep -q 'cat prompts/incubation/decision-brief-stage.md' "$CENTRAL" + grep -q 'unknown — needs discovery' "$CENTRAL" + grep -q 'No fabrication' "$CENTRAL" + grep -qE -- '--allowedTools .*WebSearch' "$CENTRAL" +} + +@test "central: never applies idea:approved — the human gate stays human" { + # The label is named only in prose/the prompt, never in a label mutation. + ! grep -E 'gh (label|discussion).*idea:approved|--add-label.*idea:approved|addLabelsToLabelable' "$CENTRAL" | grep -vq '#' || false + grep -q 'NEVER applies' "$CENTRAL" +} + +@test "central: target_repo parameterizes reads + writes; self path uses GITHUB_TOKEN, cross-repo the PAT" { + grep -q "REPO: \${{ github.event.inputs.target_repo || github.repository }}" "$CENTRAL" + grep -qE "TARGET_GH_TOKEN: .*github\.event\.inputs\.target_repo == '' .*secrets\.GITHUB_TOKEN .*secrets\.GH_PAT_DON_PETRY" "$CENTRAL" + grep -q 'GH_TOKEN: ${{ env.TARGET_GH_TOKEN }}' "$CENTRAL" + grep -q 'github_token: ${{ env.TARGET_GH_TOKEN }}' "$CENTRAL" +} + +@test "central: one lane per (target repo, discussion); sweep takes the queue lane; never cancels" { + grep -q "group: decision-brief-\${{ github.event.inputs.target_repo || github.repository }}-\${{ github.event.inputs.discussion || github.event.discussion.number || 'queue' }}" "$CENTRAL" + grep -q 'cancel-in-progress: false' "$CENTRAL" +} + +@test "central: the queue sweep dispatches per-idea runs with no model call of its own" { + grep -qE '^[[:space:]]+sweep:' "$CENTRAL" + grep -q 'bash scripts/decision-brief/ripe-from-queue.sh' "$CENTRAL" + # Between `sweep:` and `brief:` there is no model credential or action. + ! sed -n '/^ sweep:/,/^ brief:/p' "$CENTRAL" | grep -qE 'CLAUDE_CODE_OAUTH_TOKEN|claude-code-action' || false +} + +@test "central: a dry run uploads the drafted package and the intended-PR log" { + grep -q 'name: decision-brief-${{ env.SLUG || ' "$CENTRAL" + grep -q 'dry-run.jsonl' "$CENTRAL" + grep -qE 'default: true' "$CENTRAL" # dry_run defaults to true on manual dispatch +} + +# ── the hosted reusable (fleet dispatch bridge) ─────────────────────────────── + +@test "reusable: workflow_call with central_repo defaulting to this repo; no model credential" { + [ -f "$REUSABLE" ] + grep -q 'workflow_call:' "$REUSABLE" + grep -q 'default: "combsmith/.github-private"' "$REUSABLE" + # Outside comments: no model credential, no model action. + ! grep -vE '^[[:space:]]*#' "$REUSABLE" | grep -q 'CLAUDE_CODE_OAUTH_TOKEN' || false + ! grep -vE '^[[:space:]]*#' "$REUSABLE" | grep -q 'claude-code-action' || false +} + +@test "reusable: fires on the needs-brief label, schedule and manual dispatch; label path needs a trusted actor" { + grep -q "github.event.action == 'labeled' && github.event.label.name == inputs.label" "$REUSABLE" + grep -q "github.event_name == 'schedule'" "$REUSABLE" + grep -q 'collaborators/${ACTOR}/permission' "$REUSABLE" + grep -q "if: steps.trust.outputs.trusted == 'true'" "$REUSABLE" +} + +@test "reusable: dispatches the central workflow with target_repo=caller, dry_run=false; sweep uses from_queue=true" { + grep -q 'gh workflow run decision-brief.yml' "$REUSABLE" + grep -qE -- '-f discussion="\$\{DISCUSSION_NUMBER\}"' "$REUSABLE" + grep -qE -- '-f target_repo="\$\{TARGET_REPO\}"' "$REUSABLE" + grep -q -- '-f dry_run=false' "$REUSABLE" + grep -q -- '-f from_queue=true' "$REUSABLE" + grep -q 'TARGET_REPO: ${{ github.repository }}' "$REUSABLE" + grep -q 'positive integer' "$REUSABLE" +} + +# ── the thin caller stub ────────────────────────────────────────────────────── + +@test "stub: pins the hosted reusable to the decision-brief/v1-stable channel with the S7637 marker" { + [ -f "$STUB" ] + grep -qF "uses: ${REUSABLE_REF}@${CHANNEL}" "$STUB" + grep -q 'NOSONAR(githubactions:S7637)' "$STUB" + grep -q 'secrets: inherit' "$STUB" + grep -qE '^permissions: \{\}' "$STUB" +} + +@test "stub: a thin caller — no steps, no run:, forwards only inputs the reusable declares" { + ! grep -qE '^[[:space:]]+(steps|run):' "$STUB" || false + # Every `with:` key is a declared workflow_call input of the reusable. + declared="$(awk '/^ inputs:/{f=1;next} /^ secrets:/{f=0} f && /^ [a-z_]+:/{sub(/^ /,""); sub(/:.*/,""); print}' "$REUSABLE")" + forwarded="$(awk '/^ with:/{f=1;next} /^ secrets:/{f=0} f && /^ [a-z_]+:/{sub(/^ /,""); sub(/:.*/,""); print}' "$STUB")" + [ -n "$forwarded" ] + while IFS= read -r k; do + grep -qx "$k" <<<"$declared" || { echo "stub forwards undeclared input: $k"; return 1; } + done <<<"$forwarded" +} + +@test "stub: validate-caller-inputs accepts the stub against the reusable (pair mode)" { + run bash "$ROOT/scripts/validate-caller-inputs.sh" --pair "$STUB" "$REUSABLE" + [ "$status" -eq 0 ] +} + +@test "stub: triggers are discussion:labeled + an off-peak weekly schedule + workflow_dispatch; #571-safe inputs" { + grep -qE '^ discussion:' "$STUB" + grep -qE '^ types: \[labeled\]' "$STUB" + cron="$(grep -oE "cron: '[^']+'" "$STUB" | head -1 | sed "s/cron: '//; s/'$//")" + [ -n "$cron" ] + [ "${cron%% *}" != "0" ] + grep -qE '^ workflow_dispatch:' "$STUB" + # The reusable `with:` reads github.event.inputs.*, never the `inputs` context. + grep -q 'discussion: ${{ github.event.inputs.discussion }}' "$STUB" + ! grep -qE '\$\{\{[[:space:]]*inputs\.' "$STUB" || false +} + +@test "stub: adoption notes name the two labels the incubator must create" { + grep -q 'needs-brief' "$STUB" + grep -q 'idea:approved' "$STUB" + grep -q 'ideas/_TEMPLATE' "$STUB" +} + +# ── wiring in this repo ─────────────────────────────────────────────────────── + +@test "wiring: the fleet-pause registry gates the brief job; the §4 table classifies both workflows" { + grep -qE '^decision-brief\.yml job-gate brief ' "$ROOT/scripts/lib/fleet-pause-registry.tsv" + grep -q '`.github/workflows/decision-brief.yml` | 1 |' "$ROOT/docs/agentic-interaction-model.md" + grep -q '`.github/workflows/decision-brief-reusable.yml` | Reusable workflow' "$ROOT/docs/agentic-interaction-model.md" +} + +@test "wiring: lint.yml runs both decision-brief suites; cut-release knows the agent" { + grep -q 'tests/test_decision_brief.bats' "$ROOT/.github/workflows/lint.yml" + grep -q 'tests/test_decision_brief_workflow.bats' "$ROOT/.github/workflows/lint.yml" + source "$ROOT/scripts/cut-release.sh" + run valid_agent "decision-brief" + [ "$status" -eq 0 ] + export GITHUB_REPOSITORY="combsmith/.github-private" + run agent_host_repo "decision-brief" + [ "$output" = "combsmith/.github-private" ] +} + +@test "wiring: the overlay prompt exists and names the four inputs, ICE-by-default and the provenance rule" { + f="$ROOT/prompts/incubation/decision-brief-stage.md" + [ -f "$f" ] + grep -q 'Riskiest assumption' "$f" + grep -qi 'ICE.*by default' "$f" + grep -q 'unknown — needs discovery' "$f" + grep -q 'validate-package.sh' "$f" + [ -f "$ROOT/prompts/incubation/producer-ost.md" ] +} diff --git a/tests/test_fleet_pause_coverage.bats b/tests/test_fleet_pause_coverage.bats index e3d8daeda..12ee2cd1f 100644 --- a/tests/test_fleet_pause_coverage.bats +++ b/tests/test_fleet_pause_coverage.bats @@ -33,6 +33,7 @@ REGISTRY_REL="scripts/lib/fleet-pause-registry.tsv" NEWLY_GATED=( persona-runner-reusable.yml gh-aw-cross-org.yml issue-triage-runner.yml initiative-planner.yml idea-triage.yml + decision-brief.yml dependency-advisory.yml docs-health-check.yml readme-refresh.yml release-notes.yml stale-manager.yml skill-eval-report.yml model-ab.yml dev-lead-retry.yml pr-review-sweep.yml From 0de3bb9e1518744f16678bcac00702bf399ca3dc Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:39:21 -0500 Subject: [PATCH 2/8] fix(decision-brief): address the Codex + CodeRabbit review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model step gets only the read-scoped GITHUB_TOKEN; the org PAT is used for gather-context and open-pr on every path (no contents:write on the job, and the self path no longer needs it). - Forward max_briefs (central input → MAX_BRIEFS; reusable passes it); add a `slug` dispatch input so the "dispatch with an explicit slug" path exists. - Materialise the target package-spec.json and pass --spec to both validator invocations. - validate-package: level-aware section scan (nested headings stay inside), provenance across brief §1–§8 + OST labels + compact units (2.4bn/300k), [^n] footnotes count only when defined with a URL, Cheapest-test cell must be filled, exactly one bold position in the Recommendation. - gather-context: exact #N match for the queue excerpt, read an in-flight incubate/<slug> branch, slug-collision guard. - slug: a short label prefix ("Idea: X") is dropped instead of keeping it. - ripe-from-queue: read only the Idea column of the Ripe table; an API failure fails the sweep loudly. - 12 new regression tests (67 total). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .github/workflows/decision-brief-reusable.yml | 7 +- .github/workflows/decision-brief.yml | 64 ++++--- prompts/incubation/decision-brief-stage.md | 5 +- scripts/decision-brief/gather-context.sh | 44 ++++- scripts/decision-brief/lib/slug.sh | 5 + scripts/decision-brief/ripe-from-queue.sh | 24 ++- scripts/decision-brief/validate-package.sh | 115 +++++++++---- tests/fixtures/decision-brief/queue-body.md | 2 +- tests/test_decision_brief.bats | 158 +++++++++++++++++- tests/test_decision_brief_workflow.bats | 34 +++- 10 files changed, 376 insertions(+), 82 deletions(-) diff --git a/.github/workflows/decision-brief-reusable.yml b/.github/workflows/decision-brief-reusable.yml index b3810e578..b543e5af5 100644 --- a/.github/workflows/decision-brief-reusable.yml +++ b/.github/workflows/decision-brief-reusable.yml @@ -134,10 +134,15 @@ jobs: echo "Dispatched decision-brief.yml (discussion=${DISCUSSION_NUMBER}, target_repo=${TARGET_REPO}, dry_run=false)." else echo "${EVENT} on ${TARGET_REPO} → dispatching central decision-brief SWEEP (Ripe ideas without a brief) in ${CENTRAL_REPO}." + if ! [[ "${MAX_BRIEFS}" =~ ^[0-9]+$ ]]; then + echo "::error::max_briefs must be an integer, got: '${MAX_BRIEFS}'" + exit 1 + fi gh workflow run decision-brief.yml \ --repo "${CENTRAL_REPO}" \ -f from_queue=true \ + -f max_briefs="${MAX_BRIEFS}" \ -f target_repo="${TARGET_REPO}" \ -f dry_run=false - echo "Dispatched decision-brief.yml (from_queue=true, target_repo=${TARGET_REPO}, dry_run=false)." + echo "Dispatched decision-brief.yml (from_queue=true, max_briefs=${MAX_BRIEFS}, target_repo=${TARGET_REPO}, dry_run=false)." fi diff --git a/.github/workflows/decision-brief.yml b/.github/workflows/decision-brief.yml index ba56c7dc1..ddb364e62 100644 --- a/.github/workflows/decision-brief.yml +++ b/.github/workflows/decision-brief.yml @@ -55,6 +55,16 @@ on: required: false default: false type: boolean + max_briefs: + description: 'Sweep cap: how many Ripe ideas one sweep may dispatch briefs for.' + required: false + default: '3' + type: string + slug: + description: 'Explicit ideas/<slug> folder name (kebab-case). Empty => derived from the Discussion title. Use it when the title yields no valid slug or collides with another package.' + required: false + default: '' + type: string dry_run: description: 'Draft and validate the package but log the intended PR to an artifact instead of opening it (sweep mode: log the intended dispatches).' required: false @@ -132,6 +142,7 @@ jobs: REPO: ${{ github.event.inputs.target_repo || github.repository }} CENTRAL_REPO: ${{ github.repository }} DRY_RUN: ${{ inputs.dry_run == true && '1' || '0' }} + MAX_BRIEFS: ${{ github.event.inputs.max_briefs || '3' }} # Reads (queue issue, discussions, contents, PRs) on the TARGET + the # dispatch of this workflow: a PAT is needed for the dispatch regardless # of target (GITHUB_TOKEN never starts a workflow_dispatch run). @@ -168,11 +179,10 @@ jobs: if: vars.AGENTS_PAUSED != 'true' && github.event_name == 'workflow_dispatch' && inputs.from_queue != true runs-on: ubuntu-latest timeout-minutes: 30 + # contents: read is also the ceiling of the token the MODEL step gets + # (read-only); no write permission is granted to this job on purpose. permissions: contents: read # checkout, read prompts/ frameworks/ scripts/ - issues: read # the Idea Promotion Queue excerpt (self path) - pull-requests: write # open the brief PR (self path) - discussions: write # link the PR from the Discussion (self path) id-token: write # claude-code-action OIDC env: # This job only runs on workflow_dispatch (the discussion path re-dispatches @@ -180,20 +190,28 @@ jobs: # REPO is the TARGET repo to brief for; empty target_repo => this repo (self). REPO: ${{ github.event.inputs.target_repo || github.repository }} # Token for every READ of and WRITE to the TARGET repo (gather-context, the - # PR, the Discussion back-link). GITHUB_TOKEN is scoped only to - # .github-private, so it is valid for the self path only; cross-repo runs - # use GH_PAT_DON_PETRY (same pattern as initiative-planner.yml / idea-triage.yml). - TARGET_GH_TOKEN: ${{ (github.event.inputs.target_repo == '' || github.event.inputs.target_repo == github.repository) && secrets.GITHUB_TOKEN || secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} + # PR, the Discussion back-link) — ALWAYS the org PAT, on the self path too. + # GITHUB_TOKEN is deliberately not used for writes: that would force + # contents:write onto this job, and the model step's token inherits the + # job's permissions — so the PAT stays out of the model step (below) and + # GITHUB_TOKEN stays read-only. (A PAT-authored PR also gets CI; a + # GITHUB_TOKEN-authored one does not.) + TARGET_GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} DISCUSSION_NUMBER: ${{ github.event.inputs.discussion }} + SLUG: ${{ github.event.inputs.slug }} DRY_RUN: ${{ inputs.dry_run == true && '1' || '0' }} TOOLING_DIR: ${{ github.workspace }}/scripts/decision-brief steps: - - name: Guard — discussion number present + - name: Guard — discussion number present, PAT present run: | if ! [[ "${DISCUSSION_NUMBER}" =~ ^[1-9][0-9]*$ ]]; then echo "::error::discussion must be a positive integer (got '${DISCUSSION_NUMBER}'). Pass discussion=N, or from_queue=true to sweep the Idea Promotion Queue." exit 1 fi + if [ -z "${TARGET_GH_TOKEN}" ]; then + echo "::error::GH_PAT_DON_PETRY (or legacy GH_PAT_WORKFLOWS) is required — it reads the target Discussion and opens the brief PR." + exit 1 + fi - name: Checkout agent repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -205,6 +223,7 @@ jobs: echo "DRY_RUN_LOG=${{ runner.temp }}/dry-run.jsonl" >> "$GITHUB_ENV" echo "CONTEXT_PATH=${{ runner.temp }}/brief-context.json" >> "$GITHUB_ENV" echo "STATUS_PATH=${{ runner.temp }}/brief-status.json" >> "$GITHUB_ENV" + echo "SPEC_PATH=${{ runner.temp }}/package-spec.json" >> "$GITHUB_ENV" echo "BRIEF_ROOT=${{ runner.temp }}/package" >> "$GITHUB_ENV" mkdir -p "${{ runner.temp }}/package" @@ -245,15 +264,18 @@ jobs: - name: Draft the decision brief (BMAD Analyst) id: mary + # SECURITY: the model reads untrusted text (Discussion bodies, comments, + # web pages) with Bash allowed. It therefore gets ONLY the read-scoped + # GITHUB_TOKEN of this job (contents: read on this repo) — never the org + # PAT that open-pr.sh uses two steps later. Everything grounded is already + # in $CONTEXT_PATH (gather-context ran with the PAT); the target repos + # are public, so unauthenticated reads still work for extra grounding. env: - # Mary needs no GitHub write here: the package is written to - # $BRIEF_ROOT and landed by open-pr.sh in the next steps. The read token - # lets her `gh` into the target for extra grounding (issues, files). - GH_TOKEN: ${{ env.TARGET_GH_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} uses: anthropics/claude-code-action@6c690188da61cf16ee7debb28383ba67f02abb4c # v1.0.242 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - github_token: ${{ env.TARGET_GH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} claude_args: | --model ${{ steps.model.outputs.model }} --allowedTools Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch @@ -301,10 +323,11 @@ jobs: `status: final`; every section of the template filled, no `<placeholders>`; §5 shows the RICE or ICE arithmetic with Confidence stated and tied to the evidence; §6 table has ≥1 full - row (most dangerous assumption first, cheapest test named); §7 is a - position — **go / no-go / park** — with what a POC must prove and - what would make the decider say no, and names the human gate - (`idea:approved`). + row (most dangerous assumption first, cheapest test named); §7 states + exactly one bold position — `**go**`, `**no-go**` or `**park**` + (or `**Recommendation: <position>**`) — with what a POC must + prove and what would make the decider say no, and names the + human gate (`idea:approved`). 2. `$BRIEF_ROOT/ideas/$SLUG/market-research.md` — `artifact: market-research`; the detail behind brief §3; ends with a `## Sources` section listing every URL you cited (or the sentence @@ -321,7 +344,8 @@ jobs: ```bash bash scripts/decision-brief/validate-package.sh "$BRIEF_ROOT/ideas/$SLUG" \ - --slug "$SLUG" --discussion "$DISCUSSION_NUMBER" + --slug "$SLUG" --discussion "$DISCUSSION_NUMBER" \ + ${SPEC_PATH:+--spec "$SPEC_PATH"} ``` Fix every FAIL line and re-run until it passes. The workflow runs the @@ -343,8 +367,10 @@ jobs: # The deterministic half of the guard: re-run the validator the model # already ran. A package that fails here stays an artifact, never a PR. run: | + spec_args=() + [ -s "$SPEC_PATH" ] && spec_args=(--spec "$SPEC_PATH") bash scripts/decision-brief/validate-package.sh "$BRIEF_ROOT/ideas/$SLUG" \ - --slug "$SLUG" --discussion "$DISCUSSION_NUMBER" + --slug "$SLUG" --discussion "$DISCUSSION_NUMBER" "${spec_args[@]}" - name: Open (or refresh) the brief PR in the target repo id: pr diff --git a/prompts/incubation/decision-brief-stage.md b/prompts/incubation/decision-brief-stage.md index 8829e777a..590d54565 100644 --- a/prompts/incubation/decision-brief-stage.md +++ b/prompts/incubation/decision-brief-stage.md @@ -110,8 +110,9 @@ known"). Adaptations, in order of precedence: row (`#`, Assumption, If wrong…, Cheapest test, Result = `—`). The cheapest test is a concrete artifact (five problem interviews, a fake door, a 1-day spike), not "more research". -- **§7 Recommendation (go / no-go)** — one bold position: **go**, **no-go** or - **park** (buy evidence first), the reasoning, *what a POC would need to +- **§7 Recommendation (go / no-go)** — exactly one bold position, written as + `**go**`, `**no-go**` or `**park**` (or `**Recommendation: park**`); park means + buy evidence first. Then the reasoning, *what a POC would need to prove* to graduate, and *what would make the decider say no*. State that the decision is the human's, made by applying `idea:approved`. - **§8 Open questions** — everything unresolved that the decider should know is diff --git a/scripts/decision-brief/gather-context.sh b/scripts/decision-brief/gather-context.sh index 8143b1d69..1e933c67f 100644 --- a/scripts/decision-brief/gather-context.sh +++ b/scripts/decision-brief/gather-context.sh @@ -23,9 +23,11 @@ # repo controls its own brief shape # package_spec ideas/package-spec.json (the gate's required sections), or # null when the target has none -# existing_package files already under ideas/<slug>/ on the default branch -# (with brief.md's content, so a re-brief updates rather -# than overwrites blind) +# existing_package files already under ideas/<slug>/ — on the open +# incubate/<slug> branch when one exists, else the default +# branch (with brief.md's content, so a re-brief updates +# rather than overwrites blind; a brief for ANOTHER +# discussion under the same slug is a collision → error) # referenced_issues issues / PRs the Discussion thread links by #N # # Emits: @@ -34,7 +36,9 @@ # # Env: REPO, DISCUSSION_NUMBER, CONTEXT_PATH (required); GH_TOKEN; # SLUG (optional override — otherwise derived from the title); -# TEMPLATE_DIR (default ideas/_TEMPLATE); IDEAS_DIR (default ideas). +# SPEC_PATH (optional: where to write the fetched package-spec.json); +# TEMPLATE_DIR (default ideas/_TEMPLATE); IDEAS_DIR (default ideas); +# BRANCH_PREFIX (default incubate). set -euo pipefail REPO="${REPO:?REPO required}" @@ -42,6 +46,7 @@ DISCUSSION_NUMBER="${DISCUSSION_NUMBER:?DISCUSSION_NUMBER required}" CONTEXT_PATH="${CONTEXT_PATH:?CONTEXT_PATH required}" TEMPLATE_DIR="${TEMPLATE_DIR:-ideas/_TEMPLATE}" IDEAS_DIR="${IDEAS_DIR:-ideas}" +BRANCH_PREFIX="${BRANCH_PREFIX:-incubate}" [[ "$DISCUSSION_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "::error::DISCUSSION_NUMBER must be a positive integer, got: '$DISCUSSION_NUMBER'" >&2 @@ -99,9 +104,11 @@ queue_excerpt='null' if queue_body="$(gh issue list --repo "$REPO" --label idea-triage --state open \ --json number,title,body --jq 'map(select(.title | test("Idea Promotion Queue"))) | .[0] // empty' 2>/dev/null)" \ && [[ -n $queue_body ]]; then - queue_excerpt="$(printf '%s' "$queue_body" | jq -c --arg n "#${DISCUSSION_NUMBER}" ' + # Exact token match: "#4" must not pick up the rows for #44 or #413. + queue_excerpt="$(printf '%s' "$queue_body" | jq -c --arg n "$DISCUSSION_NUMBER" ' {number, title, - lines: ((.body // "") | split("\n") | map(select(contains($n))))}')" + lines: ((.body // "") | split("\n") + | map(select(test("(^|[^0-9#])#" + $n + "([^0-9]|$)|/discussions/" + $n + "([^0-9]|$)"))))}')" fi # ── the target repo's template + package spec (default branch) ─────────────── @@ -119,14 +126,32 @@ spec_raw="$(_fetch_file "${IDEAS_DIR}/package-spec.json")" package_spec='null' if [[ -n $spec_raw ]] && printf '%s' "$spec_raw" | jq -e . >/dev/null 2>&1; then package_spec="$(printf '%s' "$spec_raw" | jq -c .)" + # Materialise it so validate-package.sh can enforce the target's own + # required_sections (--spec), not just the stage's built-in list. + if [[ -n "${SPEC_PATH:-}" ]]; then printf '%s\n' "$package_spec" >"$SPEC_PATH"; fi fi # ── anything already under ideas/<slug>/ (re-brief / update mode) ──────────── -existing_files="$(gh api "repos/${REPO}/contents/${IDEAS_DIR}/${slug}" --jq '[.[] | select(.type=="file") | .name]' 2>/dev/null || echo '[]')" +# A brief PR may already be open from incubate/<slug>; a re-dispatch must see +# THAT branch's files (reviewed / hand-edited in flight), not the default +# branch's, because open-pr.sh appends to the same branch. +pkg_ref="" +if gh api "repos/${REPO}/git/ref/heads/${BRANCH_PREFIX}/${slug}" --jq '.object.sha' >/dev/null 2>&1; then + pkg_ref="${BRANCH_PREFIX}/${slug}" +fi +_ref_q=""; [[ -n $pkg_ref ]] && _ref_q="?ref=${pkg_ref}" +existing_files="$(gh api "repos/${REPO}/contents/${IDEAS_DIR}/${slug}${_ref_q}" --jq '[.[] | select(.type=="file") | .name]' 2>/dev/null || echo '[]')" printf '%s' "$existing_files" | jq -e 'type=="array"' >/dev/null 2>&1 || existing_files='[]' existing_brief="" if printf '%s' "$existing_files" | jq -e 'index("brief.md") != null' >/dev/null; then - existing_brief="$(_fetch_file "${IDEAS_DIR}/${slug}/brief.md")" + existing_brief="$(gh api "repos/${REPO}/contents/${IDEAS_DIR}/${slug}/brief.md${_ref_q}" --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true)" + # Collision guard: the same slug derived from a different idea's title would + # overwrite another package. A brief that does not link THIS discussion is + # someone else's — stop and ask for an explicit SLUG. + if [[ -n $existing_brief ]] && ! grep -qE "/discussions/${DISCUSSION_NUMBER}([^0-9]|$)" <<<"$existing_brief"; then + echo "::error::${IDEAS_DIR}/${slug}/brief.md (ref ${pkg_ref:-default}) belongs to another Discussion — slug collision; dispatch again with an explicit slug" >&2 + exit 1 + fi fi # ── issues / PRs the thread references (#N) ─────────────────────────────────── @@ -156,6 +181,7 @@ jq -n \ --argjson spec "$package_spec" \ --argjson existing "$existing_files" \ --arg existing_brief "$existing_brief" \ + --arg pkg_ref "$pkg_ref" \ --argjson refs "$ref_json" \ '{ repo: $repo, @@ -171,7 +197,7 @@ jq -n \ queue_excerpt: $queue, template: {"brief.md": $tb, "market-research.md": $tm, "ost.md": $to}, package_spec: $spec, - existing_package: {files: $existing, "brief.md": (if $existing_brief == "" then null else $existing_brief end)}, + existing_package: {ref: (if $pkg_ref == "" then null else $pkg_ref end), files: $existing, "brief.md": (if $existing_brief == "" then null else $existing_brief end)}, referenced_issues: $refs }' >"$CONTEXT_PATH" diff --git a/scripts/decision-brief/lib/slug.sh b/scripts/decision-brief/lib/slug.sh index 287480543..2d890d5de 100644 --- a/scripts/decision-brief/lib/slug.sh +++ b/scripts/decision-brief/lib/slug.sh @@ -18,6 +18,11 @@ db_slug_from_title() { local title="${1:-}" s s="$title" + # "Idea: Name" / "RFC: Name" — a short label prefix, not the title: drop it + # (otherwise every "Idea: …" would collapse to the slug "idea"). + if [[ "$s" =~ ^[[:alpha:][:space:]]{1,15}:[[:space:]]*(.+)$ ]]; then + s="${BASH_REMATCH[1]}" + fi # Keep only the part before an em-dash / en-dash / " - " / ":" subtitle separator. s="${s%%—*}" s="${s%%–*}" diff --git a/scripts/decision-brief/ripe-from-queue.sh b/scripts/decision-brief/ripe-from-queue.sh index 822c5b24f..22b8c3aaf 100644 --- a/scripts/decision-brief/ripe-from-queue.sh +++ b/scripts/decision-brief/ripe-from-queue.sh @@ -48,9 +48,17 @@ dbq_ripe_section() { } # dbq_discussion_numbers <text-on-stdin> — every distinct #N (and -# /discussions/N) reference, in first-seen order, one per line. +# /discussions/N) reference, in first-seen order, one per line. When the text +# holds a Markdown table, ONLY the first (Idea) cell of each data row is read: +# an issue or prerequisite mentioned in a "Why now" / promotion-note cell is +# not a Ripe idea and must not get a paid brief. Prose (no table) is read whole. dbq_discussion_numbers() { - grep -oE '(#|/discussions/)[0-9]+' | grep -oE '[0-9]+' | awk '!seen[$0]++' + awk ' + /^\|[[:space:]]*-+/ { sep = 1; next } + /^\|/ { if (!sep) next; n = split($0, c, "|"); if (n > 2) print c[2]; table = 1; next } + { other[++o] = $0 } + END { if (!table) for (i = 1; i <= o; i++) print other[i] } + ' | grep -oE '(#|/discussions/)[0-9]+' | grep -oE '[0-9]+' | awk '!seen[$0]++' } dbq_main() { @@ -90,14 +98,20 @@ dbq_main() { echo "::notice::cap reached (MAX_BRIEFS=${MAX_BRIEFS}); #${n} and later wait for the next sweep" break fi - disc="$(gh api graphql -f query=' + # An API/auth/rate-limit failure here is NOT "no such idea": fail the sweep + # loudly rather than silently leaving a Ripe idea unbriefed (AGENTS.md, + # "a failure read as nothing to do"). + if ! disc="$(gh api graphql -f query=' query($owner:String!,$name:String!,$num:Int!){ repository(owner:$owner,name:$name){ discussion(number:$num){ title category{ name } labels(first:20){ nodes{ name } } } } - }' -F owner="$owner" -F name="$name" -F num="$n" 2>/dev/null || true)" + }' -F owner="$owner" -F name="$name" -F num="$n")"; then + echo "::error::could not read discussion #${n} in ${REPO} (API error, not a missing idea) — sweep aborted" >&2 + return 1 + fi title="$(printf '%s' "$disc" | jq -r '.data.repository.discussion.title // empty')" - if [[ -z "$title" ]]; then echo "skip #${n}: not a readable Discussion"; skipped=$((skipped+1)); continue; fi + if [[ -z "$title" ]]; then echo "skip #${n}: not a Discussion (an issue/PR number in the Ripe table)"; skipped=$((skipped+1)); continue; fi category="$(printf '%s' "$disc" | jq -r '.data.repository.discussion.category.name // ""')" labels="$(printf '%s' "$disc" | jq -r '[.data.repository.discussion.labels.nodes[].name] | join(",")')" if [[ "$category" != "Ideas" ]]; then echo "skip #${n}: category '${category}'"; skipped=$((skipped+1)); continue; fi diff --git a/scripts/decision-brief/validate-package.sh b/scripts/decision-brief/validate-package.sh index 20ff22830..c74d74a3e 100644 --- a/scripts/decision-brief/validate-package.sh +++ b/scripts/decision-brief/validate-package.sh @@ -20,9 +20,11 @@ # (Confidence must be stated — the BA advisory ties it to how # strong the cited evidence is) # assumptions the riskiest-assumptions table has a "Cheapest test" column -# and at least one fully-filled row -# decision the Recommendation section takes a position (go / no-go / -# park) and names the human gate (`idea:approved`) +# and at least one fully-filled row whose Cheapest-test cell is +# not empty +# decision the Recommendation section states exactly one bold position +# (**go** / **no-go** / **park**) and names the human gate +# (`idea:approved`) # provenance in the Problem / Target user / Market sections of brief.md and # every section of market-research.md except Sources, any line # that states a quantified market claim (a number with %, $, @@ -62,15 +64,20 @@ vp_has_section() { } # vp_section_body <file> <needle> — the lines of the first section whose header -# contains <needle>, up to (not including) the next header of any level. The -# header line itself is excluded. Fenced code blocks are passed through. +# contains <needle>, up to (not including) the next header of the SAME or a +# HIGHER level. Nested sub-headings (### under a ##) stay inside, so a claim +# under "### Market size" inside "## Market" is still scanned. The header line +# itself is excluded. Fenced code blocks are passed through. vp_section_body() { - local file="$1" needle="$2" + local file="$1" awk -v needle="$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]')" ' /^#{1,6}[[:space:]]/ { - if (insec) exit - h=tolower($0) - if (index(h, needle) > 0) { insec=1; next } + lvl = match($0, /^#+/) ? RLENGTH : 0 + if (insec && lvl <= seclvl) exit + if (!insec) { + h = tolower($0) + if (index(h, needle) > 0) { insec = 1; seclvl = lvl; next } + } } insec { print } ' "$file" @@ -96,35 +103,60 @@ vp_placeholders() { } # A quantified market claim: a number next to a unit that reads as market size, -# price, share, adoption or demand. -VP_QUANT_RE='([0-9][0-9,.]*[[:space:]]*(%|percent|million|billion|trillion|thousand|[kKMB]\+?([^A-Za-z]|$)|users|customers|companies|orgs|organizations|organisations|downloads|stars|installs|teams|developers|repos|subscribers|seats|businesses|nonprofits|non-profits|SMBs)|\$[[:space:]]*[0-9]|€[[:space:]]*[0-9]|£[[:space:]]*[0-9]|[0-9][0-9,.]*[[:space:]]*(USD|EUR|GBP)([^A-Za-z]|$))' -# What makes such a line honest: a source, the explicit unknown, or a tagged assumption. -VP_CITED_RE='(https?://|\[\^[0-9]+\]|unknown — needs discovery|unknown - needs discovery|\[ASSUMPTION|\(assumption)' +# price, share, adoption or demand (compact forms such as 2.4bn / 300k / 5M too). +VP_QUANT_RE='([0-9][0-9,.]*[[:space:]]*(%|percent|million|billion|trillion|thousand|([kKMB]\+?|bn|mn|tn)([^A-Za-z]|$)|users|customers|companies|orgs|organizations|organisations|downloads|stars|installs|teams|developers|repos|subscribers|seats|businesses|nonprofits|non-profits|SMBs)|\$[[:space:]]*[0-9]|€[[:space:]]*[0-9]|£[[:space:]]*[0-9]|[0-9][0-9,.]*[[:space:]]*(USD|EUR|GBP)([^A-Za-z]|$))' +# What makes such a line honest on its own: a URL, the explicit unknown, or a +# tagged assumption. A [^n] footnote counts only when the file defines it with a +# URL (see vp_unsourced_claims). +VP_CITED_RE='(https?://|unknown — needs discovery|unknown - needs discovery|\[ASSUMPTION|\(assumption)' -# vp_unsourced_claims <text-on-stdin> — print each line that quantifies a -# market claim without a citation / unknown / assumption tag. Table separator -# rows and headers are skipped; fenced code is skipped. +# vp_unsourced_claims [defs-file] <text-on-stdin> — print each line that +# quantifies a market claim without a citation / unknown / assumption tag. +# Table separator rows and headers are skipped; fenced code is skipped. A line +# whose only citation is a [^n] footnote is accepted only when <defs-file> +# (the artifact itself) carries a `[^n]: … http(s)://…` definition — a dangling +# footnote is not a source. vp_unsourced_claims() { - awk ' + local defs="${1:-}" line ref ok + while IFS= read -r line; do + ok=0 + if printf '%s\n' "$line" | grep -qiE -- "$VP_CITED_RE"; then ok=1 + else + for ref in $(printf '%s\n' "$line" | grep -oE '\[\^[0-9]+\]' | tr -d '[]^' | sort -u); do + if [[ -n "$defs" && -f "$defs" ]] && grep -qE "^\[\^${ref}\]:.*https?://" "$defs"; then ok=1; break; fi + done + fi + [[ "$ok" == 1 ]] || printf '%s\n' "$line" + done < <(awk ' /^```/ { fence = !fence; next } fence { next } /^#{1,6}[[:space:]]/ { next } /^\|[[:space:]]*-+/ { next } { print } - ' | grep -iE -- "$VP_QUANT_RE" | grep -viE -- "$VP_CITED_RE" || true + ' | grep -iE -- "$VP_QUANT_RE" || true) } -# vp_table_data_rows <text-on-stdin> <min-cells> — print table rows (not the -# header, not the separator) that have at least <min-cells> non-empty cells. +# vp_table_data_rows <min-cells> [required-col-header] <text-on-stdin> — print +# table rows (not the header, not the separator) that have at least <min-cells> +# non-empty cells AND, when a column header is named, a non-empty cell in the +# column whose header contains it (case-insensitive). vp_table_data_rows() { - local min="${1:-4}" - awk -v min="$min" ' + local min="${1:-4}" col + col="$(printf '%s' "${2:-}" | tr '[:upper:]' '[:lower:]')" + awk -v min="$min" -v col="$col" ' /^\|/ { if ($0 ~ /^\|[[:space:]]*-+/) { sep_seen = 1; next } # separator row - if (!sep_seen) next # header row(s) - n = split($0, c, "|"); filled = 0 - for (i = 2; i < n; i++) { v = c[i]; gsub(/^[[:space:]]+|[[:space:]]+$/, "", v); if (v != "") filled++ } - if (filled >= min) print + if (!sep_seen) { # header row: find the column + n = split($0, c, "|") + for (i = 2; i < n; i++) { v = tolower(c[i]); gsub(/^[[:space:]]+|[[:space:]]+$/, "", v); if (col != "" && index(v, col) > 0 && colidx == 0) colidx = i } + next + } + n = split($0, c, "|"); filled = 0; colok = (col == "") ? 1 : 0 + for (i = 2; i < n; i++) { + v = c[i]; gsub(/^[[:space:]]+|[[:space:]]+$/, "", v) + if (v != "") { filled++; if (i == colidx) colok = 1 } + } + if (filled >= min && colok) print } ' } @@ -194,9 +226,9 @@ vp_check_assumptions() { vp_fail brief.md assumptions "riskiest-assumptions table has no 'Cheapest test' column" return 0 fi - rows="$(printf '%s\n' "$body" | vp_table_data_rows 4)" + rows="$(printf '%s\n' "$body" | vp_table_data_rows 4 "cheapest test")" if [[ -z "$rows" ]]; then - vp_fail brief.md assumptions "riskiest-assumptions table has no fully-filled row (assumption, if wrong, cheapest test)" + vp_fail brief.md assumptions "riskiest-assumptions table has no fully-filled row with a concrete Cheapest test (assumption, if wrong, cheapest test)" else vp_pass brief.md assumptions fi @@ -207,8 +239,11 @@ vp_check_decision() { local path="$dir/brief.md" [[ -f "$path" ]] || return 0 body="$(vp_section_body "$path" "recommendation")" - if ! printf '%s\n' "$body" | grep -qiE '\b(go|no-go|no go|park)\b'; then - vp_fail brief.md decision "Recommendation section takes no position (expected go / no-go / park)" + # Exactly ONE bold position — `**go**`, `**no-go**`, `**park**` or + # `**Recommendation: <position>**` — so an incidental "go or park" in prose + # is not read as a decision. + if [[ "$(printf '%s\n' "$body" | grep -oiE '\*\*(Recommendation:?[[:space:]]*)?(go|no-go|no go|park)\*\*' | wc -l | tr -d ' ')" -ne 1 ]]; then + vp_fail brief.md decision "Recommendation section must state exactly one bold position: **go**, **no-go** or **park** (or **Recommendation: <position>**)" elif ! printf '%s\n' "$body" | grep -q 'idea:approved'; then vp_fail brief.md decision "Recommendation section must name the human gate: the decision is made by applying \`idea:approved\`" else @@ -218,12 +253,13 @@ vp_check_decision() { vp_check_provenance() { local dir="$1" path hits sec - # brief.md: the evidence sections. + # brief.md: every section (a Reach figure in §5 or a number in §8 is a market + # claim too). Footnotes resolve against the brief itself. path="$dir/brief.md" if [[ -f "$path" ]]; then hits="" - for sec in problem "target user" market; do - hits+="$(vp_section_body "$path" "$sec" | vp_unsourced_claims)" + for sec in problem "target user" market opportunity value "riskiest assumption" recommendation "open questions"; do + hits+="$(vp_section_body "$path" "$sec" | vp_unsourced_claims "$path")" done if [[ -n "$hits" ]]; then vp_fail brief.md provenance "quantified market claim without a source / 'unknown — needs discovery' / [ASSUMPTION]: $(printf '%s' "$hits" | head -2 | tr '\n' ' ' | cut -c1-220)" @@ -231,13 +267,24 @@ vp_check_provenance() { vp_pass brief.md provenance fi fi + # ost.md: the Mermaid node labels are prose too — strip the fence markers so + # the block is scanned instead of skipped. + path="$dir/ost.md" + if [[ -f "$path" ]]; then + hits="$(sed '/^```/d' "$path" | vp_unsourced_claims "$path")" + if [[ -n "$hits" ]]; then + vp_fail ost.md provenance "quantified market claim without a source / 'unknown — needs discovery' / [ASSUMPTION]: $(printf '%s' "$hits" | head -2 | tr '\n' ' ' | cut -c1-220)" + else + vp_pass ost.md provenance + fi + fi # market-research.md: everything but Sources. path="$dir/market-research.md" if [[ -f "$path" ]]; then hits="$(awk ' /^#{1,6}[[:space:]]/ { insrc = (tolower($0) ~ /sources/) } !insrc { print } - ' "$path" | vp_unsourced_claims)" + ' "$path" | vp_unsourced_claims "$path")" if [[ -n "$hits" ]]; then vp_fail market-research.md provenance "quantified market claim without a source / 'unknown — needs discovery' / [ASSUMPTION]: $(printf '%s' "$hits" | head -2 | tr '\n' ' ' | cut -c1-220)" else diff --git a/tests/fixtures/decision-brief/queue-body.md b/tests/fixtures/decision-brief/queue-body.md index 6a3600e05..a97a30f08 100644 --- a/tests/fixtures/decision-brief/queue-body.md +++ b/tests/fixtures/decision-brief/queue-body.md @@ -5,7 +5,7 @@ Generated 2026-10-19. Legend: 🟢 ripe · 🟡 soon · ⚪ not yet. ## 🟢 Ripe to promote | Idea | Why now | Promotion note | |------|---------|----------------| -| #413 WidgetLint | three incidents | a CI lint step | +| #413 WidgetLint | three incidents, blocked on #999 | a CI lint step (see #777) | | [#44](https://github.com/acme/widgets/discussions/44) DemandRadar | demand data | see /discussions/44 | | #413 WidgetLint (dup) | listed twice | — | diff --git a/tests/test_decision_brief.bats b/tests/test_decision_brief.bats index 7df066f4c..c940f6bcd 100644 --- a/tests/test_decision_brief.bats +++ b/tests/test_decision_brief.bats @@ -54,6 +54,15 @@ validate() { run bash "$DB_DIR/validate-package.sh" "$@"; } db_slug_is_valid "$s" } +@test "slug: a short label prefix ('Idea: X', 'RFC: X') is dropped so ideas do not collapse to one slug" { + source "$DB_DIR/lib/slug.sh" + [ "$(db_slug_from_title 'Idea: WidgetLint')" = "widget-lint" ] + [ "$(db_slug_from_title 'Idea: DemandRadar')" = "demand-radar" ] + [ "$(db_slug_from_title 'RFC: Agent passports for CI')" = "agent-passports-for-ci" ] + # A long clause before the colon is a title, not a label: the subtitle rule applies. + [ "$(db_slug_from_title 'Widget manifests keep breaking production: a lint')" = "widget-manifests-keep-breaking-production" ] +} + @test "slug: the same title always yields the same slug (stable folder, stable branch)" { source "$DB_DIR/lib/slug.sh" [ "$(db_slug_from_title '💡 WidgetLint — a lint')" = "$(db_slug_from_title '💡 WidgetLint — a different pitch')" ] @@ -164,17 +173,34 @@ validate() { run bash "$DB_DIR/validate-package.sh" "$@"; } validate "$pkg" [ "$status" -ne 0 ] grep -q 'FAIL|brief.md|assumptions|.*no fully-filled row' <<<"$output" + # Four filled cells with an EMPTY Cheapest-test cell is not a full row. + pkg3="$TMP/pkg3"; cp -R "$FIX/good" "$pkg3" + sed -i.bak 's/^| 1 | The three incidents.*$/| 1 | The incidents were shape errors | the lint prevents nothing | | — |/' "$pkg3/brief.md" + validate "$pkg3" + [ "$status" -ne 0 ] + grep -q 'FAIL|brief.md|assumptions|.*Cheapest test' <<<"$output" pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" sed -i.bak 's/Cheapest test/Next step/' "$pkg2/brief.md" validate "$pkg2" grep -q "FAIL|brief.md|assumptions|.*no 'Cheapest test' column" <<<"$output" } -@test "validate: the recommendation must take a position and name the idea:approved gate" { +@test "validate: the recommendation must state exactly one BOLD position and name the idea:approved gate" { pkg="$(copy_good)" - sed -i.bak 's/\*\*Recommendation: go\*\*/**Recommendation: it depends**/; s/say no if/say maybe if/' "$pkg/brief.md" + # "go" / "park" in plain prose is not a position. + sed -i.bak 's/\*\*Recommendation: go\*\*/We could go ahead or park this/; s/say no if/say maybe if/' "$pkg/brief.md" validate "$pkg" - grep -q 'FAIL|brief.md|decision|.*takes no position' <<<"$output" + grep -q 'FAIL|brief.md|decision|.*exactly one bold position' <<<"$output" + # Two bold positions is not a decision either. + pkg4="$TMP/pkg4"; cp -R "$FIX/good" "$pkg4" + sed -i.bak 's/\*\*Recommendation: go\*\*/**go** now, or **park** —/' "$pkg4/brief.md" + validate "$pkg4" + grep -q 'FAIL|brief.md|decision|.*exactly one bold position' <<<"$output" + # The bare bold form passes. + pkg5="$TMP/pkg5"; cp -R "$FIX/good" "$pkg5" + sed -i.bak 's/\*\*Recommendation: go\*\*/**park**/' "$pkg5/brief.md" + validate "$pkg5" + [ "$status" -eq 0 ] pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" sed -i.bak 's/`idea:approved`/the label/' "$pkg2/brief.md" validate "$pkg2" @@ -216,6 +242,62 @@ The addressable market is $2.4B and growing 30% a year. grep -q 'FAIL|market-research.md|provenance|.*2.4B' <<<"$output" } +@test "provenance: the scan follows nested sub-headings inside a section" { + pkg="$(copy_good)" + sed -i.bak '/^## Market signal$/a\ +### Market size\ +The segment is worth $2.4B a year. +' "$pkg/market-research.md" + validate "$pkg" + [ "$status" -ne 0 ] + grep -q 'FAIL|market-research.md|provenance|.*2.4B' <<<"$output" + pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" + sed -i.bak '/^## 3\. Market & competitive context$/a\ +### Sizing\ +Roughly 10,000 companies publish manifests. +' "$pkg2/brief.md" + validate "$pkg2" + [ "$status" -ne 0 ] + grep -q 'FAIL|brief.md|provenance|.*10,000 companies' <<<"$output" +} + +@test "provenance: a dangling [^n] footnote is not a source; compact units (2.4bn, 300k) are claims" { + pkg="$(copy_good)" + sed -i.bak '/^## Market signal$/a\ +The category did 2.4bn in revenue last year [^9]. +' "$pkg/market-research.md" + validate "$pkg" + [ "$status" -ne 0 ] + grep -q 'FAIL|market-research.md|provenance|.*2.4bn' <<<"$output" + # Define the footnote with a URL and it passes. + printf '[^9]: https://example.com/category-report\n' >>"$pkg/market-research.md" + validate "$pkg" + [ "$status" -eq 0 ] + # A footnote defined WITHOUT a URL still does not count. + pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" + sed -i.bak '/^## Market signal$/a\ +About 300k teams use a scheduler [^8]. +' "$pkg2/market-research.md" + printf '[^8]: internal estimate\n' >>"$pkg2/market-research.md" + validate "$pkg2" + [ "$status" -ne 0 ] + grep -q 'FAIL|market-research.md|provenance|.*300k' <<<"$output" +} + +@test "provenance: §4–§8 of the brief and the OST node labels are scanned too" { + pkg="$(copy_good)" + sed -i.bak 's/^ - Impact = 3\/5, Confidence/ - Reach = 5,000 developers per quarter\ + - Impact = 3\/5, Confidence/' "$pkg/brief.md" + validate "$pkg" + [ "$status" -ne 0 ] + grep -q 'FAIL|brief.md|provenance|.*5,000 developers' <<<"$output" + pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" + sed -i.bak 's/Outcome: manifest incidents per quarter → 0/Outcome: 40% of the $1.2B market/' "$pkg2/ost.md" + validate "$pkg2" + [ "$status" -ne 0 ] + grep -q 'FAIL|ost.md|provenance' <<<"$output" +} + @test "provenance: an unsourced figure in brief.md Problem / Market sections fails; §5 arithmetic does not" { pkg="$(copy_good)" sed -i.bak '/^## 1\. Problem$/a\ @@ -359,10 +441,28 @@ exit 0' # ── ripe-from-queue ─────────────────────────────────────────────────────────── -@test "ripe-from-queue helpers: only the Ripe section is read; numbers are distinct and in order" { - source "$DB_DIR/ripe-from-queue.sh" +@test "ripe-from-queue helpers: only the Ripe section's Idea column is read; numbers are distinct and in order" { + # #999 (Why now) and #777 (promotion note) are issues in other cells, not ideas. run bash -c "source '$DB_DIR/ripe-from-queue.sh'; dbq_ripe_section < '$FIX/queue-body.md' | dbq_discussion_numbers | paste -sd, -" [ "$output" = "413,44" ] + # Prose (no table) is read whole. + run bash -c "source '$DB_DIR/ripe-from-queue.sh'; printf -- '- #12 and #15 are ripe\n' | dbq_discussion_numbers | paste -sd, -" + [ "$output" = "12,15" ] +} + +@test "ripe-from-queue: an API failure reading a Discussion fails the sweep loudly instead of skipping the idea" { + export QUEUE_FILE="$FIX/queue-body.md" + mock_gh ' +case "$*" in + *"issue list --repo acme/widgets --label idea-triage"*) jq -Rs "{title:\"🧭 Idea Promotion Queue\", body:.}" < "$QUEUE_FILE" | jq -r ".body" ;; + *graphql*) echo "gh: HTTP 502" >&2; exit 1 ;; + *) : ;; +esac +exit 0' + REPO="acme/widgets" run bash "$DB_DIR/ripe-from-queue.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"API error, not a missing idea"* ]] + ! grep -q 'workflow run' "$GH_LOG" || false } # gh stub for the sweep: queue issue, per-discussion GraphQL, contents/PR probes. @@ -431,12 +531,16 @@ mock_gh_gather() { mock_gh ' case "$*" in *graphql*) echo "{\"data\":{\"repository\":{\"discussion\":{\"id\":\"D_target\",\"title\":\"💡 WidgetLint — a lint for manifests\",\"body\":\"see #12\",\"url\":\"https://github.com/acme/widgets/discussions/413\",\"createdAt\":\"t\",\"updatedAt\":\"t\",\"author\":{\"login\":\"don\"},\"category\":{\"name\":\"Ideas\",\"slug\":\"ideas\"},\"labels\":{\"nodes\":[{\"name\":\"needs-brief\"}]},\"comments\":{\"nodes\":[{\"author\":{\"login\":\"bot\"},\"body\":\"<!-- feature-ideation:enhanced -->\\n## Enhancement\",\"createdAt\":\"t\",\"url\":\"u\"}]}}}}}" ;; - *"issue list --repo acme/widgets --label idea-triage"*) echo "" ;; + *"issue list --repo acme/widgets --label idea-triage"*) echo "${QUEUE_JSON:-}" ;; *"contents/ideas/_TEMPLATE/brief.md"*) if [ "${NO_TEMPLATE:-0}" = 1 ]; then exit 1; fi; base64 < "'"$FIX"'/template/brief.md" | tr -d "\n" ;; *"contents/ideas/_TEMPLATE/market-research.md"*) base64 < "'"$FIX"'/template/market-research.md" | tr -d "\n" ;; *"contents/ideas/_TEMPLATE/ost.md"*) base64 < "'"$FIX"'/template/ost.md" | tr -d "\n" ;; *"contents/ideas/package-spec.json"*) printf "{\"required_artifacts\":[]}" | base64 | tr -d "\n" ;; - *"contents/ideas/widget-lint"*) exit 1 ;; + *"git/ref/heads/incubate/widget-lint"*) if [ "${BRANCH_EXISTS:-0}" = 1 ]; then echo headsha; else exit 1; fi ;; + *"contents/ideas/widget-lint/brief.md?ref=incubate/widget-lint"*) printf "%s" "${BRANCH_BRIEF:-}" | base64 | tr -d "\n" ;; + *"contents/ideas/widget-lint?ref=incubate/widget-lint"*) echo "[\"brief.md\"]" ;; + *"contents/ideas/widget-lint/brief.md"*) printf "%s" "${DEFAULT_BRIEF:-}" | base64 | tr -d "\n" ;; + *"contents/ideas/widget-lint"*) if [ -n "${DEFAULT_BRIEF:-}" ]; then echo "[\"brief.md\"]"; else exit 1; fi ;; "api repos/acme/widgets/issues/12 --jq "*) echo "{\"number\":12,\"title\":\"prereq\",\"state\":\"open\",\"url\":\"u\"}" ;; *) echo "{}" ;; esac @@ -464,6 +568,46 @@ exit 0' grep -q '^SLUG=widget-lint$' "$TMP/env" } +@test "gather-context: writes the target package-spec to SPEC_PATH for the validator" { + mock_gh_gather + SPEC_PATH="$TMP/spec.json" REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" \ + run bash "$DB_DIR/gather-context.sh" + [ "$status" -eq 0 ] + jq -e '.required_artifacts' "$TMP/spec.json" >/dev/null +} + +@test "gather-context: the queue excerpt matches the discussion number exactly (#4 is not #44 or #413)" { + export QUEUE_JSON='{"number":1,"title":"🧭 Idea Promotion Queue","body":"| #4 Foo | ripe |\n| #44 Bar | ripe |\n| #413 Baz | ripe |\n| see /discussions/4 | note |\n| /discussions/413 | x |"}' + mock_gh_gather + mock="$MOCK_BIN/gh"; sed -i.bak 's#"\${QUEUE_JSON:-}"#"$QUEUE_JSON"#' "$mock" + REPO="acme/widgets" DISCUSSION_NUMBER=4 CONTEXT_PATH="$TMP/ctx.json" SLUG=foo run bash "$DB_DIR/gather-context.sh" + [ "$status" -eq 0 ] + [ "$(jq -r '.queue_excerpt.lines | length' "$TMP/ctx.json")" = "2" ] + jq -e '.queue_excerpt.lines | all(test("#4 |/discussions/4 "))' "$TMP/ctx.json" >/dev/null +} + +@test "gather-context: an in-flight incubate/<slug> branch is read instead of the default branch" { + export BRANCH_EXISTS=1 BRANCH_BRIEF="# brief for https://github.com/acme/widgets/discussions/413 (edited in flight)" + mock_gh_gather + REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -eq 0 ] + [ "$(jq -r '.existing_package.ref' "$TMP/ctx.json")" = "incubate/widget-lint" ] + jq -e '.existing_package["brief.md"] | contains("edited in flight")' "$TMP/ctx.json" >/dev/null +} + +@test "gather-context: a package under the same slug for ANOTHER discussion is a collision, not an update" { + export DEFAULT_BRIEF="# brief for https://github.com/acme/widgets/discussions/99" + mock_gh_gather + REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"slug collision"* ]] + # The same package linking THIS discussion is an update. + export DEFAULT_BRIEF="# brief for https://github.com/acme/widgets/discussions/413" + REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx2.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -eq 0 ] + jq -e '.existing_package["brief.md"] | contains("discussions/413")' "$TMP/ctx2.json" >/dev/null +} + @test "gather-context: fails loud when the target has no ideas/_TEMPLATE/brief.md" { NO_TEMPLATE=1 mock_gh_gather export NO_TEMPLATE=1 diff --git a/tests/test_decision_brief_workflow.bats b/tests/test_decision_brief_workflow.bats index 75c9e6ef2..21b5ca0be 100644 --- a/tests/test_decision_brief_workflow.bats +++ b/tests/test_decision_brief_workflow.bats @@ -69,11 +69,35 @@ setup() { cd "$ROOT"; } grep -q 'NEVER applies' "$CENTRAL" } -@test "central: target_repo parameterizes reads + writes; self path uses GITHUB_TOKEN, cross-repo the PAT" { +@test "central: target_repo parameterizes reads + writes; the write token is the PAT on every path" { grep -q "REPO: \${{ github.event.inputs.target_repo || github.repository }}" "$CENTRAL" - grep -qE "TARGET_GH_TOKEN: .*github\.event\.inputs\.target_repo == '' .*secrets\.GITHUB_TOKEN .*secrets\.GH_PAT_DON_PETRY" "$CENTRAL" - grep -q 'GH_TOKEN: ${{ env.TARGET_GH_TOKEN }}' "$CENTRAL" - grep -q 'github_token: ${{ env.TARGET_GH_TOKEN }}' "$CENTRAL" + grep -q 'TARGET_GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}' "$CENTRAL" + # gather-context and open-pr use it; the job never asks for contents: write. + [ "$(grep -c 'GH_TOKEN: ${{ env.TARGET_GH_TOKEN }}' "$CENTRAL")" -ge 2 ] + ! sed -n '/^ brief:/,/^ fleet-paused:/p' "$CENTRAL" | grep -qE '^\s+contents: write' || false +} + +@test "central: the model step never sees the write PAT — only the read-scoped GITHUB_TOKEN" { + step="$(sed -n '/name: Draft the decision brief (BMAD Analyst)/,/name: Provenance + structure gate/p' "$CENTRAL")" + grep -q 'github_token: ${{ secrets.GITHUB_TOKEN }}' <<<"$step" + grep -q 'GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}' <<<"$step" + ! grep -q 'TARGET_GH_TOKEN' <<<"$step" || false + ! grep -qE 'GH_PAT_(DON_PETRY|WORKFLOWS)' <<<"$step" || false +} + +@test "central: the gate and Mary both receive the target package-spec (--spec)" { + grep -q 'SPEC_PATH=${{ runner.temp }}/package-spec.json' "$CENTRAL" + gate="$(sed -n '/name: Provenance + structure gate/,/name: Open (or refresh)/p' "$CENTRAL")" + grep -q -- '--spec "$SPEC_PATH"' <<<"$gate" + prompt="$(sed -n '/name: Draft the decision brief (BMAD Analyst)/,/name: Provenance + structure gate/p' "$CENTRAL")" + grep -q -- '--spec "$SPEC_PATH"' <<<"$prompt" +} + +@test "central: max_briefs and slug are dispatch inputs; the sweep honours the cap" { + grep -qE '^ max_briefs:' "$CENTRAL" + grep -qE '^ slug:' "$CENTRAL" + grep -q "MAX_BRIEFS: \${{ github.event.inputs.max_briefs || '3' }}" "$CENTRAL" + grep -q 'SLUG: ${{ github.event.inputs.slug }}' "$CENTRAL" } @test "central: one lane per (target repo, discussion); sweep takes the queue lane; never cancels" { @@ -118,6 +142,8 @@ setup() { cd "$ROOT"; } grep -qE -- '-f target_repo="\$\{TARGET_REPO\}"' "$REUSABLE" grep -q -- '-f dry_run=false' "$REUSABLE" grep -q -- '-f from_queue=true' "$REUSABLE" + # The caller's max_briefs cap reaches the central sweep. + grep -q -- '-f max_briefs="${MAX_BRIEFS}"' "$REUSABLE" grep -q 'TARGET_REPO: ${{ github.repository }}' "$REUSABLE" grep -q 'positive integer' "$REUSABLE" } From 939616e6cc4be282397b7336d30f3abc7018f6cd Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:18:03 -0500 Subject: [PATCH 3/8] fix(decision-brief): render the PR body file list one file per line A single printf with two %s reused the format across the argument list, so three files rendered as 'ideas/<slug>/brief.md' then 'market-research.md/ost.md'. Build the list per file; test pins the body. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- scripts/decision-brief/open-pr.sh | 7 ++++++- tests/test_decision_brief.bats | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/decision-brief/open-pr.sh b/scripts/decision-brief/open-pr.sh index 2b5dc486a..22c379728 100644 --- a/scripts/decision-brief/open-pr.sh +++ b/scripts/decision-brief/open-pr.sh @@ -90,7 +90,12 @@ if [[ -n "${STATUS_PATH:-}" && -s "${STATUS_PATH}" ]] && jq -e . "$STATUS_PATH" "**Sources cited:** " + ((.sources // []) | length | tostring) ' "$STATUS_PATH")" fi -file_list="$(printf -- '- `%s/%s`\n' "$dest_prefix" "${files[@]}")" +# One list line per file (a single printf with two %s would reuse the format +# across the argument list and pair the files with each other). +file_list="" +for f in "${files[@]}"; do + file_list+="- \`${dest_prefix}/${f}\`"$'\n' +done body="$(cat <<EOF <!-- decision-brief:slug=${SLUG} discussion=${DISCUSSION_NUMBER} --> ## Decision brief — ${IDEA_TITLE} diff --git a/tests/test_decision_brief.bats b/tests/test_decision_brief.bats index c940f6bcd..5bd63965f 100644 --- a/tests/test_decision_brief.bats +++ b/tests/test_decision_brief.bats @@ -390,7 +390,7 @@ case "$*" in "api repos/acme/widgets/git/trees -X POST --input - --jq .sha") cat >>"$GH_LOG.tree"; echo tree444 ;; "api repos/acme/widgets/git/commits -X POST --input - --jq .sha") cat >>"$GH_LOG.commit"; echo commit555 ;; *"pr list --repo acme/widgets --head incubate/widget-lint"*) if [ "${PR_EXISTS:-0}" = 1 ]; then echo "https://github.com/acme/widgets/pull/9"; fi ;; - *"pr create"*) echo "https://github.com/acme/widgets/pull/10" ;; + *"pr create"*) for a in "$@"; do [ -n "$prev_body" ] && cp "$a" "$GH_LOG.body" && break; [ "$a" = "--body-file" ] && prev_body=1; done; echo "https://github.com/acme/widgets/pull/10" ;; *) : ;; esac exit 0' @@ -410,6 +410,10 @@ exit 0' grep -q '"author":{"name":"don-petry","email":"don-petry@users.noreply.github.com"}' "$GH_LOG.commit" grep -qF 'api repos/acme/widgets/git/refs -X POST -f ref=refs/heads/incubate/widget-lint -f sha=commit555' "$GH_LOG" grep -q 'pr create --repo acme/widgets --base main --head incubate/widget-lint' "$GH_LOG" + # The PR body lists every file under its full path, one per line. + [ "$(grep -c '^- `ideas/widget-lint/' "$GH_LOG.body")" -eq 3 ] + grep -qx -- '- `ideas/widget-lint/market-research.md`' "$GH_LOG.body" + grep -qx -- '- `ideas/widget-lint/ost.md`' "$GH_LOG.body" grep -q -- '--label decision-brief' "$GH_LOG" grep -q 'addDiscussionComment' "$GH_LOG" grep -q 'pr_url=https://github.com/acme/widgets/pull/10' "$TMP/out" From 343c6d4edb599e985095918665be16d5b309782e Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:22:17 -0500 Subject: [PATCH 4/8] fix(decision-brief): keep the org PAT out of the job-level env A job-level env reaches every step, including the claude-code-action step, so the model could have read the write-capable PAT. The PAT is now set only in the per-step env of the guard, gather-context and open-pr steps; the test checks the whole job's env, not just the model step's block. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .github/workflows/decision-brief.yml | 27 +++++++++++++++---------- tests/test_decision_brief_workflow.bats | 22 +++++++++++++------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/.github/workflows/decision-brief.yml b/.github/workflows/decision-brief.yml index ddb364e62..849937a8e 100644 --- a/.github/workflows/decision-brief.yml +++ b/.github/workflows/decision-brief.yml @@ -189,20 +189,22 @@ jobs: # here via the `redispatch` job), so inputs are always present. # REPO is the TARGET repo to brief for; empty target_repo => this repo (self). REPO: ${{ github.event.inputs.target_repo || github.repository }} - # Token for every READ of and WRITE to the TARGET repo (gather-context, the - # PR, the Discussion back-link) — ALWAYS the org PAT, on the self path too. - # GITHUB_TOKEN is deliberately not used for writes: that would force - # contents:write onto this job, and the model step's token inherits the - # job's permissions — so the PAT stays out of the model step (below) and - # GITHUB_TOKEN stays read-only. (A PAT-authored PR also gets CI; a - # GITHUB_TOKEN-authored one does not.) - TARGET_GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} + # The org PAT (GH_PAT_DON_PETRY, legacy GH_PAT_WORKFLOWS) is the token for + # every READ of and WRITE to the TARGET repo — on the self path too. It is + # deliberately NOT a job-level env var: a job-level env reaches every step, + # including the model step, where Bash + untrusted Discussion text could + # read it. It is set per step, only on the three steps that need it + # (guard, gather-context, open-pr). GITHUB_TOKEN is never used for writes, + # so this job needs no contents:write and the model step's token stays + # read-only. (A PAT-authored PR also gets CI; a GITHUB_TOKEN one does not.) DISCUSSION_NUMBER: ${{ github.event.inputs.discussion }} SLUG: ${{ github.event.inputs.slug }} DRY_RUN: ${{ inputs.dry_run == true && '1' || '0' }} TOOLING_DIR: ${{ github.workspace }}/scripts/decision-brief steps: - name: Guard — discussion number present, PAT present + env: + TARGET_GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} run: | if ! [[ "${DISCUSSION_NUMBER}" =~ ^[1-9][0-9]*$ ]]; then echo "::error::discussion must be a positive integer (got '${DISCUSSION_NUMBER}'). Pass discussion=N, or from_queue=true to sweep the Idea Promotion Queue." @@ -230,7 +232,7 @@ jobs: - name: Gather brief context env: # Reads the TARGET repo's Discussion, template, package spec and queue. - GH_TOKEN: ${{ env.TARGET_GH_TOKEN }} + GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} run: bash scripts/decision-brief/gather-context.sh # Resolve the model FAMILY (opus|sonnet|haiku) to the current concrete id via @@ -267,7 +269,9 @@ jobs: # SECURITY: the model reads untrusted text (Discussion bodies, comments, # web pages) with Bash allowed. It therefore gets ONLY the read-scoped # GITHUB_TOKEN of this job (contents: read on this repo) — never the org - # PAT that open-pr.sh uses two steps later. Everything grounded is already + # PAT that open-pr.sh uses two steps later, which is why that PAT is a + # per-step env on those steps and not a job-level env (every step would + # inherit it, `env` would print it). Everything grounded is already # in $CONTEXT_PATH (gather-context ran with the PAT); the target repos # are public, so unauthenticated reads still work for extra grounding. env: @@ -375,7 +379,8 @@ jobs: - name: Open (or refresh) the brief PR in the target repo id: pr env: - GH_TOKEN: ${{ env.TARGET_GH_TOKEN }} + # The only write-capable credential in this job, scoped to this step. + GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} PACKAGE_DIR: ${{ env.BRIEF_ROOT }}/ideas/${{ env.SLUG }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: bash scripts/decision-brief/open-pr.sh diff --git a/tests/test_decision_brief_workflow.bats b/tests/test_decision_brief_workflow.bats index 21b5ca0be..18923b8a4 100644 --- a/tests/test_decision_brief_workflow.bats +++ b/tests/test_decision_brief_workflow.bats @@ -69,20 +69,28 @@ setup() { cd "$ROOT"; } grep -q 'NEVER applies' "$CENTRAL" } -@test "central: target_repo parameterizes reads + writes; the write token is the PAT on every path" { +@test "central: target_repo parameterizes reads + writes; the write token is the PAT on every path, per step" { grep -q "REPO: \${{ github.event.inputs.target_repo || github.repository }}" "$CENTRAL" - grep -q 'TARGET_GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}' "$CENTRAL" - # gather-context and open-pr use it; the job never asks for contents: write. - [ "$(grep -c 'GH_TOKEN: ${{ env.TARGET_GH_TOKEN }}' "$CENTRAL")" -ge 2 ] + # gather-context and open-pr carry the PAT in their OWN env; the job never asks for contents: write. + gather="$(sed -n '/name: Gather brief context/,/name: Resolve model/p' "$CENTRAL")" + grep -q 'GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}' <<<"$gather" + openpr="$(sed -n '/name: Open (or refresh) the brief PR/,/name: Upload the drafted package/p' "$CENTRAL")" + grep -q 'GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}' <<<"$openpr" ! sed -n '/^ brief:/,/^ fleet-paused:/p' "$CENTRAL" | grep -qE '^\s+contents: write' || false } -@test "central: the model step never sees the write PAT — only the read-scoped GITHUB_TOKEN" { +@test "central: the model step never sees the write PAT — not in its block, not via a job-level env" { step="$(sed -n '/name: Draft the decision brief (BMAD Analyst)/,/name: Provenance + structure gate/p' "$CENTRAL")" grep -q 'github_token: ${{ secrets.GITHUB_TOKEN }}' <<<"$step" grep -q 'GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}' <<<"$step" - ! grep -q 'TARGET_GH_TOKEN' <<<"$step" || false - ! grep -qE 'GH_PAT_(DON_PETRY|WORKFLOWS)' <<<"$step" || false + ! grep -qE 'GH_PAT_(DON_PETRY|WORKFLOWS)|TARGET_GH_TOKEN' <<<"$step" || false + # A job-level (or workflow-level) env reaches every step, so the PAT must not + # be declared there: every PAT reference in the brief job sits under a step's + # own `env:` (indent 10), never under the job's `env:` (indent 6). + jobenv="$(sed -n '/^ brief:/,/^ steps:/p' "$CENTRAL")" + ! grep -vE '^\s*#' <<<"$jobenv" | grep -qE 'GH_PAT_(DON_PETRY|WORKFLOWS)' || false + ! grep -vE '^\s*#' <<<"$jobenv" | grep -qE '^ TARGET_GH_TOKEN:' || false + ! sed -n '1,/^jobs:/p' "$CENTRAL" | grep -vE '^\s*#' | grep -qE 'GH_PAT_(DON_PETRY|WORKFLOWS)' || false } @test "central: the gate and Mary both receive the target package-spec (--spec)" { From a3fd1d3c499da7db975e8c3e33bde8e7ac26d0df Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:34:17 -0500 Subject: [PATCH 5/8] fix(decision-brief): isolate the gate and PR from the model step; NOT_FOUND skip; whole-brief scan - The provenance gate and open-pr.sh now run in a separate `land` job: fresh checkout of the tooling, only the package artifact downloaded, inputs taken from the gather step's outputs (fixed before the model ran), package spec re-fetched from the target. The brief job ends at the artifact upload, so nothing the model step can edit (scripts/, $GITHUB_ENV) reaches a privileged step, and the land job is the only one holding the org PAT. - ripe-from-queue: a GraphQL NOT_FOUND on the discussion field (an issue number in the Ripe table) is skipped; other API errors still abort. - validate-package: provenance scans the whole brief body after the frontmatter, not just sections with known header keywords. - Tests updated/added (129 across the three suites). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .github/workflows/decision-brief.yml | 123 +++++++++++++++--- AGENTS.md | 4 +- .../idea-to-initiative-pipeline.md | 7 +- scripts/decision-brief/README.md | 5 +- scripts/decision-brief/gather-context.sh | 22 +++- scripts/decision-brief/ripe-from-queue.sh | 6 + scripts/decision-brief/validate-package.sh | 14 +- tests/test_decision_brief.bats | 39 +++++- tests/test_decision_brief_workflow.bats | 45 ++++--- 9 files changed, 210 insertions(+), 55 deletions(-) diff --git a/.github/workflows/decision-brief.yml b/.github/workflows/decision-brief.yml index 849937a8e..fe48a0ecc 100644 --- a/.github/workflows/decision-brief.yml +++ b/.github/workflows/decision-brief.yml @@ -27,8 +27,9 @@ # # Provenance guard (.github-private#1160): every market claim in the package # cites a source, is tagged [ASSUMPTION], or says "unknown — needs discovery". -# scripts/decision-brief/validate-package.sh enforces this as its own step AFTER -# the model step, so a package that fabricates never becomes a PR. +# scripts/decision-brief/validate-package.sh enforces this in a SEPARATE job +# (`land`) from a clean checkout AFTER the model job, so a package that +# fabricates — or a model step that tampers with the tooling — never becomes a PR. # # This is org-private automation (like initiative-planner.yml), not a thin # caller. All tooling + the vendored BMAD skills live in this repo and are @@ -184,6 +185,13 @@ jobs: permissions: contents: read # checkout, read prompts/ frameworks/ scripts/ id-token: write # claude-code-action OIDC + # Handed to the `land` job as STEP outputs of the gather step (fixed before + # the model runs); the model step can append to $GITHUB_ENV but cannot + # change these. + outputs: + slug: ${{ steps.gather.outputs.slug }} + discussion_node_id: ${{ steps.gather.outputs.discussion_node_id }} + idea_title: ${{ steps.gather.outputs.idea_title }} env: # This job only runs on workflow_dispatch (the discussion path re-dispatches # here via the `redispatch` job), so inputs are always present. @@ -230,6 +238,7 @@ jobs: mkdir -p "${{ runner.temp }}/package" - name: Gather brief context + id: gather env: # Reads the TARGET repo's Discussion, template, package spec and queue. GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} @@ -267,11 +276,11 @@ jobs: - name: Draft the decision brief (BMAD Analyst) id: mary # SECURITY: the model reads untrusted text (Discussion bodies, comments, - # web pages) with Bash allowed. It therefore gets ONLY the read-scoped - # GITHUB_TOKEN of this job (contents: read on this repo) — never the org - # PAT that open-pr.sh uses two steps later, which is why that PAT is a - # per-step env on those steps and not a job-level env (every step would - # inherit it, `env` would print it). Everything grounded is already + # web pages) with Bash/Write/Edit allowed. It therefore gets ONLY the + # read-scoped GITHUB_TOKEN of this job (contents: read on this repo), + # never the org PAT (a per-step env on the gather step, never job-level), + # and NOTHING privileged runs after it in this job: validation and the + # PR happen in the separate `land` job from a clean checkout. Everything grounded is already # in $CONTEXT_PATH (gather-context ran with the PAT); the target repos # are public, so unauthenticated reads still work for extra grounding. env: @@ -367,34 +376,108 @@ jobs: - Finish with a 3–5 line summary: the recommendation, the score and its Confidence, the riskiest assumption, and the open questions. + - name: Upload the drafted package + # Always: in dry-run it is the only output; live, it is what the `land` + # job validates and opens as the PR — from a CLEAN checkout, so nothing + # the model step could have changed in this workspace (scripts, env) + # reaches the privileged steps. + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: decision-brief-${{ steps.gather.outputs.slug || 'package' }} + path: | + ${{ runner.temp }}/package + ${{ runner.temp }}/brief-status.json + if-no-files-found: warn + + # ── validate + land, isolated from the model step ──────────────────────────── + # The model step above has Bash/Write/Edit in the `brief` job's workspace and + # reads untrusted text. Nothing it can touch runs here: this job starts from a + # fresh checkout of THIS repo's tooling, downloads only the package artifact, + # takes its inputs from the gather step's outputs (fixed before the model ran), + # and is the only job that holds the org PAT. + land: + needs: [brief] + if: >- + github.event_name == 'workflow_dispatch' && inputs.from_queue != true && + needs.brief.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read # fresh checkout of the tooling + env: + REPO: ${{ github.event.inputs.target_repo || github.repository }} + DISCUSSION_NUMBER: ${{ github.event.inputs.discussion }} + SLUG: ${{ needs.brief.outputs.slug }} + DISCUSSION_NODE_ID: ${{ needs.brief.outputs.discussion_node_id }} + IDEA_TITLE: ${{ needs.brief.outputs.idea_title }} + DRY_RUN: ${{ inputs.dry_run == true && '1' || '0' }} + steps: + - name: Checkout agent repo (clean) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Guard — slug present + run: | + if ! [[ "${SLUG}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "::error::no valid slug reached the land job (got '${SLUG}') — the gather step did not complete" + exit 1 + fi + + - name: Download the drafted package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: decision-brief-${{ env.SLUG }} + path: ${{ runner.temp }}/landing + + - name: Set paths + run: | + echo "PACKAGE_DIR=${{ runner.temp }}/landing/package/ideas/${SLUG}" >> "$GITHUB_ENV" + echo "STATUS_PATH=${{ runner.temp }}/landing/brief-status.json" >> "$GITHUB_ENV" + echo "SPEC_PATH=${{ runner.temp }}/package-spec.json" >> "$GITHUB_ENV" + echo "DRY_RUN_LOG=${{ runner.temp }}/dry-run.jsonl" >> "$GITHUB_ENV" + + - name: Fetch the target package spec + # Re-fetched here (not taken from the artifact) so the gate's extra + # required_sections come from the target repo, not from the model's + # workspace. + env: + GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} + run: | + if gh api "repos/${REPO}/contents/ideas/package-spec.json" --jq '.content' 2>/dev/null | base64 -d > "$SPEC_PATH" \ + && jq -e . "$SPEC_PATH" >/dev/null 2>&1; then + echo "package-spec.json fetched from ${REPO}" + else + rm -f "$SPEC_PATH" + echo "::notice::${REPO} has no ideas/package-spec.json — validating against the stage's built-in sections only" + fi + - name: Provenance + structure gate - # The deterministic half of the guard: re-run the validator the model - # already ran. A package that fails here stays an artifact, never a PR. + # The deterministic half of the guard, run from the clean checkout: the + # same validator the model was told to run. A package that fails here + # stays an artifact, never a PR. run: | spec_args=() [ -s "$SPEC_PATH" ] && spec_args=(--spec "$SPEC_PATH") - bash scripts/decision-brief/validate-package.sh "$BRIEF_ROOT/ideas/$SLUG" \ + bash scripts/decision-brief/validate-package.sh "$PACKAGE_DIR" \ --slug "$SLUG" --discussion "$DISCUSSION_NUMBER" "${spec_args[@]}" - name: Open (or refresh) the brief PR in the target repo id: pr env: - # The only write-capable credential in this job, scoped to this step. + # The only write-capable credential in the run, scoped to this step + # of this isolated job. GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} - PACKAGE_DIR: ${{ env.BRIEF_ROOT }}/ideas/${{ env.SLUG }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: bash scripts/decision-brief/open-pr.sh - - name: Upload the drafted package - # Always: in dry-run it is the only output; live, it is the audit copy. - if: ${{ always() }} + - name: Upload dry-run PR log + if: ${{ env.DRY_RUN == '1' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: decision-brief-${{ env.SLUG || 'package' }} - path: | - ${{ runner.temp }}/package - ${{ runner.temp }}/brief-status.json - ${{ runner.temp }}/dry-run.jsonl + name: decision-brief-${{ env.SLUG }}-dry-run + path: ${{ runner.temp }}/dry-run.jsonl if-no-files-found: warn # Fleet pause notice (#2249). With AGENTS_PAUSED=true the `brief` job above is diff --git a/AGENTS.md b/AGENTS.md index e72cc9ec8..3d84d142d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -335,7 +335,9 @@ calling it a regression, and treat CI as authoritative.** opens the package as a PR there. Its `needs-brief` `discussion:[labeled]` trigger is bridged to `workflow_dispatch` by a `redispatch` job (claude-code-action aborts on `discussion` contexts); its `from_queue` sweep briefs every idea idea-triage ranked Ripe that has no brief yet. The **provenance - guard** (`scripts/decision-brief/validate-package.sh`, #1160) is a separate step after the model: an + guard** (`scripts/decision-brief/validate-package.sh`, #1160) runs in a separate `land` job from a + clean checkout after the model job (the only job holding the org PAT; the model step gets the + read-scoped `GITHUB_TOKEN`): an unsourced market figure, a missing score/Confidence, an empty assumptions table or a positionless recommendation fails the run before any PR exists. It never applies `idea:approved`. Tooling in `scripts/decision-brief/` (tests: `tests/test_decision_brief.bats`, diff --git a/docs/initiatives/idea-to-initiative-pipeline.md b/docs/initiatives/idea-to-initiative-pipeline.md index 921241945..c1dddf048 100644 --- a/docs/initiatives/idea-to-initiative-pipeline.md +++ b/docs/initiatives/idea-to-initiative-pipeline.md @@ -175,13 +175,14 @@ inputs. and cheapest-test** table, and a **go / no-go / park** recommendation that says what a POC must prove and what would make the decider say no. - **Provenance guard (#1160).** `scripts/decision-brief/validate-package.sh` - runs as its own step after the model: any quantified market claim without a + runs in a separate `land` job from a clean checkout after the model job: any quantified market claim without a URL / `[^n]` footnote / `[ASSUMPTION]` tag / "unknown — needs discovery", a missing score or Confidence, an empty assumptions table, a recommendation that takes no position, or a leftover template placeholder fails the run — the package stays an artifact and never becomes a PR. -- `open-pr.sh` lands the package on `incubate/<slug>` in the target (one commit - via the Git Data API, author = the business-analyst persona's runtime account), +- `open-pr.sh` (same isolated `land` job, the only one holding the org PAT) + lands the package on `incubate/<slug>` in the target (one commit via the Git + Data API, author = the business-analyst persona's runtime account), opens or refreshes the PR, and links it from the Discussion. **It never applies `idea:approved`.** - Tooling: `scripts/decision-brief/` (tests: `tests/test_decision_brief.bats`, diff --git a/scripts/decision-brief/README.md b/scripts/decision-brief/README.md index 0f996d7c3..f6ad14cae 100644 --- a/scripts/decision-brief/README.md +++ b/scripts/decision-brief/README.md @@ -18,7 +18,7 @@ through the headless overlays in [`prompts/incubation/`](../../prompts/incubatio | `redispatch.sh` | Bridge the `discussion [labeled needs-brief]` trigger to `workflow_dispatch` (claude-code-action rejects `discussion` event contexts). Fired with a PAT so the dispatch actually starts a run. | | `gather-context.sh` | Fetch the Discussion + comments, the feature-ideation enhancement comment, the idea-triage queue verdict, the TARGET repo's `ideas/_TEMPLATE/*` and `package-spec.json`, anything already under `ideas/<slug>/`, and referenced issues → `$CONTEXT_PATH`; export `DISCUSSION_NODE_ID`, `SLUG`, `IDEA_TITLE`. | | `lib/slug.sh` | Pure, deterministic slug from the Discussion title (`💡 DemandRadar — …` → `demand-radar`), so a re-brief lands in the same folder and branch. | -| `validate-package.sh` | The **provenance + structure gate** (#1160): frontmatter, required sections (gate-compatible prefix match, plus `package-spec.json`), no placeholders, a RICE/ICE line with Confidence, a filled riskiest-assumption + cheapest-test row, a go/no-go that names `idea:approved`, and — the hard rule — every quantified market claim cited (URL / `[^n]`), tagged `[ASSUMPTION]`, or "unknown — needs discovery"; `market-research.md` ends with `## Sources`. Mary runs it; the workflow runs it again as its own step. | +| `validate-package.sh` | The **provenance + structure gate** (#1160): frontmatter, required sections (gate-compatible prefix match, plus `package-spec.json`), no placeholders, a RICE/ICE line with Confidence, a filled riskiest-assumption + cheapest-test row, a go/no-go that names `idea:approved`, and — the hard rule — every quantified market claim cited (URL / `[^n]`), tagged `[ASSUMPTION]`, or "unknown — needs discovery"; `market-research.md` ends with `## Sources`. Mary runs it; the workflow runs it again in the isolated `land` job (clean checkout, artifact download). | | `open-pr.sh` | Land the package on `incubate/<slug>` in the target via the Git Data API (one commit, author = the business-analyst persona's runtime account), open or refresh the PR (label `decision-brief`), link it from the Discussion. Idempotent; `DRY_RUN=1` logs instead. **Never applies `idea:approved`.** | | `ripe-from-queue.sh` | The sweep: read the target's "🧭 Idea Promotion Queue" Ripe table and dispatch one central brief run per idea that has no brief / open brief PR yet (skips `idea:approved`; capped by `MAX_BRIEFS`). | @@ -37,7 +37,8 @@ decision-brief.yml (stub, standards/workflows/) ──> decision-brief-reusable. │ trusted-actor guard; re-DISPATCHES the central workflow (PAT) with target_repo=<fleet repo> ▼ combsmith/.github-private decision-brief.yml -f discussion=N -f target_repo=<fleet repo> [-f from_queue=true] - │ gather → Mary (claude-code-action) → validate-package.sh → open-pr.sh + │ brief job: gather (PAT) → Mary (read-only GITHUB_TOKEN) → artifact + │ land job (clean checkout, PAT): validate-package.sh → open-pr.sh ▼ PR in the fleet repo: incubate/<slug> with ideas/<slug>/{brief,market-research,ost}.md │ diff --git a/scripts/decision-brief/gather-context.sh b/scripts/decision-brief/gather-context.sh index 1e933c67f..535467649 100644 --- a/scripts/decision-brief/gather-context.sh +++ b/scripts/decision-brief/gather-context.sh @@ -33,6 +33,7 @@ # Emits: # - $CONTEXT_PATH (JSON) # - DISCUSSION_NODE_ID, SLUG, IDEA_TITLE -> $GITHUB_ENV +# - discussion_node_id, slug, idea_title -> $GITHUB_OUTPUT (step outputs) # # Env: REPO, DISCUSSION_NUMBER, CONTEXT_PATH (required); GH_TOKEN; # SLUG (optional override — otherwise derived from the title); @@ -201,11 +202,20 @@ jq -n \ referenced_issues: $refs }' >"$CONTEXT_PATH" -{ - printf 'DISCUSSION_NODE_ID=%s\n' "$node_id" - printf 'SLUG=%s\n' "$slug" - # Multi-line safe: titles are single-line, but guard anyway. - printf 'IDEA_TITLE<<__EOT__\n%s\n__EOT__\n' "$title" -} >>"${GITHUB_ENV:-/dev/null}" +# Exported twice on purpose. $GITHUB_ENV serves the model step in this job; +# $GITHUB_OUTPUT (step outputs, fixed once this step ends) serves the separate +# `land` job that validates and opens the PR — a later step (the model) can +# append to $GITHUB_ENV but cannot rewrite a finished step's outputs. +for target in "${GITHUB_ENV:-}" "${GITHUB_OUTPUT:-}"; do + [[ -n "$target" ]] || continue + if [[ "$target" == "${GITHUB_OUTPUT:-}" ]]; then + { printf 'discussion_node_id=%s\n' "$node_id"; printf 'slug=%s\n' "$slug" + printf 'idea_title<<__EOT__\n%s\n__EOT__\n' "$title"; } >>"$target" + else + { printf 'DISCUSSION_NODE_ID=%s\n' "$node_id"; printf 'SLUG=%s\n' "$slug" + # Multi-line safe: titles are single-line, but guard anyway. + printf 'IDEA_TITLE<<__EOT__\n%s\n__EOT__\n' "$title"; } >>"$target" + fi +done echo "context written to ${CONTEXT_PATH} (repo=${REPO}, discussion=#${DISCUSSION_NUMBER}, slug=${slug}, enhancement=$(if [[ $enhancement != "null" ]]; then echo yes; else echo no; fi), existing=$(printf '%s' "$existing_files" | jq 'length'), refs=$(printf '%s' "$ref_json" | jq 'length'))" diff --git a/scripts/decision-brief/ripe-from-queue.sh b/scripts/decision-brief/ripe-from-queue.sh index 22b8c3aaf..f61ab5ddd 100644 --- a/scripts/decision-brief/ripe-from-queue.sh +++ b/scripts/decision-brief/ripe-from-queue.sh @@ -107,6 +107,12 @@ dbq_main() { discussion(number:$num){ title category{ name } labels(first:20){ nodes{ name } } } } }' -F owner="$owner" -F name="$name" -F num="$n")"; then + # gh exits non-zero on a GraphQL `errors` body too. A NOT_FOUND on the + # discussion field is a Ripe row naming an issue/PR number, not an + # outage: skip it and keep sweeping. + if jq -e 'any(.errors[]?; .type == "NOT_FOUND" and ((.path // []) | index("discussion")) != null)' <<<"$disc" >/dev/null 2>&1; then + echo "skip #${n}: not a Discussion (an issue/PR number in the Ripe table)"; skipped=$((skipped+1)); continue + fi echo "::error::could not read discussion #${n} in ${REPO} (API error, not a missing idea) — sweep aborted" >&2 return 1 fi diff --git a/scripts/decision-brief/validate-package.sh b/scripts/decision-brief/validate-package.sh index c74d74a3e..c203e5256 100644 --- a/scripts/decision-brief/validate-package.sh +++ b/scripts/decision-brief/validate-package.sh @@ -252,15 +252,15 @@ vp_check_decision() { } vp_check_provenance() { - local dir="$1" path hits sec - # brief.md: every section (a Reach figure in §5 or a number in §8 is a market - # claim too). Footnotes resolve against the brief itself. + local dir="$1" path hits + # brief.md: the whole body after the frontmatter, whatever the target's + # template names its sections (a Reach figure in §5, a number in §8 or in a + # "## Proposed solution" is a market claim too). Footnotes resolve against + # the brief itself. path="$dir/brief.md" if [[ -f "$path" ]]; then - hits="" - for sec in problem "target user" market opportunity value "riskiest assumption" recommendation "open questions"; do - hits+="$(vp_section_body "$path" "$sec" | vp_unsourced_claims "$path")" - done + hits="$(awk 'NR==1 && $0=="---" {infm=1; next} infm && $0=="---" {infm=0; next} !infm {print}' "$path" \ + | vp_unsourced_claims "$path")" if [[ -n "$hits" ]]; then vp_fail brief.md provenance "quantified market claim without a source / 'unknown — needs discovery' / [ASSUMPTION]: $(printf '%s' "$hits" | head -2 | tr '\n' ' ' | cut -c1-220)" else diff --git a/tests/test_decision_brief.bats b/tests/test_decision_brief.bats index 5bd63965f..9f9651730 100644 --- a/tests/test_decision_brief.bats +++ b/tests/test_decision_brief.bats @@ -298,6 +298,18 @@ About 300k teams use a scheduler [^8]. grep -q 'FAIL|ost.md|provenance' <<<"$output" } +@test "provenance: the whole brief body is scanned, whatever the target template names its sections" { + pkg="$(copy_good)" + sed -i.bak '/^## 8\. Open questions$/i\ +## Proposed solution\ +Ship it to the 5,000 developers in the org.\ + +' "$pkg/brief.md" + validate "$pkg" + [ "$status" -ne 0 ] + grep -q 'FAIL|brief.md|provenance|.*5,000 developers' <<<"$output" +} + @test "provenance: an unsourced figure in brief.md Problem / Market sections fails; §5 arithmetic does not" { pkg="$(copy_good)" sed -i.bak '/^## 1\. Problem$/a\ @@ -454,6 +466,27 @@ exit 0' [ "$output" = "12,15" ] } +@test "ripe-from-queue: a GraphQL NOT_FOUND (an issue number in the Ripe table) is skipped, not fatal" { + export QUEUE_FILE="$FIX/queue-body.md" + mock_gh ' +case "$*" in + *"issue list --repo acme/widgets --label idea-triage"*) jq -Rs "{title:\"🧭 Idea Promotion Queue\", body:.}" < "$QUEUE_FILE" | jq -r ".body" ;; + *graphql*) + case "$*" in + *"num=413"*) echo "{\"data\":{\"repository\":{\"discussion\":null}},\"errors\":[{\"type\":\"NOT_FOUND\",\"path\":[\"repository\",\"discussion\"],\"message\":\"Could not resolve to a Discussion\"}]}"; exit 1 ;; + *) echo "{\"data\":{\"repository\":{\"discussion\":{\"title\":\"DemandRadar\",\"category\":{\"name\":\"Ideas\"},\"labels\":{\"nodes\":[]}}}}}" ;; + esac ;; + *"contents/"*) exit 1 ;; + *) : ;; +esac +exit 0' + REPO="acme/widgets" run bash "$DB_DIR/ripe-from-queue.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"skip #413: not a Discussion"* ]] + grep -qF -- "-f discussion=44" "$GH_LOG" + ! grep -qF -- "-f discussion=413" "$GH_LOG" || false +} + @test "ripe-from-queue: an API failure reading a Discussion fails the sweep loudly instead of skipping the idea" { export QUEUE_FILE="$FIX/queue-body.md" mock_gh ' @@ -554,7 +587,7 @@ exit 0' @test "gather-context: reads the TARGET repo only, derives the slug, finds the enhancement comment and the template" { mock_gh_gather CTX="$TMP/ctx.json" - GITHUB_ENV="$TMP/env" REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$CTX" \ + GITHUB_ENV="$TMP/env" GITHUB_OUTPUT="$TMP/out" REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$CTX" \ run bash "$DB_DIR/gather-context.sh" [ "$status" -eq 0 ] grep -qF -- "owner=acme" "$GH_LOG" @@ -570,6 +603,10 @@ exit 0' [ "$(jq -r '.referenced_issues[0].number' "$CTX")" = "12" ] grep -q '^DISCUSSION_NODE_ID=D_target$' "$TMP/env" grep -q '^SLUG=widget-lint$' "$TMP/env" + # Also exported as step outputs for the isolated land job. + grep -q '^slug=widget-lint$' "$TMP/out" + grep -q '^discussion_node_id=D_target$' "$TMP/out" + grep -q 'WidgetLint' "$TMP/out" } @test "gather-context: writes the target package-spec to SPEC_PATH for the validator" { diff --git a/tests/test_decision_brief_workflow.bats b/tests/test_decision_brief_workflow.bats index 18923b8a4..d9a111a34 100644 --- a/tests/test_decision_brief_workflow.bats +++ b/tests/test_decision_brief_workflow.bats @@ -43,15 +43,28 @@ setup() { cd "$ROOT"; } grep -qE "vars\.AGENTS_PAUSED == 'true' &&" "$CENTRAL" } -@test "central: the provenance gate is its own step AFTER the model step and BEFORE the PR step" { - action_line="$(grep -n 'uses: anthropics/claude-code-action' "$CENTRAL" | cut -d: -f1)" - gate_line="$(grep -n 'name: Provenance + structure gate' "$CENTRAL" | cut -d: -f1)" - pr_line="$(grep -n 'run: bash scripts/decision-brief/open-pr.sh' "$CENTRAL" | cut -d: -f1)" - [ -n "$action_line" ] && [ -n "$gate_line" ] && [ -n "$pr_line" ] - [ "$action_line" -lt "$gate_line" ] - [ "$gate_line" -lt "$pr_line" ] - # The gate step runs the validator the model is also told to run. - grep -qc 'scripts/decision-brief/validate-package.sh' "$CENTRAL" +@test "central: the provenance gate and the PR run in a separate land job, isolated from the model step" { + # The brief job ends with the artifact upload: nothing privileged runs after + # the model step in its (model-writable) workspace. + brief="$(sed -n '/^ brief:/,/^ # ── validate + land/p' "$CENTRAL")" + grep -q 'uses: anthropics/claude-code-action' <<<"$brief" + ! grep -q 'name: Provenance + structure gate' <<<"$brief" || false + ! grep -q 'run: bash scripts/decision-brief/open-pr.sh' <<<"$brief" || false + ! grep -qE 'GH_PAT_(DON_PETRY|WORKFLOWS)' <<<"$(sed -n '/uses: anthropics\/claude-code-action/,$p' <<<"$brief")" || false + # The land job: needs brief, fresh checkout, downloads only the artifact, gate before PR. + land="$(sed -n '/^ land:/,/^ fleet-paused:/p' "$CENTRAL")" + grep -q 'needs: \[brief\]' <<<"$land" + grep -q 'uses: actions/checkout@' <<<"$land" + grep -qE 'uses: actions/download-artifact@[0-9a-f]{40}' <<<"$land" + gate_line="$(grep -n 'name: Provenance + structure gate' <<<"$land" | cut -d: -f1)" + pr_line="$(grep -n 'run: bash scripts/decision-brief/open-pr.sh' <<<"$land" | cut -d: -f1)" + co_line="$(grep -n 'uses: actions/checkout@' <<<"$land" | cut -d: -f1)" + [ "$co_line" -lt "$gate_line" ] && [ "$gate_line" -lt "$pr_line" ] + # Its inputs come from the gather step's outputs, not from $GITHUB_ENV the model could append to. + grep -q 'SLUG: ${{ needs.brief.outputs.slug }}' <<<"$land" + grep -q 'slug: ${{ steps.gather.outputs.slug }}' "$CENTRAL" + grep -q 'id: gather' "$CENTRAL" + # The validator is run by the model (prompt) and by the land job. [ "$(grep -c 'scripts/decision-brief/validate-package.sh' "$CENTRAL")" -ge 2 ] } @@ -71,16 +84,16 @@ setup() { cd "$ROOT"; } @test "central: target_repo parameterizes reads + writes; the write token is the PAT on every path, per step" { grep -q "REPO: \${{ github.event.inputs.target_repo || github.repository }}" "$CENTRAL" - # gather-context and open-pr carry the PAT in their OWN env; the job never asks for contents: write. + # gather-context and open-pr carry the PAT in their OWN env; no job asks for contents: write. gather="$(sed -n '/name: Gather brief context/,/name: Resolve model/p' "$CENTRAL")" grep -q 'GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}' <<<"$gather" - openpr="$(sed -n '/name: Open (or refresh) the brief PR/,/name: Upload the drafted package/p' "$CENTRAL")" + openpr="$(sed -n '/name: Open (or refresh) the brief PR/,/name: Upload dry-run PR log/p' "$CENTRAL")" grep -q 'GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }}' <<<"$openpr" - ! sed -n '/^ brief:/,/^ fleet-paused:/p' "$CENTRAL" | grep -qE '^\s+contents: write' || false + ! sed -n '/^ brief:/,$p' "$CENTRAL" | grep -qE '^\s+contents: write' || false } @test "central: the model step never sees the write PAT — not in its block, not via a job-level env" { - step="$(sed -n '/name: Draft the decision brief (BMAD Analyst)/,/name: Provenance + structure gate/p' "$CENTRAL")" + step="$(sed -n '/name: Draft the decision brief (BMAD Analyst)/,/name: Upload the drafted package/p' "$CENTRAL")" grep -q 'github_token: ${{ secrets.GITHUB_TOKEN }}' <<<"$step" grep -q 'GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}' <<<"$step" ! grep -qE 'GH_PAT_(DON_PETRY|WORKFLOWS)|TARGET_GH_TOKEN' <<<"$step" || false @@ -97,7 +110,9 @@ setup() { cd "$ROOT"; } grep -q 'SPEC_PATH=${{ runner.temp }}/package-spec.json' "$CENTRAL" gate="$(sed -n '/name: Provenance + structure gate/,/name: Open (or refresh)/p' "$CENTRAL")" grep -q -- '--spec "$SPEC_PATH"' <<<"$gate" - prompt="$(sed -n '/name: Draft the decision brief (BMAD Analyst)/,/name: Provenance + structure gate/p' "$CENTRAL")" + # The land job re-fetches the spec from the target, not from the model's workspace. + grep -q 'name: Fetch the target package spec' "$CENTRAL" + prompt="$(sed -n '/name: Draft the decision brief (BMAD Analyst)/,/name: Upload the drafted package/p' "$CENTRAL")" grep -q -- '--spec "$SPEC_PATH"' <<<"$prompt" } @@ -121,7 +136,7 @@ setup() { cd "$ROOT"; } } @test "central: a dry run uploads the drafted package and the intended-PR log" { - grep -q 'name: decision-brief-${{ env.SLUG || ' "$CENTRAL" + grep -q "name: decision-brief-\${{ steps.gather.outputs.slug || 'package' }}" "$CENTRAL" grep -q 'dry-run.jsonl' "$CENTRAL" grep -qE 'default: true' "$CENTRAL" # dry_run defaults to true on manual dispatch } From 10551b490f6a42115421585938f26705fa5cc3f0 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:37:55 -0500 Subject: [PATCH 6/8] fix(decision-brief): address the remaining Codex findings on 343c6d4e - gather-context: a 404 is "absent", any other fetch failure is fatal; all three ideas/_TEMPLATE files are required; an unparsable package-spec.json fails loudly; the NEWEST 50 comments are kept, with comments_total and comments_truncated recorded. - ripe-from-queue: the brief.md / open-PR idempotency probes abort on API errors instead of reading them as "absent" and dispatching a paid run. - validate-package: only the exact "## Sources" heading is the source list; "## Sources of demand" is provenance-scanned. - open-pr: refuses to append to an incubate/<slug> branch whose brief links another Discussion; the land job is serialized per (target, slug). - reusable: `maintain` collaborators are trusted; a failed permission lookup fails the bridge instead of reporting "untrusted". - Tests for each (74 + 24 cases in the two suites). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .github/workflows/decision-brief-reusable.yml | 10 +- .github/workflows/decision-brief.yml | 7 ++ scripts/decision-brief/gather-context.sh | 46 ++++++-- scripts/decision-brief/open-pr.sh | 14 ++- scripts/decision-brief/ripe-from-queue.sh | 21 +++- scripts/decision-brief/validate-package.sh | 39 +++++-- tests/test_decision_brief.bats | 103 ++++++++++++++++-- tests/test_decision_brief_workflow.bats | 6 + 8 files changed, 210 insertions(+), 36 deletions(-) diff --git a/.github/workflows/decision-brief-reusable.yml b/.github/workflows/decision-brief-reusable.yml index b543e5af5..e37d29916 100644 --- a/.github/workflows/decision-brief-reusable.yml +++ b/.github/workflows/decision-brief-reusable.yml @@ -98,9 +98,15 @@ jobs: echo "trusted=true" >> "$GITHUB_OUTPUT" exit 0 fi - PERM=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" --jq '.permission' 2>/dev/null || echo "none") + # A failed lookup is an API error, not an untrusted actor: fail the + # bridge loudly so the requested brief is visibly retriable instead of + # silently stranded behind an already-applied label. + if ! PERM=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" --jq '.permission'); then + echo "::error::could not read @${ACTOR}'s permission on ${REPO} — not dispatching; re-apply the label or dispatch by hand once the API recovers" + exit 1 + fi case "$PERM" in - admin|write) + admin|maintain|write) echo "Actor @${ACTOR} has permission ${PERM} — trusted." echo "trusted=true" >> "$GITHUB_OUTPUT" ;; diff --git a/.github/workflows/decision-brief.yml b/.github/workflows/decision-brief.yml index fe48a0ecc..ab1b6ca8f 100644 --- a/.github/workflows/decision-brief.yml +++ b/.github/workflows/decision-brief.yml @@ -403,6 +403,13 @@ jobs: needs.brief.result == 'success' runs-on: ubuntu-latest timeout-minutes: 10 + # Two Discussions can derive the same slug; the workflow-level group keys on + # the Discussion number (the slug is unknown until gather runs). Landing is + # therefore serialized per (target, slug) here, and open-pr.sh refuses a + # branch whose brief belongs to another Discussion. + concurrency: + group: decision-brief-land-${{ github.event.inputs.target_repo || github.repository }}-${{ needs.brief.outputs.slug }} + cancel-in-progress: false permissions: contents: read # fresh checkout of the tooling env: diff --git a/scripts/decision-brief/gather-context.sh b/scripts/decision-brief/gather-context.sh index 535467649..77ba6b899 100644 --- a/scripts/decision-brief/gather-context.sh +++ b/scripts/decision-brief/gather-context.sh @@ -8,7 +8,9 @@ # anything else Mary states must be cited or marked "unknown — needs discovery". # # What goes in: -# discussion title / body / url / labels / first 50 comments +# discussion title / body / url / labels / the NEWEST 50 comments +# (comments_total + comments_truncated say when older +# ones were left out) # enhancement the feature-ideation enhancement comment, if one exists # (marker <!-- feature-ideation:enhanced --> or the legacy # <!-- idea-enhancer:enhanced -->) — the sharpened problem, @@ -69,7 +71,7 @@ disc="$(gh api graphql -f query=' author{ login } category{ name slug } labels(first:20){ nodes{ name } } - comments(first:50){ nodes{ author{login} body createdAt url } } + comments(last:50){ totalCount nodes{ author{login} body createdAt url } } } } }' -F owner="$owner" -F name="$name" -F num="$DISCUSSION_NUMBER")" @@ -113,20 +115,42 @@ if queue_body="$(gh issue list --repo "$REPO" --label idea-triage --state open \ fi # ── the target repo's template + package spec (default branch) ─────────────── -_fetch_file() { # <path> — prints decoded content, or nothing when absent - gh api "repos/${REPO}/contents/${1}" --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true +# _fetch_file <path> [ref] — prints the decoded content. A 404 prints nothing +# and returns 0 (the file is absent); any OTHER failure (auth, rate limit, +# outage) returns 1 so the caller fails loudly instead of reading "absent". +_fetch_file() { + local path="$1" ref="${2:-}" out rc=0 + out="$(gh api "repos/${REPO}/contents/${path}${ref:+?ref=$ref}" --jq '.content' 2>"${TMPDIR:-/tmp}/db-fetch-err.$$")" || rc=$? + if [[ $rc -ne 0 ]]; then + if grep -qE 'HTTP 404|Not Found' "${TMPDIR:-/tmp}/db-fetch-err.$$"; then rm -f "${TMPDIR:-/tmp}/db-fetch-err.$$"; return 0; fi + echo "::error::could not read ${REPO}:${path}${ref:+@$ref} — $(tr '\n' ' ' <"${TMPDIR:-/tmp}/db-fetch-err.$$" | cut -c1-200)" >&2 + rm -f "${TMPDIR:-/tmp}/db-fetch-err.$$"; return 1 + fi + rm -f "${TMPDIR:-/tmp}/db-fetch-err.$$" + printf '%s' "$out" | base64 -d } tmpl_brief="$(_fetch_file "${TEMPLATE_DIR}/brief.md")" tmpl_mr="$(_fetch_file "${TEMPLATE_DIR}/market-research.md")" tmpl_ost="$(_fetch_file "${TEMPLATE_DIR}/ost.md")" -if [[ -z $tmpl_brief ]]; then - echo "::error::${REPO} has no ${TEMPLATE_DIR}/brief.md on its default branch — the decision-brief stage fills the TARGET repo's own template (copy ideas/_TEMPLATE/ from combsmith/incubator first)" >&2 +# All three templates are the target's contract for this package; a missing one +# would have Mary invent a shape of her own for that artifact. +missing_tmpl=() +[[ -n $tmpl_brief ]] || missing_tmpl+=("brief.md") +[[ -n $tmpl_mr ]] || missing_tmpl+=("market-research.md") +[[ -n $tmpl_ost ]] || missing_tmpl+=("ost.md") +if [[ ${#missing_tmpl[@]} -gt 0 ]]; then + echo "::error::${REPO} is missing ${TEMPLATE_DIR}/{${missing_tmpl[*]}} on its default branch — the decision-brief stage fills the TARGET repo's own templates (copy ideas/_TEMPLATE/ from combsmith/incubator first)" >&2 exit 1 fi spec_raw="$(_fetch_file "${IDEAS_DIR}/package-spec.json")" package_spec='null' -if [[ -n $spec_raw ]] && printf '%s' "$spec_raw" | jq -e . >/dev/null 2>&1; then - package_spec="$(printf '%s' "$spec_raw" | jq -c .)" +if [[ -n $spec_raw ]]; then + # A spec that exists but does not parse is a broken target contract, not "no + # spec": refuse to brief against a weaker, built-in-only gate. + if ! package_spec="$(printf '%s' "$spec_raw" | jq -c . 2>/dev/null)"; then + echo "::error::${REPO}:${IDEAS_DIR}/package-spec.json is not valid JSON — fix it before briefing" >&2 + exit 1 + fi # Materialise it so validate-package.sh can enforce the target's own # required_sections (--spec), not just the stage's built-in list. if [[ -n "${SPEC_PATH:-}" ]]; then printf '%s\n' "$package_spec" >"$SPEC_PATH"; fi @@ -145,7 +169,7 @@ existing_files="$(gh api "repos/${REPO}/contents/${IDEAS_DIR}/${slug}${_ref_q}" printf '%s' "$existing_files" | jq -e 'type=="array"' >/dev/null 2>&1 || existing_files='[]' existing_brief="" if printf '%s' "$existing_files" | jq -e 'index("brief.md") != null' >/dev/null; then - existing_brief="$(gh api "repos/${REPO}/contents/${IDEAS_DIR}/${slug}/brief.md${_ref_q}" --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true)" + existing_brief="$(_fetch_file "${IDEAS_DIR}/${slug}/brief.md" "$pkg_ref")" # Collision guard: the same slug derived from a different idea's title would # overwrite another package. A brief that does not link THIS discussion is # someone else's — stop and ask for an explicit SLUG. @@ -193,7 +217,9 @@ jq -n \ author: (.author.login // null), category: .category.name, labels: [.labels.nodes[].name], - comments: [.comments.nodes[] | {author: (.author.login // null), body, createdAt, url}]}), + comments: [.comments.nodes[] | {author: (.author.login // null), body, createdAt, url}], + comments_total: .comments.totalCount, + comments_truncated: (.comments.totalCount > (.comments.nodes | length))}), enhancement: $enhancement, queue_excerpt: $queue, template: {"brief.md": $tb, "market-research.md": $tm, "ost.md": $to}, diff --git a/scripts/decision-brief/open-pr.sh b/scripts/decision-brief/open-pr.sh index 22c379728..607612275 100644 --- a/scripts/decision-brief/open-pr.sh +++ b/scripts/decision-brief/open-pr.sh @@ -139,7 +139,19 @@ default_branch="$(gh api "repos/${REPO}" --jq '.default_branch')" base_sha="$(gh api "repos/${REPO}/git/ref/heads/${default_branch}" --jq '.object.sha')" parent_sha="$(gh api "repos/${REPO}/git/ref/heads/${branch}" --jq '.object.sha' 2>/dev/null || true)" branch_exists=0 -if [[ -n "$parent_sha" ]]; then branch_exists=1; else parent_sha="$base_sha"; fi +if [[ -n "$parent_sha" ]]; then + branch_exists=1 + # Two Discussions can derive the same slug and reach this point in parallel + # (gather's collision check ran before either branch existed). Before + # appending, the branch's brief must link THIS Discussion. + branch_brief="$(gh api "repos/${REPO}/contents/${dest_prefix}/brief.md?ref=${branch}" --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true)" + if [[ -n "$branch_brief" ]] && ! grep -qE "/discussions/${DISCUSSION_NUMBER}([^0-9]|$)" <<<"$branch_brief"; then + echo "::error::${branch} already carries a brief for another Discussion — slug collision; dispatch again with an explicit slug" >&2 + exit 1 + fi +else + parent_sha="$base_sha" +fi base_tree="$(gh api "repos/${REPO}/git/commits/${parent_sha}" --jq '.tree.sha')" # ── one commit: blobs → tree → commit → ref ─────────────────────────────────── diff --git a/scripts/decision-brief/ripe-from-queue.sh b/scripts/decision-brief/ripe-from-queue.sh index f61ab5ddd..551e53cff 100644 --- a/scripts/decision-brief/ripe-from-queue.sh +++ b/scripts/decision-brief/ripe-from-queue.sh @@ -124,11 +124,24 @@ dbq_main() { if [[ ",${labels}," == *",idea:approved,"* ]]; then echo "skip #${n}: already idea:approved"; skipped=$((skipped+1)); continue; fi slug="$(db_slug_from_title "$title")" if ! db_slug_is_valid "$slug"; then echo "skip #${n}: no valid slug from '${title}'"; skipped=$((skipped+1)); continue; fi - if gh api "repos/${REPO}/contents/${IDEAS_DIR}/${slug}/brief.md" --jq '.sha' >/dev/null 2>&1; then - echo "skip #${n}: ${IDEAS_DIR}/${slug}/brief.md already exists"; skipped=$((skipped+1)); continue + # Idempotency probes: "absent" is only a 404 / an empty list. Any other + # failure (auth, rate limit, outage) aborts rather than dispatching a paid + # run that could regenerate a landed package or overwrite one in flight. + local probe_err="${TMPDIR:-/tmp}/db-probe-err.$$" rc=0 + if gh api "repos/${REPO}/contents/${IDEAS_DIR}/${slug}/brief.md" --jq '.sha' >/dev/null 2>"$probe_err"; then + rm -f "$probe_err"; echo "skip #${n}: ${IDEAS_DIR}/${slug}/brief.md already exists"; skipped=$((skipped+1)); continue + elif ! grep -qE 'HTTP 404|Not Found' "$probe_err"; then + echo "::error::could not probe ${REPO}:${IDEAS_DIR}/${slug}/brief.md — $(tr '\n' ' ' <"$probe_err" | cut -c1-200) — sweep aborted" >&2 + rm -f "$probe_err"; return 1 fi - if [[ -n "$(gh pr list --repo "$REPO" --head "${BRANCH_PREFIX}/${slug}" --state open --json number --jq '.[0].number // empty' 2>/dev/null || true)" ]]; then - echo "skip #${n}: a brief PR from ${BRANCH_PREFIX}/${slug} is already open"; skipped=$((skipped+1)); continue + rm -f "$probe_err" + local open_pr + open_pr="$(gh pr list --repo "$REPO" --head "${BRANCH_PREFIX}/${slug}" --state open --json number --jq '.[0].number // empty')" || rc=$? + if [[ $rc -ne 0 ]]; then + echo "::error::could not list open PRs from ${BRANCH_PREFIX}/${slug} in ${REPO} — sweep aborted" >&2; return 1 + fi + if [[ -n "$open_pr" ]]; then + echo "skip #${n}: a brief PR from ${BRANCH_PREFIX}/${slug} is already open (#${open_pr})"; skipped=$((skipped+1)); continue fi if [[ "$is_dry" == "1" ]]; then printf '%s\n' "$(jq -nc --arg op dispatch_brief --arg central "$CENTRAL_REPO" --arg target "$REPO" \ diff --git a/scripts/decision-brief/validate-package.sh b/scripts/decision-brief/validate-package.sh index c203e5256..90e1ec0f3 100644 --- a/scripts/decision-brief/validate-package.sh +++ b/scripts/decision-brief/validate-package.sh @@ -83,6 +83,34 @@ vp_section_body() { ' "$file" } +# vp_has_sources <file> / vp_sources_body <file> — the `## Sources` section by +# EXACT heading text (any level, case-insensitive, trailing punctuation +# ignored). A heading such as "## Sources of demand" is prose that must be +# provenance-scanned, not the source list. +VP_SOURCES_HEADING_RE='^#{1,6}[[:space:]]+sources[[:space:]:.]*$' +vp_has_sources() { grep -qiE -- "$VP_SOURCES_HEADING_RE" "$1" 2>/dev/null; } +vp_sources_body() { + awk -v re="$VP_SOURCES_HEADING_RE" ' + /^#{1,6}[[:space:]]/ { + lvl = match($0, /^#+/) ? RLENGTH : 0 + if (insec && lvl <= seclvl) exit + if (!insec && tolower($0) ~ re) { insec = 1; seclvl = lvl; next } + } + insec { print } + ' "$1" +} +# vp_body_outside_sources <file> — every line except the exact Sources section. +vp_body_outside_sources() { + awk -v re="$VP_SOURCES_HEADING_RE" ' + /^#{1,6}[[:space:]]/ { + lvl = match($0, /^#+/) ? RLENGTH : 0 + if (insrc && lvl <= srclvl) insrc = 0 + if (!insrc && tolower($0) ~ re) { insrc = 1; srclvl = lvl } + } + !insrc { print } + ' "$1" +} + # vp_strip_comments <file> — the file with HTML comments removed (multi-line). vp_strip_comments() { perl -0pe 's/<!--.*?-->//gs' "$1" @@ -281,19 +309,16 @@ vp_check_provenance() { # market-research.md: everything but Sources. path="$dir/market-research.md" if [[ -f "$path" ]]; then - hits="$(awk ' - /^#{1,6}[[:space:]]/ { insrc = (tolower($0) ~ /sources/) } - !insrc { print } - ' "$path" | vp_unsourced_claims "$path")" + hits="$(vp_body_outside_sources "$path" | vp_unsourced_claims "$path")" if [[ -n "$hits" ]]; then vp_fail market-research.md provenance "quantified market claim without a source / 'unknown — needs discovery' / [ASSUMPTION]: $(printf '%s' "$hits" | head -2 | tr '\n' ' ' | cut -c1-220)" else vp_pass market-research.md provenance fi local src - src="$(vp_section_body "$path" "sources")" - if ! vp_has_section "$path" "Sources"; then - vp_fail market-research.md sources "no '## Sources' section" + src="$(vp_sources_body "$path")" + if ! vp_has_sources "$path"; then + vp_fail market-research.md sources "no '## Sources' section (exact heading)" elif ! printf '%s\n' "$src" | grep -qE 'https?://' && ! printf '%s\n' "$src" | grep -qi 'no external sources were consulted'; then vp_fail market-research.md sources "Sources lists no URL and does not state 'No external sources were consulted'" else diff --git a/tests/test_decision_brief.bats b/tests/test_decision_brief.bats index 9f9651730..f63a85240 100644 --- a/tests/test_decision_brief.bats +++ b/tests/test_decision_brief.bats @@ -350,6 +350,22 @@ Willingness to pay per seat: unknown — needs discovery. [ "$status" -eq 0 ] } +@test "provenance: only the EXACT '## Sources' heading is the source list; 'Sources of demand' is scanned" { + pkg="$(copy_good)" + sed -i.bak 's/^## Sources$/## Sources of demand\ +Roughly 40% of SMBs want this.\ +\ +## Sources/' "$pkg/market-research.md" + validate "$pkg" + [ "$status" -ne 0 ] + grep -q 'FAIL|market-research.md|provenance|.*40%' <<<"$output" + # Only "Sources of demand", no real Sources section → the sources check fails too. + pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" + sed -i.bak 's/^## Sources$/## Sources of demand/; s/^Roughly.*//' "$pkg2/market-research.md" + validate "$pkg2" + grep -q "FAIL|market-research.md|sources|no '## Sources' section" <<<"$output" +} + @test "provenance: lines inside the Sources section and fenced code are not scanned" { pkg="$(copy_good)" printf '%s\n' '[^3]: https://example.com/report — "$5B market, 20% CAGR"' >>"$pkg/market-research.md" @@ -397,6 +413,7 @@ case "$*" in "api repos/acme/widgets/git/ref/heads/main --jq .object.sha") echo base000 ;; "api repos/acme/widgets/git/ref/heads/incubate/widget-lint --jq .object.sha") if [ "${BRANCH_EXISTS:-0}" = 1 ]; then echo head111; else echo "not found" >&2; exit 1; fi ;; + *"contents/ideas/widget-lint/brief.md?ref=incubate/widget-lint"*) printf "%s" "${BRANCH_BRIEF:-# brief for https://github.com/acme/widgets/discussions/413}" | base64 | tr -d "\n" ;; api\ repos/acme/widgets/git/commits/*) echo tree222 ;; "api repos/acme/widgets/git/blobs -X POST --input - --jq .sha") cat >>"$GH_LOG.blobs"; echo blob333 ;; "api repos/acme/widgets/git/trees -X POST --input - --jq .sha") cat >>"$GH_LOG.tree"; echo tree444 ;; @@ -445,6 +462,18 @@ exit 0' [[ "$output" == *"refreshed existing PR"* ]] } +@test "open-pr (live): refuses to append to a branch whose brief belongs to another Discussion" { + pkg="$(copy_good)" + mock_gh_live + BRANCH_EXISTS=1 BRANCH_BRIEF="# brief for https://github.com/acme/widgets/discussions/99" \ + REPO="acme/widgets" SLUG="widget-lint" PACKAGE_DIR="$pkg" DISCUSSION_NUMBER=413 GIT_USER_NAME=t \ + run bash "$DB_DIR/open-pr.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"slug collision"* ]] + ! grep -q 'git/blobs' "$GH_LOG" || false + ! grep -q 'git/refs' "$GH_LOG" || false +} + @test "open-pr: the commit author defaults to the business-analyst persona's runtime account" { pkg="$(copy_good)" mock_gh_live @@ -476,7 +505,7 @@ case "$*" in *"num=413"*) echo "{\"data\":{\"repository\":{\"discussion\":null}},\"errors\":[{\"type\":\"NOT_FOUND\",\"path\":[\"repository\",\"discussion\"],\"message\":\"Could not resolve to a Discussion\"}]}"; exit 1 ;; *) echo "{\"data\":{\"repository\":{\"discussion\":{\"title\":\"DemandRadar\",\"category\":{\"name\":\"Ideas\"},\"labels\":{\"nodes\":[]}}}}}" ;; esac ;; - *"contents/"*) exit 1 ;; + *"contents/"*) echo "gh: Not Found (HTTP 404)" >&2; exit 1 ;; *) : ;; esac exit 0' @@ -513,9 +542,9 @@ case "$*" in *"num=44"*) echo "{\"data\":{\"repository\":{\"discussion\":{\"title\":\"DemandRadar\",\"category\":{\"name\":\"Ideas\"},\"labels\":{\"nodes\":[]}}}}}" ;; *) echo "{}" ;; esac ;; - *"contents/ideas/widget-lint/brief.md"*) if [ "${BRIEF_EXISTS:-0}" = 1 ]; then echo sha; else exit 1; fi ;; - *"contents/"*) exit 1 ;; - *"pr list"*) : ;; + *"contents/ideas/widget-lint/brief.md"*) if [ "${BRIEF_EXISTS:-0}" = 1 ]; then echo sha; elif [ "${PROBE_OUTAGE:-0}" = 1 ]; then echo "gh: HTTP 503" >&2; exit 1; else echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi ;; + *"contents/"*) echo "gh: Not Found (HTTP 404)" >&2; exit 1 ;; + *"pr list"*) if [ "${PRLIST_OUTAGE:-0}" = 1 ]; then echo "gh: HTTP 503" >&2; exit 1; fi ;; *) : ;; esac exit 0' @@ -549,6 +578,20 @@ exit 0' [[ "$output" == *"cap reached"* ]] } +@test "ripe-from-queue: an API error on the idempotency probes aborts instead of dispatching" { + export QUEUE_FILE="$FIX/queue-body.md" + mock_gh_sweep + PROBE_OUTAGE=1 REPO="acme/widgets" run bash "$DB_DIR/ripe-from-queue.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"could not probe"* ]] + ! grep -q 'workflow run' "$GH_LOG" || false + : >"$GH_LOG" + PRLIST_OUTAGE=1 REPO="acme/widgets" run bash "$DB_DIR/ripe-from-queue.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"could not list open PRs"* ]] + ! grep -q 'workflow run' "$GH_LOG" || false +} + @test "ripe-from-queue: DRY_RUN logs the intended dispatches; no queue issue is a clean no-op" { export QUEUE_FILE="$FIX/queue-body.md" mock_gh_sweep @@ -567,17 +610,23 @@ exit 0' mock_gh_gather() { mock_gh ' case "$*" in - *graphql*) echo "{\"data\":{\"repository\":{\"discussion\":{\"id\":\"D_target\",\"title\":\"💡 WidgetLint — a lint for manifests\",\"body\":\"see #12\",\"url\":\"https://github.com/acme/widgets/discussions/413\",\"createdAt\":\"t\",\"updatedAt\":\"t\",\"author\":{\"login\":\"don\"},\"category\":{\"name\":\"Ideas\",\"slug\":\"ideas\"},\"labels\":{\"nodes\":[{\"name\":\"needs-brief\"}]},\"comments\":{\"nodes\":[{\"author\":{\"login\":\"bot\"},\"body\":\"<!-- feature-ideation:enhanced -->\\n## Enhancement\",\"createdAt\":\"t\",\"url\":\"u\"}]}}}}}" ;; + *graphql*) echo "{\"data\":{\"repository\":{\"discussion\":{\"id\":\"D_target\",\"title\":\"💡 WidgetLint — a lint for manifests\",\"body\":\"see #12\",\"url\":\"https://github.com/acme/widgets/discussions/413\",\"createdAt\":\"t\",\"updatedAt\":\"t\",\"author\":{\"login\":\"don\"},\"category\":{\"name\":\"Ideas\",\"slug\":\"ideas\"},\"labels\":{\"nodes\":[{\"name\":\"needs-brief\"}]},\"comments\":{\"totalCount\":1,\"nodes\":[{\"author\":{\"login\":\"bot\"},\"body\":\"<!-- feature-ideation:enhanced -->\\n## Enhancement\",\"createdAt\":\"t\",\"url\":\"u\"}]}}}}}" ;; *"issue list --repo acme/widgets --label idea-triage"*) echo "${QUEUE_JSON:-}" ;; - *"contents/ideas/_TEMPLATE/brief.md"*) if [ "${NO_TEMPLATE:-0}" = 1 ]; then exit 1; fi; base64 < "'"$FIX"'/template/brief.md" | tr -d "\n" ;; + *"contents/ideas/_TEMPLATE/brief.md"*) if [ "${NO_TEMPLATE:-0}" = 1 ]; then echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi; base64 < "'"$FIX"'/template/brief.md" | tr -d "\n" ;; *"contents/ideas/_TEMPLATE/market-research.md"*) base64 < "'"$FIX"'/template/market-research.md" | tr -d "\n" ;; - *"contents/ideas/_TEMPLATE/ost.md"*) base64 < "'"$FIX"'/template/ost.md" | tr -d "\n" ;; - *"contents/ideas/package-spec.json"*) printf "{\"required_artifacts\":[]}" | base64 | tr -d "\n" ;; + *"contents/ideas/_TEMPLATE/ost.md"*) if [ "${NO_OST:-0}" = 1 ]; then echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi; base64 < "'"$FIX"'/template/ost.md" | tr -d "\n" ;; + *"contents/ideas/package-spec.json"*) + case "${SPEC_MODE:-ok}" in + ok) printf "{\"required_artifacts\":[]}" | base64 | tr -d "\n" ;; + invalid) printf "{not json" | base64 | tr -d "\n" ;; + absent) echo "gh: Not Found (HTTP 404)" >&2; exit 1 ;; + outage) echo "gh: HTTP 502 Bad Gateway" >&2; exit 1 ;; + esac ;; *"git/ref/heads/incubate/widget-lint"*) if [ "${BRANCH_EXISTS:-0}" = 1 ]; then echo headsha; else exit 1; fi ;; *"contents/ideas/widget-lint/brief.md?ref=incubate/widget-lint"*) printf "%s" "${BRANCH_BRIEF:-}" | base64 | tr -d "\n" ;; *"contents/ideas/widget-lint?ref=incubate/widget-lint"*) echo "[\"brief.md\"]" ;; *"contents/ideas/widget-lint/brief.md"*) printf "%s" "${DEFAULT_BRIEF:-}" | base64 | tr -d "\n" ;; - *"contents/ideas/widget-lint"*) if [ -n "${DEFAULT_BRIEF:-}" ]; then echo "[\"brief.md\"]"; else exit 1; fi ;; + *"contents/ideas/widget-lint"*) if [ -n "${DEFAULT_BRIEF:-}" ]; then echo "[\"brief.md\"]"; else echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi ;; "api repos/acme/widgets/issues/12 --jq "*) echo "{\"number\":12,\"title\":\"prereq\",\"state\":\"open\",\"url\":\"u\"}" ;; *) echo "{}" ;; esac @@ -649,12 +698,42 @@ exit 0' jq -e '.existing_package["brief.md"] | contains("discussions/413")' "$TMP/ctx2.json" >/dev/null } -@test "gather-context: fails loud when the target has no ideas/_TEMPLATE/brief.md" { - NO_TEMPLATE=1 mock_gh_gather +@test "gather-context: fails loud when the target lacks ANY of the three templates" { + mock_gh_gather export NO_TEMPLATE=1 REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" run bash "$DB_DIR/gather-context.sh" [ "$status" -ne 0 ] - [[ "$output" == *"has no ideas/_TEMPLATE/brief.md"* ]] + [[ "$output" == *"missing ideas/_TEMPLATE/{brief.md}"* ]] + unset NO_TEMPLATE; export NO_OST=1 + REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"missing ideas/_TEMPLATE/{ost.md}"* ]] +} + +@test "gather-context: an absent package-spec is fine; an invalid one or a fetch outage fails loud" { + mock_gh_gather + export SPEC_MODE=absent + REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" SPEC_PATH="$TMP/spec.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -eq 0 ] + [ "$(jq -r '.package_spec' "$TMP/ctx.json")" = "null" ] + [ ! -e "$TMP/spec.json" ] + export SPEC_MODE=invalid + REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx2.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"not valid JSON"* ]] + export SPEC_MODE=outage + REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx3.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"could not read acme/widgets:ideas/package-spec.json"* ]] +} + +@test "gather-context: keeps the NEWEST comments page and flags truncation" { + mock_gh_gather + REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -eq 0 ] + grep -q 'comments(last:50)' "$GH_LOG" + [ "$(jq -r '.discussion.comments_total' "$TMP/ctx.json")" = "1" ] + [ "$(jq -r '.discussion.comments_truncated' "$TMP/ctx.json")" = "false" ] } @test "gather-context: an explicit SLUG override wins; an invalid one is rejected" { diff --git a/tests/test_decision_brief_workflow.bats b/tests/test_decision_brief_workflow.bats index d9a111a34..6d1cb02c3 100644 --- a/tests/test_decision_brief_workflow.bats +++ b/tests/test_decision_brief_workflow.bats @@ -62,6 +62,8 @@ setup() { cd "$ROOT"; } [ "$co_line" -lt "$gate_line" ] && [ "$gate_line" -lt "$pr_line" ] # Its inputs come from the gather step's outputs, not from $GITHUB_ENV the model could append to. grep -q 'SLUG: ${{ needs.brief.outputs.slug }}' <<<"$land" + # Landing is serialized per (target, slug) so same-slug ideas cannot race. + grep -q 'group: decision-brief-land-${{ github.event.inputs.target_repo || github.repository }}-${{ needs.brief.outputs.slug }}' <<<"$land" grep -q 'slug: ${{ steps.gather.outputs.slug }}' "$CENTRAL" grep -q 'id: gather' "$CENTRAL" # The validator is run by the model (prompt) and by the land job. @@ -157,6 +159,10 @@ setup() { cd "$ROOT"; } grep -q "github.event_name == 'schedule'" "$REUSABLE" grep -q 'collaborators/${ACTOR}/permission' "$REUSABLE" grep -q "if: steps.trust.outputs.trusted == 'true'" "$REUSABLE" + # maintain counts as trusted; a failed lookup is fatal, not "untrusted". + grep -qE '^\s+admin\|maintain\|write\)' "$REUSABLE" + grep -q "could not read @\${ACTOR}'s permission" "$REUSABLE" + ! grep -q '|| echo "none"' "$REUSABLE" || false } @test "reusable: dispatches the central workflow with target_repo=caller, dry_run=false; sweep uses from_queue=true" { From 9b28543e1838198872423959c14ca5a60dd7a3f9 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:49:22 -0500 Subject: [PATCH 7/8] fix(decision-brief): close four provenance-gate gaps from the Codex review of 10551b49 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validate-package: headings are scanned for quantified claims; the score line must show the factor arithmetic (ICE = 3 × 1 × 2 = 6, not ICE = 6); the recommendation check reads the visible section with HTML comments stripped. - open-pr: reading the branch's brief for the collision check fails closed on anything but a 404. - Tests for each (52 in the tooling suite). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- scripts/decision-brief/open-pr.sh | 13 +++++- scripts/decision-brief/validate-package.sh | 28 +++++++----- tests/test_decision_brief.bats | 51 +++++++++++++++++++++- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/scripts/decision-brief/open-pr.sh b/scripts/decision-brief/open-pr.sh index 607612275..90e06e0a8 100644 --- a/scripts/decision-brief/open-pr.sh +++ b/scripts/decision-brief/open-pr.sh @@ -144,7 +144,18 @@ if [[ -n "$parent_sha" ]]; then # Two Discussions can derive the same slug and reach this point in parallel # (gather's collision check ran before either branch existed). Before # appending, the branch's brief must link THIS Discussion. - branch_brief="$(gh api "repos/${REPO}/contents/${dest_prefix}/brief.md?ref=${branch}" --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true)" + # Only a 404 means "no brief on the branch yet"; any other failure must not + # read as "no collision" — fail closed. + err_file="$(mktemp)" + if branch_brief_b64="$(gh api "repos/${REPO}/contents/${dest_prefix}/brief.md?ref=${branch}" --jq '.content' 2>"$err_file")"; then + branch_brief="$(printf '%s' "$branch_brief_b64" | base64 -d)" + elif grep -qE 'HTTP 404|Not Found' "$err_file"; then + branch_brief="" + else + echo "::error::could not read ${dest_prefix}/brief.md on ${branch} to check for a slug collision — $(tr '\n' ' ' <"$err_file" | cut -c1-200); not appending" >&2 + rm -f "$err_file"; exit 1 + fi + rm -f "$err_file" if [[ -n "$branch_brief" ]] && ! grep -qE "/discussions/${DISCUSSION_NUMBER}([^0-9]|$)" <<<"$branch_brief"; then echo "::error::${branch} already carries a brief for another Discussion — slug collision; dispatch again with an explicit slug" >&2 exit 1 diff --git a/scripts/decision-brief/validate-package.sh b/scripts/decision-brief/validate-package.sh index 90e1ec0f3..d62940f8b 100644 --- a/scripts/decision-brief/validate-package.sh +++ b/scripts/decision-brief/validate-package.sh @@ -16,15 +16,16 @@ # case-insensitive prefix, no template placeholders) and add the stage's own # decision-grade requirements from combsmith/incubator#3: # -# score brief.md shows a RICE or ICE score line WITH its factors -# (Confidence must be stated — the BA advisory ties it to how -# strong the cited evidence is) +# score brief.md shows a RICE or ICE score line WITH the factor +# arithmetic ("ICE = 3 × 1 × 2 = 6", not "ICE = 6"; Confidence +# must be stated — the BA advisory ties it to how strong the +# cited evidence is) # assumptions the riskiest-assumptions table has a "Cheapest test" column # and at least one fully-filled row whose Cheapest-test cell is # not empty -# decision the Recommendation section states exactly one bold position -# (**go** / **no-go** / **park**) and names the human gate -# (`idea:approved`) +# decision the visible Recommendation section (HTML comments stripped) +# states exactly one bold position (**go** / **no-go** / +# **park**) and names the human gate (`idea:approved`) # provenance in the Problem / Target user / Market sections of brief.md and # every section of market-research.md except Sources, any line # that states a quantified market claim (a number with %, $, @@ -140,7 +141,8 @@ VP_CITED_RE='(https?://|unknown — needs discovery|unknown - needs discovery|\[ # vp_unsourced_claims [defs-file] <text-on-stdin> — print each line that # quantifies a market claim without a citation / unknown / assumption tag. -# Table separator rows and headers are skipped; fenced code is skipped. A line +# Headings are scanned as text ("## $20B market opportunity" is a claim); +# table separator rows and fenced code are skipped. A line # whose only citation is a [^n] footnote is accepted only when <defs-file> # (the artifact itself) carries a `[^n]: … http(s)://…` definition — a dangling # footnote is not a source. @@ -158,7 +160,7 @@ vp_unsourced_claims() { done < <(awk ' /^```/ { fence = !fence; next } fence { next } - /^#{1,6}[[:space:]]/ { next } + /^#{1,6}[[:space:]]/ { sub(/^#+[[:space:]]+/, ""); print; next } # a claim in a heading is still a claim /^\|[[:space:]]*-+/ { next } { print } ' | grep -iE -- "$VP_QUANT_RE" || true) @@ -236,8 +238,10 @@ vp_check_score() { [[ -f "$path" ]] || return 0 body="$(vp_section_body "$path" "value")" [[ -n "$body" ]] || body="$(vp_section_body "$path" "prioriti")" - if ! printf '%s\n' "$body" | grep -qE '\b(RICE|ICE)\b.*=.*[0-9]'; then - vp_fail brief.md score "no RICE/ICE score line (expected e.g. 'ICE = 3 × 1 × 2 = 6') in the Value & feasibility section" + # The score line must SHOW the arithmetic — "ICE = 3 × 1 × 2 = 6", not + # "ICE = 6": at least two factors combined with ×, *, x or / before the result. + if ! printf '%s\n' "$body" | grep -qE '\b(RICE|ICE)\b[^=]*=[[:space:]]*\(?[[:space:]]*[0-9][0-9.]*([[:space:]]*(×|\*|x|/|÷)[[:space:]]*[0-9][0-9.]*)+[[:space:]]*\)?([[:space:]]*/[[:space:]]*[0-9][0-9.]*)?[[:space:]]*=[[:space:]]*[0-9]'; then + vp_fail brief.md score "no RICE/ICE score line showing the factor arithmetic (expected e.g. 'ICE = 3 × 1 × 2 = 6') in the Value & feasibility section" elif ! printf '%s\n' "$body" | grep -qi 'confidence'; then vp_fail brief.md score "score shown without its Confidence factor (state it and tie it to the evidence strength)" else @@ -266,7 +270,9 @@ vp_check_decision() { local dir="$1" body local path="$dir/brief.md" [[ -f "$path" ]] || return 0 - body="$(vp_section_body "$path" "recommendation")" + # Scan the VISIBLE section only: a position hidden in an HTML comment is not + # a recommendation. Comments are stripped before the section is cut. + body="$(vp_strip_comments "$path" | vp_section_body /dev/stdin "recommendation")" # Exactly ONE bold position — `**go**`, `**no-go**`, `**park**` or # `**Recommendation: <position>**` — so an incidental "go or park" in prose # is not read as a decision. diff --git a/tests/test_decision_brief.bats b/tests/test_decision_brief.bats index f63a85240..4a2338c60 100644 --- a/tests/test_decision_brief.bats +++ b/tests/test_decision_brief.bats @@ -165,6 +165,16 @@ validate() { run bash "$DB_DIR/validate-package.sh" "$@"; } sed -i.bak 's/Confidence = 2\/5 (thread evidence, no measured rate)/Conf. 2/' "$pkg2/brief.md" validate "$pkg2" grep -q 'FAIL|brief.md|score|.*Confidence' <<<"$output" + # A bare result with no factor arithmetic is not auditable. + pkg3="$TMP/pkg3"; cp -R "$FIX/good" "$pkg3" + sed -i.bak 's/ICE = 3 × 2 × 4 = 24 \/ 125/ICE = 24 (Confidence: low)/' "$pkg3/brief.md" + validate "$pkg3" + grep -q 'FAIL|brief.md|score|.*factor arithmetic' <<<"$output" + # ASCII operators and RICE with a divisor pass. + pkg4="$TMP/pkg4"; cp -R "$FIX/good" "$pkg4" + sed -i.bak 's#ICE = 3 × 2 × 4 = 24 / 125#RICE = (200 * 3 * 2) / 4 = 300#' "$pkg4/brief.md" + validate "$pkg4" + [ "$status" -eq 0 ] } @test "validate: the riskiest-assumptions table needs a Cheapest-test column and one full row" { @@ -201,6 +211,12 @@ validate() { run bash "$DB_DIR/validate-package.sh" "$@"; } sed -i.bak 's/\*\*Recommendation: go\*\*/**park**/' "$pkg5/brief.md" validate "$pkg5" [ "$status" -eq 0 ] + # A position or the gate hidden in an HTML comment does not count. + pkg6="$TMP/pkg6"; cp -R "$FIX/good" "$pkg6" + sed -i.bak 's/\*\*Recommendation: go\*\*/<!-- **go** idea:approved --> We will see/; s/`idea:approved`/the label/' "$pkg6/brief.md" + validate "$pkg6" + [ "$status" -ne 0 ] + grep -q 'FAIL|brief.md|decision' <<<"$output" pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" sed -i.bak 's/`idea:approved`/the label/' "$pkg2/brief.md" validate "$pkg2" @@ -366,6 +382,19 @@ Roughly 40% of SMBs want this.\ grep -q "FAIL|market-research.md|sources|no '## Sources' section" <<<"$output" } +@test "provenance: a quantified claim written in a heading is scanned too" { + pkg="$(copy_good)" + sed -i.bak 's/^## The gap \/ wedge$/## The gap: a $20B market opportunity/' "$pkg/market-research.md" + validate "$pkg" + [ "$status" -ne 0 ] + grep -q 'FAIL|market-research.md|provenance|.*20B' <<<"$output" + pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" + sed -i.bak 's/^## 1\. Problem$/## 1. Problem — 10,000 companies affected/' "$pkg2/brief.md" + validate "$pkg2" + [ "$status" -ne 0 ] + grep -q 'FAIL|brief.md|provenance|.*10,000 companies' <<<"$output" +} + @test "provenance: lines inside the Sources section and fenced code are not scanned" { pkg="$(copy_good)" printf '%s\n' '[^3]: https://example.com/report — "$5B market, 20% CAGR"' >>"$pkg/market-research.md" @@ -413,7 +442,12 @@ case "$*" in "api repos/acme/widgets/git/ref/heads/main --jq .object.sha") echo base000 ;; "api repos/acme/widgets/git/ref/heads/incubate/widget-lint --jq .object.sha") if [ "${BRANCH_EXISTS:-0}" = 1 ]; then echo head111; else echo "not found" >&2; exit 1; fi ;; - *"contents/ideas/widget-lint/brief.md?ref=incubate/widget-lint"*) printf "%s" "${BRANCH_BRIEF:-# brief for https://github.com/acme/widgets/discussions/413}" | base64 | tr -d "\n" ;; + *"contents/ideas/widget-lint/brief.md?ref=incubate/widget-lint"*) + case "${BRANCH_BRIEF_MODE:-ok}" in + ok) printf "%s" "${BRANCH_BRIEF:-# brief for https://github.com/acme/widgets/discussions/413}" | base64 | tr -d "\n" ;; + absent) echo "gh: Not Found (HTTP 404)" >&2; exit 1 ;; + outage) echo "gh: HTTP 503" >&2; exit 1 ;; + esac ;; api\ repos/acme/widgets/git/commits/*) echo tree222 ;; "api repos/acme/widgets/git/blobs -X POST --input - --jq .sha") cat >>"$GH_LOG.blobs"; echo blob333 ;; "api repos/acme/widgets/git/trees -X POST --input - --jq .sha") cat >>"$GH_LOG.tree"; echo tree444 ;; @@ -474,6 +508,21 @@ exit 0' ! grep -q 'git/refs' "$GH_LOG" || false } +@test "open-pr (live): a 404 on the branch brief means no collision; any other read failure fails closed" { + pkg="$(copy_good)" + mock_gh_live + BRANCH_EXISTS=1 BRANCH_BRIEF_MODE=absent REPO="acme/widgets" SLUG="widget-lint" PACKAGE_DIR="$pkg" DISCUSSION_NUMBER=413 GIT_USER_NAME=t \ + run bash "$DB_DIR/open-pr.sh" + [ "$status" -eq 0 ] + grep -qF 'git/refs/heads/incubate/widget-lint -X PATCH' "$GH_LOG" + : >"$GH_LOG" + BRANCH_EXISTS=1 BRANCH_BRIEF_MODE=outage REPO="acme/widgets" SLUG="widget-lint" PACKAGE_DIR="$pkg" DISCUSSION_NUMBER=413 GIT_USER_NAME=t \ + run bash "$DB_DIR/open-pr.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"not appending"* ]] + ! grep -q 'git/blobs' "$GH_LOG" || false +} + @test "open-pr: the commit author defaults to the business-analyst persona's runtime account" { pkg="$(copy_good)" mock_gh_live From 661a7a8be779077f435e9868412147a21d8839d6 Mon Sep 17 00:00:00 2001 From: Don Petry <36422719+don-petry@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:05:49 -0500 Subject: [PATCH 8/8] fix(decision-brief): address the Codex review of 9b28543e (11 findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reusable + stub: dispatch the central workflow at the pinned channel (`central_ref`, default decision-brief/v1-stable; the stub forwards it next to its `uses:` pin, agent_ref-style) instead of implicitly at main; the sweep dispatches per-idea runs at its own branch/tag ref. - validate-package: HTML comments are stripped before claim scanning and footnote resolution; frontmatter values count only when the block closes (an unclosed block is scanned as body); the OST mermaid block needs ≥3 edges. - gather-context: the queue listing, branch-ref probe, package listing and referenced-issue reads fail loudly on anything but a 404; labels(first:100). - land job: the spec refetch fails closed on a non-404 or invalid JSON. - ripe-from-queue: GFM tables without a leading pipe are parsed by their Idea column; labels(first:100). - open-pr: a refresh edits the PR title and body, not just a comment. - Tests for each (81 across the two suites). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .github/workflows/decision-brief-reusable.yml | 24 ++++-- .github/workflows/decision-brief.yml | 17 +++- scripts/decision-brief/gather-context.sh | 47 +++++++--- scripts/decision-brief/open-pr.sh | 11 ++- scripts/decision-brief/ripe-from-queue.sh | 23 ++++- scripts/decision-brief/validate-package.sh | 38 +++++++-- standards/workflows/decision-brief.yml | 9 +- tests/test_decision_brief.bats | 85 ++++++++++++++++++- tests/test_decision_brief_workflow.bats | 13 ++- 9 files changed, 225 insertions(+), 42 deletions(-) diff --git a/.github/workflows/decision-brief-reusable.yml b/.github/workflows/decision-brief-reusable.yml index e37d29916..f6cd236c9 100644 --- a/.github/workflows/decision-brief-reusable.yml +++ b/.github/workflows/decision-brief-reusable.yml @@ -34,6 +34,15 @@ on: required: false default: "combsmith/.github-private" type: string + central_ref: + # Must match the channel the caller's `uses:` pins (the dev-lead + # stub's `agent_ref` convention): `gh workflow run` without --ref runs + # the central workflow and its tooling from the DEFAULT branch, which + # would defeat the release isolation the channel tag exists for. + description: "Ref (channel tag or branch) of central_repo to run the central workflow at. Keep it equal to the channel this reusable is pinned at." + required: false + default: "decision-brief/v1-stable" + type: string discussion: description: "Ideas Discussion number to brief (manual dispatch from the caller). Empty on label/schedule events." required: false @@ -74,14 +83,19 @@ jobs: env: GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} CENTRAL_REPO: ${{ inputs.central_repo }} + CENTRAL_REF: ${{ inputs.central_ref }} TARGET_REPO: ${{ github.repository }} steps: - - name: Guard — PAT present + - name: Guard — PAT present, ref well-formed run: | if [ -z "${GH_TOKEN}" ]; then echo "::error::GH_PAT_DON_PETRY (or legacy GH_PAT_WORKFLOWS) is required — a workflow_dispatch fired with GITHUB_TOKEN will not start a run." exit 1 fi + if ! [[ "${CENTRAL_REF}" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]]; then + echo "::error::central_ref must be a branch or tag name, got: '${CENTRAL_REF}'" + exit 1 + fi - name: Guard — trusted actor id: trust @@ -131,21 +145,21 @@ jobs: echo "::error::discussion number must be a positive integer, got: '${DISCUSSION_NUMBER}'" exit 1 fi - echo "${EVENT} on ${TARGET_REPO} discussion #${DISCUSSION_NUMBER} → dispatching central decision-brief in ${CENTRAL_REPO}." + echo "${EVENT} on ${TARGET_REPO} discussion #${DISCUSSION_NUMBER} → dispatching central decision-brief in ${CENTRAL_REPO}@${CENTRAL_REF}." gh workflow run decision-brief.yml \ - --repo "${CENTRAL_REPO}" \ + --repo "${CENTRAL_REPO}" --ref "${CENTRAL_REF}" \ -f discussion="${DISCUSSION_NUMBER}" \ -f target_repo="${TARGET_REPO}" \ -f dry_run=false echo "Dispatched decision-brief.yml (discussion=${DISCUSSION_NUMBER}, target_repo=${TARGET_REPO}, dry_run=false)." else - echo "${EVENT} on ${TARGET_REPO} → dispatching central decision-brief SWEEP (Ripe ideas without a brief) in ${CENTRAL_REPO}." + echo "${EVENT} on ${TARGET_REPO} → dispatching central decision-brief SWEEP (Ripe ideas without a brief) in ${CENTRAL_REPO}@${CENTRAL_REF}." if ! [[ "${MAX_BRIEFS}" =~ ^[0-9]+$ ]]; then echo "::error::max_briefs must be an integer, got: '${MAX_BRIEFS}'" exit 1 fi gh workflow run decision-brief.yml \ - --repo "${CENTRAL_REPO}" \ + --repo "${CENTRAL_REPO}" --ref "${CENTRAL_REF}" \ -f from_queue=true \ -f max_briefs="${MAX_BRIEFS}" \ -f target_repo="${TARGET_REPO}" \ diff --git a/.github/workflows/decision-brief.yml b/.github/workflows/decision-brief.yml index ab1b6ca8f..2695a4099 100644 --- a/.github/workflows/decision-brief.yml +++ b/.github/workflows/decision-brief.yml @@ -452,13 +452,24 @@ jobs: env: GH_TOKEN: ${{ secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} run: | - if gh api "repos/${REPO}/contents/ideas/package-spec.json" --jq '.content' 2>/dev/null | base64 -d > "$SPEC_PATH" \ - && jq -e . "$SPEC_PATH" >/dev/null 2>&1; then + # Only a 404 means "no spec"; a fetch failure or a spec that does not + # parse must not weaken the gate to its built-in sections. + err="$(mktemp)" + if content="$(gh api "repos/${REPO}/contents/ideas/package-spec.json" --jq '.content' 2>"$err")"; then + printf '%s' "$content" | base64 -d > "$SPEC_PATH" + if ! jq -e . "$SPEC_PATH" >/dev/null 2>&1; then + echo "::error::${REPO}:ideas/package-spec.json is not valid JSON — fix it before briefing" + exit 1 + fi echo "package-spec.json fetched from ${REPO}" - else + elif grep -qE 'HTTP 404|Not Found' "$err"; then rm -f "$SPEC_PATH" echo "::notice::${REPO} has no ideas/package-spec.json — validating against the stage's built-in sections only" + else + echo "::error::could not read ${REPO}:ideas/package-spec.json — $(tr '\n' ' ' <"$err" | cut -c1-200)" + exit 1 fi + rm -f "$err" - name: Provenance + structure gate # The deterministic half of the guard, run from the clean checkout: the diff --git a/scripts/decision-brief/gather-context.sh b/scripts/decision-brief/gather-context.sh index 77ba6b899..684f85b02 100644 --- a/scripts/decision-brief/gather-context.sh +++ b/scripts/decision-brief/gather-context.sh @@ -70,7 +70,7 @@ disc="$(gh api graphql -f query=' id title body url createdAt updatedAt author{ login } category{ name slug } - labels(first:20){ nodes{ name } } + labels(first:100){ nodes{ name } } comments(last:50){ totalCount nodes{ author{login} body createdAt url } } } } @@ -104,9 +104,13 @@ enhancement="$(printf '%s' "$disc" | jq -c ' # ── the Idea Promotion Queue verdict for this idea (if triage runs here) ────── queue_excerpt='null' -if queue_body="$(gh issue list --repo "$REPO" --label idea-triage --state open \ - --json number,title,body --jq 'map(select(.title | test("Idea Promotion Queue"))) | .[0] // empty' 2>/dev/null)" \ - && [[ -n $queue_body ]]; then +# No queue issue is fine (idea-triage not adopted); a failed listing is not. +if ! queue_body="$(gh issue list --repo "$REPO" --label idea-triage --state open \ + --json number,title,body --jq 'map(select(.title | test("Idea Promotion Queue"))) | .[0] // empty')"; then + echo "::error::could not list the Idea Promotion Queue in ${REPO} (API error) — refusing to brief with partial context" >&2 + exit 1 +fi +if [[ -n $queue_body ]]; then # Exact token match: "#4" must not pick up the rows for #44 or #413. queue_excerpt="$(printf '%s' "$queue_body" | jq -c --arg n "$DISCUSSION_NUMBER" ' {number, title, @@ -160,13 +164,26 @@ fi # A brief PR may already be open from incubate/<slug>; a re-dispatch must see # THAT branch's files (reviewed / hand-edited in flight), not the default # branch's, because open-pr.sh appends to the same branch. +# _probe_404 <api-path> <jq> — prints the result; prints nothing on a 404; +# exits the script on any other failure (an outage must not read as "absent"). +_probe_404() { + local out rc=0 err="${TMPDIR:-/tmp}/db-probe-err.$$" + out="$(gh api "$1" --jq "$2" 2>"$err")" || rc=$? + if [[ $rc -ne 0 ]]; then + if grep -qE 'HTTP 404|Not Found' "$err"; then rm -f "$err"; return 0; fi + echo "::error::could not read ${REPO} ${1} — $(tr '\n' ' ' <"$err" | cut -c1-200)" >&2 + rm -f "$err"; exit 1 + fi + rm -f "$err"; printf '%s' "$out" +} +# _probe_404 runs inside $(...): its `exit 1` ends only that subshell, so every +# call carries `|| exit 1` to propagate the failure to this script. pkg_ref="" -if gh api "repos/${REPO}/git/ref/heads/${BRANCH_PREFIX}/${slug}" --jq '.object.sha' >/dev/null 2>&1; then - pkg_ref="${BRANCH_PREFIX}/${slug}" -fi +pkg_sha="$(_probe_404 "repos/${REPO}/git/ref/heads/${BRANCH_PREFIX}/${slug}" '.object.sha')" || exit 1 +[[ -n $pkg_sha ]] && pkg_ref="${BRANCH_PREFIX}/${slug}" _ref_q=""; [[ -n $pkg_ref ]] && _ref_q="?ref=${pkg_ref}" -existing_files="$(gh api "repos/${REPO}/contents/${IDEAS_DIR}/${slug}${_ref_q}" --jq '[.[] | select(.type=="file") | .name]' 2>/dev/null || echo '[]')" -printf '%s' "$existing_files" | jq -e 'type=="array"' >/dev/null 2>&1 || existing_files='[]' +existing_files="$(_probe_404 "repos/${REPO}/contents/${IDEAS_DIR}/${slug}${_ref_q}" '[.[] | select(.type=="file") | .name]')" || exit 1 +[[ -n $existing_files ]] && printf '%s' "$existing_files" | jq -e 'type=="array"' >/dev/null 2>&1 || existing_files='[]' existing_brief="" if printf '%s' "$existing_files" | jq -e 'index("brief.md") != null' >/dev/null; then existing_brief="$(_fetch_file "${IDEAS_DIR}/${slug}/brief.md" "$pkg_ref")" @@ -188,9 +205,15 @@ if [[ -n $refs ]]; then ref_json="$(while IFS= read -r n; do [[ -n $n ]] || continue [[ "$n" == "$DISCUSSION_NUMBER" ]] && continue - gh api "repos/${REPO}/issues/${n}" --jq '{number, title, state, url: .html_url}' 2>/dev/null \ - || echo "::notice::skipping referenced #${n} (not a fetchable issue/PR — discussion/self-ref)" >&2 - done <<<"$refs" | jq -sc '.')" + # A 404 is a Discussion number or a dead link — skip. Anything else is + # an API failure: abort rather than draft without evidence the thread + # explicitly linked (_probe_404 exits the script). + r="$(_probe_404 "repos/${REPO}/issues/${n}" '{number, title, state, url: .html_url}')" || exit 1 + if [[ -n "$r" ]]; then printf '%s\n' "$r"; else echo "::notice::skipping referenced #${n} (not a fetchable issue/PR — discussion/self-ref)" >&2; fi + done <<<"$refs" | jq -sc '.')" || { echo "::error::aborting: a referenced issue could not be read" >&2; exit 1; } + # The loop runs in a subshell; _probe_404's exit surfaces as an empty/invalid + # JSON document — treat that as the failure it is. + printf '%s' "$ref_json" | jq -e 'type=="array"' >/dev/null 2>&1 || { echo "::error::aborting: a referenced issue could not be read" >&2; exit 1; } fi # ── assemble ────────────────────────────────────────────────────────────────── diff --git a/scripts/decision-brief/open-pr.sh b/scripts/decision-brief/open-pr.sh index 90e06e0a8..1c0221df7 100644 --- a/scripts/decision-brief/open-pr.sh +++ b/scripts/decision-brief/open-pr.sh @@ -193,11 +193,18 @@ fi existing_pr="$(gh pr list --repo "$REPO" --head "$branch" --state open --json number,url \ --jq '.[0].url // empty' 2>/dev/null || true)" if [[ -n "$existing_pr" ]]; then + # A re-brief may change the recommendation, score or open questions: the + # PR's title and body must say what the files now say, not what the first + # run said. Then a short comment points reviewers at the new commit. + body_file="$(mktemp)" + printf '%s\n' "$body" >"$body_file" + gh pr edit "$existing_pr" --repo "$REPO" --title "$title" --body-file "$body_file" >/dev/null + rm -f "$body_file" gh pr comment "$existing_pr" --repo "$REPO" \ - --body "$(printf '<!-- decision-brief:refresh -->\nRefreshed by the decision-brief stage: commit %s updates `%s/`.%s' \ + --body "$(printf '<!-- decision-brief:refresh -->\nRefreshed by the decision-brief stage: commit %s updates `%s/`; the PR title and summary above reflect the current brief.%s' \ "${commit_sha:0:7}" "$dest_prefix" "${RUN_URL:+ Run: ${RUN_URL}}")" >/dev/null pr_url="$existing_pr" - echo "refreshed existing PR ${pr_url}" + echo "refreshed existing PR ${pr_url} (title + summary updated)" else gh label create "$PR_LABEL" --repo "$REPO" \ --description "Decision brief drafted by the org decision-brief stage (Mary)" --color 5319e7 2>/dev/null || true diff --git a/scripts/decision-brief/ripe-from-queue.sh b/scripts/decision-brief/ripe-from-queue.sh index 551e53cff..933bfdd89 100644 --- a/scripts/decision-brief/ripe-from-queue.sh +++ b/scripts/decision-brief/ripe-from-queue.sh @@ -24,6 +24,8 @@ # Env: # REPO owner/repo whose queue to read and whose ideas to brief (required) # CENTRAL_REPO owner/repo hosting decision-brief.yml (default: REPO) +# CENTRAL_REF ref (branch/tag) to dispatch at; default: this run's own +# GITHUB_REF when it is a branch or tag # WORKFLOW_FILE default decision-brief.yml # MAX_BRIEFS default 3 # IDEAS_DIR default ideas @@ -54,8 +56,15 @@ dbq_ripe_section() { # not a Ripe idea and must not get a paid brief. Prose (no table) is read whole. dbq_discussion_numbers() { awk ' - /^\|[[:space:]]*-+/ { sep = 1; next } - /^\|/ { if (!sep) next; n = split($0, c, "|"); if (n > 2) print c[2]; table = 1; next } + # Any line with a pipe is a table row; the leading pipe is optional in + # GitHub-flavored Markdown. The separator row is dashes/colons/pipes only. + /\|/ && /^[[:space:]|:-]+$/ && /-/ { sep = 1; next } + /\|/ { + if (!sep) next + line = $0; sub(/^[[:space:]]*\|/, "", line) + n = split(line, c, "|"); if (n >= 1) print c[1] + table = 1; next + } { other[++o] = $0 } END { if (!table) for (i = 1; i <= o; i++) print other[i] } ' | grep -oE '(#|/discussions/)[0-9]+' | grep -oE '[0-9]+' | awk '!seen[$0]++' @@ -74,6 +83,12 @@ dbq_main() { # shellcheck source=scripts/decision-brief/lib/slug.sh source "$(dirname "${BASH_SOURCE[0]}")/lib/slug.sh" local is_dry=0; [[ "${DRY_RUN:-0}" == "1" ]] && is_dry=1 + # Dispatch the per-idea runs at the SAME ref this sweep runs at (a channel + # tag or branch), never silently at main — the release isolation callers + # pinned for. CENTRAL_REF overrides; else GITHUB_REF when it is a branch/tag. + local ref_flags=() + if [[ -n "${CENTRAL_REF:-}" ]]; then ref_flags=(--ref "$CENTRAL_REF") + elif [[ -n "${GITHUB_REF:-}" && ( "$GITHUB_REF" == refs/heads/* || "$GITHUB_REF" == refs/tags/* ) ]]; then ref_flags=(--ref "$GITHUB_REF"); fi local owner="${REPO%/*}" name="${REPO#*/}" local queue_body @@ -104,7 +119,7 @@ dbq_main() { if ! disc="$(gh api graphql -f query=' query($owner:String!,$name:String!,$num:Int!){ repository(owner:$owner,name:$name){ - discussion(number:$num){ title category{ name } labels(first:20){ nodes{ name } } } + discussion(number:$num){ title category{ name } labels(first:100){ nodes{ name } } } } }' -F owner="$owner" -F name="$name" -F num="$n")"; then # gh exits non-zero on a GraphQL `errors` body too. A NOT_FOUND on the @@ -149,7 +164,7 @@ dbq_main() { >>"${DRY_RUN_LOG:-./dry-run.jsonl}" echo "[dry-run] would dispatch ${WORKFLOW_FILE} in ${CENTRAL_REPO} for #${n} (${slug})" else - gh workflow run "$WORKFLOW_FILE" --repo "$CENTRAL_REPO" \ + gh workflow run "$WORKFLOW_FILE" --repo "$CENTRAL_REPO" "${ref_flags[@]}" \ -f discussion="$n" -f target_repo="$REPO" -f dry_run=false echo "dispatched ${WORKFLOW_FILE} for #${n} (${slug})" fi diff --git a/scripts/decision-brief/validate-package.sh b/scripts/decision-brief/validate-package.sh index d62940f8b..6cf79e9d2 100644 --- a/scripts/decision-brief/validate-package.sh +++ b/scripts/decision-brief/validate-package.sh @@ -46,17 +46,32 @@ # ── text helpers (pure) ────────────────────────────────────────────────────── # vp_fm_value <file> <key> — the frontmatter value of <key> (empty if none). +# A frontmatter block that never closes is not frontmatter (and would swallow +# the whole file out of the provenance scan): values are printed only once the +# closing `---` has been seen. vp_fm_value() { local f="$1" key="$2" [[ -f "$f" ]] || return 0 awk -v key="$key" ' NR==1 && $0=="---" { infm=1; next } - infm && $0=="---" { exit } - infm && index($0, key ":")==1 { v=$0; sub("^" key ":[[:space:]]*", "", v); sub(/[[:space:]]*#.*$/, "", v); gsub(/["'\''` ]/, "", v); print v; exit } + infm && $0=="---" { if (found) print v; exit } + infm && !found && index($0, key ":")==1 { v=$0; sub("^" key ":[[:space:]]*", "", v); sub(/[[:space:]]*#.*$/, "", v); gsub(/["'\''` ]/, "", v); found=1 } ' "$f" return 0 } +# vp_body_after_frontmatter <file> — the file without a CLOSED frontmatter +# block; an unclosed block is treated as body (so it gets scanned). +vp_body_after_frontmatter() { + awk ' + NR==1 && $0=="---" { infm=1; buf=""; next } + infm && $0=="---" { infm=0; next } + infm { buf = buf $0 "\n"; next } + { print } + END { if (infm) printf "%s", buf } + ' "$1" +} + # vp_has_section <file> <needle> — 0 iff a markdown header line contains # <needle> (case-insensitive, fixed string — same rule as the incubation gate). vp_has_section() { @@ -147,17 +162,20 @@ VP_CITED_RE='(https?://|unknown — needs discovery|unknown - needs discovery|\[ # (the artifact itself) carries a `[^n]: … http(s)://…` definition — a dangling # footnote is not a source. vp_unsourced_claims() { - local defs="${1:-}" line ref ok + local defs="${1:-}" line ref ok defs_text="" + # HTML comments are invisible to the reader: a URL or a footnote definition + # inside one is not a source, and a claim inside one is not a claim. + if [[ -n "$defs" && -f "$defs" ]]; then defs_text="$(vp_strip_comments "$defs")"; fi while IFS= read -r line; do ok=0 if printf '%s\n' "$line" | grep -qiE -- "$VP_CITED_RE"; then ok=1 else for ref in $(printf '%s\n' "$line" | grep -oE '\[\^[0-9]+\]' | tr -d '[]^' | sort -u); do - if [[ -n "$defs" && -f "$defs" ]] && grep -qE "^\[\^${ref}\]:.*https?://" "$defs"; then ok=1; break; fi + if printf '%s\n' "$defs_text" | grep -qE "^\[\^${ref}\]:.*https?://"; then ok=1; break; fi done fi [[ "$ok" == 1 ]] || printf '%s\n' "$line" - done < <(awk ' + done < <(perl -0pe 's/<!--.*?-->//gs' | awk ' /^```/ { fence = !fence; next } fence { next } /^#{1,6}[[:space:]]/ { sub(/^#+[[:space:]]+/, ""); print; next } # a claim in a heading is still a claim @@ -293,8 +311,7 @@ vp_check_provenance() { # the brief itself. path="$dir/brief.md" if [[ -f "$path" ]]; then - hits="$(awk 'NR==1 && $0=="---" {infm=1; next} infm && $0=="---" {infm=0; next} !infm {print}' "$path" \ - | vp_unsourced_claims "$path")" + hits="$(vp_body_after_frontmatter "$path" | vp_unsourced_claims "$path")" if [[ -n "$hits" ]]; then vp_fail brief.md provenance "quantified market claim without a source / 'unknown — needs discovery' / [ASSUMPTION]: $(printf '%s' "$hits" | head -2 | tr '\n' ' ' | cut -c1-220)" else @@ -359,8 +376,11 @@ vp_check_ost() { local dir="$1" local path="$dir/ost.md" [[ -f "$path" ]] || return 0 - if grep -q '^```mermaid' "$path"; then vp_pass ost.md mermaid - else vp_fail ost.md mermaid "no \`\`\`mermaid block (the tree is rendered as Mermaid)"; fi + local edges + edges="$(awk '/^```mermaid/{f=1;next} /^```/{f=0} f' "$path" | grep -cE -- '-->' || true)" + if ! grep -q '^```mermaid' "$path"; then vp_fail ost.md mermaid "no \`\`\`mermaid block (the tree is rendered as Mermaid)" + elif [[ "$edges" -lt 3 ]]; then vp_fail ost.md mermaid "the mermaid block has ${edges} edge(s); the tree needs outcome → opportunity → solution → assumption test (≥3 edges)" + else vp_pass ost.md mermaid; fi } # vp_spec_sections <spec> <file> — the gate's required_sections for <file>, one per line. diff --git a/standards/workflows/decision-brief.yml b/standards/workflows/decision-brief.yml index 6c2399cc4..ebe440221 100644 --- a/standards/workflows/decision-brief.yml +++ b/standards/workflows/decision-brief.yml @@ -12,8 +12,9 @@ # guard live in the central workflow. # • You MUST NOT change: the `uses:` ref — it is pinned to the # `decision-brief/v1-stable` channel, a moving tag advanced centrally. Never -# repoint it to `@main`, a SHA, or a frozen `@vX`. Do not change the -# `secrets:` block or the trigger event shape. +# repoint it to `@main`, a SHA, or a frozen `@vX`. `central_ref` below must +# always equal that pin. Do not change the `secrets:` block or the trigger +# event shape. # • You MAY change: the cron schedule (keep it after your idea-triage run and # off minute 0), and the `max_briefs` sweep cap. # • If you need different behaviour, open a PR against the reusable in the @@ -69,6 +70,10 @@ jobs: decision-brief: uses: combsmith/.github-private/.github/workflows/decision-brief-reusable.yml@decision-brief/v1-stable # NOSONAR(githubactions:S7637) first-party channel ref with: + # Same channel as the `uses:` pin above (the dev-lead stub's `agent_ref` + # convention): the central workflow is dispatched at THIS ref, never at + # main. Change both together when repinning. + central_ref: decision-brief/v1-stable # `github.event.inputs.*` (not the `inputs` context) so this evaluates to # '' on the discussion/schedule events instead of failing at workflow # setup (the #571 class). diff --git a/tests/test_decision_brief.bats b/tests/test_decision_brief.bats index 4a2338c60..d44be5a75 100644 --- a/tests/test_decision_brief.bats +++ b/tests/test_decision_brief.bats @@ -395,6 +395,52 @@ Roughly 40% of SMBs want this.\ grep -q 'FAIL|brief.md|provenance|.*10,000 companies' <<<"$output" } +@test "provenance: a URL or footnote definition hidden in an HTML comment is not a source" { + pkg="$(copy_good)" + sed -i.bak '/^## Market signal$/a\ +TAM is $20B <!-- https://example.com/report -->.\ +About 300k teams use one [^7]. <!-- [^7]: https://example.com/x --> +' "$pkg/market-research.md" + validate "$pkg" + [ "$status" -ne 0 ] + grep -q 'FAIL|market-research.md|provenance|.*20B' <<<"$output" + # and a claim that is itself commented out is not reported + pkg2="$TMP/pkg2"; cp -R "$FIX/good" "$pkg2" + sed -i.bak '/^## Market signal$/a\ +<!-- draft: the market is $9B --> +' "$pkg2/market-research.md" + validate "$pkg2" + [ "$status" -eq 0 ] +} + +@test "validate: an unclosed frontmatter block is not frontmatter — its values are rejected and its text is scanned" { + pkg="$(copy_good)" + # Remove the closing delimiter and bury an unsourced figure right after it. + python3 - "$pkg/brief.md" <<'PY2' +import sys, pathlib +p = pathlib.Path(sys.argv[1]); t = p.read_text().split("\n") +assert t[0] == "---" and t[3] == "---" +t[3] = "The market is $20B." +p.write_text("\n".join(t)) +PY2 + validate "$pkg" + [ "$status" -ne 0 ] + grep -q "FAIL|brief.md|frontmatter|artifact='none'" <<<"$output" + grep -q 'FAIL|brief.md|provenance|.*20B' <<<"$output" +} + +@test "validate: an empty mermaid block is a missing OST" { + pkg="$(copy_good)" + python3 - "$pkg/ost.md" <<'PY2' +import sys, pathlib, re +p = pathlib.Path(sys.argv[1]); t = p.read_text() +p.write_text(re.sub(r"```mermaid\n.*?```", "```mermaid\n```", t, flags=re.S)) +PY2 + validate "$pkg" + [ "$status" -ne 0 ] + grep -q 'FAIL|ost.md|mermaid|.*edge' <<<"$output" +} + @test "provenance: lines inside the Sources section and fenced code are not scanned" { pkg="$(copy_good)" printf '%s\n' '[^3]: https://example.com/report — "$5B market, 20% CAGR"' >>"$pkg/market-research.md" @@ -492,6 +538,7 @@ exit 0' grep -q '"parents":\["head111"\]' "$GH_LOG.commit" grep -qF 'api repos/acme/widgets/git/refs/heads/incubate/widget-lint -X PATCH -f sha=commit555' "$GH_LOG" ! grep -q 'pr create' "$GH_LOG" || false + grep -q 'pr edit https://github.com/acme/widgets/pull/9 --repo acme/widgets --title brief(widget-lint)' "$GH_LOG" grep -q 'pr comment https://github.com/acme/widgets/pull/9' "$GH_LOG" [[ "$output" == *"refreshed existing PR"* ]] } @@ -542,6 +589,23 @@ exit 0' # Prose (no table) is read whole. run bash -c "source '$DB_DIR/ripe-from-queue.sh'; printf -- '- #12 and #15 are ripe\n' | dbq_discussion_numbers | paste -sd, -" [ "$output" = "12,15" ] + # A GFM table WITHOUT leading pipes is still a table: only the Idea column counts. + run bash -c "source '$DB_DIR/ripe-from-queue.sh'; printf 'Idea | Why now | Note\n--- | --- | ---\n#413 Widget | blocked by #999 | see #777\n#44 Radar | demand | x\n' | dbq_discussion_numbers | paste -sd, -" + [ "$output" = "413,44" ] +} + +@test "ripe-from-queue: dispatches the per-idea runs at its own branch/tag ref, or CENTRAL_REF" { + export QUEUE_FILE="$FIX/queue-body.md" + mock_gh_sweep + GITHUB_REF=refs/tags/decision-brief/v1-stable REPO="acme/widgets" run bash "$DB_DIR/ripe-from-queue.sh" + [ "$status" -eq 0 ] + grep -qF -- "--ref refs/tags/decision-brief/v1-stable -f discussion=413" "$GH_LOG" + : >"$GH_LOG" + CENTRAL_REF=decision-brief/v1-next GITHUB_REF=refs/pull/9/merge REPO="acme/widgets" run bash "$DB_DIR/ripe-from-queue.sh" + grep -qF -- "--ref decision-brief/v1-next -f discussion=413" "$GH_LOG" + : >"$GH_LOG" + GITHUB_REF=refs/pull/9/merge REPO="acme/widgets" run bash "$DB_DIR/ripe-from-queue.sh" + ! grep -q -- '--ref' "$GH_LOG" || false } @test "ripe-from-queue: a GraphQL NOT_FOUND (an issue number in the Ripe table) is skipped, not fatal" { @@ -660,7 +724,7 @@ mock_gh_gather() { mock_gh ' case "$*" in *graphql*) echo "{\"data\":{\"repository\":{\"discussion\":{\"id\":\"D_target\",\"title\":\"💡 WidgetLint — a lint for manifests\",\"body\":\"see #12\",\"url\":\"https://github.com/acme/widgets/discussions/413\",\"createdAt\":\"t\",\"updatedAt\":\"t\",\"author\":{\"login\":\"don\"},\"category\":{\"name\":\"Ideas\",\"slug\":\"ideas\"},\"labels\":{\"nodes\":[{\"name\":\"needs-brief\"}]},\"comments\":{\"totalCount\":1,\"nodes\":[{\"author\":{\"login\":\"bot\"},\"body\":\"<!-- feature-ideation:enhanced -->\\n## Enhancement\",\"createdAt\":\"t\",\"url\":\"u\"}]}}}}}" ;; - *"issue list --repo acme/widgets --label idea-triage"*) echo "${QUEUE_JSON:-}" ;; + *"issue list --repo acme/widgets --label idea-triage"*) if [ "${QUEUE_OUTAGE:-0}" = 1 ]; then echo "gh: HTTP 503" >&2; exit 1; fi; echo "${QUEUE_JSON:-}" ;; *"contents/ideas/_TEMPLATE/brief.md"*) if [ "${NO_TEMPLATE:-0}" = 1 ]; then echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi; base64 < "'"$FIX"'/template/brief.md" | tr -d "\n" ;; *"contents/ideas/_TEMPLATE/market-research.md"*) base64 < "'"$FIX"'/template/market-research.md" | tr -d "\n" ;; *"contents/ideas/_TEMPLATE/ost.md"*) if [ "${NO_OST:-0}" = 1 ]; then echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi; base64 < "'"$FIX"'/template/ost.md" | tr -d "\n" ;; @@ -671,12 +735,13 @@ case "$*" in absent) echo "gh: Not Found (HTTP 404)" >&2; exit 1 ;; outage) echo "gh: HTTP 502 Bad Gateway" >&2; exit 1 ;; esac ;; - *"git/ref/heads/incubate/widget-lint"*) if [ "${BRANCH_EXISTS:-0}" = 1 ]; then echo headsha; else exit 1; fi ;; + *"git/ref/heads/incubate/widget-lint"*) if [ "${BRANCH_EXISTS:-0}" = 1 ]; then echo headsha; elif [ "${REF_OUTAGE:-0}" = 1 ]; then echo "gh: HTTP 503" >&2; exit 1; else echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi ;; *"contents/ideas/widget-lint/brief.md?ref=incubate/widget-lint"*) printf "%s" "${BRANCH_BRIEF:-}" | base64 | tr -d "\n" ;; *"contents/ideas/widget-lint?ref=incubate/widget-lint"*) echo "[\"brief.md\"]" ;; *"contents/ideas/widget-lint/brief.md"*) printf "%s" "${DEFAULT_BRIEF:-}" | base64 | tr -d "\n" ;; *"contents/ideas/widget-lint"*) if [ -n "${DEFAULT_BRIEF:-}" ]; then echo "[\"brief.md\"]"; else echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi ;; - "api repos/acme/widgets/issues/12 --jq "*) echo "{\"number\":12,\"title\":\"prereq\",\"state\":\"open\",\"url\":\"u\"}" ;; + "api repos/acme/widgets/issues/12 --jq "*) if [ "${ISSUE_OUTAGE:-0}" = 1 ]; then echo "gh: HTTP 503" >&2; exit 1; fi; echo "{\"number\":12,\"title\":\"prereq\",\"state\":\"open\",\"url\":\"u\"}" ;; + "api repos/acme/widgets/issues/"*) echo "gh: Not Found (HTTP 404)" >&2; exit 1 ;; *) echo "{}" ;; esac exit 0' @@ -747,6 +812,20 @@ exit 0' jq -e '.existing_package["brief.md"] | contains("discussions/413")' "$TMP/ctx2.json" >/dev/null } +@test "gather-context: API failures on the queue, the branch probe or a referenced issue fail loud; 404s do not" { + mock_gh_gather + QUEUE_OUTAGE=1 REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -ne 0 ]; [[ "$output" == *"could not list the Idea Promotion Queue"* ]] + REF_OUTAGE=1 REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -ne 0 ]; [[ "$output" == *"could not read acme/widgets repos/acme/widgets/git/ref/heads/incubate/widget-lint"* ]] + ISSUE_OUTAGE=1 REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -ne 0 ]; [[ "$output" == *"referenced issue could not be read"* ]] + # The happy path (404 on the branch ref, a 404-able issue list) still succeeds. + REPO="acme/widgets" DISCUSSION_NUMBER=413 CONTEXT_PATH="$TMP/ctx.json" run bash "$DB_DIR/gather-context.sh" + [ "$status" -eq 0 ] + grep -q 'labels(first:100)' "$GH_LOG" +} + @test "gather-context: fails loud when the target lacks ANY of the three templates" { mock_gh_gather export NO_TEMPLATE=1 diff --git a/tests/test_decision_brief_workflow.bats b/tests/test_decision_brief_workflow.bats index 6d1cb02c3..40e80ac3f 100644 --- a/tests/test_decision_brief_workflow.bats +++ b/tests/test_decision_brief_workflow.bats @@ -112,8 +112,12 @@ setup() { cd "$ROOT"; } grep -q 'SPEC_PATH=${{ runner.temp }}/package-spec.json' "$CENTRAL" gate="$(sed -n '/name: Provenance + structure gate/,/name: Open (or refresh)/p' "$CENTRAL")" grep -q -- '--spec "$SPEC_PATH"' <<<"$gate" - # The land job re-fetches the spec from the target, not from the model's workspace. - grep -q 'name: Fetch the target package spec' "$CENTRAL" + # The land job re-fetches the spec from the target, not from the model's workspace, + # and fails closed on anything but a 404. + spec="$(sed -n '/name: Fetch the target package spec/,/name: Provenance + structure gate/p' "$CENTRAL")" + grep -q 'HTTP 404|Not Found' <<<"$spec" + grep -q 'is not valid JSON' <<<"$spec" + grep -q 'could not read' <<<"$spec" prompt="$(sed -n '/name: Draft the decision brief (BMAD Analyst)/,/name: Upload the drafted package/p' "$CENTRAL")" grep -q -- '--spec "$SPEC_PATH"' <<<"$prompt" } @@ -167,6 +171,9 @@ setup() { cd "$ROOT"; } @test "reusable: dispatches the central workflow with target_repo=caller, dry_run=false; sweep uses from_queue=true" { grep -q 'gh workflow run decision-brief.yml' "$REUSABLE" + # Dispatched at the pinned channel, never implicitly at main. + [ "$(grep -c -- '--repo "${CENTRAL_REPO}" --ref "${CENTRAL_REF}"' "$REUSABLE")" -eq 2 ] + grep -q 'default: "decision-brief/v1-stable"' "$REUSABLE" grep -qE -- '-f discussion="\$\{DISCUSSION_NUMBER\}"' "$REUSABLE" grep -qE -- '-f target_repo="\$\{TARGET_REPO\}"' "$REUSABLE" grep -q -- '-f dry_run=false' "$REUSABLE" @@ -182,6 +189,8 @@ setup() { cd "$ROOT"; } @test "stub: pins the hosted reusable to the decision-brief/v1-stable channel with the S7637 marker" { [ -f "$STUB" ] grep -qF "uses: ${REUSABLE_REF}@${CHANNEL}" "$STUB" + # central_ref travels with the pin (agent_ref convention). + grep -qF "central_ref: ${CHANNEL}" "$STUB" grep -q 'NOSONAR(githubactions:S7637)' "$STUB" grep -q 'secrets: inherit' "$STUB" grep -qE '^permissions: \{\}' "$STUB"