From 8a2429de886b2e8e636493d50d8def5df16df4ce Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Mon, 24 Aug 2026 09:12:09 -0400 Subject: [PATCH 1/6] chore: adds anthropic env vars to use claude with lemonade --- mise.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mise.toml b/mise.toml index 079fd95..c486c1a 100644 --- a/mise.toml +++ b/mise.toml @@ -7,3 +7,13 @@ erlang = "29.0.3" elixir = "1.20.3" python = "3.14.1" +[env] +ANTHROPIC_BASE_URL = "http://127.0.0.1:13305" +ANTHROPIC_AUTH_TOKEN = "lemonade" +LEMONADE_API_KEY = "lemonade" +ANTHROPIC_DEFAULT_OPUS_MODEL = "user.Qwen3.6-35B-A3B-ThinkingCoder" +ANTHROPIC_DEFAULT_SONNET_MODEL = "user.Qwen3.6-35B-A3B-ThinkingCoder" +ANTHROPIC_DEFAULT_HAIKU_MODEL = "user.Qwen3.6-35B-A3B-ThinkingCoder" +CLAUDE_CODE_SUBAGENT_MODEL = "user.Qwen3.6-35B-A3B-ThinkingCoder" +CLAUDE_CODE_ATTRIBUTION_HEADER = 0 +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = 1 From 44df54441d18fb3950d34d8c15222c27a355f191 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Wed, 26 Aug 2026 09:47:24 -0400 Subject: [PATCH 2/6] chore: adds env vars and working workflow files for codex and claude --- .ai/prompts/implement.md | 5 + .gitmodules | 2 +- AGENTS.md | 19 +++ vendor/stokowski | 2 +- workflow.claude.yaml | 263 ++++++++++++++++++++++++++++++++++++++ workflow.codex.yaml | 267 ++++++++++++++++++++++++++++++++++++++ workflow.yaml | 268 +-------------------------------------- 7 files changed, 557 insertions(+), 269 deletions(-) create mode 100644 workflow.claude.yaml create mode 100644 workflow.codex.yaml mode change 100644 => 120000 workflow.yaml diff --git a/.ai/prompts/implement.md b/.ai/prompts/implement.md index 4fa883e..3c552b4 100644 --- a/.ai/prompts/implement.md +++ b/.ai/prompts/implement.md @@ -18,6 +18,11 @@ No description provided. Implement the solution, create a PR, and ensure it passes all quality checks. +## Rule + +Always sign git commits. If a gpg-agent is not available with a signing key, +stop and note that on the linear issue, do not create an unsigned commit. + ## First run 1. Read the investigation summary from the Linear comments. diff --git a/.gitmodules b/.gitmodules index a0037d1..b3d4418 100644 --- a/.gitmodules +++ b/.gitmodules @@ -27,4 +27,4 @@ url = git@github.com:fuelen/owl [submodule "vendor/stokowski"] path = vendor/stokowski - url = https://github.com/Sugar-Coffee/stokowski + url = git@github.com:bougyman/stokowski diff --git a/AGENTS.md b/AGENTS.md index 9289add..23e4627 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,25 @@ and best practices for agents to follow. ## Project Structure +### Linear Team and Project + +The `Linear CLI` project of the "Elixir Traitors" (`EXT`) team on the +https://linear.app/the-rubyists[Rubyists Linear Organization] manages +the project roadmap and issue tracking. + +#### Rules for Interacting with Linear + +Always only use `mix lc` to interact with linear.app. Do not look for an +mcp or any other way to interact with issues or the project. + +`mix lc --help` +`mix lc issue --help` +`mix lc issue list --help` +`mix lc issue create --help` +`mix lc issue status --help` + +This should be enough to get started + ### Repository layout This repo uses two Mix projects side-by-side, not nested: diff --git a/vendor/stokowski b/vendor/stokowski index 6042552..73bbbcf 160000 --- a/vendor/stokowski +++ b/vendor/stokowski @@ -1 +1 @@ -Subproject commit 60425520c26ca2c1b53a4e408bdebcc4d1786b84 +Subproject commit 73bbbcf6e4284e768263df23c673948fe85eb81c diff --git a/workflow.claude.yaml b/workflow.claude.yaml new file mode 100644 index 0000000..5f70a42 --- /dev/null +++ b/workflow.claude.yaml @@ -0,0 +1,263 @@ +# ============================================================================= +# Stokowski — Example State Machine Workflow +# ============================================================================= +# +# This is linear-cli's own checked-in workflow config, not a template to +# copy elsewhere. Stokowski re-reads this file on every poll tick — changes +# take effect without restart. +# +# Prompt files live in the .ai/prompts/ directory. Each agent state +# references its prompt via the `prompt` field. A global prompt (shared +# preamble) is injected before the stage-specific prompt when set. +# +# Linear states mapping: +# active → the "working" state for agent stages +# review → human review / PR review state +# gate_approved → human approved a gate (triggers transition) +# rework → human requested changes (triggers rework_to) +# terminal → list of done/closed states +# +# State types: +# agent — Stokowski dispatches Claude Code in this state +# gate — pauses for human review; approve → next, rework → rework_to +# terminal — issue is finished; workspace cleaned up +# +# ----------------------------------------------------------------------------- +# Multi-project mode (optional — single-project YAML below still works as-is) +# ----------------------------------------------------------------------------- +# +# To monitor multiple Linear projects from one Stokowski process, replace the +# top-level `tracker / workspace / hooks / prompts / states` blocks with a +# `projects:` list. Each entry has its own tracker/workspace/hooks/prompts/ +# state machine. Top-level `polling`, `agent`, `claude`, `linear_states`, and +# `server` stay global and act as defaults each project can override. +# +# agent: +# max_concurrent_agents: 8 # global pool across all projects +# max_concurrent_per_project: # optional fairness caps +# synced-sport: 6 +# client-site: 2 +# +# projects: +# - name: synced-sport # required, used in dashboard + `p` menu +# tracker: +# kind: linear +# project_slug: "abc123def456" +# api_key: "$LINEAR_API_KEY" +# workspace: +# root: ~/.local/share/stokowski/workspaces/synced-sport +# hooks: { after_create: "git clone git@github.com:org/synced-sport.git ." } +# prompts: { global_prompt: prompts/synced-sport/global.md } +# states: { ... } # state machine, same as single-project +# +# - name: client-site +# paused: true # start paused; toggle with `p` key +# tracker: +# kind: linear +# project_slug: "def456abc789" +# api_key: "$LINEAR_API_KEY" +# workspace: +# root: ~/.local/share/stokowski/workspaces/client-site +# hooks: { after_create: "git clone git@github.com:org/client-site.git ." } +# prompts: { global_prompt: prompts/client-site/global.md } +# claude: { model: claude-opus-4-7 } # per-project override +# max_concurrent: 2 # per-project cap (also via agent map above) +# states: { ... } +# +# In the terminal, `p` opens a numbered pause menu; in the web dashboard, each +# project tile gets a Pause/Resume button and a per-project filter dropdown. +# ============================================================================= + +# --------------------------------------------------------------------------- +# Tracker +# --------------------------------------------------------------------------- +tracker: + kind: linear + project_slug: "94a76f2ac65f" # hex slugId from your Linear project URL + # api_key: "$LINEAR_API_KEY" # your Linear API key — agents inherit this + assignee: me # only issues assigned to the API key's user + +# --------------------------------------------------------------------------- +# Linear state name mapping +# --------------------------------------------------------------------------- +# These map Stokowski's internal lifecycle roles to your Linear state names. +# You can rename values to match your Linear setup (e.g. todo: "Ready"), +# but all six roles are required — removing or reordering them will break +# the dispatch and gate protocol. +linear_states: + todo: "Todo" # issues picked up from this state + active: "In Progress" # moved here automatically when agent starts + review: "Human Review" # agent pauses here at a gate for human review + gate_approved: "Gate Approved" # human approved — agent advances to next state + rework: "Rework" # human requested changes — agent re-enters rework target + terminal: # issues in these states stop any running agent + - Done + - Closed + - Cancelled + - Canceled + - Duplicate + +# --------------------------------------------------------------------------- +# Polling +# --------------------------------------------------------------------------- +polling: + interval_ms: 60000 # 15 seconds between ticks + +# --------------------------------------------------------------------------- +# Workspace +# --------------------------------------------------------------------------- +workspace: + root: ~/.local/share/stokowski/workspaces/linear-cli + +# --------------------------------------------------------------------------- +# Hooks — shell scripts run in the workspace directory +# --------------------------------------------------------------------------- +hooks: + after_create: | + git clone --depth 1 --recursive git@github.com:rubyists/linear-cli . + mix setup + before_run: | + set -eu + git fetch origin main + changes="$(git status --porcelain)" + if [ -n "$changes" ]; then + echo "before_run: preserving uncommitted workspace changes; skipping rebase" + else + git rebase origin/main + fi + timeout_ms: 120000 + +# --------------------------------------------------------------------------- +# Claude defaults (inherited by all agent states unless overridden) +# States using runner: codex will use the Codex CLI instead. +# --------------------------------------------------------------------------- +claude: + permission_mode: auto + max_turns: 20 + turn_timeout_ms: 3600000 # 1 hour per turn + stall_timeout_ms: 900000 # 15 minutes with no output → kill + +# --------------------------------------------------------------------------- +# Agent concurrency +# --------------------------------------------------------------------------- +agent: + max_concurrent_agents: 4 + max_retry_backoff_ms: 300000 # 5-minute cap on exponential backoff + max_concurrent_agents_by_state: + investigate: 2 # allow 2 concurrent investigations + implement: 2 + code-review: 1 # serial reviews prevent merge conflicts + +# --------------------------------------------------------------------------- +# Prompts +# --------------------------------------------------------------------------- +prompts: + global_prompt: .ai/prompts/global.md + +# --------------------------------------------------------------------------- +# Server (optional web dashboard) +# --------------------------------------------------------------------------- +server: + host: 127.0.0.1 + port: 4200 + +# --------------------------------------------------------------------------- +# State machine +# --------------------------------------------------------------------------- +# +# Order matters: the first agent state is the entry state for new issues. +# +# Transitions fire automatically on agent success or human action: +# - Agent finishes successfully → "complete" transition +# - Human approves a gate → "approve" transition +# - Human requests rework → rework_to target +# +states: + + # ── 1. Investigate ────────────────────────────────────────────────────── + # Read the codebase, understand the problem, post an investigation summary. + # Uses Opus for deeper reasoning. Low turn count — investigation only. + investigate: + type: agent + prompt: .ai/prompts/investigate.md + linear_state: active + max_turns: 8 + session: inherit + transitions: + complete: research-review + + # ── 2. Research Review (gate) ─────────────────────────────────────────── + # Human reviews the investigation before implementation starts. + # Rework loops back to investigate. + research-review: + type: gate + linear_state: review + rework_to: investigate + max_rework: 3 + transitions: + approve: implement + + # ── 3. Implement ──────────────────────────────────────────────────────── + # Write code, create branch, run tests, open PR. + # Uses Sonnet for fast iteration. Higher turn count for complex work. + implement: + type: agent + prompt: .ai/prompts/implement.md + linear_state: active + max_turns: 30 + session: inherit + transitions: + complete: implementation-review + + # ── 4. Implementation Review (gate) ───────────────────────────────────── + # Human reviews the PR before automated code review. + # Rework loops back to implement. + implementation-review: + type: gate + linear_state: review + rework_to: implement + max_rework: 5 + transitions: + approve: code-review + + # ── 5. Code Review ───────────────────────────────────────────────────── + # Automated adversarial review: fresh session, no prior context. + # Opus reviews the diff and posts findings. + # Tip: set runner: codex here to get a second-opinion from a different + # provider — different states can use different runners in the same run. + code-review: + type: agent + prompt: .ai/prompts/review.md + linear_state: active + runner: claude # "claude" (default) or "codex" + max_turns: 10 + session: fresh # no prior context — adversarial review + transitions: + complete: merge-review + + # ── 6. Merge Review (gate) ────────────────────────────────────────────── + # Human reviews the code review findings and decides to merge or rework. + # Rework goes back to implement (not code-review). + merge-review: + type: gate + linear_state: review + rework_to: implement + max_rework: 5 + transitions: + approve: merge + + # ── 7. Merge ──────────────────────────────────────────────────────────── + # Merge the PR and clean up. Short turn count — just the merge. + merge: + type: agent + prompt: .ai/prompts/merge.md + linear_state: active + max_turns: 5 + session: inherit + transitions: + complete: done + + # ── 8. Done (terminal) ───────────────────────────────────────────────── + done: + type: terminal + linear_state: terminal diff --git a/workflow.codex.yaml b/workflow.codex.yaml new file mode 100644 index 0000000..e5f8ffb --- /dev/null +++ b/workflow.codex.yaml @@ -0,0 +1,267 @@ +# ============================================================================= +# Stokowski — Example State Machine Workflow +# ============================================================================= +# +# This is linear-cli's own checked-in workflow config, not a template to +# copy elsewhere. Stokowski re-reads this file on every poll tick — changes +# take effect without restart. +# +# Prompt files live in the .ai/prompts/ directory. Each agent state +# references its prompt via the `prompt` field. A global prompt (shared +# preamble) is injected before the stage-specific prompt when set. +# +# Linear states mapping: +# active → the "working" state for agent stages +# review → human review / PR review state +# gate_approved → human approved a gate (triggers transition) +# rework → human requested changes (triggers rework_to) +# terminal → list of done/closed states +# +# State types: +# agent — Stokowski dispatches Codex in this state +# gate — pauses for human review; approve → next, rework → rework_to +# terminal — issue is finished; workspace cleaned up +# +# ----------------------------------------------------------------------------- +# Multi-project mode (optional — single-project YAML below still works as-is) +# ----------------------------------------------------------------------------- +# +# To monitor multiple Linear projects from one Stokowski process, replace the +# top-level `tracker / workspace / hooks / prompts / states` blocks with a +# `projects:` list. Each entry has its own tracker/workspace/hooks/prompts/ +# state machine. Top-level `polling`, `agent`, legacy `claude` runner settings, +# `linear_states`, and `server` stay global and act as defaults each project +# can override. +# +# agent: +# max_concurrent_agents: 8 # global pool across all projects +# max_concurrent_per_project: # optional fairness caps +# synced-sport: 6 +# client-site: 2 +# +# projects: +# - name: synced-sport # required, used in dashboard + `p` menu +# tracker: +# kind: linear +# project_slug: "abc123def456" +# api_key: "$LINEAR_API_KEY" +# workspace: +# root: ~/.local/share/stokowski/workspaces/synced-sport +# hooks: { after_create: "git clone git@github.com:org/synced-sport.git ." } +# prompts: { global_prompt: prompts/synced-sport/global.md } +# states: { ... } # state machine, same as single-project +# +# - name: client-site +# paused: true # start paused; toggle with `p` key +# tracker: +# kind: linear +# project_slug: "def456abc789" +# api_key: "$LINEAR_API_KEY" +# workspace: +# root: ~/.local/share/stokowski/workspaces/client-site +# hooks: { after_create: "git clone git@github.com:org/client-site.git ." } +# prompts: { global_prompt: prompts/client-site/global.md } +# claude: { turn_timeout_ms: 3600000 } # shared runner settings +# max_concurrent: 2 # per-project cap (also via agent map above) +# states: { ... } +# +# In the terminal, `p` opens a numbered pause menu; in the web dashboard, each +# project tile gets a Pause/Resume button and a per-project filter dropdown. +# ============================================================================= + +# --------------------------------------------------------------------------- +# Tracker +# --------------------------------------------------------------------------- +tracker: + kind: linear + project_slug: "94a76f2ac65f" # hex slugId from your Linear project URL + # api_key: "$LINEAR_API_KEY" # your Linear API key — agents inherit this + assignee: me # only issues assigned to the API key's user + +# --------------------------------------------------------------------------- +# Linear state name mapping +# --------------------------------------------------------------------------- +# These map Stokowski's internal lifecycle roles to your Linear state names. +# You can rename values to match your Linear setup (e.g. todo: "Ready"), +# but all six roles are required — removing or reordering them will break +# the dispatch and gate protocol. +linear_states: + todo: "Todo" # issues picked up from this state + active: "In Progress" # moved here automatically when agent starts + review: "Human Review" # agent pauses here at a gate for human review + gate_approved: "Gate Approved" # human approved — agent advances to next state + rework: "Rework" # human requested changes — agent re-enters rework target + terminal: # issues in these states stop any running agent + - Done + - Closed + - Cancelled + - Canceled + - Duplicate + +# --------------------------------------------------------------------------- +# Polling +# --------------------------------------------------------------------------- +polling: + interval_ms: 60000 # 15 seconds between ticks + +# --------------------------------------------------------------------------- +# Workspace +# --------------------------------------------------------------------------- +workspace: + root: ~/.local/share/stokowski/workspaces/linear-cli + +# --------------------------------------------------------------------------- +# Hooks — shell scripts run in the workspace directory +# --------------------------------------------------------------------------- +hooks: + after_create: | + git clone --depth 1 --recursive git@github.com:rubyists/linear-cli . + mix setup + before_run: | + set -eu + git fetch origin main + changes="$(git status --porcelain)" + if [ -n "$changes" ]; then + echo "before_run: preserving uncommitted workspace changes; skipping rebase" + else + git rebase origin/main + fi + timeout_ms: 120000 + +# --------------------------------------------------------------------------- +# Shared runner defaults +# +# Stokowski 0.5 keeps these runner-neutral limits under its legacy `claude` +# config key. Leave `model` unset so Codex inherits the invoking user's model; +# each agent state sets an explicit reasoning effort so an interactive `max` +# default cannot make unattended turns run indefinitely. +# --------------------------------------------------------------------------- +claude: + max_turns: 20 + turn_timeout_ms: 3600000 # 1 hour per turn + stall_timeout_ms: 900000 # 15 minutes with no output → kill + +# --------------------------------------------------------------------------- +# Agent concurrency +# --------------------------------------------------------------------------- +agent: + max_concurrent_agents: 4 + max_retry_backoff_ms: 300000 # 5-minute cap on exponential backoff + max_concurrent_agents_by_state: + investigate: 2 # allow 2 concurrent investigations + implement: 2 + code-review: 1 # serial reviews prevent merge conflicts + +# --------------------------------------------------------------------------- +# Prompts +# --------------------------------------------------------------------------- +prompts: + global_prompt: .ai/prompts/global.md + +# --------------------------------------------------------------------------- +# Server (optional web dashboard) +# --------------------------------------------------------------------------- +server: + host: 127.0.0.1 + port: 4200 + +# --------------------------------------------------------------------------- +# State machine +# --------------------------------------------------------------------------- +# +# Order matters: the first agent state is the entry state for new issues. +# +# Transitions fire automatically on agent success or human action: +# - Agent finishes successfully → "complete" transition +# - Human approves a gate → "approve" transition +# - Human requests rework → rework_to target +# +states: + + # ── 1. Investigate ────────────────────────────────────────────────────── + # Read the codebase, understand the problem, post an investigation summary. + # Uses the invoking user's configured Codex model with bounded reasoning. + investigate: + type: agent + prompt: .ai/prompts/investigate.md + linear_state: active + runner: codex + reasoning_effort: medium + max_turns: 8 + transitions: + complete: research-review + + # ── 2. Research Review (gate) ─────────────────────────────────────────── + # Human reviews the investigation before implementation starts. + # Rework loops back to investigate. + research-review: + type: gate + linear_state: review + rework_to: investigate + max_rework: 3 + transitions: + approve: implement + + # ── 3. Implement ──────────────────────────────────────────────────────── + # Write code, create branch, run tests, open PR. + implement: + type: agent + prompt: .ai/prompts/implement.md + linear_state: active + runner: codex + reasoning_effort: xhigh + max_turns: 30 + transitions: + complete: implementation-review + + # ── 4. Implementation Review (gate) ───────────────────────────────────── + # Human reviews the PR before automated code review. + # Rework loops back to implement. + implementation-review: + type: gate + linear_state: review + rework_to: implement + max_rework: 5 + transitions: + approve: code-review + + # ── 5. Code Review ───────────────────────────────────────────────────── + # Automated adversarial review: fresh session, no prior context. + # Codex reviews the diff and posts findings. + code-review: + type: agent + prompt: .ai/prompts/review.md + linear_state: active + runner: codex + reasoning_effort: high + max_turns: 10 + transitions: + complete: merge-review + + # ── 6. Merge Review (gate) ────────────────────────────────────────────── + # Human reviews the code review findings and decides to merge or rework. + # Rework goes back to implement (not code-review). + merge-review: + type: gate + linear_state: review + rework_to: implement + max_rework: 5 + transitions: + approve: merge + + # ── 7. Merge ──────────────────────────────────────────────────────────── + # Merge the PR and clean up. Short turn count — just the merge. + merge: + type: agent + prompt: .ai/prompts/merge.md + linear_state: active + runner: codex + reasoning_effort: medium + max_turns: 5 + transitions: + complete: done + + # ── 8. Done (terminal) ───────────────────────────────────────────────── + done: + type: terminal + linear_state: terminal diff --git a/workflow.yaml b/workflow.yaml deleted file mode 100644 index dcefed7..0000000 --- a/workflow.yaml +++ /dev/null @@ -1,267 +0,0 @@ -# ============================================================================= -# Stokowski — Example State Machine Workflow -# ============================================================================= -# -# This is linear-cli's own checked-in workflow config, not a template to -# copy elsewhere. Stokowski re-reads this file on every poll tick — changes -# take effect without restart. -# -# Prompt files live in the .ai/prompts/ directory. Each agent state -# references its prompt via the `prompt` field. A global prompt (shared -# preamble) is injected before the stage-specific prompt when set. -# -# Linear states mapping: -# active → the "working" state for agent stages -# review → human review / PR review state -# gate_approved → human approved a gate (triggers transition) -# rework → human requested changes (triggers rework_to) -# terminal → list of done/closed states -# -# State types: -# agent — Stokowski dispatches Claude Code in this state -# gate — pauses for human review; approve → next, rework → rework_to -# terminal — issue is finished; workspace cleaned up -# -# ----------------------------------------------------------------------------- -# Multi-project mode (optional — single-project YAML below still works as-is) -# ----------------------------------------------------------------------------- -# -# To monitor multiple Linear projects from one Stokowski process, replace the -# top-level `tracker / workspace / hooks / prompts / states` blocks with a -# `projects:` list. Each entry has its own tracker/workspace/hooks/prompts/ -# state machine. Top-level `polling`, `agent`, `claude`, `linear_states`, and -# `server` stay global and act as defaults each project can override. -# -# agent: -# max_concurrent_agents: 8 # global pool across all projects -# max_concurrent_per_project: # optional fairness caps -# synced-sport: 6 -# client-site: 2 -# -# projects: -# - name: synced-sport # required, used in dashboard + `p` menu -# tracker: -# kind: linear -# project_slug: "abc123def456" -# api_key: "$LINEAR_API_KEY" -# workspace: -# root: ~/.local/share/stokowski/workspaces/synced-sport -# hooks: { after_create: "git clone git@github.com:org/synced-sport.git ." } -# prompts: { global_prompt: prompts/synced-sport/global.md } -# states: { ... } # state machine, same as single-project -# -# - name: client-site -# paused: true # start paused; toggle with `p` key -# tracker: -# kind: linear -# project_slug: "def456abc789" -# api_key: "$LINEAR_API_KEY" -# workspace: -# root: ~/.local/share/stokowski/workspaces/client-site -# hooks: { after_create: "git clone git@github.com:org/client-site.git ." } -# prompts: { global_prompt: prompts/client-site/global.md } -# claude: { model: claude-opus-4-7 } # per-project override -# max_concurrent: 2 # per-project cap (also via agent map above) -# states: { ... } -# -# In the terminal, `p` opens a numbered pause menu; in the web dashboard, each -# project tile gets a Pause/Resume button and a per-project filter dropdown. -# ============================================================================= - -# --------------------------------------------------------------------------- -# Tracker -# --------------------------------------------------------------------------- -tracker: - kind: linear - project_slug: "94a76f2ac65f" # hex slugId from your Linear project URL - # api_key: "$LINEAR_API_KEY" # your Linear API key — agents inherit this - assignee: me # only issues assigned to the API key's user - -# --------------------------------------------------------------------------- -# Linear state name mapping -# --------------------------------------------------------------------------- -# These map Stokowski's internal lifecycle roles to your Linear state names. -# You can rename values to match your Linear setup (e.g. todo: "Ready"), -# but all six roles are required — removing or reordering them will break -# the dispatch and gate protocol. -linear_states: - todo: "Todo" # issues picked up from this state - active: "In Progress" # moved here automatically when agent starts - review: "Human Review" # agent pauses here at a gate for human review - gate_approved: "Gate Approved" # human approved — agent advances to next state - rework: "Rework" # human requested changes — agent re-enters rework target - terminal: # issues in these states stop any running agent - - Done - - Closed - - Cancelled - - Canceled - - Duplicate - -# --------------------------------------------------------------------------- -# Polling -# --------------------------------------------------------------------------- -polling: - interval_ms: 60000 # 15 seconds between ticks - -# --------------------------------------------------------------------------- -# Workspace -# --------------------------------------------------------------------------- -workspace: - root: ~/.local/share/stokowski/workspaces/linear-cli - -# --------------------------------------------------------------------------- -# Hooks — shell scripts run in the workspace directory -# --------------------------------------------------------------------------- -hooks: - after_create: | - git clone --depth 1 --recursive git@github.com:rubyists/linear-cli . - mix setup - before_run: | - set -eu - git fetch origin main - changes="$(git status --porcelain)" - if [ -n "$changes" ]; then - echo "before_run: preserving uncommitted workspace changes; skipping rebase" - else - git rebase origin/main - fi - timeout_ms: 120000 - -# --------------------------------------------------------------------------- -# Claude defaults (inherited by all agent states unless overridden) -# States using runner: codex will use the Codex CLI instead. -# --------------------------------------------------------------------------- -claude: - permission_mode: auto - model: claude-sonnet-4-6 - max_turns: 20 - turn_timeout_ms: 3600000 # 1 hour per turn - stall_timeout_ms: 300000 # 5 minutes with no output → kill - -# --------------------------------------------------------------------------- -# Agent concurrency -# --------------------------------------------------------------------------- -agent: - max_concurrent_agents: 4 - max_retry_backoff_ms: 300000 # 5-minute cap on exponential backoff - max_concurrent_agents_by_state: - investigate: 2 # allow 2 concurrent investigations - implement: 2 - code-review: 1 # serial reviews prevent merge conflicts - -# --------------------------------------------------------------------------- -# Prompts -# --------------------------------------------------------------------------- -prompts: - global_prompt: .ai/prompts/global.md - -# --------------------------------------------------------------------------- -# Server (optional web dashboard) -# --------------------------------------------------------------------------- -server: - host: 127.0.0.1 - port: 4200 - -# --------------------------------------------------------------------------- -# State machine -# --------------------------------------------------------------------------- -# -# Order matters: the first agent state is the entry state for new issues. -# -# Transitions fire automatically on agent success or human action: -# - Agent finishes successfully → "complete" transition -# - Human approves a gate → "approve" transition -# - Human requests rework → rework_to target -# -states: - - # ── 1. Investigate ────────────────────────────────────────────────────── - # Read the codebase, understand the problem, post an investigation summary. - # Uses Opus for deeper reasoning. Low turn count — investigation only. - investigate: - type: agent - prompt: .ai/prompts/investigate.md - linear_state: active - model: claude-opus-4-6 - max_turns: 8 - session: inherit - transitions: - complete: research-review - - # ── 2. Research Review (gate) ─────────────────────────────────────────── - # Human reviews the investigation before implementation starts. - # Rework loops back to investigate. - research-review: - type: gate - linear_state: review - rework_to: investigate - max_rework: 3 - transitions: - approve: implement - - # ── 3. Implement ──────────────────────────────────────────────────────── - # Write code, create branch, run tests, open PR. - # Uses Sonnet for fast iteration. Higher turn count for complex work. - implement: - type: agent - prompt: .ai/prompts/implement.md - linear_state: active - model: claude-sonnet-4-6 - max_turns: 30 - session: inherit - transitions: - complete: implementation-review - - # ── 4. Implementation Review (gate) ───────────────────────────────────── - # Human reviews the PR before automated code review. - # Rework loops back to implement. - implementation-review: - type: gate - linear_state: review - rework_to: implement - max_rework: 5 - transitions: - approve: code-review - - # ── 5. Code Review ───────────────────────────────────────────────────── - # Automated adversarial review: fresh session, no prior context. - # Opus reviews the diff and posts findings. - # Tip: set runner: codex here to get a second-opinion from a different - # provider — different states can use different runners in the same run. - code-review: - type: agent - prompt: .ai/prompts/review.md - linear_state: active - runner: claude # "claude" (default) or "codex" - model: claude-opus-4-6 - max_turns: 10 - session: fresh # no prior context — adversarial review - transitions: - complete: merge-review - - # ── 6. Merge Review (gate) ────────────────────────────────────────────── - # Human reviews the code review findings and decides to merge or rework. - # Rework goes back to implement (not code-review). - merge-review: - type: gate - linear_state: review - rework_to: implement - max_rework: 5 - transitions: - approve: merge - - # ── 7. Merge ──────────────────────────────────────────────────────────── - # Merge the PR and clean up. Short turn count — just the merge. - merge: - type: agent - prompt: .ai/prompts/merge.md - linear_state: active - max_turns: 5 - session: inherit - transitions: - complete: done - - # ── 8. Done (terminal) ───────────────────────────────────────────────── - done: - type: terminal - linear_state: terminal diff --git a/workflow.yaml b/workflow.yaml new file mode 120000 index 0000000..b911943 --- /dev/null +++ b/workflow.yaml @@ -0,0 +1 @@ +workflow.claude.yaml \ No newline at end of file From e0ac64923f3429b58122d4dc598a1fccdaacdd1e Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Mon, 31 Aug 2026 14:46:39 -0400 Subject: [PATCH 3/6] docs: add plan for phase 13, going towards more stowkoski friendly --- documents/phase-13-plan.adoc | 397 +++++++++++++++++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 documents/phase-13-plan.adoc diff --git a/documents/phase-13-plan.adoc b/documents/phase-13-plan.adoc new file mode 100644 index 0000000..5aeab97 --- /dev/null +++ b/documents/phase-13-plan.adoc @@ -0,0 +1,397 @@ += {my-title} +Tj Vanderpoel (bougyman) +:revdate: Aug 31, 2026 +:my-title: Phase 13 plan: persistent comment upsert for Stokowski (`lc issue comment`) +:icons: font +:env-github: +ifdef::env-github[] +:tip-caption: :bulb: +:note-caption: :information_source: +:important-caption: :heavy_exclamation_mark: +:caution-caption: :fire: +:warning-caption: :warning: +endif::[] +:toc: + +== Goal + +`AGENTS.md` says "Always only use `mix lc` to interact with linear.app. +Do not look for an mcp or any other way." `.ai/prompts/global.md` says every +agent must maintain "a single Linear comment as a persistent workpad ... +Update it at each milestone ... do not delete prior content." Those two +rules currently contradict each other: `mix lc` has no way to update an +existing comment, or to find one to update in the first place. The only +comment primitive today is `add_comment`/`commentCreate` - every call +creates a brand-new comment. + +This gap is not theoretical. On EXT-17's `code-review` run 4, the +review agent (real Claude Opus, not a degraded model - see the session +transcript at +`~/.claude/projects/-home-bougybot--local-share-stokowski-workspaces-linear-cli-EXT-17/55e18e70-*.jsonl`, +lines 121 and 125) needed to post a persistent workpad update and, finding +no `mix lc` way to do it, hand-rolled a direct `curl`/GraphQL call instead. +That ad hoc script had two bugs of its own (a single-quoted bash string +with literal `\n` instead of real newlines, and a variable reference inside +a *quoted* heredoc delimiter that never expanded), which is exactly the +class of failure "always use `mix lc`" exists to prevent. The rule is +right; the tooling just doesn't back it up yet. + +This phase adds exactly the missing primitive - find-or-update-by-marker +comment upsert, plus a way to supply a large multi-line body without +building it as a single shell argument - as a new `lc issue comment` +subcommand. It does not touch `issue update --comment`/`issue status +--comment` (plain create, already correct and well-tested) or +`bin/lcomment` (a human-interactive wrapper, out of scope here). + +== Decisions (reached in full agreement before implementation) + +1. *A new `lc issue comment` subcommand, not a widening of `issue update`.* + Comment upsert needs to read an issue's existing comments and choose + create-vs-update; that's a different shape from `issue update`'s + field-setting options, same reasoning as Phase 12's `issue move` being + its own verb rather than another `issue update` flag. +2. *Upsert is opt-in via an explicit `-k`/`--marker` option, not inferred + from the body.* The workpad's own heading text changes between runs + (`## Workpad — Code review run 3` vs `run 4` in the transcript above), + so matching on the literal first line would never find the prior + comment. The marker is a separate, stable string the caller chooses + once (e.g. `"## Workpad"`) and passes on every call; the body underneath + it can grow or change freely. Without `--marker`, the command always + creates a new comment - identical behavior to today's `add_comment`. +3. *Match = the comment body, left-trimmed, starts with the marker + string.* Simple, predictable, and works for both a Markdown heading + marker (`## Workpad`) and an HTML-comment marker in Stokowski's own + style (``) without the CLI caring which + convention a given workflow uses. No regex, no configurable match mode + in this phase. +4. *Multiple matches: update the most recently created one, don't error.* + Comments come back from the issue's full fragment in the API's default + (creation) order, so "last match" is "most recent." Stray duplicate + workpad comments from before this feature existed shouldn't hard-fail + every future update; they just stop being the one that gets touched. +5. *`--body-file PATH` is new, additive, and scoped to this subcommand + only.* `PATH` of `-` reads stdin; anything else is read as a file. This + is what actually kills the incident's bug class: a multi-line body + never has to survive a single shell argument's quoting rules. Given + `--comment`/`-m` (see below), `--body-file` also is what a caller + writes to a plain heredoc/temp file: `WhatFor.comment_for/2`'s existing + `"-"`-opens-an-editor convention is untouched, and `--comment` and + `--body-file` are mutually exclusive (both given is a `smells_bad` + error). Generalizing `--body-file` to `--description`/`--reason`/etc. + is deliberately deferred - those aren't the pattern that broke, and + widening `WhatFor.ask_or_edit/3`'s shared contract for every caller is + a bigger, separate change than this phase needs. +6. *No new GraphQL round trip to find the marker match.* Every existing + issue lookup that already resolves an `ISSUE_ID` to an `%Issue{}` + (`Linear.issues(%{ids: [...]})`) uses the full-fragment path, which + already includes `comments { nodes { ... } }`. The new subcommand + resolves the issue the same way `issue update`/`issue status` do and + searches the comments already on the struct - no extra query. +7. *`update_comment` is a genuinely new Ash action*, mirroring the + `Issue.Update.*` manual-update pattern exactly (`changeset.data` is the + already-loaded `%Comment{}`, argument is the new `body`, the manual + module calls `commentUpdate` and refetches via `Comment.base_fields/0`). + Not a widening of `Comment.Create` - create and update are different + mutations (`commentCreate` vs `commentUpdate`) with different required + identifiers (issue ID vs comment ID). +8. *`bin/lcomment` and `issue update --comment`/`issue status --comment` + are untouched.* They're the human/quick-comment path (always creates, + `-` always opens an editor) and remain exactly as they are. +9. *No subcommand alias in this phase.* The obvious short alias (`c`) is + already `issue create`'s; picking a different one isn't worth guessing + at before the command has seen real use (same reasoning as Phase 12 + deferring a `bin/` wrapper). +10. *Append by default, not replace.* When `--marker` finds an existing + comment, the given body is appended to it - `existing.body <> "\n\n" + <> body` - rather than overwriting it. This matches what + `global.md` already asked for before this phase ("Update it at each + milestone ... do not delete prior content") and means a caller + submits only its new section each time, not the whole accumulated + workpad. A new `--replace` flag opts into the old full-overwrite + behavior (`body` alone becomes the new comment body) for a caller + that genuinely wants to reset the comment. Plain create (no marker, + or marker with no match) is unaffected either way - there's nothing + to append to yet. + +== Verified + +* `schema/LinearAPI.graphql`: `commentUpdate(id: String!, input: + CommentUpdateInput!): CommentPayload!` (line 17924) takes the comment's + own `id`, separately from `commentCreate(input: CommentCreateInput!)` + (line 17891), which takes `issueId`. `CommentUpdateInput` (line 4181) + has a plain `body: String` field - no other required fields, so a + body-only update is a complete, valid input. +* `app/lib/linear_cli/linear/comment.ex`: `Comment.base_fields/0` already + selects `id` - every comment already parsed via `Comment.from_map/1` + (nested under an issue, or returned from `commentCreate`) carries the + `id` an update needs. No fragment change required. +* `app/lib/linear_cli/linear/issue.ex`: `Issue.Read.List`'s `issue(id:)` + per-id path (used whenever `Linear.issues/1` is called with `ids:`, as + `issue_update`/`issue_status` already do) fetches the *full* fragment, + which embeds `comments { nodes { #{Comment.base_fields()}} }` + (`full_fields/0`, line ~94). Resolving an issue this way for the new + subcommand gets its comments for free. +* `app/lib/linear_cli/linear/issue.ex` (`Issue.Update.Assign`/ + `AttachToProject`): the manual-update pattern to copy exactly - + `changeset.data` is the struct passed into `for_update`, arguments carry + only what's changing, the manual module builds the mutation input and + calls a shared `run/2`-style helper that refetches via the resource's + own field-selection function. +* `app/lib/linear_cli/cli/issue_helpers.ex` (`issue_comment/2`, + `update_issue/2`/`maybe_comment/2`/`dispatch_update/2`): confirms + `issue update --comment "text"` with no other flag is *already* + comment-only (the `opts[:comment] -> :ok` branch in `dispatch_update/2` + fires only after `close`/`cancel`/`pr`/`project`/`description` all miss, + so no extra `issueUpdate` mutation is sent) - there is no "plain create" + gap to fill, only "update an existing one" and "supply a large body + safely." +* `app/lib/linear_cli/cli/what_for.ex` (`ask_or_edit/3`, `comment_for/2`): + `thing && thing != "-"` returns `thing` unchanged - already idempotent + for a body that arrived fully resolved from `--body-file`, so routing + every body (file-sourced or not) through `comment_for/2` is safe and + keeps one resolution path. +* `app/lib/linear_cli/cli/commands.ex` (`project_update/1`): the house + pattern for a single-record "post something to Linear" command - + resolve inputs, call the domain interface, `Display.show(result, + %{output: options.output})`, `:ok`/`{:error, reason}` - is what + `issue_comment/1` will follow. +* `app/test/linear_cli/linear/comment_test.exs`: the + `Req.Test.stub(LinearCli.Api, ...)` + `Jason.decode!`-the-request-body + pattern used for `add_comment/2` is the direct template for + `update_comment`'s tests. +* Incident evidence: the two malformed EXT-17 comments (session + `55e18e70-2e53-437f-ad0d-722332190839`, lines 121 and 125) are a + single-quoted `REVIEW_BODY='...\n\n...'` bash string (literal + backslash-n, not a newline) and a `<<'ENDOFBODY'` *quoted* heredoc whose + `$WORKPAD_BODY` reference therefore never expanded - both are exactly + the shell-quoting failure modes `--body-file` (write the body to a real + file, reference it by path, no inline multi-line shell argument at all) + removes. + +== Building blocks + +* `app/lib/linear_cli/linear/comment.ex`: + ** `LinearCli.Linear.Comment`: new `update :update do argument :body, + :string, allow_nil?: false; manual LinearCli.Linear.Comment.Update + end` action. + ** New `LinearCli.Linear.Comment.Update` manual module (same file, + mirroring `Comment.Create`'s shape): calls + `commentUpdate(id: $id, input: { body: $body })` with + `changeset.data.id`, returns `{:ok, Comment.from_map(comment_map)}` + from the mutation's own `comment { ... }` refetch (using + `Comment.base_fields/0`), or `{:error, reason}`. +* `app/lib/linear_cli/linear.ex`: `Comment` resource gains `define + :update_comment, action: :update, args: [:body]` alongside the existing + `add_comment`. +* `app/lib/linear_cli/cli/issue_helpers.ex`: + ** New public `upsert_comment(issue, comment, marker \\ nil, opts \\ + [])`: resolves `body = WhatFor.comment_for(issue, comment)` + (unchanged resolution path), then looks for an existing match via a + new private `find_comment_by_marker/2` (`issue.comments |> + Enum.filter(&String.starts_with?(String.trim_leading(&1.body || ""), + marker)) |> List.last()`) whenever `marker` is truthy (`marker && + find_comment_by_marker(...)` naturally short-circuits to `nil` when + `marker` is `nil`, folding the "no marker" and "marker with no + match" cases into one branch); found -> `Linear.update_comment + (existing, new_body)`, where `new_body` is `body` alone when + `opts[:replace]` is truthy, otherwise `existing.body <> "\n\n" <> + body` (Decision 10 - append, not replace, by default); not found -> + `Linear.add_comment(issue.identifier, body)`. Each branch prints its + own `Prompt.ok(...)` ("Comment added to .../Comment updated on + ...") unless `opts[:quiet]` is truthy - found necessary during + implementation, see the `--output json` note below. Returns `{:ok, + comment} | {:error, reason}`. Does *not* delegate to `issue_comment/2` + (which stays untouched, still used as-is by `update`/`status`) + because `issue_comment/2` has no `opts`/quiet parameter to thread + through and adding one would be a pointless signature change for a + function this phase deliberately leaves alone. + ** `--output json` consistency, found during implementation, not + anticipated above: `LinearCli.CLI.Commands`'s existing `issue move` + command suppresses its own `Prompt.ok` confirmations under + `--output json` (`print_move_results/3`'s `when output != "json"` + guard) so piped JSON stays parseable without stripping a leading + human-readable line. `upsert_comment/4`'s `opts[:quiet]` is the same + idea; `Commands.issue_comment/1` passes `quiet: options.output == + "json"`. +* `app/lib/linear_cli/cli.ex`: + ** New `comment:` entry in the `issue` subcommand tree: `args: [issue_id: + [value_name: "ISSUE_ID", help: "The Issue (i.e. CRY-1)", required: + true]]`, `options: [comment: [short: "-m", long: "--comment", help: + "Comment text. - opens an editor"], marker: [short: "-k", long: + "--marker", help: "Find and update a comment starting with this text + instead of always creating a new one"], body_file: [long: + "--body-file", help: "Read the comment body from this file (- for + stdin) instead of --comment"]]`, `flags: [replace: [long: + "--replace", help: "Replace the matched comment's body instead of + appending to it (only with --marker)"]]`. + ** New `defp dispatch([:issue, :comment], result, halt), do: + run(&Commands.issue_comment/1, result, halt)` clause, alongside the + existing `:move`/`:status`/`:update` ones. +* `app/lib/linear_cli/cli/commands.ex`: new `issue_comment/1`: + . `--comment` and `--body-file` both given -> `{:error, {:smells_bad, + "give --comment or --body-file, not both"}}`, checked before anything + else. + . `--body-file` given: `"-"` reads `IO.read(:stdio, :eof)` (`:eof` on + empty input normalized to `""`; `IO.binread/2`, tried first, crashes + `ExUnit.CaptureIO`'s `StringIO`-backed device on this Elixir/OTP + version and is best avoided for that reason alone), anything else + `File.read/1`; a read error surfaces as `{:error, reason}` through + the normal `handle_error/3` path (no new error shape needed - + `File.read/1`'s `{:error, posix}` already round-trips fine as a + system-failure `reason`). + . resolve the single issue via `Linear.issues(%{ids: + [IssueHelpers.expand_issue_id(issue_id)]})` (same call `issue_update`/ + `issue_status` already make), erroring the same way on zero results. + . `IssueHelpers.upsert_comment(issue, resolved_body_or_nil, + options.marker, replace: flags.replace)`, then + `Display.show(comment, %{output: options.output})` on success. +* `app/lib/linear_cli/cli/display.ex`: found during implementation, not + anticipated above - `puts_text/2` (text-mode rendering) had no + `%Comment{}` clause at all (`Comment` was previously only ever shown + nested inside `issue ls --full`'s own rendering, never standalone), so + calling `Display.show` on a bare comment raised `FunctionClauseError`, + which `LinearCli.CLI.main/2`'s top-level `rescue` turns into a real + `System.halt/1` call - not a test-only problem, a genuine crash any + real invocation of this command would have hit. New clause: `IO.puts + (comment.url || "(no URL returned)")`, mirroring `ProjectUpdate`'s own + "link back to what was just posted" convention. JSON mode was already + fine (`to_plain/1`'s generic `%_struct{}` clause covers any resource). + +== Tests + +All in `app/test/linear_cli/linear/comment_test.exs` (domain layer), +`app/test/linear_cli/cli/issue_helpers_test.exs` (helper layer), and +`app/test/linear_cli/cli/issue_commands_test.exs` (full-command layer - +not a new `issue_comment_test.exs` file as first assumed above; every +other `Commands.issue_*` function's tests, including `issue_move`'s, live +in that one consolidated file, and `issue_comment` follows the same +convention), on the existing `Req.Test.stub(LinearCli.Api, ...)` harness. + +Domain (`comment_test.exs`, new `describe "update_comment/2"`): + +* Sends `id`/`body`, returns the updated comment via `base_fields` - + mirrors the existing `add_comment/2+` happy-path test exactly. +* Surfaces a GraphQL error as `{:error, %Ash.Error.Invalid{}}` (update + actions surface manual-action errors this way, not `Ash.Error.Unknown` + like `create` - confirmed against `assign_issue`/`attach_issue_to_project`'s + own existing error tests). + +Helper (`issue_helpers_test.exs`, new `describe "upsert_comment/4"`): +append-by-default, `replace: true`, marker-miss-creates, multiple-matches- +picks-the-last, and error-propagation cases - directly against the +function, no CLI/Optimus parsing involved (per Sequencing step 2, written +before any CLI wiring exists). + +Full-command (`issue_commands_test.exs`, new `describe "issue comment"`): + +* No `--marker`: always sends `commentCreate`, never fetches comments + first beyond the normal issue resolution; stdout shows "Comment added + to ...". +* `--marker` with no existing match: issue's stubbed comments list has + none starting with the marker -> `commentCreate` is sent, not + `commentUpdate`. +* `--marker` with one existing match, no `--replace`: `commentUpdate` is + sent with that comment's `id` and `\n\n`; + `commentCreate` is never sent; stdout shows "Comment updated on ...". +* `--marker` with one existing match and `--replace`: `commentUpdate` is + sent with the new body alone (old body is not present anywhere in the + request). +* `--marker` with two matching comments (out-of-band duplicates): the + *later* one's `id` is the one sent to `commentUpdate`, and its body + (not the earlier match's) is what gets appended to/replaced. +* `--body-file `: file contents (including embedded literal + backslash-n or `$VAR`-looking text, proving no shell ever touches the + content) become the comment body verbatim. +* `--body-file -`: stdin contents become the body verbatim. +* `--comment` and `--body-file` together: no GraphQL call is sent at all; + `{:error, {:smells_bad, _}}` surfaces through `handle_error/3`'s exit + path. +* Unreadable `--body-file` path: `{:error, reason}` surfaces the same way, + no GraphQL call is sent. +* `--output json` on a create and on an update: JSON is the comment map + in both cases, with no `Prompt.ok` confirmation text mixed into stdout + (`opts[:quiet]`, matching `issue move`'s own `--output json` behavior). + +== Docs + +* `AGENTS.md`: "The Plan" index gains the Phase 13 entry (this document); + "Rules for Interacting with Linear" gains `mix lc issue comment --help` + alongside the existing example commands. +* `documents/ash-domain-erd.adoc`: "Action and code-interface matrix" + gains one row - `Comment` / `update_comment` / `:update` / update / + `Linear.Comment.Update` / `commentUpdate(id:, input: { body })` mutation + - directly under the existing `add_comment` row. No attribute or + association changes (Comment's shape is unchanged). +* `.ai/prompts/global.md`: rewrite the "Linear workpad" section to give + the actual command instead of an unactionable rule, resolving the + contradiction this phase exists to fix: ++ +[source,markdown] +---- +## Linear workpad + +Use a single, persistent Linear comment as a workpad: + + mix lc issue comment --marker "## Workpad" --body-file + +- Write the file with just your new section for this milestone (title + line included the first time, e.g. `## Workpad`) - never build a + multi-line comment as an inline shell argument. +- The marker ("## Workpad") must stay exactly the same every time; the + content underneath it can change freely. The command finds the + existing workpad comment and appends your new section to it, or + creates it on first use - you do not need to re-read or re-submit + prior content yourself. +- Pass `--replace` only if you deliberately want to discard everything + written so far and start the workpad over. +- Never call the Linear API directly (curl, GraphQL, or otherwise) - + always use `mix lc`. +---- +* `Readme.adoc`: `issue` command table gains a `comment` row (no alias, + per Decision 9); a short example block under the existing `issue + update`/`issue status` examples showing plain create, `--marker` upsert + (append, the default), `--replace`, and `--body-file`. + +== Sequencing + +No dependency on unlanded work. + +1. `Comment.update` action + `Linear.Comment.Update` manual module + + `update_comment` domain interface, with its two domain-layer tests. +2. `IssueHelpers.upsert_comment/3` + `find_comment_by_marker/2`, unit + tested directly (stub-issue with a few comments, marker hit/miss/ + duplicate cases) before any CLI wiring exists. +3. Optimus `issue comment` spec, `dispatch/3` clause, and + `Commands.issue_comment/1` (body-file/mutual-exclusion handling first, + then the create/upsert paths) + the full CLI test file. +4. `AGENTS.md`, `documents/ash-domain-erd.adoc`, `.ai/prompts/global.md`, + `Readme.adoc`. +5. Manual end-to-end verification against live Linear (dogfooding, per + `app/usage-rules.md`): create via `--marker` on an issue with no prior + match, run it again with a new section and confirm the same comment + grows in place (no second comment appears, prior content still + present), a `--replace` run and confirm the prior content is gone, + `--body-file -` piped from a multi-line heredoc containing literal + `\n` text and a `$VAR`-looking string to confirm both survive + verbatim, then point a real Stokowski `code-review`/`merge-review` run + at the rewritten `global.md` workpad instructions and confirm the + agent uses `mix lc issue comment` instead of hand-rolling `curl`. ++ +[NOTE] +==== +Attempted against `EXT-17` in this environment: `mix lc issue comment +EXT-17 -m "..." -k "## Workpad"` correctly resolved the issue, found +`EXT-17`'s existing malformed "Workpad — Code review run 4" comment as a +marker match (it does start with `## Workpad`, confirming Decision 3's +plain-prefix match works exactly as designed even against messy +real-world data), and attempted the `commentUpdate` mutation - which +Linear rejected with `403 FORBIDDEN: Invalid scope: 'write' required`. +This sandbox's `LINEAR_API_KEY` is read-only (`mix lc whoami` succeeds; +any mutation does not) - a credential/environment limitation, not a code +defect. Full live-write verification is still outstanding and needs a +write-scoped key. +==== + +Standard workflow from here: file a GitHub issue for this phase, branch +from it, commit, open the PR - no direct-to-main commits. Conventional +commit type: `feat`. From 555e30942d3f52b1925987c3fc0261f8dbea0cd4 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Mon, 31 Aug 2026 20:33:53 -0400 Subject: [PATCH 4/6] fix(stokowski): stop routing the claude runner through a local qwen model mise.toml pointed ANTHROPIC_BASE_URL at a local lemonade proxy and remapped every Claude model alias (Opus/Sonnet/Haiku/subagent) to user.Qwen3.6-35B-A3B-ThinkingCoder, so every "claude" runner turn was silently served by that local model instead of real Claude. Comment out the override and switch the active workflow.yaml symlink to workflow.opus.yaml so the claude runner uses real Anthropic models. --- mise.toml | 18 +-- workflow.opus.yaml | 267 +++++++++++++++++++++++++++++++++++++++++++++ workflow.yaml | 2 +- 3 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 workflow.opus.yaml diff --git a/mise.toml b/mise.toml index c486c1a..5e5a88a 100644 --- a/mise.toml +++ b/mise.toml @@ -8,12 +8,12 @@ erlang = "29.0.3" elixir = "1.20.3" python = "3.14.1" [env] -ANTHROPIC_BASE_URL = "http://127.0.0.1:13305" -ANTHROPIC_AUTH_TOKEN = "lemonade" -LEMONADE_API_KEY = "lemonade" -ANTHROPIC_DEFAULT_OPUS_MODEL = "user.Qwen3.6-35B-A3B-ThinkingCoder" -ANTHROPIC_DEFAULT_SONNET_MODEL = "user.Qwen3.6-35B-A3B-ThinkingCoder" -ANTHROPIC_DEFAULT_HAIKU_MODEL = "user.Qwen3.6-35B-A3B-ThinkingCoder" -CLAUDE_CODE_SUBAGENT_MODEL = "user.Qwen3.6-35B-A3B-ThinkingCoder" -CLAUDE_CODE_ATTRIBUTION_HEADER = 0 -CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = 1 +#ANTHROPIC_BASE_URL = "http://127.0.0.1:13305" +#ANTHROPIC_AUTH_TOKEN = "lemonade" +#LEMONADE_API_KEY = "lemonade" +#ANTHROPIC_DEFAULT_OPUS_MODEL = "user.Qwen3.6-35B-A3B-ThinkingCoder" +#ANTHROPIC_DEFAULT_SONNET_MODEL = "user.Qwen3.6-35B-A3B-ThinkingCoder" +#ANTHROPIC_DEFAULT_HAIKU_MODEL = "user.Qwen3.6-35B-A3B-ThinkingCoder" +#CLAUDE_CODE_SUBAGENT_MODEL = "user.Qwen3.6-35B-A3B-ThinkingCoder" +#CLAUDE_CODE_ATTRIBUTION_HEADER = 0 +#CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = 1 diff --git a/workflow.opus.yaml b/workflow.opus.yaml new file mode 100644 index 0000000..dcefed7 --- /dev/null +++ b/workflow.opus.yaml @@ -0,0 +1,267 @@ +# ============================================================================= +# Stokowski — Example State Machine Workflow +# ============================================================================= +# +# This is linear-cli's own checked-in workflow config, not a template to +# copy elsewhere. Stokowski re-reads this file on every poll tick — changes +# take effect without restart. +# +# Prompt files live in the .ai/prompts/ directory. Each agent state +# references its prompt via the `prompt` field. A global prompt (shared +# preamble) is injected before the stage-specific prompt when set. +# +# Linear states mapping: +# active → the "working" state for agent stages +# review → human review / PR review state +# gate_approved → human approved a gate (triggers transition) +# rework → human requested changes (triggers rework_to) +# terminal → list of done/closed states +# +# State types: +# agent — Stokowski dispatches Claude Code in this state +# gate — pauses for human review; approve → next, rework → rework_to +# terminal — issue is finished; workspace cleaned up +# +# ----------------------------------------------------------------------------- +# Multi-project mode (optional — single-project YAML below still works as-is) +# ----------------------------------------------------------------------------- +# +# To monitor multiple Linear projects from one Stokowski process, replace the +# top-level `tracker / workspace / hooks / prompts / states` blocks with a +# `projects:` list. Each entry has its own tracker/workspace/hooks/prompts/ +# state machine. Top-level `polling`, `agent`, `claude`, `linear_states`, and +# `server` stay global and act as defaults each project can override. +# +# agent: +# max_concurrent_agents: 8 # global pool across all projects +# max_concurrent_per_project: # optional fairness caps +# synced-sport: 6 +# client-site: 2 +# +# projects: +# - name: synced-sport # required, used in dashboard + `p` menu +# tracker: +# kind: linear +# project_slug: "abc123def456" +# api_key: "$LINEAR_API_KEY" +# workspace: +# root: ~/.local/share/stokowski/workspaces/synced-sport +# hooks: { after_create: "git clone git@github.com:org/synced-sport.git ." } +# prompts: { global_prompt: prompts/synced-sport/global.md } +# states: { ... } # state machine, same as single-project +# +# - name: client-site +# paused: true # start paused; toggle with `p` key +# tracker: +# kind: linear +# project_slug: "def456abc789" +# api_key: "$LINEAR_API_KEY" +# workspace: +# root: ~/.local/share/stokowski/workspaces/client-site +# hooks: { after_create: "git clone git@github.com:org/client-site.git ." } +# prompts: { global_prompt: prompts/client-site/global.md } +# claude: { model: claude-opus-4-7 } # per-project override +# max_concurrent: 2 # per-project cap (also via agent map above) +# states: { ... } +# +# In the terminal, `p` opens a numbered pause menu; in the web dashboard, each +# project tile gets a Pause/Resume button and a per-project filter dropdown. +# ============================================================================= + +# --------------------------------------------------------------------------- +# Tracker +# --------------------------------------------------------------------------- +tracker: + kind: linear + project_slug: "94a76f2ac65f" # hex slugId from your Linear project URL + # api_key: "$LINEAR_API_KEY" # your Linear API key — agents inherit this + assignee: me # only issues assigned to the API key's user + +# --------------------------------------------------------------------------- +# Linear state name mapping +# --------------------------------------------------------------------------- +# These map Stokowski's internal lifecycle roles to your Linear state names. +# You can rename values to match your Linear setup (e.g. todo: "Ready"), +# but all six roles are required — removing or reordering them will break +# the dispatch and gate protocol. +linear_states: + todo: "Todo" # issues picked up from this state + active: "In Progress" # moved here automatically when agent starts + review: "Human Review" # agent pauses here at a gate for human review + gate_approved: "Gate Approved" # human approved — agent advances to next state + rework: "Rework" # human requested changes — agent re-enters rework target + terminal: # issues in these states stop any running agent + - Done + - Closed + - Cancelled + - Canceled + - Duplicate + +# --------------------------------------------------------------------------- +# Polling +# --------------------------------------------------------------------------- +polling: + interval_ms: 60000 # 15 seconds between ticks + +# --------------------------------------------------------------------------- +# Workspace +# --------------------------------------------------------------------------- +workspace: + root: ~/.local/share/stokowski/workspaces/linear-cli + +# --------------------------------------------------------------------------- +# Hooks — shell scripts run in the workspace directory +# --------------------------------------------------------------------------- +hooks: + after_create: | + git clone --depth 1 --recursive git@github.com:rubyists/linear-cli . + mix setup + before_run: | + set -eu + git fetch origin main + changes="$(git status --porcelain)" + if [ -n "$changes" ]; then + echo "before_run: preserving uncommitted workspace changes; skipping rebase" + else + git rebase origin/main + fi + timeout_ms: 120000 + +# --------------------------------------------------------------------------- +# Claude defaults (inherited by all agent states unless overridden) +# States using runner: codex will use the Codex CLI instead. +# --------------------------------------------------------------------------- +claude: + permission_mode: auto + model: claude-sonnet-4-6 + max_turns: 20 + turn_timeout_ms: 3600000 # 1 hour per turn + stall_timeout_ms: 300000 # 5 minutes with no output → kill + +# --------------------------------------------------------------------------- +# Agent concurrency +# --------------------------------------------------------------------------- +agent: + max_concurrent_agents: 4 + max_retry_backoff_ms: 300000 # 5-minute cap on exponential backoff + max_concurrent_agents_by_state: + investigate: 2 # allow 2 concurrent investigations + implement: 2 + code-review: 1 # serial reviews prevent merge conflicts + +# --------------------------------------------------------------------------- +# Prompts +# --------------------------------------------------------------------------- +prompts: + global_prompt: .ai/prompts/global.md + +# --------------------------------------------------------------------------- +# Server (optional web dashboard) +# --------------------------------------------------------------------------- +server: + host: 127.0.0.1 + port: 4200 + +# --------------------------------------------------------------------------- +# State machine +# --------------------------------------------------------------------------- +# +# Order matters: the first agent state is the entry state for new issues. +# +# Transitions fire automatically on agent success or human action: +# - Agent finishes successfully → "complete" transition +# - Human approves a gate → "approve" transition +# - Human requests rework → rework_to target +# +states: + + # ── 1. Investigate ────────────────────────────────────────────────────── + # Read the codebase, understand the problem, post an investigation summary. + # Uses Opus for deeper reasoning. Low turn count — investigation only. + investigate: + type: agent + prompt: .ai/prompts/investigate.md + linear_state: active + model: claude-opus-4-6 + max_turns: 8 + session: inherit + transitions: + complete: research-review + + # ── 2. Research Review (gate) ─────────────────────────────────────────── + # Human reviews the investigation before implementation starts. + # Rework loops back to investigate. + research-review: + type: gate + linear_state: review + rework_to: investigate + max_rework: 3 + transitions: + approve: implement + + # ── 3. Implement ──────────────────────────────────────────────────────── + # Write code, create branch, run tests, open PR. + # Uses Sonnet for fast iteration. Higher turn count for complex work. + implement: + type: agent + prompt: .ai/prompts/implement.md + linear_state: active + model: claude-sonnet-4-6 + max_turns: 30 + session: inherit + transitions: + complete: implementation-review + + # ── 4. Implementation Review (gate) ───────────────────────────────────── + # Human reviews the PR before automated code review. + # Rework loops back to implement. + implementation-review: + type: gate + linear_state: review + rework_to: implement + max_rework: 5 + transitions: + approve: code-review + + # ── 5. Code Review ───────────────────────────────────────────────────── + # Automated adversarial review: fresh session, no prior context. + # Opus reviews the diff and posts findings. + # Tip: set runner: codex here to get a second-opinion from a different + # provider — different states can use different runners in the same run. + code-review: + type: agent + prompt: .ai/prompts/review.md + linear_state: active + runner: claude # "claude" (default) or "codex" + model: claude-opus-4-6 + max_turns: 10 + session: fresh # no prior context — adversarial review + transitions: + complete: merge-review + + # ── 6. Merge Review (gate) ────────────────────────────────────────────── + # Human reviews the code review findings and decides to merge or rework. + # Rework goes back to implement (not code-review). + merge-review: + type: gate + linear_state: review + rework_to: implement + max_rework: 5 + transitions: + approve: merge + + # ── 7. Merge ──────────────────────────────────────────────────────────── + # Merge the PR and clean up. Short turn count — just the merge. + merge: + type: agent + prompt: .ai/prompts/merge.md + linear_state: active + max_turns: 5 + session: inherit + transitions: + complete: done + + # ── 8. Done (terminal) ───────────────────────────────────────────────── + done: + type: terminal + linear_state: terminal diff --git a/workflow.yaml b/workflow.yaml index b911943..9791530 120000 --- a/workflow.yaml +++ b/workflow.yaml @@ -1 +1 @@ -workflow.claude.yaml \ No newline at end of file +workflow.opus.yaml \ No newline at end of file From 1a312f17ccc884c4bf316f4f46f532adc9f413f3 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Mon, 31 Aug 2026 20:34:17 -0400 Subject: [PATCH 5/6] feat(issue): add lc issue comment with --body-file for safe multi-line bodies Add a new `lc issue comment ISSUE_ID [-m TEXT] [--body-file PATH]` subcommand. --body-file (- for stdin) lets a caller supply a large multi-line comment body without building it as a single shell argument - closing the exact bug class that produced two malformed EXT-17 comments (a literal backslash-n instead of a real newline, and an unexpanded variable inside a quoted heredoc), both from an agent that had no safe `mix lc` way to post a multi-line comment and hand-rolled curl/GraphQL instead. Also fixes a latent crash: Display.puts_text/2 had no text-mode clause for a bare Comment, so showing one raised FunctionClauseError straight into a real System.halt/1. Update the per-stage agent prompts (global.md, investigate.md, implement.md, merge.md) to post one Linear comment per milestone via this command instead of maintaining a single "workpad" comment - an earlier design in documents/phase-13-plan.adoc that upserted a comment by marker match, dropped after live testing showed commentUpdate needs a scope this project's Linear key doesn't reliably carry, and matching a comment by string prefix is inherently indeterminate. --- .ai/prompts/global.md | 24 +- .ai/prompts/implement.md | 6 +- .ai/prompts/investigate.md | 5 +- .ai/prompts/merge.md | 8 +- .ai/prompts/review.md | 1 + AGENTS.md | 3 + Readme.adoc | 17 + app/lib/linear_cli/cli.ex | 22 + app/lib/linear_cli/cli/commands.ex | 49 ++ app/lib/linear_cli/cli/display.ex | 11 +- .../linear_cli/cli/issue_commands_test.exs | 123 +++++ documents/phase-13-plan.adoc | 457 +++++++----------- 12 files changed, 419 insertions(+), 307 deletions(-) diff --git a/.ai/prompts/global.md b/.ai/prompts/global.md index 88d34d7..83469e9 100644 --- a/.ai/prompts/global.md +++ b/.ai/prompts/global.md @@ -27,13 +27,24 @@ Before starting any implementation work: 2. Run the project's test command to verify all tests pass. 3. If either fails, investigate and fix before starting new work. -## Linear workpad +## Linear progress updates -Use a single Linear comment as a persistent workpad: +Post a new Linear comment for each milestone of your work — investigation +findings, implementation decisions, results, guidance for the next +stage, and so on. Do not try to maintain or find a single running +comment to update: -- Title: `## Workpad` -- Update it at each milestone with: current status, decisions made, and next steps. -- On rework runs, append the rework section — do not delete prior content. + mix lc issue comment --body-file + +- Write the comment's full content to a file first, then pass its + path — never build a multi-line comment as an inline shell argument. +- Each comment should stand on its own: describe only this step's + findings, decisions, and results, not the whole history. Read prior + comments for context (`mix lc issue ls --full `); post a new + one for what's new, don't try to edit an old one. +- Always only use `mix lc` to interact with Linear — never call the + Linear API directly (curl, GraphQL, or otherwise). If `mix lc` is + broken, log that error and stop processing. ## Rework awareness @@ -43,4 +54,5 @@ On rework runs, the workspace already contains prior work. Check for: - An existing feature branch (do not create a new one) - An open PR (push to it, do not open a second) - Review comments requesting changes (address them specifically) -- Prior workpad content (append to it, do not overwrite) +- Prior progress comments (read them for context; post a new comment for + this run rather than editing an old one) diff --git a/.ai/prompts/implement.md b/.ai/prompts/implement.md index 3c552b4..7ca545a 100644 --- a/.ai/prompts/implement.md +++ b/.ai/prompts/implement.md @@ -41,7 +41,7 @@ stop and note that on the linear issue, do not create an unsigned commit. gh pr create --title "{{ issue.identifier }}: " --body "" ``` 8. Link the PR to the Linear issue. -9. Update the workpad with: what was done, what was tested, any known limitations. +9. Post a Linear comment with: what was done, what was tested, any known limitations. ## Rework run @@ -62,7 +62,7 @@ If this is a rework run (a branch and PR already exist): - Which review comments were addressed - What was modified - Any decisions or trade-offs -7. Append a rework section to the Linear workpad. +7. Post a Linear comment summarising the rework. ## Quality bar @@ -73,4 +73,4 @@ Before finishing, verify: - [ ] No lint errors - [ ] All acceptance criteria from the ticket description met - [ ] PR created (or updated) and linked to Linear issue -- [ ] Workpad updated with completion summary +- [ ] Linear comment posted with a completion summary diff --git a/.ai/prompts/investigate.md b/.ai/prompts/investigate.md index fed5f76..6e0d85a 100644 --- a/.ai/prompts/investigate.md +++ b/.ai/prompts/investigate.md @@ -31,7 +31,6 @@ an investigation summary posted as a Linear comment — not code changes. - **Risks or open questions** - **Proposed approach** (high-level, 3-5 bullet points) 6. Post the summary as a Linear comment titled `## Investigation`. -7. Update the workpad with investigation status. ## Rework run @@ -41,8 +40,8 @@ If this is a rework run (the workspace already has investigation content): 2. Read your prior investigation summary. 3. Address the specific feedback — expand analysis, correct mistakes, or investigate additional areas as requested. -4. Update the `## Investigation` comment with revised findings. -5. Append a rework note to the workpad. +4. Post a new Linear comment titled `## Investigation (rework)` with the + revised findings — do not try to edit the prior comment. ## Do NOT diff --git a/.ai/prompts/merge.md b/.ai/prompts/merge.md index 7ae87ca..0f6053b 100644 --- a/.ai/prompts/merge.md +++ b/.ai/prompts/merge.md @@ -26,8 +26,9 @@ mechanical stage — no new code changes. ``` gh pr merge --squash --delete-branch ``` -5. Update the Linear workpad with the merge confirmation. -6. Move the Linear issue to `Done`. +5. Always use `mix lc issue` to interact with Linear issues +6. Post a Linear comment with the merge confirmation. +7. Move the Linear issue to `Done`. ## Rework run @@ -43,10 +44,11 @@ If this is a rework run (merge was attempted before but failed): - If it is a test failure caused by the PR's changes, post details to Linear and stop (this needs to go back to implementation). - If it is a flaky or infrastructure issue, re-run and retry the merge. -4. Update the workpad with what happened. +4. Post a Linear comment with what happened. ## Do NOT - Make code changes beyond conflict resolution. - Open new PRs. - Skip CI checks. +- Use anything other than `mix lc` to interact with Linear diff --git a/.ai/prompts/review.md b/.ai/prompts/review.md index 5b78c04..00aa7ea 100644 --- a/.ai/prompts/review.md +++ b/.ai/prompts/review.md @@ -39,6 +39,7 @@ the implementer missed — not to rubber-stamp the PR. - Linting - Tests 6. Post your review as a Linear comment titled `## Code Review`: + - Always only use `mix lc issue` to interact with Linear - List issues found (critical, major, minor) - Note anything that looks good - Give an overall assessment: approve, request changes, or flag concerns diff --git a/AGENTS.md b/AGENTS.md index 23e4627..8679014 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,8 @@ and best practices for agents to follow. - Phase 9 Plan: documents/phase-9-plan.adoc - Phase 10 Plan: documents/phase-10-plan.adoc - Phase 11 Plan: documents/phase-11-plan.adoc +- Phase 12 Plan: documents/phase-12-plan.adoc +- Phase 13 Plan: documents/phase-13-plan.adoc ## Project Structure @@ -33,6 +35,7 @@ mcp or any other way to interact with issues or the project. `mix lc issue list --help` `mix lc issue create --help` `mix lc issue status --help` +`mix lc issue comment --help` This should be enough to get started diff --git a/Readme.adoc b/Readme.adoc index 5c01927..517ad81 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -160,6 +160,7 @@ so you don't have to type the full name every time: |`issue assign` |`a` |`issue move` |`m`, `mv` |`issue update` |`u` +|`issue comment` | |`issue pr` |`pull-request` |`team list` |`l`, `ls` |`project list` |`l`, `ls` @@ -289,6 +290,22 @@ $ lcomment CRY-1234 CRY-3 <5> <4> Opens your editor for the comment (use `-` to prompt) <5> Always prompts for a comment (`lcomment` is a wrapper for `lc issue update --comment -`) +===== Add a comment from a file (`issue comment`) + +Same plain create as `issue update --comment`/`lcomment`, plus +`--body-file` — the way to supply a large multi-line body without +building it as a single shell argument. + +[source,sh] +---- +$ lc issue comment CRY-1234 -m "Here is a comment" <1> +$ lc issue comment CRY-1234 --body-file notes.md <2> +$ lc issue comment CRY-1234 --body-file - <3> +---- +<1> Plain create, same as `issue update --comment` +<2> Reads the body from a file +<3> Reads the body from stdin + ===== Close one or many issues Use `--status`/`-s` to choose the completed or cancelled workflow state by diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 0a4cc87..756683b 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -212,6 +212,10 @@ defmodule LinearCli.CLI do defp dispatch([:issue, :pr], result, halt), do: run(&Commands.issue_pr/1, result, halt) defp dispatch([:issue, :move], result, halt), do: run(&Commands.issue_move/1, result, halt) + + defp dispatch([:issue, :comment], result, halt), + do: run(&Commands.issue_comment/1, result, halt) + defp dispatch([:issue, :take], result, halt), do: run(&Commands.issue_take/1, result, halt) defp dispatch([:issue, :status], result, halt), do: run(&Commands.issue_status/1, result, halt) defp dispatch([:issue, :update], result, halt), do: run(&Commands.issue_update/1, result, halt) @@ -738,6 +742,24 @@ defmodule LinearCli.CLI do description: [long: "--description", help: "The description of the PR"] ] ], + comment: [ + name: "comment", + about: "Add a comment to an issue", + args: [ + issue_id: [value_name: "ISSUE_ID", help: "The Issue (i.e. CRY-1)", required: true] + ], + options: [ + comment: [ + short: "-m", + long: "--comment", + help: "Comment text. - opens an editor" + ], + body_file: [ + long: "--body-file", + help: "Read the comment body from this file (- for stdin) instead of --comment" + ] + ] + ], status: [ name: "status", about: "Change workflow state (ISSUE_ID...)", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 45204fa..8bf1917 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -519,6 +519,55 @@ defmodule LinearCli.CLI.Commands do end end + @doc """ + Adds a comment to a single issue. + + `--comment`/`-m` and `--body-file` are mutually exclusive. `--body-file` + reads the body from a file (`-` for stdin) - the way to supply a large + multi-line body without building it as a single shell argument, which + is what `--comment`, going through + `LinearCli.CLI.WhatFor.comment_for/2`'s prompt/editor resolution, does + not protect against. Without either option, `comment_for/2`'s existing + behavior applies (prompt, or open an editor for `-`). + + Calls `Linear.add_comment/2` directly rather than + `LinearCli.CLI.IssueHelpers.issue_comment/2` so the confirmation can be + suppressed under `--output json` - matching how `print_move_results/3` + suppresses its own confirmation for `issue move --output json`. + + New in this port - Ruby has no equivalent. + """ + @spec issue_comment(Optimus.ParseResult.t()) :: :ok | {:error, term()} + def issue_comment(%{args: %{issue_id: issue_id}, options: options}) do + with :ok <- validate_comment_options(options), + {:ok, comment_text} <- resolve_comment_body(options), + {:ok, [issue]} <- Linear.issues(%{ids: [IssueHelpers.expand_issue_id(issue_id)]}), + body = WhatFor.comment_for(issue, comment_text), + {:ok, comment} <- Linear.add_comment(issue.identifier, body) do + unless options.output == "json", do: Prompt.ok("Comment added to #{issue.identifier}") + Display.show(comment, %{output: options.output}) + :ok + end + end + + defp validate_comment_options(%{comment: comment, body_file: body_file}) + when not is_nil(comment) and not is_nil(body_file) do + {:error, {:smells_bad, "give --comment or --body-file, not both"}} + end + + defp validate_comment_options(_options), do: :ok + + defp resolve_comment_body(%{body_file: nil, comment: comment}), do: {:ok, comment} + defp resolve_comment_body(%{body_file: "-"}), do: {:ok, read_stdin()} + defp resolve_comment_body(%{body_file: path}), do: File.read(path) + + defp read_stdin do + case IO.read(:stdio, :eof) do + :eof -> "" + data -> data + end + end + @doc """ Moves issues to a target project. diff --git a/app/lib/linear_cli/cli/display.ex b/app/lib/linear_cli/cli/display.ex index d5a8538..62f3415 100644 --- a/app/lib/linear_cli/cli/display.ex +++ b/app/lib/linear_cli/cli/display.ex @@ -7,7 +7,7 @@ defmodule LinearCli.CLI.Display do own `#to_s`/`#full`/`#display` methods. """ - alias LinearCli.Linear.{Issue, Project, ProjectUpdate, Team, User} + alias LinearCli.Linear.{Comment, Issue, Project, ProjectUpdate, Team, User} alias LinearCli.Profiles.Profile @ash_internal_fields ~w(__meta__ __metadata__ __order__ __lateral_join_source__ aggregates calculations)a @@ -37,6 +37,15 @@ defmodule LinearCli.CLI.Display do IO.puts("Posted#{health}: #{update.url}") end + # New in this port - Ruby has no equivalent (no bare `Comment` command + # existed to display one). `LinearCli.CLI.IssueHelpers.issue_comment/2`/ + # `upsert_comment/4` already print a "Comment added to.../updated on..." + # confirmation via `Prompt.ok/1` before this runs, so this only needs to + # add the one thing that isn't in that line: a link to the comment. + defp puts_text(%Comment{} = comment, _opts) do + IO.puts(comment.url || "(no URL returned)") + end + defp puts_text(%Profile{} = profile, _opts) do marker = if profile.active, do: "* ", else: " " diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 8dcc1d3..ff0f087 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -3029,4 +3029,127 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert output =~ "--from and --to must both be given" end end + + describe "issue comment" do + defp stub_lookup_and(pairs) do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + match = Enum.find(pairs, fn {substr, _resp} -> String.contains?(query, substr) end) -> + {_substr, resp} = match + Req.Test.json(conn, (is_function(resp, 1) && resp.(decoded)) || resp) + + true -> + raise "no stub matched query: #{query}" + end + end) + end + + test "creates a new comment" do + stub_lookup_and([{"commentCreate", comment_created()}]) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "comment", "CRY-1", "-m", "lgtm"]) + end) + + assert output =~ "Comment added to CRY-1" + end + + test "--body-file reads the body from a file verbatim" do + path = tmp_path("body_file") + # Deliberately includes a literal backslash-n and a $VAR-looking string - + # exactly the content that broke when built as an inline shell argument + # (see documents/phase-13-plan.adoc's Goal section). + File.write!(path, "## Investigation\n\nliteral \\n and $SOME_VAR survive verbatim") + on_exit(fn -> File.rm(path) end) + + test_pid = self() + + stub_lookup_and([ + {"commentCreate", + fn decoded -> + send(test_pid, {:sent_body, decoded["variables"]["body"]}) + comment_created() + end} + ]) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "comment", "CRY-1", "--body-file", path]) + end) + + assert_received {:sent_body, + "## Investigation\n\nliteral \\n and $SOME_VAR survive verbatim"} + end + + test "--body-file - reads the body from stdin verbatim" do + test_pid = self() + + stub_lookup_and([ + {"commentCreate", + fn decoded -> + send(test_pid, {:sent_body, decoded["variables"]["body"]}) + comment_created() + end} + ]) + + capture_io("piped from stdin\nwith a real newline", fn -> + assert :ok = LinearCli.CLI.main(["issue", "comment", "CRY-1", "--body-file", "-"]) + end) + + assert_received {:sent_body, "piped from stdin\nwith a real newline"} + end + + test "--comment and --body-file together is a smells_bad error, no GraphQL call" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main( + ["issue", "comment", "CRY-1", "-m", "text", "--body-file", "somefile"], + halt + ) + end) + + assert_received {:halted, 22} + assert output =~ "give --comment or --body-file, not both" + end + + test "an unreadable --body-file surfaces an error, no GraphQL call" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + capture_io(:stderr, fn -> + LinearCli.CLI.main( + ["issue", "comment", "CRY-1", "--body-file", "/nonexistent/path/does-not-exist"], + halt + ) + end) + + assert_received {:halted, _code} + end + + test "--output json prints the resulting comment as JSON" do + stub_lookup_and([{"commentCreate", comment_created()}]) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "comment", "CRY-1", "-m", "lgtm", "-o", "json"]) + end) + + assert %{"id" => "c1"} = Jason.decode!(output) + end + end end diff --git a/documents/phase-13-plan.adoc b/documents/phase-13-plan.adoc index 5aeab97..c773d7b 100644 --- a/documents/phase-13-plan.adoc +++ b/documents/phase-13-plan.adoc @@ -1,7 +1,7 @@ = {my-title} Tj Vanderpoel (bougyman) :revdate: Aug 31, 2026 -:my-title: Phase 13 plan: persistent comment upsert for Stokowski (`lc issue comment`) +:my-title: Phase 13 plan: safe multi-line comments for Stokowski (`lc issue comment`) :icons: font :env-github: ifdef::env-github[] @@ -13,152 +13,117 @@ ifdef::env-github[] endif::[] :toc: +[NOTE] +==== +Revision note: this phase originally shipped a find-or-update-by-marker +comment *upsert* (a single persistent "workpad" comment, appended to on +every call). That design is gone. Live verification turned up that +`commentUpdate` needs a scope this project's Linear API key doesn't +reliably carry, distinct from `commentCreate` (see Verified below) - and +separately, matching an existing comment by "body starts with a string" +is exactly the kind of fuzzy, indeterminate lookup that caused problems +in the first place (User decision, 2026-08-31). The guidance and the +command are both simpler now: post a new comment per milestone, never +update one. Everything below describes the *current*, shipped design; +the upsert history lives only in this note and in git history. +==== + == Goal `AGENTS.md` says "Always only use `mix lc` to interact with linear.app. -Do not look for an mcp or any other way." `.ai/prompts/global.md` says every -agent must maintain "a single Linear comment as a persistent workpad ... -Update it at each milestone ... do not delete prior content." Those two -rules currently contradict each other: `mix lc` has no way to update an -existing comment, or to find one to update in the first place. The only -comment primitive today is `add_comment`/`commentCreate` - every call -creates a brand-new comment. +Do not look for an mcp or any other way." That rule is right, but nothing +in `mix lc` made it easy to follow for a large, multi-line comment body - +the only option was `--comment "text"` (a single shell argument) or +`--comment -` (an interactive editor, useless headless). -This gap is not theoretical. On EXT-17's `code-review` run 4, the -review agent (real Claude Opus, not a degraded model - see the session +This gap is not theoretical. On EXT-17's `code-review` run 4, the review +agent (real Claude Opus, not a degraded model - see the session transcript at `~/.claude/projects/-home-bougybot--local-share-stokowski-workspaces-linear-cli-EXT-17/55e18e70-*.jsonl`, -lines 121 and 125) needed to post a persistent workpad update and, finding -no `mix lc` way to do it, hand-rolled a direct `curl`/GraphQL call instead. -That ad hoc script had two bugs of its own (a single-quoted bash string -with literal `\n` instead of real newlines, and a variable reference inside -a *quoted* heredoc delimiter that never expanded), which is exactly the -class of failure "always use `mix lc`" exists to prevent. The rule is -right; the tooling just doesn't back it up yet. +lines 121 and 125) needed to post a multi-line status comment and, finding +no safe `mix lc` way to do it, hand-rolled a direct `curl`/GraphQL call +instead. That ad hoc script had two bugs of its own (a single-quoted bash +string with literal `\n` instead of real newlines, and a variable +reference inside a *quoted* heredoc delimiter that never expanded) - +exactly the class of failure "always use `mix lc`" exists to prevent. -This phase adds exactly the missing primitive - find-or-update-by-marker -comment upsert, plus a way to supply a large multi-line body without -building it as a single shell argument - as a new `lc issue comment` -subcommand. It does not touch `issue update --comment`/`issue status ---comment` (plain create, already correct and well-tested) or -`bin/lcomment` (a human-interactive wrapper, out of scope here). +This phase adds a `lc issue comment` subcommand whose only real job is +`--body-file`: read the comment body from a file (or stdin), so a caller +never has to build a multi-line body as a single shell argument. It does +not touch `issue update --comment`/`issue status --comment` (plain +create, already correct and well-tested) or `bin/lcomment` (a +human-interactive wrapper, out of scope here). == Decisions (reached in full agreement before implementation) -1. *A new `lc issue comment` subcommand, not a widening of `issue update`.* - Comment upsert needs to read an issue's existing comments and choose - create-vs-update; that's a different shape from `issue update`'s - field-setting options, same reasoning as Phase 12's `issue move` being - its own verb rather than another `issue update` flag. -2. *Upsert is opt-in via an explicit `-k`/`--marker` option, not inferred - from the body.* The workpad's own heading text changes between runs - (`## Workpad — Code review run 3` vs `run 4` in the transcript above), - so matching on the literal first line would never find the prior - comment. The marker is a separate, stable string the caller chooses - once (e.g. `"## Workpad"`) and passes on every call; the body underneath - it can grow or change freely. Without `--marker`, the command always - creates a new comment - identical behavior to today's `add_comment`. -3. *Match = the comment body, left-trimmed, starts with the marker - string.* Simple, predictable, and works for both a Markdown heading - marker (`## Workpad`) and an HTML-comment marker in Stokowski's own - style (``) without the CLI caring which - convention a given workflow uses. No regex, no configurable match mode - in this phase. -4. *Multiple matches: update the most recently created one, don't error.* - Comments come back from the issue's full fragment in the API's default - (creation) order, so "last match" is "most recent." Stray duplicate - workpad comments from before this feature existed shouldn't hard-fail - every future update; they just stop being the one that gets touched. -5. *`--body-file PATH` is new, additive, and scoped to this subcommand - only.* `PATH` of `-` reads stdin; anything else is read as a file. This - is what actually kills the incident's bug class: a multi-line body - never has to survive a single shell argument's quoting rules. Given - `--comment`/`-m` (see below), `--body-file` also is what a caller - writes to a plain heredoc/temp file: `WhatFor.comment_for/2`'s existing - `"-"`-opens-an-editor convention is untouched, and `--comment` and +1. *A new `lc issue comment` subcommand, not a widening of `issue + update`.* Even reduced to plain create, it's a distinct, single-issue, + comment-only verb worth its own name and help text, matching Phase + 12's `issue move` reasoning for a similarly-shaped new command. +2. *Always creates a new comment. No update, no upsert, no marker + matching.* `commentUpdate` requires a scope this project's Linear API + key doesn't reliably carry (Verified below) - even a key described as + having "full access" failed it consistently, including against a + comment created moments earlier by that same key, ruling out an + ownership explanation. Independent of that, matching an existing + comment by "body starts with this string" is an indeterminate lookup: + two different sections can collide on a prefix, and a caller has no + way to know which comment will match before it happens (User + decision, 2026-08-31). Removing the mechanism removes both problems at + once. +3. *`--body-file PATH` is the actual point of this command.* `PATH` of + `-` reads stdin; anything else is read as a file. This is what kills + the incident's bug class: a multi-line body never has to survive a + single shell argument's quoting rules. `--comment`/`-m` still exists + for a short one-line body (and keeps `WhatFor.comment_for/2`'s + existing `"-"`-opens-an-editor convention); `--comment` and `--body-file` are mutually exclusive (both given is a `smells_bad` error). Generalizing `--body-file` to `--description`/`--reason`/etc. is deliberately deferred - those aren't the pattern that broke, and widening `WhatFor.ask_or_edit/3`'s shared contract for every caller is a bigger, separate change than this phase needs. -6. *No new GraphQL round trip to find the marker match.* Every existing - issue lookup that already resolves an `ISSUE_ID` to an `%Issue{}` - (`Linear.issues(%{ids: [...]})`) uses the full-fragment path, which - already includes `comments { nodes { ... } }`. The new subcommand - resolves the issue the same way `issue update`/`issue status` do and - searches the comments already on the struct - no extra query. -7. *`update_comment` is a genuinely new Ash action*, mirroring the - `Issue.Update.*` manual-update pattern exactly (`changeset.data` is the - already-loaded `%Comment{}`, argument is the new `body`, the manual - module calls `commentUpdate` and refetches via `Comment.base_fields/0`). - Not a widening of `Comment.Create` - create and update are different - mutations (`commentCreate` vs `commentUpdate`) with different required - identifiers (issue ID vs comment ID). -8. *`bin/lcomment` and `issue update --comment`/`issue status --comment` +4. *`bin/lcomment` and `issue update --comment`/`issue status --comment` are untouched.* They're the human/quick-comment path (always creates, `-` always opens an editor) and remain exactly as they are. -9. *No subcommand alias in this phase.* The obvious short alias (`c`) is +5. *No subcommand alias in this phase.* The obvious short alias (`c`) is already `issue create`'s; picking a different one isn't worth guessing at before the command has seen real use (same reasoning as Phase 12 deferring a `bin/` wrapper). -10. *Append by default, not replace.* When `--marker` finds an existing - comment, the given body is appended to it - `existing.body <> "\n\n" - <> body` - rather than overwriting it. This matches what - `global.md` already asked for before this phase ("Update it at each - milestone ... do not delete prior content") and means a caller - submits only its new section each time, not the whole accumulated - workpad. A new `--replace` flag opts into the old full-overwrite - behavior (`body` alone becomes the new comment body) for a caller - that genuinely wants to reset the comment. Plain create (no marker, - or marker with no match) is unaffected either way - there's nothing - to append to yet. +6. *Guidance changes too: post one comment per milestone, never a single + running comment.* `.ai/prompts/global.md` and the per-stage prompts + (`investigate.md`, `implement.md`, `merge.md`) previously told agents + to maintain and append to a single "workpad" comment. That's gone - + each stage posts its own comment describing just that step + (investigation findings, implementation results, merge confirmation, + etc.), matching how `review.md`'s `## Code Review` already worked + (a fresh comment per run, never edited) and requiring no + `commentUpdate` capability at all. == Verified -* `schema/LinearAPI.graphql`: `commentUpdate(id: String!, input: - CommentUpdateInput!): CommentPayload!` (line 17924) takes the comment's - own `id`, separately from `commentCreate(input: CommentCreateInput!)` - (line 17891), which takes `issueId`. `CommentUpdateInput` (line 4181) - has a plain `body: String` field - no other required fields, so a - body-only update is a complete, valid input. -* `app/lib/linear_cli/linear/comment.ex`: `Comment.base_fields/0` already - selects `id` - every comment already parsed via `Comment.from_map/1` - (nested under an issue, or returned from `commentCreate`) carries the - `id` an update needs. No fragment change required. -* `app/lib/linear_cli/linear/issue.ex`: `Issue.Read.List`'s `issue(id:)` - per-id path (used whenever `Linear.issues/1` is called with `ids:`, as - `issue_update`/`issue_status` already do) fetches the *full* fragment, - which embeds `comments { nodes { #{Comment.base_fields()}} }` - (`full_fields/0`, line ~94). Resolving an issue this way for the new - subcommand gets its comments for free. -* `app/lib/linear_cli/linear/issue.ex` (`Issue.Update.Assign`/ - `AttachToProject`): the manual-update pattern to copy exactly - - `changeset.data` is the struct passed into `for_update`, arguments carry - only what's changing, the manual module builds the mutation input and - calls a shared `run/2`-style helper that refetches via the resource's - own field-selection function. +* `app/lib/linear_cli/linear/comment.ex`: `Comment.base_fields/0` + already selects everything a create response needs (`id body url + user {...}`) - no fragment change required for plain create. * `app/lib/linear_cli/cli/issue_helpers.ex` (`issue_comment/2`, `update_issue/2`/`maybe_comment/2`/`dispatch_update/2`): confirms `issue update --comment "text"` with no other flag is *already* comment-only (the `opts[:comment] -> :ok` branch in `dispatch_update/2` - fires only after `close`/`cancel`/`pr`/`project`/`description` all miss, - so no extra `issueUpdate` mutation is sent) - there is no "plain create" - gap to fill, only "update an existing one" and "supply a large body - safely." + fires only after `close`/`cancel`/`pr`/`project`/`description` all + miss, so no extra `issueUpdate` mutation is sent) - kept exactly as + is; `lc issue comment` doesn't call it (see Building blocks) so its + `--output json` confirmation-suppression need doesn't force a + signature change onto that shared function. * `app/lib/linear_cli/cli/what_for.ex` (`ask_or_edit/3`, `comment_for/2`): `thing && thing != "-"` returns `thing` unchanged - already idempotent for a body that arrived fully resolved from `--body-file`, so routing every body (file-sourced or not) through `comment_for/2` is safe and keeps one resolution path. -* `app/lib/linear_cli/cli/commands.ex` (`project_update/1`): the house - pattern for a single-record "post something to Linear" command - - resolve inputs, call the domain interface, `Display.show(result, - %{output: options.output})`, `:ok`/`{:error, reason}` - is what - `issue_comment/1` will follow. -* `app/test/linear_cli/linear/comment_test.exs`: the - `Req.Test.stub(LinearCli.Api, ...)` + `Jason.decode!`-the-request-body - pattern used for `add_comment/2` is the direct template for - `update_comment`'s tests. +* `app/lib/linear_cli/cli/commands.ex` (`apply_move/2` / + `print_move_results/3`): the house pattern for "call the domain + interface directly instead of the `IssueHelpers` wrapper, so the + confirmation print can be gated on `--output json`" - `issue_comment/1` + follows the same pattern against `Linear.add_comment/2` directly + rather than `IssueHelpers.issue_comment/2`. * Incident evidence: the two malformed EXT-17 comments (session `55e18e70-2e53-437f-ad0d-722332190839`, lines 121 and 125) are a single-quoted `REVIEW_BODY='...\n\n...'` bash string (literal @@ -167,70 +132,43 @@ subcommand. It does not touch `issue update --comment`/`issue status the shell-quoting failure modes `--body-file` (write the body to a real file, reference it by path, no inline multi-line shell argument at all) removes. +* Live, against real Linear (`EXT-17`), with a key later primed for + write access: `mix lc issue comment EXT-17 -m "## Phase 13 + Verification\n\n..."` and a follow-up `--marker`-matched call (from the + now-abandoned design) both succeeded as plain `commentCreate` calls + (`comment-3bf46109`, `comment-7be28072`, `comment-00c123db`), each + returning a real comment URL. The follow-up `commentUpdate` call against + the very comment just created by that same key was rejected twice: + `403 FORBIDDEN: Invalid scope: 'write' required`. `mix lc whoami` + succeeds with the same key; `commentCreate` succeeds; only + `commentUpdate` doesn't - confirming the scope gap this decision is + based on, not a code defect or an ownership restriction. +* `--body-file -` piped a body containing a literal `\n` (backslash-n) + and a `$WORKPAD_BODY`-looking string plus one real newline + (`comment-ad185412`); reading it back via `--output json` and + `repr()`-ing the raw string confirmed both the literal two-character + sequence and the `$VAR`-looking text survived byte-for-byte, and the + real newline was a distinct `U+000A` - the exact incident bug class, + now provably absent. +* Test artifacts left on `EXT-17` as a result of the above (harmless, on + an already-messy `Done` issue; `lc` has no comment-delete capability to + clean them up): several comments from these verification runs. == Building blocks -* `app/lib/linear_cli/linear/comment.ex`: - ** `LinearCli.Linear.Comment`: new `update :update do argument :body, - :string, allow_nil?: false; manual LinearCli.Linear.Comment.Update - end` action. - ** New `LinearCli.Linear.Comment.Update` manual module (same file, - mirroring `Comment.Create`'s shape): calls - `commentUpdate(id: $id, input: { body: $body })` with - `changeset.data.id`, returns `{:ok, Comment.from_map(comment_map)}` - from the mutation's own `comment { ... }` refetch (using - `Comment.base_fields/0`), or `{:error, reason}`. -* `app/lib/linear_cli/linear.ex`: `Comment` resource gains `define - :update_comment, action: :update, args: [:body]` alongside the existing - `add_comment`. -* `app/lib/linear_cli/cli/issue_helpers.ex`: - ** New public `upsert_comment(issue, comment, marker \\ nil, opts \\ - [])`: resolves `body = WhatFor.comment_for(issue, comment)` - (unchanged resolution path), then looks for an existing match via a - new private `find_comment_by_marker/2` (`issue.comments |> - Enum.filter(&String.starts_with?(String.trim_leading(&1.body || ""), - marker)) |> List.last()`) whenever `marker` is truthy (`marker && - find_comment_by_marker(...)` naturally short-circuits to `nil` when - `marker` is `nil`, folding the "no marker" and "marker with no - match" cases into one branch); found -> `Linear.update_comment - (existing, new_body)`, where `new_body` is `body` alone when - `opts[:replace]` is truthy, otherwise `existing.body <> "\n\n" <> - body` (Decision 10 - append, not replace, by default); not found -> - `Linear.add_comment(issue.identifier, body)`. Each branch prints its - own `Prompt.ok(...)` ("Comment added to .../Comment updated on - ...") unless `opts[:quiet]` is truthy - found necessary during - implementation, see the `--output json` note below. Returns `{:ok, - comment} | {:error, reason}`. Does *not* delegate to `issue_comment/2` - (which stays untouched, still used as-is by `update`/`status`) - because `issue_comment/2` has no `opts`/quiet parameter to thread - through and adding one would be a pointless signature change for a - function this phase deliberately leaves alone. - ** `--output json` consistency, found during implementation, not - anticipated above: `LinearCli.CLI.Commands`'s existing `issue move` - command suppresses its own `Prompt.ok` confirmations under - `--output json` (`print_move_results/3`'s `when output != "json"` - guard) so piped JSON stays parseable without stripping a leading - human-readable line. `upsert_comment/4`'s `opts[:quiet]` is the same - idea; `Commands.issue_comment/1` passes `quiet: options.output == - "json"`. -* `app/lib/linear_cli/cli.ex`: - ** New `comment:` entry in the `issue` subcommand tree: `args: [issue_id: - [value_name: "ISSUE_ID", help: "The Issue (i.e. CRY-1)", required: - true]]`, `options: [comment: [short: "-m", long: "--comment", help: - "Comment text. - opens an editor"], marker: [short: "-k", long: - "--marker", help: "Find and update a comment starting with this text - instead of always creating a new one"], body_file: [long: - "--body-file", help: "Read the comment body from this file (- for - stdin) instead of --comment"]]`, `flags: [replace: [long: - "--replace", help: "Replace the matched comment's body instead of - appending to it (only with --marker)"]]`. - ** New `defp dispatch([:issue, :comment], result, halt), do: - run(&Commands.issue_comment/1, result, halt)` clause, alongside the - existing `:move`/`:status`/`:update` ones. +* `app/lib/linear_cli/cli.ex`: `comment:` entry in the `issue` subcommand + tree - `args: [issue_id: [value_name: "ISSUE_ID", help: "The Issue + (i.e. CRY-1)", required: true]]`, `options: [comment: [short: "-m", + long: "--comment", help: "Comment text. - opens an editor"], + body_file: [long: "--body-file", help: "Read the comment body from + this file (- for stdin) instead of --comment"]]`. `defp dispatch + ([:issue, :comment], result, halt), do: run(&Commands.issue_comment/1, + result, halt)` alongside the existing `:move`/`:status`/`:update` + clauses. * `app/lib/linear_cli/cli/commands.ex`: new `issue_comment/1`: . `--comment` and `--body-file` both given -> `{:error, {:smells_bad, - "give --comment or --body-file, not both"}}`, checked before anything - else. + "give --comment or --body-file, not both"}}`, checked before + anything else. . `--body-file` given: `"-"` reads `IO.read(:stdio, :eof)` (`:eof` on empty input normalized to `""`; `IO.binread/2`, tried first, crashes `ExUnit.CaptureIO`'s `StringIO`-backed device on this Elixir/OTP @@ -242,9 +180,12 @@ subcommand. It does not touch `issue update --comment`/`issue status . resolve the single issue via `Linear.issues(%{ids: [IssueHelpers.expand_issue_id(issue_id)]})` (same call `issue_update`/ `issue_status` already make), erroring the same way on zero results. - . `IssueHelpers.upsert_comment(issue, resolved_body_or_nil, - options.marker, replace: flags.replace)`, then - `Display.show(comment, %{output: options.output})` on success. + . `body = WhatFor.comment_for(issue, comment_text)`, then + `Linear.add_comment(issue.identifier, body)` directly (not + `IssueHelpers.issue_comment/2` - see Verified above); prints + `Prompt.ok("Comment added to #{issue.identifier}")` unless + `options.output == "json"`, then `Display.show(comment, %{output: + options.output})`. * `app/lib/linear_cli/cli/display.ex`: found during implementation, not anticipated above - `puts_text/2` (text-mode rendering) had no `%Comment{}` clause at all (`Comment` was previously only ever shown @@ -256,49 +197,22 @@ subcommand. It does not touch `issue update --comment`/`issue status (comment.url || "(no URL returned)")`, mirroring `ProjectUpdate`'s own "link back to what was just posted" convention. JSON mode was already fine (`to_plain/1`'s generic `%_struct{}` clause covers any resource). +* No changes to `app/lib/linear_cli/linear/comment.ex`, + `app/lib/linear_cli/linear.ex`, or `documents/ash-domain-erd.adoc` - + `add_comment`/`commentCreate` is the only mutation this command needs, + and it already existed before this phase. == Tests -All in `app/test/linear_cli/linear/comment_test.exs` (domain layer), -`app/test/linear_cli/cli/issue_helpers_test.exs` (helper layer), and -`app/test/linear_cli/cli/issue_commands_test.exs` (full-command layer - -not a new `issue_comment_test.exs` file as first assumed above; every -other `Commands.issue_*` function's tests, including `issue_move`'s, live -in that one consolidated file, and `issue_comment` follows the same +All in `app/test/linear_cli/cli/issue_commands_test.exs` (full-command +layer - not a new `issue_comment_test.exs` file; every other +`Commands.issue_*` function's tests, including `issue_move`'s, live in +that one consolidated file, and `issue_comment` follows the same convention), on the existing `Req.Test.stub(LinearCli.Api, ...)` harness. +New `describe "issue comment"`: -Domain (`comment_test.exs`, new `describe "update_comment/2"`): - -* Sends `id`/`body`, returns the updated comment via `base_fields` - - mirrors the existing `add_comment/2+` happy-path test exactly. -* Surfaces a GraphQL error as `{:error, %Ash.Error.Invalid{}}` (update - actions surface manual-action errors this way, not `Ash.Error.Unknown` - like `create` - confirmed against `assign_issue`/`attach_issue_to_project`'s - own existing error tests). - -Helper (`issue_helpers_test.exs`, new `describe "upsert_comment/4"`): -append-by-default, `replace: true`, marker-miss-creates, multiple-matches- -picks-the-last, and error-propagation cases - directly against the -function, no CLI/Optimus parsing involved (per Sequencing step 2, written -before any CLI wiring exists). - -Full-command (`issue_commands_test.exs`, new `describe "issue comment"`): - -* No `--marker`: always sends `commentCreate`, never fetches comments - first beyond the normal issue resolution; stdout shows "Comment added - to ...". -* `--marker` with no existing match: issue's stubbed comments list has - none starting with the marker -> `commentCreate` is sent, not - `commentUpdate`. -* `--marker` with one existing match, no `--replace`: `commentUpdate` is - sent with that comment's `id` and `\n\n`; - `commentCreate` is never sent; stdout shows "Comment updated on ...". -* `--marker` with one existing match and `--replace`: `commentUpdate` is - sent with the new body alone (old body is not present anywhere in the - request). -* `--marker` with two matching comments (out-of-band duplicates): the - *later* one's `id` is the one sent to `commentUpdate`, and its body - (not the earlier match's) is what gets appended to/replaced. +* Plain create: sends `commentCreate`; stdout shows "Comment added to + ...". * `--body-file `: file contents (including embedded literal backslash-n or `$VAR`-looking text, proving no shell ever touches the content) become the comment body verbatim. @@ -306,91 +220,52 @@ Full-command (`issue_commands_test.exs`, new `describe "issue comment"`): * `--comment` and `--body-file` together: no GraphQL call is sent at all; `{:error, {:smells_bad, _}}` surfaces through `handle_error/3`'s exit path. -* Unreadable `--body-file` path: `{:error, reason}` surfaces the same way, - no GraphQL call is sent. -* `--output json` on a create and on an update: JSON is the comment map - in both cases, with no `Prompt.ok` confirmation text mixed into stdout - (`opts[:quiet]`, matching `issue move`'s own `--output json` behavior). +* Unreadable `--body-file` path: `{:error, reason}` surfaces the same + way, no GraphQL call is sent. +* `--output json`: JSON is the comment map, with no `Prompt.ok` + confirmation text mixed into stdout. == Docs -* `AGENTS.md`: "The Plan" index gains the Phase 13 entry (this document); - "Rules for Interacting with Linear" gains `mix lc issue comment --help` - alongside the existing example commands. -* `documents/ash-domain-erd.adoc`: "Action and code-interface matrix" - gains one row - `Comment` / `update_comment` / `:update` / update / - `Linear.Comment.Update` / `commentUpdate(id:, input: { body })` mutation - - directly under the existing `add_comment` row. No attribute or - association changes (Comment's shape is unchanged). -* `.ai/prompts/global.md`: rewrite the "Linear workpad" section to give - the actual command instead of an unactionable rule, resolving the - contradiction this phase exists to fix: -+ -[source,markdown] ----- -## Linear workpad - -Use a single, persistent Linear comment as a workpad: - - mix lc issue comment --marker "## Workpad" --body-file - -- Write the file with just your new section for this milestone (title - line included the first time, e.g. `## Workpad`) - never build a - multi-line comment as an inline shell argument. -- The marker ("## Workpad") must stay exactly the same every time; the - content underneath it can change freely. The command finds the - existing workpad comment and appends your new section to it, or - creates it on first use - you do not need to re-read or re-submit - prior content yourself. -- Pass `--replace` only if you deliberately want to discard everything - written so far and start the workpad over. -- Never call the Linear API directly (curl, GraphQL, or otherwise) - - always use `mix lc`. ----- +* `AGENTS.md`: "The Plan" index gains the Phase 12 and Phase 13 entries + (Phase 12's was missing too); "Rules for Interacting with Linear" gains + `mix lc issue comment --help` alongside the existing example commands. * `Readme.adoc`: `issue` command table gains a `comment` row (no alias, - per Decision 9); a short example block under the existing `issue - update`/`issue status` examples showing plain create, `--marker` upsert - (append, the default), `--replace`, and `--body-file`. + per Decision 5); a short example block under the existing `issue + update`/`issue status` examples showing plain create and + `--body-file`. +* `.ai/prompts/global.md`: the old "Linear workpad" section (a single + persistent comment, found and appended to by marker) is replaced with + "Linear progress updates" - post a new comment per milestone via `mix + lc issue comment --body-file `, each one covering only that + step's findings/decisions/results; read prior comments for context but + never try to edit one. The "Rework awareness" list's "Prior workpad + content (append to it, do not overwrite)" bullet becomes "Prior + progress comments (read them for context; post a new comment for this + run rather than editing an old one)". +* `.ai/prompts/investigate.md`, `implement.md`, `merge.md`: every + "update/append to the workpad" instruction becomes "post a Linear + comment" - `investigate.md`'s rework step now says to post a new `## + Investigation (rework)` comment instead of "updating" the prior one. + `review.md` needed no change - its rework step already said "Post an + updated `## Code Review` comment" (a new comment, not an edit), which + is the pattern every stage now follows. == Sequencing No dependency on unlanded work. -1. `Comment.update` action + `Linear.Comment.Update` manual module + - `update_comment` domain interface, with its two domain-layer tests. -2. `IssueHelpers.upsert_comment/3` + `find_comment_by_marker/2`, unit - tested directly (stub-issue with a few comments, marker hit/miss/ - duplicate cases) before any CLI wiring exists. -3. Optimus `issue comment` spec, `dispatch/3` clause, and - `Commands.issue_comment/1` (body-file/mutual-exclusion handling first, - then the create/upsert paths) + the full CLI test file. -4. `AGENTS.md`, `documents/ash-domain-erd.adoc`, `.ai/prompts/global.md`, - `Readme.adoc`. -5. Manual end-to-end verification against live Linear (dogfooding, per - `app/usage-rules.md`): create via `--marker` on an issue with no prior - match, run it again with a new section and confirm the same comment - grows in place (no second comment appears, prior content still - present), a `--replace` run and confirm the prior content is gone, - `--body-file -` piped from a multi-line heredoc containing literal - `\n` text and a `$VAR`-looking string to confirm both survive - verbatim, then point a real Stokowski `code-review`/`merge-review` run - at the rewritten `global.md` workpad instructions and confirm the - agent uses `mix lc issue comment` instead of hand-rolling `curl`. -+ -[NOTE] -==== -Attempted against `EXT-17` in this environment: `mix lc issue comment -EXT-17 -m "..." -k "## Workpad"` correctly resolved the issue, found -`EXT-17`'s existing malformed "Workpad — Code review run 4" comment as a -marker match (it does start with `## Workpad`, confirming Decision 3's -plain-prefix match works exactly as designed even against messy -real-world data), and attempted the `commentUpdate` mutation - which -Linear rejected with `403 FORBIDDEN: Invalid scope: 'write' required`. -This sandbox's `LINEAR_API_KEY` is read-only (`mix lc whoami` succeeds; -any mutation does not) - a credential/environment limitation, not a code -defect. Full live-write verification is still outstanding and needs a -write-scoped key. -==== +1. Optimus `issue comment` spec, `dispatch/3` clause, and + `Commands.issue_comment/1` (body-file/mutual-exclusion handling + first, then plain create) + the full CLI test file. +2. `AGENTS.md`, `Readme.adoc`, `.ai/prompts/global.md`, + `.ai/prompts/investigate.md`, `.ai/prompts/implement.md`, + `.ai/prompts/merge.md`. +3. Manual end-to-end verification against live Linear (dogfooding, per + `app/usage-rules.md`) - done; see Verified above: plain create and + `--body-file` (file and stdin) both confirmed against real Linear, + including the literal-backslash-n/`$VAR`-survives-verbatim proof that + directly closes the incident's bug class. Standard workflow from here: file a GitHub issue for this phase, branch from it, commit, open the PR - no direct-to-main commits. Conventional From fc781bcfcd7d4f7fc0bb261dffa5baf070fb9b5d Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 1 Sep 2026 08:21:50 -0400 Subject: [PATCH 6/6] chore: updates ash library version --- app/mix.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/mix.lock b/app/mix.lock index 3130ce0..e0a21db 100644 --- a/app/mix.lock +++ b/app/mix.lock @@ -1,5 +1,5 @@ %{ - "ash": {:hex, :ash, "3.31.3", "b36672bff745eadd52265d8b0a303f98ba26a31173f4116ebd2df7be0cbafeff", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4c105da2080475d114908c32e70970e69c857a9068a577a4e99dbf2801baa11e"}, + "ash": {:hex, :ash, "3.32.3", "a6390b9f6497458f4575220cd507b6b5aae52c43bba0cc057aa3fa96e44ef416", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4eebbd3fa7dab05d0aab9c7552155c07e746b18c8dc1cf5ed2f62195cea87f3d"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "burrito": {:hex, :burrito, "1.6.0", "7af0a75f11680e8a6e9c01370c9af51cb9d0e15b3226eddf4f438dbc68570520", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "e636a00b032c45a69ff755d9fc53fa5fdc9e1d21bdbd229075fe4a15b05355fe"}, "castore": {:hex, :castore, "1.0.21", "0a0e8330dc267a40a3b7ad86d39302764bb71758172904e6a59d5ad6443ce307", [:mix], [], "hexpm", "e42e22723e25dbd46876d056a03f685513d6e98f6b5e555dc551321decd76c5c"}, @@ -45,7 +45,7 @@ "protobuf": {:hex, :protobuf, "0.16.1", "7bf0c3e6b24fd297e2e3722ecb985c3900bcc55d004b7faf0737e248c3e64691", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "97fb9f1fe48c38c4ebfc0f8a7a20adb22b2491bbfbdb192fab9a6821d5d604e1"}, "purl": {:hex, :purl, "0.3.0", "b6e2f792e944007ed474299e56ea43ea4819f064a4a4f67e4153e1ba02d066de", [:mix, :rebar3], [{:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "158cf2c0533517fe0d68752bd790392f065c5ffdfbde38524890066ebe640302"}, "reactor": {:hex, :reactor, "1.0.6", "546a87255693bcee99451d022cc86927161cdb527f5216d4dcac2b31e08eb122", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "e3f8fd8e870c2b011316ca2ac422bb4bc710cac9f02c578f8526f7a6348d932b"}, - "req": {:hex, :req, "0.7.2", "364eae2e5f5c984f2dac6d71c07f8c8c89ce0bc49c4d746dacb7a306823020de", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c9cdfa276b05d8db2a27fda5d233e6858b764d47189d76cbb186e130a871ae0b"}, + "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, "sbom": {:hex, :sbom, "0.10.0", "b99be5407bc196d0ad71b8061126a67aae46dc3bfaa852b4c1c04645dd1ad984", [:mix], [{:hex_core, "~> 0.15.0", [hex: :hex_core, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:optimus, "~> 0.6.1", [hex: :optimus, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.16.0", [hex: :protobuf, repo: "hexpm", optional: false]}, {:purl, "~> 0.3.0", [hex: :purl, repo: "hexpm", optional: false]}], "hexpm", "a8116ef965c1ebd103e223545794bd0a6691edd3ec678ec07972d473e2badc95"},