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 +9 -2
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +80 -17
- package/src/policies/continuation.ts +1 -1
- package/src/workflow-guard.ts +53 -10
- package/test/test.mts +35 -0
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
|
|
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
package/src/lib/custom-tools.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
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
|
},
|
package/src/workflow-guard.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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" }));
|