From 811efa7d75d340ac152646dcf04cd73ae636d4de Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Wed, 30 Sep 2026 12:38:48 -0300 Subject: [PATCH 1/2] refactor(opencode)!: use native tools for external effects Remove the managed executor and obsolete mutation aliases on OpenCode V1 and V2. Preserve read-only context, decisions, workers and historical state. BREAKING CHANGE: OpenCode no longer exposes workit_external_action. Use native host tools for mutations and workit_context for read-only context. Pi and CLI managed effects are unchanged. --- AGENTS.md | 10 +- CHANGELOG.md | 22 +- README.md | 17 +- docs/adaptive-workit/plan.md | 43 + docs/adaptive-workit/reliability-spec.md | 52 +- docs/workit-v1/capabilities.md | 2 +- packages/workit-core/src/core/branch.ts | 2 +- packages/workit-core/src/core/methods.ts | 5 + packages/workit-core/src/core/repo-context.ts | 2 +- .../workit-core/src/core/youtrack-tools.ts | 8 +- packages/workit-core/src/core/youtrack.ts | 2 +- packages/workit-opencode/README.md | 17 +- packages/workit-opencode/src/shared/tools.ts | 12 +- packages/workit-opencode/src/tools/context.ts | 45 + packages/workit-opencode/src/tools/index.ts | 2 +- packages/workit-opencode/src/tools/repo.ts | 184 +- packages/workit-opencode/src/tools/workit.ts | 540 +-- .../workit-opencode/src/tools/youtrack.ts | 91 +- packages/workit-opencode/src/v2/plugin.ts | 22 +- test/acceptance/scenarios.ts | 4 +- test/artifacts/phase-9-traceability.test.ts | 2 +- test/opencode-v2/matrix.test.ts | 2 +- test/opencode-v2/shell.test.ts | 201 +- test/shared/helpers/opencode-namespace.ts | 3 +- test/workit-core/auto-approval-parity.test.ts | 52 +- test/workit-core/branch-policy.test.ts | 387 -- test/workit-core/branch.test.ts | 194 +- test/workit-core/config-guard.test.ts | 7 +- test/workit-core/external-action.test.ts | 69 - test/workit-core/pr-create.test.ts | 138 - test/workit-core/repo.test.ts | 448 +-- test/workit-core/youtrack.test.ts | 141 +- test/workit-opencode/bootstrap.test.ts | 6 + test/workit-opencode/dual-entry.test.ts | 3 +- test/workit-opencode/plugin.test.ts | 3 +- .../schema-discoverability.test.ts | 167 +- test/workit-opencode/smoke.ts | 2 +- test/workit-opencode/task-hooks.test.ts | 4 +- test/workit-opencode/task-tools.test.ts | 3223 +++-------------- 39 files changed, 762 insertions(+), 5372 deletions(-) create mode 100644 packages/workit-opencode/src/tools/context.ts diff --git a/AGENTS.md b/AGENTS.md index 72153870..ac92bef1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Multi-platform workit: OpenCode, Cursor, Codex CLI/desktop, Pi, and the CLI shar | Implementation | subagent-driven via native `task`, delegated status from session parentage (child `parentID` must equal the coordinator session handle) | native `subagentStart` assignment; file writes pass through to host policy, exact stop identity, AskQuestion answers, arbitrary shell writes, and Tab edits remain agent_guided/unavailable | project-trust `tool_call` boundary; file writes pass through to host policy, shell writes remain agent_guided and no OS sandbox is provided | n/a (`workit task` only) | | Lifecycle | `workit_task` pause/resume/close (native decision receipts) | `workit_task` pause/resume/close (policy-only) | session start/compaction/shutdown continuity; supervised stock-Pi worker lifecycle | `workit task pause\|resume\|close` | | Handoff | manual `workit state export` + destination `state import` via the handoff skill (no native session spawn) | seeds a handoff prompt for the next agent | fresh stock-Pi review/worker processes; no nested worker launch | `workit state export` + destination `state import` (or printed handoff prompt) | -| Tools | one dual artifact: V1 `server()` + V2 `setup()`, ten native tools (eight families + external action + init apply) with `codemode:false` on V2 | MCP server (`workit_*`) | eight native core-backed `workit_` tools (+ adapter-owned `workit_external_action`) | `workit` commands | +| Tools | one dual artifact: V1 `server()` + V2 `setup()`, ten native tools (eight families + read-only context + init apply) with `codemode:false` on V2 | MCP server (`workit_*`) | eight native core-backed `workit_` tools (+ adapter-owned `workit_external_action`) | `workit` commands | | Shared MCP transport | n/a (native tools remain host-owned) | `@brainervirus/workit-mcp`; host wiring remains adapter-owned | n/a (native tools remain host-owned) | n/a | | Skills | `skills.paths` + fourteen canonical policy-selected method skills (no vendored Superpowers dirs) | plugin `skills/` dirs | package `pi.skills` + fourteen canonical method skills | n/a | | Branch policy init | `workit_init_apply action=branch_policy` (narrow registration) | pending — use the wizard screen (unattested MCP cannot mutate) | pending — use the wizard screen (no host surface is wired yet) | wizard screen | @@ -18,6 +18,11 @@ Multi-platform workit: OpenCode, Cursor, Codex CLI/desktop, Pi, and the CLI shar OpenCode's native adapter keeps the eight shared operation contracts authoritative while projecting advertised nested schemas to the provider's supported depth; runtime parsing remains core-owned. +OpenCode V1 and V2 expose no managed external mutation tool. Use native host +tools for effects and `workit_context` for read-only inspection; do not rebuild +proposal/approval orchestration or retain a disabled execution alias. Existing +uncertain history remains inspectable and must be reconciled before retry. + Concrete optional Git, hosting, YouTrack, and documentation mutations remain adapter-owned on host surfaces that can attest the effect. They must run through the shared one-time action reservation and host-observed settlement, @@ -27,7 +32,8 @@ matching). Caller-unattested MCP keeps optional mutations unavailable. The CLI (including `--confirm` without a TTY) return `needs_input` and never fabricate an approval receipt. -Read-only `context.read` is available on OpenCode, Pi, and the CLI for the +Read-only `workit_context` is available on OpenCode; Pi and the CLI use +`context.read` for the enumerated git/PR/YouTrack/changelog/release/affected contexts without approval or writer ownership; release context includes a deterministic draft, and affected context identifies files only. Documentation edits continue diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc52bda..756be773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,19 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -> **Version model.** The repository's package manifests pin a fixed source -> version (`0.4.0`); [semantic-release](https://semantic-release.gitbook.io) -> computes the next version from Conventional Commits and rewrites the package -> versions and internal `workspace:*` dependencies **in CI only** -> (`packages/workit-core/scripts/rewrite-workspace-deps.ts`), never committing -> the rewrite back to the repository. This file is maintained by hand and -> documents through `0.6.0`; releases published after that (for example -> `0.6.1`, `0.7.0`, `0.7.1`) were created by the release workflow and their -> notes live in GitHub Releases, not here. The published npm version can -> therefore run ahead of both the source manifests and this changelog. +> **Version model.** Semantic-release computes published versions from +> Conventional Commits. The release workflow rewrites internal dependencies +> for publishing, then synchronizes source manifests through its manifest PR. +> GitHub Releases contain the generated release notes; this file records +> manually maintained changes, including unreleased work. ## [Unreleased] +### Changed + +- OpenCode V1/V2 remove managed external mutations and proposal/approval + orchestration. Native host tools execute effects; strict read-only + `workit_context` replaces the old combined action/context tool. Existing + history and remaining-host action machinery are preserved. + ### Fixed - OpenCode action approvals revalidate same-text proposals before rejecting diff --git a/README.md b/README.md index 4eb11457..8236abce 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ host documents. | Package | Purpose | | ----------- | ------------------------------------------------------------------------------- | -| OpenCode | Native plugin with fourteen method skills, ten tools (eight shared families plus external action and init apply), and provider-safe schemas | +| OpenCode | Native plugin with fourteen method skills, ten tools (eight shared families plus read-only context and init apply), and provider-safe schemas | | Cursor | MCP transport, one native hook dispatcher, one contract rule, and fourteen skills | | Codex | Native plugin manifest, shared MCP transport, documented lifecycle hooks, and fourteen skills | | Pi | Native npm extension with nine tools (eight shared families plus external action), fourteen skills, and session continuity | @@ -296,13 +296,13 @@ MCP provider keeps read-only inspection usable without an attested caller and returns `capability_unavailable` for authority-sensitive mutations when the host cannot prove the caller boundary. -Optional Git, hosting, YouTrack, and documentation effects use one-time +On Pi and the CLI, optional Git, hosting, YouTrack, and documentation effects use one-time approved action reservations and host-observed settlement on the existing host-owned effect surfaces. A concrete call must match the exact canonical operation/target/payload approved by the native host; prose or substring matches never authorize it. Missing credentials leave unrelated core work -usable, while an uncertain remote outcome blocks blind retry. OpenCode and Pi -use native approval receipts; the CLI `workit action` route shows the exact +usable, while an uncertain remote outcome blocks blind retry. Pi +uses native approval receipts; the CLI `workit action` route shows the exact descriptor and requires an interactive TTY confirmation. A headless CLI call (including `--confirm` without a TTY) returns `needs_input`, while the caller-unattested MCP surface keeps optional mutations unavailable. Time @@ -313,8 +313,9 @@ when managed coordination or outcome reconciliation is unnecessary. Inspect the target checkout's conventions first; native permissions apply. There is no need to start a Workit task just to commit, and a local commit does not require PR readiness or task-closure paperwork. Never switch execution paths to evade a -denial or retry an uncertain managed effect. Managed actions remain optional for -their target locking and outcome reconciliation. See the +denial or retry an uncertain managed effect. OpenCode V1 and V2 use native +host tools for mutations; Workit exposes read-only `workit_context` and shared +coordination tools, with no managed external-action executor. See the [action reliability specification](docs/adaptive-workit/reliability-spec.md). Newly assessed bounded behavior changes keep behavioral checks and self-review. Security, data, public-contract and operational consequences, the thorough @@ -358,8 +359,8 @@ Examples: - comment-only `youtrack.update` with `{ "issueId": "ABC-1", "markdown": "..." }` - `changelog.apply` with `{ "entries": [{ "category": "Added", "text": "..." }] }` -All native adapters and the CLI also expose the read-only `context.read` -operation for `git`, `pr`, `youtrack`, `github_issue`, `gitlab_issue`, +OpenCode exposes the read-only `workit_context` tool with a flat payload. +Pi and the CLI expose the read-only `context.read` operation for `git`, `pr`, `youtrack`, `github_issue`, `gitlab_issue`, `changelog`, `release`, and `affected` context. The tracker kinds return the same title/body/state triple through authenticated `gh` and `glab` (GitLab subgroups kept); they fail closed when the CLI is unavailable or not logged in. diff --git a/docs/adaptive-workit/plan.md b/docs/adaptive-workit/plan.md index 149efe30..95c7a771 100644 --- a/docs/adaptive-workit/plan.md +++ b/docs/adaptive-workit/plan.md @@ -449,3 +449,46 @@ focused question only when one of those choices blocks a concrete slice. restart OpenCode once to activate the tested source; inspect new-session routing before model-driven qualification. Do not rebuild or reload while the current user session is running. + +### OpenCode native-effects cutover — 2026-09-30 + +- Baseline: PR #134 squash-merged, all fourteen PR checks and main CI passed; + v1.3.1 published. Started from manifest-synced main `94adf7a` with a clean tree. +- User selected removal, not disabled managed executors, on both OpenCode V1 + and V2. The cutover contract in `reliability-spec.md` supersedes OpenCode's + old external-mutation tool. Shared Pi/CLI effects remain in use. +- Slice 1: delete OpenCode mutation registration, proposal/autoapproval runner + and obsolete routing; expose strict read-only `workit_context`. Slice 2 + depends on 1: adapt adapter/acceptance tests, retaining generic decisions, + receipts, workers, shell policy and read-only contexts. Slice 3 depends on + 1–2: docs, full isolated checks, pack checks and installed V2 loader probe. +- Two Luna workers have disjoint source/test scopes; the coordinator owns + guidance, integration and acceptance. No active host build/reload, real-history + migration or pending-outcome settlement occurs during development. +- Acceptance includes absent executor on both versions, mutation-shaped context + input rejected without effects, unchanged state/history and remaining-host + behavior. Checkpoint and final counts follow after integration. +- Slice 1 complete: mutation registration, executor/proposal/autoapproval code + and retired repo/YouTrack aliases removed. Context uses the canonical payload + and existing read helper, without importing the generic effect executor. + Production typecheck and scoped adapter lint passed. Independent Luna review + found no definite defect; pinned V1 runtime preserves raw inputs for strict + handler validation. Shared guidance no longer points to deleted tool aliases. + Next: finalize tests and isolated build/pack/native loader checks. + +- Slices 2–3 complete: obsolete executor tests removed; strict context, receipt + replay protection, worker provenance, native shell policy and shared Pi/CLI + effects retained. Updated one historical traceability reference to the current + no-action-authorization regression. Full isolated `bun run check` passed: + 1,531 tests, lint, format, build and typecheck. Seven release-candidate tarballs + and marketplace validation passed. +- Installed V2.0.19 and official Docker V1.18.30 loaded the candidate in private + fixtures and registered the same ten tools, with context present and managed + executor absent. These are loader checks, not model-driven qualification. + The V1 fixture container was stopped; no user session was resumed. Candidate + source matches the workspace; active artifact/config hashes remain unchanged. + Evidence: `/tmp/workit-native-effects-{full-check,pack,v2-probe,v1-probe}.log` + and `/tmp/workit-native-effects-x7cspusr/` source/host hashes and probe results. +- Remaining: publish PR, babysit CI and squash-merge; then activate the local pin + only at a safe restart boundary. No live history migration is required or + authorized. Historical unknown outcomes require evidence before native retry. diff --git a/docs/adaptive-workit/reliability-spec.md b/docs/adaptive-workit/reliability-spec.md index de1f42d4..9063dfbb 100644 --- a/docs/adaptive-workit/reliability-spec.md +++ b/docs/adaptive-workit/reliability-spec.md @@ -1,10 +1,12 @@ # Workit action reliability -Date: 2026-09-30. Status: implemented and isolated acceptance passed; active-host deployment pending. +Date: 2026-09-30. Status: native-effects cutover passed isolated acceptance; v1.3.1 baseline retained. + +The v1.3.1 proposal-binding work below is historical for OpenCode mutation +execution. The native-effects cutover at the end supersedes that execution path. This extends the adaptive spec; it does not authorize live migration or effects -in the user's running sessions. Existing workspace-resolution changes are a -separate dirty-tree slice and must remain intact. +in the user's running sessions. Previously reconciled workspace-resolution changes remain intact. ## Reproduced problem @@ -18,7 +20,7 @@ rejected the approval before checking which descriptor remained current. Changing the commit message escaped the collision and caused another question. Task closure then induced an assessment and additional schema retries. -## Required behavior +## v1.3.1 required behavior (historical mutation adapter) 1. Resolve all proposals matching an observed approval before deciding whether they are ambiguous. Evict only descriptors proven stale by successful fresh @@ -80,7 +82,7 @@ defines native capabilities; questions are not permission grants. These referenc guide the implementation but do not prove Workit's reliability. Scenario tests and saved host evidence must do that. -## Proactive audit dispositions +## v1.3.1 proactive audit dispositions - Approval identity: stale same-text proposals, ask-time ordering, duplicate delivery and consumed-receipt replay have regression coverage. V2 must capture @@ -112,3 +114,43 @@ and saved host evidence must do that. loader probe do not establish one-shot reliability across models. A bounded model-driven qualification should measure retries and duplicate questions before making that claim. Do not run the budgeted 90-run suite implicitly. + +## OpenCode native-effects cutover + +User decision, 2026-09-30: remove managed external mutations from both OpenCode +versions rather than retain disabled executors or add a V1-only permission shim. +Native host tools own Git, hosting, YouTrack and documentation effects. Native +denials, sandbox rules and target-repository conventions remain authoritative. + +Remove `workit_external_action`, its adapter-only proposal queues, standing +approval orchestration and effect runner. Do not add a compatibility alias that +can still execute mutations. Retain eight shared operation families, native +decision receipts for real policy choices, worker/lifecycle support and config +initialization. Expose the existing read-only contexts through `workit_context` +with a strict flat payload schema; context reads cannot select a mutation. + +Shared core/Pi/CLI effect machinery remains in use and is outside this adapter +removal. Persisted action history, pending/unknown outcomes, decisions, tasks and +leases are preserved. Inspection remains available, but an uncertain historical +effect must be reconciled from actual evidence before any native retry. Do not +fake settlement or migrate live history during this cutover. + +Acceptance slices: + +1. Remove both native registrations and OpenCode-only execution/proposal code; + verify V1 and V2 expose context, families and init only. No effects alias. +2. Retain strict read-only context validation and session/worker provenance; + reject mutation-shaped input without Git or Workit state writes. Preserve + decision receipt replay protection and native shell-policy regression checks. +3. Replace obsolete execution tests and current guidance. Shared effects and + uncertainty tests for the remaining hosts continue to pass. Qualification + scripts must not attempt the removed tool or advertise stale capability. +4. Isolated build/full checks, package acceptance and installed V2 private loader + probe. Preserve the running host's artifact/config until a safe restart. + +The installed V2.0.19 bundle uses `options.permission ?? toolName` for coarse +whole-action deny filtering; it does not expose a custom-tool permission assert +for each subprocess. V1 has an async `ToolContext.ask`, but Workit's existing +effects are synchronous and would need another command authorization layer. +Native execution avoids duplicating the host's command scanner and permission +workflow on either version. diff --git a/docs/workit-v1/capabilities.md b/docs/workit-v1/capabilities.md index f1f99b41..9875bce9 100644 --- a/docs/workit-v1/capabilities.md +++ b/docs/workit-v1/capabilities.md @@ -1,6 +1,6 @@ # Workit v1 host capability matrix -Generated from adapter fixtures. Fixture revision: `workit-v1-2026-09-09`. +Generated from adapter fixtures. Fixture revision: `workit-v1-2026-09-30`. Unknown or untested cells fail the applicable baseline rather than reading as supported. diff --git a/packages/workit-core/src/core/branch.ts b/packages/workit-core/src/core/branch.ts index 9a8688ae..5ce36b10 100644 --- a/packages/workit-core/src/core/branch.ts +++ b/packages/workit-core/src/core/branch.ts @@ -711,7 +711,7 @@ export const branchSetup = ({ if (classifyBranchDirt(cwd) === "stash-required" && stash !== "yes") { return { error: - "dirty working tree — ask with native question, then call workit_branch_setup with stash=yes", + "dirty working tree — obtain native host approval before stashing and setting up the branch", phase: "preflight", }; } diff --git a/packages/workit-core/src/core/methods.ts b/packages/workit-core/src/core/methods.ts index ca88dc3f..d5e0e0aa 100644 --- a/packages/workit-core/src/core/methods.ts +++ b/packages/workit-core/src/core/methods.ts @@ -125,6 +125,11 @@ Preserve uncertain external outcomes and reconcile repository/provider state before retrying. Internal reservations prevent duplicate or ambiguous effects; they are not permission tickets for every edit. +On OpenCode V1 and V2, use native host tools for external mutations. Workit +provides read-only workit_context; it has no managed external-action executor. +Do not create proposals or request Workit approvals merely to invoke native +tools. Old uncertain managed effects still require evidence before any retry. + Use host-native authorization through the host's supported path. A native question receipt records an actual question interaction; it is not automatic host permission. Record a meaningful user choice once with provenance when diff --git a/packages/workit-core/src/core/repo-context.ts b/packages/workit-core/src/core/repo-context.ts index a9abbe0a..bfd5d208 100644 --- a/packages/workit-core/src/core/repo-context.ts +++ b/packages/workit-core/src/core/repo-context.ts @@ -363,7 +363,7 @@ const CHANGELOG_RULES = `- Use an [Unreleased] section. - Entries should be human-readable and user-facing. - Do not use raw commit messages as changelog bullets. - MERGE into existing ### Category under [Unreleased] — never append a second ### Added / ### Fixed block. -- Apply with the native workit_changelog_apply tool only (not hand-edits under Unreleased). +- Apply with the host-native editor, preserving the repository changelog conventions. - If Unreleased already has duplicate category headings, normalize_only first.`; /** Port of changelog-context.sh — changelog update context. */ diff --git a/packages/workit-core/src/core/youtrack-tools.ts b/packages/workit-core/src/core/youtrack-tools.ts index 562cd074..7ea08fb8 100644 --- a/packages/workit-core/src/core/youtrack-tools.ts +++ b/packages/workit-core/src/core/youtrack-tools.ts @@ -103,7 +103,7 @@ type PostData = { loggedMinutes: number; outcome?: "unknown" | "not_applied"; instructions?: string; - retry?: "workit_youtrack_post" | "workit_youtrack_log_time"; + retry?: "youtrack.update" | "youtrack.time"; }; const notApplied = (value: LegacyValue): value is NotApplied => @@ -130,7 +130,7 @@ export async function postUpdate( postedComment: false, loggedMinutes: 0, outcome: "not_applied", - retry: "workit_youtrack_post", + retry: "youtrack.update", }); unwrap(comment); } catch (error) { @@ -158,7 +158,7 @@ export async function postUpdate( postedComment: true, loggedMinutes: 0, outcome: "not_applied", - retry: "workit_youtrack_log_time", + retry: "youtrack.time", }); unwrap(time); } catch (error) { @@ -191,7 +191,7 @@ export async function logTimeUpdate( issueId: input.issueId, loggedMinutes: 0, outcome: "not_applied", - retry: "workit_youtrack_log_time", + retry: "youtrack.time", }); return ok(unwrap(value)); } catch (error) { diff --git a/packages/workit-core/src/core/youtrack.ts b/packages/workit-core/src/core/youtrack.ts index a5db1ea3..d7448d02 100644 --- a/packages/workit-core/src/core/youtrack.ts +++ b/packages/workit-core/src/core/youtrack.ts @@ -846,7 +846,7 @@ export async function postUpdate( postedComment: true, loggedMinutes: 0, error: time.error, - retry: "workit_youtrack_log_time", + retry: "youtrack.time", }; } return { ok: true, issueId, postedComment: true, loggedMinutes: minutes }; diff --git a/packages/workit-opencode/README.md b/packages/workit-opencode/README.md index 2d436514..6f4f4d7b 100644 --- a/packages/workit-opencode/README.md +++ b/packages/workit-opencode/README.md @@ -4,7 +4,7 @@ [![npm version](https://img.shields.io/npm/v/@brainervirus/workit-opencode.svg)](https://www.npmjs.com/package/@brainervirus/workit-opencode) [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](../../LICENSE) -OpenCode plugin for workit — workflow rails for agentic coding (specs, plans, YouTrack, CI-gated commits), with host-native approval, delegation, handoff, and diagnostics. +OpenCode plugin for Workit — optional coordination, policy, delegation, handoff, and read-only context. Native OpenCode tools execute Git, hosting, YouTrack and documentation effects. ## Install @@ -16,22 +16,25 @@ OpenCode plugin for workit — workflow rails for agentic coding (specs, plans, } ``` -Local dev variant (absolute path to this repo): +OpenCode V2 uses `"plugins"`; V1 uses `"plugin"`. + +Local dev variant (absolute path to this repo; use `plugins` on V2): ```jsonc { "$schema": "https://opencode.ai/config.json", - "plugin": ["file:///path/to/workit/packages/workit-opencode/src/plugin.ts"] + "plugin": ["file:///path/to/workit/packages/workit-opencode"] } ``` -Requirements: OpenCode 1.18.30+ (V1) or 2.0.18 (V2), Node ≥ 24. The published +Requirements: OpenCode 1.18.30+ (V1) or 2.0.18+ (V2), Node ≥ 24. The published plugin is a self-contained Node bundle; its default export is a dual entry (`server()` for V1, `setup()` for V2), so the same pin works on both hosts. ## What it provides - **Eight native operation tools** — `workit_task`, `workit_policy`, `workit_evidence`, `workit_finding`, `workit_decision`, `workit_worker`, `workit_writer`, and `workit_state`. +- **Read-only context and init tools** — `workit_context` accepts `{ "kind": "git" }` and the existing PR/YouTrack/changelog/release/affected context fields; `workit_init_apply` keeps confirmed configuration initialization. - **Fourteen policy-selected method skills** — challenge, behavioral TDD, review, plan, implement, debug, handoff, babysit, blast-radius, deslop, diagram, mockup, green-run, and steer. - **Native lifecycle hooks** — host-observed question receipts, direct-child task workers, compact task bootstrap/restoration, and known-surface writer checks. @@ -42,6 +45,12 @@ plugin is a self-contained Node bundle; its default export is a dual entry - **Delegation** — native `task` workers are direct-child-only; nested or uncertain lineage is denied (`delegation_lineage_denied`). - **Continuity** — compact task context is injected once on session start and once after compaction; unobservable shell surfaces are labeled `agent_guided`. +Workit does not register `workit_external_action` on either OpenCode version. +Use native tools for mutations under the host permissions and target conventions; +no Workit task, writer or decision is needed merely to run an ordinary command. +Old action/decision history is preserved. Inspect and reconcile any uncertain +effect before retrying; removal does not settle or migrate it. + ## Bundle / runtime model The build bundles the `@opencode-ai/plugin` (V1) and `@opencode/plugin` (V2) SDK surfaces used by the adapters into `dist/plugin.js`, so the published plugin has **no** runtime dependency on either SDK (both stay development/build-only pins). The plugin loads through its real package entry `dist/plugin.js`; only the fourteen method skills ship under `assets/`. diff --git a/packages/workit-opencode/src/shared/tools.ts b/packages/workit-opencode/src/shared/tools.ts index 8fea5449..a624a2a2 100644 --- a/packages/workit-opencode/src/shared/tools.ts +++ b/packages/workit-opencode/src/shared/tools.ts @@ -35,10 +35,16 @@ const familyTools = OPERATION_FAMILIES.map((family): WorkitToolSpec => ({ export const WORKIT_TOOL_CATALOG: readonly WorkitToolSpec[] = [ ...familyTools, { - name: "workit_external_action", + name: "workit_context", description: - "Run a Workit-managed external action. context.read is read-only. Mutations require an active coordinator task, checkout writer ownership, and host-native authorization. Routine Git work already permitted by the host can use native tools without a Workit task; never use another route to bypass a host denial or uncertain result.", - input: externalActionJsonSchema() as Record, + "Read Git, pull request, YouTrack, issue, changelog, release, or affected-file context.", + input: (() => { + const variants = (externalActionJsonSchema() as { oneOf: Array> }).oneOf; + const context = variants.find( + (variant) => variant.properties.operation.const === "context.read", + )!.properties.payload; + return objectSchema(context.properties, context.required ?? []); + })(), }, { name: "workit_init_apply", diff --git a/packages/workit-opencode/src/tools/context.ts b/packages/workit-opencode/src/tools/context.ts new file mode 100644 index 00000000..c405d5fc --- /dev/null +++ b/packages/workit-opencode/src/tools/context.ts @@ -0,0 +1,45 @@ +import { realpathSync } from "node:fs"; +import path from "node:path"; +import { tool } from "@opencode-ai/plugin"; +import { + externalActionRequest, + externalActionSchema, +} from "@brainervirus/workit-core/src/core/external-action"; +import type { ExternalActionRequest } from "@brainervirus/workit-core/src/core/external-action"; +import { failure } from "@brainervirus/workit-core/src/core/task-contract"; +import { readExternalContext } from "@brainervirus/workit-core/src/core/external-action-effects"; + +const contextVariant = externalActionSchema.options.find( + (variant) => variant.shape.operation.value === "context.read", +)! as any; +type ContextPayload = Extract["payload"]; + +export const createContextTool = () => + tool({ + description: + "Read Git, pull request, YouTrack, issue, changelog, release, or affected-file context.", + args: contextVariant.shape.payload.shape, + execute: async (args, context) => { + const parsed = externalActionRequest({ operation: "context.read", payload: args }); + if (!parsed.ok) return JSON.stringify(parsed, null, 2); + let root: string; + try { + const payload = parsed.data.payload as ContextPayload; + const cwd = payload.cwd; + root = realpathSync(cwd ? path.resolve(context.directory, cwd) : context.directory); + } catch { + return JSON.stringify( + failure("invalid_input", "action target directory cannot be resolved", { + outcome: "not_started", + }), + null, + 2, + ); + } + return JSON.stringify( + await readExternalContext(root, parsed.data.payload as ContextPayload), + null, + 2, + ); + }, + }); diff --git a/packages/workit-opencode/src/tools/index.ts b/packages/workit-opencode/src/tools/index.ts index 2ade64cf..68d83930 100644 --- a/packages/workit-opencode/src/tools/index.ts +++ b/packages/workit-opencode/src/tools/index.ts @@ -3,6 +3,6 @@ import { createWorkitTools } from "./workit"; export { createWorkitTools, NativeReceiptStore, observeQuestion } from "./workit"; export type { WorkitToolOptions } from "./workit"; -/** OpenCode's native surface includes the eight core families and host-owned optional actions. */ +/** OpenCode's native surface includes core operations and read-only context. */ export const createTools = (options: import("./workit").WorkitToolOptions = {}) => createWorkitTools(options); diff --git a/packages/workit-opencode/src/tools/repo.ts b/packages/workit-opencode/src/tools/repo.ts index f6c86822..265495e5 100644 --- a/packages/workit-opencode/src/tools/repo.ts +++ b/packages/workit-opencode/src/tools/repo.ts @@ -1,24 +1,11 @@ -import { realpathSync } from "node:fs"; import { tool } from "@opencode-ai/plugin"; -import { - externalActionDescriptor, - fail, - failure, - gitRevisionParts, - ok, - resolveInside, - run, - sha256, -} from "@brainervirus/workit-core/src/core"; -import { changelogApply } from "@brainervirus/workit-core/src/core/changelog"; +import { fail, gitRevisionParts, ok, run } from "@brainervirus/workit-core/src/core"; import { gitContext } from "@brainervirus/workit-core/src/core/git"; import { parseKeyValueLines, parseSections, } from "@brainervirus/workit-core/src/core/parse-sections"; import { parseVerifyOutput } from "@brainervirus/workit-core/src/core/verify-parse"; -import { branchSetup, resolveBranchPolicyFor } from "@brainervirus/workit-core/src/core/branch"; -import { getDiagnosticLogger } from "@brainervirus/workit-core/src/core/config"; import { changelogContext, docsRefreshContext, @@ -26,24 +13,10 @@ import { releaseNotesContext, } from "@brainervirus/workit-core/src/core/repo-context"; import { runVerifyProject } from "@brainervirus/workit-core/src/core/verify-project"; -import { - actionProposalQuestion, - resolveExternalActionRequest, -} from "@brainervirus/workit-core/src/core/external-action-effects"; import { initStatusData, toolkitStatusData } from "@brainervirus/workit-core/src/core/init"; -import { - normalizeLegacyResult, - type RepoRuntime, - type RunResult, -} from "@brainervirus/workit-core/src/core/repo-tools"; +import type { RepoRuntime, RunResult } from "@brainervirus/workit-core/src/core/repo-tools"; import { executeInitApply, initApplyRuntime } from "../shared/init-apply"; -import { - diagnostics, - legacyScriptResult, - output, - requireConfirmed, - scriptResult, -} from "../shared/repo-result"; +import { output, scriptResult } from "../shared/repo-result"; const defaultRuntime: RepoRuntime = { git: (root, args) => run(root, "git", args), @@ -225,157 +198,6 @@ export function createRepoTools(runtime: RepoRuntime = defaultRuntime) { execute: async ({ range }, context) => output(scriptResult(runtime.docsContext(context.directory, range), parseDocs)), }), - workit_changelog_apply: tool({ - description: "Apply confirmed Keep a Changelog entries to Unreleased", - args: { - confirmed: tool.schema.boolean(), - entries: tool.schema - .union([ - tool.schema.record(tool.schema.string(), tool.schema.array(tool.schema.string())), - tool.schema.array( - tool.schema.object({ category: tool.schema.string(), text: tool.schema.string() }), - ), - ]) - .optional(), - path: tool.schema.string().optional(), - normalize_only: tool.schema.boolean().optional(), - }, - execute: async ({ confirmed, entries, path: changelogPath, normalize_only }, context) => { - const rejected = requireConfirmed(confirmed); - if (rejected) return rejected; - try { - changelogPath = resolveInside(context.directory, changelogPath ?? "CHANGELOG.md"); - } catch (error) { - return output(fail(error instanceof Error ? error.message : "invalid changelog path")); - } - return output( - normalizeLegacyResult( - changelogApply({ - entries, - path: changelogPath, - normalize_only, - workspace_root: realpathSync(context.directory), - }) as Record, - ), - ); - }, - }), - workit_branch_setup: tool({ - description: "Apply a confirmed in-place feature or bugfix branch setup", - args: { - confirmed: tool.schema.boolean(), - action: tool.schema.enum(["setup", "reapply_stash"]).optional(), - sdd_dir: tool.schema.string().optional(), - target_branch: tool.schema.string().optional(), - stash: tool.schema.enum(["yes", "no"]).optional(), - }, - execute: async ({ confirmed, action, sdd_dir, target_branch, stash }, context) => { - const rejected = requireConfirmed(confirmed); - if (rejected) return rejected; - let resolvedSdd = sdd_dir ?? "docs"; - try { - resolvedSdd = resolveInside(context.directory, resolvedSdd); - } catch (error) { - return output(fail(error instanceof Error ? error.message : "invalid SDD path")); - } - // Flow-guard journal rides the plugin's diagnostic logger when the - // host installed one; absent logger keeps branchSetup silent. - const diagnostic = getDiagnosticLogger(); - const result = branchSetup({ - action, - sdd_dir: resolvedSdd, - target_branch, - stash, - workspace_root: context.directory, - log: diagnostic ? (message) => diagnostic.info(message) : undefined, - }); - return output( - legacyScriptResult({ - stdout: JSON.stringify(result), - stderr: "", - exitCode: "error" in result ? 1 : 0, - cwd: context.directory, - }), - ); - }, - }), - workit_commit: tool({ - description: "Commit the current index on a feature or bugfix branch without staging files", - args: { confirmed: tool.schema.boolean(), message: tool.schema.string() }, - execute: async ({ confirmed, message }, context) => { - const rejected = requireConfirmed(confirmed); - if (rejected) return rejected; - const branch = runtime.git(context.directory, ["branch", "--show-current"]); - if (branch.exitCode !== 0) - return output( - fail( - branch.stderr.trim() || branch.stdout.trim() || "unable to read current branch", - diagnostics(branch), - ), - ); - const name = branch.stdout.trim(); - const pol = resolveBranchPolicyFor(context.directory); - if (pol.protected.has(name.toLowerCase())) - return output(fail(`cannot commit on protected branch ${name}`)); - if (!pol.allowed.some((r) => r.test(name)) || name.endsWith("/")) - return output(fail(`commit requires an allowed branch (current: ${name})`)); - return output( - scriptResult(runtime.git(context.directory, ["commit", "-m", message]), (stdout) => ({ - stdout: stdout.trim(), - })), - ); - }, - }), - workit_pr_create: tool({ - description: - "Resolve a hosted pull/merge request through the shared action contract and hand it to workit_external_action", - args: { - confirmed: tool.schema.boolean(), - title: tool.schema.string(), - body: tool.schema.string().optional(), - draft: tool.schema.boolean().optional(), - target_branch: tool.schema.string().optional(), - }, - execute: async ({ confirmed, title, body, draft, target_branch }, context) => { - const rejected = requireConfirmed(confirmed); - if (rejected) return rejected; - // Decision ae03c569 re-enabled hosted PR/MR creation with pre/post - // provider SHA verification; the residual non-atomic source-SHA race is - // accepted. This legacy surface owns no native receipt, reservation, or - // writer lease, so it never contacts the provider itself: it resolves - // the canonical request through the shared resolver and delegates the - // effect to workit_external_action. - const resolved = resolveExternalActionRequest(context.directory, { - operation: "hosting.pull_request", - payload: { - title, - ...(body === undefined ? {} : { body }), - ...(draft === undefined ? {} : { draft }), - ...(target_branch === undefined ? {} : { target_branch }), - }, - }); - if (!resolved.ok) return output(resolved); - const proposal = actionProposalQuestion( - resolved.data.request, - resolved.data.descriptorPayload, - ); - return output( - failure("needs_input", proposal.presented, { - outcome: "not_started", - operation: "hosting.pull_request", - proposal: { - presented: proposal.presented, - approvedContent: proposal.approvedText, - descriptorDigest: sha256( - externalActionDescriptor("hosting.pull_request", resolved.data.descriptorPayload), - ), - }, - guidance: - "Run workit_external_action with the same hosting.pull_request payload: it records the one-time action reservation behind the native approval question, then verifies the provider PR head before reporting success.", - }), - ); - }, - }), workit_init_apply: tool({ description: "Apply a confirmed toolkit initialization action", args: { diff --git a/packages/workit-opencode/src/tools/workit.ts b/packages/workit-opencode/src/tools/workit.ts index b1a740f7..d6cf818c 100644 --- a/packages/workit-opencode/src/tools/workit.ts +++ b/packages/workit-opencode/src/tools/workit.ts @@ -2,24 +2,7 @@ import { tool } from "@opencode-ai/plugin"; import { WorkitCore, TaskStore, - approvedExternalAction, - approvedPlanCommit, - planCommitBinding, - chainStepBinding, - standingAutoApplies, - standingAutoBinding, - externalActionDescriptor, - externalActionHelp, - planReservationLength, - priorExternalAction, - priorResolvedDrift, - externalActionRequest, - externalActionSchema, - externalActionRef, canonicalJson, - createAuthorizedExternalActionRunner, - matchesNativeExternalAction, - nativeExternalActionObservation, failure, operationSchemas, OPERATION_SCHEMA_DEPTH, @@ -30,26 +13,14 @@ import { workitBindingQuestionIssue, type OperationFamily, type OperationContext, - type ExternalActionRequest, type ContractResult as Result, type Entry, type TaskRecord, type Worker, } from "@brainervirus/workit-core/src/core"; -import { - actionProposalQuestion, - assertLocalExternalActionWriter, - approvedResolvedExternalAction, - executeResolvedExternalAction, - readExternalAction, - resolveExternalActionRequest, - upgradeBranchSetupForStash, -} from "@brainervirus/workit-core/src/core/external-action-effects"; -import type { - NativeAuthorityVerifier, - NativeReconciliationVerification, -} from "@brainervirus/workit-core/src/core/authority"; +import type { NativeAuthorityVerifier } from "@brainervirus/workit-core/src/core/authority"; import type { NativeWorkerVerifier } from "@brainervirus/workit-core/src/core/workers"; +import { createContextTool } from "./context"; type SessionLookup = { session: { @@ -477,7 +448,7 @@ const sessionData = async (client: SessionLookup | undefined, sessionID: string) }; import { sameWorkspace } from "../shared/session"; -import { decisionContent, isSelfAuthorizingActionContent } from "../shared/decision-content"; +import { decisionContent } from "../shared/decision-content"; export { sameWorkspace }; const hostRef = (handle: string) => ({ kind: "host" as const, host: "opencode" as const, handle }); @@ -525,7 +496,6 @@ export const opencodeCapabilities = () => [ export const nativeAuthority = ( receipts: NativeReceiptStore, actor: string, - reconciliationTokens = new WeakSet(), ): NativeAuthorityVerifier => ({ verifyDecision: ({ observation, expected, caller }) => { const receipt = receipts.verify(observation, actor, "decision"); @@ -557,59 +527,8 @@ export const nativeAuthority = ( receipts: [hostRef(receipt.callID)], }); }, - verifyAction: ({ observation, expected, caller }) => { - if ( - caller.host !== "opencode" || - caller.actor !== actor || - !matchesNativeExternalAction(observation, { - actor, - actionRef: expected.actionRef, - outcome: expected.outcome, - ...(expected.outcome === "reserve" - ? {} - : { taskRevision: expected.taskRevision, workspaceRevision: expected.workspaceRevision }), - }) - ) - return failure("permission_denied", "native action observation is not bound to this session"); - const callId = - typeof observation === "object" && - observation !== null && - typeof (observation as { callId?: unknown }).callId === "string" - ? (observation as { callId: string }).callId - : expected.actionRef.kind === "host" - ? expected.actionRef.handle - : "external-action"; - return success(null, null, { - kind: "host_observed", - host: "opencode", - session: hostRef(actor), - workerId: null, - receipts: [hostRef(`action:${callId}`)], - }); - }, - verifyReconciliation: ({ observation, expected, caller }: NativeReconciliationVerification) => { - const value = observation as Record; - if ( - typeof observation !== "object" || - observation === null || - caller.host !== "opencode" || - caller.actor !== actor || - !reconciliationTokens.has(observation) || - value.kind !== "provider_read" || - value.outcome !== "succeeded" || - value.evidenceDigest !== expected.evidenceDigest || - (expected.step !== undefined && value.step !== expected.step) || - canonicalJson(value.actionRef) !== canonicalJson(expected.actionRef) - ) - return failure("permission_denied", "native hosting reconciliation is not attested"); - return success(null, null, { - kind: "host_observed", - host: "opencode", - session: hostRef(actor), - workerId: null, - receipts: [hostRef(`reconcile:${expected.evidenceDigest}`)], - }); - }, + verifyAction: () => + failure("capability_unavailable", "OpenCode managed external actions are unavailable"), }); export const nativeWorkerFor = ( @@ -673,89 +592,6 @@ export const nativeDispatchFor = ( }, }); -/** Bind concrete optional effects to one approved action decision in this session. */ -export const nativeExternalActionRunner = ( - root: string, - actor: string, - core: WorkitCore, - step?: string, -) => - createAuthorizedExternalActionRunner( - core, - (operation) => { - const store = new TaskStore(root); - const selected = approvedExternalAction(store, "opencode", actor, operation); - if (!selected.ok) { - const plan = - planCommitBinding(store, "opencode", actor, operation) ?? - chainStepBinding(store, "opencode", actor, operation) ?? - standingAutoBinding(core, store, "opencode", actor, operation); - if (plan) { - const actionRef = externalActionRef("opencode", actor, operation); - return { - taskId: plan.taskId, - decisionId: plan.decisionId, - actionRef, - expectedRevision: plan.expectedRevision, - expectedWorkspaceRevision: plan.expectedWorkspaceRevision, - binding: plan.binding, - step: plan.step, - refresh: () => { - const task = store.readTask(plan.taskId); - const freshWorkspace = store.readWorkspace(); - if (!task.ok || !freshWorkspace.ok || !freshWorkspace.data) - throw new Error("external action state changed"); - return { - expectedRevision: task.data.revision, - expectedWorkspaceRevision: freshWorkspace.data.revision, - }; - }, - reserveObservation: nativeExternalActionObservation(actor, actionRef, "reserve"), - settleObservation: (outcome: "succeeded" | "not_started" | "unknown", revisions) => - nativeExternalActionObservation( - actor, - actionRef, - outcome, - actionRef.kind === "host" ? actionRef.handle : "external-action", - revisions, - ), - }; - } - return selected; - } - const actionRef = externalActionRef("opencode", actor, operation); - return { - taskId: selected.data.task.id, - decisionId: selected.data.entry.id, - actionRef, - expectedRevision: selected.data.task.revision, - expectedWorkspaceRevision: selected.data.workspace.revision, - binding: selected.data.entry.data.binding, - ...(step ? { step } : {}), - refresh: () => { - const task = store.readTask(selected.data.task.id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) - throw new Error("external action state changed"); - return { - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - }; - }, - reserveObservation: nativeExternalActionObservation(actor, actionRef, "reserve"), - settleObservation: (outcome: "succeeded" | "not_started" | "unknown", revisions) => - nativeExternalActionObservation( - actor, - actionRef, - outcome, - actionRef.kind === "host" ? actionRef.handle : "external-action", - revisions, - ), - }; - }, - root, - ); - /** The dispatch coordinator is persisted for restart safety. Records created * before that field existed fall back to their original task creator. */ export const workerCoordinatorFor = (task: TaskRecord, worker: Entry): string | null => { @@ -801,16 +637,6 @@ export const createWorkitTools = ({ receipts = new NativeReceiptStore(), directChildren = new Map(), }: WorkitToolOptions = {}) => { - const actionProposals = new Map< - string, - Array<{ - descriptor: string; - presented: string; - approvedText: string; - request: ExternalActionRequest; - receiptSequence: number; - }> - >(); const make = (family: OperationFamily) => tool({ description: `Workit ${family} operations backed by the shared task contract.`, @@ -887,87 +713,9 @@ export const createWorkitTools = ({ expectation.selectedDescription = decision.binding.approvedContent; const observed = receipts.reserve(context.sessionID, "decision", expectation); if (!observed.ok) return output(failure("permission_denied", observed.error)); - let recordInput = parsed.data as Record; - let proposalToCommit: - | { - descriptor: string; - presented: string; - approvedText: string; - request: ExternalActionRequest; - } - | undefined; - if (decision.purpose === "action" && decision.response === "approved") { - const queue = actionProposals.get(context.sessionID) ?? []; - const textMatches = queue.filter( - (pending) => - pending.presented === decision.binding.presented && - pending.approvedText === decision.binding.approvedContent, - ); - let bound = false; - const valid: typeof textMatches = []; - const stale = new Set<(typeof textMatches)[number]>(); - let unresolved = false; - for (const pending of textMatches) { - // Validity is content-bound, never clock-bound. Re-resolve all - // matches before deciding whether identical text is ambiguous. - const fresh = resolveExternalActionRequest(context.directory, pending.request); - const freshDescriptor = fresh.ok - ? externalActionDescriptor(pending.request.operation, fresh.data.descriptorPayload) - : null; - if (freshDescriptor === pending.descriptor) { - valid.push(pending); - if (observed.receipt.sequence <= pending.receiptSequence) unresolved = true; - } else if (fresh.ok) { - stale.add(pending); - } else unresolved = true; - } - if (stale.size) - actionProposals.set( - context.sessionID, - queue.filter((candidate) => !stale.has(candidate)), - ); - const validDescriptors = new Set(valid.map((pending) => pending.descriptor)); - if (unresolved || validDescriptors.size > 1) - return output( - failure( - "invalid_input", - "matching action proposals are ambiguous or this receipt predates a matching proposal; resolve and ask again", - ), - ); - if (validDescriptors.size === 1) { - const pending = valid[0]; - proposalToCommit = pending; - recordInput = { - ...recordInput, - binding: { - ...(recordInput as { binding: Record }).binding, - approvedContent: pending.descriptor, - displayed: pending.approvedText, - }, - }; - bound = true; - } - if (!bound && !isSelfAuthorizingActionContent(decision.binding.approvedContent)) { - return output( - failure( - "invalid_input", - textMatches.length > 0 - ? "repository state changed since the proposal; resolve the action again for a fresh proposal" - : "no matching action proposal; resolve the action through the action tool first", - ), - ); - } - } - result = core.observeDecision(recordInput, observed.observation); + result = core.observeDecision(parsed.data, observed.observation); if (result.ok) { receipts.commit(observed.observation); - if (proposalToCommit) { - const queue = actionProposals.get(context.sessionID) ?? []; - actionProposals.set( - context.sessionID, - queue.filter((candidate) => candidate !== proposalToCommit), - ); - } } } else { const run = core[family] as unknown as (request: unknown) => Result; @@ -983,281 +731,7 @@ export const createWorkitTools = ({ ); return { ...tools, - workit_external_action: tool({ - description: `Run one fixed optional action. ${externalActionHelp}`, - args: { - operation: tool.schema.enum( - externalActionSchema.options.map((variant) => variant.shape.operation.value) as [ - string, - ...string[], - ], - ), - payload: tool.schema - .union( - externalActionSchema.options.map((variant) => variant.shape.payload) as [ - any, - any, - ...any[], - ], - ) - .describe( - "Use the payload variant for the selected operation; Workit validates their exact pairing.", - ), - }, - execute: async (args, context) => { - const parsed = externalActionRequest(args); - if (!parsed.ok) return output(parsed); - if (client && parsed.data.operation !== "context.read") { - const earlySession = await sessionData(client, context.sessionID); - if (earlySession !== null && sessionParent(earlySession) !== undefined) - return output( - failure("permission_denied", "child sessions cannot run external actions"), - ); - } - let resolved = resolveExternalActionRequest(context.directory, parsed.data); - if (!resolved.ok) return output(resolved); - resolved = upgradeBranchSetupForStash(context.directory, parsed.data, resolved); - if (!resolved.ok) return output(resolved); - if (resolved.data.request.operation === "context.read") - return output(await executeResolvedExternalAction(resolved.data, context.directory)); - if (!client) - return output( - failure("capability_unavailable", "OpenCode native session observation unavailable", { - capability: "external_action", - }), - ); - const data = await sessionData(client, context.sessionID); - if (data === null || !sameWorkspace(context.directory, data.directory ?? "")) - return output( - failure("permission_denied", "OpenCode native session observation unavailable"), - ); - if (sessionParent(data) !== undefined) - return output(failure("permission_denied", "child sessions cannot run external actions")); - const store = new TaskStore(context.directory); - const prior = priorExternalAction( - store, - "opencode", - context.sessionID, - resolved.data.request.operation, - resolved.data.request.payload, - ); - if ( - prior.ok && - prior.data.entry.data.consumption !== null && - prior.data.entry.data.consumption.state !== "uncertain" && - resolved.data.request.operation !== "git.commit" && - resolved.data.request.operation !== "git.push" - ) - return output(failure("permission_denied", "external action was already settled")); - const reconciliationTokens = new WeakSet(); - const core = new WorkitCore(store, { - root: context.directory, - caller: { host: "opencode", actor: context.sessionID }, - capabilities: opencodeCapabilities(), - constraints: [], - now: () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), - workerId: null, - nativeAuthority: nativeAuthority(receipts, context.sessionID, reconciliationTokens), - }); - if (prior.ok && prior.data.entry.data.consumption?.state === "uncertain") { - const original = approvedResolvedExternalAction( - prior.data.entry.data.binding.approvedContent, - ); - if (original.ok) { - const actionRef = prior.data.entry.data.consumption.actionRef; - const evidence = await readExternalAction(context.directory, original.data, actionRef); - if (evidence.ok && evidence.data.outcome === "succeeded") { - const freshTask = store.readTask(prior.data.task.id); - const freshWorkspace = store.readWorkspace(); - if (freshTask.ok && freshWorkspace.ok && freshWorkspace.data) { - reconciliationTokens.add(evidence.data.observation); - const reconciled = core.reconcileAction({ - taskId: prior.data.task.id, - decisionId: prior.data.entry.id, - actionRef, - expectedRevision: freshTask.data.revision, - expectedWorkspaceRevision: freshWorkspace.data.revision, - outcome: "succeeded", - evidenceDigest: evidence.data.evidenceDigest, - ...(evidence.data.step ? { step: evidence.data.step } : {}), - observation: evidence.data.observation, - }); - if (reconciled.ok) { - if (evidence.data.step === "comment") { - const remaining = await nativeExternalActionRunner( - context.directory, - context.sessionID, - core, - "time", - )(prior.data.entry.data.binding.approvedContent, (_step, reservation) => - executeResolvedExternalAction( - original.data, - context.directory, - "time", - { - host: "opencode", - actor: context.sessionID, - }, - reservation?.workspaceRevision, - ), - ); - return output(remaining); - } - return output(reconciled); - } - } - } - } - return output( - failure("external_outcome_unknown", "previous external action outcome is unknown"), - ); - } - const descriptor = externalActionDescriptor( - resolved.data.request.operation, - resolved.data.descriptorPayload, - ); - const drift = - resolved.data.request.operation === "git.branch_setup" - ? priorResolvedDrift( - store, - "opencode", - context.sessionID, - resolved.data.request.operation, - resolved.data.request.payload, - (resolved.data.descriptorPayload as { resolved?: unknown }).resolved, - ) - : success(null, null, null); - if (!drift.ok) return output(drift); - const localOperation = [ - "git.branch_setup", - "git.commit", - "git.push", - "hosting.pull_request", - "hosting.merge", - "hosting.delete_branch", - "changelog.apply", - ].includes(resolved.data.request.operation); - if (localOperation) { - const writer = assertLocalExternalActionWriter(context.directory, { - host: "opencode", - actor: context.sessionID, - }); - if (!writer.ok) - return output( - failure("needs_input", "writer ownership is required before this action", { - outcome: "not_started", - operation: resolved.data.request.operation, - guidance: - "Acquire checkout writer ownership first (writer.acquire) and retry the action.", - }), - ); - } - const selected = approvedExternalAction(store, "opencode", context.sessionID, descriptor); - let planAuthorized = false; - if (!selected.ok) { - const commitMessage = - resolved.data.request.operation === "git.commit" - ? (resolved.data.request.payload as { message?: unknown }).message - : undefined; - const currentBranch = ( - resolved.data.descriptorPayload as { resolved?: { branch?: unknown } } - ).resolved?.branch; - if (typeof commitMessage === "string" && commitMessage) { - const target = (resolved.data.descriptorPayload as { cwd?: unknown }).cwd; - const plan = approvedPlanCommit( - store, - "opencode", - context.sessionID, - commitMessage, - typeof target === "string" ? target : context.directory, - ); - planAuthorized = plan.ok && plan.data.branch === currentBranch; - } - } - // Standing auto-approval skips the question and falls through to the - // runner, which binds the recorded standing decision. - const autoApplies = - !selected.ok && - !planAuthorized && - standingAutoApplies(store, "opencode", context.sessionID, descriptor); - const planCount = planReservationLength( - resolved.data.request.operation, - resolved.data.descriptorPayload, - ); - if (planCount !== null) { - if (selected.ok) return output(success(null, null, { plan_commits: planCount })); - if (autoApplies) { - const bound = standingAutoBinding( - core, - store, - "opencode", - context.sessionID, - descriptor, - ); - if (bound) return output(success(null, null, { plan_commits: planCount })); - } - } - if (!selected.ok && !planAuthorized && !autoApplies) { - if (selected.error.startsWith("no approved action")) { - const proposal = actionProposalQuestion( - resolved.data.request, - resolved.data.descriptorPayload, - ); - const pending = actionProposals.get(context.sessionID) ?? []; - // Idempotent proposals: one descriptor opens at most one - // question. A re-resolution returns the existing proposal - // instead of minting a duplicate. - const existing = pending.find((candidate) => candidate.descriptor === descriptor); - const open = existing ?? { - descriptor, - presented: proposal.presented, - approvedText: proposal.approvedText, - // The normalized request behind this descriptor (stash - // upgrades included), so record-time re-resolution replays - // the same bytes instead of drifting on its own upgrade. - request: resolved.data.request, - receiptSequence: receipts.sequence, - }; - if (!existing) { - pending.push(open); - if (pending.length > 8) pending.shift(); - actionProposals.set(context.sessionID, pending); - } - return output( - failure("needs_input", proposal.presented, { - outcome: "not_started", - operation: resolved.data.request.operation, - proposal: { - presented: proposal.presented, - approvedContent: proposal.approvedText, - descriptorDigest: sha256(descriptor), - }, - guidance: - "Ask one native question with header `Workit decision: action`, the presented text as the question text, and exactly two options: approved (description = the proposal approvedContent) and rejected (description = `Reject this decision`). Then call decision.record with the same presented/approvedContent and the native receipt; the adapter binds the exact descriptor. Never show the raw descriptor.", - }), - ); - } - return output(selected); - } - const result = await nativeExternalActionRunner( - context.directory, - context.sessionID, - core, - )(descriptor, (step, reservation) => - executeResolvedExternalAction( - resolved.data, - context.directory, - step, - { - host: "opencode", - actor: context.sessionID, - }, - reservation?.workspaceRevision, - ), - ); - return output(result); - }, - }), + workit_context: createContextTool(), }; }; diff --git a/packages/workit-opencode/src/tools/youtrack.ts b/packages/workit-opencode/src/tools/youtrack.ts index 61ea4b72..4683fc76 100644 --- a/packages/workit-opencode/src/tools/youtrack.ts +++ b/packages/workit-opencode/src/tools/youtrack.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { tool, type ToolContext } from "@opencode-ai/plugin"; +import { tool } from "@opencode-ai/plugin"; import { fail, ok, resolveInside } from "@brainervirus/workit-core/src/core"; import { configGuardError, @@ -11,14 +11,11 @@ import { } from "@brainervirus/workit-core/src/core/youtrack"; import { defaultOperations, - logTimeUpdate, message, normalizeContext, - postUpdate, readCredentials, redact, unwrap, - ISSUE_RE, type LegacyValue, type YouTrackOperations, } from "@brainervirus/workit-core/src/core/youtrack-tools"; @@ -26,17 +23,6 @@ import { const output = (value: unknown) => JSON.stringify(value, null, 2); type MaybePromise = T | Promise; -const withWriteFlag = async (fn: () => Promise): Promise => { - const previous = process.env.WORKFLOW_YT_WRITE; - process.env.WORKFLOW_YT_WRITE = "1"; - try { - return await fn(); - } finally { - if (previous === undefined) delete process.env.WORKFLOW_YT_WRITE; - else process.env.WORKFLOW_YT_WRITE = previous; - } -}; - const standardResult = (value: LegacyValue, token = "") => { try { const data = unwrap(value); @@ -60,28 +46,6 @@ const configGap = () => { const { missing } = describeConfigGaps(["youtrack_json", "youtrack_token"]); return missing.length > 0 ? output(fail(configGuardError(missing))) : null; }; -const requireConfirmed = (confirmed: boolean) => - confirmed === true ? null : output(fail("confirmed: true required")); - -const rejectedTimeInput = (issueId: string, minutes: number) => { - const error = !ISSUE_RE.test(issueId) - ? "invalid issueId" - : !Number.isFinite(minutes) || minutes <= 0 - ? "minutes must be positive" - : null; - return error - ? output( - fail(error, { - issueId, - loggedMinutes: 0, - outcome: "not_applied", - retry: "workit_youtrack_log_time", - instructions: "Correct the invalid input, then retry workit_youtrack_log_time once.", - }), - ) - : null; -}; - export function createYouTrackTools(operations: YouTrackOperations = defaultOperations) { return { workit_youtrack_verify_token: tool({ @@ -173,58 +137,5 @@ export function createYouTrackTools(operations: YouTrackOperations = defaultOper }, execute: async (input) => invoke(() => legacyBuildDraft(input as never)), }), - workit_youtrack_log_time: tool({ - description: "Log confirmed time on an existing YouTrack issue without posting a comment", - args: { - confirmed: tool.schema.boolean(), - issueId: tool.schema.string(), - minutes: tool.schema.number(), - text: tool.schema.string().optional(), - dateMs: tool.schema.number().optional(), - }, - execute: async ({ confirmed, ...input }, context: ToolContext) => { - const rejected = requireConfirmed(confirmed); - if (rejected) return rejected; - const invalid = rejectedTimeInput(input.issueId, input.minutes); - if (invalid) return invalid; - let token = ""; - try { - token = credentials().token; - } catch (error) { - const gap = configGap(); - if (gap) return gap; - return output(fail(message(error))); - } - const result = await withWriteFlag(() => - logTimeUpdate({ ...input, workspace_root: context.directory }, operations), - ); - return output(result.ok ? result : { ...result, error: redact(result.error, token) }); - }, - }), - workit_youtrack_post: tool({ - description: "Post a confirmed es-CL comment, then optionally log time", - args: { - confirmed: tool.schema.boolean(), - issueId: tool.schema.string(), - markdown: tool.schema.string(), - minutes: tool.schema.number().optional(), - }, - execute: async (input, context: ToolContext) => { - const rejected = requireConfirmed(input.confirmed); - if (rejected) return rejected; - let token = ""; - try { - token = credentials().token; - } catch (error) { - const gap = configGap(); - if (gap) return gap; - return output(fail(message(error))); - } - const result = await withWriteFlag(() => - postUpdate({ ...input, workspace_root: context.directory }, operations), - ); - return output(result.ok ? result : { ...result, error: redact(result.error, token) }); - }, - }), }; } diff --git a/packages/workit-opencode/src/v2/plugin.ts b/packages/workit-opencode/src/v2/plugin.ts index 55dc1e00..7d8a3c6a 100644 --- a/packages/workit-opencode/src/v2/plugin.ts +++ b/packages/workit-opencode/src/v2/plugin.ts @@ -73,16 +73,6 @@ const v2Capabilities = () => [ reason: "native question answers are observed by tool.execute.after and consumed once", refs: [{ kind: "host" as const, host: "opencode" as const, handle: "question" }], }, - { - name: "external_action", - surface: "workit_external_action", - assurance: "enforced" as const, - reason: "native question receipts bind each resolved action descriptor before execution", - refs: [ - { kind: "host" as const, host: "opencode" as const, handle: "question" }, - { kind: "host" as const, host: "opencode" as const, handle: "workit_external_action" }, - ], - }, { name: "known_product_writes", surface: "edit/shell", @@ -155,8 +145,7 @@ const setup = async (ctx: Context): Promise<() => void> => { : null; }, }); - // Reuse V1's proposal/receipt runner with V2's observed session lookup; both - // paths use the same native receipts, lineage map, and shared Workit core. + // Reuse V1's native decision receipts and context reader. const nativeTools = createWorkitTools({ receipts, directChildren: lifecycle.directChildren, @@ -177,7 +166,7 @@ const setup = async (ctx: Context): Promise<() => void> => { }, }); const nativeToolMap = nativeTools as unknown as Record< - "workit_decision" | "workit_external_action", + "workit_decision" | "workit_context", { execute: ( args: unknown, @@ -186,7 +175,7 @@ const setup = async (ctx: Context): Promise<() => void> => { } >; const executeNativeTool = async ( - name: "workit_decision" | "workit_external_action", + name: "workit_decision" | "workit_context", input: unknown, sessionID: string, ): Promise<{ content: string }> => { @@ -234,6 +223,8 @@ const setup = async (ctx: Context): Promise<() => void> => { "OpenCode session location does not match the plugin checkout", ), ); + if (spec.name === "workit_context") + return executeNativeTool("workit_context", input, session.id); const store = new TaskStore(root); const workerId = workerIdFor( store, @@ -249,9 +240,6 @@ const setup = async (ctx: Context): Promise<() => void> => { if (family === "decision") return executeNativeTool("workit_decision", input, session.id); if (family !== null) return resultContent(runFamily(family, input, session, store, workerId)); - if (spec.name === "workit_external_action") { - return executeNativeTool("workit_external_action", input, session.id); - } if (spec.name === "workit_init_apply") { // The shared executor already returns the contract JSON; prose // checking stays core-side. diff --git a/test/acceptance/scenarios.ts b/test/acceptance/scenarios.ts index 01a7d2e8..5236294a 100644 --- a/test/acceptance/scenarios.ts +++ b/test/acceptance/scenarios.ts @@ -1,6 +1,6 @@ /** Fixed versioned acceptance and release-evaluation scenario fixtures (CA-01..CA-32, E-01..E-06). */ -export const FIXTURE_REVISION = "workit-v1-2026-09-09"; +export const FIXTURE_REVISION = "workit-v1-2026-09-30"; export type CaScenarioId = `CA-${string}`; export type EvaluationScenarioId = `E-${string}`; @@ -138,7 +138,7 @@ export const CA_SCENARIOS: ScenarioFixture[] = [ fixture( "CA-24", "Optional service unavailable", - "Core work remains usable; affected external action is not silently executed or reported successful.", + "Core work remains usable; unavailable services and unauthorized external writes produce a clear failure or needs-input result, never a success claim.", ), fixture( "CA-25", diff --git a/test/artifacts/phase-9-traceability.test.ts b/test/artifacts/phase-9-traceability.test.ts index 540cd692..401f43c4 100644 --- a/test/artifacts/phase-9-traceability.test.ts +++ b/test/artifacts/phase-9-traceability.test.ts @@ -105,7 +105,7 @@ const ROWS: Row[] = [ row: "POST-12", requirement: "native-choice evidence is model-forgeable", evidence: [ - "test/workit-opencode/task-tools.test.ts::OpenCode action route consumes the exact native receipt before committing", + "test/workit-opencode/task-tools.test.ts::stated design choices need no receipt and never authorize an action", ], }, { diff --git a/test/opencode-v2/matrix.test.ts b/test/opencode-v2/matrix.test.ts index 2e3ca3a4..377af427 100644 --- a/test/opencode-v2/matrix.test.ts +++ b/test/opencode-v2/matrix.test.ts @@ -38,7 +38,7 @@ const WORKIT_TOOL_NAMES = [ "workit_worker", "workit_writer", "workit_state", - "workit_external_action", + "workit_context", "workit_init_apply", ]; diff --git a/test/opencode-v2/shell.test.ts b/test/opencode-v2/shell.test.ts index 355ae889..1cd9e5cc 100644 --- a/test/opencode-v2/shell.test.ts +++ b/test/opencode-v2/shell.test.ts @@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process"; import { TaskStore, WorkitCore } from "@/packages/workit-core/src/core"; import definition from "@/packages/workit-opencode/src/v2/plugin"; import { normalizeQuestionAnswers } from "@/packages/workit-opencode/src/v2/receipts"; -import { assessment, taskStartRequest } from "../workit-core/task-fixtures"; +import { taskStartRequest } from "../workit-core/task-fixtures"; /** The exact 10 registered Workit tool names in registration order. */ const TOOL_NAMES = [ @@ -18,7 +18,7 @@ const TOOL_NAMES = [ "workit_worker", "workit_writer", "workit_state", - "workit_external_action", + "workit_context", "workit_init_apply", ]; @@ -170,31 +170,26 @@ test("setup registers the exact 10 tools with codemode off and object schemas", try { const { registered, cleanup } = await harness(root); expect(registered.map((tool) => tool.name)).toEqual(TOOL_NAMES); + expect(registered.some((tool) => tool.name === "workit_external_action")).toBe(false); for (const tool of registered) { expect(tool.options?.codemode, tool.name).toBe(false); expect(tool.input.type, tool.name).toBe("object"); expect(tool.description.length, tool.name).toBeGreaterThan(0); } - const external = registered.find((tool) => tool.name === "workit_external_action"); - const variants = external?.input.oneOf; - expect(variants.map((variant: any) => variant.properties.operation.const)).toEqual([ - "git.branch_setup", - "git.commit", - "git.push", - "hosting.pull_request", - "hosting.merge", - "hosting.delete_branch", - "youtrack.update", - "youtrack.time", - "youtrack.meeting", - "changelog.apply", - "context.read", + const contextTool = registered.find((tool) => tool.name === "workit_context"); + expect(contextTool?.input.required).toEqual(["kind"]); + expect(contextTool?.input.additionalProperties).toBe(false); + expect(contextTool?.input.properties.kind.enum).toEqual([ + "git", + "pr", + "youtrack", + "github_issue", + "gitlab_issue", + "changelog", + "release", + "affected", ]); - for (const variant of variants) { - expect(variant.required).toEqual(["operation", "payload"]); - expect(variant.additionalProperties).toBe(false); - expect(variant.properties.payload.type).toBe("object"); - } + expect(contextTool?.input.properties).not.toHaveProperty("operation"); const init = registered.find((tool) => tool.name === "workit_init_apply"); expect(init?.input.required).toEqual(["confirmed", "action"]); expect(init?.input.properties.action.enum).toContain("branch_policy"); @@ -233,6 +228,8 @@ test("foreign session locations and child sessions are denied", async () => { expect(denied.error).toContain("session location"); const child = await harness(root, { parentID: "ses_parent" }); + expect(await child.call("workit_context", { kind: "git" })).toMatchObject({ ok: true }); + expect(existsSync(path.join(root, ".workit"))).toBe(false); const blocked = await child.call("workit_task", { schemaVersion: 1, action: "list" }); expect(blocked.ok).toBe(false); expect(blocked.error).toContain("no validated Workit worker"); @@ -242,161 +239,27 @@ test("foreign session locations and child sessions are denied", async () => { } }); -test("V2 action-time targets use the shared native-receipt runner", async () => { +test("context rejects mutation-shaped arguments without Git or Workit effects", async () => { const root = repository(); - const target = repository("feature/v2-target"); try { - const { call, hooks } = await harness(root); - const read = await call("workit_external_action", { - operation: "context.read", - payload: { kind: "git" }, - }); - expect(read.ok).toBe(true); - writeFileSync(path.join(target, "change.txt"), "change\n"); - spawnSync("git", ["add", "change.txt"], { cwd: target }); - const intent = { - objective: "V2 action target", - scope: { description: "V2 action target", paths: ["."], exclusions: [] }, - authorityRefs: [], - }; - const started = await call("workit_task", taskStartRequest({ intent })); - expect(started.ok).toBe(true); - const taskId = started.data.id as string; - expect( - await call("workit_policy", { - schemaVersion: 1, - action: "assess", - taskId, - assessment: assessment(), - }), - ).toMatchObject({ ok: true }); - const initial = await call("workit_task", { - schemaVersion: 1, - action: "inspect", - taskId, - view: "full", - }); + const { call } = await harness(root); + const before = spawnSync("git", ["status", "--porcelain=v1"], { + cwd: root, + encoding: "utf8", + }).stdout; expect( - await call("workit_writer", { - schemaVersion: 1, - action: "acquire", - taskId, - expectedRevision: initial.data.task.revision, - expectedWorkspaceRevision: initial.data.workspace.revision, - workerId: null, + await call("workit_context", { + kind: "git", + operation: "git.commit", + payload: { message: "must-not-run" }, }), - ).toMatchObject({ ok: true }); - const request = { - operation: "git.commit", - payload: { cwd: target, message: "fix: commit in V2 target" }, - }; - const proposal = await call("workit_external_action", request); - expect(proposal).toMatchObject({ ok: false, code: "needs_input" }); - const item = proposal.details.proposal; - const question = { - questions: [ - { - header: "Workit decision: action", - question: item.presented, - options: [ - { label: "approved", description: item.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }; - await hooks.get("execute.before")!({ - tool: "question", - sessionID: "ses_v2", - id: "call_v2_old_question", - input: question, - }); - spawnSync("git", ["branch", "alternate"], { cwd: target }); - spawnSync("git", ["checkout", "-q", "alternate"], { cwd: target }); - const freshProposal = await call("workit_external_action", request); - expect(freshProposal).toMatchObject({ ok: false, code: "needs_input" }); - expect(freshProposal.details.proposal.presented).toBe(item.presented); - expect(freshProposal.details.proposal.approvedContent).toBe(item.approvedContent); - await hooks.get("execute.after")!({ - tool: "question", - sessionID: "ses_v2", - id: "call_v2_old_question", - input: question, - status: "completed", - result: { metadata: { answers: { q0: "approved" } } }, - }); - const current = await call("workit_task", { - schemaVersion: 1, - action: "inspect", - taskId, - view: "full", - }); - const recorded = await call("workit_decision", { - schemaVersion: 1, - action: "record", - taskId, - expectedRevision: current.data.task.revision, - purpose: "action", - binding: { - taskId, - workspaceId: current.data.workspace.id, - scope: current.data.task.intent.data.scope, - presented: item.presented, - approvedContent: item.approvedContent, - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }); - expect(recorded).toMatchObject({ ok: false, code: "invalid_input" }); - await hooks.get("execute.before")!({ - tool: "question", - sessionID: "ses_v2", - id: "call_v2_fresh_question", - input: question, - }); - await hooks.get("execute.after")!({ - tool: "question", - sessionID: "ses_v2", - id: "call_v2_fresh_question", - input: question, - status: "completed", - result: { metadata: { answers: { q0: "approved" } } }, - }); - const latest = await call("workit_task", { - schemaVersion: 1, - action: "inspect", - taskId, - view: "full", - }); - const freshDecision = await call("workit_decision", { - schemaVersion: 1, - action: "record", - taskId, - expectedRevision: latest.data.task.revision, - purpose: "action", - binding: { - taskId, - workspaceId: latest.data.workspace.id, - scope: latest.data.task.intent.data.scope, - presented: item.presented, - approvedContent: item.approvedContent, - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }); - expect(freshDecision).toMatchObject({ ok: true }); - expect(await call("workit_external_action", request)).toMatchObject({ ok: true }); + ).toMatchObject({ ok: false, code: "invalid_input" }); expect( - spawnSync("git", ["log", "-1", "--pretty=%s"], { - cwd: target, - encoding: "utf8", - }).stdout.trim(), - ).toBe("fix: commit in V2 target"); + spawnSync("git", ["status", "--porcelain=v1"], { cwd: root, encoding: "utf8" }).stdout, + ).toBe(before); + expect(existsSync(path.join(root, ".workit"))).toBe(false); } finally { rmSync(root, { recursive: true, force: true }); - rmSync(target, { recursive: true, force: true }); } }); diff --git a/test/shared/helpers/opencode-namespace.ts b/test/shared/helpers/opencode-namespace.ts index 1d21b7b4..6f86b698 100644 --- a/test/shared/helpers/opencode-namespace.ts +++ b/test/shared/helpers/opencode-namespace.ts @@ -14,6 +14,7 @@ export function assertOpencodeWorkitNamespace(): string[] { "workit_state", ]; expect(names.filter((name) => core.includes(name))).toEqual(core); - expect(names).toContain("workit_external_action"); + expect(names).toContain("workit_context"); + expect(names).not.toContain("workit_external_action"); return names; } diff --git a/test/workit-core/auto-approval-parity.test.ts b/test/workit-core/auto-approval-parity.test.ts index 442ff3a3..20354ae4 100644 --- a/test/workit-core/auto-approval-parity.test.ts +++ b/test/workit-core/auto-approval-parity.test.ts @@ -8,7 +8,7 @@ import { taskStartRequest } from "./task-fixtures"; /** * Auto-approval parity: the same standing rule produces the same outcome — - * execution with no question — on OpenCode, Pi, and the CLI. Each test + * execution with no question — on Pi and the CLI. Each test * builds a repo, an active lead task, writer ownership, a staged change, * and a matching workspace rule, then drives the host's own external-action * surface and asserts the commit lands with zero confirmations. @@ -16,7 +16,7 @@ import { taskStartRequest } from "./task-fixtures"; const git = (cwd: string, args: string[]) => spawnSync("git", args, { cwd }); -const setupTask = (host: "opencode" | "pi" | "workit_cli", actor: string) => { +const setupTask = (host: "pi" | "workit_cli", actor: string) => { const root = mkdtempSync(join(tmpdir(), "workit-auto-parity-")); for (const args of [ ["init", "-q", "-b", "feature/auto"], @@ -77,29 +77,6 @@ const setupTask = (host: "opencode" | "pi" | "workit_cli", actor: string) => { const committed = (root: string): string => spawnSync("git", ["log", "-1", "--pretty=%s"], { cwd: root, encoding: "utf8" }).stdout.trim(); -test("OpenCode executes a commit with no question under a standing rule", async () => { - const actor = "opencode-auto"; - const value = setupTask("opencode", actor); - try { - const { NativeReceiptStore, createWorkitTools } = - await import("@/packages/workit-opencode/src/tools/workit"); - const receipts = new NativeReceiptStore(); - const tools = createWorkitTools({ - receipts, - client: { session: { get: async () => ({ data: { id: actor, directory: value.root } }) } }, - }) as any; - const out = await tools.workit_external_action.execute( - { operation: "git.commit", payload: { message: "chore(auto): opencode one" } }, - { directory: value.root, sessionID: actor }, - ); - const parsed = JSON.parse(typeof out === "string" ? out : out.output); - expect(parsed.ok).toBe(true); - expect(committed(value.root)).toBe("chore(auto): opencode one"); - } finally { - value.cleanup(); - } -}); - test("Pi executes a commit with zero confirms under a standing rule", async () => { const actor = "pi-session"; const value = setupTask("pi", actor); @@ -189,34 +166,11 @@ test("CLI executes a commit headless with no TTY under a standing rule", async ( } }); -test("plan reservations record under a standing rule with no question on every host", async () => { +test("plan reservations record under a standing rule on Pi and the CLI", async () => { const steps = ["chore(auto): plan one", "chore(auto): plan two"]; const branch = "feature/auto"; const planPayload = { plan_steps: steps, plan_branch: branch }; - { - const actor = "opencode-plan"; - const value = setupTask("opencode", actor); - try { - const { createWorkitTools, NativeReceiptStore } = - await import("@/packages/workit-opencode/src/tools/workit"); - const tools = createWorkitTools({ - receipts: new NativeReceiptStore(), - client: { session: { get: async () => ({ data: { id: actor, directory: value.root } }) } }, - }) as any; - const out = await tools.workit_external_action.execute( - { operation: "git.commit", payload: planPayload }, - { directory: value.root, sessionID: actor }, - ); - expect(JSON.parse(typeof out === "string" ? out : out.output)).toMatchObject({ - ok: true, - data: { plan_commits: 2 }, - }); - } finally { - value.cleanup(); - } - } - { const actor = "pi-plan"; const value = setupTask("pi", actor); diff --git a/test/workit-core/branch-policy.test.ts b/test/workit-core/branch-policy.test.ts index 5ad62d5c..69dfe3db 100644 --- a/test/workit-core/branch-policy.test.ts +++ b/test/workit-core/branch-policy.test.ts @@ -3,7 +3,6 @@ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; -import { createRepoTools } from "@/packages/workit-opencode/src/tools/repo"; import { docsBranch, resolveBranch, @@ -11,7 +10,6 @@ import { } from "@/packages/workit-core/src/core/branch"; import { vcsConfig } from "@/packages/workit-core/src/core/vcs-config"; import { resolveWorkspace } from "@/packages/workit-core/src/core/workspaces"; -import { resolvePrBranchContext } from "@/packages/workit-core/src/core/repo-context"; import { prCreate } from "@/packages/workit-core/src/core/pr-create"; import { writeConfig } from "@/packages/workit-core/src/core/config"; import { stubCli, stubPath } from "@/test/shared/helpers/stub-cli"; @@ -104,86 +102,6 @@ test( { timeout: 60_000 }, ); -test( - "branch setup creates feature branch from develop when starting on main", - async () => { - const { root, remote } = repoWithDevelop(); - try { - const raw = await createRepoTools().workit_branch_setup.execute( - { - confirmed: true, - target_branch: "feature/x", - stash: "no", - }, - { directory: root, worktree: root } as never, - ); - const result = JSON.parse(raw as string); - expect(result.ok).toBe(true); - expect(git(root, ["branch", "--show-current"]).stdout.trim()).toBe("feature/x"); - const mergeBase = git(root, ["merge-base", "feature/x", "develop"]).stdout.trim(); - const developHead = git(root, ["rev-parse", "develop"]).stdout.trim(); - expect(mergeBase).toBe(developHead); - } finally { - rmSync(root, { recursive: true, force: true }); - rmSync(remote, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - -test( - "workspace target branch drives docs branch and branch setup", - async () => { - const { root, remote } = repoWithDevelop(); - const workspaces = path.join(isolatedConfig, "workit", "workspaces.json"); - try { - writeFileSync(path.join(root, "main.txt"), "main only\n"); - git(root, ["add", "main.txt"]); - git(root, ["commit", "-q", "-m", "main only"]); - git(root, ["push", "-q", "-u", "origin", "main"]); - mkdirSync(path.dirname(workspaces), { recursive: true }); - writeFileSync( - workspaces, - JSON.stringify({ - workspaces: [ - { - name: "github", - glob: `${root}/**`, - vcs: { provider: "github", defaultTargetBranch: "main" }, - }, - ], - }), - ); - mkdirSync(path.join(root, "docs", "github-flow"), { recursive: true }); - writeFileSync(path.join(root, "docs/github-flow/plan.md"), "# Plan\n"); - git(root, ["add", "docs/github-flow/plan.md"]); - git(root, ["commit", "-q", "-m", "plan"]); - - const resolved = docsBranch({ plan_path: "docs/github-flow/plan.md", workspace_root: root }); - expect(resolved.action).toBe("create_from_base"); - expect(resolved.base).toBe("main"); - - const raw = await createRepoTools().workit_branch_setup.execute( - { - confirmed: true, - target_branch: "feature/github-flow", - stash: "no", - }, - { directory: root, worktree: root } as never, - ); - expect(JSON.parse(raw as string).ok).toBe(true); - expect(git(root, ["rev-parse", "feature/github-flow"]).stdout.trim()).toBe( - git(root, ["rev-parse", "main"]).stdout.trim(), - ); - } finally { - rmSync(workspaces, { force: true }); - rmSync(root, { recursive: true, force: true }); - rmSync(remote, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - test( "docs branch recognizes a custom configured base", () => { @@ -229,33 +147,6 @@ test( { timeout: 60_000 }, ); -test( - "branch setup errors when origin develop is missing", - async () => { - const root = mkdtempSync(path.join(os.tmpdir(), "wf-branch-no-develop-")); - try { - git(root, ["init", "-q", "-b", "main"]); - git(root, ["config", "user.name", "Workflow Test"]); - git(root, ["config", "user.email", "workflow@example.test"]); - writeFileSync(path.join(root, "README.md"), "base\n"); - git(root, ["add", "README.md"]); - git(root, ["commit", "-q", "-m", "base"]); - const raw = await createRepoTools().workit_branch_setup.execute( - { - confirmed: true, - target_branch: "feature/x", - stash: "no", - }, - { directory: root, worktree: root } as never, - ); - expect(JSON.parse(raw as string).ok).toBe(false); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - test( "branch resolution honors use-current and bugfix slug/kind derivation", async () => { @@ -331,177 +222,6 @@ test( { timeout: 60_000 }, ); -test( - "branch setup validates new branch names and guards missing targets and dirty stash flow", - async () => { - const dir = mkdtempSync(path.join(os.tmpdir(), "wf-branch-guards-")); - const remote = mkdtempSync(path.join(os.tmpdir(), "wf-branch-guards-remote-")); - try { - const run = (args: string[]) => spawnSync("git", args, { cwd: dir, encoding: "utf8" }); - spawnSync("git", ["init", "-q", "--bare"], { cwd: remote }); - run(["init", "-q", "-b", "develop"]); - run(["config", "user.name", "T"]); - run(["config", "user.email", "t@t"]); - writeFileSync(path.join(dir, "r.md"), "x"); - run(["add", "r.md"]); - run(["commit", "-q", "-m", "base"]); - run(["remote", "add", "origin", remote]); - run(["push", "-q", "origin", "develop"]); - run(["branch", "main"]); - run(["checkout", "-q", "main"]); - - const tools = createRepoTools(); - const ctx = { directory: dir, worktree: dir } as never; - const noTarget = JSON.parse( - (await tools.workit_branch_setup.execute({ confirmed: true }, ctx)) as string, - ); - expect(noTarget.error).toContain("is required for setup"); - const protected_ = JSON.parse( - (await tools.workit_branch_setup.execute( - { confirmed: true, target_branch: "main" }, - ctx, - )) as string, - ); - expect(protected_.ok).toBe(true); - const badKind = JSON.parse( - (await tools.workit_branch_setup.execute( - { confirmed: true, target_branch: "random/x" }, - ctx, - )) as string, - ); - expect(badKind.error).toContain("allowed_pattern"); - expect(badKind.error).toContain('branch "random/x" violates user-default policy'); - - run(["checkout", "-q", "-b", "feature/dirty"]); - writeFileSync(path.join(dir, "r.md"), "uncommitted change"); - const dirty = JSON.parse( - (await tools.workit_branch_setup.execute( - { confirmed: true, target_branch: "feature/next", stash: "no" }, - ctx, - )) as string, - ); - expect(dirty.error).toContain("dirty working tree"); - } finally { - rmSync(dir, { recursive: true, force: true }); - rmSync(remote, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - -test( - "branch setup reapply_stash requires a recorded stash ref", - async () => { - const dir = mkdtempSync(path.join(os.tmpdir(), "wf-branch-reapply-")); - try { - const run = (args: string[]) => spawnSync("git", args, { cwd: dir, encoding: "utf8" }); - run(["init", "-q", "-b", "develop"]); - run(["config", "user.name", "T"]); - run(["config", "user.email", "t@t"]); - writeFileSync(path.join(dir, "r.md"), "x"); - run(["add", "r.md"]); - run(["commit", "-q", "-m", "base"]); - - const tools = createRepoTools(); - const ctx = { directory: dir, worktree: dir } as never; - const noRef = JSON.parse( - (await tools.workit_branch_setup.execute( - { confirmed: true, action: "reapply_stash" }, - ctx, - )) as string, - ); - expect(noRef.error).toContain("no stash_ref"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - -test( - "branch setup stash + reapply round trip restores changes", - async () => { - const dir = mkdtempSync(path.join(os.tmpdir(), "wf-branch-stash-roundtrip-")); - try { - const run = (args: string[]) => spawnSync("git", args, { cwd: dir, encoding: "utf8" }); - run(["init", "-q", "-b", "develop"]); - run(["config", "user.name", "T"]); - run(["config", "user.email", "t@t"]); - writeFileSync(path.join(dir, "r.md"), "x"); - run(["add", "r.md"]); - run(["commit", "-q", "-m", "base"]); - run(["checkout", "-q", "-b", "feature/dirty"]); - run(["checkout", "-q", "-b", "feature/next"]); - run(["checkout", "-q", "feature/dirty"]); - writeFileSync(path.join(dir, "dirty.txt"), "wip"); - - const tools = createRepoTools(); - const ctx = { directory: dir, worktree: dir } as never; - const setup = JSON.parse( - (await tools.workit_branch_setup.execute( - { - confirmed: true, - target_branch: "feature/next", - stash: "yes", - }, - ctx, - )) as string, - ); - expect(setup.ok).toBe(true); - expect(existsSync(path.join(dir, "dirty.txt"))).toBe(false); - - const reapply = JSON.parse( - (await tools.workit_branch_setup.execute( - { - confirmed: true, - action: "reapply_stash", - }, - ctx, - )) as string, - ); - expect(reapply.ok).toBe(true); - expect(existsSync(path.join(dir, "dirty.txt"))).toBe(true); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - -test( - "branch setup fails from a feature branch when origin develop is missing", - async () => { - const dir = mkdtempSync(path.join(os.tmpdir(), "wf-branch-no-origin-")); - try { - const run = (args: string[]) => spawnSync("git", args, { cwd: dir, encoding: "utf8" }); - run(["init", "-q", "-b", "develop"]); - run(["config", "user.name", "T"]); - run(["config", "user.email", "t@t"]); - writeFileSync(path.join(dir, "r.md"), "x"); - run(["add", "r.md"]); - run(["commit", "-q", "-m", "base"]); - run(["checkout", "-q", "-b", "feature/start"]); - - const tools = createRepoTools(); - const ctx = { directory: dir, worktree: dir } as never; - const raw = JSON.parse( - (await tools.workit_branch_setup.execute( - { - confirmed: true, - target_branch: "feature/new", - }, - ctx, - )) as string, - ); - expect(raw.ok).toBe(false); - expect(raw.ok).toBe(false); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - test("docsBranch reports keep and create_from_develop and HEAD errors", () => { const dir = mkdtempSync(path.join(os.tmpdir(), "wf-docs-branch-")); try { @@ -531,113 +251,6 @@ test("docsBranch reports keep and create_from_develop and HEAD errors", () => { } }); -test( - "RL-03: every PR surface resolves the one configured target branch per preset", - async () => { - // CLI/resolve, PR context, docs branch, OpenCode tool, and Cursor create all - // consume the same target from authoritative config — no per-surface override. - const CASES = [ - { preset: "gitflow", target: "develop" }, - { preset: "github-flow", target: "main" }, - { preset: "trunk-based", target: "main" }, - { preset: "custom", target: "trunk" }, - ] as const; - const stubBin = mkdtempSync(path.join(os.tmpdir(), "wf-rel03-bin-")); - const logFile = path.join(stubBin, "glab-args.txt"); - stubCli(stubBin, "glab", logFile, "https://gitlab.com/o/r/-/merge_requests/1"); - const prevPath = process.env.PATH; - try { - process.env.PATH = stubPath(stubBin); - for (const c of CASES) { - const { root, remote } = repoWithDevelop(); - if (c.target === "trunk") { - git(root, ["checkout", "-q", "-b", "trunk"]); - git(root, ["push", "-q", "-u", "origin", "trunk"]); - } - writeFileSync( - path.join(isolatedConfig, "workit", "config.json"), - JSON.stringify({ - branchPolicy: { - preset: c.preset, - allowed: c.preset === "custom" ? ["feature/*"] : undefined, - protected: ["main"], - }, - }), - ); - writeFileSync( - path.join(isolatedConfig, "workit", "vcs.json"), - JSON.stringify({ provider: "gitlab", defaultTargetBranch: c.target }), - ); - writeFileSync(path.join(isolatedConfig, "workit", "gitlab.token"), "test-token\n"); - writeFileSync( - path.join(isolatedConfig, "workit", "workspaces.json"), - JSON.stringify({ - workspaces: [ - { - name: "t", - glob: `${root}/**`, - vcs: { provider: "gitlab", defaultTargetBranch: c.target }, - branchPolicy: - c.preset === "custom" - ? { preset: "custom", allowed: ["feature/*"], protected: ["main"] } - : { preset: c.preset }, - }, - ], - }), - ); - try { - git(root, ["checkout", "-q", "-b", "feature/rel03"]); - - // surface 1 — CLI / config resolve - const resolved = vcsConfig("resolve", root); - expect(resolved.defaultTargetBranch).toBe(c.target); - - // surface 2 — PR context base_ref - const ctx = resolvePrBranchContext(root); - expect(ctx.ok, `${c.preset}: ${ctx.ok === false ? ctx.error : ""}`).toBe(true); - if (ctx.ok) expect([`origin/${c.target}`, c.target]).toContain(ctx.value.baseRef); - - // surface 3 — docs branch base - const db = docsBranch({ plan_path: "docs/x/plan.md", workspace_root: root }); - expect("error" in db).toBe(false); - if (!("error" in db)) expect(db.base).toBe(c.target); - - // surface 4 — OpenCode legacy hosted-create tool resolves through the - // shared contract; a local bare origin is not a provider-bound - // destination, so it fails closed without contacting a provider - // (decision ae03c569 enabled hosted creation with pre/post SHA - // verification). - const raw = await createRepoTools().workit_pr_create.execute( - { confirmed: true, title: "T" }, - { directory: root, worktree: root } as never, - ); - const toolResult = JSON.parse(raw as string); - expect(toolResult).toMatchObject({ - ok: false, - code: "capability_unavailable", - details: { capability: "hosting.pull_request", outcome: "not_started" }, - }); - - // The lower-level helper keeps branch-policy coverage; public - // Workit-hosted create entry points are guarded above. - const p = prCreate({ WF_PR_CONFIRMED: "true", WF_PR_TITLE: "T" }, root); - expect(p.ok, `${c.preset}: ${JSON.stringify(p)}`).toBe(true); - expect(p.targetBranch).toBe(c.target); - } finally { - rmSync(root, { recursive: true, force: true }); - rmSync(remote, { recursive: true, force: true }); - } - } - } finally { - if (prevPath === undefined) delete process.env.PATH; - else process.env.PATH = prevPath; - rmSync(stubBin, { recursive: true, force: true }); - } - // Slow Windows runners can exceed bun's default 5s per-test budget. - }, - { timeout: 60_000 }, -); - test("CA-01: workspace branchPolicy overrides global config policy across consumers", async () => { const stubBin = mkdtempSync(path.join(os.tmpdir(), "wf-ca01-bin-")); const logFile = path.join(stubBin, "glab-args.txt"); diff --git a/test/workit-core/branch.test.ts b/test/workit-core/branch.test.ts index 6225d5da..e8e88da9 100644 --- a/test/workit-core/branch.test.ts +++ b/test/workit-core/branch.test.ts @@ -1,17 +1,8 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; -import { createRepoTools } from "@/packages/workit-opencode/src/tools/repo"; import { branchSetup } from "@/packages/workit-core/src/core/branch"; import { resolveExternalActionRequest } from "@/packages/workit-core/src/core/external-action-effects"; import { externalActionDescriptor } from "@/packages/workit-core/src/core/external-action"; @@ -81,23 +72,6 @@ const dirtyTree = (root: string) => { writeFileSync(path.join(root, "notes.md"), "untracked doc\n"); }; -const seedReadOnlySddDir = (root: string) => { - const sddDir = path.join(root, "docs", "sdd"); - const seedOn = (branch: string) => { - git(root, ["checkout", "-q", branch]); - mkdirSync(sddDir, { recursive: true }); - writeFileSync(path.join(sddDir, ".gitkeep"), "keep\n"); - git(root, ["add", "docs/sdd/.gitkeep"]); - git(root, ["commit", "-q", "-m", "seed sdd dir"]); - }; - git(root, ["fetch", "origin", "develop:develop"]); - seedOn("develop"); - git(root, ["push", "-q", "origin", "develop"]); - seedOn("main"); - git(root, ["push", "-q", "origin", "main"]); - chmodSync(sddDir, 0o555); -}; - // Shared branch-setup journal fixture: repo with develop on origin. const journalRepo = () => repoOnMain({ withDevelop: true }); @@ -355,169 +329,3 @@ test("branch setup refuses a fetched base newer than the approved remote SHA", ( rmSync(remote, { recursive: true, force: true }); } }); - -test( - "failed base resolution fails before any stash and leaves tree intact", - async () => { - // CA-02 regression: origin lacks develop, so origin/base validation must - // fail BEFORE any mutation — no snapshot, no stash push, no checkout. - // The tree ends up exactly as it was left, with no stranded stash entry. - // Honest note: since the consolidated-guard fix these final-state - // assertions also passed via pop-back restoration; the pre-stash split - // removes the transient stash/snapshot window itself. - const { root, remote } = repoOnMain({ withDevelop: false }); - try { - dirtyTree(root); - const raw = await createRepoTools().workit_branch_setup.execute( - { - confirmed: true, - target_branch: "bugfix/x", - stash: "yes", - }, - { directory: root, worktree: root } as never, - ); - const result = JSON.parse(raw as string); - expect(result.ok).toBe(false); - expect(String(result.error)).toContain("origin/develop missing"); - expect(git(root, ["stash", "list"]).stdout.trim()).toBe(""); - expect(readFileSync(path.join(root, "README.md"), "utf8")).toBe("wip change\n"); - expect(existsSync(path.join(root, "notes.md"))).toBe(true); - expect(git(root, ["branch", "--show-current"]).stdout.trim()).toBe("main"); - } finally { - const sddDir = path.join(root, "docs", "sdd"); - if (existsSync(sddDir)) chmodSync(sddDir, 0o755); - rmSync(root, { recursive: true, force: true }); - rmSync(remote, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - -test( - "policy/base resolution runs before any stash push", - async () => { - // CA-01 regression: base resolution succeeds here but a later step fails - // after the stash push (local branch `bugfix` blocks creating `refs/heads/ - // bugfix/x`), so the error path must pop the stash back before returning — - // the tree ends up exactly as it was left, with no stranded stash entry. - const { root, remote } = repoOnMain({ withDevelop: true }); - try { - git(root, ["branch", "bugfix"]); - dirtyTree(root); - const raw = await createRepoTools().workit_branch_setup.execute( - { - confirmed: true, - target_branch: "bugfix/x", - stash: "yes", - }, - { directory: root, worktree: root } as never, - ); - const result = JSON.parse(raw as string); - expect(result.ok).toBe(false); - expect(git(root, ["stash", "list"]).stdout.trim()).toBe(""); - // Stash pop rewrites the file via git; tolerate CRLF checkout. - expect(readFileSync(path.join(root, "README.md"), "utf8").replace(/\r\n/g, "\n")).toBe( - "wip change\n", - ); - expect(existsSync(path.join(root, "notes.md"))).toBe(true); - } finally { - const sddDir = path.join(root, "docs", "sdd"); - if (existsSync(sddDir)) chmodSync(sddDir, 0o755); - rmSync(root, { recursive: true, force: true }); - rmSync(remote, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - -test( - "manifest write failure after checkout restores stash and returns to previous branch", - async () => { - // Regression: a throw in the post-checkout manifest section stranded the - // stash — the error path must pop it back before returning. The setup must - // also not leave HEAD on the half-created target: after the pop succeeds, - // checkout returns to the originating branch (best-effort). - // Skipped on win32: dir mode bits are not enforced on Windows, so the - // chmod-0500 EACCES injection never fires there. Still runs on ubuntu/macos. - if (process.platform === "win32") return; // chmod is not advisory on win32 - const { root, remote } = repoOnMain({ withDevelop: true }); - try { - seedReadOnlySddDir(root); - dirtyTree(root); - const raw = await createRepoTools().workit_branch_setup.execute( - { - confirmed: true, - target_branch: "bugfix/x", - stash: "yes", - sdd_dir: "docs/sdd", - }, - { directory: root, worktree: root } as never, - ); - const result = JSON.parse(raw as string); - expect(result.ok).toBe(false); - expect(String(result.error)).toContain("manifest update failed"); - expect(String(result.error)).not.toContain("changes preserved in stash"); - expect(git(root, ["stash", "list"]).stdout.trim()).toBe(""); - expect(git(root, ["branch", "--show-current"]).stdout.trim()).toBe("main"); - expect(readFileSync(path.join(root, "README.md"), "utf8").replace(/\r\n/g, "\n")).toBe( - "wip change\n", - ); - expect(existsSync(path.join(root, "notes.md"))).toBe(true); - } finally { - const sddDir = path.join(root, "docs", "sdd"); - if (existsSync(sddDir)) chmodSync(sddDir, 0o755); - rmSync(root, { recursive: true, force: true }); - rmSync(remote, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - -test( - "failed best-effort stash pop points at the stash in the error", - async () => { - // Diverge develop's README so the stashed README edit conflicts on pop; - // combined with an unwritable manifest dir this reaches the restore path - // where the pop itself fails — the error must say where the work lives. - // Skipped on win32: dir mode bits are not enforced on Windows, so the - // chmod-0500 EACCES injection never fires there. Still runs on ubuntu/macos. - if (process.platform === "win32") return; // chmod is not advisory on win32 - const { root, remote } = repoOnMain({ withDevelop: true }); - try { - seedReadOnlySddDir(root); - git(root, ["checkout", "-q", "develop"]); - writeFileSync(path.join(root, "README.md"), "develop version\n"); - git(root, ["add", "README.md"]); - git(root, ["commit", "-q", "-m", "diverge"]); - git(root, ["checkout", "-q", "main"]); - chmodSync(path.join(root, "docs", "sdd"), 0o555); - dirtyTree(root); - const raw = await createRepoTools().workit_branch_setup.execute( - { - confirmed: true, - target_branch: "bugfix/x", - stash: "yes", - sdd_dir: "docs/sdd", - }, - { directory: root, worktree: root } as never, - ); - const result = JSON.parse(raw as string); - expect(result.ok).toBe(false); - expect(String(result.error)).toContain("manifest update failed"); - expect(String(result.error)).toContain("changes preserved in stash"); - expect(git(root, ["stash", "list"]).stdout.trim()).not.toBe(""); - // Pop conflicts (README diverged): work stays in the stash, target tree - // keeps its own content under conflict markers. Pop rewrites via git; - // tolerate CRLF checkout. - expect(readFileSync(path.join(root, "README.md"), "utf8").replace(/\r\n/g, "\n")).toContain( - "develop version", - ); - } finally { - const sddDir = path.join(root, "docs", "sdd"); - if (existsSync(sddDir)) chmodSync(sddDir, 0o755); - rmSync(root, { recursive: true, force: true }); - rmSync(remote, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); diff --git a/test/workit-core/config-guard.test.ts b/test/workit-core/config-guard.test.ts index 573ee4c5..b01c1b08 100644 --- a/test/workit-core/config-guard.test.ts +++ b/test/workit-core/config-guard.test.ts @@ -78,15 +78,12 @@ test("configGuardError contains marker, missing ids, and fix path", () => { expect(error).toContain("npx workit init"); }); -test("youtrack tools return structured config-gap error instead of raw ENOENT", async () => { +test("YouTrack verification returns a structured config-gap error instead of raw ENOENT", async () => { const dir = mkdtempSync(path.join(os.tmpdir(), "wf-guard-tool-")); const tools = createYouTrackTools(); const ctx = { directory: "/repo", worktree: "/repo" } as never; await withIsolatedXDG(dir, async () => { - const raw = await tools.workit_youtrack_log_time.execute( - { confirmed: true, issueId: "NSR-1", minutes: 30 }, - ctx, - ); + const raw = await tools.workit_youtrack_verify_token.execute({}, ctx); const result = JSON.parse(raw as string); expect(result.ok).toBe(false); expect(result.error).toContain(CONFIG_GAP_MARKER); diff --git a/test/workit-core/external-action.test.ts b/test/workit-core/external-action.test.ts index 986f1dde..a201f481 100644 --- a/test/workit-core/external-action.test.ts +++ b/test/workit-core/external-action.test.ts @@ -38,7 +38,6 @@ import { readYouTrackAction, resolveExternalActionRequest, } from "@/packages/workit-core/src/core/external-action-effects"; -import { nativeExternalActionRunner } from "@/packages/workit-opencode/src/tools/workit"; import { assessment, scope, taskStartRequest } from "./task-fixtures"; import { stubCli, stubPath } from "@/test/shared/helpers/stub-cli"; @@ -983,13 +982,6 @@ test("local action authority follows the current writer session, not the task cr expect(externalActionState(store, "opencode", "resumed-session", descriptor)).toMatchObject({ ok: true, }); - expect( - await nativeExternalActionRunner( - root, - "resumed-session", - resumed, - )(descriptor, async () => success(null, null, "executed")), - ).toMatchObject({ ok: true, data: "executed" }); } finally { rmSync(root, { recursive: true, force: true }); } @@ -2310,67 +2302,6 @@ test("settlement refreshes exact revisions after unrelated task progress", async } }); -test("native host action binding requires the exact canonical target and payload", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-external-host-binding-")); - try { - const actor = "opencode-session"; - const store = new TaskStore(root); - const core = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - nativeAuthority: verifier(actor, "opencode"), - }); - const started = core.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("host binding setup failed"); - const descriptor = externalActionDescriptor("git.commit", { message: "approved" }); - const decision = core.observeDecision( - { - schemaVersion: 1, - action: "record", - taskId: task.data.id, - expectedRevision: task.data.revision, - purpose: "action", - binding: { - taskId: task.data.id, - workspaceId: workspace.data.id, - scope: task.data.intent.data.scope, - presented: "Approve one commit", - approvedContent: descriptor, - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }, - { kind: "decision", actor }, - ); - if (!decision.ok) throw new Error(decision.error); - const runner = nativeExternalActionRunner(root, actor, core); - let effects = 0; - expect( - await runner(externalActionDescriptor("git.commit", { message: "different" }), async () => { - effects += 1; - return "must not run"; - }), - ).toMatchObject({ ok: false, code: "permission_denied" }); - expect(effects).toBe(0); - expect( - await runner(descriptor, async () => { - effects += 1; - return "committed"; - }), - ).toMatchObject({ ok: true, data: "committed" }); - expect(effects).toBe(1); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - test("hosted PR create preserves the optional babysit preference without opting in by default", () => { const root = mkdtempSync(join(tmpdir(), "workit-pr-babysit-")); const tools = mkdtempSync(join(tmpdir(), "workit-pr-babysit-tools-")); diff --git a/test/workit-core/pr-create.test.ts b/test/workit-core/pr-create.test.ts index a281cfef..da24eb8c 100644 --- a/test/workit-core/pr-create.test.ts +++ b/test/workit-core/pr-create.test.ts @@ -3,7 +3,6 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; -import { createRepoTools } from "@/packages/workit-opencode/src/tools/repo"; import { hostingApiHostMatches, mergePr, @@ -499,66 +498,6 @@ test( { timeout: 60_000 }, ); -const withWrapperConfig = (fn: () => Promise): Promise => { - const previousConfig = process.env.WORKFLOW_TOOLKIT_CONFIG; - const previousPath = process.env.PATH; - process.env.WORKFLOW_TOOLKIT_CONFIG = cfgDir; - process.env.PATH = stubPath(); - return fn().finally(() => { - if (previousConfig === undefined) delete process.env.WORKFLOW_TOOLKIT_CONFIG; - else process.env.WORKFLOW_TOOLKIT_CONFIG = previousConfig; - if (previousPath === undefined) delete process.env.PATH; - else process.env.PATH = previousPath; - }); -}; - -test( - "CA-06: OpenCode wrapper resolves a valid target for the shared action path", - async () => { - setupRepoWithOrigin(); - git(root, ["checkout", "-q", "-b", "feature/ca06"]); - const raw = await withWrapperConfig(() => { - writeConfig({ preset: "gitflow" }, "develop"); - return createRepoTools().workit_pr_create.execute( - { confirmed: true, title: "T", target_branch: "develop" }, - { directory: root, worktree: root } as never, - ); - }); - // Decision ae03c569: the legacy wrapper resolves through the shared - // contract and delegates; it never performs a raw provider create. - const result = JSON.parse(raw as string); - expect(result).toMatchObject({ - ok: false, - code: "needs_input", - details: { operation: "hosting.pull_request" }, - }); - expect(result.details.proposal.presented).toContain("to `develop`"); - expect(result.details.guidance).toContain("workit_external_action"); - expect(existsSync(logFile)).toBe(false); - }, - { timeout: 60_000 }, -); - -test( - "CA-06: OpenCode wrapper resolves the chosen target without contacting a provider", - async () => { - setupRepoWithOrigin(); - git(root, ["checkout", "-q", "-b", "feature/ca06"]); - const raw = await withWrapperConfig(() => { - writeConfig({ preset: "gitflow" }, "develop"); - return createRepoTools().workit_pr_create.execute( - { confirmed: true, title: "T", target_branch: "main" }, - { directory: root, worktree: root } as never, - ); - }); - const result = JSON.parse(raw as string); - expect(result).toMatchObject({ ok: false, code: "needs_input" }); - expect(result.details.proposal.presented).toContain("to `main`"); - expect(existsSync(logFile)).toBe(false); - }, - { timeout: 60_000 }, -); - // The legacy CLI port must never perform a hosted create itself: it returns // the same needs_input shape as the headless `workit action` route. @@ -713,49 +652,6 @@ test( { timeout: 60_000 }, ); -test( - "B6: OpenCode wrapper delegates hosted creation without inspecting issue-linking environment", - async () => { - setupRepoWithOrigin(); - git(root, ["checkout", "-q", "-b", "feature/b6"]); - const previous = process.env.WORKFLOW_GH_ISSUE; - const previousConfig = process.env.WORKFLOW_TOOLKIT_CONFIG; - const previousPath = process.env.PATH; - process.env.WORKFLOW_TOOLKIT_CONFIG = cfgDir; - process.env.WORKFLOW_GH_ISSUE = "42"; - process.env.PATH = stubPath(); - try { - writeConfig( - { preset: "gitflow", allowed: ["feature/*", "bugfix/*"], protected: ["main", "develop"] }, - "develop", - ); - const raw = await createRepoTools().workit_pr_create.execute( - { confirmed: true, title: "T" }, - { - directory: root, - worktree: root, - } as never, - ); - const result = JSON.parse(raw as string); - expect(result).toMatchObject({ - ok: false, - code: "needs_input", - details: { operation: "hosting.pull_request" }, - }); - expect(JSON.stringify(result)).not.toContain("#42"); - expect(existsSync(logFile)).toBe(false); - } finally { - if (previous === undefined) delete process.env.WORKFLOW_GH_ISSUE; - else process.env.WORKFLOW_GH_ISSUE = previous; - if (previousConfig === undefined) delete process.env.WORKFLOW_TOOLKIT_CONFIG; - else process.env.WORKFLOW_TOOLKIT_CONFIG = previousConfig; - if (previousPath === undefined) delete process.env.PATH; - else process.env.PATH = previousPath; - } - }, - { timeout: 60_000 }, -); - // Task 2 — GitHub push-before-create honoring pr.pushBranch. const branchOn = () => { @@ -854,40 +750,6 @@ test( { timeout: 60_000 }, ); -test( - "T2: OpenCode wrapper delegates instead of pushing a branch before hosted creation", - async () => { - setupRepoWithOrigin(); - branchOn(); - const previousConfig = process.env.WORKFLOW_TOOLKIT_CONFIG; - const previousPath = process.env.PATH; - process.env.WORKFLOW_TOOLKIT_CONFIG = cfgDir; - process.env.PATH = stubPath(); - try { - writeConfig(customPolicy, "trunk", { pushBranch: true }); - const raw = await createRepoTools().workit_pr_create.execute( - { confirmed: true, title: "T" }, - { - directory: root, - worktree: root, - } as never, - ); - const result = JSON.parse(raw as string); - expect(result).toMatchObject({ ok: false, code: "needs_input" }); - expect( - git(root, ["ls-remote", `file://${bareRemote}`, "refs/heads/feature/t2"]).stdout, - ).not.toContain("feature/t2"); - expect(existsSync(logFile)).toBe(false); - } finally { - if (previousConfig === undefined) delete process.env.WORKFLOW_TOOLKIT_CONFIG; - else process.env.WORKFLOW_TOOLKIT_CONFIG = previousConfig; - if (previousPath === undefined) delete process.env.PATH; - else process.env.PATH = previousPath; - } - }, - { timeout: 60_000 }, -); - test( "T2: gitlab parity — reserved push uses one target before glab MR creation", () => { diff --git a/test/workit-core/repo.test.ts b/test/workit-core/repo.test.ts index a7257489..e553b567 100644 --- a/test/workit-core/repo.test.ts +++ b/test/workit-core/repo.test.ts @@ -1,13 +1,5 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; @@ -164,10 +156,6 @@ test( "workit_init_status", "workit_status", "workit_verify", - "workit_changelog_apply", - "workit_branch_setup", - "workit_commit", - "workit_pr_create", "workit_init_apply", ].sort(), ); @@ -353,123 +341,16 @@ test( ); test( - "mutations reject missing confirmation before any resolution", + "init_apply rejects missing confirmation before any resolution", async () => { resetCalls(); const tools = createRepoTools(runtime); - for (const name of [ - "workit_changelog_apply", - "workit_branch_setup", - "workit_commit", - "workit_init_apply", - "workit_pr_create", - ] as const) { - const raw = await tools[name].execute( - { confirmed: false } as never, - { directory: "/repo", worktree: "/repo" } as never, - ); - expect(JSON.parse(raw as string).error).toBe("confirmed: true required"); - } - expect(totalCalls()).toBe(0); - }, - { timeout: 60_000 }, -); - -test( - "commit blocks protected branches", - async () => { - const protectedRuntime = { - ...runtime, - git: (_root: string, args: string[]) => - args[0] === "branch" - ? { exitCode: 0, stdout: "main\n", stderr: "", cwd: "/repo" } - : { exitCode: 0, stdout: "", stderr: "", cwd: "/repo" }, - }; - const raw = await createRepoTools(protectedRuntime).workit_commit.execute( - { confirmed: true, message: "fix: no" }, + const raw = await tools.workit_init_apply.execute( + { confirmed: false } as never, { directory: "/repo", worktree: "/repo" } as never, ); - expect(JSON.parse(raw as string).error).toContain("protected branch main"); - }, - { timeout: 60_000 }, -); - -test( - "commit accepts only feature or bugfix branches and never stages files", - async () => { - calls.git.length = 0; - const result = await execute("workit_commit", { - confirmed: true, - message: "feat: native mutation", - }); - expect(result).toEqual({ - ok: true, - data: { stdout: "committed", exitCode: 0 }, - error: null, - }); - expect(calls.git).toEqual([ - { root: "/repo", args: ["branch", "--show-current"] }, - { root: "/repo", args: ["commit", "-m", "feat: native mutation"] }, - ]); - - const raw = await createRepoTools({ - ...runtime, - git: (root: string, args: string[]) => ({ - exitCode: 0, - stdout: args[0] === "branch" ? "chore/random\n" : "", - stderr: "", - cwd: root, - }), - }).workit_commit.execute({ confirmed: true, message: "chore: no" }, { - directory: "/repo", - worktree: "/repo", - } as never); - const rejected = JSON.parse(raw as string); - expect(rejected.ok).toBe(false); - expect(rejected.error).toContain("requires an allowed branch"); - - const emptySuffix = await createRepoTools({ - ...runtime, - git: (root: string, args: string[]) => ({ - exitCode: 0, - stdout: args[0] === "branch" ? "feature/\n" : "", - stderr: "", - cwd: root, - }), - }).workit_commit.execute({ confirmed: true, message: "fix: no empty suffix" }, { - directory: "/repo", - worktree: "/repo", - } as never); - expect(JSON.parse(emptySuffix as string).error).toContain("requires an allowed branch"); - }, - { timeout: 60_000 }, -); - -test( - "hosted PR creation fails closed on an unbound remote without running legacy code", - async () => { - resetCalls(); - const root = mkdtempSync(path.join(os.tmpdir(), "workflow-toolkit-pr-target-")); - try { - spawnSync("git", ["init", "-q", "-b", "feature/pr"], { cwd: root }); - const raw = await createRepoTools(runtime).workit_pr_create.execute( - { confirmed: true, title: "No" }, - { directory: root, worktree: root } as never, - ); - // Decision ae03c569: creation is enabled, but it still fails closed when - // the target has no provider-bound remote to bind the approved SHA to. - const result = JSON.parse(raw as string); - expect(result).toMatchObject({ - ok: false, - code: "capability_unavailable", - details: { capability: "hosting.pull_request", outcome: "not_started" }, - }); - expect(result.error).toContain("configured remote provider"); - expect(calls.git).toHaveLength(0); - expect(totalCalls()).toBe(0); - } finally { - rmSync(root, { recursive: true, force: true }); - } + expect(JSON.parse(raw as string).error).toBe("confirmed: true required"); + expect(totalCalls()).toBe(0); }, { timeout: 60_000 }, ); @@ -526,161 +407,6 @@ test( { timeout: 60_000 }, ); -test( - "branch setup requires develop remote before creating from main", - async () => { - const root = mkdtempSync(path.join(os.tmpdir(), "workflow-toolkit-branch-")); - const remote = mkdtempSync(path.join(os.tmpdir(), "workflow-toolkit-branch-remote-")); - const git = (cwd: string, args: string[]) => spawnSync("git", args, { cwd, encoding: "utf8" }); - try { - git(remote, ["init", "-q", "--bare"]); - expect(git(root, ["init", "-q", "-b", "develop"]).status).toBe(0); - git(root, ["config", "user.name", "Workflow Test"]); - git(root, ["config", "user.email", "workflow@example.test"]); - writeFileSync(path.join(root, "README.md"), "base\n"); - git(root, ["add", "README.md"]); - git(root, ["commit", "-q", "-m", "base"]); - git(root, ["remote", "add", "origin", remote]); - git(root, ["push", "-q", "-u", "origin", "develop"]); - git(root, ["branch", "main"]); - git(root, ["checkout", "-q", "main"]); - const raw = await createRepoTools().workit_branch_setup.execute( - { - confirmed: true, - target_branch: "feature/x", - stash: "no", - }, - { directory: root, worktree: root } as never, - ); - expect(JSON.parse(raw as string).ok).toBe(true); - expect(git(root, ["branch", "--show-current"]).stdout.trim()).toBe("feature/x"); - } finally { - rmSync(root, { recursive: true, force: true }); - rmSync(remote, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - -test( - "reapply stash dispatches without a target branch", - async () => { - const root = mkdtempSync(path.join(os.tmpdir(), "workflow-toolkit-reapply-")); - try { - spawnSync("git", ["init", "-q", "-b", "feature/x"], { cwd: root }); - const raw = await createRepoTools().workit_branch_setup.execute( - { - confirmed: true, - action: "reapply_stash", - }, - { directory: root, worktree: root } as never, - ); - const result = JSON.parse(raw as string); - expect(result.error).toContain("no stash_ref"); - expect(result.error).not.toContain("target branch required"); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - -test( - "branch setup treats quote-bearing manifest paths as data", - async () => { - const root = mkdtempSync(path.join(os.tmpdir(), "workflow-toolkit-quote-")); - try { - spawnSync("git", ["init", "-q", "-b", "feature/x"], { cwd: root }); - const injected = - "docs/sdd/x'); __import__('pathlib').Path('sentinel').write_text('owned'); #"; - const raw = await createRepoTools().workit_branch_setup.execute( - { - confirmed: true, - action: "reapply_stash", - sdd_dir: injected, - }, - { directory: root, worktree: root } as never, - ); - expect(JSON.parse(raw as string).error).toContain("no stash_ref"); - expect(existsSync(path.join(root, "sentinel"))).toBe(false); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - -test( - "hosted PR creation resolves through the shared contract while init mutations use scoped environment", - async () => { - resetCalls(); - const root = mkdtempSync(path.join(os.tmpdir(), "workflow-toolkit-pr-env-")); - try { - spawnSync("git", ["init", "-q", "-b", "feature/env"], { cwd: root }); - const raw = await createRepoTools(runtime).workit_pr_create.execute( - { - confirmed: true, - title: "Native tools", - body: "Ready", - draft: true, - target_branch: "develop", - }, - { directory: root, worktree: root } as never, - ); - // The resolver, not the legacy runtime, owns PR resolution; an unbound - // remote still fails closed before any provider call. - expect(JSON.parse(raw as string)).toMatchObject({ - ok: false, - code: "capability_unavailable", - details: { capability: "hosting.pull_request", outcome: "not_started" }, - }); - } finally { - rmSync(root, { recursive: true, force: true }); - } - expect( - await execute("workit_init_apply", { - confirmed: true, - action: "youtrack_json", - base_url: "https://youtrack.example.test", - }), - ).toEqual({ ok: true, data: { action: "youtrack_json", exitCode: 0 }, error: null }); - - expect(calls.git).toHaveLength(0); - expect(calls.initApply).toEqual([ - { - root: "/repo", - action: "youtrack_json", - env: { - WORKFLOW_YT_BASE_URL: "https://youtrack.example.test", - WORKFLOW_WORKSPACE_ROOT: "/repo", - }, - }, - ]); - }, - { timeout: 60_000 }, -); - -test( - "hosted PR creation reports an unresolvable target without running legacy code", - async () => { - resetCalls(); - const root = mkdtempSync(path.join(os.tmpdir(), "workflow-toolkit-error-")); - try { - const raw = await createRepoTools(runtime).workit_pr_create.execute( - { confirmed: true, title: "Unavailable" }, - { directory: root, worktree: root } as never, - ); - const result = JSON.parse(raw as string); - expect(result).toMatchObject({ ok: false, code: "capability_unavailable" }); - expect(result.error).toContain("not a Git repository"); - expect(totalCalls()).toBe(0); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - test( "legacy ok false values normalize to failures", async () => { @@ -715,47 +441,6 @@ test( { timeout: 60_000 }, ); -test( - "mutation paths cannot escape ToolContext.directory", - async () => { - resetCalls(); - const parent = mkdtempSync(path.join(os.tmpdir(), "workflow-toolkit-boundary-")); - try { - const root = path.join(parent, "repo"); - const outside = path.join(parent, "outside"); - mkdirSync(root); - mkdirSync(outside); - const branchRaw = await createRepoTools(runtime).workit_branch_setup.execute( - { - confirmed: true, - target_branch: "feature/native-tools", - sdd_dir: "../outside", - }, - { directory: root, worktree: root } as never, - ); - expect(JSON.parse(branchRaw as string).error).toBe("path must stay inside repository root"); - expect(totalCalls()).toBe(0); - - writeFileSync(path.join(outside, "CHANGELOG.md"), "# Outside\n"); - symlinkSync(path.join(outside, "CHANGELOG.md"), path.join(root, "CHANGELOG.md")); - const changelogRaw = await createRepoTools(runtime).workit_changelog_apply.execute( - { - confirmed: true, - entries: { Fixed: ["must stay inside"] }, - }, - { directory: root, worktree: root } as never, - ); - expect(JSON.parse(changelogRaw as string).error).toBe( - "path must stay inside repository root", - ); - expect(readFileSync(path.join(outside, "CHANGELOG.md"), "utf8")).toBe("# Outside\n"); - } finally { - rmSync(parent, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - test( "changelog implementation is package-owned", () => { @@ -769,127 +454,6 @@ test( { timeout: 60_000 }, ); -test( - "changelog preserves rich Markdown while consolidating categories", - async () => { - const root = mkdtempSync(path.join(os.tmpdir(), "workflow-toolkit-")); - try { - const changelog = path.join(root, "CHANGELOG.md"); - writeFileSync( - changelog, - `# Changelog - -## [Unreleased] - - - -### Added - -- Existing feature - - nested detail - continuation text - -### Notes - -Keep this custom section. - -### Added - -- Existing feature -- Second feature - with continuation - -## [1.0.0] - 2026-01-01 - -### Added - -- Historical feature -`, - ); - const raw = await createRepoTools(runtime).workit_changelog_apply.execute( - { - confirmed: true, - entries: { Added: ["New feature", "Existing feature"] }, - }, - { directory: root, worktree: root } as never, - ); - expect(JSON.parse(raw as string).ok).toBe(true); - const output = readFileSync(changelog, "utf8"); - const unreleased = output.split("## [1.0.0]")[0]; - expect((unreleased.match(/^### Added$/gm) ?? []).length).toBe(1); - expect((unreleased.match(/^- Existing feature$/gm) ?? []).length).toBe(1); - expect((unreleased.match(/^- New feature$/gm) ?? []).length).toBe(1); - for (const preserved of [ - "", - " - nested detail", - " continuation text", - "### Notes", - "Keep this custom section.", - " with continuation", - "## [1.0.0] - 2026-01-01", - "- Historical feature", - ]) - expect(output).toContain(preserved); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - -test( - "confirmed changelog apply without entries fails without editing", - async () => { - const root = mkdtempSync(path.join(os.tmpdir(), "workflow-toolkit-empty-changelog-")); - try { - const changelog = path.join(root, "CHANGELOG.md"); - const before = "# Changelog\n\n## [Unreleased]\n"; - writeFileSync(changelog, before); - const raw = await createRepoTools(runtime).workit_changelog_apply.execute( - { - confirmed: true, - }, - { directory: root, worktree: root } as never, - ); - expect(JSON.parse(raw as string)).toEqual({ - ok: false, - data: null, - error: "entries required unless normalize_only", - }); - expect(readFileSync(changelog, "utf8")).toBe(before); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - -test( - "changelog apply accepts a symlink-spelled ToolContext directory", - async () => { - const parent = mkdtempSync(path.join(os.tmpdir(), "workflow-toolkit-linked-root-")); - try { - const root = path.join(parent, "repo"); - const linkedRoot = path.join(parent, "repo-link"); - mkdirSync(root); - symlinkSync(root, linkedRoot); - writeFileSync(path.join(root, "CHANGELOG.md"), "# Changelog\n\n## [Unreleased]\n"); - const raw = await createRepoTools(runtime).workit_changelog_apply.execute( - { - confirmed: true, - entries: { Fixed: ["Canonical root"] }, - }, - { directory: linkedRoot, worktree: linkedRoot } as never, - ); - expect(JSON.parse(raw as string).ok).toBe(true); - expect(readFileSync(path.join(root, "CHANGELOG.md"), "utf8")).toContain("- Canonical root"); - } finally { - rmSync(parent, { recursive: true, force: true }); - } - }, - { timeout: 60_000 }, -); - test( "OpenCode init omits obsolete MCP dependency installation", () => { diff --git a/test/workit-core/youtrack.test.ts b/test/workit-core/youtrack.test.ts index 11c177b9..4961a80b 100644 --- a/test/workit-core/youtrack.test.ts +++ b/test/workit-core/youtrack.test.ts @@ -162,7 +162,7 @@ test("explicit not_applied time failure safely retries time only", async () => { postedComment: true, loggedMinutes: 0, outcome: "not_applied", - retry: "workit_youtrack_log_time", + retry: "youtrack.time", }, error: "rejected before request", }); @@ -192,7 +192,7 @@ test("explicit not_applied comment failure safely retries the missing effects", postedComment: false, loggedMinutes: 0, outcome: "not_applied", - retry: "workit_youtrack_post", + retry: "youtrack.update", }, error: "rejected before request", }); @@ -220,92 +220,6 @@ test("posting requires explicit confirmation before either effect", async () => expect(result).toEqual({ ok: false, data: null, error: "confirmed: true required" }); }); -test.skipIf(process.platform === "win32")( - "standalone time logging preserves ambiguous and not-applied outcomes", - async () => { - const xdg = mkdtempSync(path.join(os.tmpdir(), "wf-youtrack-outcome-")); - const directory = path.join(xdg, "workflow-toolkit"); - mkdirSync(directory); - const tokenPath = path.join(directory, "youtrack.token"); - writeFileSync(tokenPath, "dummy-token\n", { mode: 0o600 }); - writeFileSync(path.join(directory, "youtrack.json"), JSON.stringify({ tokenFile: tokenPath })); - await withNeutralXdg(xdg, async () => { - for (const [operation, outcome, retry] of [ - [ - async () => { - throw new Error("transport lost"); - }, - "unknown", - undefined, - ], - [ - async () => ({ ok: false, error: "not sent", outcome: "not_applied" }), - "not_applied", - "workit_youtrack_log_time", - ], - ] as const) { - const tools = createYouTrackTools({ - verifyToken: async () => ({}), - context: async () => ({}), - parseDuration: async () => ({}), - postComment: async () => ({}), - logTime: operation, - }); - const raw = await tools.workit_youtrack_log_time.execute( - { confirmed: true, issueId: "NSR-40", minutes: 30 }, - { directory: "/repo", worktree: "/repo" } as never, - ); - const result = JSON.parse(raw as string); - expect(result.data.outcome).toBe(outcome); - expect(result.data.retry).toBe(retry); - if (outcome === "unknown") expect(result.data.instructions).toContain("do not retry"); - } - }); - }, -); - -test("bundled standalone time logging rejects invalid inputs before credentials or HTTP", async () => { - const root = mkdtempSync(path.join(os.tmpdir(), "wf-youtrack-preflight-")); - const bin = path.join(root, "bin"); - const sentinel = path.join(root, "http-dispatched"); - mkdirSync(bin); - writeFileSync(path.join(bin, "curl"), `#!/bin/sh\ntouch '${sentinel}'\nexit 99\n`, { - mode: 0o755, - }); - const previousPath = process.env.PATH; - process.env.PATH = `${bin}:${previousPath}`; - try { - await withNeutralXdg(path.join(root, "missing-config"), async () => { - const tools = createYouTrackTools(); - for (const [input, error] of [ - [{ confirmed: true, issueId: "bad", minutes: 30 }, "invalid issueId"], - [{ confirmed: true, issueId: "NSR-40", minutes: 0 }, "minutes must be positive"], - [{ confirmed: true, issueId: "NSR-40", minutes: -1 }, "minutes must be positive"], - ] as const) { - const raw = await tools.workit_youtrack_log_time.execute(input, { - directory: root, - worktree: root, - } as never); - expect(JSON.parse(raw as string)).toEqual({ - ok: false, - data: { - issueId: input.issueId, - loggedMinutes: 0, - outcome: "not_applied", - retry: "workit_youtrack_log_time", - instructions: "Correct the invalid input, then retry workit_youtrack_log_time once.", - }, - error, - }); - } - expect(existsSync(sentinel)).toBe(false); - }); - } finally { - process.env.PATH = previousPath; - rmSync(root, { recursive: true, force: true }); - } -}); - test("YouTrack context rejects escaped spec and plan paths before credentials or operations", async () => { const parent = mkdtempSync(path.join(os.tmpdir(), "wf-youtrack-path-")); const root = path.join(parent, "repo"); @@ -605,7 +519,7 @@ test("bundled API failures never expose the token or authorization header", asyn } }); -test("registers seven standard tools without workspace_root and guards mutations", async () => { +test("registers five read-only tools without workspace_root", async () => { const tools = createYouTrackTools({ verifyToken: async () => ({}), context: async () => ({}), @@ -620,20 +534,11 @@ test("registers seven standard tools without workspace_root and guards mutations "workit_youtrack_context", "workit_youtrack_parse_duration", "workit_youtrack_draft", - "workit_youtrack_log_time", - "workit_youtrack_post", ].sort(), ); for (const definition of Object.values(tools)) { expect("workspace_root" in definition.args).toBe(false); } - for (const name of ["workit_youtrack_log_time", "workit_youtrack_post"] as const) { - const raw = await tools[name].execute( - { confirmed: false } as never, - { directory: "/repo", worktree: "/repo" } as never, - ); - expect(JSON.parse(raw as string).error).toBe("confirmed: true required"); - } }); test.skipIf(process.platform === "win32")( @@ -680,46 +585,6 @@ test.skipIf(process.platform === "win32")( }), ); -test.skipIf(process.platform === "win32")( - "log_time and post tools execute with confirmed and redact tokens from errors", - async () => - withYouTrackConfig(async () => { - const tools = createYouTrackTools({ - verifyToken: async () => ({}), - context: async () => ({}), - parseDuration: async () => ({ minutes: 30 }), - postComment: async () => ({ data: { ok: true } }), - logTime: async () => ({ error: "boom with secret" }), - }); - const ctx = { directory: "/repo", worktree: "/repo" } as never; - - const logged = JSON.parse( - (await tools.workit_youtrack_log_time.execute( - { - confirmed: true, - issueId: "NSR-1", - minutes: 30, - }, - ctx, - )) as string, - ); - expect(logged.ok).toBe(false); - expect(logged.error).toContain("boom"); - - const posted = JSON.parse( - (await tools.workit_youtrack_post.execute( - { - confirmed: true, - issueId: "NSR-1", - markdown: "Actualización", - }, - ctx, - )) as string, - ); - expect(posted.ok).toBe(true); - }), -); - test.skipIf(process.platform === "win32")( "context tool normalizes meetings mode and rejects escaped paths", async () => diff --git a/test/workit-opencode/bootstrap.test.ts b/test/workit-opencode/bootstrap.test.ts index 1a18cb75..996d77e1 100644 --- a/test/workit-opencode/bootstrap.test.ts +++ b/test/workit-opencode/bootstrap.test.ts @@ -30,6 +30,12 @@ describe("session bootstrap", () => { expect(bootstrap.toLowerCase()).not.toContain("policy.assess"); }); + test("bootstrap directs OpenCode mutations to native host tools", () => { + const bootstrap = getWorkitBootstrap() ?? ""; + expect(bootstrap).toContain("workit_context"); + expect(bootstrap).toContain("no managed external-action executor"); + }); + test("messages.transform injects bootstrap once on first user turn", async () => { const hooks = await plugin({ directory: "/repo", diff --git a/test/workit-opencode/dual-entry.test.ts b/test/workit-opencode/dual-entry.test.ts index cb1f3ed2..4385bf93 100644 --- a/test/workit-opencode/dual-entry.test.ts +++ b/test/workit-opencode/dual-entry.test.ts @@ -39,8 +39,9 @@ test("V1 behavior is unchanged through the dual entry", async () => { "workit_worker", "workit_writer", "workit_state", - "workit_external_action", + "workit_context", "workit_init_apply", ].sort(), ); + expect(hooks.tool).not.toHaveProperty("workit_external_action"); }); diff --git a/test/workit-opencode/plugin.test.ts b/test/workit-opencode/plugin.test.ts index 0af7adfd..4a841bdf 100644 --- a/test/workit-opencode/plugin.test.ts +++ b/test/workit-opencode/plugin.test.ts @@ -29,9 +29,10 @@ test("registers the eight native operation tools plus init_apply", async () => { const hooks = await plugin(pluginInput as never); expect(Object.keys(hooks.tool ?? {})).toEqual([ ...families.map((family) => `workit_${family}`), - "workit_external_action", + "workit_context", "workit_init_apply", ]); + expect(hooks.tool).not.toHaveProperty("workit_external_action"); }); test("config registers only the policy-selected method skills", async () => { diff --git a/test/workit-opencode/schema-discoverability.test.ts b/test/workit-opencode/schema-discoverability.test.ts index caf89316..fd6ff976 100644 --- a/test/workit-opencode/schema-discoverability.test.ts +++ b/test/workit-opencode/schema-discoverability.test.ts @@ -1,146 +1,41 @@ import { expect, test } from "bun:test"; import Ajv2020 from "ajv/dist/2020"; -import { - OPERATION_SCHEMA_MAX_DEPTH, - boundedOperationJsonSchema, - externalActionJsonSchema, - externalActionRequest, -} from "@/packages/workit-core/src/core"; import { WORKIT_TOOL_CATALOG } from "@/packages/workit-opencode/src/shared/tools"; import { createWorkitTools } from "@/packages/workit-opencode/src/tools/workit"; -test("V1 native argument schemas reject the same malformed payload shapes", () => { +test("V1 and V2 publish the same flat, read-only context arguments", () => { const tools = createWorkitTools() as any; - const payload = tools.workit_external_action.args.payload; - expect(payload.safeParse({ stash: false }).success).toBe(false); - expect(payload.safeParse({ stash: "no", target_branch: "feature/example" }).success).toBe(true); - expect(payload.safeParse({ plan_steps: [{ message: "not a supported step" }] }).success).toBe( - false, - ); - expect( - payload.safeParse({ - plan_steps: ["feat(example): change", { branch: "feature/next" }, { pr: true }], - }).success, - ).toBe(true); + const v1 = tools.workit_context.args; + const v2 = WORKIT_TOOL_CATALOG.find((tool) => tool.name === "workit_context")?.input; + expect(v2).toBeDefined(); + const args = { + kind: "affected", + range: "HEAD~1...HEAD", + operation: "git.commit", + payload: { message: "must not be available" }, + }; + expect(Object.keys(v1).sort()).toEqual(Object.keys((v2 as any).properties).sort()); + expect(tools).not.toHaveProperty("workit_external_action"); + const validate = new Ajv2020({ strict: false }).compile(v2 as any); + expect(validate({ kind: "affected", range: "HEAD~1...HEAD" })).toBe(true); + expect(validate(args)).toBe(false); + expect((v2 as any).required).toEqual(["kind"]); + expect((v2 as any).additionalProperties).toBe(false); }); -const record = (value: unknown): Record => value as Record; - -const maximumDepth = (value: unknown, depth = 0): number => { - if (Array.isArray(value)) - return Math.max(depth, ...value.map((child) => maximumDepth(child, depth))); - if (!value || typeof value !== "object") return depth; - const schema = record(value); - const containerDepth = schema.type === "object" || schema.type === "array" ? depth + 1 : depth; - const children = [ - ...(schema.properties && typeof schema.properties === "object" - ? Object.values(schema.properties as Record) - : []), - ...(schema.items ? [schema.items] : []), - ...(Array.isArray(schema.oneOf) ? schema.oneOf : []), - ...(Array.isArray(schema.anyOf) ? schema.anyOf : []), - ...(Array.isArray(schema.allOf) ? schema.allOf : []), - ]; - return Math.max(containerDepth, ...children.map((child) => maximumDepth(child, containerDepth))); -}; - -test("V2 external-action schema advertises the canonical operation payloads", () => { - const canonical = externalActionJsonSchema(); - const advertised = WORKIT_TOOL_CATALOG.find((tool) => tool.name === "workit_external_action"); - if (!advertised) throw new Error("V2 external-action tool is not registered"); - expect(advertised?.input).toEqual(canonical); - expect(advertised?.input.type).toBe("object"); - expect(maximumDepth(advertised?.input)).toBeLessThanOrEqual(OPERATION_SCHEMA_MAX_DEPTH); - - const variants = record(advertised?.input).oneOf as Record[]; - const branchSetup = variants.find( - (variant) => variant.properties.operation.const === "git.branch_setup", - ); - if (!branchSetup) throw new Error("branch_setup variant is missing"); - expect(branchSetup.properties.payload.properties.stash.enum).toEqual(["yes", "no"]); - - const commit = variants.find((variant) => variant.properties.operation.const === "git.commit"); - if (!commit) throw new Error("git.commit variant is missing"); - expect(commit.properties.payload.properties.plan_steps.items.anyOf).toEqual([ - expect.objectContaining({ type: "string" }), - expect.objectContaining({ - properties: { branch: expect.objectContaining({ type: "string" }) }, - }), - expect.objectContaining({ properties: { pr: expect.objectContaining({ const: true }) } }), +test("the context catalog exposes only read context families", () => { + const schema = WORKIT_TOOL_CATALOG.find((tool) => tool.name === "workit_context")?.input as any; + expect(schema.properties.kind.enum).toEqual([ + "git", + "pr", + "youtrack", + "github_issue", + "gitlab_issue", + "changelog", + "release", + "affected", ]); - expect(commit.additionalProperties).toBe(false); - expect(commit.properties.payload.additionalProperties).toBe(false); - - const validate = new Ajv2020({ strict: false }).compile(advertised.input as any); - const samples = [ - { - operation: "git.branch_setup", - payload: { action: "setup", target_branch: "feature/sample", stash: "no" }, - }, - { - operation: "git.branch_setup", - payload: { action: "setup", target_branch: "feature/sample", stash: "false" }, - }, - { - operation: "git.commit", - payload: { - plan_branch: "feature/sample", - plan_steps: ["feat(sample): commit", { branch: "feature/next" }, { pr: true }], - }, - }, - { - operation: "git.commit", - payload: { - plan_branch: "feature/sample", - plan_steps: [{ branch: "feature/next", pr: true }], - }, - }, - ]; - for (const sample of samples) expect(validate(sample)).toBe(externalActionRequest(sample).ok); -}); - -test("collapsed family schemas describe required fields and nested discriminators", () => { - const schema = boundedOperationJsonSchema("decision"); - const recordDecision = (schema.oneOf as Record[]).find( - (variant) => variant.properties.action.const === "record", - ); - if (!recordDecision) throw new Error("decision record variant is missing"); - const description = recordDecision.properties.binding.description as string; - expect(description).toContain("taskId!:str"); - expect(description).toContain('kind!:="file"'); - expect(recordDecision.properties.response.enum).toEqual(["approved", "rejected", "stated"]); - expect(description).toContain("Full nested value is validated."); -}); - -test("runtime external-action validation agrees with advertised enum and plan-step shapes", () => { - expect( - externalActionRequest({ - operation: "git.branch_setup", - payload: { action: "setup", target_branch: "feature/sample", stash: "no" }, - }).ok, - ).toBe(true); - expect( - externalActionRequest({ - operation: "git.branch_setup", - payload: { action: "setup", target_branch: "feature/sample", stash: "false" }, - }).ok, - ).toBe(false); - expect( - externalActionRequest({ - operation: "git.commit", - payload: { - plan_branch: "feature/sample", - plan_steps: ["feat(sample): commit", { branch: "feature/next" }, { pr: true }], - }, - }).ok, - ).toBe(true); - expect( - externalActionRequest({ - operation: "git.commit", - payload: { - plan_branch: "feature/sample", - plan_steps: [{ branch: "feature/next", pr: true }], - }, - }).ok, - ).toBe(false); + expect(schema.properties.cwd.type).toBe("string"); + expect(schema.properties).not.toHaveProperty("operation"); + expect(schema.properties).not.toHaveProperty("payload"); }); diff --git a/test/workit-opencode/smoke.ts b/test/workit-opencode/smoke.ts index 52467f84..adfe1e95 100644 --- a/test/workit-opencode/smoke.ts +++ b/test/workit-opencode/smoke.ts @@ -53,7 +53,7 @@ test("OpenCode plugin registers native tools without Cursor assets", async () => path.resolve(import.meta.dir, "../../packages/workit-opencode/assets/skills"), ]); expect(Object.keys(hooks.tool ?? {})).toHaveLength(10); - expect(Object.keys(hooks.tool ?? {})).toContain("workit_external_action"); + expect(Object.keys(hooks.tool ?? {})).toContain("workit_context"); expect(Object.keys(hooks.tool ?? {})).toContain("workit_init_apply"); } finally { rmSync(root, { recursive: true, force: true }); diff --git a/test/workit-opencode/task-hooks.test.ts b/test/workit-opencode/task-hooks.test.ts index c0fbe4a2..d35316a4 100644 --- a/test/workit-opencode/task-hooks.test.ts +++ b/test/workit-opencode/task-hooks.test.ts @@ -472,8 +472,8 @@ test("OpenCode affected-doc context gates an edit and public evidence captures t spawnSync("git", ["add", source], { cwd: root }); spawnSync("git", ["commit", "-qm", "source change"], { cwd: root }); - const contextResult = await (createWorkitTools() as any).workit_external_action.execute( - { operation: "context.read", payload: { kind: "affected", range: "HEAD~1...HEAD" } }, + const contextResult = await (createWorkitTools() as any).workit_context.execute( + { kind: "affected", range: "HEAD~1...HEAD" }, { directory: root, sessionID: "owner" }, ); const affected = JSON.parse( diff --git a/test/workit-opencode/task-tools.test.ts b/test/workit-opencode/task-tools.test.ts index 2993d700..31493c0a 100644 --- a/test/workit-opencode/task-tools.test.ts +++ b/test/workit-opencode/task-tools.test.ts @@ -1,26 +1,10 @@ import { expect, test } from "bun:test"; -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - renameSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { - TaskStore, - WorkitCore, - externalActionDescriptor, - planCommitDescriptor, -} from "@/packages/workit-core/src/core"; +import { TaskStore, WorkitCore } from "@/packages/workit-core/src/core"; import { scope, taskStartRequest } from "@/test/workit-core/task-fixtures"; -import { resolveExternalActionRequest } from "@/packages/workit-core/src/core/external-action-effects"; import { server as plugin } from "@/packages/workit-opencode/src/index"; import { NativeReceiptStore, @@ -29,60 +13,38 @@ import { } from "@/packages/workit-opencode/src/tools/workit"; import { tool } from "@opencode-ai/plugin"; -const context = { - directory: "/repo", - worktree: "/repo", - serverUrl: new URL("http://localhost"), -}; - -const fakeClock = (initial: string) => { - const RealDate = globalThis.Date; - let current = initial; - class FakeDate extends RealDate { - constructor(...args: any[]) { - super(args.length ? args[0] : current); - } - static now(): number { - return RealDate.parse(current); - } - } - globalThis.Date = FakeDate as unknown as DateConstructor; - return { - set(value: string) { - current = value; - }, - restore() { - globalThis.Date = RealDate; - }, - }; +const context = { directory: "/repo", worktree: "/repo", serverUrl: new URL("http://localhost") }; +const workitQuestion = (question: string, approvedContent: string) => ({ + header: "Workit decision: design", + question, + options: [ + { label: "approved", description: approvedContent }, + { label: "rejected", description: "Reject this decision" }, + ], +}); +const schemaDepth = (value: unknown, depth = 0): number => { + if (Array.isArray(value)) + return Math.max(depth, ...value.map((item) => schemaDepth(item, depth))); + if (typeof value !== "object" || value === null) return depth; + return Math.max(depth, ...Object.values(value).map((item) => schemaDepth(item, depth + 1))); }; -const createChangelogActionFixture = (actor: string, initial?: Uint8Array) => { - const root = mkdtempSync(join(tmpdir(), `workit-opencode-${actor}-`)); - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "initial.txt"), "initial\n"); - if (initial !== undefined) writeFileSync(join(root, "CHANGELOG.md"), initial); - spawnSync("git", ["add", "-A"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "fixture"], { cwd: root }); +const decisionFixture = (actor: string) => { + const root = mkdtempSync(join(tmpdir(), `workit-opencode-decision-${actor}-`)); const store = new TaskStore(root); - const setupCore = new WorkitCore(store, { + const core = new WorkitCore(store, { root, caller: { host: "opencode", actor }, capabilities: [], constraints: [], now: "2026-01-01T00:00:00Z", }); - const started = setupCore.task(taskStartRequest()); + const started = core.task(taskStartRequest()); if (!started.ok) throw new Error(started.error); const task = store.readTask((started.data as { id: string }).id); const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - const writer = setupCore.writer({ + if (!task.ok || !workspace.ok || !workspace.data) throw new Error("decision fixture failed"); + const writer = core.writer({ schemaVersion: 1, action: "acquire", taskId: task.data.id, @@ -91,9 +53,9 @@ const createChangelogActionFixture = (actor: string, initial?: Uint8Array) => { workerId: null, }); if (!writer.ok) throw new Error(writer.error); - const decisionTask = store.readTask(task.data.id); - const decisionWorkspace = store.readWorkspace(); - if (!decisionTask.ok || !decisionWorkspace.ok || !decisionWorkspace.data) + const currentTask = store.readTask(task.data.id); + const currentWorkspace = store.readWorkspace(); + if (!currentTask.ok || !currentWorkspace.ok || !currentWorkspace.data) throw new Error("writer refresh failed"); const receipts = new NativeReceiptStore(); const tools = createWorkitTools({ @@ -102,180 +64,15 @@ const createChangelogActionFixture = (actor: string, initial?: Uint8Array) => { }) as any; return { root, - store, actor, - task: decisionTask.data, - workspace: decisionWorkspace.data, + task: currentTask.data, + workspace: currentWorkspace.data, receipts, tools, + cleanup: () => rmSync(root, { recursive: true, force: true }), }; }; -const approveActionFor = async (options: { - root: string; - actor: string; - task: { id: string; revision: string; intent: { data: { scope: unknown } } }; - workspace: { id: string }; - request: Record; - callID: string; -}) => { - const receipts = new NativeReceiptStore(); - const tools = createWorkitTools({ - receipts, - client: { - session: { get: async () => ({ data: { id: options.actor, directory: options.root } }) }, - }, - }) as any; - const first = await tools.workit_external_action.execute(options.request, { - directory: options.root, - sessionID: options.actor, - }); - const parsed = JSON.parse( - typeof first === "string" ? first : (first as { output: string }).output, - ); - const proposal = parsed?.details?.proposal; - if (!proposal) throw new Error(`expected an action proposal: ${JSON.stringify(parsed)}`); - receipts.record( - { - sessionID: options.actor, - callID: options.callID, - args: { - questions: [ - { - header: "Workit decision: action", - question: proposal.presented, - options: [ - { label: "approved", description: proposal.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { metadata: { answers: [["approved"]] } }, - ); - const decision = await tools.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: options.task.id, - expectedRevision: options.task.revision, - purpose: "action", - binding: { - taskId: options.task.id, - workspaceId: options.workspace.id, - scope: options.task.intent.data.scope, - presented: proposal.presented, - approvedContent: proposal.approvedContent, - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }, - { directory: options.root, sessionID: options.actor }, - ); - return { receipts, tools, decision, proposal }; -}; - -const approveChangelogAction = async ( - fixture: ReturnType, - request: { operation: "changelog.apply"; payload: Record }, -) => - ( - await approveActionFor({ - root: fixture.root, - actor: fixture.actor, - task: fixture.task, - workspace: fixture.workspace, - request, - callID: `changelog-${fixture.actor}`, - }) - ).decision; - -test("failed action decision persistence preserves its receipt and proposal for retry", async () => { - const fixture = createChangelogActionFixture( - "decision-retry", - new TextEncoder().encode("# Log\n"), - ); - try { - const request = { - operation: "changelog.apply" as const, - payload: { entries: [{ category: "Fixed", text: "Retry safely" }] }, - }; - const first = await fixture.tools.workit_external_action.execute(request, { - directory: fixture.root, - sessionID: fixture.actor, - }); - const proposal = JSON.parse(first as string).details.proposal; - fixture.receipts.record( - { - sessionID: fixture.actor, - callID: "decision-retry", - args: { questions: [workitQuestion(proposal.presented, proposal.approvedContent)] }, - }, - { metadata: { answers: [["approved"]] } }, - ); - const record = { - schemaVersion: 1, - action: "record", - taskId: fixture.task.id, - purpose: "action", - binding: { - taskId: fixture.task.id, - workspaceId: fixture.workspace.id, - scope: fixture.task.intent.data.scope, - presented: proposal.presented, - approvedContent: proposal.approvedContent, - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }; - const failed = JSON.parse( - await fixture.tools.workit_decision.execute( - { ...record, expectedRevision: "00000000-0000-4000-8000-000000000000" }, - { directory: fixture.root, sessionID: fixture.actor }, - ), - ); - expect(failed).toMatchObject({ ok: false, code: "revision_conflict" }); - expect( - JSON.parse( - await fixture.tools.workit_decision.execute(record, { - directory: fixture.root, - sessionID: fixture.actor, - }), - ).ok, - ).toBe(true); - } finally { - rmSync(fixture.root, { recursive: true, force: true }); - } -}); - -test("OpenCode exposes the eight shared families plus init_apply", async () => { - const hooks = await plugin(context as never); - expect(Object.keys(hooks.tool ?? {})).toEqual([ - "workit_task", - "workit_policy", - "workit_evidence", - "workit_finding", - "workit_decision", - "workit_worker", - "workit_writer", - "workit_state", - "workit_external_action", - "workit_init_apply", - ]); -}); - -const workitQuestion = (question: string, approvedContent: string) => ({ - header: "Workit decision: action", - question, - options: [ - { label: "approved", description: approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], -}); - test("out-of-band question replies mint consumable decision receipts", () => { const receipts = new NativeReceiptStore(); observeQuestionEvent(receipts, { @@ -385,21 +182,6 @@ test("plugin question events never break event delivery", async () => { } as never); }); -test("OpenCode invokes the host-owned documentation context headlessly through the shared effect", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-action-")); - try { - const tools = createWorkitTools(); - const result = await tools.workit_external_action.execute( - { operation: "context.read", payload: { kind: "changelog" } }, - { directory: process.cwd(), sessionID: "opencode-action-test" } as never, - ); - const serialized = typeof result === "string" ? result : (result as { output: string }).output; - expect(JSON.parse(serialized)).toMatchObject({ schemaVersion: 1, ok: true }); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - test("OpenCode context.read returns Git context without approval or Workit writes", async () => { const root = mkdtempSync(join(tmpdir(), "workit-opencode-context-read-")); try { @@ -417,8 +199,8 @@ test("OpenCode context.read returns Git context without approval or Workit write encoding: "utf8", }).stdout; const tools = createWorkitTools(); - const result = await (tools as any).workit_external_action.execute( - { operation: "context.read", payload: { kind: "git" } }, + const result = await (tools as any).workit_context.execute( + { kind: "git" }, { directory: root, sessionID: "context-read" }, ); const value = JSON.parse(typeof result === "string" ? result : result.output); @@ -455,8 +237,8 @@ test("OpenCode context.read release includes a deterministic draft without writi encoding: "utf8", }).stdout; const tools = createWorkitTools() as any; - const result = await tools.workit_external_action.execute( - { operation: "context.read", payload: { kind: "release", range: "HEAD~1...HEAD" } }, + const result = await tools.workit_context.execute( + { kind: "release", range: "HEAD~1...HEAD" }, { directory: root, sessionID: "release-context" }, ); const value = JSON.parse(typeof result === "string" ? result : result.output); @@ -487,8 +269,8 @@ test("OpenCode context.read rejects option-like ranges before invoking Git", asy spawnSync("git", ["add", "fixture.txt"], { cwd: root }); spawnSync("git", ["commit", "-qm", "fixture"], { cwd: root }); const tools = createWorkitTools() as any; - const result = await tools.workit_external_action.execute( - { operation: "context.read", payload: { kind: "changelog", range: `--output=${injected}` } }, + const result = await tools.workit_context.execute( + { kind: "changelog", range: `--output=${injected}` }, { directory: root, sessionID: "context-range" }, ); expect(JSON.parse(typeof result === "string" ? result : result.output)).toMatchObject({ @@ -506,8 +288,8 @@ test("OpenCode context.read reports Git capability failure for a non-repository" const root = mkdtempSync(join(tmpdir(), "workit-opencode-context-nonrepo-")); try { const tools = createWorkitTools() as any; - const result = await tools.workit_external_action.execute( - { operation: "context.read", payload: { kind: "git" } }, + const result = await tools.workit_context.execute( + { kind: "git" }, { directory: root, sessionID: "context-nonrepo" }, ); expect(JSON.parse(typeof result === "string" ? result : result.output)).toMatchObject({ @@ -520,6 +302,40 @@ test("OpenCode context.read reports Git capability failure for a non-repository" } }); +test("context rejects mutation-shaped arguments without Git or Workit effects", async () => { + const root = mkdtempSync(join(tmpdir(), "workit-opencode-context-mutation-")); + try { + for (const args of [ + ["init", "-q"], + ["config", "user.email", "test@example.invalid"], + ["config", "user.name", "Workit Test"], + ]) + spawnSync("git", args, { cwd: root }); + writeFileSync(join(root, "fixture.txt"), "fixture\n"); + spawnSync("git", ["add", "fixture.txt"], { cwd: root }); + spawnSync("git", ["commit", "-qm", "fixture"], { cwd: root }); + const before = spawnSync("git", ["status", "--porcelain=v1"], { + cwd: root, + encoding: "utf8", + }).stdout; + const tools = createWorkitTools() as any; + const result = await tools.workit_context.execute( + { kind: "git", operation: "git.commit", payload: { message: "must-not-run" } }, + { directory: root, sessionID: "context-mutation" }, + ); + expect(JSON.parse(typeof result === "string" ? result : result.output)).toMatchObject({ + ok: false, + code: "invalid_input", + }); + expect( + spawnSync("git", ["status", "--porcelain=v1"], { cwd: root, encoding: "utf8" }).stdout, + ).toBe(before); + expect(existsSync(join(root, ".workit"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("OpenCode YouTrack context reads a fixed file without migration or secret fields", async () => { const root = mkdtempSync(join(tmpdir(), "workit-opencode-context-youtrack-")); const configHome = mkdtempSync(join(tmpdir(), "workit-opencode-config-")); @@ -545,8 +361,8 @@ test("OpenCode YouTrack context reads a fixed file without migration or secret f }), ); const tools = createWorkitTools() as any; - const result = await tools.workit_external_action.execute( - { operation: "context.read", payload: { kind: "youtrack", mode: "meetings" } }, + const result = await tools.workit_context.execute( + { kind: "youtrack", mode: "meetings" }, { directory: root, sessionID: "context-youtrack" }, ); const value = JSON.parse(typeof result === "string" ? result : result.output); @@ -587,8 +403,8 @@ test("OpenCode YouTrack context rejects spec and plan paths outside the workspac process.env.WORKFLOW_YOUTRACK_CONFIG = configPath; const tools = createWorkitTools() as any; for (const specPath of [outsideSpec, "linked-spec.md"]) { - const result = await tools.workit_external_action.execute( - { operation: "context.read", payload: { kind: "youtrack", specPath } }, + const result = await tools.workit_context.execute( + { kind: "youtrack", specPath }, { directory: root, sessionID: "context-youtrack-boundary" }, ); expect(JSON.parse(typeof result === "string" ? result : result.output)).toMatchObject({ @@ -604,2427 +420,196 @@ test("OpenCode YouTrack context rejects spec and plan paths outside the workspac } }); -test("OpenCode changelog.apply uses the approved target and existing writer", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-changelog-apply-")); - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "CHANGELOG.md"), "# Changelog\n\n## [Unreleased]\n\n"); - spawnSync("git", ["add", "CHANGELOG.md"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "fixture"], { cwd: root }); - const actor = "opencode-changelog-action"; - const store = new TaskStore(root); - const setupCore = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = setupCore.task(taskStartRequest()); - expect(started.ok).toBe(true); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - expect( - setupCore.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const decisionTask = store.readTask(task.data.id); - const decisionWorkspace = store.readWorkspace(); - if (!decisionTask.ok || !decisionWorkspace.ok || !decisionWorkspace.data) - throw new Error("writer refresh failed"); - const request = { - operation: "changelog.apply" as const, - payload: { - path: "CHANGELOG.md", - entries: [{ category: "Added", text: "Native changelog action" }], - }, - }; - const resolved = resolveExternalActionRequest(root, request); - if (!resolved.ok) throw new Error(resolved.error); - const { tools, decision } = await approveActionFor({ - root, - actor, - task: decisionTask.data, - workspace: decisionWorkspace.data, - request, - callID: "changelog-question", - }); - expect(JSON.parse(typeof decision === "string" ? decision : decision.output)).toMatchObject({ - ok: true, - }); - const result = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - expect(JSON.parse(typeof result === "string" ? result : result.output)).toMatchObject({ - ok: true, +test("advertised native operation schemas stay within OpenCode provider depth limits", async () => { + const hooks = await plugin(context as never); + for (const [name, definition] of Object.entries(hooks.tool ?? {})) { + if (name === "workit_context") continue; + const schema = tool.schema.toJSONSchema(tool.schema.object(definition.args), { + target: "draft-2020-12", }); - expect(readFileSync(join(root, "CHANGELOG.md"), "utf8")).toContain("Native changelog action"); - } finally { - rmSync(root, { recursive: true, force: true }); + expect(schemaDepth(schema), name).toBeLessThanOrEqual(10); + // Host-owned init tool: same depth budget, different envelope from the + // eight schemaVersion/action operation families. + if (name === "workit_init_apply") continue; + const properties = (schema as { properties?: Record }).properties ?? {}; + expect(properties.schemaVersion, name).toBeDefined(); + expect(properties.action, name).toBeDefined(); } }); -test("OpenCode changelog.apply refuses approval-time file drift without writing", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-changelog-drift-")); - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - const changelog = join(root, "CHANGELOG.md"); - writeFileSync(changelog, "# Changelog\n\n## [Unreleased]\n\n"); - spawnSync("git", ["add", "CHANGELOG.md"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "fixture"], { cwd: root }); - const actor = "opencode-changelog-drift"; - const store = new TaskStore(root); - const setupCore = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = setupCore.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - expect( - setupCore.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const decisionTask = store.readTask(task.data.id); - const decisionWorkspace = store.readWorkspace(); - if (!decisionTask.ok || !decisionWorkspace.ok || !decisionWorkspace.data) - throw new Error("writer refresh failed"); - const request = { - operation: "changelog.apply" as const, - payload: { - path: "CHANGELOG.md", - entries: [{ category: "Added", text: "Must not apply after drift" }], - }, - }; - const resolved = resolveExternalActionRequest(root, request); - if (!resolved.ok) throw new Error(resolved.error); - const { tools, decision } = await approveActionFor({ - root, - actor, - task: decisionTask.data, - workspace: decisionWorkspace.data, - request, - callID: "changelog-drift-question", - }); - expect(JSON.parse(typeof decision === "string" ? decision : decision.output)).toMatchObject({ - ok: true, - }); - const before = readFileSync(changelog, "utf8"); - writeFileSync(changelog, `${before}outside approval drift\n`); - const result = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - expect(JSON.parse(typeof result === "string" ? result : result.output)).toMatchObject({ - ok: false, - code: "needs_input", - }); - expect(readFileSync(changelog, "utf8")).toBe(`${before}outside approval drift\n`); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); +test("native receipts reject unrelated questions and are consumed once per purpose", () => { + const receipts = new NativeReceiptStore(); + receipts.record( + { + sessionID: "s", + callID: "unrelated", + args: { questions: [{ question: "Which color?", options: ["blue"] }] }, + }, + { metadata: { answers: [["blue"]] } }, + ); + expect(receipts.consume("s", "decision").ok).toBe(false); -test("OpenCode changelog.apply binds missing-file state separately from the skeleton bytes", async () => { - const fixture = createChangelogActionFixture("opencode-changelog-missing"); - try { - const request = { - operation: "changelog.apply" as const, - payload: { - path: "CHANGELOG.md", - entries: [{ category: "Added", text: "Must not replace a newly-created file" }], + receipts.record( + { + sessionID: "s", + callID: "decision", + args: { + questions: [ + { + header: "Workit decision: design", + question: "Approve this decision?", + options: [ + { label: "approved", description: "Design" }, + { label: "rejected", description: "Reject this decision" }, + ], + }, + ], }, - }; - const approval = await approveChangelogAction(fixture, request); - expect(JSON.parse(typeof approval === "string" ? approval : approval.output)).toMatchObject({ - ok: true, - }); - const skeleton = - "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n"; - writeFileSync(join(fixture.root, "CHANGELOG.md"), skeleton); - const result = await fixture.tools.workit_external_action.execute(request, { - directory: fixture.root, - sessionID: fixture.actor, - }); - expect(JSON.parse(typeof result === "string" ? result : result.output)).toMatchObject({ - ok: false, - code: "needs_input", - }); - expect(readFileSync(join(fixture.root, "CHANGELOG.md"), "utf8")).toBe(skeleton); - } finally { - rmSync(fixture.root, { recursive: true, force: true }); - } -}); - -test("OpenCode changelog.apply rejects invalid UTF-8 drift before writing", async () => { - const fixture = createChangelogActionFixture( - "opencode-changelog-invalid", - Buffer.from("# Changelog\n\n## [Unreleased]\n\n", "utf8"), + }, + { metadata: { answers: [["approved"]] } }, ); - try { - const request = { - operation: "changelog.apply" as const, - payload: { - path: "CHANGELOG.md", - entries: [{ category: "Added", text: "Must not overwrite binary drift" }], - }, - }; - const approval = await approveChangelogAction(fixture, request); - expect(JSON.parse(typeof approval === "string" ? approval : approval.output)).toMatchObject({ - ok: true, - }); - const invalid = Buffer.from([0xff, 0xfe, 0xfd]); - writeFileSync(join(fixture.root, "CHANGELOG.md"), invalid); - const result = await fixture.tools.workit_external_action.execute(request, { - directory: fixture.root, - sessionID: fixture.actor, - }); - expect(JSON.parse(typeof result === "string" ? result : result.output)).toMatchObject({ - ok: false, - code: "invalid_input", - }); - expect(readFileSync(join(fixture.root, "CHANGELOG.md"))).toEqual(invalid); - } finally { - rmSync(fixture.root, { recursive: true, force: true }); - } + expect(receipts.consume("s", "decision").ok).toBe(true); + expect(receipts.consume("s", "decision").ok).toBe(false); }); -test("OpenCode action route consumes the exact native receipt before committing", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-native-action-")); - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "initial.txt"), "initial\n"); - spawnSync("git", ["add", "initial.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "initial"], { cwd: root }); - writeFileSync(join(root, "change.txt"), "change\n"); - spawnSync("git", ["add", "change.txt"], { cwd: root }); - const actor = "opencode-action-session"; - const store = new TaskStore(root); - const core = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = core.task(taskStartRequest()); - expect(started.ok).toBe(true); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - expect( - core.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const decisionTask = store.readTask(task.data.id); - const decisionWorkspace = store.readWorkspace(); - if (!decisionTask.ok || !decisionWorkspace.ok || !decisionWorkspace.data) - throw new Error("writer refresh failed"); - const resolved = resolveExternalActionRequest(root, { - operation: "git.commit", - payload: { message: "chore(test): native commit" }, - }); - if (!resolved.ok) throw new Error(resolved.error); - const { tools, decision } = await approveActionFor({ - root, - actor, - task: decisionTask.data, - workspace: decisionWorkspace.data, - request: { operation: "git.commit", payload: { message: "chore(test): native commit" } }, - callID: "action-question", - }); - const nativeTools = tools as any; - expect( - JSON.parse(typeof decision === "string" ? decision : (decision as { output: string }).output), - ).toMatchObject({ ok: true }); - const action = await nativeTools.workit_external_action.execute( - { operation: "git.commit", payload: { message: "chore(test): native commit" } }, - { directory: root, sessionID: actor } as never, - ); - expect( - JSON.parse(typeof action === "string" ? action : (action as { output: string }).output), - ).toMatchObject({ ok: true }); - expect( - spawnSync("git", ["log", "-1", "--pretty=%s"], { cwd: root, encoding: "utf8" }).stdout.trim(), - ).toBe("chore(test): native commit"); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("OpenCode executes a plan-commit list once per listed message", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-plan-commits-")); - try { - for (const args of [ - ["init", "-q", "-b", "feature/plan"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "base.txt"), "base\n"); - spawnSync("git", ["add", "base.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "base"], { cwd: root }); - const actor = "opencode-plan-session"; - const store = new TaskStore(root); - const core = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = core.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - expect( - core.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const decisionTask = store.readTask(task.data.id); - const decisionWorkspace = store.readWorkspace(); - if (!decisionTask.ok || !decisionWorkspace.ok || !decisionWorkspace.data) - throw new Error("writer refresh failed"); - - const planRequest = { - operation: "git.commit" as const, - payload: { - plan_steps: ["chore(a): one", "chore(b): two"], - plan_branch: "feature/plan", - }, - }; - const { tools } = await approveActionFor({ - root, - actor, - task: decisionTask.data, - workspace: decisionWorkspace.data, - request: planRequest, - callID: "plan-commits-question", - }); - const recorded = store.readTask(task.data.id); - if (!recorded.ok) throw new Error("recorded task unavailable"); - const planEntry = recorded.data.decisions.find((item) => item.data.purpose === "action"); - expect( - planEntry ? planCommitDescriptor(planEntry.data.binding.approvedContent) : null, - ).toMatchObject({ - steps: [ - { kind: "commit", message: "chore(a): one" }, - { kind: "commit", message: "chore(b): two" }, - ], - branch: "feature/plan", - }); - - spawnSync("git", ["checkout", "-q", "-b", "feature/other"], { cwd: root }); - writeFileSync(join(root, "wrong-branch.txt"), "wrong\n"); - spawnSync("git", ["add", "wrong-branch.txt"], { cwd: root }); - const wrongBranch = await tools.workit_external_action.execute( - { operation: "git.commit", payload: { message: "chore(a): one" } }, - { directory: root, sessionID: actor }, - ); - expect( - JSON.parse(typeof wrongBranch === "string" ? wrongBranch : wrongBranch.output), - ).toMatchObject({ ok: false, code: "needs_input" }); - spawnSync("git", ["checkout", "-q", "feature/plan"], { cwd: root }); - - writeFileSync(join(root, "one.txt"), "one\n"); - spawnSync("git", ["add", "one.txt"], { cwd: root }); - const first = await tools.workit_external_action.execute( - { operation: "git.commit", payload: { message: "chore(a): one" } }, - { directory: root, sessionID: actor }, - ); - expect(JSON.parse(typeof first === "string" ? first : first.output)).toMatchObject({ - ok: true, - }); - - writeFileSync(join(root, "two.txt"), "two\n"); - spawnSync("git", ["add", "two.txt"], { cwd: root }); - const second = await tools.workit_external_action.execute( - { operation: "git.commit", payload: { message: "chore(b): two" } }, - { directory: root, sessionID: actor }, - ); - expect(JSON.parse(typeof second === "string" ? second : second.output)).toMatchObject({ - ok: true, - }); - const subjects = spawnSync("git", ["log", "--format=%s", "-2"], { - cwd: root, - encoding: "utf8", - }) - .stdout.trim() - .split("\n"); - expect(subjects).toEqual(["chore(b): two", "chore(a): one"]); - - writeFileSync(join(root, "three.txt"), "three\n"); - spawnSync("git", ["add", "three.txt"], { cwd: root }); - const unlisted = await tools.workit_external_action.execute( - { operation: "git.commit", payload: { message: "chore(c): three" } }, - { directory: root, sessionID: actor }, - ); - expect(JSON.parse(typeof unlisted === "string" ? unlisted : unlisted.output)).toMatchObject({ - ok: false, - code: "needs_input", - }); - const replay = await tools.workit_external_action.execute( - { operation: "git.commit", payload: { message: "chore(a): one" } }, - { directory: root, sessionID: actor }, - ); - expect(JSON.parse(typeof replay === "string" ? replay : replay.output)).toMatchObject({ - ok: false, - code: "needs_input", - }); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("identical concurrent resolves share one open proposal", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-ambiguous-")); - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "base.txt"), "base\n"); - spawnSync("git", ["add", "base.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "base"], { cwd: root }); - writeFileSync(join(root, "change.txt"), "change\n"); - spawnSync("git", ["add", "change.txt"], { cwd: root }); - const actor = "opencode-ambiguous-session"; - const store = new TaskStore(root); - const core = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = core.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - expect( - core.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const receipts = new NativeReceiptStore(); - const tools = createWorkitTools({ - receipts, - client: { session: { get: async () => ({ data: { id: actor, directory: root } }) } }, - }) as any; - const request = { operation: "git.commit", payload: { message: "chore(test): ambiguous" } }; - const firstCall = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - const secondCall = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - const firstProposal = JSON.parse( - typeof firstCall === "string" ? firstCall : (firstCall as { output: string }).output, - ).details.proposal; - const secondProposal = JSON.parse( - typeof secondCall === "string" ? secondCall : (secondCall as { output: string }).output, - ).details.proposal; - expect(secondProposal.descriptorDigest).toBe(firstProposal.descriptorDigest); - const proposal = firstProposal; - receipts.record( - { - sessionID: actor, - callID: "ambiguous-question", - args: { - questions: [ - { - header: "Workit decision: action", - question: proposal.presented, - options: [ - { label: "approved", description: proposal.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { metadata: { answers: [["approved"]] } }, - ); - const fresh = store.readTask(task.data.id); - if (!fresh.ok) throw new Error("task refresh failed"); - const decision = await tools.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: task.data.id, - expectedRevision: fresh.data.revision, - purpose: "action", - binding: { - taskId: task.data.id, - workspaceId: workspace.data.id, - scope: task.data.intent.data.scope, - presented: proposal.presented, - approvedContent: proposal.approvedContent, - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }, - { directory: root, sessionID: actor } as never, - ); - expect( - JSON.parse(typeof decision === "string" ? decision : (decision as { output: string }).output), - ).toMatchObject({ ok: true }); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("OpenCode requires writer ownership before local actions", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-writer-prereq-")); - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "base.txt"), "base\n"); - spawnSync("git", ["add", "base.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "base"], { cwd: root }); - writeFileSync(join(root, "change.txt"), "change\n"); - spawnSync("git", ["add", "change.txt"], { cwd: root }); - const actor = "opencode-writer-prereq"; - const store = new TaskStore(root); - const core = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = core.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const tools = createWorkitTools({ - receipts: new NativeReceiptStore(), - client: { session: { get: async () => ({ data: { id: actor, directory: root } }) } }, - }) as any; - const result = await tools.workit_external_action.execute( - { operation: "git.commit", payload: { message: "chore(test): needs writer" } }, - { directory: root, sessionID: actor }, - ); - const parsed = JSON.parse(typeof result === "string" ? result : result.output); - expect(parsed).toMatchObject({ ok: false, code: "needs_input" }); - expect(String(parsed.error)).toContain("writer ownership"); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("a later push of the same branch resolves as a new action", async () => { - const fixture = createChangelogActionFixture("repeat-push"); - const remote = mkdtempSync(join(tmpdir(), "workit-repeat-push-remote-")); - try { - spawnSync("git", ["init", "--bare", "-q"], { cwd: remote }); - spawnSync("git", ["checkout", "-q", "-b", "feature/repeat-push"], { cwd: fixture.root }); - spawnSync("git", ["remote", "add", "origin", `file://${remote}`], { cwd: fixture.root }); - const request = { operation: "git.push" as const, payload: { branch: "feature/repeat-push" } }; - await approveActionFor({ - root: fixture.root, - actor: fixture.actor, - task: fixture.task, - workspace: fixture.workspace, - request, - callID: "repeat-push", - }); - expect( - JSON.parse( - await fixture.tools.workit_external_action.execute(request, { - directory: fixture.root, - sessionID: fixture.actor, - }), - ), - ).toMatchObject({ ok: true }); - writeFileSync(join(fixture.root, "later.txt"), "later\n"); - spawnSync("git", ["add", "later.txt"], { cwd: fixture.root }); - spawnSync("git", ["commit", "-qm", "later"], { cwd: fixture.root }); - const later = JSON.parse( - await fixture.tools.workit_external_action.execute(request, { - directory: fixture.root, - sessionID: fixture.actor, - }), - ); - expect(later).toMatchObject({ ok: false, code: "needs_input" }); - expect(later.error).not.toContain("already settled"); - } finally { - rmSync(fixture.root, { recursive: true, force: true }); - rmSync(remote, { recursive: true, force: true }); - } -}, 15_000); - -test("aged action proposals with unchanged state still bind", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-proposal-expiry-")); - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "base.txt"), "base\n"); - spawnSync("git", ["add", "base.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "base"], { cwd: root }); - writeFileSync(join(root, "change.txt"), "change\n"); - spawnSync("git", ["add", "change.txt"], { cwd: root }); - const actor = "opencode-proposal-expiry"; - let nowMs = Date.parse("2026-01-01T00:00:00Z"); - const store = new TaskStore(root); - const core = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = core.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - expect( - core.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const receipts = new NativeReceiptStore({ now: () => nowMs }); - const tools = createWorkitTools({ - receipts, - client: { session: { get: async () => ({ data: { id: actor, directory: root } }) } }, - }) as any; - const request = { operation: "git.commit", payload: { message: "chore(test): expired" } }; - const first = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - const proposal = JSON.parse( - typeof first === "string" ? first : (first as { output: string }).output, - ).details.proposal; - nowMs += 6 * 60 * 1000; - receipts.record( - { - sessionID: actor, - callID: "expired-question", - args: { - questions: [ - { - header: "Workit decision: action", - question: proposal.presented, - options: [ - { label: "approved", description: proposal.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { metadata: { answers: [["approved"]] } }, - ); - const fresh = store.readTask(task.data.id); - if (!fresh.ok) throw new Error("task refresh failed"); - const decision = await tools.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: task.data.id, - expectedRevision: fresh.data.revision, - purpose: "action", - binding: { - taskId: task.data.id, - workspaceId: workspace.data.id, - scope: task.data.intent.data.scope, - presented: proposal.presented, - approvedContent: proposal.approvedContent, - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }, - { directory: root, sessionID: actor } as never, - ); - const parsed = JSON.parse( - typeof decision === "string" ? decision : (decision as { output: string }).output, - ); - expect(parsed).toMatchObject({ ok: true }); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("drifted repository state rejects an aged approval", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-proposal-drift-")); - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "base.txt"), "base\n"); - spawnSync("git", ["add", "base.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "base"], { cwd: root }); - writeFileSync(join(root, "change.txt"), "change\n"); - spawnSync("git", ["add", "change.txt"], { cwd: root }); - const actor = "opencode-proposal-drift"; - let nowMs = Date.parse("2026-01-01T00:00:00Z"); - const store = new TaskStore(root); - const core = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = core.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - expect( - core.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const receipts = new NativeReceiptStore({ now: () => nowMs }); - const tools = createWorkitTools({ - receipts, - client: { session: { get: async () => ({ data: { id: actor, directory: root } }) } }, - }) as any; - const request = { operation: "git.commit", payload: { message: "chore(test): drift" } }; - const first = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - const proposal = JSON.parse( - typeof first === "string" ? first : (first as { output: string }).output, - ).details.proposal; - nowMs += 6 * 60 * 1000; - writeFileSync(join(root, "other.txt"), "other\n"); - spawnSync("git", ["add", "other.txt"], { cwd: root }); - receipts.record( - { - sessionID: actor, - callID: "drift-question", - args: { - questions: [ - { - header: "Workit decision: action", - question: proposal.presented, - options: [ - { label: "approved", description: proposal.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { metadata: { answers: [["approved"]] } }, - ); - const fresh = store.readTask(task.data.id); - if (!fresh.ok) throw new Error("task refresh failed"); - const decision = await tools.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: task.data.id, - expectedRevision: fresh.data.revision, - purpose: "action", - binding: { - taskId: task.data.id, - workspaceId: workspace.data.id, - scope: task.data.intent.data.scope, - presented: proposal.presented, - approvedContent: proposal.approvedContent, - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }, - { directory: root, sessionID: actor } as never, - ); - const drifted = JSON.parse( - typeof decision === "string" ? decision : (decision as { output: string }).output, - ); - expect(drifted).toMatchObject({ ok: false, code: "invalid_input" }); - expect(String(drifted.error)).toContain("changed"); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("concise action approval without a live proposal fails closed", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-no-proposal-")); - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "base.txt"), "base\n"); - spawnSync("git", ["add", "base.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "base"], { cwd: root }); - const actor = "opencode-no-proposal"; - const store = new TaskStore(root); - const core = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = core.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - expect( - core.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const receipts = new NativeReceiptStore(); - const tools = createWorkitTools({ - receipts, - client: { session: { get: async () => ({ data: { id: actor, directory: root } }) } }, - }) as any; - const presented = - "Workit decision: action — Open a PR from `feature/workit-reliability-delta` to `main`?"; - const approvedContent = "Open the PR: feature/workit-reliability-delta → main."; - receipts.record( - { - sessionID: actor, - callID: "no-proposal-question", - args: { - questions: [ - { - header: "Workit decision: action", - question: presented, - options: [ - { label: "approved", description: approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { metadata: { answers: [["approved"]] } }, - ); - const decision = await tools.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: task.data.id, - purpose: "action", - binding: { - taskId: task.data.id, - workspaceId: workspace.data.id, - scope: task.data.intent.data.scope, - presented, - approvedContent, - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }, - { directory: root, sessionID: actor } as never, - ); - const parsed = JSON.parse( - typeof decision === "string" ? decision : (decision as { output: string }).output, - ); - expect(parsed).toMatchObject({ ok: false, code: "invalid_input" }); - expect(String(parsed.error)).toContain("no matching action proposal"); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("OpenCode child sessions cannot invoke the coordinator-only action route", async () => { - const tools = createWorkitTools({ - client: { - session: { - get: async () => ({ - data: { id: "child", directory: process.cwd(), parentID: "coordinator" }, - }), - }, - }, - }) as any; - const result = await tools.workit_external_action.execute( - { operation: "git.commit", payload: { message: "forbidden" } }, - { directory: process.cwd(), sessionID: "child" }, - ); - const serialized = typeof result === "string" ? result : result.output; - expect(JSON.parse(serialized)).toMatchObject({ ok: false, code: "permission_denied" }); -}); - -test("OpenCode reports a missing hosting CLI before reserving a pull request", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-no-gh-")); - const oldConfig = process.env.WORKFLOW_VCS_CONFIG; - const oldPath = process.env.PATH; - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "initial.txt"), "initial\n"); - spawnSync("git", ["add", "initial.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "initial"], { cwd: root }); - spawnSync("git", ["checkout", "-qb", "feature/no-gh"], { cwd: root }); - spawnSync("git", ["remote", "add", "origin", "https://github.com/org/repo.git"], { - cwd: root, - }); - const emptyBin = mkdtempSync(join(tmpdir(), "workit-empty-bin-")); - symlinkSync( - spawnSync("which", ["git"], { encoding: "utf8" }).stdout.trim(), - join(emptyBin, "git"), - ); - const tokenPath = join(root, "token"); - const configPath = join(root, "vcs.json"); - writeFileSync(tokenPath, "test-token\n"); - writeFileSync( - configPath, - JSON.stringify({ provider: "github", github: { tokenFile: tokenPath } }), - ); - process.env.WORKFLOW_VCS_CONFIG = configPath; - process.env.PATH = emptyBin; - const tools = createWorkitTools() as any; - const result = await tools.workit_external_action.execute( - { - operation: "hosting.pull_request", - payload: { title: "No CLI", body: "must not reserve" }, - }, - { directory: root, sessionID: "no-gh" }, - ); - expect(JSON.parse(typeof result === "string" ? result : result.output)).toMatchObject({ - ok: false, - code: "capability_unavailable", - details: { capability: "hosting.pull_request", outcome: "not_started" }, - }); - rmSync(emptyBin, { recursive: true, force: true }); - } finally { - if (oldConfig === undefined) delete process.env.WORKFLOW_VCS_CONFIG; - else process.env.WORKFLOW_VCS_CONFIG = oldConfig; - if (oldPath === undefined) delete process.env.PATH; - else process.env.PATH = oldPath; - rmSync(root, { recursive: true, force: true }); - } -}); - -test("OpenCode hosting action refuses an unbound PR target before reservation or provider access", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-hosting-unbound-")); - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "initial.txt"), "initial\n"); - spawnSync("git", ["add", "initial.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "initial"], { cwd: root }); - const actor = "opencode-hosting-session"; - const store = new TaskStore(root); - const core = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = core.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - expect( - core.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const request = { - operation: "hosting.pull_request" as const, - payload: { title: "Offline PR", body: "test", target_branch: "main" }, - }; - expect(resolveExternalActionRequest(root, request)).toMatchObject({ - ok: false, - code: "capability_unavailable", - details: { capability: "hosting.pull_request", outcome: "not_started" }, - }); - const tool = (createWorkitTools() as any).workit_external_action; - const result = await tool.execute(request, { directory: root, sessionID: actor }); - expect(JSON.parse(typeof result === "string" ? result : result.output)).toMatchObject({ - ok: false, - code: "capability_unavailable", - details: { capability: "hosting.pull_request", outcome: "not_started" }, - }); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test.each([undefined, 5])( - "OpenCode YouTrack update uses separate approved comment/time effects and does not replay success (%s minutes)", - async (minutes?: number) => { - const root = mkdtempSync( - join(tmpdir(), `workit-opencode-youtrack-success-${minutes ?? "comment-only"}-`), - ); - const configPath = join(root, "youtrack.json"); - const tokenPath = join(root, "youtrack.token"); - const previousConfig = process.env.WORKFLOW_YOUTRACK_CONFIG; - const previousWrite = process.env.WORKFLOW_YT_WRITE; - const previousFetch = globalThis.fetch; - const clock = fakeClock("2026-01-01T23:59:00Z"); - try { - writeFileSync(tokenPath, "test-token\n"); - chmodSync(tokenPath, 0o600); - writeFileSync( - configPath, - JSON.stringify({ baseUrl: "https://yt.example", tokenFile: tokenPath, timezone: "UTC" }), - ); - process.env.WORKFLOW_YOUTRACK_CONFIG = configPath; - process.env.WORKFLOW_YT_WRITE = "1"; - let commentPosts = 0; - let timePosts = 0; - globalThis.fetch = (async (_input: string | URL, init?: RequestInit) => { - const url = String(_input); - if (init?.method === "POST" && url.includes("/comments")) { - commentPosts += 1; - clock.set("2026-01-02T00:01:00Z"); - return { ok: true, text: async () => "{}" }; - } - if (init?.method === "POST" && url.includes("/workItems")) { - timePosts += 1; - return { ok: true, text: async () => JSON.stringify({ id: "work-1" }) }; - } - throw new Error(`unexpected YouTrack read: ${url}`); - }) as unknown as typeof fetch; - - const actor = "opencode-youtrack-success"; - const store = new TaskStore(root); - const setupCore = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = setupCore.task(taskStartRequest()); - expect(started.ok).toBe(true); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - const request = { - operation: "youtrack.update" as const, - payload: { - issueId: "ABC-1", - markdown: "Approved update", - ...(minutes === undefined ? {} : { minutes }), - }, - }; - const resolved = resolveExternalActionRequest(root, request); - if (!resolved.ok) throw new Error(resolved.error); - const { tools, decision } = await approveActionFor({ - root, - actor, - task: task.data, - workspace: workspace.data, - request, - callID: "youtrack-success-question", - }); - expect(JSON.parse(typeof decision === "string" ? decision : decision.output)).toMatchObject({ - ok: true, - }); - - const first = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - expect(JSON.parse(typeof first === "string" ? first : first.output)).toMatchObject({ - ok: true, - }); - expect({ commentPosts, timePosts }).toEqual({ - commentPosts: 1, - timePosts: minutes === undefined ? 0 : 1, - }); - const repeat = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - expect(JSON.parse(typeof repeat === "string" ? repeat : repeat.output)).toMatchObject({ - ok: false, - code: "permission_denied", - }); - expect({ commentPosts, timePosts }).toEqual({ - commentPosts: 1, - timePosts: minutes === undefined ? 0 : 1, - }); - } finally { - globalThis.fetch = previousFetch; - clock.restore(); - if (previousConfig === undefined) delete process.env.WORKFLOW_YOUTRACK_CONFIG; - else process.env.WORKFLOW_YOUTRACK_CONFIG = previousConfig; - if (previousWrite === undefined) delete process.env.WORKFLOW_YT_WRITE; - else process.env.WORKFLOW_YT_WRITE = previousWrite; - rmSync(root, { recursive: true, force: true }); - } - }, -); - -test("OpenCode rejects unavailable YouTrack writes before reservation", async () => { - const fixture = createChangelogActionFixture("opencode-youtrack-unavailable"); - const configPath = join(fixture.root, "youtrack.json"); - const tokenPath = join(fixture.root, "youtrack.token"); - const previousConfig = process.env.WORKFLOW_YOUTRACK_CONFIG; - const previousWrite = process.env.WORKFLOW_YT_WRITE; - try { - writeFileSync(tokenPath, "test-token\n"); - chmodSync(tokenPath, 0o600); - writeFileSync( - configPath, - JSON.stringify({ baseUrl: "https://yt.example", tokenFile: tokenPath, timezone: "UTC" }), - ); - process.env.WORKFLOW_YOUTRACK_CONFIG = configPath; - delete process.env.WORKFLOW_YT_WRITE; - const request = { - operation: "youtrack.update" as const, - payload: { issueId: "ABC-1", markdown: "Unavailable update" }, - }; - const noFlag = await fixture.tools.workit_external_action.execute(request, { - directory: fixture.root, - sessionID: fixture.actor, - }); - expect(JSON.parse(typeof noFlag === "string" ? noFlag : noFlag.output)).toMatchObject({ - ok: false, - code: "capability_unavailable", - }); - - process.env.WORKFLOW_YT_WRITE = "1"; - rmSync(tokenPath); - const noToken = await fixture.tools.workit_external_action.execute(request, { - directory: fixture.root, - sessionID: fixture.actor, - }); - expect(JSON.parse(typeof noToken === "string" ? noToken : noToken.output)).toMatchObject({ - ok: false, - code: "capability_unavailable", - }); - } finally { - if (previousConfig === undefined) delete process.env.WORKFLOW_YOUTRACK_CONFIG; - else process.env.WORKFLOW_YOUTRACK_CONFIG = previousConfig; - if (previousWrite === undefined) delete process.env.WORKFLOW_YT_WRITE; - else process.env.WORKFLOW_YT_WRITE = previousWrite; - rmSync(fixture.root, { recursive: true, force: true }); - } -}); - -test("OpenCode YouTrack time response loss reconciles the exact applied item without replay", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-youtrack-unknown-")); - const configPath = join(root, "youtrack.json"); - const tokenPath = join(root, "youtrack.token"); - const previousConfig = process.env.WORKFLOW_YOUTRACK_CONFIG; - const previousWrite = process.env.WORKFLOW_YT_WRITE; - const previousFetch = globalThis.fetch; - const clock = fakeClock("2026-01-01T23:59:00Z"); - try { - writeFileSync(tokenPath, "test-token\n"); - chmodSync(tokenPath, 0o600); - writeFileSync( - configPath, - JSON.stringify({ baseUrl: "https://yt.example", tokenFile: tokenPath, timezone: "UTC" }), - ); - process.env.WORKFLOW_YOUTRACK_CONFIG = configPath; - process.env.WORKFLOW_YT_WRITE = "1"; - let commentPosts = 0; - let timePosts = 0; - let appliedComment = ""; - let appliedWork: { id: string; text: string; date: number; minutes: number } | null = null; - let loseTimeResponse = true; - globalThis.fetch = (async (_input: string | URL, init?: RequestInit) => { - const url = String(_input); - if (init?.method === "POST" && url.includes("/comments")) { - commentPosts += 1; - const body = JSON.parse(String(init.body)); - appliedComment = String(body.text); - clock.set("2026-01-02T00:01:00Z"); - return { ok: true, text: async () => "{}" }; - } - if (init?.method === "POST" && url.includes("/workItems")) { - timePosts += 1; - const body = JSON.parse(String(init.body)); - appliedWork = { - id: "work-1", - text: String(body.text), - date: Number(body.date), - minutes: Number(body.duration.minutes), - }; - if (loseTimeResponse) - throw new Error("connection lost after YouTrack applied the work item"); - return { ok: true, text: async () => JSON.stringify({ id: "work-1" }) }; - } - if (init?.method === "GET" && url.includes("/comments")) - return { - ok: true, - text: async () => - JSON.stringify([{ id: "comment-1", text: appliedComment, deleted: false }]), - }; - if (init?.method === "GET" && url.includes("/workItems")) - return { - ok: true, - text: async () => - JSON.stringify( - appliedWork - ? [ - { - id: appliedWork.id, - text: appliedWork.text, - date: appliedWork.date, - duration: { minutes: appliedWork.minutes }, - }, - ] - : [], - ), - }; - throw new Error(`unexpected YouTrack request: ${url}`); - }) as unknown as typeof fetch; - - const actor = "opencode-youtrack-unknown"; - const store = new TaskStore(root); - const setupCore = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = setupCore.task(taskStartRequest()); - expect(started.ok).toBe(true); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - const request = { - operation: "youtrack.update" as const, - payload: { issueId: "ABC-1", markdown: "Applied update", minutes: 45 }, - }; - const resolved = resolveExternalActionRequest(root, request); - if (!resolved.ok) throw new Error(resolved.error); - const { tools, decision } = await approveActionFor({ - root, - actor, - task: task.data, - workspace: workspace.data, - request, - callID: "youtrack-unknown-question", - }); - expect(JSON.parse(typeof decision === "string" ? decision : decision.output)).toMatchObject({ - ok: true, - }); - - const first = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - expect(JSON.parse(typeof first === "string" ? first : first.output)).toMatchObject({ - ok: true, - data: { recovered: true }, - }); - expect({ commentPosts, timePosts }).toEqual({ commentPosts: 1, timePosts: 1 }); - loseTimeResponse = false; - const freshTools = createWorkitTools({ - client: { session: { get: async () => ({ data: { id: actor, directory: root } }) } }, - }) as any; - const reconciled = await freshTools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - expect( - JSON.parse(typeof reconciled === "string" ? reconciled : reconciled.output), - ).toMatchObject({ ok: false, code: "permission_denied" }); - expect({ commentPosts, timePosts }).toEqual({ commentPosts: 1, timePosts: 1 }); - } finally { - globalThis.fetch = previousFetch; - clock.restore(); - if (previousConfig === undefined) delete process.env.WORKFLOW_YOUTRACK_CONFIG; - else process.env.WORKFLOW_YOUTRACK_CONFIG = previousConfig; - if (previousWrite === undefined) delete process.env.WORKFLOW_YT_WRITE; - else process.env.WORKFLOW_YT_WRITE = previousWrite; - rmSync(root, { recursive: true, force: true }); - } -}); - -const schemaDepth = (value: unknown, depth = 0): number => { - if (Array.isArray(value)) - return Math.max(depth, ...value.map((item) => schemaDepth(item, depth))); - if (typeof value !== "object" || value === null) return depth; - return Math.max(depth, ...Object.values(value).map((item) => schemaDepth(item, depth + 1))); -}; - -test("advertised native operation schemas stay within OpenCode provider depth limits", async () => { - const hooks = await plugin(context as never); - for (const [name, definition] of Object.entries(hooks.tool ?? {})) { - if (name === "workit_external_action") continue; - const schema = tool.schema.toJSONSchema(tool.schema.object(definition.args), { - target: "draft-2020-12", - }); - expect(schemaDepth(schema), name).toBeLessThanOrEqual(10); - // Host-owned init tool: same depth budget, different envelope from the - // eight schemaVersion/action operation families. - if (name === "workit_init_apply") continue; - const properties = (schema as { properties?: Record }).properties ?? {}; - expect(properties.schemaVersion, name).toBeDefined(); - expect(properties.action, name).toBeDefined(); - } -}); - -test("native receipts reject unrelated questions and are consumed once per purpose", () => { - const receipts = new NativeReceiptStore(); - receipts.record( - { - sessionID: "s", - callID: "unrelated", - args: { questions: [{ question: "Which color?", options: ["blue"] }] }, - }, - { metadata: { answers: [["blue"]] } }, - ); - expect(receipts.consume("s", "decision").ok).toBe(false); - - receipts.record( - { - sessionID: "s", - callID: "decision", - args: { - questions: [ - { - header: "Workit decision: design", - question: "Approve this decision?", - options: [ - { label: "approved", description: "Design" }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { metadata: { answers: [["approved"]] } }, - ); - expect(receipts.consume("s", "decision").ok).toBe(true); - expect(receipts.consume("s", "decision").ok).toBe(false); -}); - -test("native receipts retain exact call, label, and content bindings", () => { - const receipts = new NativeReceiptStore(); - const input = { - sessionID: "bound-session", - callID: "bound-call", - args: { - questions: [ - { - header: "Workit decision: design", - question: "Approve the scoped change?", - options: [ - { label: "approved", description: "Design" }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }; - receipts.record(input, { metadata: { answers: [["approved"]] } }); - const digest = receipts.consume("bound-session", "decision"); - expect(digest.ok).toBe(true); - if (!digest.ok) throw new Error(digest.error); - expect(digest.receipt.callID).toBe("bound-call"); - expect(digest.receipt.selectedLabel).toBe("approved"); - expect(digest.receipt.contentDigest).toMatch(/^[0-9a-f]{64}$/); - - receipts.record(input, { metadata: { answers: [["approved"]] } }); - expect( - receipts.consume("bound-session", "decision", { - callID: "different-call", - selectedLabel: "approved", - contentDigest: digest.receipt.contentDigest, - }).ok, - ).toBe(false); - expect( - receipts.consume("bound-session", "decision", { - callID: "bound-call", - selectedLabel: "approved", - contentDigest: digest.receipt.contentDigest, - }).ok, - ).toBe(false); -}); - -test("consumed native call IDs stay deduplicated for the receipt store lifetime", () => { - const receipts = new NativeReceiptStore(); - const input = { - sessionID: "long-lived-session", - args: { - questions: [ - { - header: "Workit decision: action", - question: "Workit decision: action — commit?", - options: [ - { label: "approved", description: "Commit the change." }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }; - for (let index = 0; index < 1025; index += 1) { - const callID = `native-call-${index}`; - receipts.record({ ...input, callID }, { metadata: { answers: [["approved"]] } }); - if (!receipts.consume(input.sessionID, "decision", { callID }).ok) - throw new Error(`receipt ${callID} did not consume`); - } - receipts.record({ ...input, callID: "native-call-0" }, { metadata: { answers: [["approved"]] } }); - expect(receipts.consume(input.sessionID, "decision", { callID: "native-call-0" }).ok).toBe(false); -}); - -test("native receipts reject a matching-purpose answer with different content", () => { - const receipts = new NativeReceiptStore(); - receipts.record( - { - sessionID: "content-session", - callID: "content-call", - args: { - questions: [ - { - header: "Workit decision: design", - question: "Approve the first scoped change?", - options: [ - { label: "approved", description: "First change" }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { metadata: { answers: [["approved"]] } }, - ); - expect( - receipts.consume("content-session", "decision", { - selectedLabel: "approved", - question: "Approve a different scoped change?", - }).ok, - ).toBe(false); - expect( - receipts.consume("content-session", "decision", { - selectedLabel: "approved", - question: "Approve the first scoped change?", - }).ok, - ).toBe(true); -}); - -test("native operation arguments cannot supply caller or provenance", async () => { - const hooks = await plugin({ - directory: "/repo", - worktree: "/repo", - serverUrl: new URL("http://localhost"), - client: { - session: { get: async () => ({ data: { id: "native-session", directory: "/repo" } }) }, - }, - } as never); - const raw = await hooks.tool?.workit_task.execute( - { - schemaVersion: 1, - action: "list", - caller: { host: "workit_cli", actor: "forged" }, - provenance: { kind: "host_observed" }, - }, - { directory: "/repo", sessionID: "native-session" } as never, - ); - expect(JSON.parse(raw as string)).toMatchObject({ ok: false, code: "invalid_input" }); -}); - -test("valid nested operation arguments reach the shared core outcome", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-nested-")); - const directRoot = mkdtempSync(join(tmpdir(), "workit-opencode-nested-direct-")); - try { - const request = taskStartRequest({ - intent: { - objective: "nested objective", - scope: { description: "source", paths: ["src"], exclusions: ["dist"] }, - authorityRefs: [{ kind: "external", url: "https://example.test/reference" }], - }, - }); - const direct = new WorkitCore(new TaskStore(directRoot), { - root: directRoot, - caller: { host: "opencode", actor: "lead" }, - capabilities: [], - constraints: [], - now: () => "2026-01-01T00:00:00Z", - }).task(request); - const hooks = await plugin({ - directory: root, - worktree: root, - serverUrl: new URL("http://localhost"), - client: { session: { get: async () => ({ data: { id: "lead", directory: root } }) } }, - } as never); - const raw = await hooks.tool?.workit_task.execute(request, { - directory: root, - sessionID: "lead", - } as never); - const native = JSON.parse(raw as string); - expect(native.ok).toBe(direct.ok); - expect(native.data).toMatchObject({ objective: "nested objective", status: "active" }); - } finally { - rmSync(root, { recursive: true, force: true }); - rmSync(directRoot, { recursive: true, force: true }); - } -}); - -test("malformed nested operation arguments remain invalid_input", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-malformed-")); - try { - const hooks = await plugin({ - directory: root, - worktree: root, - serverUrl: new URL("http://localhost"), - client: { session: { get: async () => ({ data: { id: "lead", directory: root } }) } }, - } as never); - const raw = await hooks.tool?.workit_task.execute( - { - ...taskStartRequest(), - intent: { - ...taskStartRequest().intent, - scope: { ...taskStartRequest().intent.scope, paths: [42] }, - }, - }, - { directory: root, sessionID: "lead" } as never, - ); - expect(JSON.parse(raw as string)).toMatchObject({ ok: false, code: "invalid_input" }); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("the decision tool consumes only the matching native question receipt", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-decision-")); - try { - const store = new TaskStore(root); - const core = new WorkitCore(store, { - root, - caller: { host: "opencode", actor: "lead" }, - capabilities: [], - constraints: [], - now: () => "2026-01-01T00:00:00Z", - }); - const started = core.task(taskStartRequest()); - expect(started.ok).toBe(true); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("decision fixture missing"); - const hooks = await plugin({ - directory: root, - worktree: root, - serverUrl: new URL("http://localhost"), - client: { session: { get: async () => ({ data: { id: "lead", directory: root } }) } }, - } as never); - await hooks["tool.execute.after"]?.( - { - tool: "question", - sessionID: "lead", - callID: "decision-question", - args: { - questions: [ - { - header: "Workit decision: design", - question: "Approve this design?", - options: [ - { label: "approved", description: "the design" }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { title: "Decision", output: "approved", metadata: { answers: [["approved"]] } }, - ); - const raw = await hooks.tool?.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: task.data.id, - expectedRevision: task.data.revision, - purpose: "design", - binding: { - taskId: task.data.id, - workspaceId: workspace.data.id, - scope: scope(), - presented: "Approve this design?", - approvedContent: "the design", - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }, - { directory: root, sessionID: "lead" } as never, - ); - expect(JSON.parse(raw as string).ok).toBe(true); - const replay = await hooks.tool?.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: task.data.id, - expectedRevision: task.data.revision, - purpose: "design", - binding: { - taskId: task.data.id, - workspaceId: workspace.data.id, - scope: scope(), - presented: "Approve this design?", - approvedContent: "the design", - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }, - { directory: root, sessionID: "lead" } as never, - ); - expect(JSON.parse(replay as string)).toMatchObject({ ok: false, code: "permission_denied" }); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("OpenCode proposes a concise action approval and binds the exact descriptor", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-proposal-")); - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "initial.txt"), "initial\n"); - spawnSync("git", ["add", "initial.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "initial"], { cwd: root }); - writeFileSync(join(root, "change.txt"), "change\n"); - spawnSync("git", ["add", "change.txt"], { cwd: root }); - const actor = "opencode-proposal-session"; - const store = new TaskStore(root); - const core = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = core.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - expect( - core.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const request = { - operation: "git.commit" as const, - payload: { message: "chore(test): concise commit" }, - }; - const resolved = resolveExternalActionRequest(root, request); - if (!resolved.ok) throw new Error(resolved.error); - const descriptor = externalActionDescriptor( - resolved.data.request.operation, - resolved.data.descriptorPayload, - ); - const receipts = new NativeReceiptStore(); - const tools = createWorkitTools({ - receipts, - client: { session: { get: async () => ({ data: { id: actor, directory: root } }) } }, - }) as any; - - const first = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - } as never); - const proposalResult = JSON.parse( - typeof first === "string" ? first : (first as { output: string }).output, - ); - expect(proposalResult.ok).toBe(false); - expect(proposalResult.code).toBe("needs_input"); - const proposal = proposalResult.details?.proposal; - expect(String(proposal?.presented)).toContain("Workit decision: action"); - expect(String(proposal?.presented)).not.toContain("{"); - expect(String(proposal?.presented).length).toBeLessThanOrEqual(300); - expect(String(proposal?.approvedContent).length).toBeLessThanOrEqual(300); - expect(String(proposal?.descriptorDigest)).toMatch(/^[0-9a-f]{64}$/); - - receipts.record( - { - sessionID: actor, - callID: "proposal-question", - args: { - questions: [ - { - header: "Workit decision: action", - question: proposal.presented, - options: [ - { label: "approved", description: proposal.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { metadata: { answers: [["approved"]] } }, - ); - const freshTask = store.readTask(task.data.id); - if (!freshTask.ok) throw new Error("task refresh failed"); - const decision = await tools.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: task.data.id, - expectedRevision: freshTask.data.revision, - purpose: "action", - binding: { - taskId: task.data.id, - workspaceId: workspace.data.id, - scope: task.data.intent.data.scope, - presented: proposal.presented, - approvedContent: proposal.approvedContent, - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }, - { directory: root, sessionID: actor } as never, - ); - expect( - JSON.parse(typeof decision === "string" ? decision : (decision as { output: string }).output), - ).toMatchObject({ ok: true }); - const recorded = store.readTask(task.data.id); - if (!recorded.ok) throw new Error("recorded task unavailable"); - const entry = recorded.data.decisions.find((item) => item.data.purpose === "action"); - expect(entry?.data.binding.approvedContent).toBe(descriptor); - expect(entry && (entry.data.binding as { displayed?: string }).displayed).toBe( - proposal.approvedContent, - ); - - const second = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - } as never); - expect( - JSON.parse(typeof second === "string" ? second : (second as { output: string }).output), - ).toMatchObject({ ok: true }); - expect( - spawnSync("git", ["log", "-1", "--pretty=%s"], { cwd: root, encoding: "utf8" }).stdout.trim(), - ).toBe("chore(test): concise commit"); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("plugin denies oversize binding questions before display", async () => { - const hooks = (await plugin(context as never)) as any; - const long = "x".repeat(400); - const bindingQuestion = { - header: "Workit decision: action", - question: long, - options: [ - { label: "approved", description: "short" }, - { label: "rejected", description: "Reject this decision" }, - ], - }; - expect(() => - hooks["tool.execute.before"]( - { tool: "question", sessionID: "s", callID: "c" }, - { args: { questions: [bindingQuestion] } }, - ), - ).toThrow(/present the item/); - const short = { - ...bindingQuestion, - question: "Workit decision: action — Approve the action shown above?", - }; - expect(() => - hooks["tool.execute.before"]( - { tool: "question", sessionID: "s", callID: "c" }, - { args: { questions: [short] } }, - ), - ).not.toThrow(); -}); - -test("oversize binding questions fail record time with show-first guidance", async () => { - const fixture = createChangelogActionFixture("opencode-oversize-session"); - try { - const long = `Approve ${"x".repeat(400)}`; - fixture.receipts.record( - { - sessionID: fixture.actor, - callID: "oversize-question", - args: { - questions: [ - { - header: "Workit decision: action", - question: long, - options: [ - { label: "approved", description: long }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { metadata: { answers: [["approved"]] } }, - ); - const decision = await fixture.tools.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: fixture.task.id, - expectedRevision: fixture.task.revision, - purpose: "action", - binding: { - taskId: fixture.task.id, - workspaceId: fixture.workspace.id, - scope: fixture.task.intent.data.scope, - presented: long, - approvedContent: long, - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }, - { directory: fixture.root, sessionID: fixture.actor }, - ); - const parsed = JSON.parse( - typeof decision === "string" ? decision : (decision as { output: string }).output, - ); - expect(parsed.ok).toBe(false); - expect(parsed.code).toBe("invalid_input"); - expect(String(parsed.error)).toContain("present the item"); - } finally { - rmSync(fixture.root, { recursive: true, force: true }); - } -}); - -test("receipt near misses name the failed element", () => { - const receipts = new NativeReceiptStore(); - receipts.recordRequest("req-near", "sess-near", "call-near", [ - { - header: "Workit decision: design", - question: "Workit decision: design — Approve the plan shown above?", - options: [ - { label: "approved", description: "Plan A" }, - { label: "rejected", description: "Reject this decision" }, +test("native receipts retain exact call, label, and content bindings", () => { + const receipts = new NativeReceiptStore(); + const input = { + sessionID: "bound-session", + callID: "bound-call", + args: { + questions: [ + { + header: "Workit decision: design", + question: "Approve the scoped change?", + options: [ + { label: "approved", description: "Design" }, + { label: "rejected", description: "Reject this decision" }, + ], + }, ], }, - ]); - expect(receipts.recordReply("req-near", "sess-near", [["approved"]])).toBe(true); - const consumed = receipts.consume("sess-near", "decision", { - selectedLabel: "approved", - decisionPurpose: "design", - selectedDescription: "Plan B", - }); - expect(consumed.ok).toBe(false); - if (!consumed.ok) expect(consumed.error).toContain("description"); -}); - -const branchRepo = (actor: string) => { - const root = mkdtempSync(join(tmpdir(), `workit-opencode-branch-${actor}-`)); - const remote = mkdtempSync(join(tmpdir(), `workit-opencode-branch-remote-${actor}-`)); - const configHome = mkdtempSync(join(tmpdir(), `workit-opencode-branch-config-${actor}-`)); - const previousXdg = process.env.XDG_CONFIG_HOME; - process.env.XDG_CONFIG_HOME = configHome; - writeFileSync( - join(configHome, "config.json"), - JSON.stringify({ branchPolicy: { preset: "gitflow" } }), - ); - spawnSync("git", ["init", "-q", "--bare"], { cwd: remote }); - for (const args of [ - ["init", "-q", "-b", "main"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "base.txt"), "base\n"); - spawnSync("git", ["add", "base.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "base"], { cwd: root }); - spawnSync("git", ["remote", "add", "origin", remote], { cwd: root }); - spawnSync("git", ["push", "-q", "-u", "origin", "main"], { cwd: root }); - spawnSync("git", ["branch", "develop"], { cwd: root }); - spawnSync("git", ["push", "-q", "origin", "develop"], { cwd: root }); - spawnSync("git", ["branch", "-D", "develop"], { cwd: root }); - const store = new TaskStore(root); - const setupCore = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = setupCore.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - expect( - setupCore.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const decisionTask = store.readTask(task.data.id); - const decisionWorkspace = store.readWorkspace(); - if (!decisionTask.ok || !decisionWorkspace.ok || !decisionWorkspace.data) - throw new Error("writer refresh failed"); - return { - root, - remote, - configHome, - previousXdg, - store, - task: decisionTask.data, - workspace: decisionWorkspace.data, }; -}; - -const restoreBranchRepo = (repo: { - root: string; - remote: string; - configHome: string; - previousXdg: string | undefined; -}) => { - if (repo.previousXdg === undefined) delete process.env.XDG_CONFIG_HOME; - else process.env.XDG_CONFIG_HOME = repo.previousXdg; - rmSync(repo.root, { recursive: true, force: true }); - rmSync(repo.remote, { recursive: true, force: true }); - rmSync(repo.configHome, { recursive: true, force: true }); -}; - -const branchOutput = (result: unknown) => - JSON.parse(typeof result === "string" ? result : (result as { output: string }).output); - -test("dirty branch setup proposes the stash and executes in one approval", async () => { - const actor = "opencode-branch-dirty"; - const repo = branchRepo(actor); - const { root, task, workspace } = repo; - try { - writeFileSync(join(root, "base.txt"), "wip\n"); - writeFileSync(join(root, "notes.md"), "untracked\n"); - const receipts = new NativeReceiptStore(); - const tools = createWorkitTools({ - receipts, - client: { session: { get: async () => ({ data: { id: actor, directory: root } }) } }, - }) as any; - const request = { operation: "git.branch_setup", payload: { target_branch: "feature/dirty" } }; - const first = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - const proposal = branchOutput(first).details.proposal; - expect(String(proposal.presented)).toContain("Stash"); - receipts.record( - { - sessionID: actor, - callID: "dirty-question", - args: { - questions: [ - { - header: "Workit decision: action", - question: proposal.presented, - options: [ - { label: "approved", description: proposal.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { metadata: { answers: [["approved"]] } }, - ); - const fresh = new TaskStore(root).readTask(task.id); - if (!fresh.ok) throw new Error("task refresh failed"); - const decision = await tools.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: task.id, - expectedRevision: fresh.data.revision, - purpose: "action", - binding: { - taskId: task.id, - workspaceId: workspace.id, - scope: (task as any).intent.data.scope, - presented: proposal.presented, - approvedContent: proposal.approvedContent, - contentRefs: [], - }, - response: "approved", - requirementIds: [], - }, - { directory: root, sessionID: actor } as never, - ); - expect(branchOutput(decision)).toMatchObject({ ok: true }); - const result = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - expect(branchOutput(result)).toMatchObject({ ok: true }); - expect( - spawnSync("git", ["branch", "--show-current"], { cwd: root, encoding: "utf8" }).stdout.trim(), - ).toBe("feature/dirty"); - expect( - spawnSync("git", ["status", "--short"], { cwd: root, encoding: "utf8" }).stdout.trim(), - ).toBe("?? docs/"); - expect( - spawnSync("git", ["stash", "list"], { cwd: root, encoding: "utf8" }).stdout.trim(), - ).toContain("workit: pre-checkout feature/dirty"); - } finally { - restoreBranchRepo(repo); - } -}); - -test("branch approval carries across unrelated current-HEAD moves", async () => { - const actor = "opencode-branch-carry"; - const repo = branchRepo(actor); - const { root, task, workspace } = repo; - try { - const request = { operation: "git.branch_setup", payload: { target_branch: "feature/carry" } }; - const { tools } = await approveActionFor({ - root, - actor, - task, - workspace, - request, - callID: "carry-question", - }); - const approvedBase = spawnSync("git", ["rev-parse", "refs/remotes/origin/main"], { - cwd: root, - encoding: "utf8", - }).stdout.trim(); - spawnSync("git", ["checkout", "-qb", "feature/current"], { cwd: root }); - writeFileSync(join(root, "later.txt"), "later\n"); - spawnSync("git", ["add", "later.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "later"], { cwd: root }); - const result = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - expect(branchOutput(result)).toMatchObject({ ok: true }); - expect( - spawnSync("git", ["rev-parse", "feature/carry"], { - cwd: root, - encoding: "utf8", - }).stdout.trim(), - ).toBe(approvedBase); - } finally { - restoreBranchRepo(repo); - } -}); + receipts.record(input, { metadata: { answers: [["approved"]] } }); + const digest = receipts.consume("bound-session", "decision"); + expect(digest.ok).toBe(true); + if (!digest.ok) throw new Error(digest.error); + expect(digest.receipt.callID).toBe("bound-call"); + expect(digest.receipt.selectedLabel).toBe("approved"); + expect(digest.receipt.contentDigest).toMatch(/^[0-9a-f]{64}$/); -test("stated design choices record without a receipt and never authorize actions", async () => { - const actor = "opencode-stated"; - const repo = branchRepo(actor); - const { root, task, workspace } = repo; - try { - const receipts = new NativeReceiptStore(); - const tools = createWorkitTools({ - receipts, - client: { session: { get: async () => ({ data: { id: actor, directory: root } }) } }, - }) as any; - const stated = await tools.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: task.id, - expectedRevision: task.revision, - purpose: "design", - binding: { - taskId: task.id, - workspaceId: workspace.id, - scope: (task as any).intent.data.scope, - presented: "Take the second approach?", - approvedContent: "Take the second approach.", - contentRefs: [], - statedChoice: { ref: "call-stated-1", text: "take the second one" }, - }, - response: "stated", - requirementIds: [], - }, - { directory: root, sessionID: actor } as never, - ); - expect(branchOutput(stated)).toMatchObject({ ok: true }); - expect(JSON.stringify(branchOutput(stated))).toContain('"response":"stated"'); - const statedAction = await tools.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: task.id, - expectedRevision: task.revision, - purpose: "action", - binding: { - taskId: task.id, - workspaceId: workspace.id, - scope: (task as any).intent.data.scope, - presented: "Run it?", - approvedContent: "Run it.", - contentRefs: [], - statedChoice: { ref: "call-stated-2", text: "yes, run it" }, - }, - response: "stated", - requirementIds: [], - }, - { directory: root, sessionID: actor } as never, - ); - expect(branchOutput(statedAction)).toMatchObject({ ok: false }); - } finally { - restoreBranchRepo(repo); - } + receipts.record(input, { metadata: { answers: [["approved"]] } }); + expect( + receipts.consume("bound-session", "decision", { + callID: "different-call", + selectedLabel: "approved", + contentDigest: digest.receipt.contentDigest, + }).ok, + ).toBe(false); + expect( + receipts.consume("bound-session", "decision", { + callID: "bound-call", + selectedLabel: "approved", + contentDigest: digest.receipt.contentDigest, + }).ok, + ).toBe(false); }); -test("drifted proposals evict so a fresh resolve never wedges ambiguous", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-proposal-evict-")); - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "base.txt"), "base\n"); - spawnSync("git", ["add", "base.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "base"], { cwd: root }); - writeFileSync(join(root, "change.txt"), "change\n"); - spawnSync("git", ["add", "change.txt"], { cwd: root }); - const actor = "opencode-proposal-evict"; - const store = new TaskStore(root); - const setupCore = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = setupCore.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - const workspaceId = workspace.data.id; - expect( - setupCore.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const receipts = new NativeReceiptStore(); - const tools = createWorkitTools({ - receipts, - client: { session: { get: async () => ({ data: { id: actor, directory: root } }) } }, - }) as any; - const request = { operation: "git.commit", payload: { message: "chore(test): evict" } }; - const resolve = async () => { - const resolved = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - return branchOutput(resolved).details.proposal; - }; - const record = async ( - callID: string, - proposal: { presented: string; approvedContent: string }, - ) => { - receipts.record( - { - sessionID: actor, - callID, - args: { - questions: [ - { - header: "Workit decision: action", - question: proposal.presented, - options: [ - { label: "approved", description: proposal.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { metadata: { answers: [["approved"]] } }, - ); - const fresh = store.readTask(task.data.id); - if (!fresh.ok) throw new Error("task refresh failed"); - return tools.workit_decision.execute( +test("consumed native call IDs stay deduplicated for the receipt store lifetime", () => { + const receipts = new NativeReceiptStore(); + const input = { + sessionID: "long-lived-session", + args: { + questions: [ { - schemaVersion: 1, - action: "record", - taskId: task.data.id, - expectedRevision: fresh.data.revision, - purpose: "action", - binding: { - taskId: task.data.id, - workspaceId, - scope: (task.data as any).intent.data.scope, - presented: proposal.presented, - approvedContent: proposal.approvedContent, - contentRefs: [], - }, - response: "approved", - requirementIds: [], + header: "Workit decision: action", + question: "Workit decision: action — commit?", + options: [ + { label: "approved", description: "Commit the change." }, + { label: "rejected", description: "Reject this decision" }, + ], }, - { directory: root, sessionID: actor } as never, - ); - }; - const stale = await resolve(); - writeFileSync(join(root, "drift.txt"), "drift\n"); - spawnSync("git", ["add", "drift.txt"], { cwd: root }); - expect(branchOutput(await record("evict-drifted", stale))).toMatchObject({ - ok: false, - code: "invalid_input", - }); - const recovered = await record("evict-retry", await resolve()); - expect(branchOutput(recovered)).toMatchObject({ ok: true }); - } finally { - rmSync(root, { recursive: true, force: true }); + ], + }, + }; + for (let index = 0; index < 1025; index += 1) { + const callID = `native-call-${index}`; + receipts.record({ ...input, callID }, { metadata: { answers: [["approved"]] } }); + if (!receipts.consume(input.sessionID, "decision", { callID }).ok) + throw new Error(`receipt ${callID} did not consume`); } + receipts.record({ ...input, callID: "native-call-0" }, { metadata: { answers: [["approved"]] } }); + expect(receipts.consume(input.sessionID, "decision", { callID: "native-call-0" }).ok).toBe(false); }); -test("branch changes keep identical-text proposals distinct from old receipts", async () => { - const root = mkdtempSync(join(tmpdir(), "workit-opencode-proposal-branch-")); - const sibling = mkdtempSync(join(tmpdir(), "workit-opencode-proposal-sibling-")); - try { - for (const args of [ - ["init", "-q"], - ["config", "user.email", "test@example.invalid"], - ["config", "user.name", "Workit Test"], - ]) - spawnSync("git", args, { cwd: root }); - writeFileSync(join(root, "base.txt"), "base\n"); - spawnSync("git", ["add", "base.txt"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "base"], { cwd: root }); - spawnSync("git", ["clone", "-q", root, sibling]); - spawnSync("git", ["config", "user.email", "test@example.invalid"], { cwd: sibling }); - spawnSync("git", ["config", "user.name", "Workit Test"], { cwd: sibling }); - writeFileSync(join(sibling, "change.txt"), "change\n"); - spawnSync("git", ["add", "change.txt"], { cwd: sibling }); - const actor = "opencode-proposal-branch"; - const store = new TaskStore(root); - const setupCore = new WorkitCore(store, { - root, - caller: { host: "opencode", actor }, - capabilities: [], - constraints: [], - now: "2026-01-01T00:00:00Z", - }); - const started = setupCore.task(taskStartRequest()); - if (!started.ok) throw new Error(started.error); - const task = store.readTask((started.data as { id: string }).id); - const workspace = store.readWorkspace(); - if (!task.ok || !workspace.ok || !workspace.data) throw new Error("task setup failed"); - const workspaceId = workspace.data.id; - expect( - setupCore.writer({ - schemaVersion: 1, - action: "acquire", - taskId: task.data.id, - expectedRevision: task.data.revision, - expectedWorkspaceRevision: workspace.data.revision, - workerId: null, - }), - ).toMatchObject({ ok: true }); - const receipts = new NativeReceiptStore(); - const tools = createWorkitTools({ - receipts, - client: { session: { get: async () => ({ data: { id: actor, directory: root } }) } }, - }) as any; - const request = { - operation: "git.commit", - payload: { message: "chore(test): evict", cwd: sibling }, - }; - const resolve = async () => { - const resolved = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - return branchOutput(resolved).details.proposal; - }; - const record = async ( - callID: string, - proposal: { presented: string; approvedContent: string }, - mintReceipt = true, - ) => { - if (mintReceipt) - receipts.record( +test("native receipts reject a matching-purpose answer with different content", () => { + const receipts = new NativeReceiptStore(); + receipts.record( + { + sessionID: "content-session", + callID: "content-call", + args: { + questions: [ { - sessionID: actor, - callID, - args: { - questions: [ - { - header: "Workit decision: action", - question: proposal.presented, - options: [ - { label: "approved", description: proposal.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], - }, - }, - { metadata: { answers: [["approved"]] } }, - ); - const fresh = store.readTask(task.data.id); - if (!fresh.ok) throw new Error("task refresh failed"); - return tools.workit_decision.execute( - { - schemaVersion: 1, - action: "record", - taskId: task.data.id, - expectedRevision: fresh.data.revision, - purpose: "action", - binding: { - taskId: task.data.id, - workspaceId, - scope: (task.data as any).intent.data.scope, - presented: proposal.presented, - approvedContent: proposal.approvedContent, - contentRefs: [], + header: "Workit decision: design", + question: "Approve the first scoped change?", + options: [ + { label: "approved", description: "First change" }, + { label: "rejected", description: "Reject this decision" }, + ], }, - response: "approved", - requirementIds: [], - }, - { directory: root, sessionID: actor } as never, - ); - }; - const stale = await resolve(); - receipts.recordRequest("asked-before-branch-change", actor, "old-branch-call", [ - { - header: "Workit decision: action", - question: stale.presented, - options: [ - { label: "approved", description: stale.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ]); - receipts.recordRequest("asked-before-branch-change-2", actor, "old-branch-call-2", [ - { - header: "Workit decision: action", - question: stale.presented, - options: [ - { label: "approved", description: stale.approvedContent }, - { label: "rejected", description: "Reject this decision" }, ], }, - ]); - receipts.record( + }, + { metadata: { answers: [["approved"]] } }, + ); + expect( + receipts.consume("content-session", "decision", { + selectedLabel: "approved", + question: "Approve a different scoped change?", + }).ok, + ).toBe(false); + expect( + receipts.consume("content-session", "decision", { + selectedLabel: "approved", + question: "Approve the first scoped change?", + }).ok, + ).toBe(true); +}); + +test("OpenCode refuses oversized decision questions before displaying them", async () => { + const hooks = (await plugin(context as never)) as any; + const question = { + header: "Workit decision: design", + question: "x".repeat(400), + options: [ + { label: "approved", description: "short" }, + { label: "rejected", description: "Reject this decision" }, + ], + }; + expect(() => + hooks["tool.execute.before"]( + { tool: "question", sessionID: "decision-session", callID: "decision-call" }, + { args: { questions: [question] } }, + ), + ).toThrow(/present the item/); +}); + +test("oversized decision bindings fail closed at record time", async () => { + const fixture = decisionFixture("oversize"); + try { + const long = `Approve ${"x".repeat(400)}`; + fixture.receipts.record( { - sessionID: actor, - callID: "unresolved-receipt", + sessionID: fixture.actor, + callID: "oversize-question", args: { questions: [ { - header: "Workit decision: action", - question: stale.presented, + header: "Workit decision: design", + question: long, options: [ - { label: "approved", description: stale.approvedContent }, + { label: "approved", description: long }, { label: "rejected", description: "Reject this decision" }, ], }, @@ -3033,172 +618,262 @@ test("branch changes keep identical-text proposals distinct from old receipts", }, { metadata: { answers: [["approved"]] } }, ); - renameSync(join(sibling, ".git"), join(sibling, "git-unavailable")); - expect(branchOutput(await record("unresolved-proposal", stale, false))).toMatchObject({ - ok: false, - code: "invalid_input", - }); - renameSync(join(sibling, "git-unavailable"), join(sibling, ".git")); - spawnSync("git", ["branch", "alternate"], { cwd: sibling }); - spawnSync("git", ["checkout", "-q", "alternate"], { cwd: sibling }); - const fresh = await resolve(); - expect(fresh.presented).toBe(stale.presented); - expect(fresh.approvedContent).toBe(stale.approvedContent); - expect(receipts.recordReply("asked-before-branch-change-2", actor, [["approved"]])).toBe(true); - receipts.record( + const result = await fixture.tools.workit_decision.execute( { - sessionID: actor, - callID: "old-branch-call-2", - args: { - questions: [ - { - header: "Workit decision: action", - question: stale.presented, - options: [ - { label: "approved", description: stale.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], + schemaVersion: 1, + action: "record", + taskId: fixture.task.id, + expectedRevision: fixture.task.revision, + purpose: "design", + binding: { + taskId: fixture.task.id, + workspaceId: fixture.workspace.id, + scope: fixture.task.intent.data.scope, + presented: long, + approvedContent: long, + contentRefs: [], }, + response: "approved", + requirementIds: [], }, - { metadata: { answers: [["approved"]] } }, + { directory: fixture.root, sessionID: fixture.actor }, ); - expect(branchOutput(await record("reply-before-hook", fresh, false))).toMatchObject({ + expect(JSON.parse(typeof result === "string" ? result : result.output)).toMatchObject({ ok: false, code: "invalid_input", }); - receipts.record( - { - sessionID: actor, - callID: "old-branch-call", - args: { - questions: [ - { - header: "Workit decision: action", - question: stale.presented, - options: [ - { label: "approved", description: stale.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], - }, - ], + } finally { + fixture.cleanup(); + } +}); + +test("receipt near misses identify the mismatched binding", () => { + const receipts = new NativeReceiptStore(); + receipts.recordRequest("req-near", "sess-near", "call-near", [ + { + header: "Workit decision: design", + question: "Workit decision: design — Approve the plan shown above?", + options: [ + { label: "approved", description: "Plan A" }, + { label: "rejected", description: "Reject this decision" }, + ], + }, + ]); + expect(receipts.recordReply("req-near", "sess-near", [["approved"]])).toBe(true); + const consumed = receipts.consume("sess-near", "decision", { + selectedLabel: "approved", + decisionPurpose: "design", + selectedDescription: "Plan B", + }); + expect(consumed.ok).toBe(false); + if (!consumed.ok) expect(consumed.error).toContain("description"); +}); + +test("stated design choices need no receipt and never authorize an action", async () => { + const fixture = decisionFixture("stated-choice"); + try { + const record = (purpose: "design" | "action", callRef: string) => + fixture.tools.workit_decision.execute( + { + schemaVersion: 1, + action: "record", + taskId: fixture.task.id, + expectedRevision: fixture.task.revision, + purpose, + binding: { + taskId: fixture.task.id, + workspaceId: fixture.workspace.id, + scope: fixture.task.intent.data.scope, + presented: "Take the second approach?", + approvedContent: "Take the second approach.", + contentRefs: [], + statedChoice: { ref: callRef, text: "take the second one" }, + }, + response: "stated", + requirementIds: [], }, + { directory: fixture.root, sessionID: fixture.actor }, + ); + const stated = await record("design", "design-choice"); + expect(JSON.parse(typeof stated === "string" ? stated : stated.output)).toMatchObject({ + ok: true, + }); + const action = await record("action", "action-choice"); + expect(JSON.parse(typeof action === "string" ? action : action.output).ok).toBe(false); + } finally { + fixture.cleanup(); + } +}); + +test("native operation arguments cannot supply caller or provenance", async () => { + const hooks = await plugin({ + directory: "/repo", + worktree: "/repo", + serverUrl: new URL("http://localhost"), + client: { + session: { get: async () => ({ data: { id: "native-session", directory: "/repo" } }) }, + }, + } as never); + const raw = await hooks.tool?.workit_task.execute( + { + schemaVersion: 1, + action: "list", + caller: { host: "workit_cli", actor: "forged" }, + provenance: { kind: "host_observed" }, + }, + { directory: "/repo", sessionID: "native-session" } as never, + ); + expect(JSON.parse(raw as string)).toMatchObject({ ok: false, code: "invalid_input" }); +}); + +test("valid nested operation arguments reach the shared core outcome", async () => { + const root = mkdtempSync(join(tmpdir(), "workit-opencode-nested-")); + const directRoot = mkdtempSync(join(tmpdir(), "workit-opencode-nested-direct-")); + try { + const request = taskStartRequest({ + intent: { + objective: "nested objective", + scope: { description: "source", paths: ["src"], exclusions: ["dist"] }, + authorityRefs: [{ kind: "external", url: "https://example.test/reference" }], }, - { metadata: { answers: [["approved"]] } }, - ); - expect(receipts.recordReply("asked-before-branch-change", actor, [["approved"]])).toBe(true); - expect(branchOutput(await record("hook-before-reply", fresh, false))).toMatchObject({ - ok: false, - code: "invalid_input", }); - expect(branchOutput(await record("fresh-branch-receipt", fresh))).toMatchObject({ ok: true }); - receipts.recordRequest("fresh-question-reply", actor, "fresh-branch-receipt", [ + const direct = new WorkitCore(new TaskStore(directRoot), { + root: directRoot, + caller: { host: "opencode", actor: "lead" }, + capabilities: [], + constraints: [], + now: () => "2026-01-01T00:00:00Z", + }).task(request); + const hooks = await plugin({ + directory: root, + worktree: root, + serverUrl: new URL("http://localhost"), + client: { session: { get: async () => ({ data: { id: "lead", directory: root } }) } }, + } as never); + const raw = await hooks.tool?.workit_task.execute(request, { + directory: root, + sessionID: "lead", + } as never); + const native = JSON.parse(raw as string); + expect(native.ok).toBe(direct.ok); + expect(native.data).toMatchObject({ objective: "nested objective", status: "active" }); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(directRoot, { recursive: true, force: true }); + } +}); + +test("malformed nested operation arguments remain invalid_input", async () => { + const root = mkdtempSync(join(tmpdir(), "workit-opencode-malformed-")); + try { + const hooks = await plugin({ + directory: root, + worktree: root, + serverUrl: new URL("http://localhost"), + client: { session: { get: async () => ({ data: { id: "lead", directory: root } }) } }, + } as never); + const raw = await hooks.tool?.workit_task.execute( { - header: "Workit decision: action", - question: fresh.presented, - options: [ - { label: "approved", description: fresh.approvedContent }, - { label: "rejected", description: "Reject this decision" }, - ], + ...taskStartRequest(), + intent: { + ...taskStartRequest().intent, + scope: { ...taskStartRequest().intent.scope, paths: [42] }, + }, }, - ]); - expect(receipts.recordReply("fresh-question-reply", actor, [["approved"]])).toBe(true); - expect( - receipts.consume(actor, "decision", { - callID: "fresh-branch-receipt", - decisionPurpose: "action", - question: fresh.presented, - }).ok, - ).toBe(false); + { directory: root, sessionID: "lead" } as never, + ); + expect(JSON.parse(raw as string)).toMatchObject({ ok: false, code: "invalid_input" }); } finally { rmSync(root, { recursive: true, force: true }); - rmSync(sibling, { recursive: true, force: true }); } }); -test("docs-confined dirt carries onto the branch with no stash question", async () => { - const actor = "opencode-branch-carry-docs"; - const repo = branchRepo(actor); - const { root, task, workspace } = repo; +test("the decision tool consumes only the matching native question receipt", async () => { + const root = mkdtempSync(join(tmpdir(), "workit-opencode-decision-")); try { - mkdirSync(join(root, "docs"), { recursive: true }); - writeFileSync(join(root, "docs", "plan.md"), "plan\n"); - spawnSync("git", ["add", "docs/plan.md"], { cwd: root }); - spawnSync("git", ["commit", "-qm", "plan"], { cwd: root }); - spawnSync("git", ["push", "-q", "origin", "main"], { cwd: root }); - // Same blob on base and HEAD so the base checkout keeps the worktree. - spawnSync("git", ["checkout", "-q", "develop"], { cwd: root }); - spawnSync("git", ["merge", "-q", "--ff-only", "main"], { cwd: root }); - spawnSync("git", ["checkout", "-q", "main"], { cwd: root }); - writeFileSync(join(root, "docs", "plan.md"), "plan v2\n"); - const receipts = new NativeReceiptStore(); - const tools = createWorkitTools({ - receipts, - client: { session: { get: async () => ({ data: { id: actor, directory: root } }) } }, - }) as any; - const request = { operation: "git.branch_setup", payload: { target_branch: "feature/carry" } }; - const first = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, + const store = new TaskStore(root); + const core = new WorkitCore(store, { + root, + caller: { host: "opencode", actor: "lead" }, + capabilities: [], + constraints: [], + now: () => "2026-01-01T00:00:00Z", }); - const proposal = branchOutput(first).details.proposal; - expect(String(proposal.presented)).not.toContain("Stash"); - receipts.record( + const started = core.task(taskStartRequest()); + expect(started.ok).toBe(true); + if (!started.ok) throw new Error(started.error); + const task = store.readTask((started.data as { id: string }).id); + const workspace = store.readWorkspace(); + if (!task.ok || !workspace.ok || !workspace.data) throw new Error("decision fixture missing"); + const hooks = await plugin({ + directory: root, + worktree: root, + serverUrl: new URL("http://localhost"), + client: { session: { get: async () => ({ data: { id: "lead", directory: root } }) } }, + } as never); + await hooks["tool.execute.after"]?.( { - sessionID: actor, - callID: "carry-docs-question", + tool: "question", + sessionID: "lead", + callID: "decision-question", args: { questions: [ { - header: "Workit decision: action", - question: proposal.presented, + header: "Workit decision: design", + question: "Approve this design?", options: [ - { label: "approved", description: proposal.approvedContent }, + { label: "approved", description: "the design" }, { label: "rejected", description: "Reject this decision" }, ], }, ], }, }, - { metadata: { answers: [["approved"]] } }, + { title: "Decision", output: "approved", metadata: { answers: [["approved"]] } }, ); - const fresh = new TaskStore(root).readTask(task.id); - if (!fresh.ok) throw new Error("task refresh failed"); - const decision = await tools.workit_decision.execute( + const raw = await hooks.tool?.workit_decision.execute( { schemaVersion: 1, action: "record", - taskId: task.id, - expectedRevision: fresh.data.revision, - purpose: "action", + taskId: task.data.id, + expectedRevision: task.data.revision, + purpose: "design", binding: { - taskId: task.id, - workspaceId: workspace.id, - scope: (task as any).intent.data.scope, - presented: proposal.presented, - approvedContent: proposal.approvedContent, + taskId: task.data.id, + workspaceId: workspace.data.id, + scope: scope(), + presented: "Approve this design?", + approvedContent: "the design", contentRefs: [], }, response: "approved", requirementIds: [], }, - { directory: root, sessionID: actor } as never, + { directory: root, sessionID: "lead" } as never, ); - expect(branchOutput(decision)).toMatchObject({ ok: true }); - const result = await tools.workit_external_action.execute(request, { - directory: root, - sessionID: actor, - }); - expect(branchOutput(result)).toMatchObject({ ok: true }); - expect( - spawnSync("git", ["branch", "--show-current"], { cwd: root, encoding: "utf8" }).stdout.trim(), - ).toBe("feature/carry"); - expect(readFileSync(join(root, "docs", "plan.md"), "utf8")).toBe("plan v2\n"); - expect(spawnSync("git", ["stash", "list"], { cwd: root, encoding: "utf8" }).stdout.trim()).toBe( - "", + expect(JSON.parse(raw as string).ok).toBe(true); + const replay = await hooks.tool?.workit_decision.execute( + { + schemaVersion: 1, + action: "record", + taskId: task.data.id, + expectedRevision: task.data.revision, + purpose: "design", + binding: { + taskId: task.data.id, + workspaceId: workspace.data.id, + scope: scope(), + presented: "Approve this design?", + approvedContent: "the design", + contentRefs: [], + }, + response: "approved", + requirementIds: [], + }, + { directory: root, sessionID: "lead" } as never, ); + expect(JSON.parse(replay as string)).toMatchObject({ ok: false, code: "permission_denied" }); } finally { - restoreBranchRepo(repo); + rmSync(root, { recursive: true, force: true }); } }); From 4d32565b35591ba0cfd54470dd07b28c45d3909a Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Wed, 30 Sep 2026 12:48:29 -0300 Subject: [PATCH 2/2] chore(opencode): remove unused action helpers --- .../workit-opencode/src/shared/decision-content.ts | 13 ------------- packages/workit-opencode/src/shared/repo-result.ts | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/workit-opencode/src/shared/decision-content.ts b/packages/workit-opencode/src/shared/decision-content.ts index bee81c25..5e9cf61d 100644 --- a/packages/workit-opencode/src/shared/decision-content.ts +++ b/packages/workit-opencode/src/shared/decision-content.ts @@ -17,16 +17,3 @@ export const decisionContent = ( { label: "rejected", description: rejectedDescription }, ], }); - -/** - * Concise approval text always needs a live proposal to bind it; an exact - * descriptor, plan list, or bare operation binds its own bytes. - */ -export const isSelfAuthorizingActionContent = (content: string): boolean => { - try { - const value = JSON.parse(content) as { operation?: unknown }; - return typeof value.operation === "string" && value.operation.length > 0; - } catch { - return /^[a-z][a-z_]*\.[a-z_]+$/.test(content); - } -}; diff --git a/packages/workit-opencode/src/shared/repo-result.ts b/packages/workit-opencode/src/shared/repo-result.ts index 4b510503..88f89032 100644 --- a/packages/workit-opencode/src/shared/repo-result.ts +++ b/packages/workit-opencode/src/shared/repo-result.ts @@ -3,7 +3,7 @@ import type { RunResult } from "@brainervirus/workit-core/src/core/repo-tools"; export const output = (value: unknown) => JSON.stringify(value, null, 2); -export const diagnostics = ({ stdout, stderr, exitCode }: RunResult) => ({ +const diagnostics = ({ stdout, stderr, exitCode }: RunResult) => ({ stdout, stderr, exitCode,