Files
stack/agents/rocko/work/queue-73/build.patch
T
jason.woltjeandClaude Opus 5.5 2137dd7f5d review(rocko): row 73 round 1 packet, coder role drops task.reassign (#1567)
Rocko's packet agents/rocko/work/queue-73/ (candidate manifest bf4e5ed6,
packet manifest 41ffcd5c, 53 files), their BUILD-LOG entry and SESSIONS
line. Sage's SESSIONS line for the review request (comment 27467) and
row 79 (#1573) for the stale text the packet names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:20:34 -05:00

211 lines
11 KiB
Diff

diff --git a/agents/rocko/work/queue-73/mutants.sh b/agents/rocko/work/queue-73/mutants.sh
new file mode 100755
index 00000000..8d550d1f
--- /dev/null
+++ b/agents/rocko/work/queue-73/mutants.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Row 73 (#1567): rerun the role-file revert and the mutants against a tree
+# with the candidate. Usage: mutants.sh <tree>. Each mutant edits one line,
+# runs the row's tests, and restores the file from a saved copy, so the tree
+# need not be committed. Prints "killed" or "SURVIVED" per mutant; exit 1 if
+# any survives, a sed doesn't change its file, or the unmutated run fails.
+# The tasks test is in the list but kills nothing here: the verb refuses a
+# coder's task.reassign with or without the grant.
+set -euo pipefail
+TREE="$(cd "${1:?usage: mutants.sh <tree>}" && pwd)"
+cd "$TREE"
+ROLE=roles/coder.json
+BROKER=packages/bus/src/broker.mjs
+RESOLVE=packages/business/src/resolve.mjs
+TOOLS=packages/harness/src/tools.mjs
+TESTS=(
+ packages/bus/tests/broker.test.mjs
+ packages/business/tests/role.test.mjs
+ packages/business/tests/resolve.test.mjs
+ packages/harness/tests/tools.test.mjs
+ packages/tasks/tests/verbs.test.mjs
+)
+run_tests() { node --test "${TESTS[@]}" >"$1" 2>&1; }
+
+# name | file | sed expression
+MUTANTS=(
+ 'role-keeps-reassign|'"$ROLE"'|s/"crossRole": \["task.scope.change"\]/"crossRole": ["task.reassign", "task.scope.change"]/'
+ 'broker-unlisted-cross|'"$BROKER"'|/^ ? .cross-role.$/{n;s/^ : .gated.;$/ : '"'"'cross-role'"'"';/}'
+ 'resolve-cross-unnarrowed|'"$RESOLVE"'|s/^ crossRole = crossRole.filter((a) => vars\["limits.authority"\].includes(a));$/ crossRole = [...crossRole];/'
+ 'classify-unlisted-cross|'"$RESOLVE"'|/^ if (resolved.limits.authority.crossRole.includes(action)) return "cross";$/{n;s/return "gated";/return "cross";/}'
+ 'tools-all-task-verbs|'"$TOOLS"'|s/^ if (held.has(action)) add(name, action, action, description, obj(properties, required));$/ add(name, action, action, description, obj(properties, required));/'
+)
+
+OUT="${OUT:-$TREE/.mutants}"
+mkdir -p "$OUT"
+run_tests "$OUT/baseline.log" || { echo "baseline run failed: $OUT/baseline.log" >&2; exit 1; }
+echo "baseline: pass"
+status=0
+for m in "${MUTANTS[@]}"; do
+ IFS='|' read -r name file expr <<<"$m"
+ cp -p "$file" "$OUT/orig"
+ sed -i -e "$expr" "$file"
+ if cmp -s "$OUT/orig" "$file"; then
+ echo "$name: NOT APPLIED (sed matched nothing)"; status=1; continue
+ fi
+ diff -u "$OUT/orig" "$file" >"$OUT/$name.diff" || true
+ if run_tests "$OUT/$name.log"; then
+ echo "$name: SURVIVED"; status=1
+ else
+ echo "$name: killed ($(grep -E '^✖' "$OUT/$name.log" | grep -v 'failing tests' | sed 's/ ([0-9.]*ms)$//' | sort -u | tr '\n' ';'))"
+ fi
+ cp -p "$OUT/orig" "$file"
+ cmp -s "$OUT/orig" "$file" || { echo "restore failed: $file" >&2; exit 1; }
+ rm -f "$OUT/orig"
+done
+exit "$status"
diff --git a/packages/bus/tests/broker.test.mjs b/packages/bus/tests/broker.test.mjs
index 3e908d70..d5d53d9e 100644
--- a/packages/bus/tests/broker.test.mjs
+++ b/packages/bus/tests/broker.test.mjs
@@ -4,6 +4,9 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
+import { busBusiness } from '../src/business.mjs';
+import { resolveInstance, validateBusinessDocument } from '../../business/src/index.mjs';
+import { businessDoc, REPO_ROLES, systemFor } from '../../business/tests/helpers.mjs';
const { Broker } = await import('../src/broker.mjs').catch((e) => {
if (e.code === 'ERR_MODULE_NOT_FOUND') return {};
throw e;
@@ -354,3 +357,26 @@ test('both arbiters require human resolution when their cross-role route is them
assert.equal(b.authorize(cap, action, { decision: d.id, target: 'vikunja:3/41' }).class, 'cross-role');
}
});
+
+test('the shipped coder role raises task.reassign as gated to the human; task.scope.change stays cross-role', (t) => {
+ const root = mkdtempSync(join(tmpdir(), 'bus-roles-'));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const business = validateBusinessDocument(businessDoc(root, 'demo'), join(root, 'businesses', 'demo.json'), {
+ rolesDir: REPO_ROLES,
+ });
+ const resolved = {};
+ for (const instance of Object.keys(business.roles))
+ resolved[instance] = resolveInstance({ system: systemFor(root), business, instance });
+ const { b, agent, human } = setup(t, { demo: busBusiness(business, resolved) });
+ const c = agent('coder');
+ call(b, c, 'role.claim');
+ const reassign = raise(b, c, 'task.reassign', { target: 'vikunja:3/7' });
+ assert.equal(reassign.class, 'gated');
+ assert.equal(reassign.route_to, 'human');
+ const scope = raise(b, c, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/7' });
+ assert.equal(scope.class, 'cross-role');
+ assert.equal(scope.route_to, 'cto');
+ // Approval passes the broker; the task verb still refuses a coder with field-writer (packages/tasks).
+ call(b, human, 'decision.resolve', { id: reassign.id, choice: 'yes' });
+ assert.equal(b.authorize(c, 'task.reassign', { decision: reassign.id, target: 'vikunja:3/7' }).class, 'gated');
+});
diff --git a/packages/business/tests/resolve.test.mjs b/packages/business/tests/resolve.test.mjs
index 0995eb5c..75ac2925 100644
--- a/packages/business/tests/resolve.test.mjs
+++ b/packages/business/tests/resolve.test.mjs
@@ -122,21 +122,23 @@ test("limits.authority without role.launch leaves the launcher with no launch bl
});
test("limits.authority narrows cross-role actions too", () => {
+ // pm, the role with two cross-role actions: the limit keeps one and drops the other.
const s = setup((d) => {
- d.roles.coder.vars["limits.authority"] = ["task.update.assigned", "git.push.working", "task.scope.change"];
+ d.roles.pm.vars["limits.authority"] = ["task.create", "task.close", "task.priority.change"];
});
- const coder = resolve(s, "coder");
- assert.deepEqual(coder.limits.authority, { withinRole: ["task.update.assigned", "git.push.working"], crossRole: ["task.scope.change"] });
- assert.equal(classify(coder, "task.reassign"), "gated");
- assert.equal(classify(coder, "task.scope.change"), "cross");
- assert.equal(classify(coder, "review.request"), "gated");
+ const pm = resolve(s, "pm");
+ assert.deepEqual(pm.limits.authority, { withinRole: ["task.create", "task.close"], crossRole: ["task.priority.change"] });
+ assert.equal(classify(pm, "task.priority.change"), "cross");
+ assert.equal(classify(pm, "task.scope.change"), "gated");
+ assert.equal(classify(pm, "task.assign"), "gated");
});
test("classify", () => {
const s = setup();
const coder = resolve(s, "coder");
assert.equal(classify(coder, "git.push.working"), "within");
- assert.equal(classify(coder, "task.reassign"), "cross");
+ assert.equal(classify(coder, "task.scope.change"), "cross");
+ assert.equal(classify(coder, "task.reassign"), "gated");
assert.equal(classify(coder, "task.close"), "gated");
assert.equal(classify(coder, "deploy"), "gated");
assert.equal(classify(resolve(s, "pm"), "task.priority.change"), "cross");
diff --git a/packages/business/tests/role.test.mjs b/packages/business/tests/role.test.mjs
index 1a401307..00628f80 100644
--- a/packages/business/tests/role.test.mjs
+++ b/packages/business/tests/role.test.mjs
@@ -61,6 +61,8 @@ test("shipped role scopes match addendum B section 2 and the SR runbook", () =>
test("shipped authority follows the note's table", () => {
const auth = (name) => loadRole(REPO_ROLES, name).authority;
assert.ok(auth("coder").withinRole.includes("git.push.working"));
+ // Assignee is pm-only (decision 90): a coder grant of task.reassign could never succeed.
+ assert.deepEqual(auth("coder").crossRole, ["task.scope.change"]);
assert.ok(!auth("reviewer").withinRole.includes("git.push.working"));
assert.deepEqual(auth("reviewer").crossRole, []);
assert.ok(auth("pm").withinRole.includes("role.launch"));
diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs
index b520dd4c..31606131 100644
--- a/packages/harness/tests/tools.test.mjs
+++ b/packages/harness/tests/tools.test.mjs
@@ -35,6 +35,14 @@ test("a coder gets no launch, no resolve_decision, and no task tools without a t
assert.ok(!untyped.includes("message.send"));
});
+test("a coder with a tracker gets its own task tools and no task_reassign", (t) => {
+ const coder = resolvedFor(t, "coder");
+ assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
+ "send_message", "task_scope_change", "task_update_assigned",
+ "raise_decision", "list_agents", "inbox", "trail", "list_tasks",
+ ]);
+});
+
test("launch only when the business's launch block names the instance as launcher", (t) => {
const pm = resolvedFor(t, "pm", (d) => delete d.launch);
assert.equal(pm.launch, null);
diff --git a/packages/tasks/tests/verbs.test.mjs b/packages/tasks/tests/verbs.test.mjs
index f5621f7b..c9ea98a6 100644
--- a/packages/tasks/tests/verbs.test.mjs
+++ b/packages/tasks/tests/verbs.test.mjs
@@ -93,6 +93,9 @@ test('assign and reassign move the role bots and record task.assigned', async (t
// The field-writer check runs before authority, so the refused call consumes nothing.
await assert.rejects(w.call(coder, 'task.assign', { task_ref, role: 'coder' }), refusedWith('field-writer'));
assert.equal(allowed(w, 'task.assign').length, 0);
+ // Assignee stays pm-only (decision 90); the coder role holds no task.reassign grant, and the verb refuses it anyway.
+ await assert.rejects(w.call(coder, 'task.reassign', { task_ref, role: 'reviewer' }), refusedWith('field-writer'));
+ assert.equal(allowed(w, 'task.reassign').length, 0);
await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'cto' }), refusedWith('unknown-role'));
await assert.rejects(w.call(pm, 'task.assign', { task_ref, role: 'toString' }), refusedWith('unknown-role'));
await assert.rejects(w.call(pm, 'task.reassign', { task_ref, role: 'coder' }), refusedWith('task-unassigned'));
diff --git a/packages/tasks/tests/world.mjs b/packages/tasks/tests/world.mjs
index d50e96ea..5085e779 100644
--- a/packages/tasks/tests/world.mjs
+++ b/packages/tasks/tests/world.mjs
@@ -42,7 +42,7 @@ export const AUTHORITY = {
},
coder: {
withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'],
- crossRole: ['task.reassign', 'task.scope.change'],
+ crossRole: ['task.scope.change'],
},
reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] },
cto: {
diff --git a/roles/coder.json b/roles/coder.json
index 2a7325a1..04220271 100644
--- a/roles/coder.json
+++ b/roles/coder.json
@@ -7,7 +7,7 @@
"network": "api-only",
"authority": {
"withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send"],
- "crossRole": ["task.reassign", "task.scope.change"]
+ "crossRole": ["task.scope.change"]
},
"credentials": [
{