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 +23 -1
- package/package.json +1 -1
- package/src/lib/learning.ts +104 -0
- package/src/lib/project-memory.ts +157 -0
- package/src/lib/state.ts +10 -0
- package/src/lib/types.ts +59 -0
- package/src/lib/utils.ts +36 -3
- package/src/policies/boundary.ts +33 -4
- package/src/policies/destructive.ts +41 -2
- package/src/policies/git.ts +16 -0
- package/src/policies/interpreter.ts +19 -4
- package/src/policies/secrets.ts +12 -0
- package/src/workflow-guard.ts +229 -56
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 #
|
|
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
|
@@ -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
|
-
|
|
48
|
-
|
|
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[] {
|
package/src/policies/boundary.ts
CHANGED
|
@@ -158,10 +158,11 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
|
158
158
|
what: `tee to '${teeTargets[0]!}'`,
|
|
159
159
|
};
|
|
160
160
|
}
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
const
|
|
164
|
-
const target =
|
|
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
|
}
|
package/src/policies/git.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(?:
|
|
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
|
|
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
|
+
}
|
package/src/policies/secrets.ts
CHANGED
|
@@ -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
|
}
|
package/src/workflow-guard.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
858
|
-
|
|
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
|
|
1370
|
-
// finalization gates so the model is not surprised by
|
|
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\
|
|
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 ({
|