opencode-workflow-guard 1.8.3 → 1.8.4

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.3",
3
+ "version": "1.8.4",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  "workflow"
35
35
  ],
36
36
  "scripts": {
37
- "test": "node test/test.mts",
37
+ "test": "node test/run-test.mjs",
38
38
  "test:install": "node test/test-e2e.mts",
39
39
  "test:all": "npm run typecheck && npm test && npm run test:install",
40
40
  "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { execFile, spawnSync } from "node:child_process";
5
5
  import { Database, type SqliteDatabase } from "./sqlite.ts";
@@ -7,13 +7,27 @@ import type { ProjectMemoryInput, ProjectMemoryKind, ProjectMemoryRecord, Projec
7
7
 
8
8
  const KINDS = new Set<ProjectMemoryKind>(["fact", "decision", "constraint", "lesson"]);
9
9
  const SOURCES = new Set<ProjectMemorySource>(["user", "file", "git", "tool", "agent", "portable"]);
10
+ const DAY_MS = 24 * 60 * 60 * 1000;
11
+ const OBSOLETE_RETENTION_MS = 90 * DAY_MS;
12
+ const MAINTENANCE_INTERVAL_MS = DAY_MS;
13
+ const STORAGE_LIMIT_BYTES = 100 * 1024 * 1024;
14
+ const STORAGE_TARGET_BYTES = 80 * 1024 * 1024;
10
15
 
11
16
  export interface ProjectMemoryStore {
12
17
  db: SqliteDatabase;
13
18
  projectId: string;
19
+ path: string;
20
+ activeMarker?: string;
14
21
  close(): void;
15
22
  }
16
23
 
24
+ interface MaintenanceOptions {
25
+ force?: boolean;
26
+ now?: number;
27
+ storageLimitBytes?: number;
28
+ storageTargetBytes?: number;
29
+ }
30
+
17
31
  export function getProjectMemoryDir(): string {
18
32
  const base = process.env.XDG_DATA_HOME || join(process.env.HOME || process.cwd(), ".local", "share");
19
33
  return join(base, "opencode", "workflow-guard", "project-memory");
@@ -66,14 +80,20 @@ export async function isProjectMemoryFreshAsync(memory: ProjectMemoryRecord, roo
66
80
  export function openProjectMemory(projectId: string, directory = getProjectMemoryDir()): ProjectMemoryStore {
67
81
  if (!projectId || projectId.length > 200) throw new Error("Invalid project memory identity.");
68
82
  mkdirSync(directory, { recursive: true, mode: 0o700 });
69
- const db = new Database(join(directory, `${projectId.replace(/[^a-zA-Z0-9._-]/g, "_")}.sqlite`));
70
- db.exec(`
83
+ const path = join(directory, `${projectId.replace(/[^a-zA-Z0-9._-]/g, "_")}.sqlite`);
84
+ const activeMarker = `${path}.active.${process.pid}.${randomUUID()}`;
85
+ const releaseLock = acquireDatabaseLock(directory);
86
+ let db: SqliteDatabase | undefined;
87
+ try {
88
+ writeFileSync(activeMarker, "", { mode: 0o600 });
89
+ db = new Database(path);
90
+ db.exec(`
71
91
  PRAGMA journal_mode = WAL;
72
92
  PRAGMA busy_timeout = 2000;
73
93
  CREATE TABLE IF NOT EXISTS memories (
74
94
  id TEXT PRIMARY KEY, project_id TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL,
75
95
  source TEXT NOT NULL, created_at INTEGER NOT NULL, session_id TEXT, commit_sha TEXT,
76
- paths TEXT NOT NULL, supersedes TEXT, status TEXT NOT NULL DEFAULT 'current'
96
+ paths TEXT NOT NULL, supersedes TEXT, status TEXT NOT NULL DEFAULT 'current', superseded_at INTEGER
77
97
  );
78
98
  CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content);
79
99
  CREATE TABLE IF NOT EXISTS review_followups (
@@ -81,8 +101,105 @@ export function openProjectMemory(projectId: string, directory = getProjectMemor
81
101
  reviewer TEXT NOT NULL, created_at INTEGER NOT NULL, resolved_at INTEGER, session_id TEXT,
82
102
  commit_sha TEXT, paths TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open'
83
103
  );
84
- `);
85
- return { db, projectId, close: () => db.close() };
104
+ `);
105
+ const memoryColumns = db.prepare("PRAGMA table_info(memories)").all() as Array<{ name: string }>;
106
+ if (!memoryColumns.some((column) => column.name === "superseded_at")) {
107
+ db.exec("ALTER TABLE memories ADD COLUMN superseded_at INTEGER");
108
+ db.exec("UPDATE memories SET superseded_at = created_at WHERE status = 'superseded'");
109
+ }
110
+ } catch (error) {
111
+ try { db?.close(); } finally { rmSync(activeMarker, { force: true }); }
112
+ throw error;
113
+ } finally {
114
+ releaseLock();
115
+ }
116
+ if (!db) throw new Error("Project memory database failed to initialize.");
117
+ const store: ProjectMemoryStore = { db, projectId, path, activeMarker, close: () => { try { db.close(); } finally { rmSync(activeMarker, { force: true }); } } };
118
+ try { maintainProjectMemoryStorage(store, directory); } catch {}
119
+ return store;
120
+ }
121
+
122
+ function acquireDatabaseLock(directory: string): () => void {
123
+ const lock = new Database(join(directory, ".coordination"));
124
+ try {
125
+ lock.exec("PRAGMA busy_timeout = 2000; BEGIN IMMEDIATE");
126
+ } catch (error) {
127
+ lock.close();
128
+ throw error;
129
+ }
130
+ return () => { try { lock.exec("COMMIT"); } finally { lock.close(); } };
131
+ }
132
+
133
+ function databaseDiskUsage(path: string): number {
134
+ let size = 0;
135
+ for (const suffix of ["", "-wal", "-shm"]) try { size += statSync(path + suffix).size; } catch {}
136
+ return size;
137
+ }
138
+
139
+ function databaseIsActive(path: string, directory: string): boolean {
140
+ const prefix = `${path.slice(directory.length + 1)}.active.`;
141
+ let active = false;
142
+ for (const name of readdirSync(directory).filter((entry) => entry.startsWith(prefix))) {
143
+ const pid = Number(name.slice(prefix.length).split(".", 1)[0]);
144
+ if (!Number.isInteger(pid) || pid <= 0) { active = true; continue; }
145
+ try { process.kill(pid, 0); active = true; } catch (error) {
146
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") rmSync(join(directory, name), { force: true });
147
+ else active = true;
148
+ }
149
+ }
150
+ return active;
151
+ }
152
+
153
+ export function maintainProjectMemoryStorage(store: ProjectMemoryStore, directory = getProjectMemoryDir(), options: MaintenanceOptions = {}): void {
154
+ const now = options.now ?? Date.now();
155
+ const maintenanceMarker = `${store.path}.maintenance`;
156
+ const releaseMaintenanceLock = acquireDatabaseLock(directory);
157
+ try {
158
+ let lastMaintained = 0;
159
+ try { lastMaintained = statSync(maintenanceMarker).mtimeMs; } catch {}
160
+ if (!options.force && now - lastMaintained < MAINTENANCE_INTERVAL_MS) return;
161
+
162
+ const cutoff = now - OBSOLETE_RETENTION_MS;
163
+ store.db.exec("BEGIN IMMEDIATE");
164
+ try {
165
+ store.db.prepare("DELETE FROM memories_fts WHERE id IN (SELECT id FROM memories WHERE status = 'superseded' AND superseded_at < ?)").run(cutoff);
166
+ store.db.prepare("DELETE FROM memories WHERE status = 'superseded' AND superseded_at < ?").run(cutoff);
167
+ store.db.prepare("DELETE FROM review_followups WHERE status = 'resolved' AND resolved_at < ?").run(cutoff);
168
+ store.db.exec("COMMIT");
169
+ } catch (error) {
170
+ store.db.exec("ROLLBACK");
171
+ throw error;
172
+ }
173
+ store.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
174
+ store.db.exec("PRAGMA optimize");
175
+ store.db.exec("VACUUM");
176
+ try { utimesSync(store.path, now / 1000, now / 1000); } catch {}
177
+ writeFileSync(maintenanceMarker, "", { mode: 0o600 });
178
+ try { utimesSync(maintenanceMarker, now / 1000, now / 1000); } catch {}
179
+ } finally {
180
+ releaseMaintenanceLock();
181
+ }
182
+
183
+ const limit = options.storageLimitBytes ?? STORAGE_LIMIT_BYTES;
184
+ const target = Math.min(limit, options.storageTargetBytes ?? STORAGE_TARGET_BYTES);
185
+ const candidates = readdirSync(directory)
186
+ .filter((name) => name.endsWith(".sqlite"))
187
+ .map((name) => join(directory, name))
188
+ .filter((path) => path !== store.path)
189
+ .flatMap((path) => { try { const stat = statSync(path); return [{ path, size: databaseDiskUsage(path), mtimeMs: stat.mtimeMs }]; } catch { return []; } });
190
+ let total = candidates.reduce((sum, item) => sum + item.size, databaseDiskUsage(store.path));
191
+ if (total <= limit) return;
192
+ for (const candidate of candidates.sort((a, b) => a.mtimeMs - b.mtimeMs)) {
193
+ const releaseCandidateLock = acquireDatabaseLock(directory);
194
+ try {
195
+ if (databaseIsActive(candidate.path, directory)) continue;
196
+ for (const suffix of ["", "-wal", "-shm", ".maintenance"]) rmSync(candidate.path + suffix, { force: true });
197
+ } finally {
198
+ releaseCandidateLock();
199
+ }
200
+ total -= candidate.size;
201
+ if (total <= target) break;
202
+ }
86
203
  }
87
204
 
88
205
  function followupRowToRecord(row: Record<string, unknown>): ReviewFollowup {
@@ -147,8 +264,8 @@ export function recordProjectMemory(store: ProjectMemoryStore, input: ProjectMem
147
264
  const createdAt = Date.now();
148
265
  store.db.exec("BEGIN IMMEDIATE");
149
266
  try {
150
- if (input.supersedes) store.db.prepare("UPDATE memories SET status = 'superseded' WHERE id = ? AND project_id = ?").run(input.supersedes, store.projectId);
151
- store.db.prepare("INSERT INTO memories VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'current')").run(
267
+ if (input.supersedes) store.db.prepare("UPDATE memories SET status = 'superseded', superseded_at = ? WHERE id = ? AND project_id = ?").run(createdAt, input.supersedes, store.projectId);
268
+ store.db.prepare("INSERT INTO memories (id, project_id, kind, content, source, created_at, session_id, commit_sha, paths, supersedes, status, superseded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'current', NULL)").run(
152
269
  id, store.projectId, input.kind, input.content.trim(), input.source, createdAt,
153
270
  input.sessionID ?? null, input.commit ?? null, JSON.stringify(input.paths ?? []), input.supersedes ?? null,
154
271
  );
@@ -129,7 +129,7 @@ export function escapeAppleScriptString(str: string): string {
129
129
 
130
130
  /**
131
131
  * Dispatches a native OS desktop notification (macOS osascript or Linux notify-send)
132
- * on important guard events (policy blocks, verification completion).
132
+ * for events that explicitly require the user's attention.
133
133
  * Non-blocking, fails gracefully.
134
134
  */
135
135
  export function sendDesktopNotification(title: string, message: string): void {
@@ -31,9 +31,9 @@ function text(props: Record<string, unknown>, children: Child[]) {
31
31
  }
32
32
 
33
33
  const BADGE_ACTIVE = "Workflow Guard 🛡️";
34
+ const BLOCKED_BADGE_DURATION_MS = 5_000;
34
35
  let lastBlockedReason: string | undefined;
35
36
  const sessionBlockedReasons = new Map<string, string>();
36
- const badgeNodes = new Map<string, BaseRenderable>();
37
37
 
38
38
  export function readProjectOption(root: string, option: "recoveryCheckpoints" | "projectMemory" | "learning"): boolean {
39
39
  const path = projectConfigPath(root);
@@ -86,12 +86,15 @@ export function formatBadge(sessionID?: string): { text: string; isBlocked: bool
86
86
  if (!reason) {
87
87
  return { text: BADGE_ACTIVE, isBlocked: false };
88
88
  }
89
- const cleanReason = reason.replace(/^\[workflow-guard\]\s*/, "").replace(/^Blocked:\s*/, "");
89
+ const cleanReason = reason.replace(/^\[workflow-guard\]\s*/i, "").replace(/^Blocked(?::|\s+)\s*/i, "");
90
90
  const shortReason = cleanReason.length > 30 ? cleanReason.slice(0, 27) + "..." : cleanReason;
91
91
  return { text: `[Workflow Guard: Blocked: ${shortReason}]`, isBlocked: true };
92
92
  }
93
93
 
94
94
  export const WorkflowGuardTui: TuiPlugin = async (api) => {
95
+ const badgeNodes = new Map<string, BaseRenderable>();
96
+ const blockedBadgeTimers = new Map<string, ReturnType<typeof setTimeout>>();
97
+
95
98
  api.keymap.registerLayer({
96
99
  commands: [{
97
100
  name: "workflow-guard.project-options",
@@ -125,18 +128,34 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
125
128
  bindings: [],
126
129
  });
127
130
 
131
+ function scheduleBadgeReset(sessionID?: string): void {
132
+ const key = sessionID ?? "home";
133
+ const existing = blockedBadgeTimers.get(key);
134
+ if (existing) clearTimeout(existing);
135
+ blockedBadgeTimers.set(key, setTimeout(() => {
136
+ blockedBadgeTimers.delete(key);
137
+ if (sessionID) sessionBlockedReasons.delete(sessionID);
138
+ else lastBlockedReason = undefined;
139
+ const node = badgeNodes.get(key);
140
+ if (node) {
141
+ setProp(node, "content", BADGE_ACTIVE);
142
+ setProp(node, "fg", api.theme.current.success);
143
+ }
144
+ }, BLOCKED_BADGE_DURATION_MS));
145
+ }
146
+
128
147
  try {
129
148
  api.event.on("tui.toast.show", (e) => {
130
149
  const title = e.properties.title;
131
150
  const msg = e.properties.message;
132
151
  if (title === "Workflow Guard Blocked" && typeof msg === "string") {
133
- lastBlockedReason = msg;
134
152
  const route = api.route.current;
135
153
  const sessionID =
136
154
  route.name === "session" && typeof route.params?.sessionID === "string"
137
155
  ? route.params.sessionID
138
156
  : undefined;
139
157
  if (sessionID) sessionBlockedReasons.set(sessionID, msg);
158
+ else lastBlockedReason = msg;
140
159
  const key = sessionID ?? "home";
141
160
  const node = badgeNodes.get(key);
142
161
  if (node) {
@@ -144,6 +163,7 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
144
163
  setProp(node, "content", badge.text);
145
164
  setProp(node, "fg", badge.isBlocked ? api.theme.current.warning : api.theme.current.success);
146
165
  }
166
+ scheduleBadgeReset(sessionID);
147
167
  }
148
168
  });
149
169
  } catch {}
@@ -122,6 +122,7 @@ export {
122
122
  ensureProjectMemoryExcluded,
123
123
  isProjectMemoryFresh,
124
124
  openProjectMemory,
125
+ maintainProjectMemoryStorage,
125
126
  recordProjectMemory,
126
127
  searchProjectMemory,
127
128
  getRecentProjectMemory,
@@ -270,7 +271,6 @@ export { branchHasDocumentationChange };
270
271
  import {
271
272
  checkInteractiveTtyCommand,
272
273
  checkPackageHygiene,
273
- sendDesktopNotification,
274
274
  escapeAppleScriptString,
275
275
  } from "./policies/shell-safety.ts";
276
276
 
@@ -278,7 +278,8 @@ import { checkCompletionClaims } from "./policies/completion.ts";
278
278
 
279
279
  export { checkCompletionClaims };
280
280
 
281
- export { checkInteractiveTtyCommand, checkPackageHygiene, sendDesktopNotification, escapeAppleScriptString };
281
+ export { sendDesktopNotification } from "./policies/shell-safety.ts";
282
+ export { checkInteractiveTtyCommand, checkPackageHygiene, escapeAppleScriptString };
282
283
 
283
284
  function logObservation(client: unknown, message: string): void {
284
285
  try {
@@ -322,9 +323,6 @@ export async function guardToolCall(
322
323
  }
323
324
 
324
325
  async function emitBlockFeedback(message: string): Promise<void> {
325
- try {
326
- sendDesktopNotification("Workflow Guard Blocked", message);
327
- } catch {}
328
326
  await showBlockToast(message);
329
327
  }
330
328