opencode-workflow-guard 1.2.1 → 1.3.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/README.md CHANGED
@@ -79,6 +79,7 @@ opencode-workflow-guard/
79
79
  | **21** | **Documentation Synchronization** | Verifies documentation is updated when introducing new public features or policies. |
80
80
  | **22** | **TTY Hang Guard** | Blocks interactive editors (`vim`, `nano`), pagers (`less`), and commands missing non-interactive flags (`npm init` without `-y`). |
81
81
  | **23** | **Package Supply-Chain Hygiene** | Blocks destructive `npm audit fix --force`, global unversioned installs (`npm i -g`), and direct CLI `npm publish`. |
82
+ | **24** | **Completion Claims vs Evidence** | When the final response asserts completion/passing tests, the claim is compared against recorded verification evidence; failing, stale, or missing evidence is journaled (observability, never blocks). Also enriches `todowrite`'s tool description with the finalization-gate note via `tool.definition`. |
82
83
 
83
84
  👉 **For complete policy specifications and override rules, see [docs/policies.md](docs/policies.md).**
84
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "OpenCode plugin enforcing workflow discipline through deterministic tool.execute.before hooks",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,90 @@
1
+ import { sessionVerifyResults, lastVerify, sessionMutationTimestamps, lastMutationTimestamp } from "../lib/state.ts";
2
+ import type { VerifyResult } from "../lib/types.ts";
3
+
4
+ /**
5
+ * Claims-vs-evidence check (Policy 24): when the assistant's final text
6
+ * asserts that work is complete or that tests pass, compare the claim against
7
+ * recorded verification evidence. This is observability, not gating - the
8
+ * hook audits mismatches so a confident wrap-up cannot silently contradict a
9
+ * failing or stale verification state.
10
+ *
11
+ * Heuristic by design: natural-language claims are matched on explicit
12
+ * completion/verification phrases. Only high-signal assertions
13
+ * ("all tests pass", "done", "verified", "green build") count; casual usage
14
+ * of the same words ("testing the fix", "it passed earlier") should not trip
15
+ * the detector on multi-sentence responses without assertion framing.
16
+ */
17
+
18
+ const COMPLETION_CLAIM_RE =
19
+ /\b(?:all\s+(?:tests?|checks?)\s+(?:pass(?:es|ed|ing)?|green|succeed(?:ed|ing)?)|tests?\s+(?:are\s+)?(?:all\s+)?(?:pass(?:ing|ed)?|green)|build\s+(?:is\s+)?green|(?:successfully\s+)?verif(?:ied|ication\s+(?:passed|succeeded|is\s+clean))|(?:work|task|implementation)\s+is\s+(?:complete|done|finished)|everything\s+(?:is\s+)?(?:done|complete|works)|(?:all|every)\s+(?:task|item)s?\s+(?:is|are)\s+(?:done|complete(?:d)?))\b/i;
20
+
21
+ const VERIFICATION_FRESH_WINDOW_MS = 120_000;
22
+
23
+ export interface CompletionClaimCheck {
24
+ claimsCompletion: boolean;
25
+ claim?: string;
26
+ evidenceState?: "fresh-pass" | "stale-pass" | "failing" | "missing";
27
+ reason?: string;
28
+ }
29
+
30
+ export function checkCompletionClaims(
31
+ text: string,
32
+ context?: { sessionID?: string },
33
+ ): CompletionClaimCheck {
34
+ if (!text) return { claimsCompletion: false };
35
+
36
+ const match = text.match(COMPLETION_CLAIM_RE);
37
+ if (!match) return { claimsCompletion: false };
38
+
39
+ // Session-scoped evidence wins when the session has its own entry OR has
40
+ // recorded mutations (evidence was invalidated); otherwise fall back to the
41
+ // most recent global result.
42
+ const sessionHadEvidence =
43
+ context?.sessionID !== undefined &&
44
+ (sessionVerifyResults.has(context.sessionID) ||
45
+ (sessionMutationTimestamps.get(context.sessionID) ?? 0) > 0);
46
+ const verifyResult: VerifyResult | undefined = context?.sessionID
47
+ ? (sessionVerifyResults.get(context.sessionID) ??
48
+ (sessionHadEvidence ? undefined : lastVerify))
49
+ : lastVerify;
50
+
51
+ let evidenceState: CompletionClaimCheck["evidenceState"];
52
+ const mutationTimestamp = context?.sessionID
53
+ ? (sessionMutationTimestamps.get(context.sessionID) ?? 0)
54
+ : lastMutationTimestamp;
55
+ if (!verifyResult) {
56
+ // A session-scoped lookup that was invalidated by a later mutation is
57
+ // stale, not merely missing - the agent verified and then changed code.
58
+ evidenceState =
59
+ context?.sessionID && mutationTimestamp > 0 ? "stale-pass" : "missing";
60
+ } else if (!verifyResult.passed) {
61
+ evidenceState = "failing";
62
+ } else if (
63
+ verifyResult.timestamp < mutationTimestamp ||
64
+ Date.now() - verifyResult.timestamp > VERIFICATION_FRESH_WINDOW_MS
65
+ ) {
66
+ evidenceState = "stale-pass";
67
+ } else {
68
+ evidenceState = "fresh-pass";
69
+ }
70
+
71
+ if (evidenceState === "fresh-pass") {
72
+ return { claimsCompletion: true, claim: match[0], evidenceState };
73
+ }
74
+
75
+ const reasonByState = {
76
+ missing:
77
+ "the response asserts completion/verification, but no verification evidence has been recorded in this session.",
78
+ failing: `the response asserts completion, but the latest verification (${verifyResult?.command}) FAILED.`,
79
+ "stale-pass":
80
+ "the response asserts completion, but the last passing verification is stale - mutations were recorded after it.",
81
+ "fresh-pass": "",
82
+ } as const;
83
+
84
+ return {
85
+ claimsCompletion: true,
86
+ claim: match[0],
87
+ evidenceState,
88
+ reason: reasonByState[evidenceState],
89
+ };
90
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { tool, type Plugin, type PluginModule } from "@opencode-ai/plugin";
12
+ import { spawnSync } from "node:child_process";
12
13
  import { existsSync, readFileSync } from "node:fs";
13
14
  import { resolve } from "node:path";
14
15
 
@@ -212,6 +213,10 @@ import {
212
213
  sendDesktopNotification,
213
214
  } from "./policies/shell-safety.ts";
214
215
 
216
+ import { checkCompletionClaims } from "./policies/completion.ts";
217
+
218
+ export { checkCompletionClaims };
219
+
215
220
  export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification };
216
221
 
217
222
  const SHELL_TOOL_NAMES = new Set(["bash", "run_commands", "execute_command", "shell"]);
@@ -547,9 +552,11 @@ async function guardToolCallImpl(
547
552
  // Workspace boundary check for external git repo targets
548
553
  for (const invocation of gitInvocations) {
549
554
  const normalizedInvocation = `git ${invocation.rest}`;
555
+ // The workspace boundary has no allow-live override (same invariant as
556
+ // file redirects/mv): an external repository write is out of bounds even
557
+ // with WORKFLOW_GUARD_ALLOW_LIVE=1.
550
558
  if (
551
559
  isPathOutsideWorkspace(invocation.repoDir, currentRoot) &&
552
- !allowLive &&
553
560
  (GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))
554
561
  ) {
555
562
  logBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
@@ -929,7 +936,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
929
936
  }),
930
937
  record_review: tool({
931
938
  description:
932
- "Record a secondary reviewer agent's approval or critique of the current changes.",
939
+ "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.",
933
940
  args: {
934
941
  reviewer: tool.schema
935
942
  .string()
@@ -946,6 +953,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
946
953
  if (!parentSessionID) {
947
954
  return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session.";
948
955
  }
956
+ // The rubric is the contract: a recorded review must demonstrate it
957
+ // was evaluated against the 5 axes, not just assert a verdict.
958
+ const axesRefs = [
959
+ "test integrity",
960
+ "task completeness",
961
+ "cleanliness",
962
+ "security",
963
+ "platform",
964
+ ];
965
+ const summaryLower = args.summary.toLowerCase();
966
+ const referenced = axesRefs.filter((axis) => summaryLower.includes(axis));
967
+ if (referenced.length < 3) {
968
+ return (
969
+ `[workflow-guard] Review rejected: summary must reference the review axes ` +
970
+ `(found ${referenced.length}/5). Call guard_review_rubric to get the rubric, ` +
971
+ `evaluate each axis, and include findings per axis in the summary.`
972
+ );
973
+ }
949
974
  recordReviewResult(
950
975
  args.reviewer,
951
976
  args.summary,
@@ -958,6 +983,42 @@ export const WorkflowGuard: Plugin = async (ctx) => {
958
983
  : `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}.`;
959
984
  },
960
985
  }),
986
+ guard_review_rubric: tool({
987
+ description:
988
+ "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.",
989
+ args: {
990
+ base: tool.schema
991
+ .string()
992
+ .optional()
993
+ .describe("Base ref to diff against (default: origin/main, origin/master, main)"),
994
+ },
995
+ execute: async (args) => {
996
+ const bases = args.base
997
+ ? [args.base]
998
+ : ["origin/main", "origin/master", "main", "master"];
999
+ let diffText = "";
1000
+ for (const base of bases) {
1001
+ const res = spawnSync(
1002
+ "git",
1003
+ ["diff", `${base}...HEAD`],
1004
+ { cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 },
1005
+ );
1006
+ if (res.status === 0 && res.stdout.trim()) {
1007
+ diffText = res.stdout;
1008
+ break;
1009
+ }
1010
+ }
1011
+ if (!diffText) {
1012
+ const last = spawnSync("git", ["diff", "HEAD~1"], {
1013
+ cwd: getWorkspaceRoot(),
1014
+ encoding: "utf8",
1015
+ timeout: 10_000,
1016
+ });
1017
+ diffText = last.status === 0 ? last.stdout : "(no diff available)";
1018
+ }
1019
+ return buildReviewRubric(diffText);
1020
+ },
1021
+ }),
961
1022
  guard_worktree_create: tool({
962
1023
  description:
963
1024
  "Create an isolated git worktree directory for concurrent subagent execution.",
@@ -1142,6 +1203,40 @@ export const WorkflowGuard: Plugin = async (ctx) => {
1142
1203
  });
1143
1204
  },
1144
1205
 
1206
+ // Policy 24 (observability): claims-vs-evidence. When the assistant's final
1207
+ // text asserts completion or passing verification, compare the claim against
1208
+ // recorded verification evidence. A mismatch is journaled (not blocked) so
1209
+ // confident wrap-ups cannot silently contradict failing or stale state.
1210
+ "experimental.text.complete": async (input, output) => {
1211
+ try {
1212
+ const sessionID = input.sessionID;
1213
+ const text = typeof output.text === "string" ? output.text : "";
1214
+ const check = checkCompletionClaims(text, { sessionID });
1215
+ if (check.claimsCompletion && check.evidenceState && check.evidenceState !== "fresh-pass") {
1216
+ logBlock(`[workflow-guard] completion claim mismatch: ${check.reason}`);
1217
+ audit({
1218
+ ts: new Date().toISOString(),
1219
+ sessionID,
1220
+ tool: "experimental.text.complete",
1221
+ decision: "allow",
1222
+ reason: `Completion claim '${check.claim}' has ${check.evidenceState} verification evidence`,
1223
+ input: { claim: check.claim, evidenceState: check.evidenceState },
1224
+ });
1225
+ }
1226
+ } catch {}
1227
+ },
1228
+
1229
+ // Keep tool descriptions honest: todowrite's description reflects the
1230
+ // finalization gates so the model is not surprised by verification blocks.
1231
+ "tool.definition": async (input, output) => {
1232
+ if (input.toolID !== "todowrite") return;
1233
+ const description = typeof output.description === "string" ? output.description : "";
1234
+ if (description.includes("verification evidence")) return;
1235
+ output.description =
1236
+ description +
1237
+ "\n\nNote: marking every task completed triggers the workflow guard's finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply.";
1238
+ },
1239
+
1145
1240
  event: async ({
1146
1241
  event,
1147
1242
  }: { event: { type?: string; properties?: unknown } }) => {