opencode-workflow-guard 1.4.1 → 1.5.1
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 +24 -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 +63 -0
- package/src/lib/utils.ts +168 -11
- package/src/policies/boundary.ts +33 -4
- package/src/policies/changelog.ts +114 -15
- package/src/policies/continuation.ts +83 -0
- package/src/policies/destructive.ts +41 -2
- package/src/policies/git.ts +16 -0
- package/src/policies/interpreter.ts +19 -4
- package/src/policies/planning.ts +50 -0
- package/src/policies/secrets.ts +12 -0
- package/src/policies/todo.ts +8 -2
- package/src/workflow-guard.ts +296 -62
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
|
---
|
|
@@ -67,6 +68,7 @@ For complete policy specifications and override rules, see [docs/policies.md](do
|
|
|
67
68
|
|
|
68
69
|
| Tool | Purpose |
|
|
69
70
|
|---|---|
|
|
71
|
+
| `guard_next_tasks` | Load `TODO.md`, or fall back to repository roadmap/plan/task Markdown when deciding what to work on next |
|
|
70
72
|
| `guard_status` | Inspect active guardrails, current branch protection, and verification/review status |
|
|
71
73
|
| `guard_why` | Simulate and explain whether a specific tool call or command would be blocked |
|
|
72
74
|
| `guard_audit` | View recent audit log entries recorded in `workflow-guard.jsonl` |
|
|
@@ -74,6 +76,27 @@ For complete policy specifications and override rules, see [docs/policies.md](do
|
|
|
74
76
|
| `record_review` | Record a secondary reviewer subagent's verdict evaluated against the review rubric |
|
|
75
77
|
| `guard_worktree_create` | Create an isolated git worktree directory for concurrent subagent execution |
|
|
76
78
|
| `guard_worktree_cleanup` | Snapshot-commit remaining changes and remove an isolated worktree directory |
|
|
79
|
+
| `learning_profile` | Inspect the evidence-based local learner profile (when learning mode is enabled) |
|
|
80
|
+
| `learning_checkpoint` | Rank high-value Socratic opportunities while respecting the session intervention budget |
|
|
81
|
+
| `learning_record` | Persist concise evidence observed during a real learning interaction |
|
|
82
|
+
| `project_memory_search` | Search current durable project knowledge in the local index |
|
|
83
|
+
| `project_memory_record` | Record a durable fact, decision, constraint, or lesson with provenance |
|
|
84
|
+
| `project_memory_export` | Explicitly promote selected records to repo-local human-readable JSONL |
|
|
85
|
+
| `project_memory_import` | Import promoted repo-local knowledge into the local index |
|
|
86
|
+
|
|
87
|
+
### Experimental Socratic Learning
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
### Project Memory
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
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.
|
|
98
|
+
|
|
99
|
+
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
100
|
|
|
78
101
|
---
|
|
79
102
|
|
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
|
@@ -7,6 +7,10 @@ export interface TodoSdkClient {
|
|
|
7
7
|
session?: {
|
|
8
8
|
todo?: (opts: { path: { id: string } }) => Promise<{ data?: unknown }>;
|
|
9
9
|
get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown } }>;
|
|
10
|
+
promptAsync?: (opts: {
|
|
11
|
+
path: { id: string };
|
|
12
|
+
body: { messageID?: string; parts: Array<{ type: "text"; text: string }> };
|
|
13
|
+
}) => Promise<unknown>;
|
|
10
14
|
};
|
|
11
15
|
tui?: {
|
|
12
16
|
showToast?: (opts: { body: { title?: string; message: string; variant?: string } }) => Promise<unknown>;
|
|
@@ -22,6 +26,7 @@ export interface ProjectConfig {
|
|
|
22
26
|
requireReview?: boolean;
|
|
23
27
|
requireDocumentation?: boolean;
|
|
24
28
|
maxSubagentMutations?: number;
|
|
29
|
+
maxLearningInterventions?: number;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export interface VerifyResult {
|
|
@@ -77,3 +82,61 @@ export interface ShellMutation {
|
|
|
77
82
|
target?: string;
|
|
78
83
|
what: string;
|
|
79
84
|
}
|
|
85
|
+
|
|
86
|
+
export type LearningStage = "exposed" | "developing" | "demonstrated" | "independent" | "critique";
|
|
87
|
+
export type LearningEvidenceKind = LearningStage | "needs-reinforcement";
|
|
88
|
+
export type LearningOpportunityType = "design" | "debugging" | "new-concept";
|
|
89
|
+
|
|
90
|
+
export interface LearningEvidence {
|
|
91
|
+
concept: string;
|
|
92
|
+
kind: LearningEvidenceKind;
|
|
93
|
+
summary: string;
|
|
94
|
+
timestamp: number;
|
|
95
|
+
sessionID?: string;
|
|
96
|
+
project?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface LearnerConcept {
|
|
100
|
+
stage: LearningStage;
|
|
101
|
+
lastObservedAt: number;
|
|
102
|
+
evidence: LearningEvidence[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface LearnerProfile {
|
|
106
|
+
version: 1;
|
|
107
|
+
concepts: Record<string, LearnerConcept>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface LearningOpportunity {
|
|
111
|
+
type: LearningOpportunityType;
|
|
112
|
+
concept: string;
|
|
113
|
+
relevance: number;
|
|
114
|
+
consequence: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type ProjectMemoryKind = "fact" | "decision" | "constraint" | "lesson";
|
|
118
|
+
export type ProjectMemorySource = "user" | "file" | "git" | "tool" | "agent" | "portable";
|
|
119
|
+
|
|
120
|
+
export interface ProjectMemoryRecord {
|
|
121
|
+
id: string;
|
|
122
|
+
projectId: string;
|
|
123
|
+
kind: ProjectMemoryKind;
|
|
124
|
+
content: string;
|
|
125
|
+
source: ProjectMemorySource;
|
|
126
|
+
createdAt: number;
|
|
127
|
+
sessionID?: string;
|
|
128
|
+
commit?: string;
|
|
129
|
+
paths: string[];
|
|
130
|
+
supersedes?: string;
|
|
131
|
+
status: "current" | "superseded";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ProjectMemoryInput {
|
|
135
|
+
kind: ProjectMemoryKind;
|
|
136
|
+
content: string;
|
|
137
|
+
source: ProjectMemorySource;
|
|
138
|
+
sessionID?: string;
|
|
139
|
+
commit?: string;
|
|
140
|
+
paths?: string[];
|
|
141
|
+
supersedes?: string;
|
|
142
|
+
}
|
package/src/lib/utils.ts
CHANGED
|
@@ -38,33 +38,137 @@ export function decodeShellEscapes(text: string): string {
|
|
|
38
38
|
// Unicode escapes: \uHHHH
|
|
39
39
|
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
40
40
|
// Octal escapes: \0OO or \OOO (e.g. \162 -> 'r')
|
|
41
|
-
.replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)))
|
|
42
|
-
// Single character escapes: \c -> c (e.g. \r\m -> rm)
|
|
43
|
-
.replace(/\\(.)/g, "$1");
|
|
41
|
+
.replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
export function shellWords(command: string): string[] {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
const words: string[] = [];
|
|
46
|
+
let word = "";
|
|
47
|
+
let quote: "'" | '"' | undefined;
|
|
48
|
+
let escaped = false;
|
|
49
|
+
let started = false;
|
|
50
|
+
for (const char of command) {
|
|
51
|
+
if (escaped) {
|
|
52
|
+
word += char;
|
|
53
|
+
escaped = false;
|
|
54
|
+
started = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (char === "\\" && quote !== "'") {
|
|
58
|
+
escaped = true;
|
|
59
|
+
started = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (char === "'" || char === '"') {
|
|
63
|
+
if (!quote) quote = char;
|
|
64
|
+
else if (quote === char) quote = undefined;
|
|
65
|
+
else word += char;
|
|
66
|
+
started = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!quote && /\s/.test(char)) {
|
|
70
|
+
if (started) words.push(word);
|
|
71
|
+
word = "";
|
|
72
|
+
started = false;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
word += char;
|
|
76
|
+
started = true;
|
|
77
|
+
}
|
|
78
|
+
if (escaped) word += "\\";
|
|
79
|
+
if (started) words.push(word);
|
|
80
|
+
return words;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function envSplitWords(value: string): string[] {
|
|
84
|
+
const words: string[] = [];
|
|
85
|
+
let word = "";
|
|
86
|
+
let quote: "'" | '"' | undefined;
|
|
87
|
+
let started = false;
|
|
88
|
+
const push = () => {
|
|
89
|
+
if (started) words.push(word);
|
|
90
|
+
word = "";
|
|
91
|
+
started = false;
|
|
92
|
+
};
|
|
93
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
94
|
+
const char = value[i]!;
|
|
95
|
+
if (char === "'" || char === '"') {
|
|
96
|
+
if (!quote) quote = char;
|
|
97
|
+
else if (quote === char) quote = undefined;
|
|
98
|
+
else word += char;
|
|
99
|
+
started = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (char === "\\" && i + 1 < value.length) {
|
|
103
|
+
const next = value[++i]!;
|
|
104
|
+
if (quote === "'" && next !== "'" && next !== "\\") {
|
|
105
|
+
word += `\\${next}`;
|
|
106
|
+
started = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (next === "c" && !quote) break;
|
|
110
|
+
if (next === "_" && quote !== "'") {
|
|
111
|
+
if (quote === '"') {
|
|
112
|
+
word += " ";
|
|
113
|
+
started = true;
|
|
114
|
+
} else push();
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const escapes: Record<string, string> = { f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "#": "#", "$": "$", '"': '"', "'": "'", "\\": "\\" };
|
|
118
|
+
word += escapes[next] ?? next;
|
|
119
|
+
started = true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!quote && /[\s\f\v]/.test(char)) {
|
|
123
|
+
push();
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!quote && char === "#" && !started) break;
|
|
127
|
+
if (char === "$" && quote !== "'" && value[i + 1] === "{") {
|
|
128
|
+
const end = value.indexOf("}", i + 2);
|
|
129
|
+
if (end >= 0) {
|
|
130
|
+
const name = value.slice(i + 2, end);
|
|
131
|
+
word += process.env[name] ?? "";
|
|
132
|
+
started = true;
|
|
133
|
+
i = end;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
word += char;
|
|
138
|
+
started = true;
|
|
139
|
+
}
|
|
140
|
+
push();
|
|
141
|
+
return words;
|
|
50
142
|
}
|
|
51
143
|
|
|
52
144
|
export function splitShellSegments(command: string): string[] {
|
|
53
145
|
const segments: string[] = [];
|
|
54
146
|
let current = "";
|
|
55
|
-
let quote: "'" | '"' | undefined;
|
|
147
|
+
let quote: "'" | '"' | "ansi" | undefined;
|
|
56
148
|
let escaped = false;
|
|
57
|
-
for (
|
|
149
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
150
|
+
const char = command[i]!;
|
|
58
151
|
if (escaped) {
|
|
59
152
|
current += char;
|
|
60
153
|
escaped = false;
|
|
61
154
|
continue;
|
|
62
155
|
}
|
|
156
|
+
if (!quote && char === "$" && command[i + 1] === "'") {
|
|
157
|
+
current += "$'";
|
|
158
|
+
quote = "ansi";
|
|
159
|
+
i += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
63
162
|
if (char === "\\" && quote !== "'") {
|
|
64
163
|
current += char;
|
|
65
164
|
escaped = true;
|
|
66
165
|
continue;
|
|
67
166
|
}
|
|
167
|
+
if (quote === "ansi" && char === "'") {
|
|
168
|
+
quote = undefined;
|
|
169
|
+
current += char;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
68
172
|
if (char === "'" || char === '"') {
|
|
69
173
|
if (!quote) quote = char;
|
|
70
174
|
else if (quote === char) quote = undefined;
|
|
@@ -82,7 +186,7 @@ export function splitShellSegments(command: string): string[] {
|
|
|
82
186
|
return segments;
|
|
83
187
|
}
|
|
84
188
|
|
|
85
|
-
|
|
189
|
+
function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: boolean } {
|
|
86
190
|
let cleanCmd = decodeShellEscapes(command.trim());
|
|
87
191
|
// Strip outer grouping parentheses/braces if present e.g. ( git commit ) or { git commit; }
|
|
88
192
|
while (
|
|
@@ -95,6 +199,7 @@ export function unwrapShellWords(command: string): string[] {
|
|
|
95
199
|
|
|
96
200
|
const words = shellWords(cleanCmd);
|
|
97
201
|
let i = 0;
|
|
202
|
+
let changesCwd = false;
|
|
98
203
|
while (i < words.length) {
|
|
99
204
|
const rawWord = words[i]!;
|
|
100
205
|
const word = rawWord.replace(/^[\({]+/, "").replace(/[\)};]+$/, "");
|
|
@@ -165,6 +270,7 @@ export function unwrapShellWords(command: string): string[] {
|
|
|
165
270
|
]);
|
|
166
271
|
while (i < words.length && words[i]!.startsWith("-")) {
|
|
167
272
|
const option = words[i++]!;
|
|
273
|
+
if (option === "-D" || (option.startsWith("-D") && option !== "-D") || option === "--chdir" || option.startsWith("--chdir=")) changesCwd = true;
|
|
168
274
|
if (valueOptions.has(option) && i < words.length) i++;
|
|
169
275
|
}
|
|
170
276
|
continue;
|
|
@@ -174,6 +280,7 @@ export function unwrapShellWords(command: string): string[] {
|
|
|
174
280
|
const valueOptions = new Set([
|
|
175
281
|
"-u",
|
|
176
282
|
"--unset",
|
|
283
|
+
"--argv0",
|
|
177
284
|
"-C",
|
|
178
285
|
"--chdir",
|
|
179
286
|
"-S",
|
|
@@ -181,12 +288,54 @@ export function unwrapShellWords(command: string): string[] {
|
|
|
181
288
|
]);
|
|
182
289
|
while (i < words.length) {
|
|
183
290
|
const w = words[i]!;
|
|
291
|
+
if (w === "--") {
|
|
292
|
+
i++;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
184
295
|
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
|
|
185
296
|
i++;
|
|
186
297
|
continue;
|
|
187
298
|
}
|
|
299
|
+
let splitValue: string | undefined;
|
|
300
|
+
let consumed = 0;
|
|
301
|
+
const clustered = /^-[iv0]*([SC])(.*)$/.exec(w);
|
|
302
|
+
if (clustered?.[1] === "C") {
|
|
303
|
+
changesCwd = true;
|
|
304
|
+
i++;
|
|
305
|
+
if (!clustered[2] && i < words.length) i++;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (clustered?.[1] === "S") {
|
|
309
|
+
if (clustered[2]) splitValue = clustered[2];
|
|
310
|
+
else if (i + 1 < words.length) {
|
|
311
|
+
splitValue = words[i + 1]!;
|
|
312
|
+
consumed = 1;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else if (w.startsWith("-S") && w !== "-S") splitValue = w.slice(2);
|
|
316
|
+
else if (w.startsWith("--split-string=")) splitValue = w.slice("--split-string=".length);
|
|
317
|
+
else if ((w === "-S" || w === "--split-string") && i + 1 < words.length) {
|
|
318
|
+
splitValue = words[i + 1]!;
|
|
319
|
+
consumed = 1;
|
|
320
|
+
}
|
|
321
|
+
if (splitValue !== undefined) {
|
|
322
|
+
words.splice(i, consumed + 1, ...envSplitWords(splitValue));
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const clusteredValue = /^-[iv0]*([ua])(.*)$/.exec(w);
|
|
326
|
+
if (clusteredValue) {
|
|
327
|
+
i++;
|
|
328
|
+
if (!clusteredValue[2] && i < words.length) i++;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (w.startsWith("-C") && w !== "-C") {
|
|
332
|
+
changesCwd = true;
|
|
333
|
+
i++;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
188
336
|
if (!w.startsWith("-")) break;
|
|
189
337
|
i++;
|
|
338
|
+
if (w === "-C" || w === "--chdir" || w.startsWith("--chdir=")) changesCwd = true;
|
|
190
339
|
if (valueOptions.has(w) && i < words.length) i++;
|
|
191
340
|
}
|
|
192
341
|
continue;
|
|
@@ -197,10 +346,18 @@ export function unwrapShellWords(command: string): string[] {
|
|
|
197
346
|
}
|
|
198
347
|
break;
|
|
199
348
|
}
|
|
200
|
-
return words
|
|
349
|
+
return { words: words
|
|
201
350
|
.slice(i)
|
|
202
351
|
.map((w) => w.replace(/^[\({]+/, "").replace(/[\)};]+$/, ""))
|
|
203
|
-
.filter(Boolean);
|
|
352
|
+
.filter(Boolean), changesCwd };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function unwrapShellWords(command: string): string[] {
|
|
356
|
+
return unwrapShellWordsImpl(command).words;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function shellWrappersChangeCwd(command: string): boolean {
|
|
360
|
+
return unwrapShellWordsImpl(command).changesCwd;
|
|
204
361
|
}
|
|
205
362
|
|
|
206
363
|
export function unwrapShellCommand(command: string): string {
|