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/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,
|
|
@@ -80,7 +89,9 @@ import {
|
|
|
80
89
|
asRecord,
|
|
81
90
|
extractCommands,
|
|
82
91
|
normalize,
|
|
92
|
+
shellWrappersChangeCwd,
|
|
83
93
|
splitShellSegments,
|
|
94
|
+
unwrapShellCommand,
|
|
84
95
|
getCleanEnv,
|
|
85
96
|
showBlockToast,
|
|
86
97
|
SENSITIVE_ENV_KEYS,
|
|
@@ -89,6 +100,48 @@ import {
|
|
|
89
100
|
|
|
90
101
|
export { getCleanEnv };
|
|
91
102
|
|
|
103
|
+
// ── Adaptive learning engine ─────────────────────────────────────────────────
|
|
104
|
+
import {
|
|
105
|
+
loadLearnerProfile,
|
|
106
|
+
recordLearningEvidence,
|
|
107
|
+
selectLearningOpportunity,
|
|
108
|
+
updateLearnerProfile,
|
|
109
|
+
} from "./lib/learning.ts";
|
|
110
|
+
export {
|
|
111
|
+
createLearnerProfile,
|
|
112
|
+
getLearnerProfilePath,
|
|
113
|
+
loadLearnerProfile,
|
|
114
|
+
recordLearningEvidence,
|
|
115
|
+
saveLearnerProfile,
|
|
116
|
+
selectLearningOpportunity,
|
|
117
|
+
updateLearnerProfile,
|
|
118
|
+
} from "./lib/learning.ts";
|
|
119
|
+
|
|
120
|
+
// ── Project memory ───────────────────────────────────────────────────────────
|
|
121
|
+
export {
|
|
122
|
+
getProjectMemoryDir,
|
|
123
|
+
getProjectMemoryIdentity,
|
|
124
|
+
ensureProjectMemoryExcluded,
|
|
125
|
+
isProjectMemoryFresh,
|
|
126
|
+
openProjectMemory,
|
|
127
|
+
recordProjectMemory,
|
|
128
|
+
searchProjectMemory,
|
|
129
|
+
getRecentProjectMemory,
|
|
130
|
+
exportProjectKnowledge,
|
|
131
|
+
importProjectKnowledge,
|
|
132
|
+
} from "./lib/project-memory.ts";
|
|
133
|
+
import {
|
|
134
|
+
exportProjectKnowledge,
|
|
135
|
+
ensureProjectMemoryExcluded,
|
|
136
|
+
getProjectMemoryIdentity,
|
|
137
|
+
getRecentProjectMemory,
|
|
138
|
+
importProjectKnowledge,
|
|
139
|
+
isProjectMemoryFresh,
|
|
140
|
+
openProjectMemory,
|
|
141
|
+
recordProjectMemory,
|
|
142
|
+
searchProjectMemory,
|
|
143
|
+
} from "./lib/project-memory.ts";
|
|
144
|
+
|
|
92
145
|
// ── Audit & durable verification cache ───────────────────────────────────────
|
|
93
146
|
import {
|
|
94
147
|
getAuditFilePath,
|
|
@@ -153,6 +206,13 @@ import {
|
|
|
153
206
|
effectiveTodoOwnerSessionID,
|
|
154
207
|
hasActiveTodo,
|
|
155
208
|
} from "./policies/todo.ts";
|
|
209
|
+
import {
|
|
210
|
+
clearContinuationState,
|
|
211
|
+
continueUnfinishedSession,
|
|
212
|
+
recordUserMessage,
|
|
213
|
+
} from "./policies/continuation.ts";
|
|
214
|
+
import { discoverPlanningSources } from "./policies/planning.ts";
|
|
215
|
+
export { discoverPlanningSources };
|
|
156
216
|
|
|
157
217
|
export { validateTodoLifecycle };
|
|
158
218
|
|
|
@@ -170,6 +230,7 @@ import {
|
|
|
170
230
|
isBranchAlreadyMergedOrClosed,
|
|
171
231
|
checkMergeConflicts,
|
|
172
232
|
checkBranchBaseIsUpToDate,
|
|
233
|
+
hasUnsafeGitAlias,
|
|
173
234
|
} from "./policies/git.ts";
|
|
174
235
|
|
|
175
236
|
export {
|
|
@@ -215,6 +276,7 @@ import {
|
|
|
215
276
|
extractInterpreterPayload,
|
|
216
277
|
secretPathInPayload,
|
|
217
278
|
outsideWritePathInPayload,
|
|
279
|
+
writePathsInPayload,
|
|
218
280
|
} from "./policies/interpreter.ts";
|
|
219
281
|
export { extractInterpreterPayload, secretPathInPayload, outsideWritePathInPayload };
|
|
220
282
|
|
|
@@ -248,6 +310,14 @@ function logBlock(message: string): void {
|
|
|
248
310
|
} catch {}
|
|
249
311
|
}
|
|
250
312
|
|
|
313
|
+
function logObservation(client: unknown, message: string): void {
|
|
314
|
+
try {
|
|
315
|
+
void (client as any)?.app?.log?.({
|
|
316
|
+
body: { service: "workflow-guard", level: "info", message },
|
|
317
|
+
});
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
320
|
+
|
|
251
321
|
const READ_ONLY_ROLES = new Set([
|
|
252
322
|
"reviewer",
|
|
253
323
|
"planner",
|
|
@@ -425,7 +495,7 @@ async function guardToolCallImpl(
|
|
|
425
495
|
logBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
|
|
426
496
|
return PROTECTED_PATH_REASON;
|
|
427
497
|
}
|
|
428
|
-
if (target && isSecretPath(target)
|
|
498
|
+
if (target && isSecretPath(target)) {
|
|
429
499
|
logBlock(`[workflow-guard] blocked ${toolName}: secret file path ${target}`);
|
|
430
500
|
return (
|
|
431
501
|
`Blocked: modifying secret file '${target}' directly is not permitted. ` +
|
|
@@ -442,7 +512,7 @@ async function guardToolCallImpl(
|
|
|
442
512
|
);
|
|
443
513
|
return PROTECTED_PATH_REASON;
|
|
444
514
|
}
|
|
445
|
-
if (isSecretPath(patchPath)
|
|
515
|
+
if (isSecretPath(patchPath)) {
|
|
446
516
|
logBlock(
|
|
447
517
|
`[workflow-guard] blocked apply_patch: secret file path ${patchPath}`,
|
|
448
518
|
);
|
|
@@ -502,14 +572,9 @@ async function guardToolCallImpl(
|
|
|
502
572
|
}
|
|
503
573
|
}
|
|
504
574
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
`[workflow-guard] blocked ${toolName}: payload tampers with settings`,
|
|
509
|
-
);
|
|
510
|
-
return PROTECTED_PATH_REASON;
|
|
511
|
-
}
|
|
512
|
-
}
|
|
575
|
+
}
|
|
576
|
+
for (const content of extractEditContent(input)) {
|
|
577
|
+
if (isSettingsTamper(content)) return PROTECTED_PATH_REASON;
|
|
513
578
|
}
|
|
514
579
|
|
|
515
580
|
if (onProtectedBranch(currentRoot)) {
|
|
@@ -596,6 +661,9 @@ async function guardToolCallImpl(
|
|
|
596
661
|
}
|
|
597
662
|
|
|
598
663
|
const normalizedCommand = normalizeGitCommands(command);
|
|
664
|
+
if (hasUnsafeGitAlias(command)) {
|
|
665
|
+
return "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.";
|
|
666
|
+
}
|
|
599
667
|
const gitInvocations = splitShellSegments(command)
|
|
600
668
|
.map((segment) => parseGitInvocation(segment.trim()))
|
|
601
669
|
.filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
|
|
@@ -646,8 +714,7 @@ async function guardToolCallImpl(
|
|
|
646
714
|
}
|
|
647
715
|
|
|
648
716
|
// ── Policy 17: secret file reads via shell ──
|
|
649
|
-
|
|
650
|
-
for (const segment of command.split(/[\n|;&]+/)) {
|
|
717
|
+
for (const segment of command.split(/[\n|;&]+/)) {
|
|
651
718
|
const secretFile = secretFileReadIn(segment.trim());
|
|
652
719
|
if (secretFile) {
|
|
653
720
|
logBlock(
|
|
@@ -658,7 +725,6 @@ async function guardToolCallImpl(
|
|
|
658
725
|
"Reference environment variables by name or inspect safe templates (e.g. .env.example) instead."
|
|
659
726
|
);
|
|
660
727
|
}
|
|
661
|
-
}
|
|
662
728
|
}
|
|
663
729
|
|
|
664
730
|
// ── Policy 18: interpreter inline evasion scanner ──
|
|
@@ -672,6 +738,21 @@ async function guardToolCallImpl(
|
|
|
672
738
|
);
|
|
673
739
|
return `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
|
|
674
740
|
}
|
|
741
|
+
const writePaths = writePathsInPayload(payload);
|
|
742
|
+
if (writePaths.length > 0) {
|
|
743
|
+
if (context?.agent && isReadOnlyRole(context.agent)) {
|
|
744
|
+
return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations.`;
|
|
745
|
+
}
|
|
746
|
+
for (const path of writePaths) {
|
|
747
|
+
if (isProtectedPath(path)) return PROTECTED_PATH_REASON;
|
|
748
|
+
}
|
|
749
|
+
if (onProtectedBranch(currentRoot)) return branchGuardReason();
|
|
750
|
+
const todos = await effectiveTodos(context?.sessionID);
|
|
751
|
+
if (todos !== undefined && !hasActiveTodo(todos)) {
|
|
752
|
+
return "Blocked: inline interpreter file mutation with no active todo item.";
|
|
753
|
+
}
|
|
754
|
+
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
755
|
+
}
|
|
675
756
|
|
|
676
757
|
if (!allowLive) {
|
|
677
758
|
const normPayload = normalizeGitCommands(normalize(payload));
|
|
@@ -682,23 +763,10 @@ async function guardToolCallImpl(
|
|
|
682
763
|
);
|
|
683
764
|
return `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard.`;
|
|
684
765
|
}
|
|
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
766
|
}
|
|
767
|
+
if (isSettingsTamper(payload)) return PROTECTED_PATH_REASON;
|
|
768
|
+
const secretPath = secretPathInPayload(payload);
|
|
769
|
+
if (secretPath) return `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`;
|
|
702
770
|
}
|
|
703
771
|
|
|
704
772
|
// ── Policies 1, 7 & 8: shell file mutations get the same gates as edits ──
|
|
@@ -784,12 +852,12 @@ async function guardToolCallImpl(
|
|
|
784
852
|
const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
|
|
785
853
|
const prTool = isAz ? "az repos pr create" : "gh pr create";
|
|
786
854
|
const descFlag = isAz ? "--description" : "--body";
|
|
855
|
+
const preflightFailures: string[] = [];
|
|
787
856
|
|
|
788
857
|
// Policy 19: conflict-free mergeability gate before opening PR
|
|
789
858
|
const conflictCheck = checkMergeConflicts(currentRoot);
|
|
790
859
|
if (conflictCheck.hasConflicts) {
|
|
791
|
-
|
|
792
|
-
return `Blocked: ${conflictCheck.reason}`;
|
|
860
|
+
preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
|
|
793
861
|
}
|
|
794
862
|
|
|
795
863
|
// Policy 20: branch already merged/closed
|
|
@@ -797,10 +865,7 @@ async function guardToolCallImpl(
|
|
|
797
865
|
if (branch) {
|
|
798
866
|
const mergedStatus = isBranchAlreadyMergedOrClosed(currentRoot, branch);
|
|
799
867
|
if (mergedStatus.merged) {
|
|
800
|
-
|
|
801
|
-
`[workflow-guard] blocked ${prTool}: branch already merged/closed`,
|
|
802
|
-
);
|
|
803
|
-
return `Blocked: ${mergedStatus.reason}`;
|
|
868
|
+
preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
|
|
804
869
|
}
|
|
805
870
|
}
|
|
806
871
|
|
|
@@ -816,11 +881,7 @@ async function guardToolCallImpl(
|
|
|
816
881
|
review.gitStatus === getGitStatusSummary(currentRoot) &&
|
|
817
882
|
review.worktreeFingerprint === getGitWorktreeFingerprint(currentRoot);
|
|
818
883
|
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
|
-
);
|
|
884
|
+
preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
|
|
824
885
|
}
|
|
825
886
|
}
|
|
826
887
|
|
|
@@ -828,34 +889,38 @@ async function guardToolCallImpl(
|
|
|
828
889
|
if (isDocumentationRequired(currentRoot)) {
|
|
829
890
|
const hasDocChange = branchHasDocumentationChange(currentRoot);
|
|
830
891
|
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
|
-
);
|
|
892
|
+
preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
|
|
836
893
|
}
|
|
837
894
|
}
|
|
838
895
|
|
|
839
896
|
const branchChangelog = branchHasChangelogChange(currentRoot);
|
|
840
|
-
|
|
841
|
-
|
|
897
|
+
let shellCwdKnown = true;
|
|
898
|
+
const prSegments: Array<{ segment: string; invocationRoot: string | null }> = [];
|
|
899
|
+
for (const segment of splitShellSegments(raw)) {
|
|
900
|
+
const unwrapped = unwrapShellCommand(segment);
|
|
901
|
+
if (/(?:^|\s)(?:(?:builtin|command)\s+)?(?:cd|pushd|popd)(?:\s|$)/.test(unwrapped)) shellCwdKnown = false;
|
|
902
|
+
if (hasPrCreateInvocation(segment)) {
|
|
903
|
+
const wrapperChangesCwd = shellWrappersChangeCwd(segment);
|
|
904
|
+
prSegments.push({ segment, invocationRoot: shellCwdKnown && !wrapperChangesCwd ? currentRoot : null });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
842
907
|
const hasChangelog =
|
|
843
908
|
branchChangelog ||
|
|
844
|
-
prSegments.every((segment) => prBodyIncludesChangelog(segment));
|
|
909
|
+
prSegments.every(({ segment, invocationRoot }) => prBodyIncludesChangelog(segment, invocationRoot));
|
|
845
910
|
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
|
-
);
|
|
911
|
+
preflightFailures.push(`Changelog is required; update a CHANGELOG file or include a 'Changelog:' section in the PR description (${descFlag}).`);
|
|
852
912
|
}
|
|
853
913
|
|
|
854
914
|
// Lockfile synchronization pre-flight gate
|
|
855
915
|
const lockCheck = checkLockfileSync(currentRoot);
|
|
856
916
|
if (lockCheck.isOutOfSync) {
|
|
857
|
-
|
|
858
|
-
|
|
917
|
+
preflightFailures.push(lockCheck.reason ?? "Dependency lockfile is out of sync with its manifest.");
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (preflightFailures.length > 0) {
|
|
921
|
+
const reason = `Blocked: PR preflight failed:\n${preflightFailures.map((failure) => `- ${failure}`).join("\n")}`;
|
|
922
|
+
logBlock(`[workflow-guard] blocked ${prTool}: ${preflightFailures.length} preflight requirement(s) failed`);
|
|
923
|
+
return reason;
|
|
859
924
|
}
|
|
860
925
|
}
|
|
861
926
|
|
|
@@ -951,11 +1016,28 @@ async function emitBlockFeedback(message: string): Promise<void> {
|
|
|
951
1016
|
|
|
952
1017
|
export const WorkflowGuard: Plugin = async (ctx) => {
|
|
953
1018
|
// Honor worktree if present (e.g. opencode worktrees or devcontainers)
|
|
954
|
-
// so worktree plugins cannot punch through boundary gates.
|
|
955
|
-
|
|
1019
|
+
// so worktree plugins cannot punch through boundary gates. When the host
|
|
1020
|
+
// reports the filesystem root, use the SDK's actual project worktree instead.
|
|
1021
|
+
const hostRoot = ctx.worktree || ctx.directory || process.cwd();
|
|
1022
|
+
const hostIsFilesystemRoot = resolve(hostRoot) === resolve(hostRoot, "..");
|
|
1023
|
+
const effectiveRoot = hostIsFilesystemRoot && ctx.project?.worktree
|
|
1024
|
+
? ctx.project.worktree
|
|
1025
|
+
: hostRoot;
|
|
956
1026
|
setWorkspaceRoot(effectiveRoot);
|
|
957
1027
|
setSdkClient(ctx.client);
|
|
958
1028
|
reloadProjectConfig(effectiveRoot);
|
|
1029
|
+
const learningEnabled = isLearningEnabled(effectiveRoot);
|
|
1030
|
+
const learningInterventions = new Map<string, number>();
|
|
1031
|
+
const portableMemoryPath = join(effectiveRoot, ".opencode", "memory", "project-memory.jsonl");
|
|
1032
|
+
let projectMemory: ReturnType<typeof openProjectMemory> | undefined;
|
|
1033
|
+
try {
|
|
1034
|
+
projectMemory = openProjectMemory(getProjectMemoryIdentity(effectiveRoot));
|
|
1035
|
+
ensureProjectMemoryExcluded(effectiveRoot);
|
|
1036
|
+
importProjectKnowledge(projectMemory, portableMemoryPath, (content) => secretIn(content) !== undefined);
|
|
1037
|
+
} catch {
|
|
1038
|
+
try { projectMemory?.close(); } catch {}
|
|
1039
|
+
projectMemory = undefined;
|
|
1040
|
+
}
|
|
959
1041
|
|
|
960
1042
|
try {
|
|
961
1043
|
await (ctx.client as any)?.app?.log?.({
|
|
@@ -969,6 +1051,131 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
969
1051
|
|
|
970
1052
|
return {
|
|
971
1053
|
tool: {
|
|
1054
|
+
guard_next_tasks: tool({
|
|
1055
|
+
description:
|
|
1056
|
+
"Load durable repository task context when deciding what to work on next. Prefers TODO.md; if absent, discovers conventional roadmap, plan, tasks, backlog, and docs/plans Markdown files.",
|
|
1057
|
+
args: {},
|
|
1058
|
+
execute: async () => {
|
|
1059
|
+
const sources = discoverPlanningSources(effectiveRoot);
|
|
1060
|
+
return sources.length > 0
|
|
1061
|
+
? JSON.stringify({ sources }, null, 2)
|
|
1062
|
+
: JSON.stringify({ sources: [], message: "No TODO, roadmap, plan, tasks, or backlog Markdown files found." });
|
|
1063
|
+
},
|
|
1064
|
+
}),
|
|
1065
|
+
project_memory_search: tool({
|
|
1066
|
+
description: "Search current durable knowledge for this project. Returns concise typed records with provenance; superseded records are excluded.",
|
|
1067
|
+
args: { query: tool.schema.string() },
|
|
1068
|
+
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.",
|
|
1069
|
+
}),
|
|
1070
|
+
project_memory_record: tool({
|
|
1071
|
+
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.",
|
|
1072
|
+
args: {
|
|
1073
|
+
kind: tool.schema.string().describe("fact, decision, constraint, or lesson"),
|
|
1074
|
+
content: tool.schema.string(),
|
|
1075
|
+
paths: tool.schema.array(tool.schema.string()),
|
|
1076
|
+
supersedes: tool.schema.string().optional(),
|
|
1077
|
+
},
|
|
1078
|
+
execute: async (args, toolContext) => {
|
|
1079
|
+
if (!projectMemory) return "[workflow-guard] Project memory unavailable; core guard enforcement remains active.";
|
|
1080
|
+
if (!(["fact", "decision", "constraint", "lesson"] as string[]).includes(args.kind)) return "[workflow-guard] Project memory rejected: invalid kind.";
|
|
1081
|
+
const detectedSecret = secretIn(args.content);
|
|
1082
|
+
if (detectedSecret) return `[workflow-guard] Project memory rejected: possible secret detected (${detectedSecret}).`;
|
|
1083
|
+
const record = recordProjectMemory(projectMemory, {
|
|
1084
|
+
kind: args.kind as "fact" | "decision" | "constraint" | "lesson",
|
|
1085
|
+
content: args.content,
|
|
1086
|
+
source: "agent",
|
|
1087
|
+
sessionID: toolContext.sessionID,
|
|
1088
|
+
commit: getCurrentGitCommitHash(effectiveRoot),
|
|
1089
|
+
paths: args.paths,
|
|
1090
|
+
supersedes: args.supersedes || undefined,
|
|
1091
|
+
});
|
|
1092
|
+
return JSON.stringify(record);
|
|
1093
|
+
},
|
|
1094
|
+
}),
|
|
1095
|
+
project_memory_export: tool({
|
|
1096
|
+
description: "Explicitly promote selected durable project-memory records to the human-readable repo-local .opencode/memory/project-memory.jsonl file. Nothing is exported automatically.",
|
|
1097
|
+
args: { ids: tool.schema.array(tool.schema.string()) },
|
|
1098
|
+
execute: async (args) => {
|
|
1099
|
+
if (!projectMemory) return "[workflow-guard] Project memory unavailable; core guard enforcement remains active.";
|
|
1100
|
+
if (args.ids.length > 100 || args.ids.some((id) => id.length > 200)) return "[workflow-guard] Project memory export rejected: invalid record IDs.";
|
|
1101
|
+
const count = exportProjectKnowledge(projectMemory, args.ids, portableMemoryPath, (content) => secretIn(content) !== undefined);
|
|
1102
|
+
return `[workflow-guard] Exported ${count} project-memory record(s) to .opencode/memory/project-memory.jsonl.`;
|
|
1103
|
+
},
|
|
1104
|
+
}),
|
|
1105
|
+
project_memory_import: tool({
|
|
1106
|
+
description: "Import the fixed repo-local .opencode/memory/project-memory.jsonl file into this project's local working-memory index.",
|
|
1107
|
+
args: {},
|
|
1108
|
+
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.",
|
|
1109
|
+
}),
|
|
1110
|
+
...(learningEnabled ? {
|
|
1111
|
+
learning_profile: tool({
|
|
1112
|
+
description: "Inspect the local evidence-based learner profile so teaching can build on demonstrated knowledge without assuming unobserved concepts are gaps.",
|
|
1113
|
+
args: {},
|
|
1114
|
+
execute: async () => JSON.stringify(loadLearnerProfile(), null, 2),
|
|
1115
|
+
}),
|
|
1116
|
+
learning_checkpoint: tool({
|
|
1117
|
+
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.",
|
|
1118
|
+
args: {
|
|
1119
|
+
opportunities: tool.schema.array(tool.schema.object({
|
|
1120
|
+
type: tool.schema.string().describe("design, debugging, or new-concept"),
|
|
1121
|
+
concept: tool.schema.string().describe("Transferable engineering concept"),
|
|
1122
|
+
relevance: tool.schema.number().describe("Current-task relevance from 0 to 1"),
|
|
1123
|
+
consequence: tool.schema.number().describe("Decision consequence from 0 to 1"),
|
|
1124
|
+
})),
|
|
1125
|
+
},
|
|
1126
|
+
execute: async (args, toolContext) => {
|
|
1127
|
+
const valid = args.opportunities.slice(0, 20).filter((candidate): candidate is typeof candidate & { type: "design" | "debugging" | "new-concept" } =>
|
|
1128
|
+
(candidate.type === "design" || candidate.type === "debugging" || candidate.type === "new-concept") &&
|
|
1129
|
+
candidate.concept.length > 0 && candidate.concept.length <= 100 &&
|
|
1130
|
+
candidate.relevance >= 0 && candidate.relevance <= 1 && candidate.consequence >= 0 && candidate.consequence <= 1,
|
|
1131
|
+
);
|
|
1132
|
+
const used = learningInterventions.get(toolContext.sessionID) ?? 0;
|
|
1133
|
+
const selected = selectLearningOpportunity(loadLearnerProfile(), valid, {
|
|
1134
|
+
interventionsThisSession: used,
|
|
1135
|
+
maxInterventionsPerSession: getLearningInterventionBudget(effectiveRoot),
|
|
1136
|
+
});
|
|
1137
|
+
if (!selected) return JSON.stringify({ intervene: false, reason: "No opportunity selected or session learning budget reached." });
|
|
1138
|
+
learningInterventions.set(toolContext.sessionID, used + 1);
|
|
1139
|
+
return JSON.stringify({ intervene: true, opportunity: selected, guidance: "Ask one question, use the answer as task context, briefly reconcile if useful, then continue building." });
|
|
1140
|
+
},
|
|
1141
|
+
}),
|
|
1142
|
+
learning_record: tool({
|
|
1143
|
+
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.",
|
|
1144
|
+
args: {
|
|
1145
|
+
concept: tool.schema.string(),
|
|
1146
|
+
kind: tool.schema.string().describe("exposed, developing, demonstrated, independent, critique, or needs-reinforcement"),
|
|
1147
|
+
summary: tool.schema.string().describe("Short factual description of observed reasoning"),
|
|
1148
|
+
},
|
|
1149
|
+
execute: async (args, toolContext) => {
|
|
1150
|
+
const validKinds = new Set(["exposed", "developing", "demonstrated", "independent", "critique", "needs-reinforcement"]);
|
|
1151
|
+
if (!validKinds.has(args.kind)) return "[workflow-guard] Learning evidence rejected: invalid evidence kind.";
|
|
1152
|
+
if (args.concept.length < 1 || args.concept.length > 100 || args.summary.length < 1 || args.summary.length > 1000) {
|
|
1153
|
+
return "[workflow-guard] Learning evidence rejected: concept must be 1-100 characters and summary 1-1000 characters.";
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
updateLearnerProfile((profile) => {
|
|
1157
|
+
if (!profile.concepts[args.concept] && Object.keys(profile.concepts).length >= 500) {
|
|
1158
|
+
throw new Error("concept-limit");
|
|
1159
|
+
}
|
|
1160
|
+
recordLearningEvidence(profile, {
|
|
1161
|
+
concept: args.concept,
|
|
1162
|
+
kind: args.kind as LearningEvidenceKind,
|
|
1163
|
+
summary: args.summary,
|
|
1164
|
+
timestamp: Date.now(),
|
|
1165
|
+
sessionID: toolContext.sessionID,
|
|
1166
|
+
project: effectiveRoot,
|
|
1167
|
+
});
|
|
1168
|
+
});
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
if ((error as Error).message === "concept-limit") {
|
|
1171
|
+
return "[workflow-guard] Learning evidence rejected: learner profile concept limit reached.";
|
|
1172
|
+
}
|
|
1173
|
+
throw error;
|
|
1174
|
+
}
|
|
1175
|
+
return `[workflow-guard] Learning evidence recorded for ${args.concept}.`;
|
|
1176
|
+
},
|
|
1177
|
+
}),
|
|
1178
|
+
} : {}),
|
|
972
1179
|
guard_status: tool({
|
|
973
1180
|
description:
|
|
974
1181
|
"Inspect active guardrails, current branch protection, and verification/review status.",
|
|
@@ -1289,6 +1496,13 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1289
1496
|
);
|
|
1290
1497
|
}
|
|
1291
1498
|
contextBlocks.push(stateLines.join("\n"));
|
|
1499
|
+
const projectKnowledge = (projectMemory ? getRecentProjectMemory(projectMemory, 20) : [])
|
|
1500
|
+
.filter((memory) => memory.source !== "portable" && isProjectMemoryFresh(memory, effectiveRoot))
|
|
1501
|
+
.slice(0, 8);
|
|
1502
|
+
if (projectKnowledge.length > 0) {
|
|
1503
|
+
const lines = projectKnowledge.map((memory) => `- [${memory.kind}:${memory.id.slice(0, 8)}] ${memory.content.slice(0, 300)}`);
|
|
1504
|
+
contextBlocks.push(`## Project Memory\n${lines.join("\n")}\nTreat these as historical project knowledge; verify against current repository state when relevant files have changed.`);
|
|
1505
|
+
}
|
|
1292
1506
|
|
|
1293
1507
|
if (Array.isArray(output?.context)) {
|
|
1294
1508
|
output.context.push(contextBlocks.join("\n\n"));
|
|
@@ -1353,7 +1567,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1353
1567
|
const text = typeof output.text === "string" ? output.text : "";
|
|
1354
1568
|
const check = checkCompletionClaims(text, { sessionID });
|
|
1355
1569
|
if (check.claimsCompletion && check.evidenceState && check.evidenceState !== "fresh-pass") {
|
|
1356
|
-
|
|
1570
|
+
logObservation(ctx.client, `[workflow-guard] completion claim mismatch: ${check.reason}`);
|
|
1357
1571
|
audit({
|
|
1358
1572
|
ts: new Date().toISOString(),
|
|
1359
1573
|
sessionID,
|
|
@@ -1366,20 +1580,40 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1366
1580
|
} catch {}
|
|
1367
1581
|
},
|
|
1368
1582
|
|
|
1369
|
-
// Keep tool descriptions honest: todowrite's description reflects
|
|
1370
|
-
// finalization gates so the model is not surprised by
|
|
1583
|
+
// Keep tool descriptions honest: todowrite's description reflects lifecycle
|
|
1584
|
+
// and finalization gates so the model is not surprised by preventable blocks.
|
|
1371
1585
|
"tool.definition": async (input, output) => {
|
|
1372
1586
|
if (input.toolID !== "todowrite") return;
|
|
1373
1587
|
const description = typeof output.description === "string" ? output.description : "";
|
|
1374
1588
|
if (description.includes("verification evidence")) return;
|
|
1375
1589
|
output.description =
|
|
1376
1590
|
description +
|
|
1377
|
-
"\n\
|
|
1591
|
+
"\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
1592
|
},
|
|
1379
1593
|
|
|
1380
1594
|
event: async ({
|
|
1381
1595
|
event,
|
|
1382
1596
|
}: { event: { type?: string; properties?: unknown } }) => {
|
|
1597
|
+
if (event?.type === "session.idle") {
|
|
1598
|
+
const sessionID = (event.properties as { sessionID?: unknown })?.sessionID;
|
|
1599
|
+
if (typeof sessionID === "string") {
|
|
1600
|
+
await runWithRuntimeState(effectiveRoot, ctx.client, () => continueUnfinishedSession(sessionID));
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
if (event?.type === "message.updated") {
|
|
1604
|
+
const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown } })?.info;
|
|
1605
|
+
if (info?.role === "user" && typeof info.sessionID === "string") {
|
|
1606
|
+
await runWithRuntimeState(effectiveRoot, ctx.client, () => {
|
|
1607
|
+
recordUserMessage(info.sessionID as string, typeof info.id === "string" ? info.id : undefined);
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
if (event?.type === "session.deleted") {
|
|
1612
|
+
const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
|
|
1613
|
+
if (typeof sessionID === "string") {
|
|
1614
|
+
await runWithRuntimeState(effectiveRoot, ctx.client, () => clearContinuationState(sessionID));
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1383
1617
|
if (
|
|
1384
1618
|
event?.type === "command.executed" ||
|
|
1385
1619
|
event?.type === "permission.updated" ||
|