opencode-workflow-guard 1.8.4 → 1.8.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.8.4",
3
+ "version": "1.8.6",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,7 @@ import { detectVerifyCommand, getCurrentGitCommitHash } from "./verify.ts";
30
30
  import { buildReviewRubric } from "./review.ts";
31
31
  import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
32
32
  import { restoreRecoveryCheckpoint } from "./checkpoint.ts";
33
- import { getRecentAuditEntries } from "./audit.ts";
33
+ import { audit, getRecentAuditEntries } from "./audit.ts";
34
34
  import { guardToolCallImpl } from "./guard-dispatcher.ts";
35
35
  import { discoverPlanningSources } from "../policies/planning.ts";
36
36
  import { secretIn } from "../policies/secrets.ts";
@@ -154,14 +154,16 @@ export function createCustomTools(options: {
154
154
  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.",
155
155
  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") },
156
156
  execute: async (args, toolContext) => {
157
+ 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 } });
157
158
  const parentSessionID = await fetchParentSessionID(toolContext.sessionID);
158
- if (!parentSessionID) return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session.";
159
+ if (!parentSessionID) { auditVerdict("rejected", "missing_parent_session"); return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session."; }
159
160
  const axesRefs = ["test integrity", "task completeness", "cleanliness", "security", "platform"];
160
161
  const referenced = axesRefs.filter((axis) => args.summary.toLowerCase().includes(axis));
161
- if (referenced.length < 3) return `[workflow-guard] Review rejected: summary must reference the review axes (found ${referenced.length}/5). Call guard_review_rubric to get the rubric, evaluate each axis, and include findings per axis in the summary.`;
162
- if (args.passed && /(?:^|\s)(?:\[p[01]\]|p[01]\s*:\s*(?:blocker|defect|vulnerability|error|bug|issue)|p[01]\s+blocker)/i.test(args.summary)) return "[workflow-guard] Review rejected: cannot record approval when P0 or P1 blockers are flagged in findings. Resolve all P0/P1 issues before approving or record review with passed=false.";
162
+ if (referenced.length < 3) { auditVerdict("rejected", "insufficient_rubric_axes"); return `[workflow-guard] Review rejected: summary must reference the review axes (found ${referenced.length}/5). Call guard_review_rubric to get the rubric, evaluate each axis, and include findings per axis in the summary.`; }
163
+ if (args.passed && /(?:^|\s)(?:\[p[01]\]|p[01]\s*:\s*(?:blocker|defect|vulnerability|error|bug|issue)|p[01]\s+blocker)/i.test(args.summary)) { auditVerdict("rejected", "approval_contains_blocker"); return "[workflow-guard] Review rejected: cannot record approval when P0 or P1 blockers are flagged in findings. Resolve all P0/P1 issues before approving or record review with passed=false."; }
163
164
  recordReviewResult(args.reviewer, args.summary, args.passed, parentSessionID, toolContext.worktree || toolContext.directory);
164
165
  if (followupStore && !secretIn(args.summary)) for (const finding of extractReviewFollowups(args.summary)) recordReviewFollowup(followupStore, { severity: finding.severity, summary: finding.summary, reviewer: args.reviewer, sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(effectiveRoot) });
166
+ auditVerdict(args.passed ? "approved" : "changes_requested", args.passed ? "approved" : "changes_requested");
165
167
  return args.passed ? `[workflow-guard] Review recorded as APPROVED by ${args.reviewer}.` : `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}.`;
166
168
  },
167
169
  }),
@@ -88,8 +88,8 @@ export function openProjectMemory(projectId: string, directory = getProjectMemor
88
88
  writeFileSync(activeMarker, "", { mode: 0o600 });
89
89
  db = new Database(path);
90
90
  db.exec(`
91
- PRAGMA journal_mode = WAL;
92
91
  PRAGMA busy_timeout = 2000;
92
+ PRAGMA journal_mode = WAL;
93
93
  CREATE TABLE IF NOT EXISTS memories (
94
94
  id TEXT PRIMARY KEY, project_id TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL,
95
95
  source TEXT NOT NULL, created_at INTEGER NOT NULL, session_id TEXT, commit_sha TEXT,
@@ -122,7 +122,7 @@ export function openProjectMemory(projectId: string, directory = getProjectMemor
122
122
  function acquireDatabaseLock(directory: string): () => void {
123
123
  const lock = new Database(join(directory, ".coordination"));
124
124
  try {
125
- lock.exec("PRAGMA busy_timeout = 2000; BEGIN IMMEDIATE");
125
+ lock.exec("PRAGMA busy_timeout = 10000; BEGIN IMMEDIATE");
126
126
  } catch (error) {
127
127
  lock.close();
128
128
  throw error;
package/src/lib/shell.ts CHANGED
@@ -242,7 +242,22 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
242
242
  continue;
243
243
  }
244
244
 
245
- if (word === "command" || word === "exec" || word === "eval" || word === "nohup") {
245
+ if (word === "command") {
246
+ i++;
247
+ while (i < words.length && words[i] === "-p") i++;
248
+ if (words[i] === "--") i++;
249
+ continue;
250
+ }
251
+ if (word === "exec") {
252
+ i++;
253
+ while (i < words.length && words[i]!.startsWith("-")) {
254
+ const option = words[i++]!;
255
+ if (option === "--") break;
256
+ if (option === "-a" && i < words.length) i++;
257
+ }
258
+ continue;
259
+ }
260
+ if (word === "nohup") {
246
261
  i++;
247
262
  continue;
248
263
  }
package/src/lib/types.ts CHANGED
@@ -9,7 +9,7 @@ export interface TodoSdkClient {
9
9
  };
10
10
  session?: {
11
11
  todo?: (opts: { path: { id: string } }) => Promise<{ data?: unknown }>;
12
- get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown } }>;
12
+ get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown; title?: unknown } }>;
13
13
  promptAsync?: (opts: {
14
14
  path: { id: string };
15
15
  body: { messageID?: string; parts: Array<{ type: "text"; text: string; synthetic?: boolean }> };
@@ -36,6 +36,7 @@ export interface ProjectConfig {
36
36
  recoveryCheckpoints?: boolean;
37
37
  projectMemory?: boolean;
38
38
  learning?: boolean;
39
+ titleSettleWorkaround?: boolean;
39
40
  maxSubagentMutations?: number;
40
41
  maxLearningInterventions?: number;
41
42
  }
@@ -83,6 +84,7 @@ export interface AuditEntry {
83
84
  durationMs?: number;
84
85
  };
85
86
  allowLive?: boolean;
87
+ reviewVerdict?: "approved" | "changes_requested" | "rejected";
86
88
  };
87
89
  }
88
90
 
@@ -3,6 +3,9 @@ import { getSdkClient } from "../lib/state.ts";
3
3
  import { effectiveTodosWithOwner, hasActiveTodo } from "./todo.ts";
4
4
 
5
5
  const MAX_CONSECUTIVE_CONTINUATIONS = 3;
6
+ const TITLE_SETTLE_MS = 250;
7
+ const TITLE_SETTLE_ATTEMPTS = 8;
8
+ const DEFAULT_SESSION_TITLE = /^(?:New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
6
9
  type ContinuationState = {
7
10
  counts: Map<string, number>;
8
11
  generatedMessageIDs: Map<string, Set<string>>;
@@ -10,6 +13,20 @@ type ContinuationState = {
10
13
  };
11
14
  const states = new WeakMap<object, ContinuationState>();
12
15
 
16
+ export async function waitForSessionTitle(sessionID: string): Promise<void> {
17
+ const session = getSdkClient()?.session;
18
+ if (typeof session?.get !== "function") return;
19
+ for (let attempt = 0; attempt < TITLE_SETTLE_ATTEMPTS; attempt++) {
20
+ try {
21
+ const current = (await session.get({ path: { id: sessionID } }))?.data;
22
+ if (current?.parentID || typeof current?.title !== "string" || !DEFAULT_SESSION_TITLE.test(current.title)) return;
23
+ } catch {
24
+ return;
25
+ }
26
+ if (attempt + 1 < TITLE_SETTLE_ATTEMPTS) await new Promise((resolve) => setTimeout(resolve, TITLE_SETTLE_MS));
27
+ }
28
+ }
29
+
13
30
  function getState(): ContinuationState | undefined {
14
31
  const client = getSdkClient();
15
32
  if (!client || (typeof client !== "object" && typeof client !== "function")) return undefined;
@@ -21,13 +38,14 @@ function getState(): ContinuationState | undefined {
21
38
  return state;
22
39
  }
23
40
 
24
- export async function continueUnfinishedSession(sessionID: string): Promise<boolean> {
41
+ export async function continueUnfinishedSession(sessionID: string, settleTitle = false): Promise<boolean> {
25
42
  const state = getState();
26
43
  if (!state || state.inFlight.has(sessionID)) return false;
27
44
  state.inFlight.add(sessionID);
28
45
  try {
29
46
  const effective = await effectiveTodosWithOwner(sessionID);
30
- if (!effective || !hasActiveTodo(effective.todos) || effective.ownerSessionID !== sessionID) return false;
47
+ if (!effective || effective.ownerSessionID !== sessionID || !hasActiveTodo(effective.todos)) return false;
48
+ if (settleTitle) await waitForSessionTitle(sessionID);
31
49
 
32
50
  const count = state.counts.get(sessionID) ?? 0;
33
51
  if (count >= MAX_CONSECUTIVE_CONTINUATIONS) return false;
@@ -1,4 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { basename } from "node:path";
3
+ import { splitShellSegments, unwrapShellWords } from "../lib/shell.ts";
2
4
 
3
5
  // AI agents frequently run dangerous package manager flags (e.g. npm audit fix --force,
4
6
  // npm install -g, pip install --force-reinstall, agent-driven publish) that introduce
@@ -48,11 +50,6 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
48
50
  name: "interactive text editor",
49
51
  advice: "Use the edit, write, or apply_patch tools instead of interactive terminal editors.",
50
52
  },
51
- {
52
- regex: /\b(?:less|more|most)\b/i,
53
- name: "terminal pager",
54
- advice: "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.",
55
- },
56
53
  {
57
54
  regex: /\bsudo\b/i,
58
55
  name: "sudo with password prompt",
@@ -86,6 +83,7 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
86
83
  ];
87
84
 
88
85
  const MONITOR_ADVICE = "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.";
86
+ const PAGER_ADVICE = "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.";
89
87
  // Process monitoring tokens are matched on whitespace/segment boundaries so that
90
88
  // an unrelated word such as `desktop`, a hyphenated filename like `top-level-dir`,
91
89
  // or a quoted argument like `echo "top"` is not mistaken for the `top` command.
@@ -105,9 +103,29 @@ function checkProcessMonitorCommand(command: string): { isInteractive: boolean;
105
103
  return { isInteractive: false };
106
104
  }
107
105
 
106
+ function containsPagerCommand(command: string, depth = 0): boolean {
107
+ if (depth >= 16) return false;
108
+ for (const segment of splitShellSegments(command)) {
109
+ const words = unwrapShellWords(segment);
110
+ while (words[0] === "--") words.shift();
111
+ const executable = basename(words[0] ?? "");
112
+ if (/^(?:less|more|most)$/i.test(executable)) return true;
113
+ if (executable === "eval" && words.length > 1 && containsPagerCommand(words.slice(1).join(" "), depth + 1)) return true;
114
+ if (executable === "busybox" && /^(?:less|more)$/i.test(words[1] ?? "")) return true;
115
+ if (/^(?:ba|z|da|k)?sh$/i.test(executable)) {
116
+ const commandFlag = words.findIndex((word, index) => index > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
117
+ if (commandFlag >= 0 && words[commandFlag + 1] && containsPagerCommand(words[commandFlag + 1]!, depth + 1)) return true;
118
+ }
119
+ }
120
+ return false;
121
+ }
122
+
108
123
  export function checkInteractiveTtyCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
109
124
  const monitor = checkProcessMonitorCommand(command);
110
125
  if (monitor.isInteractive) return monitor;
126
+ if (containsPagerCommand(command)) {
127
+ return { isInteractive: true, name: "terminal pager", advice: PAGER_ADVICE };
128
+ }
111
129
  for (const { regex, name, advice } of INTERACTIVE_COMMAND_PATTERNS) {
112
130
  if (regex.test(command)) {
113
131
  return { isInteractive: true, name, advice };
@@ -97,14 +97,39 @@ export function validateTodoLifecycle(
97
97
  return s === "pending" || s === "in_progress";
98
98
  });
99
99
  if (activeExisting.length > 0) {
100
+ const activeNew = newTodos.filter((t) => ACTIVE_TODO_STATUSES.has(String(t.status ?? "")));
101
+ const reviewObligation = /\b(?:secondary\s+review|re-?review|reviewer|(?:run|perform|request|obtain|complete)\s+(?:an?\s+)?review)\b/i;
102
+ const remediationAction = /\b(?:address|fix|resolve|remediate)\b/i;
103
+ const reviewReference = /\b(?:secondary\s+review|re-?review|reviewer|review)\b/i;
104
+ const remediationSubject = /\b(?:findings?|feedback|comments?|issues?)\b/i;
105
+ const isReviewObligation = (content: string) => reviewObligation.test(content) && !(remediationAction.test(content) && reviewReference.test(content) && remediationSubject.test(content));
100
106
  const newContentCounts = new Map<string, number>();
101
107
  for (const todo of newTodos) {
102
108
  const content = String(todo.content ?? "").trim();
103
109
  newContentCounts.set(content, (newContentCounts.get(content) ?? 0) + 1);
104
110
  }
111
+ const retainedReviewCounts = new Map<string, number>();
112
+ for (const todo of activeNew) {
113
+ const content = String(todo.content ?? "").trim();
114
+ retainedReviewCounts.set(content, (retainedReviewCounts.get(content) ?? 0) + 1);
115
+ }
116
+ let retainedReviewObligations = 0;
117
+ for (const todo of activeExisting) {
118
+ const content = String(todo.content ?? "").trim();
119
+ const remaining = retainedReviewCounts.get(content) ?? 0;
120
+ if (remaining > 0 && isReviewObligation(content)) {
121
+ retainedReviewCounts.set(content, remaining - 1);
122
+ retainedReviewObligations++;
123
+ }
124
+ }
125
+ let replacementReviewObligations = activeNew.filter((t) => isReviewObligation(String(t.content ?? ""))).length - retainedReviewObligations;
105
126
  const missing = activeExisting.find((todo) => {
106
127
  const content = String(todo.content ?? "").trim();
107
128
  const remaining = newContentCounts.get(content) ?? 0;
129
+ if (remaining === 0 && isReviewObligation(content) && replacementReviewObligations > 0) {
130
+ replacementReviewObligations--;
131
+ return false;
132
+ }
108
133
  if (remaining === 0) return true;
109
134
  newContentCounts.set(content, remaining - 1);
110
135
  return false;
@@ -35,7 +35,9 @@ const BLOCKED_BADGE_DURATION_MS = 5_000;
35
35
  let lastBlockedReason: string | undefined;
36
36
  const sessionBlockedReasons = new Map<string, string>();
37
37
 
38
- export function readProjectOption(root: string, option: "recoveryCheckpoints" | "projectMemory" | "learning"): boolean {
38
+ type ProjectToggle = "recoveryCheckpoints" | "projectMemory" | "learning" | "titleSettleWorkaround";
39
+
40
+ export function readProjectOption(root: string, option: ProjectToggle): boolean {
39
41
  const path = projectConfigPath(root);
40
42
  if (!existsSync(path)) return option === "projectMemory";
41
43
  const errors: ParseError[] = [];
@@ -44,7 +46,7 @@ export function readProjectOption(root: string, option: "recoveryCheckpoints" |
44
46
  return option === "projectMemory" ? config?.projectMemory !== false : config?.[option] === true;
45
47
  }
46
48
 
47
- function writeProjectOption(root: string, option: "recoveryCheckpoints" | "projectMemory" | "learning", enabled: boolean): string {
49
+ function writeProjectOption(root: string, option: ProjectToggle, enabled: boolean): string {
48
50
  const path = projectConfigPath(root);
49
51
  const raw = existsSync(path) ? readFileSync(path, "utf8") : "{}\n";
50
52
  const errors: ParseError[] = [];
@@ -107,6 +109,7 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
107
109
  const recovery = readProjectOption(root, "recoveryCheckpoints");
108
110
  const memory = readProjectOption(root, "projectMemory");
109
111
  const learning = readProjectOption(root, "learning");
112
+ const titleSettle = readProjectOption(root, "titleSettleWorkaround");
110
113
  api.ui.dialog.replace(() => api.ui.DialogSelect({
111
114
  title: "Workflow Guard Project Options",
112
115
  current: undefined,
@@ -114,9 +117,10 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
114
117
  { title: `Recovery checkpoints: ${recovery ? "On" : "Off"}`, value: "recoveryCheckpoints", description: "Toggle durable pre-run Git checkpoints" },
115
118
  { title: `Project memory: ${memory ? "On" : "Off"}`, value: "projectMemory", description: "Toggle durable local project memory" },
116
119
  { title: `Learner mode: ${learning ? "On" : "Off"}`, value: "learning", description: "Toggle evidence-based learning tools" },
120
+ { title: `Title settle workaround: ${titleSettle ? "On" : "Off"}`, value: "titleSettleWorkaround", description: "Delay automatic continuation while OpenCode generates a session title" },
117
121
  ],
118
122
  onSelect: (option) => {
119
- const key = option.value as "recoveryCheckpoints" | "projectMemory" | "learning";
123
+ const key = option.value as ProjectToggle;
120
124
  const enabled = !readProjectOption(root, key);
121
125
  const path = writeProjectOption(root, key, enabled);
122
126
  api.ui.dialog.clear();
@@ -650,7 +650,8 @@ export const WorkflowGuard: Plugin = async (ctx) => {
650
650
  if (recoveryRun !== undefined) {
651
651
  finalizeRecoveryCheckpoint(effectiveRoot, sessionID, recoveryRun);
652
652
  }
653
- await runWithRuntimeState(effectiveRoot, ctx.client, () => continueUnfinishedSession(sessionID));
653
+ const settleTitle = getProjectConfig(effectiveRoot).titleSettleWorkaround === true;
654
+ await runWithRuntimeState(effectiveRoot, ctx.client, () => continueUnfinishedSession(sessionID, settleTitle));
654
655
  }
655
656
  }
656
657
  if (event?.type === "message.updated") {