opencode-workflow-guard 1.4.1 → 1.5.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
@@ -32,7 +32,8 @@ For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`
32
32
  ```bash
33
33
  npm run typecheck # Strict TypeScript check (0 errors)
34
34
  npm test # Run 415+ unit and adversarial tests
35
- npm run test:all # Run full verification suite (typecheck + unit + live e2e)
35
+ npm run test:all # Typecheck + unit + install/load checks
36
+ WORKFLOW_GUARD_LIVE_E2E=1 npm run test:install # Also run model-driven OpenCode policy probes
36
37
  ```
37
38
 
38
39
  ---
@@ -74,6 +75,27 @@ For complete policy specifications and override rules, see [docs/policies.md](do
74
75
  | `record_review` | Record a secondary reviewer subagent's verdict evaluated against the review rubric |
75
76
  | `guard_worktree_create` | Create an isolated git worktree directory for concurrent subagent execution |
76
77
  | `guard_worktree_cleanup` | Snapshot-commit remaining changes and remove an isolated worktree directory |
78
+ | `learning_profile` | Inspect the evidence-based local learner profile (when learning mode is enabled) |
79
+ | `learning_checkpoint` | Rank high-value Socratic opportunities while respecting the session intervention budget |
80
+ | `learning_record` | Persist concise evidence observed during a real learning interaction |
81
+ | `project_memory_search` | Search current durable project knowledge in the local index |
82
+ | `project_memory_record` | Record a durable fact, decision, constraint, or lesson with provenance |
83
+ | `project_memory_export` | Explicitly promote selected records to repo-local human-readable JSONL |
84
+ | `project_memory_import` | Import promoted repo-local knowledge into the local index |
85
+
86
+ ### Experimental Socratic Learning
87
+
88
+ Learning mode is opt-in and advisory: it never blocks tool calls or weakens deterministic guardrails. Enable it by setting `WORKFLOW_GUARD_LEARNING=1` in the user environment before OpenCode starts. Project configuration cannot enable learning or expose the global profile on its own. `maxLearningInterventions` defaults to 3 per session and can be set to `0` in `.opencode/workflow-guard.json[c]` to suppress checkpoints.
89
+
90
+ The learner profile is global to the local user and follows the XDG data convention: `$XDG_DATA_HOME/opencode/workflow-guard/learner-profile.json`, or `~/.local/share/opencode/workflow-guard/learner-profile.json` when `XDG_DATA_HOME` is unset. It stores distilled concept evidence, session/project provenance, and progression (`exposed` through `critique`), not conversation transcripts or numeric grades. Unobserved concepts remain unknown rather than being labeled knowledge gaps. Back up or delete this file independently from project repositories as desired.
91
+
92
+ ### Project Memory
93
+
94
+ Project memory keeps durable working knowledge between sessions without treating conversation history as project truth. Facts, decisions, constraints, and lessons are stored in a local SQLite/FTS5 index under `$XDG_DATA_HOME/opencode/workflow-guard/project-memory/`, or `~/.local/share/opencode/workflow-guard/project-memory/` when `XDG_DATA_HOME` is unset. Git repositories are identified from their common Git directory, so linked worktrees share the same local project index.
95
+
96
+ The local index is private working memory. Nothing from it is committed automatically. `project_memory_export` promotes only the explicitly selected current record IDs to `.opencode/memory/project-memory.jsonl`, a human-readable portable representation that another clone can import. The plugin adds `.opencode/memory/` to the clone-local `.git/info/exclude` by default; teams that deliberately want to version promoted knowledge can remove that exclusion or force-add the JSONL file. SQLite databases themselves should not be committed.
97
+
98
+ Memory is supporting context, not authority. Superseded records are excluded from normal retrieval, recording and plugin imports reject content recognized by Workflow Guard's secret detector, and compaction injects at most eight recent current local records. Portable repository records are never injected automatically; they are available through explicit memory search after import. A local record tied to repository paths and a Git commit is omitted from compaction when those paths have committed, staged, or unstaged changes after that commit. Always prefer current repository state when it conflicts with historical memory.
77
99
 
78
100
  ---
79
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.4.1",
3
+ "version": "1.5.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,104 @@
1
+ import type {
2
+ LearnerProfile,
3
+ LearningEvidence,
4
+ LearningOpportunity,
5
+ LearningStage,
6
+ } from "./types.ts";
7
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+
11
+ const STAGE_ORDER: LearningStage[] = ["exposed", "developing", "demonstrated", "independent", "critique"];
12
+ const TYPE_WEIGHT = { design: 0.3, debugging: 0.3, "new-concept": 0.2 } as const;
13
+
14
+ export function createLearnerProfile(): LearnerProfile {
15
+ return { version: 1, concepts: {} };
16
+ }
17
+
18
+ export function getLearnerProfilePath(): string {
19
+ const dataRoot = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
20
+ return join(dataRoot, "opencode", "workflow-guard", "learner-profile.json");
21
+ }
22
+
23
+ export function loadLearnerProfile(path = getLearnerProfilePath()): LearnerProfile {
24
+ try {
25
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<LearnerProfile>;
26
+ const validConcepts = parsed.concepts && typeof parsed.concepts === "object" && !Array.isArray(parsed.concepts) &&
27
+ Object.values(parsed.concepts).every((concept) =>
28
+ concept && typeof concept === "object" &&
29
+ STAGE_ORDER.includes(concept.stage) &&
30
+ typeof concept.lastObservedAt === "number" && Number.isFinite(concept.lastObservedAt) &&
31
+ Array.isArray(concept.evidence),
32
+ );
33
+ if (parsed.version === 1 && validConcepts) {
34
+ return parsed as LearnerProfile;
35
+ }
36
+ } catch {}
37
+ return createLearnerProfile();
38
+ }
39
+
40
+ export function saveLearnerProfile(profile: LearnerProfile, path = getLearnerProfilePath()): void {
41
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
42
+ const temporary = `${path}.${process.pid}.tmp`;
43
+ writeFileSync(temporary, JSON.stringify(profile, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
44
+ renameSync(temporary, path);
45
+ }
46
+
47
+ export function updateLearnerProfile(
48
+ update: (profile: LearnerProfile) => void,
49
+ path = getLearnerProfilePath(),
50
+ ): LearnerProfile {
51
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
52
+ const lockPath = `${path}.lock`;
53
+ let lock: number;
54
+ try {
55
+ lock = openSync(lockPath, "wx", 0o600);
56
+ } catch {
57
+ throw new Error("Learner profile is busy; evidence was not recorded. Remove a stale .lock file before retrying if its owning process has terminated.");
58
+ }
59
+ try {
60
+ writeFileSync(lock, String(process.pid));
61
+ const profile = loadLearnerProfile(path);
62
+ update(profile);
63
+ saveLearnerProfile(profile, path);
64
+ return profile;
65
+ } finally {
66
+ closeSync(lock);
67
+ try { unlinkSync(lockPath); } catch {}
68
+ }
69
+ }
70
+
71
+ export function recordLearningEvidence(profile: LearnerProfile, evidence: LearningEvidence): void {
72
+ const current = profile.concepts[evidence.concept];
73
+ const evidenceStage = evidence.kind === "needs-reinforcement" ? undefined : evidence.kind;
74
+ const stage = evidenceStage ?? current?.stage ?? "exposed";
75
+ const nextStage = current && STAGE_ORDER.indexOf(current.stage) > STAGE_ORDER.indexOf(stage)
76
+ ? current.stage
77
+ : stage;
78
+
79
+ profile.concepts[evidence.concept] = {
80
+ stage: nextStage,
81
+ lastObservedAt: evidence.timestamp,
82
+ evidence: [...(current?.evidence ?? []), evidence].slice(-20),
83
+ };
84
+ }
85
+
86
+ export function selectLearningOpportunity(
87
+ profile: LearnerProfile,
88
+ opportunities: LearningOpportunity[],
89
+ options: { interventionsThisSession?: number; maxInterventionsPerSession?: number } = {},
90
+ ): LearningOpportunity | undefined {
91
+ const used = options.interventionsThisSession ?? 0;
92
+ const budget = options.maxInterventionsPerSession ?? 3;
93
+ if (used >= budget) return undefined;
94
+
95
+ let best: { opportunity: LearningOpportunity; score: number } | undefined;
96
+ for (const opportunity of opportunities) {
97
+ const known = profile.concepts[opportunity.concept];
98
+ const needsReinforcement = known?.evidence.at(-1)?.kind === "needs-reinforcement";
99
+ const gap = needsReinforcement ? 1 : known ? 1 - (STAGE_ORDER.indexOf(known.stage) + 1) / STAGE_ORDER.length : 1;
100
+ const score = opportunity.relevance * 0.4 + opportunity.consequence * 0.3 + gap * 0.3 + TYPE_WEIGHT[opportunity.type];
101
+ if (!best || score > best.score) best = { opportunity, score };
102
+ }
103
+ return best && best.score >= 0.75 ? best.opportunity : undefined;
104
+ }
@@ -0,0 +1,157 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import type { ProjectMemoryInput, ProjectMemoryKind, ProjectMemoryRecord, ProjectMemorySource } from "./types.ts";
7
+
8
+ const KINDS = new Set<ProjectMemoryKind>(["fact", "decision", "constraint", "lesson"]);
9
+ const SOURCES = new Set<ProjectMemorySource>(["user", "file", "git", "tool", "agent", "portable"]);
10
+
11
+ export interface ProjectMemoryStore {
12
+ db: DatabaseSync;
13
+ projectId: string;
14
+ close(): void;
15
+ }
16
+
17
+ export function getProjectMemoryDir(): string {
18
+ const base = process.env.XDG_DATA_HOME || join(process.env.HOME || process.cwd(), ".local", "share");
19
+ return join(base, "opencode", "workflow-guard", "project-memory");
20
+ }
21
+
22
+ export function getProjectMemoryIdentity(root: string): string {
23
+ const result = spawnSync("git", ["-C", root, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" });
24
+ const identity = result.status === 0 && result.stdout.trim() ? resolve(result.stdout.trim()) : resolve(root);
25
+ return createHash("sha256").update(identity).digest("hex").slice(0, 24);
26
+ }
27
+
28
+ export function ensureProjectMemoryExcluded(root: string): boolean {
29
+ const result = spawnSync("git", ["-C", root, "rev-parse", "--git-path", "info/exclude"], { encoding: "utf8" });
30
+ if (result.status !== 0 || !result.stdout.trim()) return false;
31
+ const path = resolve(root, result.stdout.trim());
32
+ let current = "";
33
+ try { current = readFileSync(path, "utf8"); } catch {}
34
+ if (current.split("\n").some((line) => line.trim() === ".opencode/memory/")) return true;
35
+ appendFileSync(path, `${current && !current.endsWith("\n") ? "\n" : ""}.opencode/memory/\n`);
36
+ return true;
37
+ }
38
+
39
+ export function isProjectMemoryFresh(memory: ProjectMemoryRecord, root: string): boolean {
40
+ if (!memory.commit || memory.paths.length === 0) return true;
41
+ if (!/^[0-9a-f]{7,64}$/i.test(memory.commit)) return false;
42
+ const result = spawnSync("git", ["-C", root, "diff", "--quiet", memory.commit, "--", ...memory.paths], { encoding: "utf8" });
43
+ if (result.status !== 0) return false;
44
+ const untracked = spawnSync("git", ["-C", root, "ls-files", "--others", "--exclude-standard", "--", ...memory.paths], { encoding: "utf8" });
45
+ return untracked.status === 0 && !untracked.stdout.trim();
46
+ }
47
+
48
+ export function openProjectMemory(projectId: string, directory = getProjectMemoryDir()): ProjectMemoryStore {
49
+ if (!projectId || projectId.length > 200) throw new Error("Invalid project memory identity.");
50
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
51
+ const db = new DatabaseSync(join(directory, `${projectId.replace(/[^a-zA-Z0-9._-]/g, "_")}.sqlite`));
52
+ db.exec(`
53
+ PRAGMA journal_mode = WAL;
54
+ PRAGMA busy_timeout = 2000;
55
+ CREATE TABLE IF NOT EXISTS memories (
56
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL,
57
+ source TEXT NOT NULL, created_at INTEGER NOT NULL, session_id TEXT, commit_sha TEXT,
58
+ paths TEXT NOT NULL, supersedes TEXT, status TEXT NOT NULL DEFAULT 'current'
59
+ );
60
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content);
61
+ `);
62
+ return { db, projectId, close: () => db.close() };
63
+ }
64
+
65
+ function validateInput(input: ProjectMemoryInput): void {
66
+ if (!KINDS.has(input.kind) || !SOURCES.has(input.source)) throw new Error("Invalid project memory kind or source.");
67
+ if (!input.content.trim() || input.content.length > 4000) throw new Error("Project memory content must be 1-4000 characters.");
68
+ if ((input.paths?.length ?? 0) > 20 || input.paths?.some((path) => !path || path.length > 500 || isAbsolute(path) || path.split(/[\\/]/).includes("..") || /[\0\r\n]/.test(path))) throw new Error("Invalid project memory paths.");
69
+ if (input.sessionID && input.sessionID.length > 200) throw new Error("Invalid project memory session ID.");
70
+ if (input.commit && !/^[0-9a-f]{7,64}$/i.test(input.commit)) throw new Error("Invalid project memory commit.");
71
+ if (input.supersedes && (input.supersedes.length > 200 || /[\0\r\n]/.test(input.supersedes))) throw new Error("Invalid superseded project memory ID.");
72
+ }
73
+
74
+ function rowToRecord(row: Record<string, unknown>): ProjectMemoryRecord {
75
+ return {
76
+ id: String(row.id), projectId: String(row.project_id), kind: String(row.kind) as ProjectMemoryKind,
77
+ content: String(row.content), source: String(row.source) as ProjectMemorySource, createdAt: Number(row.created_at),
78
+ sessionID: row.session_id ? String(row.session_id) : undefined, commit: row.commit_sha ? String(row.commit_sha) : undefined,
79
+ paths: JSON.parse(String(row.paths)) as string[], supersedes: row.supersedes ? String(row.supersedes) : undefined,
80
+ status: String(row.status) as "current" | "superseded",
81
+ };
82
+ }
83
+
84
+ export function recordProjectMemory(store: ProjectMemoryStore, input: ProjectMemoryInput, id: string = randomUUID()): ProjectMemoryRecord {
85
+ validateInput(input);
86
+ const createdAt = Date.now();
87
+ store.db.exec("BEGIN IMMEDIATE");
88
+ try {
89
+ if (input.supersedes) store.db.prepare("UPDATE memories SET status = 'superseded' WHERE id = ? AND project_id = ?").run(input.supersedes, store.projectId);
90
+ store.db.prepare("INSERT INTO memories VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'current')").run(
91
+ id, store.projectId, input.kind, input.content.trim(), input.source, createdAt,
92
+ input.sessionID ?? null, input.commit ?? null, JSON.stringify(input.paths ?? []), input.supersedes ?? null,
93
+ );
94
+ store.db.prepare("INSERT INTO memories_fts(id, content) VALUES (?, ?)").run(id, input.content.trim());
95
+ store.db.exec("COMMIT");
96
+ } catch (error) {
97
+ store.db.exec("ROLLBACK");
98
+ throw error;
99
+ }
100
+ return { id, projectId: store.projectId, ...input, content: input.content.trim(), paths: input.paths ?? [], createdAt, status: "current" };
101
+ }
102
+
103
+ export function searchProjectMemory(store: ProjectMemoryStore, query: string, limit = 8): ProjectMemoryRecord[] {
104
+ const terms = query.match(/[\p{L}\p{N}_-]+/gu)?.slice(0, 12) ?? [];
105
+ if (terms.length === 0) return [];
106
+ const ftsQuery = terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
107
+ const rows = store.db.prepare(`
108
+ SELECT m.* FROM memories_fts f JOIN memories m ON m.id = f.id
109
+ WHERE memories_fts MATCH ? AND m.project_id = ? AND m.status = 'current'
110
+ ORDER BY bm25(memories_fts), m.created_at DESC LIMIT ?
111
+ `).all(ftsQuery, store.projectId, Math.max(1, Math.min(limit, 20))) as Record<string, unknown>[];
112
+ return rows.map(rowToRecord);
113
+ }
114
+
115
+ export function getRecentProjectMemory(store: ProjectMemoryStore, limit = 8): ProjectMemoryRecord[] {
116
+ const rows = store.db.prepare("SELECT * FROM memories WHERE project_id = ? AND status = 'current' ORDER BY created_at DESC LIMIT ?")
117
+ .all(store.projectId, Math.max(1, Math.min(limit, 20))) as Record<string, unknown>[];
118
+ return rows.map(rowToRecord);
119
+ }
120
+
121
+ export function exportProjectKnowledge(store: ProjectMemoryStore, ids: string[], path: string, rejectContent: (content: string) => boolean = () => false): number {
122
+ const selected = ids.slice(0, 100).flatMap((id) => {
123
+ const row = store.db.prepare("SELECT * FROM memories WHERE id = ? AND project_id = ? AND status = 'current'").get(id, store.projectId) as Record<string, unknown> | undefined;
124
+ if (!row) return [];
125
+ const record = rowToRecord(row);
126
+ return rejectContent(record.content) ? [] : [record];
127
+ });
128
+ mkdirSync(dirname(path), { recursive: true });
129
+ const temporary = `${path}.${process.pid}.tmp`;
130
+ writeFileSync(temporary, selected.map((record) => JSON.stringify(record)).join("\n") + (selected.length ? "\n" : ""), { mode: 0o600 });
131
+ renameSync(temporary, path);
132
+ return selected.length;
133
+ }
134
+
135
+ export function importProjectKnowledge(store: ProjectMemoryStore, path: string, rejectContent: (content: string) => boolean = () => false): number {
136
+ let text: string;
137
+ try {
138
+ if (statSync(path).size > 1_000_000) return 0;
139
+ text = readFileSync(path, "utf8");
140
+ } catch { return 0; }
141
+ let imported = 0;
142
+ for (const line of text.split("\n").filter(Boolean).slice(0, 1000)) {
143
+ try {
144
+ const parsed = JSON.parse(line) as Partial<ProjectMemoryRecord>;
145
+ if (!parsed.id || typeof parsed.id !== "string" || parsed.id.length > 200 || /[\0\r\n]/.test(parsed.id) || typeof parsed.content !== "string" || rejectContent(parsed.content) || !KINDS.has(parsed.kind as ProjectMemoryKind)) continue;
146
+ const exists = store.db.prepare("SELECT 1 FROM memories WHERE id = ? AND project_id = ?").get(parsed.id, store.projectId);
147
+ if (exists) continue;
148
+ const superseded = typeof parsed.supersedes === "string"
149
+ ? store.db.prepare("SELECT source FROM memories WHERE id = ? AND project_id = ?").get(parsed.supersedes, store.projectId) as { source?: string } | undefined
150
+ : undefined;
151
+ const supersedes = superseded?.source === "portable" ? parsed.supersedes : undefined;
152
+ recordProjectMemory(store, { kind: parsed.kind as ProjectMemoryKind, content: parsed.content, source: "portable", paths: Array.isArray(parsed.paths) ? parsed.paths : [], commit: parsed.commit, supersedes }, parsed.id);
153
+ imported++;
154
+ } catch {}
155
+ }
156
+ return imported;
157
+ }
package/src/lib/state.ts CHANGED
@@ -291,3 +291,13 @@ export function getSubagentMutationBudget(root: string): number {
291
291
  }
292
292
  return 50;
293
293
  }
294
+
295
+ export function isLearningEnabled(root: string): boolean {
296
+ return process.env.WORKFLOW_GUARD_LEARNING === "1";
297
+ }
298
+
299
+ export function getLearningInterventionBudget(root: string): number {
300
+ const configured = getProjectConfig(root).maxLearningInterventions;
301
+ if (typeof configured === "number" && configured >= 0) return Math.floor(configured);
302
+ return 3;
303
+ }
package/src/lib/types.ts CHANGED
@@ -22,6 +22,7 @@ export interface ProjectConfig {
22
22
  requireReview?: boolean;
23
23
  requireDocumentation?: boolean;
24
24
  maxSubagentMutations?: number;
25
+ maxLearningInterventions?: number;
25
26
  }
26
27
 
27
28
  export interface VerifyResult {
@@ -77,3 +78,61 @@ export interface ShellMutation {
77
78
  target?: string;
78
79
  what: string;
79
80
  }
81
+
82
+ export type LearningStage = "exposed" | "developing" | "demonstrated" | "independent" | "critique";
83
+ export type LearningEvidenceKind = LearningStage | "needs-reinforcement";
84
+ export type LearningOpportunityType = "design" | "debugging" | "new-concept";
85
+
86
+ export interface LearningEvidence {
87
+ concept: string;
88
+ kind: LearningEvidenceKind;
89
+ summary: string;
90
+ timestamp: number;
91
+ sessionID?: string;
92
+ project?: string;
93
+ }
94
+
95
+ export interface LearnerConcept {
96
+ stage: LearningStage;
97
+ lastObservedAt: number;
98
+ evidence: LearningEvidence[];
99
+ }
100
+
101
+ export interface LearnerProfile {
102
+ version: 1;
103
+ concepts: Record<string, LearnerConcept>;
104
+ }
105
+
106
+ export interface LearningOpportunity {
107
+ type: LearningOpportunityType;
108
+ concept: string;
109
+ relevance: number;
110
+ consequence: number;
111
+ }
112
+
113
+ export type ProjectMemoryKind = "fact" | "decision" | "constraint" | "lesson";
114
+ export type ProjectMemorySource = "user" | "file" | "git" | "tool" | "agent" | "portable";
115
+
116
+ export interface ProjectMemoryRecord {
117
+ id: string;
118
+ projectId: string;
119
+ kind: ProjectMemoryKind;
120
+ content: string;
121
+ source: ProjectMemorySource;
122
+ createdAt: number;
123
+ sessionID?: string;
124
+ commit?: string;
125
+ paths: string[];
126
+ supersedes?: string;
127
+ status: "current" | "superseded";
128
+ }
129
+
130
+ export interface ProjectMemoryInput {
131
+ kind: ProjectMemoryKind;
132
+ content: string;
133
+ source: ProjectMemorySource;
134
+ sessionID?: string;
135
+ commit?: string;
136
+ paths?: string[];
137
+ supersedes?: string;
138
+ }
package/src/lib/utils.ts CHANGED
@@ -44,9 +44,42 @@ export function decodeShellEscapes(text: string): string {
44
44
  }
45
45
 
46
46
  export function shellWords(command: string): string[] {
47
- return (command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((word) =>
48
- word.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, "$1$2"),
49
- );
47
+ const words: string[] = [];
48
+ let word = "";
49
+ let quote: "'" | '"' | undefined;
50
+ let escaped = false;
51
+ let started = false;
52
+ for (const char of command) {
53
+ if (escaped) {
54
+ word += char;
55
+ escaped = false;
56
+ started = true;
57
+ continue;
58
+ }
59
+ if (char === "\\" && quote !== "'") {
60
+ escaped = true;
61
+ started = true;
62
+ continue;
63
+ }
64
+ if (char === "'" || char === '"') {
65
+ if (!quote) quote = char;
66
+ else if (quote === char) quote = undefined;
67
+ else word += char;
68
+ started = true;
69
+ continue;
70
+ }
71
+ if (!quote && /\s/.test(char)) {
72
+ if (started) words.push(word);
73
+ word = "";
74
+ started = false;
75
+ continue;
76
+ }
77
+ word += char;
78
+ started = true;
79
+ }
80
+ if (escaped) word += "\\";
81
+ if (started) words.push(word);
82
+ return words;
50
83
  }
51
84
 
52
85
  export function splitShellSegments(command: string): string[] {
@@ -158,10 +158,11 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
158
158
  what: `tee to '${teeTargets[0]!}'`,
159
159
  };
160
160
  }
161
- const sedMatch = segment.match(/\bsed\s+(?:-(?:[a-zA-Z]*i[a-zA-Z]*|i\S*)\s+)+[^\s;&|]+/);
162
- if (sedMatch) {
163
- const tokens = sedMatch[0]!.split(/\s+/);
164
- const target = tokens[tokens.length - 1];
161
+ const sedWords = shellWords(unwrapShellCommand(segment));
162
+ if (sedWords[0] === "sed" && sedWords.slice(1).some((word) => /^-(?:[a-zA-Z]*i|i\S*)$/.test(word) || /^--in-place(?:=.*)?$/.test(word))) {
163
+ const operands = sedWords.slice(1).filter((word) => !word.startsWith("-"));
164
+ const target = operands.at(-1);
165
+ if (!target) return undefined;
165
166
  return { kind: "command", target, what: `sed -i on '${target}'` };
166
167
  }
167
168
  const transfer = filesystemTransferInfo(segment);
@@ -220,6 +221,34 @@ export function simpleFilesystemMutations(segment: string): ShellMutation[] {
220
221
  };
221
222
  });
222
223
  }
224
+ if (command === "sed") {
225
+ let inPlace = false;
226
+ let scriptSupplied = false;
227
+ const targets: string[] = [];
228
+ for (let i = 1; i < words.length; i++) {
229
+ const word = words[i]!;
230
+ if (/^-(?:[a-zA-Z]*i|i\S*)$/.test(word) || /^--in-place(?:=.*)?$/.test(word)) {
231
+ inPlace = true;
232
+ continue;
233
+ }
234
+ if (word === "-e" || word === "--expression" || word === "-f" || word === "--file") {
235
+ scriptSupplied = true;
236
+ i++;
237
+ continue;
238
+ }
239
+ if (/^(?:-e|--expression=|-f|--file=)/.test(word) || word.startsWith("-")) {
240
+ if (/^(?:-e|--expression=)/.test(word)) scriptSupplied = true;
241
+ continue;
242
+ }
243
+ if (!scriptSupplied) {
244
+ scriptSupplied = true;
245
+ continue;
246
+ }
247
+ targets.push(word);
248
+ }
249
+ if (!inPlace) return [];
250
+ return targets.map((target) => ({ kind: "command" as const, target, what: `sed -i on '${target}'` }));
251
+ }
223
252
  if (!SIMPLE_MUTATION_COMMANDS.has(command)) return [];
224
253
  return words
225
254
  .slice(1)
@@ -1,4 +1,4 @@
1
- import { asRecord } from "../lib/utils.ts";
1
+ import { asRecord, shellWords, splitShellSegments } from "../lib/utils.ts";
2
2
 
3
3
  export interface LivePattern {
4
4
  re: RegExp;
@@ -62,9 +62,48 @@ export const LIVE_MUTATION_PATTERNS: LivePattern[] = [
62
62
  { re: /\bmknod\s+\S+\s+p\b/, what: ["named FIFO pipe ", "creation (reverse shell backpipe)"].join("") },
63
63
  ];
64
64
 
65
+ const KUBECTL_VALUE_GLOBALS = new Set([
66
+ "--as", "--as-group", "--as-uid", "--as-user-extra", "--cache-dir", "--certificate-authority", "--client-certificate", "--client-key",
67
+ "--cluster", "--context", "--kubeconfig", "--kuberc", "--namespace", "--password", "--profile", "--profile-output", "--request-timeout",
68
+ "--server", "--storage-driver-buffer-duration", "--storage-driver-db", "--storage-driver-host", "--storage-driver-password", "--storage-driver-table",
69
+ "--storage-driver-user", "--tls-server-name", "--token", "--user", "--username", "-n", "-s",
70
+ ]);
71
+ const KUBECTL_BOOLEAN_GLOBALS = new Set(["--disable-compression", "--insecure-skip-tls-verify", "--match-server-version", "--storage-driver-secure", "--version", "--warnings-as-errors"]);
72
+
73
+ function normalizedKubectlCommands(command: string): string {
74
+ const normalized: string[] = [];
75
+ for (const segment of splitShellSegments(command)) {
76
+ const words = shellWords(segment);
77
+ for (let i = 0; i < words.length; i++) {
78
+ if (words[i] !== "kubectl") continue;
79
+ let j = i + 1;
80
+ while (j < words.length) {
81
+ const option = words[j];
82
+ const name = option.split("=", 1)[0];
83
+ if (/^-[ns].+/.test(option) && !option.startsWith("--")) {
84
+ j++;
85
+ continue;
86
+ }
87
+ if (KUBECTL_VALUE_GLOBALS.has(name)) {
88
+ j += option.includes("=") ? 1 : 2;
89
+ continue;
90
+ }
91
+ if (KUBECTL_BOOLEAN_GLOBALS.has(name)) {
92
+ j++;
93
+ continue;
94
+ }
95
+ break;
96
+ }
97
+ if (j < words.length) normalized.push(`kubectl ${words.slice(j).join(" ")}`);
98
+ }
99
+ }
100
+ return normalized.join(" ; ");
101
+ }
102
+
65
103
  export function liveMutationIn(command: string): string | undefined {
104
+ const optionNormalized = `${normalizedKubectlCommands(command)} ; ${command.replace(/\b(terraform|tofu)\s+(?:(?:-chdir(?:=\S+|\s+\S+)|-(?:no-color|version))\s+)*/g, "$1 ")}`;
66
105
  for (const { re, what } of LIVE_MUTATION_PATTERNS) {
67
- if (re.test(command)) {
106
+ if (re.test(command) || re.test(optionNormalized)) {
68
107
  return what;
69
108
  }
70
109
  }
@@ -16,6 +16,22 @@ export const PROTECTED_BRANCHES = new Set(["main", "master"]);
16
16
  export const PUSH_TO_MAIN_RE =
17
17
  /\bgit\s+push\b[^|;&]*(?:^|\s)\+?[\w./-]*:(?:main|master)(?![\w./-])|\bgit\s+push\b[^|;&]*(?:\s|\/)(?:main|master)(?![\w./-])/;
18
18
 
19
+ export function hasUnsafeGitAlias(command: string): boolean {
20
+ return splitShellSegments(command).some((segment) => {
21
+ const words = unwrapShellWords(segment);
22
+ const gitIndex = words.findIndex((word, index) =>
23
+ word === "git" && words.slice(0, index).every((prefix) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(prefix)),
24
+ );
25
+ if (gitIndex < 0) return false;
26
+ const args = words.slice(gitIndex + 1);
27
+ return args.some((arg, index) =>
28
+ /^-calias\./i.test(arg) ||
29
+ /^--config-env=alias\./i.test(arg) ||
30
+ ((arg === "-c" || arg === "--config-env") && /^alias\./i.test(args[index + 1] ?? "")),
31
+ );
32
+ });
33
+ }
34
+
19
35
  export const GIT_BRANCH_CREATE_RE =
20
36
  /\bgit\s+(?:checkout\s+-b|switch\s+(?:-c|--create))\b/;
21
37
 
@@ -4,7 +4,7 @@ import { isPathOutsideWorkspace } from "./boundary.ts";
4
4
  export function extractInterpreterPayload(segment: string): string[] {
5
5
  const payloads: string[] = [];
6
6
  const inlineMatch = segment.match(
7
- /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce]\s+)(?:"([^"]*)"|'([^']*)')/i,
7
+ /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh)\s+(?:-[a-zA-Z]*[ce])\s*(?:"([^"]*)"|'([^']*)')/i,
8
8
  );
9
9
  if (inlineMatch?.[1] || inlineMatch?.[2]) {
10
10
  payloads.push(inlineMatch[1] ?? inlineMatch[2] ?? "");
@@ -49,9 +49,14 @@ export function secretPathInPayload(payload: string): string | undefined {
49
49
  return undefined;
50
50
  }
51
51
 
52
- const FUNCTION_TARGET_PATTERNS: RegExp[] = [
52
+ const WRITE_TARGET_PATTERNS: RegExp[] = [
53
53
  /\b(?:writeFileSync|writeFile|appendFile|appendFileSync|createWriteStream|unlink|unlinkSync|rmSync|rmdirSync|mkdirSync|renameSync|copyFileSync|cpSync|truncateSync)\s*\(\s*["']([^"']+)["']/g,
54
- /\b(?:open|os\.remove|os\.unlink|os\.rmdir|os\.mkdir|os\.makedirs|os\.rename|shutil\.rmtree|shutil\.copy|shutil\.copy2|shutil\.move|Path)\s*\(\s*["']([^"']+)["']/g,
54
+ /\b(?:renameSync|copyFileSync|cpSync)\s*\(\s*["'][^"']+["']\s*,\s*["']([^"']+)["']/g,
55
+ /\b(?:os\.remove|os\.unlink|os\.rmdir|os\.mkdir|os\.makedirs|os\.rename|shutil\.rmtree|shutil\.copy|shutil\.copy2|shutil\.move)\s*\(\s*["']([^"']+)["']/g,
56
+ /\b(?:os\.rename|shutil\.copy|shutil\.copy2|shutil\.move)\s*\(\s*["'][^"']+["']\s*,\s*["']([^"']+)["']/g,
57
+ /\bopen\s*\(\s*["']([^"']+)["']\s*,\s*["'][^"']*[wax+][^"']*["']/g,
58
+ /\bPath\s*\(\s*["']([^"']+)["']\s*\)\s*\.\s*(?:write_text|write_bytes|unlink|mkdir|touch|rename|replace)\s*\(/g,
59
+ /\bPath\s*\(\s*["'][^"']+["']\s*\)\s*\.\s*(?:rename|replace)\s*\(\s*["']([^"']+)["']/g,
55
60
  /[>]{1,2}\s*["']?([^\s;&"'|)]+)/g,
56
61
  ];
57
62
 
@@ -63,7 +68,7 @@ const FUNCTION_TARGET_PATTERNS: RegExp[] = [
63
68
  export function outsideWritePathInPayload(payload: string, root: string): string | undefined {
64
69
  const candidates = new Set<string>();
65
70
 
66
- for (const pattern of FUNCTION_TARGET_PATTERNS) {
71
+ for (const pattern of WRITE_TARGET_PATTERNS) {
67
72
  const matches = payload.matchAll(pattern);
68
73
  for (const m of matches) {
69
74
  if (m[1]) candidates.add(m[1]);
@@ -78,3 +83,13 @@ export function outsideWritePathInPayload(payload: string, root: string): string
78
83
  }
79
84
  return undefined;
80
85
  }
86
+
87
+ export function writePathsInPayload(payload: string): string[] {
88
+ const candidates = new Set<string>();
89
+ for (const pattern of WRITE_TARGET_PATTERNS) {
90
+ for (const match of payload.matchAll(pattern)) {
91
+ if (match[1]) candidates.add(match[1]);
92
+ }
93
+ }
94
+ return [...candidates];
95
+ }
@@ -1,6 +1,7 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { basename, resolve } from "node:path";
3
3
  import { getWorkspaceRoot } from "../lib/state.ts";
4
+ import { shellWords, unwrapShellCommand } from "../lib/utils.ts";
4
5
 
5
6
  export const SAFE_ENV_FIXTURE_RE =
6
7
  /\.env\.(example|sample|template|dist|schema)(\.[\w-]+)*$/i;
@@ -154,5 +155,16 @@ export function secretFileReadIn(segment: string): string | undefined {
154
155
  }
155
156
  const simpleMatch = segment.match(SIMPLE_FILE_READ_COMMAND_RE);
156
157
  if (simpleMatch?.[1] && isSecretPath(simpleMatch[1])) return simpleMatch[1];
158
+ const words = shellWords(unwrapShellCommand(segment));
159
+ if (["cat", "head", "tail", "less", "more", "od", "hexdump", "strings", "base64", "xxd", "nl", "view", "nano", "vim"].includes(words[0] ?? "")) {
160
+ for (const operand of words.slice(1).filter((word) => !word.startsWith("-"))) {
161
+ if (isSecretPath(operand)) return operand;
162
+ }
163
+ }
164
+ if (words[0] === "dd") {
165
+ for (const word of words.slice(1)) {
166
+ if (word.startsWith("if=") && isSecretPath(word.slice(3))) return word.slice(3);
167
+ }
168
+ }
157
169
  return undefined;
158
170
  }
@@ -9,9 +9,11 @@
9
9
  */
10
10
 
11
11
  import { tool, type Plugin, type PluginModule } from "@opencode-ai/plugin";
12
+ import type { LearningEvidenceKind } from "./lib/types.ts";
12
13
  import { spawnSync } from "node:child_process";
13
14
  import { existsSync, readFileSync } from "node:fs";
14
15
  import { resolve } from "node:path";
16
+ import { join } from "node:path";
15
17
 
16
18
  // ── Types ────────────────────────────────────────────────────────────────────
17
19
  export type {
@@ -23,6 +25,9 @@ export type {
23
25
  AuditEntry,
24
26
  GitInvocation,
25
27
  ShellMutation,
28
+ LearnerProfile,
29
+ LearningEvidence,
30
+ LearningOpportunity,
26
31
  } from "./lib/types.ts";
27
32
 
28
33
  // ── State & runtime context ──────────────────────────────────────────────────
@@ -48,6 +53,8 @@ import {
48
53
  isReviewRequired,
49
54
  isDocumentationRequired,
50
55
  getSubagentMutationBudget,
56
+ isLearningEnabled,
57
+ getLearningInterventionBudget,
51
58
  recordReviewResult,
52
59
  getLastReviewResult,
53
60
  resetReviewState,
@@ -70,6 +77,8 @@ export {
70
77
  isReviewRequired,
71
78
  isDocumentationRequired,
72
79
  getSubagentMutationBudget,
80
+ isLearningEnabled,
81
+ getLearningInterventionBudget,
73
82
  recordReviewResult,
74
83
  getLastReviewResult,
75
84
  resetReviewState,
@@ -89,6 +98,48 @@ import {
89
98
 
90
99
  export { getCleanEnv };
91
100
 
101
+ // ── Adaptive learning engine ─────────────────────────────────────────────────
102
+ import {
103
+ loadLearnerProfile,
104
+ recordLearningEvidence,
105
+ selectLearningOpportunity,
106
+ updateLearnerProfile,
107
+ } from "./lib/learning.ts";
108
+ export {
109
+ createLearnerProfile,
110
+ getLearnerProfilePath,
111
+ loadLearnerProfile,
112
+ recordLearningEvidence,
113
+ saveLearnerProfile,
114
+ selectLearningOpportunity,
115
+ updateLearnerProfile,
116
+ } from "./lib/learning.ts";
117
+
118
+ // ── Project memory ───────────────────────────────────────────────────────────
119
+ export {
120
+ getProjectMemoryDir,
121
+ getProjectMemoryIdentity,
122
+ ensureProjectMemoryExcluded,
123
+ isProjectMemoryFresh,
124
+ openProjectMemory,
125
+ recordProjectMemory,
126
+ searchProjectMemory,
127
+ getRecentProjectMemory,
128
+ exportProjectKnowledge,
129
+ importProjectKnowledge,
130
+ } from "./lib/project-memory.ts";
131
+ import {
132
+ exportProjectKnowledge,
133
+ ensureProjectMemoryExcluded,
134
+ getProjectMemoryIdentity,
135
+ getRecentProjectMemory,
136
+ importProjectKnowledge,
137
+ isProjectMemoryFresh,
138
+ openProjectMemory,
139
+ recordProjectMemory,
140
+ searchProjectMemory,
141
+ } from "./lib/project-memory.ts";
142
+
92
143
  // ── Audit & durable verification cache ───────────────────────────────────────
93
144
  import {
94
145
  getAuditFilePath,
@@ -170,6 +221,7 @@ import {
170
221
  isBranchAlreadyMergedOrClosed,
171
222
  checkMergeConflicts,
172
223
  checkBranchBaseIsUpToDate,
224
+ hasUnsafeGitAlias,
173
225
  } from "./policies/git.ts";
174
226
 
175
227
  export {
@@ -215,6 +267,7 @@ import {
215
267
  extractInterpreterPayload,
216
268
  secretPathInPayload,
217
269
  outsideWritePathInPayload,
270
+ writePathsInPayload,
218
271
  } from "./policies/interpreter.ts";
219
272
  export { extractInterpreterPayload, secretPathInPayload, outsideWritePathInPayload };
220
273
 
@@ -425,7 +478,7 @@ async function guardToolCallImpl(
425
478
  logBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
426
479
  return PROTECTED_PATH_REASON;
427
480
  }
428
- if (target && isSecretPath(target) && !allowLive) {
481
+ if (target && isSecretPath(target)) {
429
482
  logBlock(`[workflow-guard] blocked ${toolName}: secret file path ${target}`);
430
483
  return (
431
484
  `Blocked: modifying secret file '${target}' directly is not permitted. ` +
@@ -442,7 +495,7 @@ async function guardToolCallImpl(
442
495
  );
443
496
  return PROTECTED_PATH_REASON;
444
497
  }
445
- if (isSecretPath(patchPath) && !allowLive) {
498
+ if (isSecretPath(patchPath)) {
446
499
  logBlock(
447
500
  `[workflow-guard] blocked apply_patch: secret file path ${patchPath}`,
448
501
  );
@@ -502,14 +555,9 @@ async function guardToolCallImpl(
502
555
  }
503
556
  }
504
557
  }
505
- for (const content of extractEditContent(input)) {
506
- if (isSettingsTamper(content)) {
507
- logBlock(
508
- `[workflow-guard] blocked ${toolName}: payload tampers with settings`,
509
- );
510
- return PROTECTED_PATH_REASON;
511
- }
512
- }
558
+ }
559
+ for (const content of extractEditContent(input)) {
560
+ if (isSettingsTamper(content)) return PROTECTED_PATH_REASON;
513
561
  }
514
562
 
515
563
  if (onProtectedBranch(currentRoot)) {
@@ -596,6 +644,9 @@ async function guardToolCallImpl(
596
644
  }
597
645
 
598
646
  const normalizedCommand = normalizeGitCommands(command);
647
+ if (hasUnsafeGitAlias(command)) {
648
+ return "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.";
649
+ }
599
650
  const gitInvocations = splitShellSegments(command)
600
651
  .map((segment) => parseGitInvocation(segment.trim()))
601
652
  .filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
@@ -646,8 +697,7 @@ async function guardToolCallImpl(
646
697
  }
647
698
 
648
699
  // ── Policy 17: secret file reads via shell ──
649
- if (!allowLive) {
650
- for (const segment of command.split(/[\n|;&]+/)) {
700
+ for (const segment of command.split(/[\n|;&]+/)) {
651
701
  const secretFile = secretFileReadIn(segment.trim());
652
702
  if (secretFile) {
653
703
  logBlock(
@@ -658,7 +708,6 @@ async function guardToolCallImpl(
658
708
  "Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
659
709
  );
660
710
  }
661
- }
662
711
  }
663
712
 
664
713
  // ── Policy 18: interpreter inline evasion scanner ──
@@ -672,6 +721,21 @@ async function guardToolCallImpl(
672
721
  );
673
722
  return `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
674
723
  }
724
+ const writePaths = writePathsInPayload(payload);
725
+ if (writePaths.length > 0) {
726
+ if (context?.agent && isReadOnlyRole(context.agent)) {
727
+ return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations.`;
728
+ }
729
+ for (const path of writePaths) {
730
+ if (isProtectedPath(path)) return PROTECTED_PATH_REASON;
731
+ }
732
+ if (onProtectedBranch(currentRoot)) return branchGuardReason();
733
+ const todos = await effectiveTodos(context?.sessionID);
734
+ if (todos !== undefined && !hasActiveTodo(todos)) {
735
+ return "Blocked: inline interpreter file mutation with no active todo item.";
736
+ }
737
+ recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
738
+ }
675
739
 
676
740
  if (!allowLive) {
677
741
  const normPayload = normalizeGitCommands(normalize(payload));
@@ -682,23 +746,10 @@ async function guardToolCallImpl(
682
746
  );
683
747
  return `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard.`;
684
748
  }
685
- if (isSettingsTamper(payload)) {
686
- logBlock(
687
- `[workflow-guard] blocked interpreter payload with settings tamper`,
688
- );
689
- return PROTECTED_PATH_REASON;
690
- }
691
- const secretPath = secretPathInPayload(payload);
692
- if (secretPath) {
693
- logBlock(
694
- `[workflow-guard] blocked interpreter payload accessing secret file: ${secretPath}`,
695
- );
696
- return (
697
- `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted. ` +
698
- "Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
699
- );
700
- }
701
749
  }
750
+ if (isSettingsTamper(payload)) return PROTECTED_PATH_REASON;
751
+ const secretPath = secretPathInPayload(payload);
752
+ if (secretPath) return `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`;
702
753
  }
703
754
 
704
755
  // ── Policies 1, 7 & 8: shell file mutations get the same gates as edits ──
@@ -784,12 +835,12 @@ async function guardToolCallImpl(
784
835
  const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
785
836
  const prTool = isAz ? "az repos pr create" : "gh pr create";
786
837
  const descFlag = isAz ? "--description" : "--body";
838
+ const preflightFailures: string[] = [];
787
839
 
788
840
  // Policy 19: conflict-free mergeability gate before opening PR
789
841
  const conflictCheck = checkMergeConflicts(currentRoot);
790
842
  if (conflictCheck.hasConflicts) {
791
- logBlock(`[workflow-guard] blocked ${prTool}: merge conflicts detected`);
792
- return `Blocked: ${conflictCheck.reason}`;
843
+ preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
793
844
  }
794
845
 
795
846
  // Policy 20: branch already merged/closed
@@ -797,10 +848,7 @@ async function guardToolCallImpl(
797
848
  if (branch) {
798
849
  const mergedStatus = isBranchAlreadyMergedOrClosed(currentRoot, branch);
799
850
  if (mergedStatus.merged) {
800
- logBlock(
801
- `[workflow-guard] blocked ${prTool}: branch already merged/closed`,
802
- );
803
- return `Blocked: ${mergedStatus.reason}`;
851
+ preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
804
852
  }
805
853
  }
806
854
 
@@ -816,11 +864,7 @@ async function guardToolCallImpl(
816
864
  review.gitStatus === getGitStatusSummary(currentRoot) &&
817
865
  review.worktreeFingerprint === getGitWorktreeFingerprint(currentRoot);
818
866
  if (!reviewMatchesContext) {
819
- logBlock(`[workflow-guard] blocked ${prTool}: review approval required`);
820
- return (
821
- `Blocked: PR creation requires a passing review approval. ` +
822
- "Invoke a secondary review subagent to record an approval using the record_review tool first."
823
- );
867
+ preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
824
868
  }
825
869
  }
826
870
 
@@ -828,11 +872,7 @@ async function guardToolCallImpl(
828
872
  if (isDocumentationRequired(currentRoot)) {
829
873
  const hasDocChange = branchHasDocumentationChange(currentRoot);
830
874
  if (!hasDocChange) {
831
- logBlock(`[workflow-guard] blocked ${prTool}: documentation update required`);
832
- return (
833
- `Blocked: PR requires documentation updates (Policy 21). ` +
834
- "Update README.md or relevant documentation in docs/ before opening a PR."
835
- );
875
+ preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
836
876
  }
837
877
  }
838
878
 
@@ -843,19 +883,19 @@ async function guardToolCallImpl(
843
883
  branchChangelog ||
844
884
  prSegments.every((segment) => prBodyIncludesChangelog(segment));
845
885
  if (!hasChangelog) {
846
- logBlock(`[workflow-guard] blocked ${prTool}: no changelog found`);
847
- return (
848
- `Blocked: PR must include a changelog. Either update a ` +
849
- `CHANGELOG file in this branch's diff, or include a ` +
850
- `'Changelog:' section in the PR description (${descFlag}).`
851
- );
886
+ preflightFailures.push(`Changelog is required; update a CHANGELOG file or include a 'Changelog:' section in the PR description (${descFlag}).`);
852
887
  }
853
888
 
854
889
  // Lockfile synchronization pre-flight gate
855
890
  const lockCheck = checkLockfileSync(currentRoot);
856
891
  if (lockCheck.isOutOfSync) {
857
- logBlock(`[workflow-guard] blocked ${prTool}: ${lockCheck.reason}`);
858
- return `Blocked: ${lockCheck.reason}`;
892
+ preflightFailures.push(lockCheck.reason ?? "Dependency lockfile is out of sync with its manifest.");
893
+ }
894
+
895
+ if (preflightFailures.length > 0) {
896
+ const reason = `Blocked: PR preflight failed:\n${preflightFailures.map((failure) => `- ${failure}`).join("\n")}`;
897
+ logBlock(`[workflow-guard] blocked ${prTool}: ${preflightFailures.length} preflight requirement(s) failed`);
898
+ return reason;
859
899
  }
860
900
  }
861
901
 
@@ -956,6 +996,18 @@ export const WorkflowGuard: Plugin = async (ctx) => {
956
996
  setWorkspaceRoot(effectiveRoot);
957
997
  setSdkClient(ctx.client);
958
998
  reloadProjectConfig(effectiveRoot);
999
+ const learningEnabled = isLearningEnabled(effectiveRoot);
1000
+ const learningInterventions = new Map<string, number>();
1001
+ const portableMemoryPath = join(effectiveRoot, ".opencode", "memory", "project-memory.jsonl");
1002
+ let projectMemory: ReturnType<typeof openProjectMemory> | undefined;
1003
+ try {
1004
+ projectMemory = openProjectMemory(getProjectMemoryIdentity(effectiveRoot));
1005
+ ensureProjectMemoryExcluded(effectiveRoot);
1006
+ importProjectKnowledge(projectMemory, portableMemoryPath, (content) => secretIn(content) !== undefined);
1007
+ } catch {
1008
+ try { projectMemory?.close(); } catch {}
1009
+ projectMemory = undefined;
1010
+ }
959
1011
 
960
1012
  try {
961
1013
  await (ctx.client as any)?.app?.log?.({
@@ -969,6 +1021,120 @@ export const WorkflowGuard: Plugin = async (ctx) => {
969
1021
 
970
1022
  return {
971
1023
  tool: {
1024
+ project_memory_search: tool({
1025
+ description: "Search current durable knowledge for this project. Returns concise typed records with provenance; superseded records are excluded.",
1026
+ args: { query: tool.schema.string() },
1027
+ 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.",
1028
+ }),
1029
+ project_memory_record: tool({
1030
+ description: "Record a durable project fact, decision, constraint, or lesson when it will matter in future sessions. Do not record transient tool output, secrets, or speculative hypotheses.",
1031
+ args: {
1032
+ kind: tool.schema.string().describe("fact, decision, constraint, or lesson"),
1033
+ content: tool.schema.string(),
1034
+ paths: tool.schema.array(tool.schema.string()),
1035
+ supersedes: tool.schema.string().optional(),
1036
+ },
1037
+ execute: async (args, toolContext) => {
1038
+ if (!projectMemory) return "[workflow-guard] Project memory unavailable; core guard enforcement remains active.";
1039
+ if (!(["fact", "decision", "constraint", "lesson"] as string[]).includes(args.kind)) return "[workflow-guard] Project memory rejected: invalid kind.";
1040
+ const detectedSecret = secretIn(args.content);
1041
+ if (detectedSecret) return `[workflow-guard] Project memory rejected: possible secret detected (${detectedSecret}).`;
1042
+ const record = recordProjectMemory(projectMemory, {
1043
+ kind: args.kind as "fact" | "decision" | "constraint" | "lesson",
1044
+ content: args.content,
1045
+ source: "agent",
1046
+ sessionID: toolContext.sessionID,
1047
+ commit: getCurrentGitCommitHash(effectiveRoot),
1048
+ paths: args.paths,
1049
+ supersedes: args.supersedes || undefined,
1050
+ });
1051
+ return JSON.stringify(record);
1052
+ },
1053
+ }),
1054
+ project_memory_export: tool({
1055
+ description: "Explicitly promote selected durable project-memory records to the human-readable repo-local .opencode/memory/project-memory.jsonl file. Nothing is exported automatically.",
1056
+ args: { ids: tool.schema.array(tool.schema.string()) },
1057
+ execute: async (args) => {
1058
+ if (!projectMemory) return "[workflow-guard] Project memory unavailable; core guard enforcement remains active.";
1059
+ if (args.ids.length > 100 || args.ids.some((id) => id.length > 200)) return "[workflow-guard] Project memory export rejected: invalid record IDs.";
1060
+ const count = exportProjectKnowledge(projectMemory, args.ids, portableMemoryPath, (content) => secretIn(content) !== undefined);
1061
+ return `[workflow-guard] Exported ${count} project-memory record(s) to .opencode/memory/project-memory.jsonl.`;
1062
+ },
1063
+ }),
1064
+ project_memory_import: tool({
1065
+ description: "Import the fixed repo-local .opencode/memory/project-memory.jsonl file into this project's local working-memory index.",
1066
+ args: {},
1067
+ 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.",
1068
+ }),
1069
+ ...(learningEnabled ? {
1070
+ learning_profile: tool({
1071
+ description: "Inspect the local evidence-based learner profile so teaching can build on demonstrated knowledge without assuming unobserved concepts are gaps.",
1072
+ args: {},
1073
+ execute: async () => JSON.stringify(loadLearnerProfile(), null, 2),
1074
+ }),
1075
+ learning_checkpoint: tool({
1076
+ description: "At a high-value design decision, debugging moment, or important new concept, rank candidate Socratic learning opportunities. If one is selected, ask one concise question before continuing the work; do not turn routine syntax into a lesson.",
1077
+ args: {
1078
+ opportunities: tool.schema.array(tool.schema.object({
1079
+ type: tool.schema.string().describe("design, debugging, or new-concept"),
1080
+ concept: tool.schema.string().describe("Transferable engineering concept"),
1081
+ relevance: tool.schema.number().describe("Current-task relevance from 0 to 1"),
1082
+ consequence: tool.schema.number().describe("Decision consequence from 0 to 1"),
1083
+ })),
1084
+ },
1085
+ execute: async (args, toolContext) => {
1086
+ const valid = args.opportunities.slice(0, 20).filter((candidate): candidate is typeof candidate & { type: "design" | "debugging" | "new-concept" } =>
1087
+ (candidate.type === "design" || candidate.type === "debugging" || candidate.type === "new-concept") &&
1088
+ candidate.concept.length > 0 && candidate.concept.length <= 100 &&
1089
+ candidate.relevance >= 0 && candidate.relevance <= 1 && candidate.consequence >= 0 && candidate.consequence <= 1,
1090
+ );
1091
+ const used = learningInterventions.get(toolContext.sessionID) ?? 0;
1092
+ const selected = selectLearningOpportunity(loadLearnerProfile(), valid, {
1093
+ interventionsThisSession: used,
1094
+ maxInterventionsPerSession: getLearningInterventionBudget(effectiveRoot),
1095
+ });
1096
+ if (!selected) return JSON.stringify({ intervene: false, reason: "No opportunity selected or session learning budget reached." });
1097
+ learningInterventions.set(toolContext.sessionID, used + 1);
1098
+ return JSON.stringify({ intervene: true, opportunity: selected, guidance: "Ask one question, use the answer as task context, briefly reconcile if useful, then continue building." });
1099
+ },
1100
+ }),
1101
+ learning_record: tool({
1102
+ 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.",
1103
+ args: {
1104
+ concept: tool.schema.string(),
1105
+ kind: tool.schema.string().describe("exposed, developing, demonstrated, independent, critique, or needs-reinforcement"),
1106
+ summary: tool.schema.string().describe("Short factual description of observed reasoning"),
1107
+ },
1108
+ execute: async (args, toolContext) => {
1109
+ const validKinds = new Set(["exposed", "developing", "demonstrated", "independent", "critique", "needs-reinforcement"]);
1110
+ if (!validKinds.has(args.kind)) return "[workflow-guard] Learning evidence rejected: invalid evidence kind.";
1111
+ if (args.concept.length < 1 || args.concept.length > 100 || args.summary.length < 1 || args.summary.length > 1000) {
1112
+ return "[workflow-guard] Learning evidence rejected: concept must be 1-100 characters and summary 1-1000 characters.";
1113
+ }
1114
+ try {
1115
+ updateLearnerProfile((profile) => {
1116
+ if (!profile.concepts[args.concept] && Object.keys(profile.concepts).length >= 500) {
1117
+ throw new Error("concept-limit");
1118
+ }
1119
+ recordLearningEvidence(profile, {
1120
+ concept: args.concept,
1121
+ kind: args.kind as LearningEvidenceKind,
1122
+ summary: args.summary,
1123
+ timestamp: Date.now(),
1124
+ sessionID: toolContext.sessionID,
1125
+ project: effectiveRoot,
1126
+ });
1127
+ });
1128
+ } catch (error) {
1129
+ if ((error as Error).message === "concept-limit") {
1130
+ return "[workflow-guard] Learning evidence rejected: learner profile concept limit reached.";
1131
+ }
1132
+ throw error;
1133
+ }
1134
+ return `[workflow-guard] Learning evidence recorded for ${args.concept}.`;
1135
+ },
1136
+ }),
1137
+ } : {}),
972
1138
  guard_status: tool({
973
1139
  description:
974
1140
  "Inspect active guardrails, current branch protection, and verification/review status.",
@@ -1289,6 +1455,13 @@ export const WorkflowGuard: Plugin = async (ctx) => {
1289
1455
  );
1290
1456
  }
1291
1457
  contextBlocks.push(stateLines.join("\n"));
1458
+ const projectKnowledge = (projectMemory ? getRecentProjectMemory(projectMemory, 20) : [])
1459
+ .filter((memory) => memory.source !== "portable" && isProjectMemoryFresh(memory, effectiveRoot))
1460
+ .slice(0, 8);
1461
+ if (projectKnowledge.length > 0) {
1462
+ const lines = projectKnowledge.map((memory) => `- [${memory.kind}:${memory.id.slice(0, 8)}] ${memory.content.slice(0, 300)}`);
1463
+ contextBlocks.push(`## Project Memory\n${lines.join("\n")}\nTreat these as historical project knowledge; verify against current repository state when relevant files have changed.`);
1464
+ }
1292
1465
 
1293
1466
  if (Array.isArray(output?.context)) {
1294
1467
  output.context.push(contextBlocks.join("\n\n"));
@@ -1366,15 +1539,15 @@ export const WorkflowGuard: Plugin = async (ctx) => {
1366
1539
  } catch {}
1367
1540
  },
1368
1541
 
1369
- // Keep tool descriptions honest: todowrite's description reflects the
1370
- // finalization gates so the model is not surprised by verification blocks.
1542
+ // Keep tool descriptions honest: todowrite's description reflects lifecycle
1543
+ // and finalization gates so the model is not surprised by preventable blocks.
1371
1544
  "tool.definition": async (input, output) => {
1372
1545
  if (input.toolID !== "todowrite") return;
1373
1546
  const description = typeof output.description === "string" ? output.description : "";
1374
1547
  if (description.includes("verification evidence")) return;
1375
1548
  output.description =
1376
1549
  description +
1377
- "\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.";
1550
+ "\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.";
1378
1551
  },
1379
1552
 
1380
1553
  event: async ({