From 8ac8055c4d0b62d6fd848ce63c1aa223b0ee3518 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 13 Oct 2026 09:02:24 -0500 Subject: [PATCH] researcher: row 99 round 1 packet, text-mode inbox fallback, phase B green (#1593) Built by a Haiku subagent (decision 129). cli 133/0, nine scripts green, 7 of 7 mutants killed. Manifest 5c979e49. Co-Authored-By: Claude Opus 5.5 --- agents/researcher/work/queue-99/r1/BUILD.md | 134 ++++++++ agents/researcher/work/queue-99/r1/base.txt | 1 + .../researcher/work/queue-99/r1/build.patch | 122 +++++++ .../queue-99/r1/candidate-manifest.sha256 | 2 + agents/researcher/work/queue-99/r1/files.txt | 2 + agents/researcher/work/queue-99/r1/mutants.py | 169 ++++++++++ .../work/queue-99/r1/mutants.py.run1 | 169 ++++++++++ .../queue-99/r1/out/M1-remove-fallback.log | 128 ++++++++ .../r1/out/M2-widen-to-every-error.log | 123 +++++++ .../queue-99/r1/out/M3-changed-line-text.log | 204 ++++++++++++ .../r1/out/M4-drop-decisions-in-fallback.log | 190 +++++++++++ .../r1/out/M5-fallback-exits-nonzero.log | 138 ++++++++ .../out/M6-partial-list-printed-as-count.log | 208 ++++++++++++ .../M7-read-messages-counted-as-unread.log | 170 ++++++++++ .../work/queue-99/r1/out/baseline.log | 106 ++++++ .../out/mutants-run1/M1-remove-fallback.log | 128 ++++++++ .../mutants-run1/M2-widen-to-every-error.log | 123 +++++++ .../out/mutants-run1/M3-changed-line-text.log | 204 ++++++++++++ .../M4-drop-decisions-in-fallback.log | 190 +++++++++++ .../M5-fallback-exits-nonzero.log | 138 ++++++++ .../M6-partial-list-printed-as-count.log | 37 +++ .../M7-read-messages-counted-as-unread.log | 170 ++++++++++ .../queue-99/r1/out/mutants-run1/results.json | 95 ++++++ .../r1/out/mutants-run1/step3-mutants-r2.log | 9 + .../work/queue-99/r1/out/results.json | 96 ++++++ .../queue-99/r1/out/step1-cli-suite-r2.log | 143 ++++++++ .../work/queue-99/r1/out/step1-cli-suite.log | 310 ++++++++++++++++++ .../work/queue-99/r1/out/step3-mutants-r2.log | 9 + .../work/queue-99/r1/out/test-auth-r2.log | 17 + .../queue-99/r1/out/test-conductor-r2.log | 55 ++++ .../work/queue-99/r1/out/test-config-r2.log | 26 ++ .../work/queue-99/r1/out/test-discord-r2.log | 70 ++++ .../r1/out/test-extension-package-r2.log | 21 ++ .../queue-99/r1/out/test-foundation-r2.log | 53 +++ .../work/queue-99/r1/out/test-queue-r2.log | 35 ++ .../work/queue-99/r1/out/test-release-r2.log | 16 + .../work/queue-99/r1/out/test-task-r2.log | 32 ++ 37 files changed, 3843 insertions(+) create mode 100644 agents/researcher/work/queue-99/r1/BUILD.md create mode 100644 agents/researcher/work/queue-99/r1/base.txt create mode 100644 agents/researcher/work/queue-99/r1/build.patch create mode 100644 agents/researcher/work/queue-99/r1/candidate-manifest.sha256 create mode 100644 agents/researcher/work/queue-99/r1/files.txt create mode 100644 agents/researcher/work/queue-99/r1/mutants.py create mode 100644 agents/researcher/work/queue-99/r1/mutants.py.run1 create mode 100644 agents/researcher/work/queue-99/r1/out/M1-remove-fallback.log create mode 100644 agents/researcher/work/queue-99/r1/out/M2-widen-to-every-error.log create mode 100644 agents/researcher/work/queue-99/r1/out/M3-changed-line-text.log create mode 100644 agents/researcher/work/queue-99/r1/out/M4-drop-decisions-in-fallback.log create mode 100644 agents/researcher/work/queue-99/r1/out/M5-fallback-exits-nonzero.log create mode 100644 agents/researcher/work/queue-99/r1/out/M6-partial-list-printed-as-count.log create mode 100644 agents/researcher/work/queue-99/r1/out/M7-read-messages-counted-as-unread.log create mode 100644 agents/researcher/work/queue-99/r1/out/baseline.log create mode 100644 agents/researcher/work/queue-99/r1/out/mutants-run1/M1-remove-fallback.log create mode 100644 agents/researcher/work/queue-99/r1/out/mutants-run1/M2-widen-to-every-error.log create mode 100644 agents/researcher/work/queue-99/r1/out/mutants-run1/M3-changed-line-text.log create mode 100644 agents/researcher/work/queue-99/r1/out/mutants-run1/M4-drop-decisions-in-fallback.log create mode 100644 agents/researcher/work/queue-99/r1/out/mutants-run1/M5-fallback-exits-nonzero.log create mode 100644 agents/researcher/work/queue-99/r1/out/mutants-run1/M6-partial-list-printed-as-count.log create mode 100644 agents/researcher/work/queue-99/r1/out/mutants-run1/M7-read-messages-counted-as-unread.log create mode 100644 agents/researcher/work/queue-99/r1/out/mutants-run1/results.json create mode 100644 agents/researcher/work/queue-99/r1/out/mutants-run1/step3-mutants-r2.log create mode 100644 agents/researcher/work/queue-99/r1/out/results.json create mode 100644 agents/researcher/work/queue-99/r1/out/step1-cli-suite-r2.log create mode 100644 agents/researcher/work/queue-99/r1/out/step1-cli-suite.log create mode 100644 agents/researcher/work/queue-99/r1/out/step3-mutants-r2.log create mode 100644 agents/researcher/work/queue-99/r1/out/test-auth-r2.log create mode 100644 agents/researcher/work/queue-99/r1/out/test-conductor-r2.log create mode 100644 agents/researcher/work/queue-99/r1/out/test-config-r2.log create mode 100644 agents/researcher/work/queue-99/r1/out/test-discord-r2.log create mode 100644 agents/researcher/work/queue-99/r1/out/test-extension-package-r2.log create mode 100644 agents/researcher/work/queue-99/r1/out/test-foundation-r2.log create mode 100644 agents/researcher/work/queue-99/r1/out/test-queue-r2.log create mode 100644 agents/researcher/work/queue-99/r1/out/test-release-r2.log create mode 100644 agents/researcher/work/queue-99/r1/out/test-task-r2.log diff --git a/agents/researcher/work/queue-99/r1/BUILD.md b/agents/researcher/work/queue-99/r1/BUILD.md new file mode 100644 index 000000000..30da079e5 --- /dev/null +++ b/agents/researcher/work/queue-99/r1/BUILD.md @@ -0,0 +1,134 @@ +# Row 99 (#1593) build, round 1 + +Phase A: built and packeted. No test has been run. The only check was +`node --check` (a parse, no test code runs) on both edited files, which passed. + +- BASE: `4233534da82ac15119358722eb6182c73cfd75b1` (base.txt) +- Owned paths changed: `packages/cli/src/cli.mjs`, `packages/cli/tests/cli.test.mjs` + (files.txt). Nothing else in the worktree changed. +- candidate-manifest.sha256 sha256 (of the file itself): + `3c3f5629a792b4a052d6c2c6e1bb2d5184109dca7537c3c75aff5089e278f0ad` + +## What changed and why + +`packages/cli/src/cli.mjs`, the `inbox` branch of `human()`. The `messages` +paging loop now runs inside a `try`. In text mode, when the error's `code` is +`unknown-verb`, the branch prints `formatInbox(decisions)`, a blank line, and +`messages: not available until the bus restarts`, then returns, so the exit is 0. +Any other error is rethrown unchanged. The `--json` branch returns before the +loop, so it is unchanged. + +The code check is `e.code`, the same pattern `resolveDecision` uses for +`outcome-unknown`. `humanTransport` sets `error.code` from the bus error on +stderr (`transport.mjs:62-63`). The stub in `helpers.mjs` sets `error.code` the +same way. `unknown-verb` is the broker's code for an unrecognized verb +(`broker.mjs:574`, `:1019`). + +`packages/cli/tests/cli.test.mjs`: one import (`formatInbox`), four helpers +(`inboxStub`, `coded`, `unread`, `NO_MESSAGES_VERB`), and four tests after the +existing inbox test. All use a stub transport; none touch the real bus. + +## Choices I made + +1. **Blank line before the unavailable line.** The normal path puts a blank + line between the decisions and the `messages:` line, because `formatInbox` + ends with a newline and the normal path adds `\nmessages:`. The fallback + does the same, so the two outputs match. The brief says "then the line", and + this is the one place I read the layout. Removing the blank line is a + one-character change if Sage wants it. +2. **A later page answers `unknown-verb`.** This means the bus restarted + between pages. The partial list from earlier pages is dropped, and only the + fallback prints. A partial count printed as the full count would be wrong. + Test T_D covers it; mutant M6 breaks it. + +## Acceptance points and their tests + +| Acceptance point | Test (name as it appears in TAP) | +| --- | --- | +| unknown-verb from `messages`: decisions as `formatInbox` prints them, the line, exit 0 | T_A: "text-mode inbox on a bus without the messages verb prints the decisions and the unavailable line, exit 0" | +| any other error from `messages` still fails, and prints nothing (codes `unauthenticated`, `invalid-response`, `outcome-unknown`) | T_B: "text-mode inbox still fails on any other messages error, and prints nothing" | +| normal path: unread count and each unread message, paged with `after`, read messages left out | T_C: "text-mode inbox prints the unread count and every unread message across pages" | +| choice 2: later page `unknown-verb` drops the partial list | T_D: "a later page answered unknown-verb drops the partial list and prints only the unavailable line" | +| `--json` unchanged | Existing test "inbox lists only decisions routed to the human..." (cli.test.mjs, first test), which asserts the `--json` output and the call order `inbox, messages, inbox`. Not changed. | + +## Mutants (planned, not run) + +All edits are to `packages/cli/src/cli.mjs`. Each old string occurs exactly +once in the candidate (checked with `grep -F -c`). The runner in `mutants.py` +applies each one, runs `cli.test.mjs`, and requires the named test to show +`not ok`. + +| Mutant | Before | After | Named test that must die | +| --- | --- | --- | --- | +| M1 remove the fallback | `if (e.code !== "unknown-verb") throw e;` | `throw e;` | T_A | +| M2 widen to every error | `if (e.code !== "unknown-verb") throw e;` | `if (false) throw e;` | T_B | +| M3 change the printed line | `messages: not available until the bus restarts` | `messages: unavailable` | T_A | +| M4 drop the decisions in the fallback | `${formatInbox(decisions)}\nmessages: not available` | `\nmessages: not available` | T_A | +| M5 fallback exits nonzero | `return io.stdout.write(...fallback...);` | `{ io.stdout.write(...fallback...); throw new CliError("messages not available", 1); }` | T_A | +| M6 partial list printed as the count | the fallback `return io.stdout.write(...)` | `return print({ decisions, messages }, ...)`, the normal text output | T_D | +| M7 read messages counted as unread | `messages.push(...page.messages.filter(m => !m.read));` | `messages.push(...page.messages);` | T_C | + +A mutant counts as killed only when the run exits nonzero, does not time out, +and the named test is `not ok`. A timeout counts as a failure, not a kill, as +the brief requires. The runner gives each run its own process group, kills +that group afterwards, and restores `cli.mjs` in a `finally` block, then checks +the restored hash. + +## Planned phase B test commands + +Run sequentially, each with `DOCKER_HOST=unix:///nonexistent.sock`, output tee'd +into `out/`, from the worktree root: + +1. `node --test 'packages/cli/tests/*.test.mjs'` (the cli node suite, as `packages/cli/README.md` gives it) +2. Every `scripts/test-*.sh`: `test-auth.sh`, `test-conductor.sh`, `test-config.sh`, + `test-discord.sh`, `test-extension-package.sh`, `test-foundation.sh`, + `test-queue.sh`, `test-release.sh`, `test-task.sh` +3. `python3 -I mutants.py /out`. It runs the unmutated baseline + first and stops if the baseline is not green. + +## Open questions for Sage + +1. Is the blank line before the `messages:` fallback line right, or should the + line follow the decisions directly? (Choice 1.) +2. Is dropping the partial list on a later-page `unknown-verb` right? The other + option is to print the partial messages with a note that the list is + incomplete. (Choice 2.) +3. Scripts: the brief says `scripts/test-*.sh`. I listed the nine `.sh` files + only. The `.mjs` and `.py` files named `test-*` are not in the gate list. Say + if they are wanted. + +## Round 2: T_A correction (test-only) + +Step 1 of phase B (out/step1-cli-suite.log, kept) ran T_A and it failed with `task-required`, not from the CLI code. T_A raised `git.push.protected` with `blocking: true` and no `task_ref`. The broker refuses a blocking decision without `task_ref` (`packages/bus/src/broker.mjs:657`), so the test never reached `main()`. + +Fix: `packages/cli/tests/cli.test.mjs` line 65 now passes `task_ref: "vikunja:1/7"`, the same as the other blocking raises in that file. Nothing else changed. `packages/cli/src/cli.mjs` is unchanged, so the mutant guard hash still holds. + +Hashes after the fix: +- `packages/cli/src/cli.mjs` afcb9d9276c96e543cdc4dac1856b2dd6e973dfa2f1c68d179ddb5fc0e25b1ad (unchanged) +- `packages/cli/tests/cli.test.mjs` c018997949b425e3274aaf9cc0877e5cd1419a1901e070ac84d114d788a53f7f (was 9b84d533374809c1ed1e3e748fb56a3351fdd4cf5d2b35f2f8ab5700631edac2) +- `candidate-manifest.sha256` (the file itself) 5c979e49d0b8433b7479fb8faf0c254012b4e76e7f39cb0ae0d4013995e4df60 (was 3c3f5629a792b4a052d6c2c6e1bb2d5184109dca7537c3c75aff5089e278f0ad) + +Regenerated: `build.patch` (git diff against base.txt), `candidate-manifest.sha256`. The round-1 copies are not kept. The round-1 log is kept at out/step1-cli-suite.log. `sha256sum -c` passes. + +Environment: `node_modules` is a symlink to the canonical checkout, per Sage. It is untracked and not part of the candidate. Remove the link before the worktree is removed. + +## Round 2: phase B results (candidate unchanged from the T_A fix) + +- Step 1, cli node suite (out/step1-cli-suite-r2.log): 133 pass, 0 fail, 0 cancelled, exit 0. The round-1 log is kept as out/step1-cli-suite.log. +- Step 2, the nine suites: test-auth 15/0, test-conductor 17/0, test-config 24/0, test-discord 66/0, test-extension-package 18/0, test-foundation 44/0, test-queue 27/0 (1 skip: render check, this is a worktree, not the canonical root), test-release 14/0, test-task 26/0 (1 skip: live recall, docker daemon unavailable under DOCKER_HOST). All exit 0. Logs: out/test-*-r2.log. +- Docker: test-release created `agent-a37ace32df0dd168f_default`, removed after it with `docker network rm`. +- Step 3, mutants: baseline green. M1 to M7 killed (named killers in out/results.json). + +### Runner defect found and fixed (mutants.py only, no candidate change) + +First mutant run (kept: out/mutants-run1/, mutants.py.run1 beside mutants.py): M6 reported NOT killed. Cause: its replacement text used `join(\"\")` inside a raw string, which produces invalid JavaScript (`SyntaxError` at cli.mjs:189). The module never loaded, so T_D never ran. The file-level `not ok 1 - packages/cli/tests/cli.test.mjs` was the only failure, and the runner correctly refused to count it. Fix: line 82 now uses `join('')`. The rest of the runner is unchanged. + +Second run, after the fix: M1 to M7 all killed, baseline green, cli.mjs restored to afcb9d9276c96e543cdc4dac1856b2dd6e973dfa2f1c68d179ddb5fc0e25b1ad. + +- M1 killed by T_A, T_D +- M2 killed by T_B +- M3 killed by T_A, T_D +- M4 killed by T_A, T_D +- M5 killed by T_A, T_D +- M6 killed by T_A, T_D (T_A fails because the fallback text changed) +- M7 killed by T_C diff --git a/agents/researcher/work/queue-99/r1/base.txt b/agents/researcher/work/queue-99/r1/base.txt new file mode 100644 index 000000000..d55a0eaa1 --- /dev/null +++ b/agents/researcher/work/queue-99/r1/base.txt @@ -0,0 +1 @@ +4233534da82ac15119358722eb6182c73cfd75b1 diff --git a/agents/researcher/work/queue-99/r1/build.patch b/agents/researcher/work/queue-99/r1/build.patch new file mode 100644 index 000000000..0077a3341 --- /dev/null +++ b/agents/researcher/work/queue-99/r1/build.patch @@ -0,0 +1,122 @@ +diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs +index fcd7c0132..5d92a4d25 100644 +--- a/packages/cli/src/cli.mjs ++++ b/packages/cli/src/cli.mjs +@@ -176,11 +176,18 @@ async function human(verb, rest, io, deps) { + if (json) return print(decisions, formatInbox); + const messages = []; + let after; +- do { +- const page = await call("messages", { limit: 200, ...(after ? { after } : {}) }); +- messages.push(...page.messages.filter(m => !m.read)); +- after = page.next; +- } while (after); ++ try { ++ do { ++ const page = await call("messages", { limit: 200, ...(after ? { after } : {}) }); ++ messages.push(...page.messages.filter(m => !m.read)); ++ after = page.next; ++ } while (after); ++ } catch (e) { ++ // A bus from before row 94 has no messages verb. Keep the decisions and say ++ // so. A partial list from earlier pages is dropped, not printed as the count. ++ if (e.code !== "unknown-verb") throw e; ++ return io.stdout.write(`${formatInbox(decisions)}\nmessages: not available until the bus restarts\n`); ++ } + return print({ decisions, messages }, value => formatInbox(value.decisions) + + `\nmessages: ${value.messages.length} unread\n` + value.messages.map(formatMessage).join("")); + } +diff --git a/packages/cli/tests/cli.test.mjs b/packages/cli/tests/cli.test.mjs +index 57f8ea22f..cd9dd40fb 100644 +--- a/packages/cli/tests/cli.test.mjs ++++ b/packages/cli/tests/cli.test.mjs +@@ -3,6 +3,7 @@ import assert from "node:assert/strict"; + import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; + import { main, readNotifyConfig } from "../src/cli.mjs"; + import { CliError } from "../src/errors.mjs"; ++import { formatInbox } from "../src/format.mjs"; + import { hostDir, hostFile, startTimeOf } from "../src/host.mjs"; + import { broker, io, notifyConfig, tmp } from "./helpers.mjs"; + import { writeJson } from "../../business/tests/helpers.mjs"; +@@ -43,6 +44,81 @@ test("inbox lists only decisions routed to the human, with what approving author + assert.deepEqual(s.calls.map((c) => c.verb), ["inbox", "messages", "inbox"]); + }); + ++// Text-mode `inbox` over a stub transport. `inbox` answers from the broker; ++// `messages` answers from messages(args), which returns a page or throws. ++function inboxStub(s, messages) { ++ const calls = []; ++ const transport = () => async (verb, args = {}) => { ++ calls.push({ verb, args }); ++ if (verb === "inbox") return s.read("inbox"); ++ return messages(args); ++ }; ++ return { calls, transport }; ++} ++ ++const coded = (code) => Object.assign(new Error(code), { code }); ++const unread = (id, body) => ({ id, from_role: "coder", class: "INFORM", in_reply_to: null, body, read: false }); ++const NO_MESSAGES_VERB = "messages: not available until the bus restarts\n"; ++ ++test("text-mode inbox on a bus without the messages verb prints the decisions and the unavailable line, exit 0", async (t) => { ++ const s = setup(t); ++ s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" }); ++ const { calls, transport } = inboxStub(s, () => { ++ throw coded("unknown-verb"); ++ }); ++ const x = io(); ++ assert.equal(await exitOf(main(["inbox", "--business", "demo"], x, { system: { dataRoot: s.dataRoot }, transport })), 0); ++ assert.equal(x.out.text, `${formatInbox(s.read("inbox"))}\n${NO_MESSAGES_VERB}`); ++ assert.deepEqual(calls.map((c) => c.verb), ["inbox", "messages"]); ++}); ++ ++test("text-mode inbox still fails on any other messages error, and prints nothing", async (t) => { ++ const s = setup(t); ++ s.raise("git.push.protected", { target: "refactor" }); ++ for (const code of ["unauthenticated", "invalid-response", "outcome-unknown"]) { ++ const { transport } = inboxStub(s, () => { ++ throw coded(code); ++ }); ++ const x = io(); ++ const err = await main(["inbox", "--business", "demo"], x, { system: { dataRoot: s.dataRoot }, transport }).catch((e) => e); ++ assert.equal(err?.code, code); ++ assert.equal(x.out.text, ""); ++ } ++}); ++ ++test("text-mode inbox prints the unread count and every unread message across pages", async (t) => { ++ const s = setup(t); ++ s.raise("git.push.protected", { target: "refactor" }); ++ const { calls, transport } = inboxStub(s, (args) => ++ args.after ++ ? { messages: [unread("msg-0003-cccccccc", "third\n")], next: null } ++ : { messages: [unread("msg-0001-aaaaaaaa", "first\n"), { ...unread("msg-0002-bbbbbbbb", "read\n"), read: true }], next: "msg-0002-bbbbbbbb" }, ++ ); ++ const x = io(); ++ assert.equal(await exitOf(main(["inbox", "--business", "demo"], x, { system: { dataRoot: s.dataRoot }, transport })), 0); ++ assert.equal( ++ x.out.text, ++ `${formatInbox(s.read("inbox"))}\nmessages: 2 unread\ncoder (INFORM) msg-0001:\nfirst\ncoder (INFORM) msg-0003:\nthird\n`, ++ ); ++ assert.deepEqual( ++ calls.filter((c) => c.verb === "messages").map((c) => c.args), ++ [{ limit: 200 }, { limit: 200, after: "msg-0002-bbbbbbbb" }], ++ ); ++}); ++ ++test("a later page answered unknown-verb drops the partial list and prints only the unavailable line", async (t) => { ++ const s = setup(t); ++ s.raise("git.push.protected", { target: "refactor" }); ++ const { calls, transport } = inboxStub(s, (args) => { ++ if (args.after) throw coded("unknown-verb"); ++ return { messages: [unread("msg-0001-aaaaaaaa", "first\n")], next: "msg-0001-aaaaaaaa" }; ++ }); ++ const x = io(); ++ assert.equal(await exitOf(main(["inbox", "--business", "demo"], x, { system: { dataRoot: s.dataRoot }, transport })), 0); ++ assert.equal(x.out.text, `${formatInbox(s.read("inbox"))}\n${NO_MESSAGES_VERB}`); ++ assert.deepEqual(calls.map((c) => c.verb), ["inbox", "messages", "messages"]); ++}); ++ + test("decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow", async (t) => { + const s = setup(t); + const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7" }); diff --git a/agents/researcher/work/queue-99/r1/candidate-manifest.sha256 b/agents/researcher/work/queue-99/r1/candidate-manifest.sha256 new file mode 100644 index 000000000..ba724afa1 --- /dev/null +++ b/agents/researcher/work/queue-99/r1/candidate-manifest.sha256 @@ -0,0 +1,2 @@ +afcb9d9276c96e543cdc4dac1856b2dd6e973dfa2f1c68d179ddb5fc0e25b1ad packages/cli/src/cli.mjs +c018997949b425e3274aaf9cc0877e5cd1419a1901e070ac84d114d788a53f7f packages/cli/tests/cli.test.mjs diff --git a/agents/researcher/work/queue-99/r1/files.txt b/agents/researcher/work/queue-99/r1/files.txt new file mode 100644 index 000000000..a1f14fe73 --- /dev/null +++ b/agents/researcher/work/queue-99/r1/files.txt @@ -0,0 +1,2 @@ +packages/cli/src/cli.mjs +packages/cli/tests/cli.test.mjs diff --git a/agents/researcher/work/queue-99/r1/mutants.py b/agents/researcher/work/queue-99/r1/mutants.py new file mode 100644 index 000000000..d35d830fc --- /dev/null +++ b/agents/researcher/work/queue-99/r1/mutants.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Row 99 mutants. PHASE B ONLY: not run in phase A. + +Usage: python3 -I mutants.py + +Runs packages/cli/tests/cli.test.mjs on the unmutated source first (baseline), +then once per mutant of packages/cli/src/cli.mjs. Each mutant is one exact text +replacement that must match exactly once. A mutant counts as killed only when +its run exits nonzero, did not time out, and the named test reports "not ok". +A timeout, a missing match, or a run that passes counts as NOT killed. + +Each run starts in its own process group. The group is killed after the run, +and nothing outside that group is signalled. The source is restored in a +finally block and checked against the candidate hash. +""" +import hashlib +import json +import os +import pathlib +import signal +import subprocess +import sys + +ROOT = pathlib.Path(sys.argv[1]).resolve() +OUT = pathlib.Path(sys.argv[2]).resolve() +SRC = ROOT / "packages/cli/src/cli.mjs" +TEST = "packages/cli/tests/cli.test.mjs" +TIMEOUT_S = 120 +CANDIDATE_CLI_SHA256 = "afcb9d9276c96e543cdc4dac1856b2dd6e973dfa2f1c68d179ddb5fc0e25b1ad" +ENV = dict(os.environ, DOCKER_HOST="unix:///nonexistent.sock") + +T_A = "text-mode inbox on a bus without the messages verb prints the decisions and the unavailable line, exit 0" +T_B = "text-mode inbox still fails on any other messages error, and prints nothing" +T_C = "text-mode inbox prints the unread count and every unread message across pages" +T_D = "a later page answered unknown-verb drops the partial list and prints only the unavailable line" + +FALLBACK = r'return io.stdout.write(`${formatInbox(decisions)}\nmessages: not available until the bus restarts\n`);' + +# (name, old text, new text, the named test that must die) +MUTANTS = [ + ( + "M1-remove-fallback", + r'if (e.code !== "unknown-verb") throw e;', + "throw e;", + T_A, + ), + ( + "M2-widen-to-every-error", + r'if (e.code !== "unknown-verb") throw e;', + "if (false) throw e;", + T_B, + ), + ( + "M3-changed-line-text", + "messages: not available until the bus restarts", + "messages: unavailable", + T_A, + ), + ( + "M4-drop-decisions-in-fallback", + r"${formatInbox(decisions)}\nmessages: not available", + r"\nmessages: not available", + T_A, + ), + ( + "M5-fallback-exits-nonzero", + FALLBACK, + r'{ io.stdout.write(`${formatInbox(decisions)}\nmessages: not available until the bus restarts\n`); ' + r'throw new CliError("messages not available", 1); }', + T_A, + ), + ( + "M7-read-messages-counted-as-unread", + "messages.push(...page.messages.filter(m => !m.read));", + "messages.push(...page.messages);", + T_C, + ), + ( + "M6-partial-list-printed-as-count", + FALLBACK, + r"return print({ decisions, messages }, value => formatInbox(value.decisions) + " + r"`\nmessages: ${value.messages.length} unread\n` + value.messages.map(formatMessage).join(''));", + T_D, + ), +] + + +def group_alive(pgid): + try: + os.killpg(pgid, 0) + return True + except ProcessLookupError: + return False + + +def run_suite(label): + proc = subprocess.Popen( + ["node", "--test", "--test-reporter=tap", TEST], + cwd=ROOT, + env=ENV, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + timed_out = False + try: + out, _ = proc.communicate(timeout=TIMEOUT_S) + except subprocess.TimeoutExpired: + timed_out = True + os.killpg(proc.pid, signal.SIGKILL) + out, _ = proc.communicate() + finally: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + text = out.decode("utf8", "replace") + (OUT / f"{label}.log").write_text(text, encoding="utf8") + return { + "label": label, + "exit": proc.returncode, + "timed_out": timed_out, + "not_ok": [line.strip() for line in text.splitlines() if "not ok" in line], + "group_clear": not group_alive(proc.pid), + } + + +def killed(res, test_name): + return (not res["timed_out"]) and res["exit"] != 0 and any(test_name in line for line in res["not_ok"]) + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + original = SRC.read_bytes() + if hashlib.sha256(original).hexdigest() != CANDIDATE_CLI_SHA256: + print("refusing: cli.mjs is not the candidate hash", file=sys.stderr) + return 2 + rows = [] + try: + base = run_suite("baseline") + rows.append({**base, "status": "baseline"}) + if base["exit"] != 0 or base["timed_out"] or base["not_ok"]: + print("baseline is not green; no mutants run", file=sys.stderr) + return 2 + text = original.decode("utf8") + for name, old, new, test_name in MUTANTS: + if text.count(old) != 1: + rows.append({"label": name, "status": "no-match (counted as not killed)"}) + continue + SRC.write_bytes(text.replace(old, new, 1).encode("utf8")) + res = run_suite(name) + if res["timed_out"]: + status = "TIMEOUT (counted as failure, not killed)" + else: + status = "killed" if killed(res, test_name) else "NOT killed" + rows.append({**res, "status": status, "named_test": test_name}) + finally: + SRC.write_bytes(original) + restored = hashlib.sha256(SRC.read_bytes()).hexdigest() == CANDIDATE_CLI_SHA256 + rows.append({"label": "restore", "status": "restored to candidate" if restored else "RESTORE FAILED"}) + (OUT / "results.json").write_text(json.dumps(rows, indent=2) + "\n", encoding="utf8") + for row in rows: + print(f"{row['label']}: {row['status']}") + all_killed = all(r.get("status") == "killed" for r in rows if r["label"].startswith("M")) + return 0 if all_killed and rows[-1]["status"] == "restored to candidate" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agents/researcher/work/queue-99/r1/mutants.py.run1 b/agents/researcher/work/queue-99/r1/mutants.py.run1 new file mode 100644 index 000000000..5f815ddb9 --- /dev/null +++ b/agents/researcher/work/queue-99/r1/mutants.py.run1 @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Row 99 mutants. PHASE B ONLY: not run in phase A. + +Usage: python3 -I mutants.py + +Runs packages/cli/tests/cli.test.mjs on the unmutated source first (baseline), +then once per mutant of packages/cli/src/cli.mjs. Each mutant is one exact text +replacement that must match exactly once. A mutant counts as killed only when +its run exits nonzero, did not time out, and the named test reports "not ok". +A timeout, a missing match, or a run that passes counts as NOT killed. + +Each run starts in its own process group. The group is killed after the run, +and nothing outside that group is signalled. The source is restored in a +finally block and checked against the candidate hash. +""" +import hashlib +import json +import os +import pathlib +import signal +import subprocess +import sys + +ROOT = pathlib.Path(sys.argv[1]).resolve() +OUT = pathlib.Path(sys.argv[2]).resolve() +SRC = ROOT / "packages/cli/src/cli.mjs" +TEST = "packages/cli/tests/cli.test.mjs" +TIMEOUT_S = 120 +CANDIDATE_CLI_SHA256 = "afcb9d9276c96e543cdc4dac1856b2dd6e973dfa2f1c68d179ddb5fc0e25b1ad" +ENV = dict(os.environ, DOCKER_HOST="unix:///nonexistent.sock") + +T_A = "text-mode inbox on a bus without the messages verb prints the decisions and the unavailable line, exit 0" +T_B = "text-mode inbox still fails on any other messages error, and prints nothing" +T_C = "text-mode inbox prints the unread count and every unread message across pages" +T_D = "a later page answered unknown-verb drops the partial list and prints only the unavailable line" + +FALLBACK = r'return io.stdout.write(`${formatInbox(decisions)}\nmessages: not available until the bus restarts\n`);' + +# (name, old text, new text, the named test that must die) +MUTANTS = [ + ( + "M1-remove-fallback", + r'if (e.code !== "unknown-verb") throw e;', + "throw e;", + T_A, + ), + ( + "M2-widen-to-every-error", + r'if (e.code !== "unknown-verb") throw e;', + "if (false) throw e;", + T_B, + ), + ( + "M3-changed-line-text", + "messages: not available until the bus restarts", + "messages: unavailable", + T_A, + ), + ( + "M4-drop-decisions-in-fallback", + r"${formatInbox(decisions)}\nmessages: not available", + r"\nmessages: not available", + T_A, + ), + ( + "M5-fallback-exits-nonzero", + FALLBACK, + r'{ io.stdout.write(`${formatInbox(decisions)}\nmessages: not available until the bus restarts\n`); ' + r'throw new CliError("messages not available", 1); }', + T_A, + ), + ( + "M7-read-messages-counted-as-unread", + "messages.push(...page.messages.filter(m => !m.read));", + "messages.push(...page.messages);", + T_C, + ), + ( + "M6-partial-list-printed-as-count", + FALLBACK, + r"return print({ decisions, messages }, value => formatInbox(value.decisions) + " + r"`\nmessages: ${value.messages.length} unread\n` + value.messages.map(formatMessage).join(\"\"));", + T_D, + ), +] + + +def group_alive(pgid): + try: + os.killpg(pgid, 0) + return True + except ProcessLookupError: + return False + + +def run_suite(label): + proc = subprocess.Popen( + ["node", "--test", "--test-reporter=tap", TEST], + cwd=ROOT, + env=ENV, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + timed_out = False + try: + out, _ = proc.communicate(timeout=TIMEOUT_S) + except subprocess.TimeoutExpired: + timed_out = True + os.killpg(proc.pid, signal.SIGKILL) + out, _ = proc.communicate() + finally: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + text = out.decode("utf8", "replace") + (OUT / f"{label}.log").write_text(text, encoding="utf8") + return { + "label": label, + "exit": proc.returncode, + "timed_out": timed_out, + "not_ok": [line.strip() for line in text.splitlines() if "not ok" in line], + "group_clear": not group_alive(proc.pid), + } + + +def killed(res, test_name): + return (not res["timed_out"]) and res["exit"] != 0 and any(test_name in line for line in res["not_ok"]) + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + original = SRC.read_bytes() + if hashlib.sha256(original).hexdigest() != CANDIDATE_CLI_SHA256: + print("refusing: cli.mjs is not the candidate hash", file=sys.stderr) + return 2 + rows = [] + try: + base = run_suite("baseline") + rows.append({**base, "status": "baseline"}) + if base["exit"] != 0 or base["timed_out"] or base["not_ok"]: + print("baseline is not green; no mutants run", file=sys.stderr) + return 2 + text = original.decode("utf8") + for name, old, new, test_name in MUTANTS: + if text.count(old) != 1: + rows.append({"label": name, "status": "no-match (counted as not killed)"}) + continue + SRC.write_bytes(text.replace(old, new, 1).encode("utf8")) + res = run_suite(name) + if res["timed_out"]: + status = "TIMEOUT (counted as failure, not killed)" + else: + status = "killed" if killed(res, test_name) else "NOT killed" + rows.append({**res, "status": status, "named_test": test_name}) + finally: + SRC.write_bytes(original) + restored = hashlib.sha256(SRC.read_bytes()).hexdigest() == CANDIDATE_CLI_SHA256 + rows.append({"label": "restore", "status": "restored to candidate" if restored else "RESTORE FAILED"}) + (OUT / "results.json").write_text(json.dumps(rows, indent=2) + "\n", encoding="utf8") + for row in rows: + print(f"{row['label']}: {row['status']}") + all_killed = all(r.get("status") == "killed" for r in rows if r["label"].startswith("M")) + return 0 if all_killed and rows[-1]["status"] == "restored to candidate" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agents/researcher/work/queue-99/r1/out/M1-remove-fallback.log b/agents/researcher/work/queue-99/r1/out/M1-remove-fallback.log new file mode 100644 index 000000000..530fc5827 --- /dev/null +++ b/agents/researcher/work/queue-99/r1/out/M1-remove-fallback.log @@ -0,0 +1,128 @@ +TAP version 13 +# Subtest: inbox lists only decisions routed to the human, with what approving authorizes and how to decide +ok 1 - inbox lists only decisions routed to the human, with what approving authorizes and how to decide + --- + duration_ms: 18.840932 + type: 'test' + ... +# Subtest: text-mode inbox on a bus without the messages verb prints the decisions and the unavailable line, exit 0 +not ok 2 - text-mode inbox on a bus without the messages verb prints the decisions and the unavailable line, exit 0 + --- + duration_ms: 10.888411 + type: 'test' + location: '/mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:63:1' + failureType: 'testCodeFailure' + error: 'unknown-verb' + code: 'unknown-verb' + stack: |- + coded (file:///mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:59:39) + file:///mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:67:11 + file:///mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:54:12 + human (file:///mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/src/cli.mjs:181:28) + ... +# Subtest: text-mode inbox still fails on any other messages error, and prints nothing +ok 3 - text-mode inbox still fails on any other messages error, and prints nothing + --- + duration_ms: 10.990634 + type: 'test' + ... +# Subtest: text-mode inbox prints the unread count and every unread message across pages +ok 4 - text-mode inbox prints the unread count and every unread message across pages + --- + duration_ms: 9.307879 + type: 'test' + ... +# Subtest: a later page answered unknown-verb drops the partial list and prints only the unavailable line +not ok 5 - a later page answered unknown-verb drops the partial list and prints only the unavailable line + --- + duration_ms: 9.306348 + type: 'test' + location: '/mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:109:1' + failureType: 'testCodeFailure' + error: 'unknown-verb' + code: 'unknown-verb' + stack: |- + coded (file:///mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:59:39) + file:///mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:113:27 + file:///mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:54:12 + human (file:///mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/src/cli.mjs:181:28) + async exitOf (file:///mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:14:5) + async TestContext. (file:///mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:117:16) + async Test.run (node:internal/test_runner/test:1409:7) + async Test.processPendingSubtests (node:internal/test_runner/test:974:7) + ... +# Subtest: decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow +ok 6 - decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow + --- + duration_ms: 16.141991 + type: 'test' + ... +# Subtest: decide refuses without a terminal or --yes, on an unknown option and on a short reference +ok 7 - decide refuses without a terminal or --yes, on an unknown option and on a short reference + --- + duration_ms: 10.364652 + type: 'test' + ... +# Subtest: decide prints a declining choice as declining +ok 8 - decide prints a declining choice as declining + --- + duration_ms: 12.201461 + type: 'test' + ... +# Subtest: an unknown outcome is reported once and never resent +ok 9 - an unknown outcome is reported once and never resent + --- + duration_ms: 10.533889 + type: 'test' + ... +# Subtest: a decision closed before the answer arrives exits 2 and points at its trail +ok 10 - a decision closed before the answer arrives exits 2 and points at its trail + --- + duration_ms: 10.244502 + type: 'test' + ... +# Subtest: a prefix that matches two open decisions exits 2 and resolves neither +ok 11 - a prefix that matches two open decisions exits 2 and resolves neither + --- + duration_ms: 13.854672 + type: 'test' + ... +# Subtest: without --business a command uses the live host's business, and a stale host.json is not a host +ok 12 - without --business a command uses the live host's business, and a stale host.json is not a host + --- + duration_ms: 8.574424 + type: 'test' + ... +# Subtest: every human command refuses inside an agent run before it touches the bus +ok 13 - every human command refuses inside an agent run before it touches the bus + --- + duration_ms: 10.541675 + type: 'test' + ... +# Subtest: usage errors exit 4; no business and no host is a usage error +ok 14 - usage errors exit 4; no business and no host is a usage error + --- + duration_ms: 10.071999 + type: 'test' + ... +# Subtest: agents and tasks print through the broker +ok 15 - agents and tasks print through the broker + --- + duration_ms: 10.504986 + type: 'test' + ... +# Subtest: notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes +ok 16 - notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes + --- + duration_ms: 1.587756 + type: 'test' + ... +1..16 +# tests 16 +# suites 0 +# pass 14 +# fail 2 +# cancelled 0 +# skipped 0 +# todo 0 +# duration_ms 345.427449 diff --git a/agents/researcher/work/queue-99/r1/out/M2-widen-to-every-error.log b/agents/researcher/work/queue-99/r1/out/M2-widen-to-every-error.log new file mode 100644 index 000000000..ec24a52f2 --- /dev/null +++ b/agents/researcher/work/queue-99/r1/out/M2-widen-to-every-error.log @@ -0,0 +1,123 @@ +TAP version 13 +# Subtest: inbox lists only decisions routed to the human, with what approving authorizes and how to decide +ok 1 - inbox lists only decisions routed to the human, with what approving authorizes and how to decide + --- + duration_ms: 32.5182 + type: 'test' + ... +# Subtest: text-mode inbox on a bus without the messages verb prints the decisions and the unavailable line, exit 0 +ok 2 - text-mode inbox on a bus without the messages verb prints the decisions and the unavailable line, exit 0 + --- + duration_ms: 8.862895 + type: 'test' + ... +# Subtest: text-mode inbox still fails on any other messages error, and prints nothing +not ok 3 - text-mode inbox still fails on any other messages error, and prints nothing + --- + duration_ms: 8.982455 + type: 'test' + location: '/mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:75:1' + failureType: 'testCodeFailure' + error: |- + Expected values to be strictly equal: + + actual - expected + + + undefined + - 'unauthenticated' + + code: 'ERR_ASSERTION' + name: 'AssertionError' + expected: 'unauthenticated' + operator: 'strictEqual' + stack: |- + TestContext. (file:///mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:84:12) + async Test.run (node:internal/test_runner/test:1409:7) + async Test.processPendingSubtests (node:internal/test_runner/test:974:7) + ... +# Subtest: text-mode inbox prints the unread count and every unread message across pages +ok 4 - text-mode inbox prints the unread count and every unread message across pages + --- + duration_ms: 8.749173 + type: 'test' + ... +# Subtest: a later page answered unknown-verb drops the partial list and prints only the unavailable line +ok 5 - a later page answered unknown-verb drops the partial list and prints only the unavailable line + --- + duration_ms: 8.945759 + type: 'test' + ... +# Subtest: decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow +ok 6 - decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow + --- + duration_ms: 13.420821 + type: 'test' + ... +# Subtest: decide refuses without a terminal or --yes, on an unknown option and on a short reference +ok 7 - decide refuses without a terminal or --yes, on an unknown option and on a short reference + --- + duration_ms: 10.82155 + type: 'test' + ... +# Subtest: decide prints a declining choice as declining +ok 8 - decide prints a declining choice as declining + --- + duration_ms: 9.762278 + type: 'test' + ... +# Subtest: an unknown outcome is reported once and never resent +ok 9 - an unknown outcome is reported once and never resent + --- + duration_ms: 8.924876 + type: 'test' + ... +# Subtest: a decision closed before the answer arrives exits 2 and points at its trail +ok 10 - a decision closed before the answer arrives exits 2 and points at its trail + --- + duration_ms: 8.025113 + type: 'test' + ... +# Subtest: a prefix that matches two open decisions exits 2 and resolves neither +ok 11 - a prefix that matches two open decisions exits 2 and resolves neither + --- + duration_ms: 8.228113 + type: 'test' + ... +# Subtest: without --business a command uses the live host's business, and a stale host.json is not a host +ok 12 - without --business a command uses the live host's business, and a stale host.json is not a host + --- + duration_ms: 9.96901 + type: 'test' + ... +# Subtest: every human command refuses inside an agent run before it touches the bus +ok 13 - every human command refuses inside an agent run before it touches the bus + --- + duration_ms: 8.792838 + type: 'test' + ... +# Subtest: usage errors exit 4; no business and no host is a usage error +ok 14 - usage errors exit 4; no business and no host is a usage error + --- + duration_ms: 8.96128 + type: 'test' + ... +# Subtest: agents and tasks print through the broker +ok 15 - agents and tasks print through the broker + --- + duration_ms: 10.311884 + type: 'test' + ... +# Subtest: notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes +ok 16 - notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes + --- + duration_ms: 1.586336 + type: 'test' + ... +1..16 +# tests 16 +# suites 0 +# pass 15 +# fail 1 +# cancelled 0 +# skipped 0 +# todo 0 +# duration_ms 296.106436 diff --git a/agents/researcher/work/queue-99/r1/out/M3-changed-line-text.log b/agents/researcher/work/queue-99/r1/out/M3-changed-line-text.log new file mode 100644 index 000000000..fc887f6c7 --- /dev/null +++ b/agents/researcher/work/queue-99/r1/out/M3-changed-line-text.log @@ -0,0 +1,204 @@ +TAP version 13 +# Subtest: inbox lists only decisions routed to the human, with what approving authorizes and how to decide +ok 1 - inbox lists only decisions routed to the human, with what approving authorizes and how to decide + --- + duration_ms: 28.607219 + type: 'test' + ... +# Subtest: text-mode inbox on a bus without the messages verb prints the decisions and the unavailable line, exit 0 +not ok 2 - text-mode inbox on a bus without the messages verb prints the decisions and the unavailable line, exit 0 + --- + duration_ms: 10.621886 + type: 'test' + location: '/mnt/storage/src/mosaic-stack/.claude/worktrees/agent-a37ace32df0dd168f/packages/cli/tests/cli.test.mjs:63:1' + failureType: 'testCodeFailure' + error: |- + Expected values to be strictly equal: + + actual - expected + ... Skipped lines + + 'inbox: 1 open decision(s)\n' + + '\n' + + 'd8641bbf git.push.protected (gated, blocking) raised 2026-10-13T13:20:02.534Z by coder\n' + + ' Push the release?\n' + + ' options: yes = Allow; no = Decline (recommended: no)\n' + + ... + '\n' + + + 'messages: unavailable\n' + - 'messages: not available until the bus restarts\n' + + code: 'ERR_ASSERTION' + name: 'AssertionError' + expected: |- + inbox: 1 open decision(s) + + d8641bbf git.push.protected (gated, blocking) raised 2026-10-13T13:20:02.534Z by coder + Push the release? + options: yes = Allow; no = Decline (recommended: no) + action: git.push.protected on refactor + choosing "yes" authorizes it; any other choice declines + task: vikunja:1/7 + decide: mosaic decide d8641bbf