diff --git a/.agents/skills/linear-ticket-intake/SKILL.md b/.agents/skills/linear-ticket-intake/SKILL.md new file mode 100644 index 00000000000..988ffd607c5 --- /dev/null +++ b/.agents/skills/linear-ticket-intake/SKILL.md @@ -0,0 +1,104 @@ +--- +name: linear-ticket-intake +description: >- + Agent-only procedure for query-only Linear poll events and agent-owned ticket + updates. Use before arming the Linear poller and on any + `procevent linear ` wake. Owns Linear re-fetch, + duplicate prevention, one-writer assignment, Sol and Luna role separation, + comment routing, writer transfer, and process-event acknowledgement. +user-invocable: false +metadata: + internal: true +--- + +# Linear ticket intake + +Use this procedure before arming `bin/fm-procevent-linear.sh` and whenever a `check:` wake carries `procevent linear `. +Load `process-event-sources` for the shared capture, read, and handled-acknowledgement contract. + +The poller is a detector, never a Linear writer. +It may query mapped projects, compare its private observation snapshot, and emit immutable issue ids, identifiers, project names, event types, comment ids, and the API-provided canonical URL. +Never add a mutation to it, give it a ticket-writer lease, or use it as a fallback when an assigned worker cannot reach Linear MCP. + +## Arm + +Review the private config, then arm the source through its adapter: + +```sh +bin/fm-procevent-linear.sh arm config/linear-poll.json +``` + +The registered command blocks outside the conversational turn and checks every 30 seconds. +An unchanged snapshot prints nothing, so the process-event runner has no result to capture and no wake or model call to create. + +## Handle a detected Todo + +Read the exact captured result through the adapter: + +```sh +bin/fm-procevent-linear.sh read state/procevent-inbox/..result +``` + +Re-fetch the issue through Linear MCP using its immutable id or identifier. +Treat Linear as the source of truth for current status, blockers, project mapping, and canonical URL; reject a mapping mismatch rather than guessing. +Check Firstmate's backlog, live task metadata, and `bin/fm-linear-ticket-writer.sh show ` before dispatch. +If the issue is blocked, no longer Todo, already leased, or already represented by a live or retained local task, do not create another task. + +For a new eligible issue: + +1. Create the normal Firstmate ship task and its local backlog record. +2. Choose one persistent Luna implementation worker as ticket owner and create its lease before spawn: + + ```sh + bin/fm-linear-ticket-writer.sh assign + bin/fm-linear-ticket-writer.sh owner-brief + ``` + +3. Create a separate Sol planning or review task when needed and apply its no-write brief before spawn: + + ```sh + bin/fm-linear-ticket-writer.sh planner-brief + ``` + +4. Spawn through the normal Firstmate harness procedure. + +The owner brief is the authority boundary. +Luna confirms the exact ticket, checks its lease before every Linear mutation, moves Todo to In Progress, creates or updates exactly one `## Firstmate Workpad`, and records the accepted plan, progress, blockers, PR URL, review results, fixes, and completion state. +Luna may change only its assigned ticket and must report a blocker when Linear MCP is unavailable. +Luna moves the ticket to Human Review only after the project delivery gates pass, and marks it Done only after independently verifying the PR merged. + +Sol can plan and review but cannot mutate Linear. +Sol reports findings through Firstmate, and Firstmate steers those findings to Luna so the sole writer records them on the ticket. +Firstmate owns its local backlog and fleet records and must not ask Luna to edit them. + +## Handle a detected comment + +Re-fetch the comment and issue through Linear MCP. +If the issue has a current writer lease, steer the comment to that Luna task through the durable task inbox instead of creating another task or replying as Firstmate. +If no valid lease exists, reconcile the local task state before deciding whether this is missed intake or stale external activity. +The poll event itself never authorizes a Linear reply. + +## Transfer a writer + +Two live workers never share write authority. +Stop or revoke the old worker's Linear work first, then transfer explicitly: + +```sh +bin/fm-linear-ticket-writer.sh transfer +``` + +The current lease generation and append-only transfer history are the durable authority. +Apply an owner brief for the replacement only after the transfer succeeds. +A stale worker fails `assert-writer` and `assert-target` after transfer. + +## Reconcile and acknowledge + +Firstmate may read Linear to reconcile status but does not duplicate Luna's comments, Workpad edits, status changes, or completion update. +After the Todo or comment event is fully routed, acknowledge that exact captured sequence: + +```sh +bin/fm-procevent.sh handled +``` + +Repeated wakes for an already represented issue are a dedupe check, not permission to create another task, lease, or Workpad. +Never merge a project PR without the captain's explicit authority unless the project's separately configured standing merge posture already grants it. diff --git a/.agents/skills/process-event-sources/SKILL.md b/.agents/skills/process-event-sources/SKILL.md index e8550505cd6..a50eb218440 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -65,7 +65,7 @@ Eligibility is a firstmate judgment made BEFORE arming, because the scripts cann Never bind an action that is destructive, irreversible, or security-sensitive, an action needing captain approval or any gate decision, or an action whose right form depends on what the condition finds - those keep the existing check-fires-then-firstmate-decides flow, for which a plain custom check or another adapter stays correct. When in doubt, arm only the condition half as an ordinary check and keep the action as a wake-time decision. -`bin/fm-procevent.sh --help`, `bin/fm-procevent-lavish.sh --help`, `bin/fm-procevent-when.sh --help`, `bin/fm-procevent-quota.sh --help`, and `bin/fm-procevent-remote-reply.sh --help` own the exact commands and flags. +`bin/fm-procevent.sh --help`, `bin/fm-procevent-lavish.sh --help`, `bin/fm-procevent-linear.sh --help`, `bin/fm-procevent-when.sh --help`, `bin/fm-procevent-quota.sh --help`, and `bin/fm-procevent-remote-reply.sh --help` own the exact commands and flags. An explicitly enabled external adapter registers through `bin/fm-procevent.sh register-extension`, never through a package-discovered script or package-supplied argv. [`docs/configuration.md`](../../../docs/configuration.md#trusted-external-process-event-adapters-configextensionsd) owns setup and [`docs/extension-bindings.md`](../../../docs/extension-bindings.md) owns the narrow trusted-code and untrusted-evidence boundary. @@ -97,6 +97,9 @@ Two rules the commands cannot enforce for you: : Ask the adapter what the result means rather than parsing it yourself. `bin/fm-procevent.sh classify ` routes through the immutable built-in or extension identity captured with that result; for Lavish, its existing direct command returns `feedback`, `ended`, `waiting`, `missing`, or `unknown`. Consume a Lavish capture with `bin/fm-procevent-lavish.sh read ` rather than grepping the raw file: that command reports declared and presented item counts plus a completeness verdict, enumerates every captured queued item while retaining supplied element identity, and surfaces a `tag=message` session-ending message as its own field. +: A `linear` wake is intake evidence, not write authority. + Load `linear-ticket-intake`, consume the capture with `bin/fm-procevent-linear.sh read `, and follow its re-fetch, dedupe, one-writer, comment-routing, and acknowledgement procedure. + Never let the poller comment, change status, assign a worker, or become the fallback writer. `answers` remains the keyed-choice extractor and never treats freeform prose as a decision key. A `feedback` result can still be the last one a review ever produces, so never assume another wake is coming just because the state is not `ended`. : A routine no-op an adapter positively identifies never becomes a wake at all - it is recorded as handled and stays silent, so you never see it. For Lavish that is exactly an ended session carrying nothing: a board the captain closed without saying anything. A board close carrying a real answer, and every other result, still wakes you unchanged. Never read the absence of a wake as proof a review is still open; ask the source, not the queue. diff --git a/AGENTS.md b/AGENTS.md index 40bb092cb64..2fa03fb5e68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,7 @@ config/turnend-churn-absorb optional presence flag opting this home into the de config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/watched-tools.json optional list of the tools this home depends on, read by the update check armed with bin/fm-tool-update-check.sh; LOCAL, gitignored, firstmate-maintained but human-editable, and NOT inherited by secondmate homes; see docs/configuration.md "Watched tool updates" +config/linear-poll.json optional mapped-project configuration for the read-only Linear GraphQL detector; LOCAL, gitignored, and not inherited; see docs/configuration.md "Linear Todo and comment polling" config/x-mode.env generated Relay watcher cadence; LOCAL, gitignored; source before arming watcher when present data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history @@ -117,6 +118,8 @@ state/ runtime records and signals; gitignored pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh procevent/ registered process-to-event sources, one private record per canonical source id; written only by bin/fm-procevent.sh, and their presence alone keeps supervision required (section 13) procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line + linear-poll/ private read-only detector snapshots keyed by Linear process-event source id; written only by bin/fm-procevent-linear.sh + linear-ticket-writers/ private one-writer leases and append-only transfer histories keyed by Linear issue identifier; written only by bin/fm-linear-ticket-writer.sh decision-bindings/ private records marking a captured-answer source as feeding the keyed-answer intake, with a legacy origin on pre-collapse records; written only by bin/fm-captain-hold.sh bind, dropped by unbind and by source retirement (section 13; docs/captain-hold-lifecycle.md) when/ private condition->action watch specs, their trust bindings, and single-fire markers; written only by bin/fm-procevent-when.sh (section 13's process-event-sources trigger) inbox/ captain notes captured out of band by bin/fm-inbox.sh, including the voice handover's queued requests; each note appends one `check` wake and stays pending until acknowledged with `bin/fm-inbox.sh drain --ack `, which moves it to inbox/handled/ (docs/voice-relay.md) @@ -549,6 +552,7 @@ These skills are not captain-invocable; load them only at their precise triggers - `captain-hold-lifecycle` - load before treating an investigation or visual review as complete, before ending a visual review that exposed a captain decision, when recording or routing the captain's answer, and on any `RECORD DIVERGENCE` line from the wake drain. - `process-event-sources` - load before arming a long-polling source, before registering a deterministic condition->action watch (do X as soon as Y is true), and on any `procevent ` check wake. Never run a registered source's blocking command yourself in a conversational turn. +- `linear-ticket-intake` - load before arming the Linear poller and on any `procevent linear ` check wake; it owns read-only re-fetch, duplicate prevention, one-writer assignment, Sol/Luna role separation, comment routing, writer transfer, and acknowledgement. - `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the Relay configuration blocker, on a `public-followup ...` `check:` wake or a startup-surfaced public commitment, and on any milestone or terminal wake for a Relay-linked task before posting its completion follow-up; relevant only when Relay is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. diff --git a/README.md b/README.md index 937cba18f4b..429ef7884c3 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/architecture.md](docs/architecture.md) - maintainer architecture for the crew, supervision, worktrees, secondmates, and project modes. - [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, optional Relay and its X and Discord setup steps, trusted external process-event adapter setup, the files you set, and harness support. +- [docs/configuration.md#linear-todo-and-comment-polling](docs/configuration.md#linear-todo-and-comment-polling) - optional query-only Linear Todo and comment polling with exact ticket links and one assigned ticket writer. - [docs/extension-bindings.md](docs/extension-bindings.md) - maintainer architecture for the narrow trusted external `process-event-adapter/1` package, binding, handshake, and evidence boundary. - [docs/remote-secondmates.md](docs/remote-secondmates.md) - current setup, routing, transfer, recovery, and safety behavior for whole-home remote second mates. - [docs/calm.md](docs/calm.md) - current Pi `/calm` behavior and supported presentation limits. diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 267b0902a93..cc610ed0546 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -420,6 +420,53 @@ nm_run_head_matches_worktree() { fm_nm_head_matches_worktree "$WT" "$run_head" } +# A terminal no-mistakes `outcome: passed` is only a local pipeline result until +# the linked GitHub PR is checked live. Never turn an unverified or still-open +# PR into a merged/closed claim. A missing PR remains a genuine local-only +# completion, while non-GitHub PRs and unavailable GitHub reads stay explicit +# without making a forge claim this helper cannot prove. +nm_passed_run_detail() { + local pr_url identity repo_path number pr_out state + pr_url=$(strip_quotes "$(nm_field pr)") + if [ -z "$pr_url" ]; then + printf 'run passed: local work complete' + return + fi + + # Only GitHub URLs can be checked by the required gh-axi GitHub surface. + # Strip ordinary URL decorations before applying the exact owner/repo/pull + # shape, so malformed or foreign-forge URLs cannot become a merge claim. + pr_url=${pr_url%%\?*} + pr_url=${pr_url%%\#*} + pr_url=${pr_url%/} + identity=$(printf '%s\n' "$pr_url" \ + | sed -nE 's#^https://github\.com/([^/]+/[^/]+)/pull/([0-9]+)$#\1 \2#p') + if [ -z "$identity" ]; then + printf 'run passed: PR state unverified' + return + fi + repo_path=${identity% *} + number=${identity##* } + if ! command -v gh-axi >/dev/null 2>&1; then + printf 'run passed: PR state unverified' + return + fi + # Address the exact linked repository explicitly via gh-axi's --repo flag + # (as bin/fm-pr-merge.sh already does), so a stale or foreign-fork link + # cannot silently resolve against the crew worktree's own checkout instead. + pr_out=$(cd "$WT" && gh-axi pr view "$number" --repo "$repo_path" 2>/dev/null) || { + printf 'run passed: PR state unverified' + return + } + state=$(fm_nm_strip_quotes "$(fm_nm_field "$pr_out" state)") + case "$state" in + merged) printf 'run passed: PR merged/closed' ;; + closed) printf 'run passed: PR closed (not merged)' ;; + open) printf 'run passed: PR open (not merged/closed)' ;; + *) printf 'run passed: PR state unverified' ;; + esac +} + # Coarse runs-list rows are " ...". 0 if the short # sha for this branch row matches the worktree head under the same rules as # nm_run_head_matches_worktree (equal, or local is ancestor of run tip). @@ -499,7 +546,7 @@ if [ "$HAVE_RUN" = 1 ]; then if [ -n "$outcome" ]; then case "$outcome" in - passed) RUN_STATE="done"; RUN_DETAIL="run passed: PR merged/closed" ;; + passed) RUN_STATE="done"; RUN_DETAIL=$(nm_passed_run_detail) ;; checks-passed) RUN_STATE="done"; RUN_DETAIL="checks green: PR ready for review" ;; failed) RUN_STATE=failed; RUN_DETAIL="run failed" ;; cancelled) RUN_STATE=failed; RUN_DETAIL="run cancelled" ;; diff --git a/bin/fm-linear-ticket-writer.sh b/bin/fm-linear-ticket-writer.sh new file mode 100755 index 00000000000..a73d9ab4fbd --- /dev/null +++ b/bin/fm-linear-ticket-writer.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# Own durable, exclusive Linear ticket-writer assignments and role briefs. +# +# Usage: +# fm-linear-ticket-writer.sh assign +# fm-linear-ticket-writer.sh transfer +# fm-linear-ticket-writer.sh assert-writer +# fm-linear-ticket-writer.sh assert-target +# fm-linear-ticket-writer.sh owner-brief +# fm-linear-ticket-writer.sh planner-brief +# fm-linear-ticket-writer.sh show +# +# Firstmate owns these records. A ticket worker reads and proves its assignment, +# but never edits the lease or Firstmate backlog directly. Replacing a worker is +# an explicit transfer that rewrites the current lease and appends a durable +# history row while holding the assignment lock. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" +LEASE_DIR="$STATE/linear-ticket-writers" +LOCK="$LEASE_DIR/.lock" + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" + +die() { printf 'error: %s\n' "$1" >&2; exit 1; } +usage() { sed -n '2,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 2; } + +valid_identifier() { [[ ${1-} =~ ^[A-Z][A-Z0-9]*-[1-9][0-9]*$ ]]; } +valid_actor() { fm_task_id_path_safe "${1-}"; } +valid_issue_id() { [ -n "${1-}" ] && [[ ${1-} != *$'\n'* ]]; } +valid_url() { [[ ${1-} == https://linear.app/* ]] && [[ ${1-} != *$'\n'* ]]; } + +lease_path() { printf '%s/%s.lease\n' "$LEASE_DIR" "$1"; } +history_path() { printf '%s/%s.history\n' "$LEASE_DIR" "$1"; } + +prepare_dir() { + (umask 077; mkdir -p "$LEASE_DIR") || die "cannot create Linear writer state directory" + [ -d "$LEASE_DIR" ] && [ ! -L "$LEASE_DIR" ] || die "unsafe Linear writer state directory" + chmod 700 "$LEASE_DIR" || die "cannot secure Linear writer state directory" +} + +lock_acquire() { + prepare_dir + fm_lock_acquire_wait "$LOCK" || die "cannot lock Linear writer assignments" +} + +lock_release() { fm_lock_release "$LOCK"; } + +load_lease() { # + local identifier=$1 file issue task writer issue_id url generation extra + file=$(lease_path "$identifier") + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + { + IFS= read -r issue + IFS= read -r task + IFS= read -r writer + IFS= read -r issue_id + IFS= read -r url + IFS= read -r generation + ! IFS= read -r extra + } < "$file" || return 2 + issue=${issue#issue=} + task=${task#task=} + writer=${writer#writer=} + issue_id=${issue_id#issue_id=} + url=${url#url=} + generation=${generation#generation=} + [ "$issue" = "$identifier" ] && valid_actor "$task" && valid_actor "$writer" \ + && valid_issue_id "$issue_id" && valid_url "$url" \ + && [[ $generation =~ ^[1-9][0-9]*$ ]] || return 2 + LINEAR_LEASE_TASK=$task + LINEAR_LEASE_WRITER=$writer + LINEAR_LEASE_ISSUE_ID=$issue_id + LINEAR_LEASE_URL=$url + LINEAR_LEASE_GENERATION=$generation +} + +write_lease_locked() { # + local identifier=$1 issue_id=$2 url=$3 task=$4 writer=$5 generation=$6 file staged + file=$(lease_path "$identifier") + staged=$(mktemp "$LEASE_DIR/.lease.XXXXXX") || return 1 + if printf 'issue=%s\ntask=%s\nwriter=%s\nissue_id=%s\nurl=%s\ngeneration=%s\n' \ + "$identifier" "$task" "$writer" "$issue_id" "$url" "$generation" > "$staged" \ + && chmod 600 "$staged" && mv "$staged" "$file"; then + return 0 + fi + rm -f "$staged" + return 1 +} + +task_has_other_lease_locked() { # + local task=$1 except=$2 file identifier + for file in "$LEASE_DIR"/*.lease; do + [ -e "$file" ] || continue + identifier=${file##*/} + identifier=${identifier%.lease} + [ "$identifier" = "$except" ] && continue + load_lease "$identifier" || return 0 + [ "$LINEAR_LEASE_TASK" != "$task" ] || return 0 + done + return 1 +} + +cmd_assign() { + local issue_id=${1-} identifier=${2-} url=${3-} task=${4-} writer=${5-} status + [ "$#" -eq 5 ] || usage + valid_issue_id "$issue_id" || die "invalid immutable Linear issue id" + valid_identifier "$identifier" || die "invalid Linear identifier: $identifier" + valid_url "$url" || die "invalid canonical Linear URL" + valid_actor "$task" || die "invalid Firstmate task id: $task" + valid_actor "$writer" || die "invalid writer id: $writer" + lock_acquire + status=0 + if load_lease "$identifier"; then + if [ "$LINEAR_LEASE_ISSUE_ID" = "$issue_id" ] && [ "$LINEAR_LEASE_URL" = "$url" ] \ + && [ "$LINEAR_LEASE_TASK" = "$task" ] && [ "$LINEAR_LEASE_WRITER" = "$writer" ]; then + printf 'already-assigned: issue=%s task=%s writer=%s\n' "$identifier" "$task" "$writer" + else + status=1 + fi + elif [ "$?" -eq 2 ] || task_has_other_lease_locked "$task" "$identifier"; then + status=1 + elif write_lease_locked "$identifier" "$issue_id" "$url" "$task" "$writer" 1; then + printf '%s\tassign\tissue=%s\ttask=%s\twriter=%s\tgeneration=1\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$identifier" "$task" "$writer" >> "$(history_path "$identifier")" + chmod 600 "$(history_path "$identifier")" + printf 'assigned: issue=%s task=%s writer=%s\n' "$identifier" "$task" "$writer" + else + status=1 + fi + lock_release + [ "$status" -eq 0 ] || die "Linear ticket already has a different writer or task assignment: $identifier" +} + +cmd_transfer() { + local identifier=${1-} expected=${2-} replacement=${3-} generation status=0 + [ "$#" -eq 3 ] || usage + valid_identifier "$identifier" || die "invalid Linear identifier: $identifier" + valid_actor "$expected" || die "invalid expected writer id: $expected" + valid_actor "$replacement" || die "invalid replacement writer id: $replacement" + [ "$expected" != "$replacement" ] || die "replacement writer must differ from current writer" + lock_acquire + if ! load_lease "$identifier" || [ "$LINEAR_LEASE_WRITER" != "$expected" ]; then + status=1 + else + generation=$((LINEAR_LEASE_GENERATION + 1)) + write_lease_locked "$identifier" "$LINEAR_LEASE_ISSUE_ID" "$LINEAR_LEASE_URL" \ + "$LINEAR_LEASE_TASK" "$replacement" "$generation" || status=1 + if [ "$status" -eq 0 ]; then + printf '%s\ttransfer\tissue=%s\ttask=%s\tfrom=%s\twriter=%s\tgeneration=%s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$identifier" "$LINEAR_LEASE_TASK" \ + "$expected" "$replacement" "$generation" >> "$(history_path "$identifier")" + chmod 600 "$(history_path "$identifier")" + printf 'transferred: issue=%s task=%s writer=%s generation=%s\n' \ + "$identifier" "$LINEAR_LEASE_TASK" "$replacement" "$generation" + fi + fi + lock_release + [ "$status" -eq 0 ] || die "writer transfer refused; expected writer does not hold ticket: $identifier" +} + +assert_writer() { # + local identifier=$1 task=$2 writer=$3 + if ! valid_identifier "$identifier" || ! valid_actor "$task" || ! valid_actor "$writer"; then + die "invalid writer assertion" + fi + load_lease "$identifier" || die "no valid writer assignment for $identifier" + [ "$LINEAR_LEASE_TASK" = "$task" ] && [ "$LINEAR_LEASE_WRITER" = "$writer" ] \ + || die "Linear write authority denied for $identifier" +} + +cmd_assert_writer() { + [ "$#" -eq 3 ] || usage + assert_writer "$1" "$2" "$3" + printf 'writer-authorized: issue=%s task=%s writer=%s\n' "$1" "$2" "$3" +} + +cmd_assert_target() { + [ "$#" -eq 4 ] || usage + assert_writer "$1" "$2" "$3" + [ "$1" = "$4" ] || die "assigned writer for $1 may not update $4" + printf 'target-authorized: issue=%s task=%s writer=%s\n' "$1" "$2" "$3" +} + +append_brief_section() { # + local brief=$1 marker=$2 body=$3 staged mode + [ -f "$brief" ] && [ ! -L "$brief" ] || die "task brief does not exist: $brief" + if grep -Fqx "$marker" "$brief"; then + die "task brief already contains $marker" + fi + mode=$(fm_pr_file_mode "$brief") || die "cannot read task brief mode" + staged=$(mktemp "${brief%/*}/.brief.XXXXXX") || die "cannot stage task brief" + if { cat "$brief"; printf '\n%s\n%s\n' "$marker" "$body"; } > "$staged" \ + && chmod "$mode" "$staged" \ + && mv "$staged" "$brief"; then + return 0 + fi + rm -f "$staged" + die "cannot update task brief" +} + +replace_owner_assertion() { # + local brief=$1 replacement=$2 staged mode count + mode=$(fm_pr_file_mode "$brief") || die "cannot read task brief mode" + count=$(grep -c '^Before every Linear mutation, run:' "$brief" || true) + [ "$count" -eq 1 ] || die "existing owner brief has an invalid writer assertion" + staged=$(mktemp "${brief%/*}/.brief.XXXXXX") || die "cannot stage task brief" + if awk -v replacement="$replacement" ' + /^Before every Linear mutation, run:/ { print replacement; next } + { print } + ' "$brief" > "$staged" && chmod "$mode" "$staged" && mv "$staged" "$brief"; then + return 0 + fi + rm -f "$staged" + die "cannot update task brief" +} + +cmd_owner_brief() { + local identifier=${1-} task=${2-} writer=${3-} brief body assertion + [ "$#" -eq 3 ] || usage + assert_writer "$identifier" "$task" "$writer" + brief="$DATA/$task/brief.md" + assertion="Before every Linear mutation, run: bin/fm-linear-ticket-writer.sh assert-target $identifier $task $writer " + body=$(printf '%s\n' \ + "You are the sole Linear writer for $identifier." \ + "You may update only $identifier." \ + "$assertion" \ + "Use Linear MCP for ticket status, comments, and Workpad updates." \ + "Create or update exactly one \`## Firstmate Workpad\` on $identifier." \ + "Record the accepted plan, progress, blockers, PR URL, review outcomes, and fixes on $identifier." \ + "Do not update another Linear issue." \ + "Do not modify Firstmate's local backlog directly." \ + "Do not mark the ticket Done until its PR is verified merged." \ + "If Linear MCP is unavailable, report a blocker; the poller never becomes a fallback writer.") + if grep -Fqx "# Linear ticket ownership" "$brief"; then + replace_owner_assertion "$brief" "$assertion" + else + append_brief_section "$brief" "# Linear ticket ownership" "$body" + fi + printf 'owner-brief: %s\n' "$brief" +} + +cmd_planner_brief() { + local identifier=${1-} task=${2-} brief body + [ "$#" -eq 2 ] || usage + valid_identifier "$identifier" || die "invalid Linear identifier: $identifier" + valid_actor "$task" || die "invalid Firstmate task id: $task" + brief="$DATA/$task/brief.md" + body=$(printf '%s\n' \ + "You are planning or reviewing $identifier, but you hold no Linear write authority." \ + "Do not create comments, edit the Workpad, change status, change assignment, or perform any other Linear mutation." \ + "Report plans and review findings through Firstmate so the assigned ticket owner can record them in Linear.") + append_brief_section "$brief" "# Linear write restriction" "$body" + printf 'planner-brief: %s\n' "$brief" +} + +cmd_show() { + local identifier=${1-} + [ "$#" -eq 1 ] || usage + valid_identifier "$identifier" || die "invalid Linear identifier: $identifier" + load_lease "$identifier" || die "no valid writer assignment for $identifier" + cat "$(lease_path "$identifier")" +} + +case "${1-}" in + assign) shift; cmd_assign "$@" ;; + transfer) shift; cmd_transfer "$@" ;; + assert-writer) shift; cmd_assert_writer "$@" ;; + assert-target) shift; cmd_assert_target "$@" ;; + owner-brief) shift; cmd_owner_brief "$@" ;; + planner-brief) shift; cmd_planner_brief "$@" ;; + show) shift; cmd_show "$@" ;; + ''|-h|--help|help) usage ;; + *) die "unknown command: $1" ;; +esac diff --git a/bin/fm-procevent-linear.sh b/bin/fm-procevent-linear.sh new file mode 100755 index 00000000000..13303d3e510 --- /dev/null +++ b/bin/fm-procevent-linear.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Read-only Linear GraphQL process-event adapter. +# +# Usage: +# fm-procevent-linear.sh arm +# fm-procevent-linear.sh retire +# fm-procevent-linear.sh poll +# fm-procevent-linear.sh poll-once +# fm-procevent-linear.sh source-id +# fm-procevent-linear.sh classify +# fm-procevent-linear.sh terminal +# fm-procevent-linear.sh silent +# fm-procevent-linear.sh read +# +# The adapter owns detection only. Every GraphQL document below is a named query, +# and the adapter has no mutation command or fallback writer. It records a private +# observation snapshot so an unchanged Linear response produces no process result, +# wake, or model call. The generic process-event runner owns durable capture after +# this command prints an event. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +GRAPHQL_URL="${FM_LINEAR_GRAPHQL_URL:-https://api.linear.app/graphql}" +POLL_INTERVAL="${FM_LINEAR_POLL_INTERVAL:-30}" + +die() { printf 'error: %s\n' "$1" >&2; exit 1; } +usage() { sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 2; } + +require_runtime() { + command -v curl >/dev/null 2>&1 || die "curl is required" + command -v jq >/dev/null 2>&1 || die "jq is required" + [ -n "${LINEAR_API_KEY:-}" ] || die "LINEAR_API_KEY is required" + case "$POLL_INTERVAL" in + ''|*[!0-9]*) die "FM_LINEAR_POLL_INTERVAL must be whole seconds: $POLL_INTERVAL" ;; + esac +} + +canonical_config() { + local config=${1-} real + [ -n "$config" ] || usage + real=$(perl -MCwd=realpath -e '$p = realpath($ARGV[0]); defined($p) or exit 1; print "$p\n"' "$config" 2>/dev/null) \ + || die "cannot resolve Linear poll config: $config" + [ -f "$real" ] && [ ! -L "$real" ] || die "Linear poll config is not a regular file: $config" + jq -e ' + .schema == "fm-linear-poll.v1" + and (.projects | type == "array" and length > 0) + and all(.projects[]; + (.linearProjectSlug | type == "string" and length > 0) + and (.linearProjectName | type == "string" and length > 0) + and (.firstmateProject | type == "string" and length > 0)) + and ((.allowIssues // []) | type == "array") + ' "$real" >/dev/null 2>&1 || die "invalid Linear poll config: $config" + printf '%s\n' "$real" +} + +cmd_source_id() { + local config real digest + config=${1-} + [ "$#" -eq 1 ] || usage + real=$(canonical_config "$config") || exit 1 + if command -v shasum >/dev/null 2>&1; then + digest=$(printf '%s' "$real" | shasum -a 256 | awk '{print substr($1,1,16)}') + else + digest=$(printf '%s' "$real" | sha256sum | awk '{print substr($1,1,16)}') + fi + printf 'linear-%s\n' "$digest" +} + +snapshot_path() { + local id=$1 + printf '%s/linear-poll/%s.snapshot.json\n' "$STATE" "$id" +} + +graphql_query() { # + local query=$1 variables=$2 response + response=$(curl -fsS -X POST "$GRAPHQL_URL" \ + -H "Authorization: $LINEAR_API_KEY" \ + -H 'Content-Type: application/json' \ + --data-binary "$(jq -cn --arg query "$query" --argjson variables "$variables" \ + '{query:$query,variables:$variables}')") \ + || die "Linear GraphQL query failed" + printf '%s' "$response" | jq -e '.errors == null and (.data | type == "object")' >/dev/null 2>&1 \ + || die "Linear GraphQL returned errors" + printf '%s\n' "$response" +} + +# shellcheck disable=SC2016 # GraphQL variables are literal dollar-prefixed names. +ISSUES_QUERY='query FirstmateLinearIssues($projectSlug: String!, $stateNames: [String!]!, $first: Int!, $after: String) { + issues(filter: {project: {slugId: {eq: $projectSlug}}, state: {name: {in: $stateNames}}}, first: $first, after: $after) { + nodes { + id identifier title url updatedAt + project { id name slugId } + state { name type } + inverseRelations(first: 50) { nodes { type issue { id identifier state { name type } } } } + } + pageInfo { hasNextPage endCursor } + } +}' + +# shellcheck disable=SC2016 # GraphQL variables are literal dollar-prefixed names. +COMMENTS_QUERY='query FirstmateLinearComments($issueId: String!, $first: Int!, $after: String) { + issue(id: $issueId) { + comments(first: $first, after: $after) { + nodes { id createdAt updatedAt } + pageInfo { hasNextPage endCursor } + } + } +}' + +fetch_project_issues() { # + local slug=$1 states=$2 after='' page all='[]' variables + while :; do + variables=$(jq -cn --arg slug "$slug" --argjson states "$states" --arg after "$after" ' + {projectSlug:$slug,stateNames:$states,first:50,after:(if $after == "" then null else $after end)}') + page=$(graphql_query "$ISSUES_QUERY" "$variables") || return 1 + all=$(jq -cn --argjson accumulated "$all" --argjson page "$page" \ + '$accumulated + ($page.data.issues.nodes // [])') || return 1 + [ "$(printf '%s' "$page" | jq -r '.data.issues.pageInfo.hasNextPage // false')" = true ] || break + after=$(printf '%s' "$page" | jq -r '.data.issues.pageInfo.endCursor // empty') + [ -n "$after" ] || die "Linear issue pagination omitted endCursor" + done + printf '%s\n' "$all" +} + +fetch_issue_comments() { # + local issue_id=$1 after='' page all='[]' variables + while :; do + variables=$(jq -cn --arg issue "$issue_id" --arg after "$after" ' + {issueId:$issue,first:50,after:(if $after == "" then null else $after end)}') + page=$(graphql_query "$COMMENTS_QUERY" "$variables") || return 1 + all=$(jq -cn --argjson accumulated "$all" --argjson page "$page" \ + '$accumulated + ($page.data.issue.comments.nodes // [])') || return 1 + [ "$(printf '%s' "$page" | jq -r '.data.issue.comments.pageInfo.hasNextPage // false')" = true ] || break + after=$(printf '%s' "$page" | jq -r '.data.issue.comments.pageInfo.endCursor // empty') + [ -n "$after" ] || die "Linear comment pagination omitted endCursor" + done + printf '%s\n' "$all" +} + +poll_cycle() { # : prints one envelope only on change + local config=$1 id=$2 snapshot previous='{"issues":{},"comments":{}}' first_observation=true + local states allow project slug project_name fm_project issues issue issue_id comments + local current events='[]' staged + snapshot=$(snapshot_path "$id") + if [ -f "$snapshot" ]; then + jq -e '.issues | type == "object"' "$snapshot" >/dev/null 2>&1 \ + || die "invalid Linear poll snapshot: $snapshot" + previous=$(jq -c . "$snapshot") || exit 1 + first_observation=false + fi + # Carry forward every previously observed issue/comment identity rather than + # rebuilding from only this cycle's active-state issues: an issue that + # leaves the active states and later returns must not look brand-new, or + # its already-seen comments replay as fresh comment.detected events. + current=$previous + states=$(jq -c '.activeStates // ["Todo", "In Progress", "Blocked", "Human Review"]' "$config") + allow=$(jq -c '.allowIssues // []' "$config") + + while IFS= read -r project; do + slug=$(printf '%s' "$project" | jq -r '.linearProjectSlug') + project_name=$(printf '%s' "$project" | jq -r '.linearProjectName') + fm_project=$(printf '%s' "$project" | jq -r '.firstmateProject') + issues=$(fetch_project_issues "$slug" "$states") || return 1 + while IFS= read -r issue; do + [ -n "$issue" ] || continue + issue_id=$(printf '%s' "$issue" | jq -r '.id') + current=$(jq -cn --argjson current "$current" --argjson issue "$issue" \ + '$current | .issues[$issue.id] = {identifier:$issue.identifier,state:$issue.state.name,updatedAt:$issue.updatedAt,url:$issue.url}') + comments=$(fetch_issue_comments "$issue_id") || return 1 + current=$(jq -cn --argjson current "$current" --argjson comments "$comments" ' + reduce $comments[] as $comment ($current; + .comments[$comment.id] = {createdAt:$comment.createdAt,updatedAt:$comment.updatedAt})') + + if [ "$(printf '%s' "$issue" | jq -r '.state.name')" = Todo ] \ + && printf '%s' "$issue" | jq -e ' + [(.inverseRelations.nodes // [])[] + | select((.type | ascii_downcase) == "blocks") + | select((.issue.state.type | ascii_downcase) != "completed") + | select((.issue.state.type | ascii_downcase) != "canceled")] + | length == 0' >/dev/null \ + && { [ "$(printf '%s' "$allow" | jq 'length')" -eq 0 ] \ + || printf '%s' "$allow" | jq -e --arg identifier "$(printf '%s' "$issue" | jq -r '.identifier')" \ + 'index($identifier) != null' >/dev/null; } \ + && ! printf '%s' "$previous" | jq -e --arg issue "$issue_id" '.issues[$issue] != null' >/dev/null; then + events=$(jq -cn --argjson events "$events" --argjson issue "$issue" \ + --arg project "$project_name" --arg firstmateProject "$fm_project" ' + $events + [{eventType:"todo.detected",issueId:$issue.id,identifier:$issue.identifier, + title:$issue.title,projectName:$project,firstmateProject:$firstmateProject,url:$issue.url, + observedUpdatedAt:$issue.updatedAt}]') + fi + + if [ "$first_observation" = false ]; then + events=$(jq -cn --argjson events "$events" --argjson issue "$issue" --argjson comments "$comments" \ + --argjson previous "$previous" --arg project "$project_name" --arg firstmateProject "$fm_project" ' + reduce ($comments[] | select($previous.comments[.id] == null)) as $comment ($events; + . + [{eventType:"comment.detected",issueId:$issue.id,identifier:$issue.identifier, + commentId:$comment.id,projectName:$project,firstmateProject:$firstmateProject,url:$issue.url, + commentCreatedAt:$comment.createdAt,commentUpdatedAt:$comment.updatedAt}])') + fi + done < <(printf '%s' "$issues" | jq -c '.[]') + done < <(jq -c '.projects[]' "$config") + + mkdir -p "$(dirname "$snapshot")" || die "cannot create Linear poll state directory" + staged=$(mktemp "$(dirname "$snapshot")/.snapshot.XXXXXX") || die "cannot stage Linear poll snapshot" + printf '%s\n' "$current" | jq -S . > "$staged" || { rm -f "$staged"; die "cannot write Linear poll snapshot"; } + chmod 600 "$staged" 2>/dev/null || true + mv "$staged" "$snapshot" || { rm -f "$staged"; die "cannot publish Linear poll snapshot"; } + + [ "$(printf '%s' "$events" | jq 'length')" -gt 0 ] || return 0 + jq -cn --arg schema fm-linear-event.v1 --arg source "$id" --argjson events "$events" \ + '{schema:$schema,sourceId:$source,events:$events}' +} + +cmd_poll_once() { + local config real id + config=${1-} + [ "$#" -eq 1 ] || usage + require_runtime + real=$(canonical_config "$config") || exit 1 + id=$(cmd_source_id "$real") || exit 1 + poll_cycle "$real" "$id" +} + +cmd_poll() { + local config real id result + config=${1-} + [ "$#" -eq 1 ] || usage + require_runtime + real=$(canonical_config "$config") || exit 1 + id=$(cmd_source_id "$real") || exit 1 + while :; do + result=$(poll_cycle "$real" "$id") || return 1 + if [ -n "$result" ]; then + printf '%s\n' "$result" + return 0 + fi + sleep "$POLL_INTERVAL" + done +} + +cmd_arm() { + local config real id + config=${1-} + [ "$#" -eq 1 ] || usage + require_runtime + real=$(canonical_config "$config") || exit 1 + id=$(cmd_source_id "$real") || exit 1 + "$SCRIPT_DIR/fm-procevent.sh" register linear "$id" \ + -- "$SCRIPT_DIR/fm-procevent-linear.sh" poll "$real" || exit 1 + printf 'armed: %s\nconfig: %s\n' "$id" "$real" +} + +cmd_retire() { + local config=${1-} id + [ "$#" -eq 1 ] || usage + id=$(cmd_source_id "$config") || exit 1 + "$SCRIPT_DIR/fm-procevent.sh" retire "$id" +} + +cmd_classify() { + jq -e '.schema == "fm-linear-event.v1" and (.events | length > 0)' "${1-}" >/dev/null 2>&1 \ + && printf 'changed\n' || printf 'unknown\n' +} + +cmd_terminal() { return 1; } +cmd_silent() { return 1; } + +cmd_read() { + local file=${1-} + [ -f "$file" ] || die "result file does not exist: $file" + jq -r ' + "LINEAR EVENTS: \(.events | length)", + (.events[] | "event_type: \(.eventType)\nidentifier: \(.identifier)\nproject: \(.projectName)\nurl: \(.url)" + + (if .commentId then "\ncomment_id: \(.commentId)" else "" end)) + ' "$file" +} + +case "${1-}" in + arm) shift; cmd_arm "$@" ;; + retire) shift; cmd_retire "$@" ;; + poll) shift; cmd_poll "$@" ;; + poll-once) shift; cmd_poll_once "$@" ;; + source-id) shift; cmd_source_id "$@" ;; + classify) shift; cmd_classify "$@" ;; + terminal) shift; cmd_terminal "$@" ;; + silent) shift; cmd_silent "$@" ;; + read) shift; cmd_read "$@" ;; + ''|-h|--help|help) usage ;; + *) die "unknown command: $1" ;; +esac diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 3f01bc81005..40f1a14a5f0 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -548,6 +548,7 @@ tests/fm-herdr-version-floor-live-e2e.test.sh 20 tests/fm-inactive-reconcile.test.sh 41671 tests/fm-kimi-harness.test.sh 15092 tests/fm-lint-workflows.test.sh 744 +tests/fm-linear-ticket-writer.test.sh 1500 tests/fm-muse-harness.test.sh 27414 tests/fm-muse-signals-live-e2e.test.sh 21 tests/fm-on.test.sh 8602 @@ -560,6 +561,7 @@ tests/fm-pi-watch-extension.test.sh 17979 tests/fm-pr-check-security.test.sh 250417 tests/fm-procevent-when.test.sh 15249 tests/fm-procevent.test.sh 53142 +tests/fm-procevent-linear.test.sh 5000 tests/fm-project-origin.test.sh 105 tests/fm-public-followup.test.sh 36301 tests/fm-quota-array-dispatch-live-e2e.test.sh 18 @@ -1166,6 +1168,12 @@ families_for_changed_path() { printf '%s\n' __script__:fm-procevent-when.test.sh printf '%s\n' __script__:fm-remote-reply.test.sh ;; + bin/fm-procevent-linear.sh) + printf '%s\n' __script__:fm-procevent-linear.test.sh + ;; + bin/fm-linear-ticket-writer.sh) + printf '%s\n' __script__:fm-linear-ticket-writer.test.sh + ;; bin/fm-timeout-lib.sh) # The shared hard bound: session start's runtime bound, the fleet/bearings # snapshots, the vendor auth probe, the stow cascade's per-home step, and diff --git a/docs/configuration.md b/docs/configuration.md index 99e1c1fd608..ebd26f8ec9f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -741,6 +741,52 @@ The published `lavish-axi poll` clears feedback destructively before returning i Never describe this path as at-least-once, no-loss, or lossless. `docs/verification/process-event-sources.md` holds the measurements and `.agents/skills/process-event-sources/SKILL.md` owns the handling procedure. +### Linear Todo and comment polling + +`bin/fm-procevent-linear.sh` is a built-in read-only Linear GraphQL adapter. +It runs named GraphQL `query` operations every 30 seconds, emits eligible mapped Todos and newly observed comments, and stays silent when its private snapshot is unchanged. +It has no mutation command and never comments, changes status, assigns a worker, creates a Workpad, or edits Firstmate's backlog. + +Copy [`docs/examples/linear-poll.json`](examples/linear-poll.json) to the gitignored `config/linear-poll.json`, then replace each example mapping with the Linear project slug, display name, and matching Firstmate project name. +`activeStates` controls which issues remain visible for comment detection. +An empty or omitted `allowIssues` watches every mapped issue; a non-empty list limits detection during a rollout or focused proof. +The poller obtains the canonical ticket URL from Linear's API response and carries those exact bytes into the captured event. + +Export a personal Linear API key as `LINEAR_API_KEY` in the environment that runs Firstmate. +The adapter sends it only in the `Authorization` header to `https://api.linear.app/graphql`; it never stores the value in config, argv, snapshots, results, task briefs, or Git. +Arm and retire the source through the adapter: + +```sh +bin/fm-procevent-linear.sh arm config/linear-poll.json +bin/fm-procevent-linear.sh retire config/linear-poll.json +``` + +The source's stable id is derived from the physical config path, so one config has one machine-wide process-event owner. +Existing comments are baselined on first observation and do not create historical-comment wakes. +A currently eligible Todo is emitted on first observation, while a blocker relation whose blocking issue is not completed or canceled suppresses Todo intake. +The detector snapshot under `state/linear-poll/` is observation state only and grants no ticket authority. + +On a `procevent linear ...` wake, `linear-ticket-intake` owns Linear MCP re-fetch, project and blocker validation, local duplicate checks, assignment, comment routing, and handled acknowledgement. +Firstmate creates exactly one lease with `bin/fm-linear-ticket-writer.sh assign`, gives the persistent Luna implementation worker the owner brief, and gives a Sol planner or reviewer the no-write brief. +The lease record has this fixed current-state shape: + +```text +issue=HAN-28 +task= +writer= +issue_id= +url= +generation= +``` + +Only the assigned task and writer pass `assert-writer` and `assert-target`, and the target assertion refuses any identifier other than the leased issue. +An exact repeated assignment is idempotent, while a different task or writer is refused, preventing repeat poll events from creating duplicate local ownership. +Writer replacement requires `transfer `, increments the generation, updates the current lease, and appends an immutable transfer row to the issue history before the replacement owner brief is refreshed. + +Luna is the sole Linear writer: it moves the issue to In Progress, maintains exactly one `## Firstmate Workpad`, records plan, progress, blockers, PR and review state, moves to Human Review only after delivery gates, and marks Done only after verifying the PR merged. +Sol reports planning and review findings through Firstmate and performs no Linear mutation. +Firstmate may read Linear to reconcile but does not duplicate Luna's ticket writes, and the poller never inherits writer authority when Linear MCP is unavailable to Luna. + ## Spoken interface and captain inbox (config/voice-*, config/inbox-*) The spoken interface in [`docs/voice-relay.md`](voice-relay.md) and the model-backed subcommands of `bin/fm-inbox.sh` reach a paid API in a named account, so no region, model id or AWS profile is shipped as a tracked default. diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 8bb68bd4ba2..669bc1eba13 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -208,6 +208,10 @@ "path": ".agents/skills/harness-adapters/references/harness/pi.md", "audience": "agent-runtime" }, + { + "path": ".agents/skills/linear-ticket-intake/SKILL.md", + "audience": "agent-runtime" + }, { "path": ".agents/skills/process-event-sources/SKILL.md", "audience": "agent-runtime" @@ -316,6 +320,10 @@ "path": "docs/examples/crew-dispatch.json", "audience": "operator-example" }, + { + "path": "docs/examples/linear-poll.json", + "audience": "operator-example" + }, { "path": "docs/examples/process-event-extension/file-signal.mjs", "audience": "operator-example" diff --git a/docs/examples/linear-poll.json b/docs/examples/linear-poll.json new file mode 100644 index 00000000000..628c9a4dfba --- /dev/null +++ b/docs/examples/linear-poll.json @@ -0,0 +1,17 @@ +{ + "schema": "fm-linear-poll.v1", + "activeStates": [ + "Todo", + "In Progress", + "Blocked", + "Human Review" + ], + "projects": [ + { + "linearProjectSlug": "replace-with-linear-project-slug", + "linearProjectName": "Replace with Linear project name", + "firstmateProject": "Replace with Firstmate project name" + } + ], + "allowIssues": [] +} diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index a284cbe8eb6..8d12497d331 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -25,6 +25,7 @@ # This is the direct regression pair for the 2026-07-02 herdr incident, # proving the watcher's own absorb-only-when-provably-working predicate # benefits from the fix in both directions. +# (l) a passed run with an open GitHub PR is never reported as merged/closed set -u # shellcheck source=tests/lib.sh @@ -78,6 +79,36 @@ case "${1:-}" in printf '%s\n' "${FM_FAKE_RUNS_LIST:-}" ;; esac exit 0 +SH + cat > "$fb/gh-axi" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" = pr ] && [ "${2:-}" = view ]; then + [ -z "${FM_FAKE_GH_CALLS:-}" ] || printf '%s\n' "$*" >> "$FM_FAKE_GH_CALLS" + [ "${FM_FAKE_GH_PR_VIEW:-ok}" = unavailable ] && exit 1 + if [ "${FM_FAKE_GH_PR_VIEW:-ok}" = malformed ]; then + printf 'error: pull request state unavailable\n' + exit 0 + fi + number=${3:-1} + repo_arg="" + shift 3 + while [ $# -gt 0 ]; do + case "$1" in + --repo) repo_arg=${2:-}; shift 2 ;; + *) shift ;; + esac + done + # Real gh-axi resolves --repo authoritatively: an unexpected repo/PR pair + # (a stale or foreign-fork link) is not found, exactly as GitHub would 404 it. + if [ -n "${FM_FAKE_GH_REPO_EXPECTED:-}" ] && [ "$repo_arg" != "$FM_FAKE_GH_REPO_EXPECTED" ]; then + exit 1 + fi + printf 'pull_request:\n number: %s\n state: %s\n' \ + "$number" "${FM_FAKE_GH_PR_STATE:-open}" + exit 0 +fi +exit 1 SH cat > "$fb/tmux" <<'SH' #!/usr/bin/env bash @@ -122,7 +153,7 @@ case "${1:-}" in esac exit 0 SH - chmod +x "$fb/no-mistakes" "$fb/tmux" "$fb/herdr" + chmod +x "$fb/no-mistakes" "$fb/tmux" "$fb/herdr" "$fb/gh-axi" printf '%s\n' "$fb" } @@ -170,8 +201,13 @@ reset_fakes() { FM_FAKE_HERDR_MISSING=0 FM_FAKE_HERDR_AGENT_STATUS="" FM_FAKE_CI_LOGS="" + FM_FAKE_GH_PR_VIEW=ok + FM_FAKE_GH_PR_STATE=open + FM_FAKE_GH_REPO_EXPECTED= + FM_FAKE_GH_CALLS= export FM_FAKE_AXI_STATUS FM_FAKE_AXI_STATUS_RUN FM_FAKE_RUNS_LIST FM_FAKE_BUSY FM_FAKE_BUSY_TEXT FM_FAKE_TMUX_MISSING export FM_FAKE_HERDR_BUSY FM_FAKE_HERDR_MISSING FM_FAKE_HERDR_AGENT_STATUS FM_FAKE_CI_LOGS + export FM_FAKE_GH_PR_VIEW FM_FAKE_GH_PR_STATE FM_FAKE_GH_REPO_EXPECTED FM_FAKE_GH_CALLS } # --- run-object fixtures (TOON, as `no-mistakes axi status` emits) ----------- @@ -278,6 +314,19 @@ outcome: passed EOF } +run_passed_local() { # + cat < cat </dev/null + fm_write_meta "$d/state/feat-open-pr.meta" "window=fm:fm-feat-open-pr" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_passed fm/feat-open-pr)" + FM_FAKE_GH_PR_STATE=open + FM_FAKE_GH_REPO_EXPECTED=o/r + local out; out=$(FM_FAKE_GH_CALLS="$d/gh.calls" run_crew_state "$d" feat-open-pr) + assert_contains "$out" "state: done" "passed run with open PR remains locally done" + assert_contains "$out" "run passed: PR open (not merged/closed)" "live open PR state is reported" + assert_not_contains "$out" "PR merged/closed" "open PR is never reported as merged/closed" + assert_grep 'pr view 1 --repo o/r' "$d/gh.calls" "the linked GitHub PR was checked live in its own repo" + pass "passed run with open PR is not falsely reported as merged/closed" +} + +test_terminal_passed_unverified_pr_not_reported_merged() { + reset_fakes + local d; d=$(new_case passed-unverified-pr) + make_repo_on_branch "$d/wt" fm/feat-unverified-pr + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-unverified-pr.meta" "window=fm:fm-feat-unverified-pr" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_passed fm/feat-unverified-pr)" + FM_FAKE_GH_PR_VIEW=unavailable + local out; out=$(run_crew_state "$d" feat-unverified-pr) + assert_contains "$out" "run passed: PR state unverified" "unverified PR state is explicit" + assert_not_contains "$out" "PR merged/closed" "unverified PR is never reported as merged/closed" + pass "unverified passed PR is not falsely reported as merged/closed" +} + +test_terminal_passed_mismatched_repo_pr_not_reported_merged() { + reset_fakes + local d; d=$(new_case passed-mismatched-repo-pr) + make_repo_on_branch "$d/wt" fm/feat-mismatched-pr + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-mismatched-pr.meta" "window=fm:fm-feat-mismatched-pr" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(cat </dev/null + fm_write_meta "$d/state/feat-merged-pr.meta" "window=fm:fm-feat-merged-pr" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_passed fm/feat-merged-pr)" + FM_FAKE_GH_PR_STATE=merged + FM_FAKE_GH_REPO_EXPECTED=o/r + local out; out=$(run_crew_state "$d" feat-merged-pr) + assert_contains "$out" "run passed: PR merged/closed" "live merged PR state permits the merge claim" + pass "verified merged PR retains the merged/closed claim" +} + +test_terminal_passed_local_only_stays_done() { + reset_fakes + local d; d=$(new_case passed-local-only) + make_repo_on_branch "$d/wt" fm/feat-local-only + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-local-only.meta" "window=fm:fm-feat-local-only" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_passed_local fm/feat-local-only)" + local out; out=$(run_crew_state "$d" feat-local-only) + assert_contains "$out" "state: done" "local-only passed run remains done" + assert_contains "$out" "run passed: local work complete" "local-only completion remains visible" + assert_not_contains "$out" "state unverified" "local-only completion is not downgraded to an unverifiable PR" + pass "passed local-only work remains genuinely finished" +} + test_terminal_failed() { reset_fakes local d; d=$(new_case failed) @@ -1567,6 +1701,11 @@ test_ci_fixing_after_green_stays_working test_top_level_fixing_ci_running_after_green_stays_working test_top_level_fixing_done_log_stays_working test_terminal_passed +test_terminal_passed_open_pr_not_reported_merged +test_terminal_passed_unverified_pr_not_reported_merged +test_terminal_passed_mismatched_repo_pr_not_reported_merged +test_terminal_passed_merged_pr_reports_merged +test_terminal_passed_local_only_stays_done test_terminal_failed test_cross_branch_attribution_via_runs_list test_cross_branch_attribution_picks_most_recent_row diff --git a/tests/fm-linear-ticket-writer.test.sh b/tests/fm-linear-ticket-writer.test.sh new file mode 100755 index 00000000000..e8adf9354c1 --- /dev/null +++ b/tests/fm-linear-ticket-writer.test.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Behavior tests for exclusive Linear writer assignments and role briefs. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TMP_ROOT=$(fm_test_tmproot fm-linear-ticket-writer) +TMP_ROOT=$(cd "$TMP_ROOT" && pwd -P) +HOME_DIR="$TMP_ROOT/home" +OWNER_TASK=han-28-owner +PLANNER_TASK=han-28-plan +URL=https://linear.app/hanzireader/issue/HAN-28/make-m14-the-default-and-first-cameraonboarding-profile +mkdir -p "$HOME_DIR/state" "$HOME_DIR/data/$OWNER_TASK" "$HOME_DIR/data/$PLANNER_TASK" +printf '# Owner task\n' > "$HOME_DIR/data/$OWNER_TASK/brief.md" +printf '# Planner task\n' > "$HOME_DIR/data/$PLANNER_TASK/brief.md" +trap fm_test_cleanup EXIT + +writer() { FM_HOME="$HOME_DIR" "$ROOT/bin/fm-linear-ticket-writer.sh" "$@"; } + +out=$(writer assign issue-immutable-id HAN-28 "$URL" "$OWNER_TASK" luna-han-28) +assert_contains "$out" "assigned: issue=HAN-28 task=$OWNER_TASK writer=luna-han-28" \ + "assignment records the ticket, task, and sole writer" +lease="$HOME_DIR/state/linear-ticket-writers/HAN-28.lease" +assert_grep 'issue=HAN-28' "$lease" "lease omitted the assigned issue" +assert_grep "task=$OWNER_TASK" "$lease" "lease omitted the Firstmate task" +assert_grep 'writer=luna-han-28' "$lease" "lease omitted the writer" +pass "one durable Linear writer assignment is created" + +out=$(writer assign issue-immutable-id HAN-28 "$URL" "$OWNER_TASK" luna-han-28) +assert_contains "$out" "already-assigned" "an exact replay is idempotent" +set +e +out=$(writer assign issue-immutable-id HAN-28 "$URL" duplicate-task luna-duplicate 2>&1) +status=$? +set -e +expect_code 1 "$status" "a duplicate task and writer must be refused" +assert_contains "$out" "already has a different writer or task assignment" \ + "duplicate ownership refusal is explicit" +pass "repeated dispatch cannot create a second local owner" + +writer assert-writer HAN-28 "$OWNER_TASK" luna-han-28 >/dev/null \ + || fail "assigned Luna writer was not authorized" +set +e +writer assert-writer HAN-28 "$PLANNER_TASK" sol-han-28 >/dev/null 2>&1 +status=$? +set -e +expect_code 1 "$status" "Sol must not acquire Linear write authority" +set +e +out=$(writer assert-target HAN-28 "$OWNER_TASK" luna-han-28 HAN-27 2>&1) +status=$? +set -e +expect_code 1 "$status" "the HAN-28 writer must not target another issue" +assert_contains "$out" "may not update HAN-27" "cross-ticket denial names the forbidden target" +pass "only the assigned writer and assigned ticket pass the writer guard" + +writer owner-brief HAN-28 "$OWNER_TASK" luna-han-28 >/dev/null +owner_brief="$HOME_DIR/data/$OWNER_TASK/brief.md" +assert_grep 'You are the sole Linear writer for HAN-28.' "$owner_brief" \ + "owner brief omitted exclusive writer authority" +assert_grep 'You may update only HAN-28.' "$owner_brief" \ + "owner brief omitted ticket scope" +# shellcheck disable=SC2016 # Backticks are literal Markdown in the generated brief. +assert_grep 'Create or update exactly one `## Firstmate Workpad` on HAN-28.' "$owner_brief" \ + "owner brief omitted the single Workpad rule" +assert_grep "Do not modify Firstmate's local backlog directly." "$owner_brief" \ + "owner brief omitted Firstmate backlog ownership" +assert_grep 'Do not mark the ticket Done until its PR is verified merged.' "$owner_brief" \ + "owner brief omitted merge verification" +writer planner-brief HAN-28 "$PLANNER_TASK" >/dev/null +planner_brief="$HOME_DIR/data/$PLANNER_TASK/brief.md" +assert_grep 'You are planning or reviewing HAN-28, but you hold no Linear write authority.' "$planner_brief" \ + "Sol brief omitted the no-write role" +assert_grep 'Do not create comments, edit the Workpad, change status, change assignment, or perform any other Linear mutation.' \ + "$planner_brief" "Sol brief omitted the mutation prohibition" +pass "generated Luna and Sol briefs carry opposite Linear authority contracts" + +out=$(writer transfer HAN-28 luna-han-28 luna-han-28-replacement) +assert_contains "$out" "transferred: issue=HAN-28 task=$OWNER_TASK writer=luna-han-28-replacement generation=2" \ + "writer transfer reports the replacement and generation" +assert_grep 'writer=luna-han-28-replacement' "$lease" "current lease did not move to replacement writer" +writer owner-brief HAN-28 "$OWNER_TASK" luna-han-28-replacement >/dev/null +[ "$(grep -c '^# Linear ticket ownership$' "$owner_brief")" -eq 1 ] \ + || fail "writer transfer duplicated the owner brief" +assert_grep "assert-target HAN-28 $OWNER_TASK luna-han-28-replacement " "$owner_brief" \ + "replacement owner brief retained stale writer authority" +history="$HOME_DIR/state/linear-ticket-writers/HAN-28.history" +[ "$(grep -c $'\tassign\t' "$history")" -eq 1 ] || fail "history did not retain exactly one assignment" +[ "$(grep -c $'\ttransfer\t' "$history")" -eq 1 ] || fail "history did not retain exactly one transfer" +set +e +writer transfer HAN-28 luna-han-28 another-writer >/dev/null 2>&1 +status=$? +set -e +expect_code 1 "$status" "a stale writer cannot transfer the lease" +pass "writer replacement is explicit, generation-bound, and durable" + +printf 'ok: Linear ticket writer behavior tests passed\n' diff --git a/tests/fm-procevent-linear.test.sh b/tests/fm-procevent-linear.test.sh new file mode 100755 index 00000000000..e625edf3a39 --- /dev/null +++ b/tests/fm-procevent-linear.test.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# Behavior tests for the read-only Linear process-event adapter. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TMP_ROOT=$(fm_test_tmproot fm-procevent-linear-tests) +TMP_ROOT=$(cd "$TMP_ROOT" && pwd -P) +export FM_PROCEVENT_CLAIM_ROOT="$TMP_ROOT/claims" +HOME_DIR="$TMP_ROOT/home" +SERVER="$TMP_ROOT/fake-linear.py" +CONFIG="$TMP_ROOT/linear-poll.json" +PHASE="$TMP_ROOT/phase" +REQUESTS="$TMP_ROOT/requests.jsonl" +PORT_FILE="$TMP_ROOT/port" +RUNNER_LOG="$TMP_ROOT/runner.log" +RUNNER_PID='' +mkdir -p "$HOME_DIR/state" + +cat > "$SERVER" <<'PY' +import json +import pathlib +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +phase_path = pathlib.Path(sys.argv[1]) +requests_path = pathlib.Path(sys.argv[2]) +port_path = pathlib.Path(sys.argv[3]) + +issue = { + "id": "issue-immutable-id", + "identifier": "HAN-28", + "title": "Make M14 the default and first CameraOnboarding profile", + "url": "https://linear.app/hanzireader/issue/HAN-28/make-m14-the-default-and-first-cameraonboarding-profile", + "updatedAt": "2026-08-30T12:00:00.000Z", + "project": {"id": "project-id", "name": "Messsucher", "slugId": "messsucher-729853ec4ffb"}, + "state": {"name": "Todo", "type": "unstarted"}, + "inverseRelations": {"nodes": []}, +} + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + return + + def do_POST(self): + size = int(self.headers.get("Content-Length", "0")) + body = json.loads(self.rfile.read(size)) + query = body.get("query", "") + with requests_path.open("a", encoding="utf-8") as output: + output.write(json.dumps({"query": query, "variables": body.get("variables")}) + "\n") + if query.lstrip().startswith("mutation"): + self.send_response(409) + self.end_headers() + return + if "FirstmateLinearIssues" in query: + data = {"issues": {"nodes": [issue], "pageInfo": {"hasNextPage": False, "endCursor": None}}} + elif "FirstmateLinearComments" in query: + comments = [{"id": "comment-existing", "createdAt": "2026-08-29T10:00:00.000Z", "updatedAt": "2026-08-29T10:00:00.000Z"}] + if phase_path.exists() and phase_path.read_text(encoding="utf-8").strip() == "new-comment": + comments.append({"id": "comment-new", "createdAt": "2026-08-30T13:00:00.000Z", "updatedAt": "2026-08-30T13:00:00.000Z"}) + data = {"issue": {"comments": {"nodes": comments, "pageInfo": {"hasNextPage": False, "endCursor": None}}}} + else: + self.send_response(400) + self.end_headers() + return + payload = json.dumps({"data": data}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + +server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) +port_path.write_text(str(server.server_address[1]), encoding="utf-8") +server.serve_forever() +PY + +cat > "$CONFIG" <<'JSON' +{ + "schema": "fm-linear-poll.v1", + "projects": [ + { + "linearProjectSlug": "messsucher-729853ec4ffb", + "linearProjectName": "Messsucher", + "firstmateProject": "FilmLeica" + } + ], + "allowIssues": ["HAN-28"] +} +JSON + +python3 "$SERVER" "$PHASE" "$REQUESTS" "$PORT_FILE" & +SERVER_PID=$! +cleanup() { + FM_HOME="$HOME_DIR" "$ROOT/bin/fm-procevent.sh" sweep-home >/dev/null 2>&1 || true + [ -z "$RUNNER_PID" ] || kill "$RUNNER_PID" >/dev/null 2>&1 || true + kill "$SERVER_PID" >/dev/null 2>&1 || true + wait "$SERVER_PID" >/dev/null 2>&1 || true + fm_test_cleanup +} +trap cleanup EXIT + +for _ in $(seq 1 100); do + [ -s "$PORT_FILE" ] && break + sleep 0.02 +done +[ -s "$PORT_FILE" ] || fail "fake Linear GraphQL server did not start" +URL="http://127.0.0.1:$(cat "$PORT_FILE")/graphql" + +linear() { + FM_HOME="$HOME_DIR" FM_LINEAR_GRAPHQL_URL="$URL" FM_LINEAR_POLL_INTERVAL=1 \ + LINEAR_API_KEY=test-key "$ROOT/bin/fm-procevent-linear.sh" "$@" +} + +out=$(linear poll-once "$CONFIG") +assert_contains "$out" '"eventType":"todo.detected"' "first observation emits the eligible Todo" +assert_contains "$out" '"issueId":"issue-immutable-id"' "event carries the immutable Linear issue id" +assert_contains "$out" '"url":"https://linear.app/hanzireader/issue/HAN-28/make-m14-the-default-and-first-cameraonboarding-profile"' \ + "event preserves the exact API-provided Linear URL" +assert_not_contains "$out" comment-existing "existing comments are baselined without an event" +pass "eligible Todo detection is exact and existing comments are baselined" + +out=$(linear poll-once "$CONFIG") +[ -z "$out" ] || fail "unchanged snapshot produced output: $out" +pass "an unchanged Linear snapshot is silent" + +linear arm "$CONFIG" >/dev/null +source_id=$(linear source-id "$CONFIG") +FM_HOME="$HOME_DIR" FM_LINEAR_GRAPHQL_URL="$URL" FM_LINEAR_POLL_INTERVAL=1 LINEAR_API_KEY=test-key \ + "$ROOT/bin/fm-procevent.sh" start "$source_id" > "$RUNNER_LOG" 2>&1 & +RUNNER_PID=$! +sleep 0.3 +[ ! -s "$HOME_DIR/state/.wake-queue" ] || fail "unchanged polling created a wake" +[ -z "$(find "$HOME_DIR/state/procevent-inbox" -type f -name '*.result' -print 2>/dev/null)" ] \ + || fail "unchanged polling created a process result" +pass "unchanged background polling creates no wake or model-triggering result" + +printf 'new-comment\n' > "$PHASE" +for _ in $(seq 1 50); do + [ -s "$HOME_DIR/state/.wake-queue" ] && break + sleep 0.1 +done +if [ ! -s "$HOME_DIR/state/.wake-queue" ]; then + FM_HOME="$HOME_DIR" "$ROOT/bin/fm-procevent.sh" list >&2 || true + tail -40 "$RUNNER_LOG" >&2 || true + fail "new comment did not produce a wake" +fi +result=$(find "$HOME_DIR/state/procevent-inbox" -type f -name '*.result' -print | head -1) +[ -n "$result" ] || fail "new comment wake had no durable result" +out=$(linear read "$result") +assert_contains "$out" "event_type: comment.detected" "new comment is classified in the durable result" +assert_contains "$out" "identifier: HAN-28" "new comment retains the assigned issue identifier" +assert_contains "$out" "comment_id: comment-new" "new comment retains its immutable id" +assert_contains "$out" "url: https://linear.app/hanzireader/issue/HAN-28/make-m14-the-default-and-first-cameraonboarding-profile" \ + "comment event preserves the exact API URL" +pass "a new comment becomes one durable process event" + +linear retire "$CONFIG" >/dev/null +if jq -e -s 'any(.[]; (.query | ltrimstr(" ") | startswith("mutation")))' "$REQUESTS" >/dev/null; then + fail "poller attempted a GraphQL mutation" +fi +[ "$(jq -r -s '[.[].query | capture("^(?[A-Za-z]+)").kind] | unique | join(",")' "$REQUESTS")" = query ] \ + || fail "poller sent a non-query GraphQL operation" +pass "the fake GraphQL server observed query operations only" + +printf 'ok: Linear process-event adapter behavior tests passed\n'