Darkwing found the brief had the host run unconfined git in a clone the session writes. The brief now requires a clean git environment, hook and fsmonitor overrides, an lstat walk of .git, a config-key allowlist and a session-live check, and adds bundle.mjs and a harness test to the files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
16 KiB
Status: written by Sage, lead. It follows docs/plans/BRIEF-TEMPLATE.md.
It triages Vikunja P19 #228 (dogfood), which Mos filed from Jason's
first PM request after the PM started (talk fef3fa41, PM RESULT e300f8af,
about 2026-10-12T19:27Z; Mos sage-0100), and the three gaps Mos added in
#228 c78 from the same test (sage-0101). Three rows: the board read, the
requirement ids, and the role checkout. All three block S7 (row 42): the
PM can't carry a request to a task, or the coder work on the project,
until they land. Gitea issues #1583, #1584 and #1585.
The PM's task flow on a real board (#228)
PM task reads that fit a session
Problem
The PM's list_tasks tool takes no arguments
(packages/harness/src/tools.mjs:116, obj({})). The broker's tasks
verb returns every row of task_current with all its fields, description
included (packages/bus/src/broker.mjs:313-329, :860), and the MCP
server sends it as indented JSON (packages/harness/src/mcp-server.mjs:40,
pi-extension.mjs:45). On the Mosaic Stack board that reply was about
120 KB. Claude Code saved the oversized result to a file outside the
PM's workspace, and the run's file guard refused to read it. That refusal
is right; the reply is the defect. So the PM couldn't see the board.
The same session text lists the wrong built-in tools for Claude Code
(the PM's findings, fault 9). bundle.mjs:67 prints the role's pi
names, so the PM read "read, write, edit, grep, find, ls", while
Claude Code gets Read, Write, Edit, Grep and Glob
(packages/harness/src/gate.mjs:17,29, claudeBuiltins).
Owner and reviewer
Owner: Dewey, after row 81 lands. Reviewer: Darkwing.
Files owned
packages/bus/src/broker.mjs, thetasksverb and#taskRowsonlypackages/harness/src/tools.mjs,mcp-server.mjs,pi-extension.mjspackages/harness/src/bundle.mjs, the built-in tools line onlypackages/bus/README.md,packages/harness/README.md, the sections on thetasksverb and the tool replies- tests under
packages/bus/tests/andpackages/harness/tests/ agents/dewey/work/queue-<row>/
What ships
- The broker's
tasksverb takes optional arguments. With none, it returns what it returns today, so the Console andmosaicare unchanged. With any, it filters and pages:done(true, false; the tool's default is false),assignee(a role),label,requirement,limit(default 50, at most 200) andafter(a task ref, for the next page). A filtered reply is{ tasks, total, next }, and each task is the short form: ref, title, done, bucket, assignee roles, labels, priority, requirement and the last change time, with no description. An unknown argument or a bad value refuses withinvalid-request. - One more argument,
task_ref, returns that one task in full, as today's row, so the PM reads a description only when it needs one. list_tasksexposes those arguments and says the default is open tasks, 50 at a time. Aget_tasktool takestask_ref. Both are typed for the pm role and the same holders as today.- Every tool reply has a size cap, in
mcp-server.mjsandpi-extension.mjsboth, set below the size at which the pinned Claude CLI saves a tool result to a file. Dewey finds that size for the version the bus unit runs (MOSAIC_CLAUDE_BIN, decision 91), records how in the packet, and sets the cap with margin. Over the cap, the tool returns an error naming the size and the cap and telling the session to narrow the query, never a cut-off reply. Replies go out as compact JSON, not indented. - The session text names the built-in tools the session's harness
really has:
claudeBuiltins(policy)for Claude Code, the pi names for pi. - Tests: a board of 500 fixture tasks with long descriptions, where
list_taskswith defaults fits under the cap and pages to the end withnext; each filter;task_ref; the refusals; the cap error from both servers; the no-argument call still matching today's reply; and the tools line for each harness.
Suites: the bus, harness, webui and cli node suites, and every
scripts/test-*.sh.
Out of scope
Loosening the run's file guard or adding the tool-results directory to the workspace. The Console's task views. Any change to who may read tasks. Server-side search over descriptions.
Gate
Darkwing approves on the row's issue, naming the candidate manifest. The suites above pass on Sage's gate rerun. The broker and harness change is live at the next bus restart, which ends the PM session and launches a new one (decision 109). Sage notifies Mos before the landing and sends the commit after it; Mos restarts the bus and confirms the PM relaunched.
The approved requirement ids as data
Problem
task.create needs a requirement id, and verbs.mjs:187 checks only
its format, REQ-[A-Z]+-[1-9][0-9]*. Nothing tells the PM which ids
exist. The PRD (docs/prd/mosaic-stack.md, version 1.0, approved
2026-10-05, lead decision 61) holds 24 of them, but no tool reads it. So
the PM refused to create a task, and asked Jason in its RESULT instead
of raising a decision to his Inbox. In the same test it later created
P19 #229 and #230 under REQ-QA-1, which passes the format check and
isn't in the PRD.
Two more gaps from #228 c78 are words, not missing paths:
task_create'srequestsays "Id of the message or human input this task answers" (tools.mjs:41).create()accepts only ahuman.inputevent id (verbs.mjs:352,broker.mjs:460), so the PM's message id refusedrequest-not-found. Each messagemessage.receivereturns already carries that id asrequest(broker.mjs:790-797); the tool doesn't say to use it.- A blocking decision needs a task (
broker.mjs:596,task-required), and the PM read that as no Inbox path for a missing requirement. A non-blocking decision needs none.prd.approveis gated (broker.mjs:29), so it routes to the human. Nothing tells the PM this. - The same rule stopped the PM asking Jason to stop the coder and
reviewer (PM fault 10, #228 c91, Mos sage-0107). It raised a blocking
role.revokewith no task and gottask-required. A non-blocking one routes to the human, sincerole.revokeis gated too (broker.mjs:38). Stopping a run is the human's own verb (mosaic stop;launch.revokeis human-only,broker.mjs:815-823), and nothing in the session text says so.
Owner and reviewer
Owner: Dewey, after the row above (both touch broker.mjs). Reviewer:
Filbert.
Files owned
packages/tasks/requirements.json, newpackages/tasks/src/requirements.mjs, new, andpackages/tasks/src/verbs.mjs, thetask.createrequirement check onlypackages/bus/src/broker.mjs, arequirementsread verb onlypackages/harness/src/tools.mjs, alist_requirementstool andtask_create'srequestdescription onlyroles/pm.md, the paragraphs on creating tasks and raising decisions onlypackages/harness/src/bundle.mjs, one session-text line on stopping a run, andtools.mjs'sraise_decisionblockingdescriptionpackages/tasks/README.md,packages/bus/README.md- tests under
packages/tasks/tests/,packages/bus/tests/andpackages/harness/tests/ agents/dewey/work/queue-<row>/
What ships
packages/tasks/requirements.jsonlists the PRD version and path and each id with a one-line title. A test reads the PRD's**REQ-...**bullets and fails unless the list matches them exactly, ids and order. A later PRD version is a new list in a reviewed commit, like the PRD.requirements.mjschecks the file's shape and loads it frozen, asfields.mjsdoes forfields.json(row 61). A bad file refuses at load.task.createrefuses withunknown-requirementwhen the id isn't in the list. The format check stays.- The broker's
requirementsverb returns the list, a read for every session that can create tasks. Thelist_requirementstool exposes it to the pm role. roles/pm.md: before creating a task, pick the id fromlist_requirements. When no id fits the request, raise a decision with actionprd.approve,blockingfalse and notask_ref, naming the request and the nearest ids, and don't create the task; don't ask in a RESULT.task_create'srequestsays it is therequestfield of the human message being answered, not the message id.pm.mdsays the same.raise_decision'sblocking(tools.mjs:100, today "True when you can't continue without the answer") says a blocking decision holds a task and needstask_ref; a request with no task is non-blocking. The broker's rule stays: a blocking decision with no task still refusestask-required.- The session text says a run is stopped by the human with
mosaic stop; a session that thinks a run should stop raisesrole.revokewithblockingfalse, naming the run, and says why.pm.mdsays the same. - Tests: the PM's role session raises that
prd.approvedecision with no task and it routes to the human;task.createwith a message id still refusesrequest-not-found, and with that message'srequestit passes the request check; a non-blockingrole.revokewith no task from the PM routes to the human's Inbox.
Suites: the tasks, bus, harness, webui and cli node suites, and every
scripts/test-*.sh.
Out of scope
Editing the PRD or adding ids. Mapping Jason's past requests to ids. Showing the list in the Console. Letting a blocking decision go without a task. What happens to #229 and #230; their id is the PM's and Jason's to correct.
Gate
Filbert approves on the row's issue, naming the candidate manifest. The suites above pass on Sage's gate rerun. Live at the next bus restart and PM launch, so the same decision-109 notices as the row above.
A project checkout in each role workspace
Problem
A launched role session works in
<dataRoot>/workspaces/<business>/<instance>/
(packages/seat/src/session.mjs:20,46), which the launcher creates
empty (packages/cli/src/launcher.mjs:159-160). Nothing puts the
project there. In #228's test the coder's workspace held only a fresh
.git and its own new files, so it worked beside the source rather than
in it, and the PM asked Jason for a checkout (dad88d23). Slice 1's goal
is the stack building pieces of itself (2026-10-04_slice-1.md, line
20), which needs the coder in the project. The business file names each
project's root (packages/business/src/business.mjs:170-177); that is
the only source the launcher has.
Owner and reviewer
Owner: Darkwing, after row 92 (#1586, sessions confined to their own files), so the clone is built and tested inside the confined workspace. Reviewer: Filbert.
Files owned
packages/cli/src/launcher.mjs, the workspace step instart()onlypackages/seat/src/checkout.mjs, newpackages/seat/README.md,packages/cli/README.md, the workspace paragraphspackages/harness/src/bundle.mjs, the line inprompt()that names the clone's path and the doc comment on the session argument (added by decision 126)- tests under
packages/seat/tests/,packages/cli/tests/andpackages/harness/tests/ agents/darkwing/work/queue-<row>/
What ships
- At every launch, before the bundle is built, the host makes sure each
project in the business file has a clone at
<workspace>/<project id>/. The host runs git, outside the session's sandbox. A missing clone isgit clone --no-hardlinks <root>, at the root's checked-out branch. An existing clone with a clean tree on that branch fast-forwards from its origin. Anything else (a dirty tree, another branch, a fast-forward that fails) is left exactly as it is, and the launch log recordscheckout-keptwith the project id and the reason. The host never resets, cleans, deletes or force-updates a clone. - A clone's origin is the local root path. No credential, token or Gitea remote goes into it. The root is never written.
- A clone that can't be made refuses the launch with
checkout-failedand the project id, through the broker'srefuse()like the other launch steps. No partial clone is left behind. - The bundle's session text names the clone's path, so the session knows where the project is.
- The clone is written by the session, so the host treats it as hostile
before it runs git there (decision 126). Without these guards, a
session could make the host run a command outside the sandbox, which
would undo row 92.
- Every git call runs with
GIT_CONFIG_NOSYSTEM=1,GIT_CONFIG_GLOBAL=/dev/null, an empty host-madeHOMEandGIT_TERMINAL_PROMPT=0, and with-c core.hooksPath=/dev/null -c core.fsmonitor=false -c protocol.allow=never -c protocol.file.allow=always. - Before any git runs in an existing clone, the host checks it with
lstatand never follows a link.<workspace>/<id>and its.gitare real directories. Nothing under.gitis a symlink or a special file, and no regular file there has more than one link..git/commondirandobjects/info/alternatesare absent. - The host reads
.git/config, a regular file under a size cap, withgit config --file <f> --list, which follows no include and runs nothing. Every key is on an allowlist: the keysgit clonewrites, withremote.origin.urlequal to the root and the standard fetch line, plusbranch.<name>.remoteandbranch.<name>.merge. - A clone that fails a check is kept,
checkout-keptwith reasonnot-a-cloneorconfig-changed, and no git runs in it. - The checks and the git calls run only while no session holds that
workspace, so a session can't change the clone between check and
use. If the launcher already refuses a second live session in one
workspace, the packet cites that refusal and a test shows it.
Otherwise the clone is kept with reason
session-live. - A new clone goes to a temporary sibling directory and is renamed into place, so a failure removes only that directory.
- A root with no branch checked out refuses
checkout-failedfor a missing clone and keeps an existing one. - The git step gets no bwrap sandbox of its own. One would have to
bind the root's
.git, whose remote URL may carry a credential. The allowlist closes the execution paths instead, and Filbert reviews it as the row's main risk.
- Every git call runs with
- Tests: one case for each way a session could make the host run
something or write outside the clone:
core.fsmonitor,core.hooksPath, a hook file,include.path, a symlinked clone directory, agitdir:file, a symlink under.git(logs,refs,objects), alternates andcommondir. Each case keeps the clone and shows the command never ran, because the marker file it would write is absent. The root is unchanged. - Tests: a first launch clones; a second fast-forwards after a commit
to the root; a dirty clone and one on another branch are kept and
logged; an unreadable root refuses
checkout-failedwith nothing left; the root's tree and refs are unchanged after each case; no remote URL in a clone carries a credential.
Suites: the seat, cli, bus and harness node suites, and every
scripts/test-*.sh.
Out of scope
How the coder's change reaches the reviewer and the repository: pushing
a working branch (git.push.working), the reviewer reading the coder's
change, and landing. That is the next row, which Sage briefs once this
one lands. Credentials in a clone. The PM's workspace contents beyond
the clone.
Gate
Filbert approves on the row's issue, naming the candidate manifest. The suites above pass on Sage's gate rerun. The launcher runs in the bus host, so the change is live at the next bus restart: the same decision-109 notices as the rows above.