opencode-workflow-guard 1.4.0 → 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 +26 -2
- package/package.json +3 -2
- package/src/lib/learning.ts +104 -0
- package/src/lib/project-memory.ts +157 -0
- package/src/lib/state.ts +16 -2
- package/src/lib/types.ts +62 -0
- package/src/lib/utils.ts +36 -3
- package/src/lib/verify.ts +38 -0
- package/src/policies/boundary.ts +71 -21
- 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/policies/shell-safety.ts +25 -6
- package/src/workflow-guard.ts +236 -58
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,
|
|
@@ -110,6 +161,7 @@ import {
|
|
|
110
161
|
snipVerifyOutput,
|
|
111
162
|
getCurrentGitCommitHash,
|
|
112
163
|
getGitStatusSummary,
|
|
164
|
+
getGitWorktreeFingerprint,
|
|
113
165
|
} from "./lib/verify.ts";
|
|
114
166
|
|
|
115
167
|
export {
|
|
@@ -118,6 +170,7 @@ export {
|
|
|
118
170
|
snipVerifyOutput,
|
|
119
171
|
getCurrentGitCommitHash,
|
|
120
172
|
getGitStatusSummary,
|
|
173
|
+
getGitWorktreeFingerprint,
|
|
121
174
|
};
|
|
122
175
|
|
|
123
176
|
// ── Secondary review rubric ──────────────────────────────────────────────────
|
|
@@ -168,6 +221,7 @@ import {
|
|
|
168
221
|
isBranchAlreadyMergedOrClosed,
|
|
169
222
|
checkMergeConflicts,
|
|
170
223
|
checkBranchBaseIsUpToDate,
|
|
224
|
+
hasUnsafeGitAlias,
|
|
171
225
|
} from "./policies/git.ts";
|
|
172
226
|
|
|
173
227
|
export {
|
|
@@ -213,6 +267,7 @@ import {
|
|
|
213
267
|
extractInterpreterPayload,
|
|
214
268
|
secretPathInPayload,
|
|
215
269
|
outsideWritePathInPayload,
|
|
270
|
+
writePathsInPayload,
|
|
216
271
|
} from "./policies/interpreter.ts";
|
|
217
272
|
export { extractInterpreterPayload, secretPathInPayload, outsideWritePathInPayload };
|
|
218
273
|
|
|
@@ -423,7 +478,7 @@ async function guardToolCallImpl(
|
|
|
423
478
|
logBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
|
|
424
479
|
return PROTECTED_PATH_REASON;
|
|
425
480
|
}
|
|
426
|
-
if (target && isSecretPath(target)
|
|
481
|
+
if (target && isSecretPath(target)) {
|
|
427
482
|
logBlock(`[workflow-guard] blocked ${toolName}: secret file path ${target}`);
|
|
428
483
|
return (
|
|
429
484
|
`Blocked: modifying secret file '${target}' directly is not permitted. ` +
|
|
@@ -440,7 +495,7 @@ async function guardToolCallImpl(
|
|
|
440
495
|
);
|
|
441
496
|
return PROTECTED_PATH_REASON;
|
|
442
497
|
}
|
|
443
|
-
if (isSecretPath(patchPath)
|
|
498
|
+
if (isSecretPath(patchPath)) {
|
|
444
499
|
logBlock(
|
|
445
500
|
`[workflow-guard] blocked apply_patch: secret file path ${patchPath}`,
|
|
446
501
|
);
|
|
@@ -500,14 +555,9 @@ async function guardToolCallImpl(
|
|
|
500
555
|
}
|
|
501
556
|
}
|
|
502
557
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
`[workflow-guard] blocked ${toolName}: payload tampers with settings`,
|
|
507
|
-
);
|
|
508
|
-
return PROTECTED_PATH_REASON;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
558
|
+
}
|
|
559
|
+
for (const content of extractEditContent(input)) {
|
|
560
|
+
if (isSettingsTamper(content)) return PROTECTED_PATH_REASON;
|
|
511
561
|
}
|
|
512
562
|
|
|
513
563
|
if (onProtectedBranch(currentRoot)) {
|
|
@@ -594,6 +644,9 @@ async function guardToolCallImpl(
|
|
|
594
644
|
}
|
|
595
645
|
|
|
596
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
|
+
}
|
|
597
650
|
const gitInvocations = splitShellSegments(command)
|
|
598
651
|
.map((segment) => parseGitInvocation(segment.trim()))
|
|
599
652
|
.filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
|
|
@@ -644,8 +697,7 @@ async function guardToolCallImpl(
|
|
|
644
697
|
}
|
|
645
698
|
|
|
646
699
|
// ── Policy 17: secret file reads via shell ──
|
|
647
|
-
|
|
648
|
-
for (const segment of command.split(/[\n|;&]+/)) {
|
|
700
|
+
for (const segment of command.split(/[\n|;&]+/)) {
|
|
649
701
|
const secretFile = secretFileReadIn(segment.trim());
|
|
650
702
|
if (secretFile) {
|
|
651
703
|
logBlock(
|
|
@@ -656,7 +708,6 @@ async function guardToolCallImpl(
|
|
|
656
708
|
"Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
|
|
657
709
|
);
|
|
658
710
|
}
|
|
659
|
-
}
|
|
660
711
|
}
|
|
661
712
|
|
|
662
713
|
// ── Policy 18: interpreter inline evasion scanner ──
|
|
@@ -670,6 +721,21 @@ async function guardToolCallImpl(
|
|
|
670
721
|
);
|
|
671
722
|
return `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
|
|
672
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
|
+
}
|
|
673
739
|
|
|
674
740
|
if (!allowLive) {
|
|
675
741
|
const normPayload = normalizeGitCommands(normalize(payload));
|
|
@@ -680,23 +746,10 @@ async function guardToolCallImpl(
|
|
|
680
746
|
);
|
|
681
747
|
return `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard.`;
|
|
682
748
|
}
|
|
683
|
-
if (isSettingsTamper(payload)) {
|
|
684
|
-
logBlock(
|
|
685
|
-
`[workflow-guard] blocked interpreter payload with settings tamper`,
|
|
686
|
-
);
|
|
687
|
-
return PROTECTED_PATH_REASON;
|
|
688
|
-
}
|
|
689
|
-
const secretPath = secretPathInPayload(payload);
|
|
690
|
-
if (secretPath) {
|
|
691
|
-
logBlock(
|
|
692
|
-
`[workflow-guard] blocked interpreter payload accessing secret file: ${secretPath}`,
|
|
693
|
-
);
|
|
694
|
-
return (
|
|
695
|
-
`Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted. ` +
|
|
696
|
-
"Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
|
|
697
|
-
);
|
|
698
|
-
}
|
|
699
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.`;
|
|
700
753
|
}
|
|
701
754
|
|
|
702
755
|
// ── Policies 1, 7 & 8: shell file mutations get the same gates as edits ──
|
|
@@ -782,12 +835,12 @@ async function guardToolCallImpl(
|
|
|
782
835
|
const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
|
|
783
836
|
const prTool = isAz ? "az repos pr create" : "gh pr create";
|
|
784
837
|
const descFlag = isAz ? "--description" : "--body";
|
|
838
|
+
const preflightFailures: string[] = [];
|
|
785
839
|
|
|
786
840
|
// Policy 19: conflict-free mergeability gate before opening PR
|
|
787
841
|
const conflictCheck = checkMergeConflicts(currentRoot);
|
|
788
842
|
if (conflictCheck.hasConflicts) {
|
|
789
|
-
|
|
790
|
-
return `Blocked: ${conflictCheck.reason}`;
|
|
843
|
+
preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
|
|
791
844
|
}
|
|
792
845
|
|
|
793
846
|
// Policy 20: branch already merged/closed
|
|
@@ -795,10 +848,7 @@ async function guardToolCallImpl(
|
|
|
795
848
|
if (branch) {
|
|
796
849
|
const mergedStatus = isBranchAlreadyMergedOrClosed(currentRoot, branch);
|
|
797
850
|
if (mergedStatus.merged) {
|
|
798
|
-
|
|
799
|
-
`[workflow-guard] blocked ${prTool}: branch already merged/closed`,
|
|
800
|
-
);
|
|
801
|
-
return `Blocked: ${mergedStatus.reason}`;
|
|
851
|
+
preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
|
|
802
852
|
}
|
|
803
853
|
}
|
|
804
854
|
|
|
@@ -808,14 +858,13 @@ async function guardToolCallImpl(
|
|
|
808
858
|
: getLastReviewResult();
|
|
809
859
|
const reviewMatchesContext =
|
|
810
860
|
review?.passed === true &&
|
|
811
|
-
|
|
812
|
-
(!review.targetSessionID || review.targetSessionID === context?.sessionID)
|
|
861
|
+
review.workspace === resolve(currentRoot) &&
|
|
862
|
+
(!review.targetSessionID || review.targetSessionID === context?.sessionID) &&
|
|
863
|
+
review.commitHash === getCurrentGitCommitHash(currentRoot) &&
|
|
864
|
+
review.gitStatus === getGitStatusSummary(currentRoot) &&
|
|
865
|
+
review.worktreeFingerprint === getGitWorktreeFingerprint(currentRoot);
|
|
813
866
|
if (!reviewMatchesContext) {
|
|
814
|
-
|
|
815
|
-
return (
|
|
816
|
-
`Blocked: PR creation requires a passing review approval. ` +
|
|
817
|
-
"Invoke a secondary review subagent to record an approval using the record_review tool first."
|
|
818
|
-
);
|
|
867
|
+
preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
|
|
819
868
|
}
|
|
820
869
|
}
|
|
821
870
|
|
|
@@ -823,11 +872,7 @@ async function guardToolCallImpl(
|
|
|
823
872
|
if (isDocumentationRequired(currentRoot)) {
|
|
824
873
|
const hasDocChange = branchHasDocumentationChange(currentRoot);
|
|
825
874
|
if (!hasDocChange) {
|
|
826
|
-
|
|
827
|
-
return (
|
|
828
|
-
`Blocked: PR requires documentation updates (Policy 21). ` +
|
|
829
|
-
"Update README.md or relevant documentation in docs/ before opening a PR."
|
|
830
|
-
);
|
|
875
|
+
preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
|
|
831
876
|
}
|
|
832
877
|
}
|
|
833
878
|
|
|
@@ -838,19 +883,19 @@ async function guardToolCallImpl(
|
|
|
838
883
|
branchChangelog ||
|
|
839
884
|
prSegments.every((segment) => prBodyIncludesChangelog(segment));
|
|
840
885
|
if (!hasChangelog) {
|
|
841
|
-
|
|
842
|
-
return (
|
|
843
|
-
`Blocked: PR must include a changelog. Either update a ` +
|
|
844
|
-
`CHANGELOG file in this branch's diff, or include a ` +
|
|
845
|
-
`'Changelog:' section in the PR description (${descFlag}).`
|
|
846
|
-
);
|
|
886
|
+
preflightFailures.push(`Changelog is required; update a CHANGELOG file or include a 'Changelog:' section in the PR description (${descFlag}).`);
|
|
847
887
|
}
|
|
848
888
|
|
|
849
889
|
// Lockfile synchronization pre-flight gate
|
|
850
890
|
const lockCheck = checkLockfileSync(currentRoot);
|
|
851
891
|
if (lockCheck.isOutOfSync) {
|
|
852
|
-
|
|
853
|
-
|
|
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;
|
|
854
899
|
}
|
|
855
900
|
}
|
|
856
901
|
|
|
@@ -951,6 +996,18 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
951
996
|
setWorkspaceRoot(effectiveRoot);
|
|
952
997
|
setSdkClient(ctx.client);
|
|
953
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
|
+
}
|
|
954
1011
|
|
|
955
1012
|
try {
|
|
956
1013
|
await (ctx.client as any)?.app?.log?.({
|
|
@@ -964,6 +1021,120 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
964
1021
|
|
|
965
1022
|
return {
|
|
966
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
|
+
} : {}),
|
|
967
1138
|
guard_status: tool({
|
|
968
1139
|
description:
|
|
969
1140
|
"Inspect active guardrails, current branch protection, and verification/review status.",
|
|
@@ -1284,6 +1455,13 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1284
1455
|
);
|
|
1285
1456
|
}
|
|
1286
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
|
+
}
|
|
1287
1465
|
|
|
1288
1466
|
if (Array.isArray(output?.context)) {
|
|
1289
1467
|
output.context.push(contextBlocks.join("\n\n"));
|
|
@@ -1361,15 +1539,15 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1361
1539
|
} catch {}
|
|
1362
1540
|
},
|
|
1363
1541
|
|
|
1364
|
-
// Keep tool descriptions honest: todowrite's description reflects
|
|
1365
|
-
// 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.
|
|
1366
1544
|
"tool.definition": async (input, output) => {
|
|
1367
1545
|
if (input.toolID !== "todowrite") return;
|
|
1368
1546
|
const description = typeof output.description === "string" ? output.description : "";
|
|
1369
1547
|
if (description.includes("verification evidence")) return;
|
|
1370
1548
|
output.description =
|
|
1371
1549
|
description +
|
|
1372
|
-
"\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.";
|
|
1373
1551
|
},
|
|
1374
1552
|
|
|
1375
1553
|
event: async ({
|