opencode-workflow-guard 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/policies.md CHANGED
@@ -108,11 +108,13 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
108
108
 
109
109
  ### 15. Compaction State & Policy Context Preservation
110
110
  - Integrates with OpenCode's `experimental.session.compacting` hook to inject bounded active policy state into `output.context` before context summarization:
111
+ - Caps injected continuity context at 8,000 characters, preserving operational guard state first and filling the remaining budget with active tasks, review follow-ups, and fresh local project memory.
111
112
  - Active `todowrite` tasks with status badges and subagent hierarchy attribution.
112
113
  - Active Git branch name and protected branch status.
113
114
  - Test verification status (passed/failed, test command, and commit hash).
114
115
  - Secondary review verdicts (reviewer name and approval status).
115
116
  - Uncommitted mutation counts.
117
+ - Compacted project-memory context points back to `project_memory_search` for deeper historical retrieval instead of attempting to embed the full durable store.
116
118
  - Preserves policy-relevant context across session compactions without assigning, prioritizing, or sequencing tasks for the model.
117
119
 
118
120
  ### 16. TUI Visual Feedback & Static Status Badge
@@ -155,8 +157,13 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
155
157
  - Mismatches (failing verification, stale evidence due to mutations after the verify run, or no evidence at all) are journaled to the audit trail and logged at info level. This is **observability, not gating**: the response is never blocked, but a confident wrap-up cannot silently contradict a failing or absent verification state.
156
158
  - Uses the `experimental.text.complete` hook; claim detection is a conservative phrase heuristic to avoid false positives on casual wording.
157
159
 
158
- ### Tool Description Honesty
159
- - Via the `tool.definition` hook, the guard enriches the `todowrite` tool's description with its replacement-list lifecycle and finalization gates (including verification evidence required after the last mutation), so the model is not surprised by preventable lifecycle blocks. Other tools are untouched, and the enrichment is idempotent.
160
+ ### Tool Description Honesty & Cross-Model Adherence
161
+ - Via the `tool.definition` hook, the guard enriches tool descriptions to keep them honest and proactive across all model families (OpenAI, Gemini, Claude, and open-weights models):
162
+ - `todowrite`: Explains its replacement-list lifecycle, finalization verification gate, and directs models to `guard_next_tasks` for roadmap discovery and `guard_status` for gate checks.
163
+ - Mutating tools (`edit`, `write`, `apply_patch`): States preconditions (active todo, feature branch, prior read).
164
+ - `task`: Guides orchestrator agents to isolate parallel mutations with `guard_worktree_create` and conduct secondary reviews via `guard_review_rubric` and `record_review`.
165
+ - Via the `experimental.chat.system.transform` hook, the guard provides concise operational guidelines in the system prompt detailing available guard tools (`guard_next_tasks`, `guard_status`, `guard_review_rubric`, `guard_worktree_create`, `project_memory_*`, `learning_*`, `guard_why`), heavily encouraging open models and Gemini/Claude to proactively use workflow discipline features throughout development.
166
+ - Companion tools feature proactive action triggers ("Proactively call...", "Recommended at session start..."), and `guard_status` returns actionable `recommendedActions` directing models to the exact next step.
160
167
 
161
168
  ---
162
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -53,6 +53,51 @@ export function extractReviewFollowups(summary: string): Array<{ severity: "P2"
53
53
  .map((finding) => ({ severity: finding.severity, summary: finding.line }));
54
54
  }
55
55
 
56
+ export function buildWorkflowGuardSystemGuidance(options: {
57
+ projectMemoryEnabled?: boolean;
58
+ learningEnabled?: boolean;
59
+ recoveryCheckpointsEnabled?: boolean;
60
+ isReadOnly?: boolean;
61
+ }): string {
62
+ const sections: string[] = [
63
+ "## Workflow Guard & Operational Tools",
64
+ "Workflow Guard enforces safe engineering discipline and provides proactive workflow tools:",
65
+ "- Task Planning: Proactively call `guard_next_tasks` at session start or when planning work to discover repository roadmap, plan, and TODO items. Check `guard_status` to inspect branch protection, mutation count, and outstanding verification/review gates.",
66
+ ];
67
+
68
+ if (!options.isReadOnly) {
69
+ sections.push(
70
+ "- Review & Verification: Before completing tasks or creating PRs, run tests to produce fresh verification evidence, retrieve the review rubric via `guard_review_rubric` (5 core axes: test integrity, task completeness, cleanliness, security, platform), and spawn a reviewer subagent via `task` that records its verdict with `record_review`.",
71
+ "- Worktree Isolation: When orchestrating parallel subagent tasks with `task`, call `guard_worktree_create` to prevent concurrent file conflicts, and `guard_worktree_cleanup` when finished.",
72
+ );
73
+ }
74
+
75
+ sections.push(
76
+ "- Technical Debt: Inspect open P2/P3 review follow-ups with `guard_review_followups` during planning; resolve fixed items with `guard_review_followup_resolve`.",
77
+ "- Policy Simulation: If uncertain whether a command or file edit is permitted, simulate it with `guard_why`.",
78
+ );
79
+
80
+ if (options.projectMemoryEnabled) {
81
+ sections.push(
82
+ "- Project Memory: Proactively call `project_memory_search` before architectural changes or complex implementations. Record durable lessons, architectural decisions, and repo constraints with `project_memory_record`.",
83
+ );
84
+ }
85
+
86
+ if (options.learningEnabled) {
87
+ sections.push(
88
+ "- Socratic Learning: When facing non-trivial architectural trade-offs, design choices, or debugging breakthroughs, call `learning_checkpoint`. If selected (intervene: true), ask one concise question via `question` before proceeding, and capture observed understanding with `learning_record`.",
89
+ );
90
+ }
91
+
92
+ if (options.recoveryCheckpointsEnabled) {
93
+ sections.push(
94
+ "- Recovery: If workspace state needs rollback to the pre-run checkpoint, use `guard_recovery_restore`.",
95
+ );
96
+ }
97
+
98
+ return sections.join("\n");
99
+ }
100
+
56
101
  export function createCustomTools(options: {
57
102
  effectiveRoot: string;
58
103
  projectMemoryEnabled: boolean;
@@ -80,7 +125,7 @@ export function createCustomTools(options: {
80
125
  }),
81
126
  } : {}),
82
127
  guard_next_tasks: tool({
83
- description: "Load durable repository task context when deciding what to work on next. Prefers TODO.md; if absent, discovers conventional roadmap, plan, tasks, backlog, and docs/plans Markdown files.",
128
+ description: "Load durable repository task context when deciding what to work on next or planning upcoming steps. Proactively call at session start or during planning to discover roadmap, plan, tasks, backlog, and TODO.md files.",
84
129
  args: {},
85
130
  execute: async () => {
86
131
  const sources = discoverPlanningSources(effectiveRoot);
@@ -89,12 +134,12 @@ export function createCustomTools(options: {
89
134
  }),
90
135
  ...(projectMemoryEnabled ? {
91
136
  project_memory_search: tool({
92
- description: "Search current durable knowledge for this project. Returns concise typed records with provenance; superseded records are excluded.",
137
+ description: "Search current durable knowledge for this project, including past architectural decisions, constraints, and conventions. Proactively call before starting complex architectural changes, refactors, or new feature implementations.",
93
138
  args: { query: tool.schema.string() },
94
139
  execute: async (args) => projectMemory ? JSON.stringify(args.query.length <= 500 ? searchProjectMemory(projectMemory, args.query, 8) : [], null, 2) : "[workflow-guard] Project memory unavailable; core guard enforcement remains active.",
95
140
  }),
96
141
  project_memory_record: tool({
97
- description: "Record a durable project fact, decision, constraint, or lesson when it will matter in future sessions. Do not record transient tool output, secrets, or speculative hypotheses.",
142
+ description: "Record a durable project fact, decision, constraint, or lesson when it will matter in future sessions. Proactively call after making important architectural choices, discovering tricky repo constraints, or establishing new patterns.",
98
143
  args: { kind: tool.schema.string().describe("fact, decision, constraint, or lesson"), content: tool.schema.string(), paths: tool.schema.array(tool.schema.string()), supersedes: tool.schema.string().optional() },
99
144
  execute: async (args, toolContext) => {
100
145
  if (!projectMemory) return "[workflow-guard] Project memory unavailable; core guard enforcement remains active.";
@@ -105,7 +150,7 @@ export function createCustomTools(options: {
105
150
  },
106
151
  }),
107
152
  project_memory_export: tool({
108
- description: "Explicitly promote selected durable project-memory records to the human-readable repo-local .opencode/memory/project-memory.jsonl file. Nothing is exported automatically.",
153
+ description: "Explicitly promote selected durable project-memory records to the human-readable repo-local .opencode/memory/project-memory.jsonl file. Call when durable knowledge should be committed and shared with the team.",
109
154
  args: { ids: tool.schema.array(tool.schema.string()) },
110
155
  execute: async (args) => {
111
156
  if (!projectMemory) return "[workflow-guard] Project memory unavailable; core guard enforcement remains active.";
@@ -114,12 +159,12 @@ export function createCustomTools(options: {
114
159
  return `[workflow-guard] Exported ${count} project-memory record(s) to .opencode/memory/project-memory.jsonl.`;
115
160
  },
116
161
  }),
117
- project_memory_import: tool({ description: "Import the fixed repo-local .opencode/memory/project-memory.jsonl file into this project's local working-memory index.", args: {}, execute: async () => projectMemory ? `[workflow-guard] Imported ${importProjectKnowledge(projectMemory, portableMemoryPath, (content) => secretIn(content) !== undefined)} new project-memory record(s).` : "[workflow-guard] Project memory unavailable; core guard enforcement remains active." }),
162
+ project_memory_import: tool({ description: "Import the repo-local .opencode/memory/project-memory.jsonl file into this project's local working-memory index. Call to synchronize local project memory with shared repository records.", args: {}, execute: async () => projectMemory ? `[workflow-guard] Imported ${importProjectKnowledge(projectMemory, portableMemoryPath, (content) => secretIn(content) !== undefined)} new project-memory record(s).` : "[workflow-guard] Project memory unavailable; core guard enforcement remains active." }),
118
163
  } : {}),
119
164
  ...(learningEnabled ? {
120
- learning_profile: tool({ description: "Inspect the local evidence-based learner profile so teaching can build on demonstrated knowledge without assuming unobserved concepts are gaps.", args: {}, execute: async () => JSON.stringify(loadLearnerProfile(), null, 2) }),
165
+ learning_profile: tool({ description: "Inspect the local evidence-based learner profile so explanations and teaching can build on demonstrated knowledge without assuming unobserved concepts are gaps.", args: {}, execute: async () => JSON.stringify(loadLearnerProfile(), null, 2) }),
121
166
  learning_checkpoint: tool({
122
- description: "At a high-value design decision, debugging moment, or important new concept, rank candidate Socratic learning opportunities. If one is selected, ask one concise question before continuing the work; do not turn routine syntax into a lesson.",
167
+ description: "At a high-value design decision, debugging moment, or important new concept, rank candidate Socratic learning opportunities. Proactively call when encountering non-trivial architectural trade-offs, design choices, or tricky bugs. If selected (intervene: true), ask one concise question via question before continuing; do not turn routine syntax into a lesson.",
123
168
  args: { opportunities: tool.schema.array(tool.schema.object({ type: tool.schema.string().describe("design, debugging, or new-concept"), concept: tool.schema.string().describe("Transferable engineering concept"), relevance: tool.schema.number().describe("Current-task relevance from 0 to 1"), consequence: tool.schema.number().describe("Decision consequence from 0 to 1") })) },
124
169
  execute: async (args, toolContext) => {
125
170
  const valid = args.opportunities.slice(0, 20).filter((candidate): candidate is typeof candidate & { type: "design" | "debugging" | "new-concept" } => (candidate.type === "design" || candidate.type === "debugging" || candidate.type === "new-concept") && candidate.concept.length > 0 && candidate.concept.length <= 100 && candidate.relevance >= 0 && candidate.relevance <= 1 && candidate.consequence >= 0 && candidate.consequence <= 1);
@@ -131,7 +176,7 @@ export function createCustomTools(options: {
131
176
  },
132
177
  }),
133
178
  learning_record: tool({
134
- description: "Record concise evidence from a real Socratic interaction after observing the learner's reasoning. Record what was demonstrated, not a grade or an inferred deficit.",
179
+ description: "Record concise evidence from a real Socratic interaction after observing the learner's reasoning and response to a learning checkpoint question. Record what was demonstrated, not a grade or an inferred deficit.",
135
180
  args: { concept: tool.schema.string(), kind: tool.schema.string().describe("exposed, developing, demonstrated, independent, critique, or needs-reinforcement"), summary: tool.schema.string().describe("Short factual description of observed reasoning") },
136
181
  execute: async (args, toolContext) => {
137
182
  const validKinds = new Set(["exposed", "developing", "demonstrated", "independent", "critique", "needs-reinforcement"]);
@@ -151,7 +196,7 @@ export function createCustomTools(options: {
151
196
  }),
152
197
  } : {}),
153
198
  guard_status: tool({
154
- description: "Inspect active guardrails, current branch protection, and verification/review status.", args: {},
199
+ description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. Proactively call at session start and before completing tasks or creating PRs.", args: {},
155
200
  execute: async (_args, toolContext) => {
156
201
  const root = effectiveRoot; const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResultForWorkspace(root); const lastR = getLastReviewResultForWorkspace(root); const lastMut = getWorkspaceMutationTimestamp(root); const cfg = loadProjectConfig(root);
157
202
  const subject = { workspace: projectRootKey(root), commitHash: getCurrentGitCommitHash(root), worktreeFingerprint: getGitWorktreeFingerprint(root) };
@@ -167,14 +212,32 @@ export function createCustomTools(options: {
167
212
  ...(reviewRequired && !reviewFresh ? ["review"] : []),
168
213
  ...(documentationRequired && !branchHasDocumentationChange(root) ? ["documentation"] : []),
169
214
  ];
215
+ const recommendedActions: string[] = [];
216
+ if (outstandingRequirements.includes("verification")) {
217
+ recommendedActions.push(verifyCommand ? `Execute test verification command '${verifyCommand}' to produce fresh verification evidence.` : "Run project tests to establish fresh verification evidence.");
218
+ }
219
+ if (outstandingRequirements.includes("review")) {
220
+ recommendedActions.push("Call guard_review_rubric to retrieve the 5-axis review rubric, then spawn a secondary reviewer subagent via task to evaluate changes and record verdict with record_review.");
221
+ }
222
+ if (outstandingRequirements.includes("documentation")) {
223
+ recommendedActions.push("Update documentation files (e.g. README.md, docs/) to satisfy the documentation gate before opening a PR.");
224
+ }
225
+ let openFollowupsCount = 0;
226
+ try { openFollowupsCount = followupStore ? listReviewFollowups(followupStore, "open", 10).length : 0; } catch {}
227
+ if (openFollowupsCount > 0) {
228
+ recommendedActions.push(`Inspect ${openFollowupsCount} open P2/P3 review follow-up(s) with guard_review_followups and resolve with guard_review_followup_resolve once addressed.`);
229
+ }
230
+ if (recommendedActions.length === 0) {
231
+ recommendedActions.push("All operational requirements are satisfied for task finalization and PR creation.");
232
+ }
170
233
  const ralphOutcome = runWithRuntimeState(root, client, () => getRalphOutcome(toolContext.sessionID));
171
- return JSON.stringify({ workspaceRoot: root, branch, onProtectedBranch: isProtected, outstandingRequirements, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) } }, null, 2);
234
+ return JSON.stringify({ workspaceRoot: root, branch, onProtectedBranch: isProtected, outstandingRequirements, recommendedActions, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) } }, null, 2);
172
235
  },
173
236
  }),
174
- guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
175
- guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(effectiveRoot, client, () => guardToolCallImpl(args.tool, args.input ?? {}, { sessionID: toolContext.sessionID, worktree: toolContext.worktree, directory: toolContext.directory, simulate: true })), null, 2) }),
237
+ guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard. Use to diagnose policy decisions, blocked commands, or outcome telemetry.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
238
+ guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command. Proactively use before executing questionable or complex commands to check if they would be blocked by guard policies.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(effectiveRoot, client, () => guardToolCallImpl(args.tool, args.input ?? {}, { sessionID: toolContext.sessionID, worktree: toolContext.worktree, directory: toolContext.directory, simulate: true })), null, 2) }),
176
239
  record_review: tool({
177
- description: "Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric.",
240
+ description: "Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric (test integrity, task completeness, cleanliness, security, platform).",
178
241
  args: { reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"), summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"), passed: tool.schema.boolean().describe("True if change is approved, false if changes requested") },
179
242
  execute: async (args, toolContext) => {
180
243
  const auditVerdict = (verdict: "approved" | "changes_requested" | "rejected", reason: string) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_review.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { reviewVerdict: verdict } });
@@ -190,10 +253,10 @@ export function createCustomTools(options: {
190
253
  return args.passed ? `[workflow-guard] Review recorded as APPROVED by ${args.reviewer}.` : `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}.`;
191
254
  },
192
255
  }),
193
- guard_review_followups: tool({ description: "List durable local P2/P3 review follow-ups for this project. Open findings are technical debt that should be addressed rather than indefinitely deferred.", args: {}, execute: async () => JSON.stringify(followupStore ? listReviewFollowups(followupStore) : [], null, 2) }),
256
+ guard_review_followups: tool({ description: "List durable local P2/P3 review follow-ups (technical debt) for this project. Proactively check during planning or before final verification to address open findings.", args: {}, execute: async () => JSON.stringify(followupStore ? listReviewFollowups(followupStore) : [], null, 2) }),
194
257
  guard_review_followup_resolve: tool({ description: "Resolve a durable local review follow-up after the underlying issue has been fixed and verified.", args: { id: tool.schema.string() }, execute: async (args) => followupStore && resolveReviewFollowup(followupStore, args.id) ? `[workflow-guard] Review follow-up ${args.id} resolved.` : `[workflow-guard] Review follow-up ${args.id} was not open or was not found.` }),
195
258
  guard_review_rubric: tool({
196
- description: "Get the secondary-review rubric for the current branch diff. The orchestrator calls this, spawns a reviewer subagent with the rubric as the prompt, then the reviewer records its verdict via record_review.", args: { base: tool.schema.string().optional().describe("Base ref to diff against (default: origin/main, origin/master, main)") },
259
+ description: "Get the secondary-review rubric for the current branch diff across 5 core review axes (test integrity, task completeness, cleanliness, security, platform). The orchestrator should call this before finalizing work or creating a PR, then spawn a reviewer subagent with the rubric as the prompt, which records its verdict via record_review.", args: { base: tool.schema.string().optional().describe("Base ref to diff against (default: origin/main, origin/master, main)") },
197
260
  execute: async (args) => {
198
261
  const sanitizedBase = typeof args.base === "string" && !args.base.startsWith("-") && !/[\s;'"\0]/.test(args.base) ? args.base : undefined;
199
262
  const bases = sanitizedBase ? [sanitizedBase] : ["origin/main", "origin/master", "main", "master"];
@@ -204,7 +267,7 @@ export function createCustomTools(options: {
204
267
  },
205
268
  }),
206
269
  guard_worktree_create: tool({
207
- description: "Create an isolated git worktree directory for concurrent subagent execution.", args: { branch: tool.schema.string().describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"), baseBranch: tool.schema.string().optional().describe("Base branch to branch off of (defaults to HEAD)") },
270
+ description: "Create an isolated git worktree directory for concurrent subagent execution. Proactively call when launching parallel tasks/subagents to prevent file collision and maintain branch isolation.", args: { branch: tool.schema.string().describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"), baseBranch: tool.schema.string().optional().describe("Base branch to branch off of (defaults to HEAD)") },
208
271
  execute: async (args, toolContext) => {
209
272
  const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree creation with no active todo item. Break the request down with todowrite first, then create worktrees.";
210
273
  const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot); if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}`;
@@ -212,7 +275,7 @@ export function createCustomTools(options: {
212
275
  },
213
276
  }),
214
277
  guard_worktree_cleanup: tool({
215
- description: "Commit a final snapshot and remove an isolated git worktree directory.", args: { worktreePath: tool.schema.string().describe("Path of the worktree directory to clean up") },
278
+ description: "Commit a final snapshot and remove an isolated git worktree directory. Call to clean up worktrees created with guard_worktree_create after subagent tasks complete.", args: { worktreePath: tool.schema.string().describe("Path of the worktree directory to clean up") },
216
279
  execute: async (args, toolContext) => {
217
280
  const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree cleanup with no active todo item. Break the request down with todowrite first, then clean up worktrees.";
218
281
  const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = cleanupGitWorktree(args.worktreePath, toolRoot); if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
@@ -97,7 +97,7 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
97
97
  messageID,
98
98
  parts: [{
99
99
  type: "text",
100
- text: `Workflow Guard: unfinished todos remain. Continue working through them.${tasksSummary}\nIf you need user input, use the question tool instead of ending the run.`,
100
+ text: `Workflow Guard: unfinished todos remain. Continue working through them.${tasksSummary}\nIf you need user input, use the question tool instead of ending the run. Check guard_status for outstanding verification/review requirements, and guard_review_rubric before final completion.`,
101
101
  synthetic: true,
102
102
  }],
103
103
  },
@@ -18,10 +18,10 @@ import { beginReadObservation, clearReadFingerprints, recordSuccessfulRead, stal
18
18
  import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
19
19
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
20
20
  import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
21
- import { createCustomTools } from "./lib/custom-tools.ts";
21
+ import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
22
22
  import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
23
23
  export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
24
- export { extractReviewFollowups } from "./lib/custom-tools.ts";
24
+ export { extractReviewFollowups, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
25
25
 
26
26
  // ── Types ────────────────────────────────────────────────────────────────────
27
27
  export type {
@@ -345,6 +345,24 @@ async function emitBlockFeedback(message: string): Promise<void> {
345
345
  await showBlockToast(message);
346
346
  }
347
347
 
348
+ export function buildCompactionContext(operationalState: string, priorityBlocks: string[], maxChars = 8_000): string {
349
+ if (!Number.isFinite(maxChars) || maxChars < 0 || !Number.isInteger(maxChars)) {
350
+ throw new RangeError("maxChars must be a non-negative finite integer");
351
+ }
352
+ const truncate = (value: string, limit: number): string => {
353
+ let end = Math.min(value.length, limit);
354
+ if (end > 0 && end < value.length && /[\uD800-\uDBFF]/.test(value[end - 1])) end -= 1;
355
+ return value.slice(0, end);
356
+ };
357
+ let context = truncate(operationalState, maxChars);
358
+ for (const block of priorityBlocks) {
359
+ const remaining = maxChars - context.length - 2;
360
+ if (remaining <= 0) break;
361
+ context += `\n\n${truncate(block, remaining)}`;
362
+ }
363
+ return context;
364
+ }
365
+
348
366
  export const WorkflowGuard: Plugin = async (ctx) => {
349
367
  // Honor worktree if present (e.g. opencode worktrees or devcontainers)
350
368
  // so worktree plugins cannot punch through boundary gates. When the host
@@ -416,7 +434,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
416
434
  await emitBlockFeedback(reason);
417
435
  const failureCount = input.sessionID ? toolOutcomes.getFailureCount(input.sessionID) : 0;
418
436
  const circuitBreakerSuffix = failureCount >= 2
419
- ? "\n\n[Workflow Guard Circuit Breaker: Repeated failures detected in this session. Stop attempting alternative workarounds or shell laundering. Address the required step above directly.]"
437
+ ? "\n\n[Workflow Guard Circuit Breaker: Repeated failures detected in this session. Stop attempting alternative workarounds or shell laundering. Address the required step above directly, or inspect policy details with guard_status or guard_why.]"
420
438
  : "";
421
439
  throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
422
440
  }
@@ -484,7 +502,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
484
502
  : getLastReviewResult();
485
503
  const mutationCountVal = sessionID ? sessionMutationCount : getMutationCount();
486
504
 
487
- const contextBlocks: string[] = [];
505
+ const priorityContextBlocks: string[] = [];
488
506
 
489
507
  if (active && active.length > 0) {
490
508
  const lines = active.slice(0, 20).map(
@@ -497,7 +515,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
497
515
  : sessionID
498
516
  ? ` (Session: ${sessionID})`
499
517
  : "";
500
- contextBlocks.push(
518
+ priorityContextBlocks.push(
501
519
  `## Active Tasks${attribution}\n` +
502
520
  lines.join("\n") +
503
521
  "\nComplete tasks efficiently - mark finished items as completed and address remaining ones.",
@@ -523,9 +541,9 @@ export const WorkflowGuard: Plugin = async (ctx) => {
523
541
  try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
524
542
  if (openFollowups.length > 0) {
525
543
  stateLines.push(`- Open Review Follow-ups: ${openFollowups.length} local P2/P3 item(s)`);
526
- contextBlocks.push(`## Review Follow-ups\n${openFollowups.map((item) => `- [${item.severity}:${item.id.slice(0, 8)}] ${item.summary.slice(0, 300)}`).join("\n")}\nTreat these as durable technical debt: address relevant items when practical and resolve them explicitly after verification.`);
544
+ priorityContextBlocks.push(`## Review Follow-ups\n${openFollowups.map((item) => `- [${item.severity}:${item.id.slice(0, 8)}] ${item.summary.slice(0, 300)}`).join("\n")}\nTreat these as durable technical debt: address relevant items when practical and resolve them explicitly after verification.`);
527
545
  }
528
- contextBlocks.push(stateLines.join("\n"));
546
+ const operationalState = stateLines.join("\n");
529
547
  let projectKnowledge: ReturnType<typeof getRecentProjectMemory> = [];
530
548
  try {
531
549
  const candidates = (projectMemory ? getRecentProjectMemory(projectMemory, 8) : [])
@@ -535,11 +553,11 @@ export const WorkflowGuard: Plugin = async (ctx) => {
535
553
  } catch {}
536
554
  if (projectKnowledge.length > 0) {
537
555
  const lines = projectKnowledge.map((memory) => `- [${memory.kind}:${memory.id.slice(0, 8)}] ${memory.content.slice(0, 300)}`);
538
- contextBlocks.push(`## Project Memory\n${lines.join("\n")}\nTreat these as historical project knowledge; verify against current repository state when relevant files have changed.`);
556
+ priorityContextBlocks.push(`## Project Memory\nUse project_memory_search when deeper historical context is needed. Treat these as historical project knowledge; verify against current repository state when relevant files have changed.\n${lines.join("\n")}`);
539
557
  }
540
558
 
541
559
  if (Array.isArray(output?.context)) {
542
- output.context.push(contextBlocks.join("\n\n"));
560
+ output.context.push(buildCompactionContext(operationalState, priorityContextBlocks));
543
561
  }
544
562
  } catch {}
545
563
  },
@@ -611,13 +629,14 @@ export const WorkflowGuard: Plugin = async (ctx) => {
611
629
  // Keep tool descriptions honest: todowrite's description reflects lifecycle
612
630
  // and finalization gates so the model is not surprised by preventable blocks.
613
631
  // Similarly, mutating tools reflect their active-todo, feature-branch, and prior-read requirements.
632
+ // Subagent orchestration tools reflect worktree isolation and review expectations.
614
633
  "tool.definition": async (input, output) => {
615
634
  if (input.toolID === "todowrite") {
616
635
  const description = typeof output.description === "string" ? output.description : "";
617
636
  if (description.includes("verification evidence")) return;
618
637
  output.description =
619
638
  description +
620
- "\n\nWorkflow Guard lifecycle: each todowrite call replaces the complete task list. Preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled; do not omit active tasks when adding new work. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply.";
639
+ "\n\nWorkflow Guard lifecycle: each todowrite call replaces the complete task list. Preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled; do not omit active tasks when adding new work. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply. Proactively call guard_next_tasks when planning work to discover existing roadmap/TODOs, and guard_status to check outstanding requirements.";
621
640
  return;
622
641
  }
623
642
  if (EDIT_TOOL_NAMES.has(input.toolID)) {
@@ -628,6 +647,30 @@ export const WorkflowGuard: Plugin = async (ctx) => {
628
647
  "\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
629
648
  return;
630
649
  }
650
+ if (input.toolID === "task") {
651
+ const description = typeof output.description === "string" ? output.description : "";
652
+ if (description.includes("Workflow Guard subagent guidance")) return;
653
+ output.description =
654
+ description +
655
+ "\n\nWorkflow Guard subagent guidance: When spawning parallel worker subagents, use guard_worktree_create to isolate file mutations in separate worktrees. When conducting code reviews before task completion or PR creation, fetch the rubric with guard_review_rubric and provide it to a reviewer subagent to evaluate and record via record_review.";
656
+ return;
657
+ }
658
+ },
659
+
660
+ "experimental.chat.system.transform": async (input, output) => {
661
+ try {
662
+ if (!Array.isArray(output?.system)) return;
663
+ const existing = output.system.join("\n");
664
+ if (existing.includes("## Workflow Guard & Operational Tools")) return;
665
+ const isReadOnly = (input as { agent?: string })?.agent ? isReadOnlyRole((input as { agent?: string }).agent!) : false;
666
+ const guidance = buildWorkflowGuardSystemGuidance({
667
+ projectMemoryEnabled,
668
+ learningEnabled,
669
+ recoveryCheckpointsEnabled: isRecoveryCheckpointsEnabled(effectiveRoot),
670
+ isReadOnly,
671
+ });
672
+ output.system.push(guidance);
673
+ } catch {}
631
674
  },
632
675
 
633
676
  "chat.message": async (input) => {
package/test/test.mts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  setWorkspaceRoot,
13
13
  setSdkClient,
14
14
  WorkflowGuard,
15
+ buildCompactionContext,
15
16
  detectVerifyCommand,
16
17
  runVerify,
17
18
  getCleanEnv,
@@ -565,6 +566,13 @@ check("multi-target tee with an outside path is blocked", blocked(await call("ba
565
566
  check("tee --append within workspace is allowed with todos", !(await call("bash", { command: "echo x | tee --append src/a.ts" }, { sessionID: "s-active" })));
566
567
 
567
568
  console.log("- Compaction focus preservation & TUI toast -");
569
+ const boundedCompaction = buildCompactionContext("## Operational Guard State\n- critical", ["## Active Tasks\n" + "a".repeat(10_000), "## Project Memory\nshould-not-fit"], 500);
570
+ check("compaction packer enforces its total context budget", boundedCompaction.length === 500);
571
+ check("compaction packer preserves mandatory operational state before optional context", boundedCompaction.startsWith("## Operational Guard State\n- critical") && !boundedCompaction.includes("should-not-fit"));
572
+ check("compaction packer preserves the largest valid Unicode prefix", buildCompactionContext("critical \u{1F680}state", [], 10) === "critical ");
573
+ let invalidCompactionBudgetRejected = false;
574
+ try { buildCompactionContext("critical", [], Number.NaN); } catch (error) { invalidCompactionBudgetRejected = error instanceof RangeError; }
575
+ check("compaction packer rejects invalid public budgets", invalidCompactionBudgetRejected);
568
576
  let toasts: unknown[] = [];
569
577
  const toastClient = {
570
578
  session: fakeClient.session,
@@ -588,6 +596,8 @@ if (typeof compactFn === "function") {
588
596
  await compactFn({ sessionID: "s-active" } as any, compactOutput as any);
589
597
  }
590
598
  check("compaction hook injects active tasks into output.context", compactOutput.context.length > 0 && (compactOutput.context[0]?.includes("Active Tasks") ?? false));
599
+ check("compaction hook keeps injected continuity context bounded", compactOutput.context.every((context) => context.length <= 8000));
600
+ check("compaction hook preserves operational guard state within its budget", compactOutput.context.some((context) => context.includes("## Operational Guard State") && context.includes("Git Branch:")));
591
601
 
592
602
  // Blocked tool call emits one in-app warning via tui.showToast.
593
603
  toasts = [];
@@ -1899,6 +1909,10 @@ check("guard_status exposes subject-bound evidence references", parsedStatus.las
1899
1909
  check("guard_status exposes effective operation profile without TUI state", parsedStatus.projectConfig.profile === "interactive" && typeof parsedStatus.projectConfig.recoveryCheckpoints === "boolean");
1900
1910
  check("guard_status exposes disabled-by-default Ralph accountability state", parsedStatus.ralph?.enabled === false && parsedStatus.ralph?.maxIterations === 10 && parsedStatus.ralph?.outcome === null);
1901
1911
  check("guard_status exposes bounded outstanding requirements", Array.isArray(parsedStatus.outstandingRequirements) && parsedStatus.outstandingRequirements.every((requirement: unknown) => ["verification", "review", "documentation"].includes(String(requirement))));
1912
+ check("guard_status exposes actionable recommended actions", Array.isArray(parsedStatus.recommendedActions) && parsedStatus.recommendedActions.length > 0);
1913
+ check("guard_next_tasks description includes proactive trigger", Boolean(customPlugin.tool?.guard_next_tasks?.description.includes("Proactively call")));
1914
+ check("project_memory_search description includes proactive trigger", Boolean(customPlugin.tool?.project_memory_search?.description?.includes("Proactively call")));
1915
+ check("guard_review_rubric description includes orchestrator trigger", Boolean(customPlugin.tool?.guard_review_rubric?.description?.includes("orchestrator should call")));
1902
1916
 
1903
1917
  const requirementsRoot = mkdtempSync(join(tmpdir(), "wg-status-requirements-"));
1904
1918
  mkdirSync(join(requirementsRoot, ".opencode"), { recursive: true });
@@ -3208,6 +3222,26 @@ const editIdemDef = { description: editDef.description, parameters: {} };
3208
3222
  await defPlugin["tool.definition"]?.({ toolID: "edit" } as any, editIdemDef);
3209
3223
  check("tool.definition edit enrichment is idempotent", editIdemDef.description === editDef.description);
3210
3224
 
3225
+ const taskDef = { description: "Launch subagents.", parameters: {} };
3226
+ await defPlugin["tool.definition"]?.({ toolID: "task" } as any, taskDef);
3227
+ check("tool.definition enriches task tool with subagent and review guidance", taskDef.description.includes("Workflow Guard subagent guidance") && taskDef.description.includes("guard_worktree_create") && taskDef.description.includes("guard_review_rubric"));
3228
+ const taskIdemDef = { description: taskDef.description, parameters: {} };
3229
+ await defPlugin["tool.definition"]?.({ toolID: "task" } as any, taskIdemDef);
3230
+ check("tool.definition task enrichment is idempotent", taskIdemDef.description === taskDef.description);
3231
+ check("tool.definition todowrite enriches with planning and status guidance", todoDef.description.includes("guard_next_tasks") && todoDef.description.includes("guard_status"));
3232
+
3233
+ // experimental.chat.system.transform provides cross-model workflow guidance
3234
+ const systemOutput = { system: ["Base system prompt"] };
3235
+ await defPlugin["experimental.chat.system.transform"]?.({ model: { id: "gemini-flash" } as any }, systemOutput);
3236
+ check("experimental.chat.system.transform adds workflow guard guidance", systemOutput.system.length === 2 && systemOutput.system[1].includes("## Workflow Guard & Operational Tools") && systemOutput.system[1].includes("guard_next_tasks") && systemOutput.system[1].includes("guard_review_rubric"));
3237
+ const systemOutputIdem = { system: [...systemOutput.system] };
3238
+ await defPlugin["experimental.chat.system.transform"]?.({ model: { id: "gemini-flash" } as any }, systemOutputIdem);
3239
+ check("experimental.chat.system.transform is idempotent", systemOutputIdem.system.length === 2);
3240
+
3241
+ const readOnlySystemOutput = { system: ["Base system prompt"] };
3242
+ await defPlugin["experimental.chat.system.transform"]?.({ agent: "reviewer", model: { id: "claude-sonnet" } as any } as any, readOnlySystemOutput);
3243
+ check("experimental.chat.system.transform respects read-only roles", !readOnlySystemOutput.system[1].includes("spawn a reviewer subagent"));
3244
+
3211
3245
  // Socratic learning engine: evidence is explicit and interventions favor
3212
3246
  // relevant gaps without repeatedly interrupting demonstrated knowledge.
3213
3247
  const learner = createLearnerProfile();
@@ -3287,6 +3321,7 @@ const learningPlugin = await WorkflowGuard({ directory: root, worktree: root, cl
3287
3321
  check("learning mode registers profile tool when explicitly enabled", !!learningPlugin.tool?.learning_profile);
3288
3322
  check("learning mode registers adaptive checkpoint tool", !!learningPlugin.tool?.learning_checkpoint);
3289
3323
  check("learning mode registers evidence recorder", !!learningPlugin.tool?.learning_record);
3324
+ check("learning_checkpoint description includes proactive trigger", Boolean(learningPlugin.tool?.learning_checkpoint?.description?.includes("Proactively call")));
3290
3325
  const checkpointResult = JSON.parse(await (learningPlugin.tool as any).learning_checkpoint.execute({
3291
3326
  opportunities: [{ type: "design", concept: "application-state", relevance: 1, consequence: 0.9 }],
3292
3327
  }, { sessionID: "s-learning-tools" }));