From 9b4b5b88b6284b0f849a5af3b2e7a63b5219fb7b Mon Sep 17 00:00:00 2001 From: Keith Lee Date: Sat, 29 Aug 2026 13:11:51 -0700 Subject: [PATCH 01/12] feat(skills): gate handback and merge on PR comment reconciliation Before reporting checks-passed or merging, verify every PR comment and review thread has been responded to or addressed, escalating unresolved product questions like ask-user findings. --- skills/no-mistakes/SKILL.md | 385 ++++++++++++++++++++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 skills/no-mistakes/SKILL.md diff --git a/skills/no-mistakes/SKILL.md b/skills/no-mistakes/SKILL.md new file mode 100644 index 00000000000..92012e177aa --- /dev/null +++ b/skills/no-mistakes/SKILL.md @@ -0,0 +1,385 @@ +--- +name: no-mistakes +description: Validate your code changes through the no-mistakes pipeline - automated code review, tests, lint, docs, push, PR, and CI - before they reach the configured push target. Use when the user asks to run no-mistakes, gate or ship or validate their changes, push safely, asks you to do a task and then validate it, or invokes /no-mistakes. +user-invocable: true +--- + +# no-mistakes + +`no-mistakes` is a local gate that validates your code changes through a pipeline +(intent, rebase, review, test, document, lint, push, PR, CI) before they reach +the configured push target. You drive it through the `no-mistakes axi` command family, which prints +machine-readable [TOON](https://toonformat.dev) to stdout and progress to stderr. + + +## Active validation-step boundary + +A no-mistakes validation-step agent is already inside an active outer run. It +must inspect, fix, and return only its assigned phase. It must never initialize, +start, reattach, rerun, respond to, synchronize, abort, eject, or directly push +a no-mistakes pipeline. Delivery requirements in user intent remain +acceptance context, but the outer executor alone performs the other validation, +push, PR, and CI phases. + +`NO_MISTAKES_GATE` is fast diagnostic evidence, not authorization by +itself. The runtime combines managed Git identity with authenticated process +ancestry. If a pipeline-control command returns +`error.code: nested_gate_context`, stop immediately and +return control to the outer executor. Safe inspection remains available through +`no-mistakes axi status`, `no-mistakes axi logs`, help, and +`no-mistakes doctor`. + + +When the user invokes `/no-mistakes`, report the outcome at the end. If the user +asks for something specific, translate that request into the matching `axi run` +flags yourself - for example, "skip the lint step" becomes `--skip=lint`. Run +`no-mistakes axi run --help` to see the available flags. + +## Two ways to invoke + +`/no-mistakes` works in two modes, depending on whether the user hands you a +task along with the command: + +- **Validate-only** - bare `/no-mistakes` (optionally with flag-style requests + like "skip the lint step"). The user's code changes are already committed; + validate them and report the outcome. +- **Task-first** - `/no-mistakes `, e.g. + `/no-mistakes add a --json flag to the status command`. First carry out the + task yourself, then validate the result through the pipeline: + 1. **Check scope.** Inspect `git status` before you change or commit anything. + Preserve unrelated pre-existing uncommitted changes, and when you commit, + commit only the changes that belong to the user's task. + 2. **Do the work.** Make the changes the task describes, then **commit them on + a feature branch**. If the user is on the repository's default branch, + create a feature branch first - the gate validates committed history on a + non-default branch, so the work must land there before you run. + 3. **Then validate**, passing the user's task as your `--intent`. The task + text is exactly what the user set out to accomplish, in their own words, so + it *is* the intent - preserve requirements stated directly by the user, + including constraints, exclusions, acceptance criteria, and later decisions; + do not condense them into a diff summary or drop them while adding + implementation context. Enrich it with the decisions and tradeoffs you + made while doing the work (see + [Intent is required](#intent-is-required)). + + +## Test-quality rule + +Never add a test whose only evidence is that it opens, reads, greps, parses, or +snapshots implementation source code and finds or omits particular strings, +tokens, lines, commands, function names, prompt phrases, regex matches, AST +shapes, or incidental snapshots. That does not prove behavior: matching text +can be dead or commented out, and a behavior-preserving refactor can change it. + +Instead execute a public or executable interface and assert observable behavior, +state, output, side effects, and failure modes. For machine-consumed declarative +artifacts such as workflow YAML, JSON, policy, .gitignore, or generated +configuration, invoke the real consumer when feasible or parse into a typed or +normalized semantic model and assert meaning. A raw substring or regex over the +file is still the anti-pattern. + +Reading a file is legitimate when the file is itself generated public output, a +serialized protocol, persisted state, an intentional snapshot, or another +explicitly owned text or byte contract. Name that contract, and do not use its +contents as a proxy that unrelated code works. A natural-language prompt or +instruction is not proven effective because its source contains a sentence. +Deterministic CI may test the final emitted prompt delivered to an agent as an +intentional generated interface; model interpretation belongs in +development-only evaluation, not live-LLM CI. + +For a regression, reproduce the reported failure when feasible: the test should +fail before the fix and pass after it. + + +Everything below - preconditions, intent, the validate-and-decide loop - applies +the same way once the work is committed on a feature branch. + +## Before you start + +- The work you want validated must be **committed** on a branch. The gate + validates committed history, not your uncommitted working tree. +- You must be on a **feature branch**, not the repository's default branch. +- The repository must already be initialized with `no-mistakes init`. +- The daemon must have a runnable configured pipeline agent: a supported native + agent binary, the `agent: cursor` ACP alias, or an explicit `acp:` through + `acpx`. You are the AXI driver, not + an implicit pipeline-agent backend. If none is available, the run fails + before its first step; `no-mistakes doctor` reports the configuration problem. + +If any of these is not met, `axi run` returns an `error:` with the exact command +to fix it - read it and act on it (commit your work, or create a branch). If the +repository is not initialized, run `no-mistakes init` first; if the `no-mistakes` +command itself is missing or misbehaving, `no-mistakes doctor` reports what is +wrong. +Before starting, run `no-mistakes axi` (home view). +If it shows an active run on your current branch, inspect it with `no-mistakes axi status`. +If it is parked at a gate, drive it with `no-mistakes axi respond`. +Reattach an in-flight run by re-running `no-mistakes axi run` when it still matches your current `HEAD` - either as the submitted head or as the current pipeline head. +Only `no-mistakes axi abort` it when you mean to discard that run before starting over; aborting is a between-runs action, never a way to take over or bypass a gate while a run is still going (see [Validate and decide](#validate-and-decide)). +If it shows an active run on another branch, leave that run alone and start validation for your current branch with `no-mistakes axi run --intent "..."`. + +## Intent is required + +When you start a run you must pass `--intent`: **what the user set out to +accomplish** - the goal or request behind this work, in their terms. This is not +a description of the diff or the files you changed; it is the objective the +change is meant to achieve. You know it from the conversation, so pass it +directly - no-mistakes uses it verbatim instead of inferring it from local agent +transcripts (slower and flakier). + +Err on the side of completeness, not brevity. The review step uses `--intent` +to tell a deliberate decision apart from a mistake, so a thin one-line summary +makes it flag things the user already chose. Capture the nuance: the user's +goal, the specific decisions and tradeoffs they made along the way, any +constraints or approaches they ruled in or out, and anything they explicitly +asked for that might otherwise look surprising in the diff. A few sentences to a +short paragraph is normal - write down what you learned from the conversation +that a reviewer reading only the diff would not know. + +## Validate and decide + +Run the pipeline and decide on its findings as they come up: + +1. Start the run. It blocks until the first decision point or the end: + ```sh + no-mistakes axi run --intent "" + ``` + `axi run` and every `axi respond` block synchronously - the review, test, + and CI steps can each take **several minutes**, so a single call may not + return for a while. That is normal; allow a long timeout and do not cancel + or re-issue the command because it seems slow. To check progress without + disturbing the run, use `no-mistakes axi status` from a separate call. + A long-running call is working, not stalled - background it if your harness + needs to, but the run **never advances past a gate on its own**. Read every + return; on a `gate:`, respond; loop until an `outcome:`. Never idle-wait + for the run to move forward by itself. + When that status output includes `awaiting_agent: parked ` under the run, + the run is parked at an approval or fix-review gate and waiting for you to + send `axi respond`. The field is observability only: it does not change + gate resolution, auto-resume the run, or make `--yes` the default. + While a step is actively `running` or `fixing`, `axi status` may include + `active_steps` with `active_for`, `last_activity`, a native `agent_pid` when + a subprocess agent is running, and the current round such as `round 1`, + `auto-fix 1/3`, or `fix 2`. If `last_activity` is prefixed with + `quiet`, no step log or native-agent lifecycle activity has arrived for + longer than `step_quiet_warning`. Treat that as a liveness clue, not as + permission to cancel, rerun, or edit the worktree yourself. +2. If the output contains a `gate:` object, the pipeline is waiting on you. + Read its `findings` table. Each finding has an `id`, `severity`, + `file`, `description`, and an `action` that tells you how the + pipeline classified it: + - `auto-fix` - mechanical and low-risk; you can authorize the fix on + your own judgment by responding with `--action fix`. + - `no-op` - informational only; nothing to do. + - `ask-user` - the finding challenges the user's deliberate intent or + touches product behavior. This is a call only the user can make - see + [Escalate `ask-user` findings](#escalate-ask-user-findings) below. + + **Review auto-fix is disabled by default** (`auto_fix.review: 0`; a repo + or global `auto_fix.review > 0` override re-enables it), so blocking and + ask-user review findings park for your decision rather than being silently + self-fixed. (Other steps such as test and lint may auto-fix within the + pipeline and re-run before they ever gate.) + + Choose one response: + ```sh + # accept the step as-is and continue + no-mistakes axi respond --action approve + + # have the pipeline fix specific findings, then continue + no-mistakes axi respond --action fix --findings --instructions "" + + # skip this step + no-mistakes axi respond --action skip + ``` + While a run is active, never fix findings by editing the code yourself - + the pipeline owns both the findings and the fixes. Your job at a gate is to + decide and respond; `--action fix` has the pipeline apply the fix and + re-review the result. For the same reason, while a run is active do **not** + `abort` or `rerun` to go fix a finding yourself - even a real bug in + your own code - because that discards the pipeline's in-flight work and + forces a full re-validation. `abort` and `rerun` are for *between* + runs (after a `failed` or `cancelled` outcome), never to circumvent a + gate. + + Each `respond` blocks until the next `gate:`, `checks-passed` decision point, or final outcome. + + Two extra flags are available on `respond` when you need them: + - `--add-finding ''` (with `--action fix`) folds a finding you + spotted yourself - one the pipeline did not surface - into the fix round, + as a JSON finding object. Use it for a problem you noticed that is not in + the gate's own `findings` table. + - `--step ` responds to a specific step instead of the one currently + awaiting approval. You rarely need this; omit it to answer the active gate. +3. Repeat step 2 until the output has an `outcome:` instead of a `gate:`. The + outcomes are: + - `checks-passed` - the change is validated and CI is green (or the + trusted default-branch config declares `no_ci: true` and no checks are + registered - the help line names that declaration when it applies), but + the PR is not merged yet. **You are done driving the pipeline.** Do not + wait for the merge: tell the user the PR is ready and ask them to review + and merge it (the PR link is in the `help` line). A generic empty forge + check list without that declaration is not ready. no-mistakes keeps + monitoring the PR in the background until it is merged, closed, or its + configured idle timeout elapses, so a human can watch it in the TUI. + Before reporting `checks-passed`, reconcile every open comment on the PR + per [PR comment reconciliation](#pr-comment-reconciliation-before-handback-or-merge). + - `passed` - the changes cleared the gate and the PR was merged or closed. + - `failed` or `cancelled` - they did not; read the output and address it. + Fix whatever the output points at (a failing test, a lint error, a finding + you skipped), commit the fix on the same feature branch, then drive the + pipeline again - `no-mistakes axi run --intent "..."` starts a fresh run, + or `no-mistakes rerun` re-runs the pipeline for the current branch. This + is the right place to start over: a fresh run or `rerun` is a + *between-runs* action, correct only after a terminal outcome like this - + never mid-run to circumvent a gate. Do not leave the user at a `failed` + outcome without either retrying or explaining what blocks it. + +## PR comment reconciliation before handback or merge + +Before you report `checks-passed` (which hands the PR back for human review) or +before the PR is merged, verify that every comment on the PR has been responded +to or addressed. An open, unanswered comment thread is a blocker: the pipeline +must not present the PR as ready for human review, nor merge it, while a review +comment or thread is still waiting for a response or a fix. + +The check covers every comment surface the forge exposes - issue comments and +inline review threads (conversations) on the PR - and runs before the PR leaves +pipeline control: + +1. List the PR's comments and review threads with the forge's CLI, for example + `gh pr view --comments` plus `gh api` for review threads, or the + GitLab equivalent for `glab`. +2. For each comment or thread, confirm it is either: + - already responded to with a reply, or + - addressed by a change in the PR branch (and the reply notes the fix), or + - a non-actionable note that carries no question or requested change. +3. When a comment carries a question or a requested change and has no response, + respond to it before reporting `checks-passed` or merging - post the reply + through the forge's CLI (for example `gh api` or `gh pr comment`) explaining + how it was handled or resolved. +4. Only then report `checks-passed` or allow the merge. + +An unanswered comment is a captain-facing handback: if a comment needs a product +decision you cannot make yourself, escalate it before reporting the PR ready, +the same way an `ask-user` finding is escalated. Never dismiss a real comment +as noise, and never claim a comment is addressed when no change or reply covers +it. The goal is that a human reviewer opening the PR finds every concern +already answered or resolved. + +Before any post-pipeline local commit or fresh run, read the structured `branch_sync` object returned by AXI home, status, or a drive result. +Only when its `next_action.code` is `sync`, run `no-mistakes axi sync` first. +That guarded sync may be a strict fast-forward or a content-equivalent diverged advance that anchors the pre-sync head before moving the branch with reset semantics; genuine divergence stays blocked. +If it reports `next_action.code` is `continue_active_run`, the pipeline still owns the branch: run the reported command, keep driving the active run, and do not make local follow-up commits. +When `next_action.code` is `recover_custody`, a terminal run left unpublished pipeline commits preserved in the local gate: run `no-mistakes axi sync --recover` to return custody and take the preserved head, or `no-mistakes rerun` to resume validating it instead. +Recovery takes that head by fast-forward, or by adopting a diverged preserved head proven to carry every local change - the ordinary result of the pipeline rebasing your commits onto a newer base - after anchoring your pre-recovery head under `refs/no-mistakes/recover-local/`. +That proof is deliberately narrow, so a rebase whose fix rounds also rewrote your own lines refuses instead of being adopted: when nothing can tell a deliberate pipeline fix from a dropped change, the decision is yours. +A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated `--recover` there is a harmless no-op. +A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; `--keep-local` keeps your current head while the preserved commits stay anchored under `refs/no-mistakes/recover/`. +If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement. +After synchronization, commit the follow-up on top and re-run `no-mistakes axi run --intent "..."` with the original user intent. +This preserves every prior gate-fix commit regardless of its configured subject. + +The CI step deliberately keeps watching the PR after checks pass, so +`axi run` returns `checks-passed` the moment checks are green (or a trusted +`no_ci: true` declaration covers a zero-check repository) rather than +blocking on the human merge. Never poll or re-run waiting for the merge yourself. +Never treat "no CI checks reported" alone as green. + +Because that monitor stays live, a PR that falls behind the default branch or +hits a merge conflict after checks pass - commonly because another PR merged +first - needs **no command from you**: never hand-rebase. When the CI monitor +sees an actual conflict it **rebases onto the base, resolves it, and re-pushes +the branch itself**; a PR that is merely behind but still clean needs nothing +either, since the platform merges it. The one exception is when that monitor is +no longer running - the PR was closed, the run was aborted or superseded, it +idle-timed-out, or its auto-fix attempts were exhausted - in which case recover +with `no-mistakes rerun`, which cancels the stale monitor and re-runs the full +pipeline including a deterministic rebase step. Do **not** reach for +`no-mistakes axi run` to refresh a still-active PR: after `checks-passed` it +reattaches to the running monitor (HEAD unchanged) and returns its output +without rebasing. + +On a successful outcome (`checks-passed` or `passed`), close the loop with the +user: summarize what happened during the pipeline in a concise, easily readable +format - what was validated and what was found. If the output includes a +`fixes` table, the pipeline fixed findings your original change missed: +acknowledge those misses and explicitly list each fix so the user can easily +review them. When any PR comment was responded to or resolved during +[PR comment reconciliation](#pr-comment-reconciliation-before-handback-or-merge), +mention those responses so the user knows the review threads are closed. + +## Escalate `ask-user` findings + +A gate whose findings are all `auto-fix` or `no-op` is safe to drive on your +own judgment: respond with `--action fix` or `--action approve` as +appropriate. But a finding marked +`ask-user` is a decision that belongs to the user, not you - the pipeline +flagged it because it challenges their deliberate intent or changes product +behavior. Do not approve, fix, or skip it on your own. Instead, stop and bring +it to the user before you respond: + +- Relay each `ask-user` finding to them as the pipeline wrote it - its + `id`, `file`, and full `description` verbatim. Do not paraphrase, + summarize away the detail, or pre-judge the answer. +- Ask how they want to proceed, then translate their decision into the matching + `respond` call: `--action fix` (pass their guidance through + `--instructions`), `--action approve`, or `--action skip`. + +The one exception is `--yes` (below): it is the user's standing consent to +drive every gate unattended, so under `--yes` you resolve `ask-user` +findings automatically instead of stopping to ask. + +If you have clear consent to drive the run automatically, pass `--yes` to `axi run` +or `axi respond`. It treats every actionable finding - `auto-fix` and +`ask-user` alike - as consent to fix it, selects every current finding for one +fix round, accepts the resulting fix review, and approves gates with only +`no-op` findings. Only use it when the user has asked you to drive the whole +run without checking back. + +## Inspecting state + +```sh +no-mistakes axi # home view: current branch, active runs, next steps +no-mistakes axi status # full detail plus cached branch_sync when relevant +no-mistakes axi sync --check # freshly verify an offered synchronization plan +no-mistakes axi sync # apply only an offered guarded synchronization +no-mistakes axi sync --recover # return custody after a terminal run left unpublished pipeline commits +no-mistakes axi logs --step --full # full log output of one step +no-mistakes axi abort # cancel the current-branch active run +no-mistakes axi abort --run # cancel a specific run by id (works outside its worktree) +``` + +## Reading the output + +- Output is TOON: `key: value` pairs, `name[N]{cols}:` tables, and `help[N]:` hints. +- A non-terminal run object may include `awaiting_agent: parked ` immediately after `status`; that means the run is parked at a gate awaiting your `axi respond`. +- A run object with a `running` or `fixing` step may include an `active_steps` table. Use it to see the active duration, latest activity, native agent PID, and current execution or fix round. +- The `help` list at the bottom of most responses tells you the next commands to run. +- Errors are printed as `error: ...` on stdout with a `help` list; act on the suggestion. +- Exit codes: `0` success, no-op, or normal decision gates, `1` failed or cancelled final outcomes, `2` bad usage. + +A `gate:` waiting on you looks roughly like this - a `gate:` line naming the step, optional step-specific fields such as `note`, a `findings[N]{...}:` table with one row per finding, and a `help[N]:` list of next commands: + +``` +gate: review +note: Review auto-fix is disabled by default (auto_fix.review: 0; a repo or global auto_fix.review > 0 override re-enables it), so blocking and ask-user review findings park for your decision rather than being silently self-fixed. +findings[2]{id,severity,file,line,action,description}: + r1,warning,internal/pipeline/executor.go,,auto-fix,Error from os.Remove is ignored + r2,error,cmd/no-mistakes/main.go,,ask-user,New --force flag bypasses the confirm prompt +help[6]: + Run `no-mistakes axi respond --action approve` to accept this step and continue + Run `no-mistakes axi respond --action fix --findings ` to have the pipeline fix the selected findings (do not edit files yourself) + Run `no-mistakes axi respond --action skip` to skip this step + Run `no-mistakes axi logs --step review --full` to read the full step log + A long-running call is working, not stalled - background it if your harness needs to, but the run never advances past a gate on its own. Read every return; on a `gate:`, respond; loop until an `outcome:`. + Commit post-pipeline follow-up work on top of the existing branch so every pipeline fix commit remains present. Never abort-and-restart, reset, or replace the branch in a way that drops prior gate-fix commits. +``` + +Read the `action` column per row: decide `r1` (auto-fix) on your own +judgment - `respond --action fix --findings r1` hands it to the pipeline to +fix - but stop and escalate `r2` (ask-user) to the user before responding. A +final state +instead shows `outcome: ` with no +`findings` table. Field names and exact columns can vary by step and version, +so read the actual `findings` header rather than assuming this layout. From a0b1af248b2275c646f54ccc22f211a912d74a70 Mon Sep 17 00:00:00 2001 From: Keith Lee Date: Sat, 29 Aug 2026 13:19:45 -0700 Subject: [PATCH 02/12] Revert "feat(skills): gate handback and merge on PR comment reconciliation" This reverts commit 39c643b45bf74f6a818c7baa3bdd7b67f8da0558. --- skills/no-mistakes/SKILL.md | 385 ------------------------------------ 1 file changed, 385 deletions(-) delete mode 100644 skills/no-mistakes/SKILL.md diff --git a/skills/no-mistakes/SKILL.md b/skills/no-mistakes/SKILL.md deleted file mode 100644 index 92012e177aa..00000000000 --- a/skills/no-mistakes/SKILL.md +++ /dev/null @@ -1,385 +0,0 @@ ---- -name: no-mistakes -description: Validate your code changes through the no-mistakes pipeline - automated code review, tests, lint, docs, push, PR, and CI - before they reach the configured push target. Use when the user asks to run no-mistakes, gate or ship or validate their changes, push safely, asks you to do a task and then validate it, or invokes /no-mistakes. -user-invocable: true ---- - -# no-mistakes - -`no-mistakes` is a local gate that validates your code changes through a pipeline -(intent, rebase, review, test, document, lint, push, PR, CI) before they reach -the configured push target. You drive it through the `no-mistakes axi` command family, which prints -machine-readable [TOON](https://toonformat.dev) to stdout and progress to stderr. - - -## Active validation-step boundary - -A no-mistakes validation-step agent is already inside an active outer run. It -must inspect, fix, and return only its assigned phase. It must never initialize, -start, reattach, rerun, respond to, synchronize, abort, eject, or directly push -a no-mistakes pipeline. Delivery requirements in user intent remain -acceptance context, but the outer executor alone performs the other validation, -push, PR, and CI phases. - -`NO_MISTAKES_GATE` is fast diagnostic evidence, not authorization by -itself. The runtime combines managed Git identity with authenticated process -ancestry. If a pipeline-control command returns -`error.code: nested_gate_context`, stop immediately and -return control to the outer executor. Safe inspection remains available through -`no-mistakes axi status`, `no-mistakes axi logs`, help, and -`no-mistakes doctor`. - - -When the user invokes `/no-mistakes`, report the outcome at the end. If the user -asks for something specific, translate that request into the matching `axi run` -flags yourself - for example, "skip the lint step" becomes `--skip=lint`. Run -`no-mistakes axi run --help` to see the available flags. - -## Two ways to invoke - -`/no-mistakes` works in two modes, depending on whether the user hands you a -task along with the command: - -- **Validate-only** - bare `/no-mistakes` (optionally with flag-style requests - like "skip the lint step"). The user's code changes are already committed; - validate them and report the outcome. -- **Task-first** - `/no-mistakes `, e.g. - `/no-mistakes add a --json flag to the status command`. First carry out the - task yourself, then validate the result through the pipeline: - 1. **Check scope.** Inspect `git status` before you change or commit anything. - Preserve unrelated pre-existing uncommitted changes, and when you commit, - commit only the changes that belong to the user's task. - 2. **Do the work.** Make the changes the task describes, then **commit them on - a feature branch**. If the user is on the repository's default branch, - create a feature branch first - the gate validates committed history on a - non-default branch, so the work must land there before you run. - 3. **Then validate**, passing the user's task as your `--intent`. The task - text is exactly what the user set out to accomplish, in their own words, so - it *is* the intent - preserve requirements stated directly by the user, - including constraints, exclusions, acceptance criteria, and later decisions; - do not condense them into a diff summary or drop them while adding - implementation context. Enrich it with the decisions and tradeoffs you - made while doing the work (see - [Intent is required](#intent-is-required)). - - -## Test-quality rule - -Never add a test whose only evidence is that it opens, reads, greps, parses, or -snapshots implementation source code and finds or omits particular strings, -tokens, lines, commands, function names, prompt phrases, regex matches, AST -shapes, or incidental snapshots. That does not prove behavior: matching text -can be dead or commented out, and a behavior-preserving refactor can change it. - -Instead execute a public or executable interface and assert observable behavior, -state, output, side effects, and failure modes. For machine-consumed declarative -artifacts such as workflow YAML, JSON, policy, .gitignore, or generated -configuration, invoke the real consumer when feasible or parse into a typed or -normalized semantic model and assert meaning. A raw substring or regex over the -file is still the anti-pattern. - -Reading a file is legitimate when the file is itself generated public output, a -serialized protocol, persisted state, an intentional snapshot, or another -explicitly owned text or byte contract. Name that contract, and do not use its -contents as a proxy that unrelated code works. A natural-language prompt or -instruction is not proven effective because its source contains a sentence. -Deterministic CI may test the final emitted prompt delivered to an agent as an -intentional generated interface; model interpretation belongs in -development-only evaluation, not live-LLM CI. - -For a regression, reproduce the reported failure when feasible: the test should -fail before the fix and pass after it. - - -Everything below - preconditions, intent, the validate-and-decide loop - applies -the same way once the work is committed on a feature branch. - -## Before you start - -- The work you want validated must be **committed** on a branch. The gate - validates committed history, not your uncommitted working tree. -- You must be on a **feature branch**, not the repository's default branch. -- The repository must already be initialized with `no-mistakes init`. -- The daemon must have a runnable configured pipeline agent: a supported native - agent binary, the `agent: cursor` ACP alias, or an explicit `acp:` through - `acpx`. You are the AXI driver, not - an implicit pipeline-agent backend. If none is available, the run fails - before its first step; `no-mistakes doctor` reports the configuration problem. - -If any of these is not met, `axi run` returns an `error:` with the exact command -to fix it - read it and act on it (commit your work, or create a branch). If the -repository is not initialized, run `no-mistakes init` first; if the `no-mistakes` -command itself is missing or misbehaving, `no-mistakes doctor` reports what is -wrong. -Before starting, run `no-mistakes axi` (home view). -If it shows an active run on your current branch, inspect it with `no-mistakes axi status`. -If it is parked at a gate, drive it with `no-mistakes axi respond`. -Reattach an in-flight run by re-running `no-mistakes axi run` when it still matches your current `HEAD` - either as the submitted head or as the current pipeline head. -Only `no-mistakes axi abort` it when you mean to discard that run before starting over; aborting is a between-runs action, never a way to take over or bypass a gate while a run is still going (see [Validate and decide](#validate-and-decide)). -If it shows an active run on another branch, leave that run alone and start validation for your current branch with `no-mistakes axi run --intent "..."`. - -## Intent is required - -When you start a run you must pass `--intent`: **what the user set out to -accomplish** - the goal or request behind this work, in their terms. This is not -a description of the diff or the files you changed; it is the objective the -change is meant to achieve. You know it from the conversation, so pass it -directly - no-mistakes uses it verbatim instead of inferring it from local agent -transcripts (slower and flakier). - -Err on the side of completeness, not brevity. The review step uses `--intent` -to tell a deliberate decision apart from a mistake, so a thin one-line summary -makes it flag things the user already chose. Capture the nuance: the user's -goal, the specific decisions and tradeoffs they made along the way, any -constraints or approaches they ruled in or out, and anything they explicitly -asked for that might otherwise look surprising in the diff. A few sentences to a -short paragraph is normal - write down what you learned from the conversation -that a reviewer reading only the diff would not know. - -## Validate and decide - -Run the pipeline and decide on its findings as they come up: - -1. Start the run. It blocks until the first decision point or the end: - ```sh - no-mistakes axi run --intent "" - ``` - `axi run` and every `axi respond` block synchronously - the review, test, - and CI steps can each take **several minutes**, so a single call may not - return for a while. That is normal; allow a long timeout and do not cancel - or re-issue the command because it seems slow. To check progress without - disturbing the run, use `no-mistakes axi status` from a separate call. - A long-running call is working, not stalled - background it if your harness - needs to, but the run **never advances past a gate on its own**. Read every - return; on a `gate:`, respond; loop until an `outcome:`. Never idle-wait - for the run to move forward by itself. - When that status output includes `awaiting_agent: parked ` under the run, - the run is parked at an approval or fix-review gate and waiting for you to - send `axi respond`. The field is observability only: it does not change - gate resolution, auto-resume the run, or make `--yes` the default. - While a step is actively `running` or `fixing`, `axi status` may include - `active_steps` with `active_for`, `last_activity`, a native `agent_pid` when - a subprocess agent is running, and the current round such as `round 1`, - `auto-fix 1/3`, or `fix 2`. If `last_activity` is prefixed with - `quiet`, no step log or native-agent lifecycle activity has arrived for - longer than `step_quiet_warning`. Treat that as a liveness clue, not as - permission to cancel, rerun, or edit the worktree yourself. -2. If the output contains a `gate:` object, the pipeline is waiting on you. - Read its `findings` table. Each finding has an `id`, `severity`, - `file`, `description`, and an `action` that tells you how the - pipeline classified it: - - `auto-fix` - mechanical and low-risk; you can authorize the fix on - your own judgment by responding with `--action fix`. - - `no-op` - informational only; nothing to do. - - `ask-user` - the finding challenges the user's deliberate intent or - touches product behavior. This is a call only the user can make - see - [Escalate `ask-user` findings](#escalate-ask-user-findings) below. - - **Review auto-fix is disabled by default** (`auto_fix.review: 0`; a repo - or global `auto_fix.review > 0` override re-enables it), so blocking and - ask-user review findings park for your decision rather than being silently - self-fixed. (Other steps such as test and lint may auto-fix within the - pipeline and re-run before they ever gate.) - - Choose one response: - ```sh - # accept the step as-is and continue - no-mistakes axi respond --action approve - - # have the pipeline fix specific findings, then continue - no-mistakes axi respond --action fix --findings --instructions "" - - # skip this step - no-mistakes axi respond --action skip - ``` - While a run is active, never fix findings by editing the code yourself - - the pipeline owns both the findings and the fixes. Your job at a gate is to - decide and respond; `--action fix` has the pipeline apply the fix and - re-review the result. For the same reason, while a run is active do **not** - `abort` or `rerun` to go fix a finding yourself - even a real bug in - your own code - because that discards the pipeline's in-flight work and - forces a full re-validation. `abort` and `rerun` are for *between* - runs (after a `failed` or `cancelled` outcome), never to circumvent a - gate. - - Each `respond` blocks until the next `gate:`, `checks-passed` decision point, or final outcome. - - Two extra flags are available on `respond` when you need them: - - `--add-finding ''` (with `--action fix`) folds a finding you - spotted yourself - one the pipeline did not surface - into the fix round, - as a JSON finding object. Use it for a problem you noticed that is not in - the gate's own `findings` table. - - `--step ` responds to a specific step instead of the one currently - awaiting approval. You rarely need this; omit it to answer the active gate. -3. Repeat step 2 until the output has an `outcome:` instead of a `gate:`. The - outcomes are: - - `checks-passed` - the change is validated and CI is green (or the - trusted default-branch config declares `no_ci: true` and no checks are - registered - the help line names that declaration when it applies), but - the PR is not merged yet. **You are done driving the pipeline.** Do not - wait for the merge: tell the user the PR is ready and ask them to review - and merge it (the PR link is in the `help` line). A generic empty forge - check list without that declaration is not ready. no-mistakes keeps - monitoring the PR in the background until it is merged, closed, or its - configured idle timeout elapses, so a human can watch it in the TUI. - Before reporting `checks-passed`, reconcile every open comment on the PR - per [PR comment reconciliation](#pr-comment-reconciliation-before-handback-or-merge). - - `passed` - the changes cleared the gate and the PR was merged or closed. - - `failed` or `cancelled` - they did not; read the output and address it. - Fix whatever the output points at (a failing test, a lint error, a finding - you skipped), commit the fix on the same feature branch, then drive the - pipeline again - `no-mistakes axi run --intent "..."` starts a fresh run, - or `no-mistakes rerun` re-runs the pipeline for the current branch. This - is the right place to start over: a fresh run or `rerun` is a - *between-runs* action, correct only after a terminal outcome like this - - never mid-run to circumvent a gate. Do not leave the user at a `failed` - outcome without either retrying or explaining what blocks it. - -## PR comment reconciliation before handback or merge - -Before you report `checks-passed` (which hands the PR back for human review) or -before the PR is merged, verify that every comment on the PR has been responded -to or addressed. An open, unanswered comment thread is a blocker: the pipeline -must not present the PR as ready for human review, nor merge it, while a review -comment or thread is still waiting for a response or a fix. - -The check covers every comment surface the forge exposes - issue comments and -inline review threads (conversations) on the PR - and runs before the PR leaves -pipeline control: - -1. List the PR's comments and review threads with the forge's CLI, for example - `gh pr view --comments` plus `gh api` for review threads, or the - GitLab equivalent for `glab`. -2. For each comment or thread, confirm it is either: - - already responded to with a reply, or - - addressed by a change in the PR branch (and the reply notes the fix), or - - a non-actionable note that carries no question or requested change. -3. When a comment carries a question or a requested change and has no response, - respond to it before reporting `checks-passed` or merging - post the reply - through the forge's CLI (for example `gh api` or `gh pr comment`) explaining - how it was handled or resolved. -4. Only then report `checks-passed` or allow the merge. - -An unanswered comment is a captain-facing handback: if a comment needs a product -decision you cannot make yourself, escalate it before reporting the PR ready, -the same way an `ask-user` finding is escalated. Never dismiss a real comment -as noise, and never claim a comment is addressed when no change or reply covers -it. The goal is that a human reviewer opening the PR finds every concern -already answered or resolved. - -Before any post-pipeline local commit or fresh run, read the structured `branch_sync` object returned by AXI home, status, or a drive result. -Only when its `next_action.code` is `sync`, run `no-mistakes axi sync` first. -That guarded sync may be a strict fast-forward or a content-equivalent diverged advance that anchors the pre-sync head before moving the branch with reset semantics; genuine divergence stays blocked. -If it reports `next_action.code` is `continue_active_run`, the pipeline still owns the branch: run the reported command, keep driving the active run, and do not make local follow-up commits. -When `next_action.code` is `recover_custody`, a terminal run left unpublished pipeline commits preserved in the local gate: run `no-mistakes axi sync --recover` to return custody and take the preserved head, or `no-mistakes rerun` to resume validating it instead. -Recovery takes that head by fast-forward, or by adopting a diverged preserved head proven to carry every local change - the ordinary result of the pipeline rebasing your commits onto a newer base - after anchoring your pre-recovery head under `refs/no-mistakes/recover-local/`. -That proof is deliberately narrow, so a rebase whose fix rounds also rewrote your own lines refuses instead of being adopted: when nothing can tell a deliberate pipeline fix from a dropped change, the decision is yours. -A `branch_sync.state` of `user_owned` means the run went terminal before changing the submitted head and cancellation released the branch: the exact branch and head are yours and immediately usable for whichever delivery path is authorized - no sync action is needed, and a repeated `--recover` there is a harmless no-op. -A dirty worktree, or divergence that cannot be proven contained, makes the recovery refuse with explicit choices; `--keep-local` keeps your current head while the preserved commits stay anchored under `refs/no-mistakes/recover/`. -If synchronization is blocked, process that structured state instead of improvising reset, stash, merge, rebase, force, or branch replacement. -After synchronization, commit the follow-up on top and re-run `no-mistakes axi run --intent "..."` with the original user intent. -This preserves every prior gate-fix commit regardless of its configured subject. - -The CI step deliberately keeps watching the PR after checks pass, so -`axi run` returns `checks-passed` the moment checks are green (or a trusted -`no_ci: true` declaration covers a zero-check repository) rather than -blocking on the human merge. Never poll or re-run waiting for the merge yourself. -Never treat "no CI checks reported" alone as green. - -Because that monitor stays live, a PR that falls behind the default branch or -hits a merge conflict after checks pass - commonly because another PR merged -first - needs **no command from you**: never hand-rebase. When the CI monitor -sees an actual conflict it **rebases onto the base, resolves it, and re-pushes -the branch itself**; a PR that is merely behind but still clean needs nothing -either, since the platform merges it. The one exception is when that monitor is -no longer running - the PR was closed, the run was aborted or superseded, it -idle-timed-out, or its auto-fix attempts were exhausted - in which case recover -with `no-mistakes rerun`, which cancels the stale monitor and re-runs the full -pipeline including a deterministic rebase step. Do **not** reach for -`no-mistakes axi run` to refresh a still-active PR: after `checks-passed` it -reattaches to the running monitor (HEAD unchanged) and returns its output -without rebasing. - -On a successful outcome (`checks-passed` or `passed`), close the loop with the -user: summarize what happened during the pipeline in a concise, easily readable -format - what was validated and what was found. If the output includes a -`fixes` table, the pipeline fixed findings your original change missed: -acknowledge those misses and explicitly list each fix so the user can easily -review them. When any PR comment was responded to or resolved during -[PR comment reconciliation](#pr-comment-reconciliation-before-handback-or-merge), -mention those responses so the user knows the review threads are closed. - -## Escalate `ask-user` findings - -A gate whose findings are all `auto-fix` or `no-op` is safe to drive on your -own judgment: respond with `--action fix` or `--action approve` as -appropriate. But a finding marked -`ask-user` is a decision that belongs to the user, not you - the pipeline -flagged it because it challenges their deliberate intent or changes product -behavior. Do not approve, fix, or skip it on your own. Instead, stop and bring -it to the user before you respond: - -- Relay each `ask-user` finding to them as the pipeline wrote it - its - `id`, `file`, and full `description` verbatim. Do not paraphrase, - summarize away the detail, or pre-judge the answer. -- Ask how they want to proceed, then translate their decision into the matching - `respond` call: `--action fix` (pass their guidance through - `--instructions`), `--action approve`, or `--action skip`. - -The one exception is `--yes` (below): it is the user's standing consent to -drive every gate unattended, so under `--yes` you resolve `ask-user` -findings automatically instead of stopping to ask. - -If you have clear consent to drive the run automatically, pass `--yes` to `axi run` -or `axi respond`. It treats every actionable finding - `auto-fix` and -`ask-user` alike - as consent to fix it, selects every current finding for one -fix round, accepts the resulting fix review, and approves gates with only -`no-op` findings. Only use it when the user has asked you to drive the whole -run without checking back. - -## Inspecting state - -```sh -no-mistakes axi # home view: current branch, active runs, next steps -no-mistakes axi status # full detail plus cached branch_sync when relevant -no-mistakes axi sync --check # freshly verify an offered synchronization plan -no-mistakes axi sync # apply only an offered guarded synchronization -no-mistakes axi sync --recover # return custody after a terminal run left unpublished pipeline commits -no-mistakes axi logs --step --full # full log output of one step -no-mistakes axi abort # cancel the current-branch active run -no-mistakes axi abort --run # cancel a specific run by id (works outside its worktree) -``` - -## Reading the output - -- Output is TOON: `key: value` pairs, `name[N]{cols}:` tables, and `help[N]:` hints. -- A non-terminal run object may include `awaiting_agent: parked ` immediately after `status`; that means the run is parked at a gate awaiting your `axi respond`. -- A run object with a `running` or `fixing` step may include an `active_steps` table. Use it to see the active duration, latest activity, native agent PID, and current execution or fix round. -- The `help` list at the bottom of most responses tells you the next commands to run. -- Errors are printed as `error: ...` on stdout with a `help` list; act on the suggestion. -- Exit codes: `0` success, no-op, or normal decision gates, `1` failed or cancelled final outcomes, `2` bad usage. - -A `gate:` waiting on you looks roughly like this - a `gate:` line naming the step, optional step-specific fields such as `note`, a `findings[N]{...}:` table with one row per finding, and a `help[N]:` list of next commands: - -``` -gate: review -note: Review auto-fix is disabled by default (auto_fix.review: 0; a repo or global auto_fix.review > 0 override re-enables it), so blocking and ask-user review findings park for your decision rather than being silently self-fixed. -findings[2]{id,severity,file,line,action,description}: - r1,warning,internal/pipeline/executor.go,,auto-fix,Error from os.Remove is ignored - r2,error,cmd/no-mistakes/main.go,,ask-user,New --force flag bypasses the confirm prompt -help[6]: - Run `no-mistakes axi respond --action approve` to accept this step and continue - Run `no-mistakes axi respond --action fix --findings ` to have the pipeline fix the selected findings (do not edit files yourself) - Run `no-mistakes axi respond --action skip` to skip this step - Run `no-mistakes axi logs --step review --full` to read the full step log - A long-running call is working, not stalled - background it if your harness needs to, but the run never advances past a gate on its own. Read every return; on a `gate:`, respond; loop until an `outcome:`. - Commit post-pipeline follow-up work on top of the existing branch so every pipeline fix commit remains present. Never abort-and-restart, reset, or replace the branch in a way that drops prior gate-fix commits. -``` - -Read the `action` column per row: decide `r1` (auto-fix) on your own -judgment - `respond --action fix --findings r1` hands it to the pipeline to -fix - but stop and escalate `r2` (ask-user) to the user before responding. A -final state -instead shows `outcome: ` with no -`findings` table. Field names and exact columns can vary by step and version, -so read the actual `findings` header rather than assuming this layout. From b1e9375ce3a55dcd72b9d99457f52dfa6b74c2da Mon Sep 17 00:00:00 2001 From: Keith Lee Date: Sun, 30 Aug 2026 14:39:43 -0700 Subject: [PATCH 03/12] feat(linear): add read-only issue and comment detector --- bin/fm-procevent-linear.sh | 288 ++++++++++++++++++++++++++++++ bin/fm-test-run.sh | 4 + tests/fm-procevent-linear.test.sh | 168 +++++++++++++++++ 3 files changed, 460 insertions(+) create mode 100755 bin/fm-procevent-linear.sh create mode 100755 tests/fm-procevent-linear.test.sh diff --git a/bin/fm-procevent-linear.sh b/bin/fm-procevent-linear.sh new file mode 100755 index 00000000000..8e22771d4cf --- /dev/null +++ b/bin/fm-procevent-linear.sh @@ -0,0 +1,288 @@ +#!/usr/bin/env bash +# Read-only Linear GraphQL process-event adapter. +# +# Usage: +# fm-procevent-linear.sh arm +# fm-procevent-linear.sh retire +# fm-procevent-linear.sh poll +# fm-procevent-linear.sh poll-once +# fm-procevent-linear.sh source-id +# fm-procevent-linear.sh classify +# fm-procevent-linear.sh terminal +# fm-procevent-linear.sh silent +# fm-procevent-linear.sh read +# +# The adapter owns detection only. Every GraphQL document below is a named query, +# and the adapter has no mutation command or fallback writer. It records a private +# observation snapshot so an unchanged Linear response produces no process result, +# wake, or model call. The generic process-event runner owns durable capture after +# this command prints an event. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +GRAPHQL_URL="${FM_LINEAR_GRAPHQL_URL:-https://api.linear.app/graphql}" +POLL_INTERVAL="${FM_LINEAR_POLL_INTERVAL:-30}" + +die() { printf 'error: %s\n' "$1" >&2; exit 1; } +usage() { sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 2; } + +require_runtime() { + command -v curl >/dev/null 2>&1 || die "curl is required" + command -v jq >/dev/null 2>&1 || die "jq is required" + [ -n "${LINEAR_API_KEY:-}" ] || die "LINEAR_API_KEY is required" + case "$POLL_INTERVAL" in + ''|*[!0-9]*) die "FM_LINEAR_POLL_INTERVAL must be whole seconds: $POLL_INTERVAL" ;; + esac +} + +canonical_config() { + local config=${1-} real + [ -n "$config" ] || usage + real=$(perl -MCwd=realpath -e '$p = realpath($ARGV[0]); defined($p) or exit 1; print "$p\n"' "$config" 2>/dev/null) \ + || die "cannot resolve Linear poll config: $config" + [ -f "$real" ] && [ ! -L "$real" ] || die "Linear poll config is not a regular file: $config" + jq -e ' + .schema == "fm-linear-poll.v1" + and (.projects | type == "array" and length > 0) + and all(.projects[]; + (.linearProjectSlug | type == "string" and length > 0) + and (.linearProjectName | type == "string" and length > 0) + and (.firstmateProject | type == "string" and length > 0)) + and ((.allowIssues // []) | type == "array") + ' "$real" >/dev/null 2>&1 || die "invalid Linear poll config: $config" + printf '%s\n' "$real" +} + +cmd_source_id() { + local config real digest + config=${1-} + [ "$#" -eq 1 ] || usage + real=$(canonical_config "$config") || exit 1 + if command -v shasum >/dev/null 2>&1; then + digest=$(printf '%s' "$real" | shasum -a 256 | awk '{print substr($1,1,16)}') + else + digest=$(printf '%s' "$real" | sha256sum | awk '{print substr($1,1,16)}') + fi + printf 'linear-%s\n' "$digest" +} + +snapshot_path() { + local id=$1 + printf '%s/linear-poll/%s.snapshot.json\n' "$STATE" "$id" +} + +graphql_query() { # + local query=$1 variables=$2 response + response=$(curl -fsS -X POST "$GRAPHQL_URL" \ + -H "Authorization: $LINEAR_API_KEY" \ + -H 'Content-Type: application/json' \ + --data-binary "$(jq -cn --arg query "$query" --argjson variables "$variables" \ + '{query:$query,variables:$variables}')") \ + || die "Linear GraphQL query failed" + printf '%s' "$response" | jq -e '.errors == null and (.data | type == "object")' >/dev/null 2>&1 \ + || die "Linear GraphQL returned errors" + printf '%s\n' "$response" +} + +# shellcheck disable=SC2016 # GraphQL variables are literal dollar-prefixed names. +ISSUES_QUERY='query FirstmateLinearIssues($projectSlug: String!, $stateNames: [String!]!, $first: Int!, $after: String) { + issues(filter: {project: {slugId: {eq: $projectSlug}}, state: {name: {in: $stateNames}}}, first: $first, after: $after) { + nodes { + id identifier title url updatedAt + project { id name slugId } + state { name type } + inverseRelations(first: 50) { nodes { type issue { id identifier state { name type } } } } + } + pageInfo { hasNextPage endCursor } + } +}' + +# shellcheck disable=SC2016 # GraphQL variables are literal dollar-prefixed names. +COMMENTS_QUERY='query FirstmateLinearComments($issueId: String!, $first: Int!, $after: String) { + issue(id: $issueId) { + comments(first: $first, after: $after) { + nodes { id createdAt updatedAt } + pageInfo { hasNextPage endCursor } + } + } +}' + +fetch_project_issues() { # + local slug=$1 states=$2 after='' page all='[]' variables + while :; do + variables=$(jq -cn --arg slug "$slug" --argjson states "$states" --arg after "$after" ' + {projectSlug:$slug,stateNames:$states,first:50,after:(if $after == "" then null else $after end)}') + page=$(graphql_query "$ISSUES_QUERY" "$variables") || return 1 + all=$(jq -cn --argjson accumulated "$all" --argjson page "$page" \ + '$accumulated + ($page.data.issues.nodes // [])') || return 1 + [ "$(printf '%s' "$page" | jq -r '.data.issues.pageInfo.hasNextPage // false')" = true ] || break + after=$(printf '%s' "$page" | jq -r '.data.issues.pageInfo.endCursor // empty') + [ -n "$after" ] || die "Linear issue pagination omitted endCursor" + done + printf '%s\n' "$all" +} + +fetch_issue_comments() { # + local issue_id=$1 after='' page all='[]' variables + while :; do + variables=$(jq -cn --arg issue "$issue_id" --arg after "$after" ' + {issueId:$issue,first:50,after:(if $after == "" then null else $after end)}') + page=$(graphql_query "$COMMENTS_QUERY" "$variables") || return 1 + all=$(jq -cn --argjson accumulated "$all" --argjson page "$page" \ + '$accumulated + ($page.data.issue.comments.nodes // [])') || return 1 + [ "$(printf '%s' "$page" | jq -r '.data.issue.comments.pageInfo.hasNextPage // false')" = true ] || break + after=$(printf '%s' "$page" | jq -r '.data.issue.comments.pageInfo.endCursor // empty') + [ -n "$after" ] || die "Linear comment pagination omitted endCursor" + done + printf '%s\n' "$all" +} + +poll_cycle() { # : prints one envelope only on change + local config=$1 id=$2 snapshot previous='{"issues":{},"comments":{}}' first_observation=true + local states allow project slug project_name fm_project issues issue issue_id comments + local current='{"issues":{},"comments":{}}' events='[]' staged + snapshot=$(snapshot_path "$id") + if [ -f "$snapshot" ]; then + jq -e '.issues | type == "object"' "$snapshot" >/dev/null 2>&1 \ + || die "invalid Linear poll snapshot: $snapshot" + previous=$(jq -c . "$snapshot") || exit 1 + first_observation=false + fi + states=$(jq -c '.activeStates // ["Todo", "In Progress", "Blocked", "Human Review"]' "$config") + allow=$(jq -c '.allowIssues // []' "$config") + + while IFS= read -r project; do + slug=$(printf '%s' "$project" | jq -r '.linearProjectSlug') + project_name=$(printf '%s' "$project" | jq -r '.linearProjectName') + fm_project=$(printf '%s' "$project" | jq -r '.firstmateProject') + issues=$(fetch_project_issues "$slug" "$states") || return 1 + while IFS= read -r issue; do + [ -n "$issue" ] || continue + issue_id=$(printf '%s' "$issue" | jq -r '.id') + current=$(jq -cn --argjson current "$current" --argjson issue "$issue" \ + '$current | .issues[$issue.id] = {identifier:$issue.identifier,state:$issue.state.name,updatedAt:$issue.updatedAt,url:$issue.url}') + comments=$(fetch_issue_comments "$issue_id") || return 1 + current=$(jq -cn --argjson current "$current" --argjson comments "$comments" ' + reduce $comments[] as $comment ($current; + .comments[$comment.id] = {createdAt:$comment.createdAt,updatedAt:$comment.updatedAt})') + + if [ "$(printf '%s' "$issue" | jq -r '.state.name')" = Todo ] \ + && printf '%s' "$issue" | jq -e ' + [(.inverseRelations.nodes // [])[] + | select((.type | ascii_downcase) == "blocks") + | select((.issue.state.type | ascii_downcase) != "completed") + | select((.issue.state.type | ascii_downcase) != "canceled")] + | length == 0' >/dev/null \ + && { [ "$(printf '%s' "$allow" | jq 'length')" -eq 0 ] \ + || printf '%s' "$allow" | jq -e --arg identifier "$(printf '%s' "$issue" | jq -r '.identifier')" \ + 'index($identifier) != null' >/dev/null; } \ + && ! printf '%s' "$previous" | jq -e --arg issue "$issue_id" '.issues[$issue] != null' >/dev/null; then + events=$(jq -cn --argjson events "$events" --argjson issue "$issue" \ + --arg project "$project_name" --arg firstmateProject "$fm_project" ' + $events + [{eventType:"todo.detected",issueId:$issue.id,identifier:$issue.identifier, + title:$issue.title,projectName:$project,firstmateProject:$firstmateProject,url:$issue.url, + observedUpdatedAt:$issue.updatedAt}]') + fi + + if [ "$first_observation" = false ]; then + events=$(jq -cn --argjson events "$events" --argjson issue "$issue" --argjson comments "$comments" \ + --argjson previous "$previous" --arg project "$project_name" --arg firstmateProject "$fm_project" ' + reduce ($comments[] | select($previous.comments[.id] == null)) as $comment ($events; + . + [{eventType:"comment.detected",issueId:$issue.id,identifier:$issue.identifier, + commentId:$comment.id,projectName:$project,firstmateProject:$firstmateProject,url:$issue.url, + commentCreatedAt:$comment.createdAt,commentUpdatedAt:$comment.updatedAt}])') + fi + done < <(printf '%s' "$issues" | jq -c '.[]') + done < <(jq -c '.projects[]' "$config") + + mkdir -p "$(dirname "$snapshot")" || die "cannot create Linear poll state directory" + staged=$(mktemp "$(dirname "$snapshot")/.snapshot.XXXXXX") || die "cannot stage Linear poll snapshot" + printf '%s\n' "$current" | jq -S . > "$staged" || { rm -f "$staged"; die "cannot write Linear poll snapshot"; } + chmod 600 "$staged" 2>/dev/null || true + mv "$staged" "$snapshot" || { rm -f "$staged"; die "cannot publish Linear poll snapshot"; } + + [ "$(printf '%s' "$events" | jq 'length')" -gt 0 ] || return 0 + jq -cn --arg schema fm-linear-event.v1 --arg source "$id" --argjson events "$events" \ + '{schema:$schema,sourceId:$source,events:$events}' +} + +cmd_poll_once() { + local config real id + config=${1-} + [ "$#" -eq 1 ] || usage + require_runtime + real=$(canonical_config "$config") || exit 1 + id=$(cmd_source_id "$real") || exit 1 + poll_cycle "$real" "$id" +} + +cmd_poll() { + local config real id result + config=${1-} + [ "$#" -eq 1 ] || usage + require_runtime + real=$(canonical_config "$config") || exit 1 + id=$(cmd_source_id "$real") || exit 1 + while :; do + result=$(poll_cycle "$real" "$id") || return 1 + if [ -n "$result" ]; then + printf '%s\n' "$result" + return 0 + fi + sleep "$POLL_INTERVAL" + done +} + +cmd_arm() { + local config real id + config=${1-} + [ "$#" -eq 1 ] || usage + require_runtime + real=$(canonical_config "$config") || exit 1 + id=$(cmd_source_id "$real") || exit 1 + "$SCRIPT_DIR/fm-procevent.sh" register linear "$id" \ + -- "$SCRIPT_DIR/fm-procevent-linear.sh" poll "$real" || exit 1 + printf 'armed: %s\nconfig: %s\n' "$id" "$real" +} + +cmd_retire() { + local config=${1-} id + [ "$#" -eq 1 ] || usage + id=$(cmd_source_id "$config") || exit 1 + "$SCRIPT_DIR/fm-procevent.sh" retire "$id" +} + +cmd_classify() { + jq -e '.schema == "fm-linear-event.v1" and (.events | length > 0)' "${1-}" >/dev/null 2>&1 \ + && printf 'changed\n' || printf 'unknown\n' +} + +cmd_terminal() { return 1; } +cmd_silent() { return 1; } + +cmd_read() { + local file=${1-} + [ -f "$file" ] || die "result file does not exist: $file" + jq -r ' + "LINEAR EVENTS: \(.events | length)", + (.events[] | "event_type: \(.eventType)\nidentifier: \(.identifier)\nproject: \(.projectName)\nurl: \(.url)" + + (if .commentId then "\ncomment_id: \(.commentId)" else "" end)) + ' "$file" +} + +case "${1-}" in + arm) shift; cmd_arm "$@" ;; + retire) shift; cmd_retire "$@" ;; + poll) shift; cmd_poll "$@" ;; + poll-once) shift; cmd_poll_once "$@" ;; + source-id) shift; cmd_source_id "$@" ;; + classify) shift; cmd_classify "$@" ;; + terminal) shift; cmd_terminal "$@" ;; + silent) shift; cmd_silent "$@" ;; + read) shift; cmd_read "$@" ;; + ''|-h|--help|help) usage ;; + *) die "unknown command: $1" ;; +esac diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 3f01bc81005..a3a2d9bda9b 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -560,6 +560,7 @@ tests/fm-pi-watch-extension.test.sh 17979 tests/fm-pr-check-security.test.sh 250417 tests/fm-procevent-when.test.sh 15249 tests/fm-procevent.test.sh 53142 +tests/fm-procevent-linear.test.sh 5000 tests/fm-project-origin.test.sh 105 tests/fm-public-followup.test.sh 36301 tests/fm-quota-array-dispatch-live-e2e.test.sh 18 @@ -1166,6 +1167,9 @@ families_for_changed_path() { printf '%s\n' __script__:fm-procevent-when.test.sh printf '%s\n' __script__:fm-remote-reply.test.sh ;; + bin/fm-procevent-linear.sh) + printf '%s\n' __script__:fm-procevent-linear.test.sh + ;; bin/fm-timeout-lib.sh) # The shared hard bound: session start's runtime bound, the fleet/bearings # snapshots, the vendor auth probe, the stow cascade's per-home step, and diff --git a/tests/fm-procevent-linear.test.sh b/tests/fm-procevent-linear.test.sh new file mode 100755 index 00000000000..e625edf3a39 --- /dev/null +++ b/tests/fm-procevent-linear.test.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# Behavior tests for the read-only Linear process-event adapter. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TMP_ROOT=$(fm_test_tmproot fm-procevent-linear-tests) +TMP_ROOT=$(cd "$TMP_ROOT" && pwd -P) +export FM_PROCEVENT_CLAIM_ROOT="$TMP_ROOT/claims" +HOME_DIR="$TMP_ROOT/home" +SERVER="$TMP_ROOT/fake-linear.py" +CONFIG="$TMP_ROOT/linear-poll.json" +PHASE="$TMP_ROOT/phase" +REQUESTS="$TMP_ROOT/requests.jsonl" +PORT_FILE="$TMP_ROOT/port" +RUNNER_LOG="$TMP_ROOT/runner.log" +RUNNER_PID='' +mkdir -p "$HOME_DIR/state" + +cat > "$SERVER" <<'PY' +import json +import pathlib +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +phase_path = pathlib.Path(sys.argv[1]) +requests_path = pathlib.Path(sys.argv[2]) +port_path = pathlib.Path(sys.argv[3]) + +issue = { + "id": "issue-immutable-id", + "identifier": "HAN-28", + "title": "Make M14 the default and first CameraOnboarding profile", + "url": "https://linear.app/hanzireader/issue/HAN-28/make-m14-the-default-and-first-cameraonboarding-profile", + "updatedAt": "2026-08-30T12:00:00.000Z", + "project": {"id": "project-id", "name": "Messsucher", "slugId": "messsucher-729853ec4ffb"}, + "state": {"name": "Todo", "type": "unstarted"}, + "inverseRelations": {"nodes": []}, +} + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + return + + def do_POST(self): + size = int(self.headers.get("Content-Length", "0")) + body = json.loads(self.rfile.read(size)) + query = body.get("query", "") + with requests_path.open("a", encoding="utf-8") as output: + output.write(json.dumps({"query": query, "variables": body.get("variables")}) + "\n") + if query.lstrip().startswith("mutation"): + self.send_response(409) + self.end_headers() + return + if "FirstmateLinearIssues" in query: + data = {"issues": {"nodes": [issue], "pageInfo": {"hasNextPage": False, "endCursor": None}}} + elif "FirstmateLinearComments" in query: + comments = [{"id": "comment-existing", "createdAt": "2026-08-29T10:00:00.000Z", "updatedAt": "2026-08-29T10:00:00.000Z"}] + if phase_path.exists() and phase_path.read_text(encoding="utf-8").strip() == "new-comment": + comments.append({"id": "comment-new", "createdAt": "2026-08-30T13:00:00.000Z", "updatedAt": "2026-08-30T13:00:00.000Z"}) + data = {"issue": {"comments": {"nodes": comments, "pageInfo": {"hasNextPage": False, "endCursor": None}}}} + else: + self.send_response(400) + self.end_headers() + return + payload = json.dumps({"data": data}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + +server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) +port_path.write_text(str(server.server_address[1]), encoding="utf-8") +server.serve_forever() +PY + +cat > "$CONFIG" <<'JSON' +{ + "schema": "fm-linear-poll.v1", + "projects": [ + { + "linearProjectSlug": "messsucher-729853ec4ffb", + "linearProjectName": "Messsucher", + "firstmateProject": "FilmLeica" + } + ], + "allowIssues": ["HAN-28"] +} +JSON + +python3 "$SERVER" "$PHASE" "$REQUESTS" "$PORT_FILE" & +SERVER_PID=$! +cleanup() { + FM_HOME="$HOME_DIR" "$ROOT/bin/fm-procevent.sh" sweep-home >/dev/null 2>&1 || true + [ -z "$RUNNER_PID" ] || kill "$RUNNER_PID" >/dev/null 2>&1 || true + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" >/dev/null 2>&1 || true + fm_test_cleanup +} +trap cleanup EXIT + +for _ in $(seq 1 100); do + [ -s "$PORT_FILE" ] && break + sleep 0.02 +done +[ -s "$PORT_FILE" ] || fail "fake Linear GraphQL server did not start" +URL="http://127.0.0.1:$(cat "$PORT_FILE")/graphql" + +linear() { + FM_HOME="$HOME_DIR" FM_LINEAR_GRAPHQL_URL="$URL" FM_LINEAR_POLL_INTERVAL=1 \ + LINEAR_API_KEY=test-key "$ROOT/bin/fm-procevent-linear.sh" "$@" +} + +out=$(linear poll-once "$CONFIG") +assert_contains "$out" '"eventType":"todo.detected"' "first observation emits the eligible Todo" +assert_contains "$out" '"issueId":"issue-immutable-id"' "event carries the immutable Linear issue id" +assert_contains "$out" '"url":"https://linear.app/hanzireader/issue/HAN-28/make-m14-the-default-and-first-cameraonboarding-profile"' \ + "event preserves the exact API-provided Linear URL" +assert_not_contains "$out" comment-existing "existing comments are baselined without an event" +pass "eligible Todo detection is exact and existing comments are baselined" + +out=$(linear poll-once "$CONFIG") +[ -z "$out" ] || fail "unchanged snapshot produced output: $out" +pass "an unchanged Linear snapshot is silent" + +linear arm "$CONFIG" >/dev/null +source_id=$(linear source-id "$CONFIG") +FM_HOME="$HOME_DIR" FM_LINEAR_GRAPHQL_URL="$URL" FM_LINEAR_POLL_INTERVAL=1 LINEAR_API_KEY=test-key \ + "$ROOT/bin/fm-procevent.sh" start "$source_id" > "$RUNNER_LOG" 2>&1 & +RUNNER_PID=$! +sleep 0.3 +[ ! -s "$HOME_DIR/state/.wake-queue" ] || fail "unchanged polling created a wake" +[ -z "$(find "$HOME_DIR/state/procevent-inbox" -type f -name '*.result' -print 2>/dev/null)" ] \ + || fail "unchanged polling created a process result" +pass "unchanged background polling creates no wake or model-triggering result" + +printf 'new-comment\n' > "$PHASE" +for _ in $(seq 1 50); do + [ -s "$HOME_DIR/state/.wake-queue" ] && break + sleep 0.1 +done +if [ ! -s "$HOME_DIR/state/.wake-queue" ]; then + FM_HOME="$HOME_DIR" "$ROOT/bin/fm-procevent.sh" list >&2 || true + tail -40 "$RUNNER_LOG" >&2 || true + fail "new comment did not produce a wake" +fi +result=$(find "$HOME_DIR/state/procevent-inbox" -type f -name '*.result' -print | head -1) +[ -n "$result" ] || fail "new comment wake had no durable result" +out=$(linear read "$result") +assert_contains "$out" "event_type: comment.detected" "new comment is classified in the durable result" +assert_contains "$out" "identifier: HAN-28" "new comment retains the assigned issue identifier" +assert_contains "$out" "comment_id: comment-new" "new comment retains its immutable id" +assert_contains "$out" "url: https://linear.app/hanzireader/issue/HAN-28/make-m14-the-default-and-first-cameraonboarding-profile" \ + "comment event preserves the exact API URL" +pass "a new comment becomes one durable process event" + +linear retire "$CONFIG" >/dev/null +if jq -e -s 'any(.[]; (.query | ltrimstr(" ") | startswith("mutation")))' "$REQUESTS" >/dev/null; then + fail "poller attempted a GraphQL mutation" +fi +[ "$(jq -r -s '[.[].query | capture("^(?[A-Za-z]+)").kind] | unique | join(",")' "$REQUESTS")" = query ] \ + || fail "poller sent a non-query GraphQL operation" +pass "the fake GraphQL server observed query operations only" + +printf 'ok: Linear process-event adapter behavior tests passed\n' From cc92dcce212bedb992b088c7b2ece21d7cfa4b15 Mon Sep 17 00:00:00 2001 From: Keith Lee Date: Sun, 30 Aug 2026 14:44:34 -0700 Subject: [PATCH 04/12] feat(linear): assign ticket updates to task owners --- .agents/skills/linear-ticket-intake/SKILL.md | 104 +++++++ bin/fm-linear-ticket-writer.sh | 282 +++++++++++++++++++ bin/fm-test-run.sh | 4 + tests/fm-linear-ticket-writer.test.sh | 97 +++++++ 4 files changed, 487 insertions(+) create mode 100644 .agents/skills/linear-ticket-intake/SKILL.md create mode 100755 bin/fm-linear-ticket-writer.sh create mode 100755 tests/fm-linear-ticket-writer.test.sh diff --git a/.agents/skills/linear-ticket-intake/SKILL.md b/.agents/skills/linear-ticket-intake/SKILL.md new file mode 100644 index 00000000000..988ffd607c5 --- /dev/null +++ b/.agents/skills/linear-ticket-intake/SKILL.md @@ -0,0 +1,104 @@ +--- +name: linear-ticket-intake +description: >- + Agent-only procedure for query-only Linear poll events and agent-owned ticket + updates. Use before arming the Linear poller and on any + `procevent linear ` wake. Owns Linear re-fetch, + duplicate prevention, one-writer assignment, Sol and Luna role separation, + comment routing, writer transfer, and process-event acknowledgement. +user-invocable: false +metadata: + internal: true +--- + +# Linear ticket intake + +Use this procedure before arming `bin/fm-procevent-linear.sh` and whenever a `check:` wake carries `procevent linear `. +Load `process-event-sources` for the shared capture, read, and handled-acknowledgement contract. + +The poller is a detector, never a Linear writer. +It may query mapped projects, compare its private observation snapshot, and emit immutable issue ids, identifiers, project names, event types, comment ids, and the API-provided canonical URL. +Never add a mutation to it, give it a ticket-writer lease, or use it as a fallback when an assigned worker cannot reach Linear MCP. + +## Arm + +Review the private config, then arm the source through its adapter: + +```sh +bin/fm-procevent-linear.sh arm config/linear-poll.json +``` + +The registered command blocks outside the conversational turn and checks every 30 seconds. +An unchanged snapshot prints nothing, so the process-event runner has no result to capture and no wake or model call to create. + +## Handle a detected Todo + +Read the exact captured result through the adapter: + +```sh +bin/fm-procevent-linear.sh read state/procevent-inbox/..result +``` + +Re-fetch the issue through Linear MCP using its immutable id or identifier. +Treat Linear as the source of truth for current status, blockers, project mapping, and canonical URL; reject a mapping mismatch rather than guessing. +Check Firstmate's backlog, live task metadata, and `bin/fm-linear-ticket-writer.sh show ` before dispatch. +If the issue is blocked, no longer Todo, already leased, or already represented by a live or retained local task, do not create another task. + +For a new eligible issue: + +1. Create the normal Firstmate ship task and its local backlog record. +2. Choose one persistent Luna implementation worker as ticket owner and create its lease before spawn: + + ```sh + bin/fm-linear-ticket-writer.sh assign + bin/fm-linear-ticket-writer.sh owner-brief + ``` + +3. Create a separate Sol planning or review task when needed and apply its no-write brief before spawn: + + ```sh + bin/fm-linear-ticket-writer.sh planner-brief + ``` + +4. Spawn through the normal Firstmate harness procedure. + +The owner brief is the authority boundary. +Luna confirms the exact ticket, checks its lease before every Linear mutation, moves Todo to In Progress, creates or updates exactly one `## Firstmate Workpad`, and records the accepted plan, progress, blockers, PR URL, review results, fixes, and completion state. +Luna may change only its assigned ticket and must report a blocker when Linear MCP is unavailable. +Luna moves the ticket to Human Review only after the project delivery gates pass, and marks it Done only after independently verifying the PR merged. + +Sol can plan and review but cannot mutate Linear. +Sol reports findings through Firstmate, and Firstmate steers those findings to Luna so the sole writer records them on the ticket. +Firstmate owns its local backlog and fleet records and must not ask Luna to edit them. + +## Handle a detected comment + +Re-fetch the comment and issue through Linear MCP. +If the issue has a current writer lease, steer the comment to that Luna task through the durable task inbox instead of creating another task or replying as Firstmate. +If no valid lease exists, reconcile the local task state before deciding whether this is missed intake or stale external activity. +The poll event itself never authorizes a Linear reply. + +## Transfer a writer + +Two live workers never share write authority. +Stop or revoke the old worker's Linear work first, then transfer explicitly: + +```sh +bin/fm-linear-ticket-writer.sh transfer +``` + +The current lease generation and append-only transfer history are the durable authority. +Apply an owner brief for the replacement only after the transfer succeeds. +A stale worker fails `assert-writer` and `assert-target` after transfer. + +## Reconcile and acknowledge + +Firstmate may read Linear to reconcile status but does not duplicate Luna's comments, Workpad edits, status changes, or completion update. +After the Todo or comment event is fully routed, acknowledge that exact captured sequence: + +```sh +bin/fm-procevent.sh handled +``` + +Repeated wakes for an already represented issue are a dedupe check, not permission to create another task, lease, or Workpad. +Never merge a project PR without the captain's explicit authority unless the project's separately configured standing merge posture already grants it. diff --git a/bin/fm-linear-ticket-writer.sh b/bin/fm-linear-ticket-writer.sh new file mode 100755 index 00000000000..a73d9ab4fbd --- /dev/null +++ b/bin/fm-linear-ticket-writer.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# Own durable, exclusive Linear ticket-writer assignments and role briefs. +# +# Usage: +# fm-linear-ticket-writer.sh assign +# fm-linear-ticket-writer.sh transfer +# fm-linear-ticket-writer.sh assert-writer +# fm-linear-ticket-writer.sh assert-target +# fm-linear-ticket-writer.sh owner-brief +# fm-linear-ticket-writer.sh planner-brief +# fm-linear-ticket-writer.sh show +# +# Firstmate owns these records. A ticket worker reads and proves its assignment, +# but never edits the lease or Firstmate backlog directly. Replacing a worker is +# an explicit transfer that rewrites the current lease and appends a durable +# history row while holding the assignment lock. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +LEASE_DIR="$STATE/linear-ticket-writers" +LOCK="$LEASE_DIR/.lock" + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" + +die() { printf 'error: %s\n' "$1" >&2; exit 1; } +usage() { sed -n '2,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 2; } + +valid_identifier() { [[ ${1-} =~ ^[A-Z][A-Z0-9]*-[1-9][0-9]*$ ]]; } +valid_actor() { fm_task_id_path_safe "${1-}"; } +valid_issue_id() { [ -n "${1-}" ] && [[ ${1-} != *$'\n'* ]]; } +valid_url() { [[ ${1-} == https://linear.app/* ]] && [[ ${1-} != *$'\n'* ]]; } + +lease_path() { printf '%s/%s.lease\n' "$LEASE_DIR" "$1"; } +history_path() { printf '%s/%s.history\n' "$LEASE_DIR" "$1"; } + +prepare_dir() { + (umask 077; mkdir -p "$LEASE_DIR") || die "cannot create Linear writer state directory" + [ -d "$LEASE_DIR" ] && [ ! -L "$LEASE_DIR" ] || die "unsafe Linear writer state directory" + chmod 700 "$LEASE_DIR" || die "cannot secure Linear writer state directory" +} + +lock_acquire() { + prepare_dir + fm_lock_acquire_wait "$LOCK" || die "cannot lock Linear writer assignments" +} + +lock_release() { fm_lock_release "$LOCK"; } + +load_lease() { # + local identifier=$1 file issue task writer issue_id url generation extra + file=$(lease_path "$identifier") + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + { + IFS= read -r issue + IFS= read -r task + IFS= read -r writer + IFS= read -r issue_id + IFS= read -r url + IFS= read -r generation + ! IFS= read -r extra + } < "$file" || return 2 + issue=${issue#issue=} + task=${task#task=} + writer=${writer#writer=} + issue_id=${issue_id#issue_id=} + url=${url#url=} + generation=${generation#generation=} + [ "$issue" = "$identifier" ] && valid_actor "$task" && valid_actor "$writer" \ + && valid_issue_id "$issue_id" && valid_url "$url" \ + && [[ $generation =~ ^[1-9][0-9]*$ ]] || return 2 + LINEAR_LEASE_TASK=$task + LINEAR_LEASE_WRITER=$writer + LINEAR_LEASE_ISSUE_ID=$issue_id + LINEAR_LEASE_URL=$url + LINEAR_LEASE_GENERATION=$generation +} + +write_lease_locked() { # + local identifier=$1 issue_id=$2 url=$3 task=$4 writer=$5 generation=$6 file staged + file=$(lease_path "$identifier") + staged=$(mktemp "$LEASE_DIR/.lease.XXXXXX") || return 1 + if printf 'issue=%s\ntask=%s\nwriter=%s\nissue_id=%s\nurl=%s\ngeneration=%s\n' \ + "$identifier" "$task" "$writer" "$issue_id" "$url" "$generation" > "$staged" \ + && chmod 600 "$staged" && mv "$staged" "$file"; then + return 0 + fi + rm -f "$staged" + return 1 +} + +task_has_other_lease_locked() { # + local task=$1 except=$2 file identifier + for file in "$LEASE_DIR"/*.lease; do + [ -e "$file" ] || continue + identifier=${file##*/} + identifier=${identifier%.lease} + [ "$identifier" = "$except" ] && continue + load_lease "$identifier" || return 0 + [ "$LINEAR_LEASE_TASK" != "$task" ] || return 0 + done + return 1 +} + +cmd_assign() { + local issue_id=${1-} identifier=${2-} url=${3-} task=${4-} writer=${5-} status + [ "$#" -eq 5 ] || usage + valid_issue_id "$issue_id" || die "invalid immutable Linear issue id" + valid_identifier "$identifier" || die "invalid Linear identifier: $identifier" + valid_url "$url" || die "invalid canonical Linear URL" + valid_actor "$task" || die "invalid Firstmate task id: $task" + valid_actor "$writer" || die "invalid writer id: $writer" + lock_acquire + status=0 + if load_lease "$identifier"; then + if [ "$LINEAR_LEASE_ISSUE_ID" = "$issue_id" ] && [ "$LINEAR_LEASE_URL" = "$url" ] \ + && [ "$LINEAR_LEASE_TASK" = "$task" ] && [ "$LINEAR_LEASE_WRITER" = "$writer" ]; then + printf 'already-assigned: issue=%s task=%s writer=%s\n' "$identifier" "$task" "$writer" + else + status=1 + fi + elif [ "$?" -eq 2 ] || task_has_other_lease_locked "$task" "$identifier"; then + status=1 + elif write_lease_locked "$identifier" "$issue_id" "$url" "$task" "$writer" 1; then + printf '%s\tassign\tissue=%s\ttask=%s\twriter=%s\tgeneration=1\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$identifier" "$task" "$writer" >> "$(history_path "$identifier")" + chmod 600 "$(history_path "$identifier")" + printf 'assigned: issue=%s task=%s writer=%s\n' "$identifier" "$task" "$writer" + else + status=1 + fi + lock_release + [ "$status" -eq 0 ] || die "Linear ticket already has a different writer or task assignment: $identifier" +} + +cmd_transfer() { + local identifier=${1-} expected=${2-} replacement=${3-} generation status=0 + [ "$#" -eq 3 ] || usage + valid_identifier "$identifier" || die "invalid Linear identifier: $identifier" + valid_actor "$expected" || die "invalid expected writer id: $expected" + valid_actor "$replacement" || die "invalid replacement writer id: $replacement" + [ "$expected" != "$replacement" ] || die "replacement writer must differ from current writer" + lock_acquire + if ! load_lease "$identifier" || [ "$LINEAR_LEASE_WRITER" != "$expected" ]; then + status=1 + else + generation=$((LINEAR_LEASE_GENERATION + 1)) + write_lease_locked "$identifier" "$LINEAR_LEASE_ISSUE_ID" "$LINEAR_LEASE_URL" \ + "$LINEAR_LEASE_TASK" "$replacement" "$generation" || status=1 + if [ "$status" -eq 0 ]; then + printf '%s\ttransfer\tissue=%s\ttask=%s\tfrom=%s\twriter=%s\tgeneration=%s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$identifier" "$LINEAR_LEASE_TASK" \ + "$expected" "$replacement" "$generation" >> "$(history_path "$identifier")" + chmod 600 "$(history_path "$identifier")" + printf 'transferred: issue=%s task=%s writer=%s generation=%s\n' \ + "$identifier" "$LINEAR_LEASE_TASK" "$replacement" "$generation" + fi + fi + lock_release + [ "$status" -eq 0 ] || die "writer transfer refused; expected writer does not hold ticket: $identifier" +} + +assert_writer() { # + local identifier=$1 task=$2 writer=$3 + if ! valid_identifier "$identifier" || ! valid_actor "$task" || ! valid_actor "$writer"; then + die "invalid writer assertion" + fi + load_lease "$identifier" || die "no valid writer assignment for $identifier" + [ "$LINEAR_LEASE_TASK" = "$task" ] && [ "$LINEAR_LEASE_WRITER" = "$writer" ] \ + || die "Linear write authority denied for $identifier" +} + +cmd_assert_writer() { + [ "$#" -eq 3 ] || usage + assert_writer "$1" "$2" "$3" + printf 'writer-authorized: issue=%s task=%s writer=%s\n' "$1" "$2" "$3" +} + +cmd_assert_target() { + [ "$#" -eq 4 ] || usage + assert_writer "$1" "$2" "$3" + [ "$1" = "$4" ] || die "assigned writer for $1 may not update $4" + printf 'target-authorized: issue=%s task=%s writer=%s\n' "$1" "$2" "$3" +} + +append_brief_section() { # + local brief=$1 marker=$2 body=$3 staged mode + [ -f "$brief" ] && [ ! -L "$brief" ] || die "task brief does not exist: $brief" + if grep -Fqx "$marker" "$brief"; then + die "task brief already contains $marker" + fi + mode=$(fm_pr_file_mode "$brief") || die "cannot read task brief mode" + staged=$(mktemp "${brief%/*}/.brief.XXXXXX") || die "cannot stage task brief" + if { cat "$brief"; printf '\n%s\n%s\n' "$marker" "$body"; } > "$staged" \ + && chmod "$mode" "$staged" \ + && mv "$staged" "$brief"; then + return 0 + fi + rm -f "$staged" + die "cannot update task brief" +} + +replace_owner_assertion() { # + local brief=$1 replacement=$2 staged mode count + mode=$(fm_pr_file_mode "$brief") || die "cannot read task brief mode" + count=$(grep -c '^Before every Linear mutation, run:' "$brief" || true) + [ "$count" -eq 1 ] || die "existing owner brief has an invalid writer assertion" + staged=$(mktemp "${brief%/*}/.brief.XXXXXX") || die "cannot stage task brief" + if awk -v replacement="$replacement" ' + /^Before every Linear mutation, run:/ { print replacement; next } + { print } + ' "$brief" > "$staged" && chmod "$mode" "$staged" && mv "$staged" "$brief"; then + return 0 + fi + rm -f "$staged" + die "cannot update task brief" +} + +cmd_owner_brief() { + local identifier=${1-} task=${2-} writer=${3-} brief body assertion + [ "$#" -eq 3 ] || usage + assert_writer "$identifier" "$task" "$writer" + brief="$DATA/$task/brief.md" + assertion="Before every Linear mutation, run: bin/fm-linear-ticket-writer.sh assert-target $identifier $task $writer " + body=$(printf '%s\n' \ + "You are the sole Linear writer for $identifier." \ + "You may update only $identifier." \ + "$assertion" \ + "Use Linear MCP for ticket status, comments, and Workpad updates." \ + "Create or update exactly one \`## Firstmate Workpad\` on $identifier." \ + "Record the accepted plan, progress, blockers, PR URL, review outcomes, and fixes on $identifier." \ + "Do not update another Linear issue." \ + "Do not modify Firstmate's local backlog directly." \ + "Do not mark the ticket Done until its PR is verified merged." \ + "If Linear MCP is unavailable, report a blocker; the poller never becomes a fallback writer.") + if grep -Fqx "# Linear ticket ownership" "$brief"; then + replace_owner_assertion "$brief" "$assertion" + else + append_brief_section "$brief" "# Linear ticket ownership" "$body" + fi + printf 'owner-brief: %s\n' "$brief" +} + +cmd_planner_brief() { + local identifier=${1-} task=${2-} brief body + [ "$#" -eq 2 ] || usage + valid_identifier "$identifier" || die "invalid Linear identifier: $identifier" + valid_actor "$task" || die "invalid Firstmate task id: $task" + brief="$DATA/$task/brief.md" + body=$(printf '%s\n' \ + "You are planning or reviewing $identifier, but you hold no Linear write authority." \ + "Do not create comments, edit the Workpad, change status, change assignment, or perform any other Linear mutation." \ + "Report plans and review findings through Firstmate so the assigned ticket owner can record them in Linear.") + append_brief_section "$brief" "# Linear write restriction" "$body" + printf 'planner-brief: %s\n' "$brief" +} + +cmd_show() { + local identifier=${1-} + [ "$#" -eq 1 ] || usage + valid_identifier "$identifier" || die "invalid Linear identifier: $identifier" + load_lease "$identifier" || die "no valid writer assignment for $identifier" + cat "$(lease_path "$identifier")" +} + +case "${1-}" in + assign) shift; cmd_assign "$@" ;; + transfer) shift; cmd_transfer "$@" ;; + assert-writer) shift; cmd_assert_writer "$@" ;; + assert-target) shift; cmd_assert_target "$@" ;; + owner-brief) shift; cmd_owner_brief "$@" ;; + planner-brief) shift; cmd_planner_brief "$@" ;; + show) shift; cmd_show "$@" ;; + ''|-h|--help|help) usage ;; + *) die "unknown command: $1" ;; +esac diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index a3a2d9bda9b..40f1a14a5f0 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -548,6 +548,7 @@ tests/fm-herdr-version-floor-live-e2e.test.sh 20 tests/fm-inactive-reconcile.test.sh 41671 tests/fm-kimi-harness.test.sh 15092 tests/fm-lint-workflows.test.sh 744 +tests/fm-linear-ticket-writer.test.sh 1500 tests/fm-muse-harness.test.sh 27414 tests/fm-muse-signals-live-e2e.test.sh 21 tests/fm-on.test.sh 8602 @@ -1170,6 +1171,9 @@ families_for_changed_path() { bin/fm-procevent-linear.sh) printf '%s\n' __script__:fm-procevent-linear.test.sh ;; + bin/fm-linear-ticket-writer.sh) + printf '%s\n' __script__:fm-linear-ticket-writer.test.sh + ;; bin/fm-timeout-lib.sh) # The shared hard bound: session start's runtime bound, the fleet/bearings # snapshots, the vendor auth probe, the stow cascade's per-home step, and diff --git a/tests/fm-linear-ticket-writer.test.sh b/tests/fm-linear-ticket-writer.test.sh new file mode 100755 index 00000000000..e8adf9354c1 --- /dev/null +++ b/tests/fm-linear-ticket-writer.test.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Behavior tests for exclusive Linear writer assignments and role briefs. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TMP_ROOT=$(fm_test_tmproot fm-linear-ticket-writer) +TMP_ROOT=$(cd "$TMP_ROOT" && pwd -P) +HOME_DIR="$TMP_ROOT/home" +OWNER_TASK=han-28-owner +PLANNER_TASK=han-28-plan +URL=https://linear.app/hanzireader/issue/HAN-28/make-m14-the-default-and-first-cameraonboarding-profile +mkdir -p "$HOME_DIR/state" "$HOME_DIR/data/$OWNER_TASK" "$HOME_DIR/data/$PLANNER_TASK" +printf '# Owner task\n' > "$HOME_DIR/data/$OWNER_TASK/brief.md" +printf '# Planner task\n' > "$HOME_DIR/data/$PLANNER_TASK/brief.md" +trap fm_test_cleanup EXIT + +writer() { FM_HOME="$HOME_DIR" "$ROOT/bin/fm-linear-ticket-writer.sh" "$@"; } + +out=$(writer assign issue-immutable-id HAN-28 "$URL" "$OWNER_TASK" luna-han-28) +assert_contains "$out" "assigned: issue=HAN-28 task=$OWNER_TASK writer=luna-han-28" \ + "assignment records the ticket, task, and sole writer" +lease="$HOME_DIR/state/linear-ticket-writers/HAN-28.lease" +assert_grep 'issue=HAN-28' "$lease" "lease omitted the assigned issue" +assert_grep "task=$OWNER_TASK" "$lease" "lease omitted the Firstmate task" +assert_grep 'writer=luna-han-28' "$lease" "lease omitted the writer" +pass "one durable Linear writer assignment is created" + +out=$(writer assign issue-immutable-id HAN-28 "$URL" "$OWNER_TASK" luna-han-28) +assert_contains "$out" "already-assigned" "an exact replay is idempotent" +set +e +out=$(writer assign issue-immutable-id HAN-28 "$URL" duplicate-task luna-duplicate 2>&1) +status=$? +set -e +expect_code 1 "$status" "a duplicate task and writer must be refused" +assert_contains "$out" "already has a different writer or task assignment" \ + "duplicate ownership refusal is explicit" +pass "repeated dispatch cannot create a second local owner" + +writer assert-writer HAN-28 "$OWNER_TASK" luna-han-28 >/dev/null \ + || fail "assigned Luna writer was not authorized" +set +e +writer assert-writer HAN-28 "$PLANNER_TASK" sol-han-28 >/dev/null 2>&1 +status=$? +set -e +expect_code 1 "$status" "Sol must not acquire Linear write authority" +set +e +out=$(writer assert-target HAN-28 "$OWNER_TASK" luna-han-28 HAN-27 2>&1) +status=$? +set -e +expect_code 1 "$status" "the HAN-28 writer must not target another issue" +assert_contains "$out" "may not update HAN-27" "cross-ticket denial names the forbidden target" +pass "only the assigned writer and assigned ticket pass the writer guard" + +writer owner-brief HAN-28 "$OWNER_TASK" luna-han-28 >/dev/null +owner_brief="$HOME_DIR/data/$OWNER_TASK/brief.md" +assert_grep 'You are the sole Linear writer for HAN-28.' "$owner_brief" \ + "owner brief omitted exclusive writer authority" +assert_grep 'You may update only HAN-28.' "$owner_brief" \ + "owner brief omitted ticket scope" +# shellcheck disable=SC2016 # Backticks are literal Markdown in the generated brief. +assert_grep 'Create or update exactly one `## Firstmate Workpad` on HAN-28.' "$owner_brief" \ + "owner brief omitted the single Workpad rule" +assert_grep "Do not modify Firstmate's local backlog directly." "$owner_brief" \ + "owner brief omitted Firstmate backlog ownership" +assert_grep 'Do not mark the ticket Done until its PR is verified merged.' "$owner_brief" \ + "owner brief omitted merge verification" +writer planner-brief HAN-28 "$PLANNER_TASK" >/dev/null +planner_brief="$HOME_DIR/data/$PLANNER_TASK/brief.md" +assert_grep 'You are planning or reviewing HAN-28, but you hold no Linear write authority.' "$planner_brief" \ + "Sol brief omitted the no-write role" +assert_grep 'Do not create comments, edit the Workpad, change status, change assignment, or perform any other Linear mutation.' \ + "$planner_brief" "Sol brief omitted the mutation prohibition" +pass "generated Luna and Sol briefs carry opposite Linear authority contracts" + +out=$(writer transfer HAN-28 luna-han-28 luna-han-28-replacement) +assert_contains "$out" "transferred: issue=HAN-28 task=$OWNER_TASK writer=luna-han-28-replacement generation=2" \ + "writer transfer reports the replacement and generation" +assert_grep 'writer=luna-han-28-replacement' "$lease" "current lease did not move to replacement writer" +writer owner-brief HAN-28 "$OWNER_TASK" luna-han-28-replacement >/dev/null +[ "$(grep -c '^# Linear ticket ownership$' "$owner_brief")" -eq 1 ] \ + || fail "writer transfer duplicated the owner brief" +assert_grep "assert-target HAN-28 $OWNER_TASK luna-han-28-replacement " "$owner_brief" \ + "replacement owner brief retained stale writer authority" +history="$HOME_DIR/state/linear-ticket-writers/HAN-28.history" +[ "$(grep -c $'\tassign\t' "$history")" -eq 1 ] || fail "history did not retain exactly one assignment" +[ "$(grep -c $'\ttransfer\t' "$history")" -eq 1 ] || fail "history did not retain exactly one transfer" +set +e +writer transfer HAN-28 luna-han-28 another-writer >/dev/null 2>&1 +status=$? +set -e +expect_code 1 "$status" "a stale writer cannot transfer the lease" +pass "writer replacement is explicit, generation-bound, and durable" + +printf 'ok: Linear ticket writer behavior tests passed\n' From 07fce150bd0eb588b7bbb5ee3558c7e8bfd8a38d Mon Sep 17 00:00:00 2001 From: Keith Lee Date: Sun, 30 Aug 2026 14:46:01 -0700 Subject: [PATCH 05/12] docs(linear): document poller and writer ownership --- .agents/skills/process-event-sources/SKILL.md | 5 +- AGENTS.md | 4 ++ README.md | 1 + docs/configuration.md | 46 +++++++++++++++++++ docs/documentation-audiences.json | 8 ++++ docs/examples/linear-poll.json | 17 +++++++ 6 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 docs/examples/linear-poll.json diff --git a/.agents/skills/process-event-sources/SKILL.md b/.agents/skills/process-event-sources/SKILL.md index e8550505cd6..a50eb218440 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -65,7 +65,7 @@ Eligibility is a firstmate judgment made BEFORE arming, because the scripts cann Never bind an action that is destructive, irreversible, or security-sensitive, an action needing captain approval or any gate decision, or an action whose right form depends on what the condition finds - those keep the existing check-fires-then-firstmate-decides flow, for which a plain custom check or another adapter stays correct. When in doubt, arm only the condition half as an ordinary check and keep the action as a wake-time decision. -`bin/fm-procevent.sh --help`, `bin/fm-procevent-lavish.sh --help`, `bin/fm-procevent-when.sh --help`, `bin/fm-procevent-quota.sh --help`, and `bin/fm-procevent-remote-reply.sh --help` own the exact commands and flags. +`bin/fm-procevent.sh --help`, `bin/fm-procevent-lavish.sh --help`, `bin/fm-procevent-linear.sh --help`, `bin/fm-procevent-when.sh --help`, `bin/fm-procevent-quota.sh --help`, and `bin/fm-procevent-remote-reply.sh --help` own the exact commands and flags. An explicitly enabled external adapter registers through `bin/fm-procevent.sh register-extension`, never through a package-discovered script or package-supplied argv. [`docs/configuration.md`](../../../docs/configuration.md#trusted-external-process-event-adapters-configextensionsd) owns setup and [`docs/extension-bindings.md`](../../../docs/extension-bindings.md) owns the narrow trusted-code and untrusted-evidence boundary. @@ -97,6 +97,9 @@ Two rules the commands cannot enforce for you: : Ask the adapter what the result means rather than parsing it yourself. `bin/fm-procevent.sh classify ` routes through the immutable built-in or extension identity captured with that result; for Lavish, its existing direct command returns `feedback`, `ended`, `waiting`, `missing`, or `unknown`. Consume a Lavish capture with `bin/fm-procevent-lavish.sh read ` rather than grepping the raw file: that command reports declared and presented item counts plus a completeness verdict, enumerates every captured queued item while retaining supplied element identity, and surfaces a `tag=message` session-ending message as its own field. +: A `linear` wake is intake evidence, not write authority. + Load `linear-ticket-intake`, consume the capture with `bin/fm-procevent-linear.sh read `, and follow its re-fetch, dedupe, one-writer, comment-routing, and acknowledgement procedure. + Never let the poller comment, change status, assign a worker, or become the fallback writer. `answers` remains the keyed-choice extractor and never treats freeform prose as a decision key. A `feedback` result can still be the last one a review ever produces, so never assume another wake is coming just because the state is not `ended`. : A routine no-op an adapter positively identifies never becomes a wake at all - it is recorded as handled and stays silent, so you never see it. For Lavish that is exactly an ended session carrying nothing: a board the captain closed without saying anything. A board close carrying a real answer, and every other result, still wakes you unchanged. Never read the absence of a wake as proof a review is still open; ask the source, not the queue. diff --git a/AGENTS.md b/AGENTS.md index 40bb092cb64..2fa03fb5e68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,7 @@ config/turnend-churn-absorb optional presence flag opting this home into the de config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/watched-tools.json optional list of the tools this home depends on, read by the update check armed with bin/fm-tool-update-check.sh; LOCAL, gitignored, firstmate-maintained but human-editable, and NOT inherited by secondmate homes; see docs/configuration.md "Watched tool updates" +config/linear-poll.json optional mapped-project configuration for the read-only Linear GraphQL detector; LOCAL, gitignored, and not inherited; see docs/configuration.md "Linear Todo and comment polling" config/x-mode.env generated Relay watcher cadence; LOCAL, gitignored; source before arming watcher when present data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history @@ -117,6 +118,8 @@ state/ runtime records and signals; gitignored pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh procevent/ registered process-to-event sources, one private record per canonical source id; written only by bin/fm-procevent.sh, and their presence alone keeps supervision required (section 13) procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line + linear-poll/ private read-only detector snapshots keyed by Linear process-event source id; written only by bin/fm-procevent-linear.sh + linear-ticket-writers/ private one-writer leases and append-only transfer histories keyed by Linear issue identifier; written only by bin/fm-linear-ticket-writer.sh decision-bindings/ private records marking a captured-answer source as feeding the keyed-answer intake, with a legacy origin on pre-collapse records; written only by bin/fm-captain-hold.sh bind, dropped by unbind and by source retirement (section 13; docs/captain-hold-lifecycle.md) when/ private condition->action watch specs, their trust bindings, and single-fire markers; written only by bin/fm-procevent-when.sh (section 13's process-event-sources trigger) inbox/ captain notes captured out of band by bin/fm-inbox.sh, including the voice handover's queued requests; each note appends one `check` wake and stays pending until acknowledged with `bin/fm-inbox.sh drain --ack `, which moves it to inbox/handled/ (docs/voice-relay.md) @@ -549,6 +552,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `captain-hold-lifecycle` - load before treating an investigation or visual review as complete, before ending a visual review that exposed a captain decision, when recording or routing the captain's answer, and on any `RECORD DIVERGENCE` line from the wake drain. - `process-event-sources` - load before arming a long-polling source, before registering a deterministic condition->action watch (do X as soon as Y is true), and on any `procevent ` check wake. Never run a registered source's blocking command yourself in a conversational turn. +- `linear-ticket-intake` - load before arming the Linear poller and on any `procevent linear ` check wake; it owns read-only re-fetch, duplicate prevention, one-writer assignment, Sol/Luna role separation, comment routing, writer transfer, and acknowledgement. - `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the Relay configuration blocker, on a `public-followup ...` `check:` wake or a startup-surfaced public commitment, and on any milestone or terminal wake for a Relay-linked task before posting its completion follow-up; relevant only when Relay is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. diff --git a/README.md b/README.md index 937cba18f4b..429ef7884c3 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/architecture.md](docs/architecture.md) - maintainer architecture for the crew, supervision, worktrees, secondmates, and project modes. - [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, optional Relay and its X and Discord setup steps, trusted external process-event adapter setup, the files you set, and harness support. +- [docs/configuration.md#linear-todo-and-comment-polling](docs/configuration.md#linear-todo-and-comment-polling) - optional query-only Linear Todo and comment polling with exact ticket links and one assigned ticket writer. - [docs/extension-bindings.md](docs/extension-bindings.md) - maintainer architecture for the narrow trusted external `process-event-adapter/1` package, binding, handshake, and evidence boundary. - [docs/remote-secondmates.md](docs/remote-secondmates.md) - current setup, routing, transfer, recovery, and safety behavior for whole-home remote second mates. - [docs/calm.md](docs/calm.md) - current Pi `/calm` behavior and supported presentation limits. diff --git a/docs/configuration.md b/docs/configuration.md index 99e1c1fd608..ebd26f8ec9f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -741,6 +741,52 @@ The published `lavish-axi poll` clears feedback destructively before returning i Never describe this path as at-least-once, no-loss, or lossless. `docs/verification/process-event-sources.md` holds the measurements and `.agents/skills/process-event-sources/SKILL.md` owns the handling procedure. +### Linear Todo and comment polling + +`bin/fm-procevent-linear.sh` is a built-in read-only Linear GraphQL adapter. +It runs named GraphQL `query` operations every 30 seconds, emits eligible mapped Todos and newly observed comments, and stays silent when its private snapshot is unchanged. +It has no mutation command and never comments, changes status, assigns a worker, creates a Workpad, or edits Firstmate's backlog. + +Copy [`docs/examples/linear-poll.json`](examples/linear-poll.json) to the gitignored `config/linear-poll.json`, then replace each example mapping with the Linear project slug, display name, and matching Firstmate project name. +`activeStates` controls which issues remain visible for comment detection. +An empty or omitted `allowIssues` watches every mapped issue; a non-empty list limits detection during a rollout or focused proof. +The poller obtains the canonical ticket URL from Linear's API response and carries those exact bytes into the captured event. + +Export a personal Linear API key as `LINEAR_API_KEY` in the environment that runs Firstmate. +The adapter sends it only in the `Authorization` header to `https://api.linear.app/graphql`; it never stores the value in config, argv, snapshots, results, task briefs, or Git. +Arm and retire the source through the adapter: + +```sh +bin/fm-procevent-linear.sh arm config/linear-poll.json +bin/fm-procevent-linear.sh retire config/linear-poll.json +``` + +The source's stable id is derived from the physical config path, so one config has one machine-wide process-event owner. +Existing comments are baselined on first observation and do not create historical-comment wakes. +A currently eligible Todo is emitted on first observation, while a blocker relation whose blocking issue is not completed or canceled suppresses Todo intake. +The detector snapshot under `state/linear-poll/` is observation state only and grants no ticket authority. + +On a `procevent linear ...` wake, `linear-ticket-intake` owns Linear MCP re-fetch, project and blocker validation, local duplicate checks, assignment, comment routing, and handled acknowledgement. +Firstmate creates exactly one lease with `bin/fm-linear-ticket-writer.sh assign`, gives the persistent Luna implementation worker the owner brief, and gives a Sol planner or reviewer the no-write brief. +The lease record has this fixed current-state shape: + +```text +issue=HAN-28 +task= +writer= +issue_id= +url= +generation= +``` + +Only the assigned task and writer pass `assert-writer` and `assert-target`, and the target assertion refuses any identifier other than the leased issue. +An exact repeated assignment is idempotent, while a different task or writer is refused, preventing repeat poll events from creating duplicate local ownership. +Writer replacement requires `transfer `, increments the generation, updates the current lease, and appends an immutable transfer row to the issue history before the replacement owner brief is refreshed. + +Luna is the sole Linear writer: it moves the issue to In Progress, maintains exactly one `## Firstmate Workpad`, records plan, progress, blockers, PR and review state, moves to Human Review only after delivery gates, and marks Done only after verifying the PR merged. +Sol reports planning and review findings through Firstmate and performs no Linear mutation. +Firstmate may read Linear to reconcile but does not duplicate Luna's ticket writes, and the poller never inherits writer authority when Linear MCP is unavailable to Luna. + ## Spoken interface and captain inbox (config/voice-*, config/inbox-*) The spoken interface in [`docs/voice-relay.md`](voice-relay.md) and the model-backed subcommands of `bin/fm-inbox.sh` reach a paid API in a named account, so no region, model id or AWS profile is shipped as a tracked default. diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 8bb68bd4ba2..669bc1eba13 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -208,6 +208,10 @@ "path": ".agents/skills/harness-adapters/references/harness/pi.md", "audience": "agent-runtime" }, + { + "path": ".agents/skills/linear-ticket-intake/SKILL.md", + "audience": "agent-runtime" + }, { "path": ".agents/skills/process-event-sources/SKILL.md", "audience": "agent-runtime" @@ -316,6 +320,10 @@ "path": "docs/examples/crew-dispatch.json", "audience": "operator-example" }, + { + "path": "docs/examples/linear-poll.json", + "audience": "operator-example" + }, { "path": "docs/examples/process-event-extension/file-signal.mjs", "audience": "operator-example" diff --git a/docs/examples/linear-poll.json b/docs/examples/linear-poll.json new file mode 100644 index 00000000000..628c9a4dfba --- /dev/null +++ b/docs/examples/linear-poll.json @@ -0,0 +1,17 @@ +{ + "schema": "fm-linear-poll.v1", + "activeStates": [ + "Todo", + "In Progress", + "Blocked", + "Human Review" + ], + "projects": [ + { + "linearProjectSlug": "replace-with-linear-project-slug", + "linearProjectName": "Replace with Linear project name", + "firstmateProject": "Replace with Firstmate project name" + } + ], + "allowIssues": [] +} From d9e59a8a7a08ad9eea56b2bce2f21a9d059e1f46 Mon Sep 17 00:00:00 2001 From: Keith Lee Date: Mon, 31 Aug 2026 00:27:22 -0700 Subject: [PATCH 06/12] config: add crew dispatch profiles --- config/crew-dispatch.json | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 config/crew-dispatch.json diff --git a/config/crew-dispatch.json b/config/crew-dispatch.json new file mode 100644 index 00000000000..cce38282a3e --- /dev/null +++ b/config/crew-dispatch.json @@ -0,0 +1,40 @@ +{ + "rules": [ + { + "when": "planning, investigation, or review tasks when Firstmate is running from Claude", + "use": { + "harness": "claude", + "model": "claude-opus-5", + "effort": "high" + }, + "why": "Use Opus for planning and review with high reasoning." + }, + { + "when": "implementation or execution tasks when Firstmate is running from Claude", + "use": { + "harness": "claude", + "model": "claude-sonnet-5", + "effort": "low" + }, + "why": "Use Sonnet for implementation with low reasoning effort." + }, + { + "when": "planning, investigation, or review tasks when Firstmate is running from Codex", + "use": { + "harness": "codex", + "model": "gpt-5.6-sol", + "effort": "high" + }, + "why": "Use Sol for planning and review with high reasoning." + }, + { + "when": "implementation or execution tasks when Firstmate is running from Codex", + "use": { + "harness": "codex", + "model": "gpt-5.6-luna", + "effort": "high" + }, + "why": "Use Luna for implementation with high reasoning." + } + ] +} From 3355599b288589a384b9cc92ca8986be99fe550e Mon Sep 17 00:00:00 2001 From: Keith Lee Date: Mon, 31 Aug 2026 12:40:50 -0700 Subject: [PATCH 07/12] fix: verify PR state before reporting merge --- bin/fm-crew-state.sh | 47 ++++++++++++++++- tests/fm-crew-state.test.sh | 100 +++++++++++++++++++++++++++++++++++- 2 files changed, 145 insertions(+), 2 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 267b0902a93..e11ec519744 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -420,6 +420,51 @@ nm_run_head_matches_worktree() { fm_nm_head_matches_worktree "$WT" "$run_head" } +# A terminal no-mistakes `outcome: passed` is only a local pipeline result until +# the linked GitHub PR is checked live. Never turn an unverified or still-open +# PR into a merged/closed claim. A missing PR remains a genuine local-only +# completion, while non-GitHub PRs and unavailable GitHub reads stay explicit +# without making a forge claim this helper cannot prove. +nm_passed_run_detail() { + local pr_url identity repo number pr_out state merged + pr_url=$(strip_quotes "$(nm_field pr)") + if [ -z "$pr_url" ]; then + printf 'run passed: local work complete' + return + fi + + # Only GitHub URLs can be checked by the required gh-axi GitHub surface. + # Strip ordinary URL decorations before applying the exact owner/repo/pull + # shape, so malformed or foreign-forge URLs cannot become a merge claim. + pr_url=${pr_url%%\?*} + pr_url=${pr_url%%\#*} + pr_url=${pr_url%/} + identity=$(printf '%s\n' "$pr_url" \ + | sed -nE 's#^https://github\.com/([^/]+/[^/]+)/pull/([0-9]+)$#\1 \2#p') + if [ -z "$identity" ]; then + printf 'run passed: PR state unverified' + return + fi + repo=${identity% *} + number=${identity##* } + if ! command -v gh-axi >/dev/null 2>&1; then + printf 'run passed: PR state unverified' + return + fi + pr_out=$(gh-axi pr view "$number" -R "$repo" 2>/dev/null) || { + printf 'run passed: PR state unverified' + return + } + state=$(fm_nm_strip_quotes "$(fm_nm_field "$pr_out" state)") + merged=$(fm_nm_strip_quotes "$(fm_nm_field "$pr_out" merged)") + case "$merged:$state" in + yes:*|true:*) printf 'run passed: PR merged/closed' ;; + no:closed|false:closed) printf 'run passed: PR closed (not merged)' ;; + no:open|false:open) printf 'run passed: PR open (not merged/closed)' ;; + *) printf 'run passed: PR state unverified' ;; + esac +} + # Coarse runs-list rows are " ...". 0 if the short # sha for this branch row matches the worktree head under the same rules as # nm_run_head_matches_worktree (equal, or local is ancestor of run tip). @@ -499,7 +544,7 @@ if [ "$HAVE_RUN" = 1 ]; then if [ -n "$outcome" ]; then case "$outcome" in - passed) RUN_STATE="done"; RUN_DETAIL="run passed: PR merged/closed" ;; + passed) RUN_STATE="done"; RUN_DETAIL=$(nm_passed_run_detail) ;; checks-passed) RUN_STATE="done"; RUN_DETAIL="checks green: PR ready for review" ;; failed) RUN_STATE=failed; RUN_DETAIL="run failed" ;; cancelled) RUN_STATE=failed; RUN_DETAIL="run cancelled" ;; diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index a284cbe8eb6..021ca4d5b08 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -25,6 +25,7 @@ # This is the direct regression pair for the 2026-07-02 herdr incident, # proving the watcher's own absorb-only-when-provably-working predicate # benefits from the fix in both directions. +# (l) a passed run with an open GitHub PR is never reported as merged/closed set -u # shellcheck source=tests/lib.sh @@ -78,6 +79,22 @@ case "${1:-}" in printf '%s\n' "${FM_FAKE_RUNS_LIST:-}" ;; esac exit 0 +SH + cat > "$fb/gh-axi" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" = pr ] && [ "${2:-}" = view ]; then + [ -z "${FM_FAKE_GH_CALLS:-}" ] || printf '%s\n' "$*" >> "$FM_FAKE_GH_CALLS" + [ "${FM_FAKE_GH_PR_VIEW:-ok}" = unavailable ] && exit 1 + if [ "${FM_FAKE_GH_PR_VIEW:-ok}" = malformed ]; then + printf 'error: pull request state unavailable\n' + exit 0 + fi + printf 'pull_request:\n number: %s\n state: %s\n merged: %s\n' \ + "${3:-1}" "${FM_FAKE_GH_PR_STATE:-open}" "${FM_FAKE_GH_PR_MERGED:-no}" + exit 0 +fi +exit 1 SH cat > "$fb/tmux" <<'SH' #!/usr/bin/env bash @@ -122,7 +139,7 @@ case "${1:-}" in esac exit 0 SH - chmod +x "$fb/no-mistakes" "$fb/tmux" "$fb/herdr" + chmod +x "$fb/no-mistakes" "$fb/tmux" "$fb/herdr" "$fb/gh-axi" printf '%s\n' "$fb" } @@ -170,8 +187,13 @@ reset_fakes() { FM_FAKE_HERDR_MISSING=0 FM_FAKE_HERDR_AGENT_STATUS="" FM_FAKE_CI_LOGS="" + FM_FAKE_GH_PR_VIEW=ok + FM_FAKE_GH_PR_STATE=open + FM_FAKE_GH_PR_MERGED=no + FM_FAKE_GH_CALLS= export FM_FAKE_AXI_STATUS FM_FAKE_AXI_STATUS_RUN FM_FAKE_RUNS_LIST FM_FAKE_BUSY FM_FAKE_BUSY_TEXT FM_FAKE_TMUX_MISSING export FM_FAKE_HERDR_BUSY FM_FAKE_HERDR_MISSING FM_FAKE_HERDR_AGENT_STATUS FM_FAKE_CI_LOGS + export FM_FAKE_GH_PR_VIEW FM_FAKE_GH_PR_STATE FM_FAKE_GH_PR_MERGED FM_FAKE_GH_CALLS } # --- run-object fixtures (TOON, as `no-mistakes axi status` emits) ----------- @@ -278,6 +300,19 @@ outcome: passed EOF } +run_passed_local() { # + cat < cat </dev/null + fm_write_meta "$d/state/feat-open-pr.meta" "window=fm:fm-feat-open-pr" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_passed fm/feat-open-pr)" + FM_FAKE_GH_PR_STATE=open + FM_FAKE_GH_PR_MERGED=no + local out; out=$(FM_FAKE_GH_CALLS="$d/gh.calls" run_crew_state "$d" feat-open-pr) + assert_contains "$out" "state: done" "passed run with open PR remains locally done" + assert_contains "$out" "run passed: PR open (not merged/closed)" "live open PR state is reported" + assert_not_contains "$out" "PR merged/closed" "open PR is never reported as merged/closed" + assert_grep 'pr view 1 -R o/r' "$d/gh.calls" "the linked GitHub PR was checked live" + pass "passed run with open PR is not falsely reported as merged/closed" +} + +test_terminal_passed_unverified_pr_not_reported_merged() { + reset_fakes + local d; d=$(new_case passed-unverified-pr) + make_repo_on_branch "$d/wt" fm/feat-unverified-pr + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-unverified-pr.meta" "window=fm:fm-feat-unverified-pr" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_passed fm/feat-unverified-pr)" + FM_FAKE_GH_PR_VIEW=unavailable + local out; out=$(run_crew_state "$d" feat-unverified-pr) + assert_contains "$out" "run passed: PR state unverified" "unverified PR state is explicit" + assert_not_contains "$out" "PR merged/closed" "unverified PR is never reported as merged/closed" + pass "unverified passed PR is not falsely reported as merged/closed" +} + +test_terminal_passed_merged_pr_reports_merged() { + reset_fakes + local d; d=$(new_case passed-merged-pr) + make_repo_on_branch "$d/wt" fm/feat-merged-pr + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-merged-pr.meta" "window=fm:fm-feat-merged-pr" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_passed fm/feat-merged-pr)" + FM_FAKE_GH_PR_STATE=closed + FM_FAKE_GH_PR_MERGED=yes + local out; out=$(run_crew_state "$d" feat-merged-pr) + assert_contains "$out" "run passed: PR merged/closed" "live merged PR state permits the merge claim" + pass "verified merged PR retains the merged/closed claim" +} + +test_terminal_passed_local_only_stays_done() { + reset_fakes + local d; d=$(new_case passed-local-only) + make_repo_on_branch "$d/wt" fm/feat-local-only + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-local-only.meta" "window=fm:fm-feat-local-only" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_passed_local fm/feat-local-only)" + local out; out=$(run_crew_state "$d" feat-local-only) + assert_contains "$out" "state: done" "local-only passed run remains done" + assert_contains "$out" "run passed: local work complete" "local-only completion remains visible" + assert_not_contains "$out" "state unverified" "local-only completion is not downgraded to an unverifiable PR" + pass "passed local-only work remains genuinely finished" +} + test_terminal_failed() { reset_fakes local d; d=$(new_case failed) @@ -1567,6 +1661,10 @@ test_ci_fixing_after_green_stays_working test_top_level_fixing_ci_running_after_green_stays_working test_top_level_fixing_done_log_stays_working test_terminal_passed +test_terminal_passed_open_pr_not_reported_merged +test_terminal_passed_unverified_pr_not_reported_merged +test_terminal_passed_merged_pr_reports_merged +test_terminal_passed_local_only_stays_done test_terminal_failed test_cross_branch_attribution_via_runs_list test_cross_branch_attribution_picks_most_recent_row From 5ec35178c1a84717902e59cda8b39cd0542e9aeb Mon Sep 17 00:00:00 2001 From: Keith Lee Date: Mon, 31 Aug 2026 17:45:50 -0700 Subject: [PATCH 08/12] fix: query linked PR from crew repository --- bin/fm-crew-state.sh | 8 +++++--- tests/fm-crew-state.test.sh | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index e11ec519744..8f029bf37c1 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -426,7 +426,7 @@ nm_run_head_matches_worktree() { # completion, while non-GitHub PRs and unavailable GitHub reads stay explicit # without making a forge claim this helper cannot prove. nm_passed_run_detail() { - local pr_url identity repo number pr_out state merged + local pr_url identity number pr_out state merged pr_url=$(strip_quotes "$(nm_field pr)") if [ -z "$pr_url" ]; then printf 'run passed: local work complete' @@ -445,13 +445,15 @@ nm_passed_run_detail() { printf 'run passed: PR state unverified' return fi - repo=${identity% *} number=${identity##* } if ! command -v gh-axi >/dev/null 2>&1; then printf 'run passed: PR state unverified' return fi - pr_out=$(gh-axi pr view "$number" -R "$repo" 2>/dev/null) || { + # gh-axi resolves the repository from the current checkout and does not + # accept gh's -R override. Query from the crew worktree so the live PR state + # belongs to the project that produced this run. + pr_out=$(cd "$WT" && gh-axi pr view "$number" 2>/dev/null) || { printf 'run passed: PR state unverified' return } diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 021ca4d5b08..8bfa0d9596c 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -719,7 +719,7 @@ test_terminal_passed_open_pr_not_reported_merged() { assert_contains "$out" "state: done" "passed run with open PR remains locally done" assert_contains "$out" "run passed: PR open (not merged/closed)" "live open PR state is reported" assert_not_contains "$out" "PR merged/closed" "open PR is never reported as merged/closed" - assert_grep 'pr view 1 -R o/r' "$d/gh.calls" "the linked GitHub PR was checked live" + assert_grep 'pr view 1' "$d/gh.calls" "the linked GitHub PR was checked live" pass "passed run with open PR is not falsely reported as merged/closed" } From 4340c2d31ea562f27ae36bd4e3c940529bab5692 Mon Sep 17 00:00:00 2001 From: Keith Lee Date: Mon, 31 Aug 2026 22:48:47 -0700 Subject: [PATCH 09/12] no-mistakes(review): Parse gh-axi's real single state field for PR merge claims --- bin/fm-crew-state.sh | 11 +++++------ tests/fm-crew-state.test.sh | 11 ++++------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 8f029bf37c1..e918321c504 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -426,7 +426,7 @@ nm_run_head_matches_worktree() { # completion, while non-GitHub PRs and unavailable GitHub reads stay explicit # without making a forge claim this helper cannot prove. nm_passed_run_detail() { - local pr_url identity number pr_out state merged + local pr_url identity number pr_out state pr_url=$(strip_quotes "$(nm_field pr)") if [ -z "$pr_url" ]; then printf 'run passed: local work complete' @@ -458,11 +458,10 @@ nm_passed_run_detail() { return } state=$(fm_nm_strip_quotes "$(fm_nm_field "$pr_out" state)") - merged=$(fm_nm_strip_quotes "$(fm_nm_field "$pr_out" merged)") - case "$merged:$state" in - yes:*|true:*) printf 'run passed: PR merged/closed' ;; - no:closed|false:closed) printf 'run passed: PR closed (not merged)' ;; - no:open|false:open) printf 'run passed: PR open (not merged/closed)' ;; + case "$state" in + merged) printf 'run passed: PR merged/closed' ;; + closed) printf 'run passed: PR closed (not merged)' ;; + open) printf 'run passed: PR open (not merged/closed)' ;; *) printf 'run passed: PR state unverified' ;; esac } diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 8bfa0d9596c..8cc89be966f 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -90,8 +90,8 @@ if [ "${1:-}" = pr ] && [ "${2:-}" = view ]; then printf 'error: pull request state unavailable\n' exit 0 fi - printf 'pull_request:\n number: %s\n state: %s\n merged: %s\n' \ - "${3:-1}" "${FM_FAKE_GH_PR_STATE:-open}" "${FM_FAKE_GH_PR_MERGED:-no}" + printf 'pull_request:\n number: %s\n state: %s\n' \ + "${3:-1}" "${FM_FAKE_GH_PR_STATE:-open}" exit 0 fi exit 1 @@ -189,11 +189,10 @@ reset_fakes() { FM_FAKE_CI_LOGS="" FM_FAKE_GH_PR_VIEW=ok FM_FAKE_GH_PR_STATE=open - FM_FAKE_GH_PR_MERGED=no FM_FAKE_GH_CALLS= export FM_FAKE_AXI_STATUS FM_FAKE_AXI_STATUS_RUN FM_FAKE_RUNS_LIST FM_FAKE_BUSY FM_FAKE_BUSY_TEXT FM_FAKE_TMUX_MISSING export FM_FAKE_HERDR_BUSY FM_FAKE_HERDR_MISSING FM_FAKE_HERDR_AGENT_STATUS FM_FAKE_CI_LOGS - export FM_FAKE_GH_PR_VIEW FM_FAKE_GH_PR_STATE FM_FAKE_GH_PR_MERGED FM_FAKE_GH_CALLS + export FM_FAKE_GH_PR_VIEW FM_FAKE_GH_PR_STATE FM_FAKE_GH_CALLS } # --- run-object fixtures (TOON, as `no-mistakes axi status` emits) ----------- @@ -714,7 +713,6 @@ test_terminal_passed_open_pr_not_reported_merged() { fm_write_meta "$d/state/feat-open-pr.meta" "window=fm:fm-feat-open-pr" "worktree=$d/wt" "kind=ship" FM_FAKE_AXI_STATUS="$(run_passed fm/feat-open-pr)" FM_FAKE_GH_PR_STATE=open - FM_FAKE_GH_PR_MERGED=no local out; out=$(FM_FAKE_GH_CALLS="$d/gh.calls" run_crew_state "$d" feat-open-pr) assert_contains "$out" "state: done" "passed run with open PR remains locally done" assert_contains "$out" "run passed: PR open (not merged/closed)" "live open PR state is reported" @@ -744,8 +742,7 @@ test_terminal_passed_merged_pr_reports_merged() { make_fakebin "$d" >/dev/null fm_write_meta "$d/state/feat-merged-pr.meta" "window=fm:fm-feat-merged-pr" "worktree=$d/wt" "kind=ship" FM_FAKE_AXI_STATUS="$(run_passed fm/feat-merged-pr)" - FM_FAKE_GH_PR_STATE=closed - FM_FAKE_GH_PR_MERGED=yes + FM_FAKE_GH_PR_STATE=merged local out; out=$(run_crew_state "$d" feat-merged-pr) assert_contains "$out" "run passed: PR merged/closed" "live merged PR state permits the merge claim" pass "verified merged PR retains the merged/closed claim" From d1009ee9e9c7e0fa20d570d558c2ef96d9a58511 Mon Sep 17 00:00:00 2001 From: Keith Lee Date: Mon, 31 Aug 2026 22:53:26 -0700 Subject: [PATCH 10/12] no-mistakes(review): Fail-closed unless PR link's owner/repo matches worktree remote --- bin/fm-crew-state.sh | 25 ++++++++++++++++++++++--- tests/fm-crew-state.test.sh | 21 +++++++++++++++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index e918321c504..74431ee5657 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -426,7 +426,7 @@ nm_run_head_matches_worktree() { # completion, while non-GitHub PRs and unavailable GitHub reads stay explicit # without making a forge claim this helper cannot prove. nm_passed_run_detail() { - local pr_url identity number pr_out state + local pr_url identity repo_path number pr_out state remote_url remote_path pr_url=$(strip_quotes "$(nm_field pr)") if [ -z "$pr_url" ]; then printf 'run passed: local work complete' @@ -445,14 +445,33 @@ nm_passed_run_detail() { printf 'run passed: PR state unverified' return fi + repo_path=${identity% *} number=${identity##* } if ! command -v gh-axi >/dev/null 2>&1; then printf 'run passed: PR state unverified' return fi # gh-axi resolves the repository from the current checkout and does not - # accept gh's -R override. Query from the crew worktree so the live PR state - # belongs to the project that produced this run. + # accept gh's -R override, so the stored PR's owner/repo must be proven to + # match the crew worktree's own remote before the number is trusted - + # otherwise a stale or foreign-fork link would silently report a + # same-numbered PR in the wrong repository. + remote_url=$(git -C "$WT" remote get-url origin 2>/dev/null) || { + printf 'run passed: PR state unverified' + return + } + remote_path=${remote_url%/} + remote_path=${remote_path%.git} + remote_path=$(printf '%s\n' "$remote_path" \ + | sed -nE 's#^(https://github\.com/|git@github\.com:|ssh://git@github\.com/)([^/]+/[^/]+)$#\2#p') + if [ -z "$remote_path" ] \ + || [ "$(printf '%s' "$remote_path" | tr '[:upper:]' '[:lower:]')" \ + != "$(printf '%s' "$repo_path" | tr '[:upper:]' '[:lower:]')" ]; then + printf 'run passed: PR state unverified' + return + fi + # Query from the crew worktree so the live PR state belongs to the project + # that produced this run, now that its identity is proven to match. pr_out=$(cd "$WT" && gh-axi pr view "$number" 2>/dev/null) || { printf 'run passed: PR state unverified' return diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 8cc89be966f..5399aeeb9a6 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -39,10 +39,11 @@ fm_git_identity fmtest fmtest@example.invalid # A real git repo checked out on , so the helper's branch attribution # (git symbolic-ref) resolves like it would for a live crew worktree. -make_repo_on_branch() { # - local dir=$1 branch=$2 +make_repo_on_branch() { # [origin-owner/repo] + local dir=$1 branch=$2 origin=${3:-o/r} mkdir -p "$dir" git -C "$dir" init -q + git -C "$dir" remote add origin "https://github.com/$origin" 2>/dev/null || true git -C "$dir" commit -q --allow-empty -m init git -C "$dir" checkout -q -b "$branch" # Real worktree HEAD for run head-binding (fixtures read FM_FAKE_RUN_HEAD). @@ -735,6 +736,21 @@ test_terminal_passed_unverified_pr_not_reported_merged() { pass "unverified passed PR is not falsely reported as merged/closed" } +test_terminal_passed_mismatched_repo_pr_not_reported_merged() { + reset_fakes + local d; d=$(new_case passed-mismatched-repo-pr) + make_repo_on_branch "$d/wt" fm/feat-mismatched-pr other-owner/other-repo + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-mismatched-pr.meta" "window=fm:fm-feat-mismatched-pr" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_passed fm/feat-mismatched-pr)" + FM_FAKE_GH_PR_STATE=merged + local out; out=$(FM_FAKE_GH_CALLS="$d/gh.calls" run_crew_state "$d" feat-mismatched-pr) + assert_contains "$out" "run passed: PR state unverified" "a stale link to a foreign repo is never trusted" + assert_not_contains "$out" "PR merged/closed" "a mismatched repo PR is never reported as merged/closed" + [ ! -f "$d/gh.calls" ] || fail "gh-axi must not be queried for a PR in a repo the worktree does not match" + pass "PR link pointing at a different repo is not falsely reported as merged/closed" +} + test_terminal_passed_merged_pr_reports_merged() { reset_fakes local d; d=$(new_case passed-merged-pr) @@ -1660,6 +1676,7 @@ test_top_level_fixing_done_log_stays_working test_terminal_passed test_terminal_passed_open_pr_not_reported_merged test_terminal_passed_unverified_pr_not_reported_merged +test_terminal_passed_mismatched_repo_pr_not_reported_merged test_terminal_passed_merged_pr_reports_merged test_terminal_passed_local_only_stays_done test_terminal_failed From 271ed59a4133b33556ab48d2a7046de78257b577 Mon Sep 17 00:00:00 2001 From: Keith Lee Date: Mon, 31 Aug 2026 22:58:59 -0700 Subject: [PATCH 11/12] no-mistakes(review): Use gh-axi's real --repo flag instead of remote matching --- bin/fm-crew-state.sh | 28 +++++------------------ tests/fm-crew-state.test.sh | 45 +++++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 32 deletions(-) diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 74431ee5657..cc610ed0546 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -426,7 +426,7 @@ nm_run_head_matches_worktree() { # completion, while non-GitHub PRs and unavailable GitHub reads stay explicit # without making a forge claim this helper cannot prove. nm_passed_run_detail() { - local pr_url identity repo_path number pr_out state remote_url remote_path + local pr_url identity repo_path number pr_out state pr_url=$(strip_quotes "$(nm_field pr)") if [ -z "$pr_url" ]; then printf 'run passed: local work complete' @@ -451,28 +451,10 @@ nm_passed_run_detail() { printf 'run passed: PR state unverified' return fi - # gh-axi resolves the repository from the current checkout and does not - # accept gh's -R override, so the stored PR's owner/repo must be proven to - # match the crew worktree's own remote before the number is trusted - - # otherwise a stale or foreign-fork link would silently report a - # same-numbered PR in the wrong repository. - remote_url=$(git -C "$WT" remote get-url origin 2>/dev/null) || { - printf 'run passed: PR state unverified' - return - } - remote_path=${remote_url%/} - remote_path=${remote_path%.git} - remote_path=$(printf '%s\n' "$remote_path" \ - | sed -nE 's#^(https://github\.com/|git@github\.com:|ssh://git@github\.com/)([^/]+/[^/]+)$#\2#p') - if [ -z "$remote_path" ] \ - || [ "$(printf '%s' "$remote_path" | tr '[:upper:]' '[:lower:]')" \ - != "$(printf '%s' "$repo_path" | tr '[:upper:]' '[:lower:]')" ]; then - printf 'run passed: PR state unverified' - return - fi - # Query from the crew worktree so the live PR state belongs to the project - # that produced this run, now that its identity is proven to match. - pr_out=$(cd "$WT" && gh-axi pr view "$number" 2>/dev/null) || { + # Address the exact linked repository explicitly via gh-axi's --repo flag + # (as bin/fm-pr-merge.sh already does), so a stale or foreign-fork link + # cannot silently resolve against the crew worktree's own checkout instead. + pr_out=$(cd "$WT" && gh-axi pr view "$number" --repo "$repo_path" 2>/dev/null) || { printf 'run passed: PR state unverified' return } diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 5399aeeb9a6..8d12497d331 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -39,11 +39,10 @@ fm_git_identity fmtest fmtest@example.invalid # A real git repo checked out on , so the helper's branch attribution # (git symbolic-ref) resolves like it would for a live crew worktree. -make_repo_on_branch() { # [origin-owner/repo] - local dir=$1 branch=$2 origin=${3:-o/r} +make_repo_on_branch() { # + local dir=$1 branch=$2 mkdir -p "$dir" git -C "$dir" init -q - git -C "$dir" remote add origin "https://github.com/$origin" 2>/dev/null || true git -C "$dir" commit -q --allow-empty -m init git -C "$dir" checkout -q -b "$branch" # Real worktree HEAD for run head-binding (fixtures read FM_FAKE_RUN_HEAD). @@ -91,8 +90,22 @@ if [ "${1:-}" = pr ] && [ "${2:-}" = view ]; then printf 'error: pull request state unavailable\n' exit 0 fi + number=${3:-1} + repo_arg="" + shift 3 + while [ $# -gt 0 ]; do + case "$1" in + --repo) repo_arg=${2:-}; shift 2 ;; + *) shift ;; + esac + done + # Real gh-axi resolves --repo authoritatively: an unexpected repo/PR pair + # (a stale or foreign-fork link) is not found, exactly as GitHub would 404 it. + if [ -n "${FM_FAKE_GH_REPO_EXPECTED:-}" ] && [ "$repo_arg" != "$FM_FAKE_GH_REPO_EXPECTED" ]; then + exit 1 + fi printf 'pull_request:\n number: %s\n state: %s\n' \ - "${3:-1}" "${FM_FAKE_GH_PR_STATE:-open}" + "$number" "${FM_FAKE_GH_PR_STATE:-open}" exit 0 fi exit 1 @@ -190,10 +203,11 @@ reset_fakes() { FM_FAKE_CI_LOGS="" FM_FAKE_GH_PR_VIEW=ok FM_FAKE_GH_PR_STATE=open + FM_FAKE_GH_REPO_EXPECTED= FM_FAKE_GH_CALLS= export FM_FAKE_AXI_STATUS FM_FAKE_AXI_STATUS_RUN FM_FAKE_RUNS_LIST FM_FAKE_BUSY FM_FAKE_BUSY_TEXT FM_FAKE_TMUX_MISSING export FM_FAKE_HERDR_BUSY FM_FAKE_HERDR_MISSING FM_FAKE_HERDR_AGENT_STATUS FM_FAKE_CI_LOGS - export FM_FAKE_GH_PR_VIEW FM_FAKE_GH_PR_STATE FM_FAKE_GH_CALLS + export FM_FAKE_GH_PR_VIEW FM_FAKE_GH_PR_STATE FM_FAKE_GH_REPO_EXPECTED FM_FAKE_GH_CALLS } # --- run-object fixtures (TOON, as `no-mistakes axi status` emits) ----------- @@ -714,11 +728,12 @@ test_terminal_passed_open_pr_not_reported_merged() { fm_write_meta "$d/state/feat-open-pr.meta" "window=fm:fm-feat-open-pr" "worktree=$d/wt" "kind=ship" FM_FAKE_AXI_STATUS="$(run_passed fm/feat-open-pr)" FM_FAKE_GH_PR_STATE=open + FM_FAKE_GH_REPO_EXPECTED=o/r local out; out=$(FM_FAKE_GH_CALLS="$d/gh.calls" run_crew_state "$d" feat-open-pr) assert_contains "$out" "state: done" "passed run with open PR remains locally done" assert_contains "$out" "run passed: PR open (not merged/closed)" "live open PR state is reported" assert_not_contains "$out" "PR merged/closed" "open PR is never reported as merged/closed" - assert_grep 'pr view 1' "$d/gh.calls" "the linked GitHub PR was checked live" + assert_grep 'pr view 1 --repo o/r' "$d/gh.calls" "the linked GitHub PR was checked live in its own repo" pass "passed run with open PR is not falsely reported as merged/closed" } @@ -739,15 +754,26 @@ test_terminal_passed_unverified_pr_not_reported_merged() { test_terminal_passed_mismatched_repo_pr_not_reported_merged() { reset_fakes local d; d=$(new_case passed-mismatched-repo-pr) - make_repo_on_branch "$d/wt" fm/feat-mismatched-pr other-owner/other-repo + make_repo_on_branch "$d/wt" fm/feat-mismatched-pr make_fakebin "$d" >/dev/null fm_write_meta "$d/state/feat-mismatched-pr.meta" "window=fm:fm-feat-mismatched-pr" "worktree=$d/wt" "kind=ship" - FM_FAKE_AXI_STATUS="$(run_passed fm/feat-mismatched-pr)" + FM_FAKE_AXI_STATUS="$(cat < Date: Mon, 31 Aug 2026 23:13:36 -0700 Subject: [PATCH 12/12] fix: stop replaying old Linear activity and untrack local dispatch profile Poller now carries forward previously observed issue/comment identities instead of rebuilding the snapshot from only this cycle's active-state issues, so an issue that leaves and re-enters an active state no longer looks brand-new and its already-seen comments stop replaying as fresh comment.detected events. config/crew-dispatch.json was force-added under the gitignored config/ directory, turning a documented LOCAL, per-user dispatch profile into a tracked repo default that silently downgraded every Claude implementation task to low reasoning effort. Untrack it so the effort fallback in AGENTS.md applies again unless a user opts in locally. --- bin/fm-procevent-linear.sh | 7 ++++++- config/crew-dispatch.json | 40 -------------------------------------- 2 files changed, 6 insertions(+), 41 deletions(-) delete mode 100644 config/crew-dispatch.json diff --git a/bin/fm-procevent-linear.sh b/bin/fm-procevent-linear.sh index 8e22771d4cf..13303d3e510 100755 --- a/bin/fm-procevent-linear.sh +++ b/bin/fm-procevent-linear.sh @@ -143,7 +143,7 @@ fetch_issue_comments() { # poll_cycle() { # : prints one envelope only on change local config=$1 id=$2 snapshot previous='{"issues":{},"comments":{}}' first_observation=true local states allow project slug project_name fm_project issues issue issue_id comments - local current='{"issues":{},"comments":{}}' events='[]' staged + local current events='[]' staged snapshot=$(snapshot_path "$id") if [ -f "$snapshot" ]; then jq -e '.issues | type == "object"' "$snapshot" >/dev/null 2>&1 \ @@ -151,6 +151,11 @@ poll_cycle() { # : prints one envelope only on change previous=$(jq -c . "$snapshot") || exit 1 first_observation=false fi + # Carry forward every previously observed issue/comment identity rather than + # rebuilding from only this cycle's active-state issues: an issue that + # leaves the active states and later returns must not look brand-new, or + # its already-seen comments replay as fresh comment.detected events. + current=$previous states=$(jq -c '.activeStates // ["Todo", "In Progress", "Blocked", "Human Review"]' "$config") allow=$(jq -c '.allowIssues // []' "$config") diff --git a/config/crew-dispatch.json b/config/crew-dispatch.json deleted file mode 100644 index cce38282a3e..00000000000 --- a/config/crew-dispatch.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "rules": [ - { - "when": "planning, investigation, or review tasks when Firstmate is running from Claude", - "use": { - "harness": "claude", - "model": "claude-opus-5", - "effort": "high" - }, - "why": "Use Opus for planning and review with high reasoning." - }, - { - "when": "implementation or execution tasks when Firstmate is running from Claude", - "use": { - "harness": "claude", - "model": "claude-sonnet-5", - "effort": "low" - }, - "why": "Use Sonnet for implementation with low reasoning effort." - }, - { - "when": "planning, investigation, or review tasks when Firstmate is running from Codex", - "use": { - "harness": "codex", - "model": "gpt-5.6-sol", - "effort": "high" - }, - "why": "Use Sol for planning and review with high reasoning." - }, - { - "when": "implementation or execution tasks when Firstmate is running from Codex", - "use": { - "harness": "codex", - "model": "gpt-5.6-luna", - "effort": "high" - }, - "why": "Use Luna for implementation with high reasoning." - } - ] -}