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.
@@ -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) && !allowLive) {
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) && !allowLive) {
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
- for (const content of extractEditContent(input)) {
504
- if (isSettingsTamper(content)) {
505
- logBlock(
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
- if (!allowLive) {
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
- logBlock(`[workflow-guard] blocked ${prTool}: merge conflicts detected`);
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
- logBlock(
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
- (!review.workspace || review.workspace === currentRoot) &&
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
- logBlock(`[workflow-guard] blocked ${prTool}: review approval required`);
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
- logBlock(`[workflow-guard] blocked ${prTool}: documentation update required`);
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
- logBlock(`[workflow-guard] blocked ${prTool}: no changelog found`);
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
- logBlock(`[workflow-guard] blocked ${prTool}: ${lockCheck.reason}`);
853
- return `Blocked: ${lockCheck.reason}`;
892
+ preflightFailures.push(lockCheck.reason ?? "Dependency lockfile is out of sync with its manifest.");
893
+ }
894
+
895
+ if (preflightFailures.length > 0) {
896
+ const reason = `Blocked: PR preflight failed:\n${preflightFailures.map((failure) => `- ${failure}`).join("\n")}`;
897
+ logBlock(`[workflow-guard] blocked ${prTool}: ${preflightFailures.length} preflight requirement(s) failed`);
898
+ return reason;
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 the
1365
- // finalization gates so the model is not surprised by verification blocks.
1542
+ // Keep tool descriptions honest: todowrite's description reflects lifecycle
1543
+ // and finalization gates so the model is not surprised by preventable blocks.
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\nNote: marking every task completed triggers the workflow guard's finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply.";
1550
+ "\n\nWorkflow Guard lifecycle: each todowrite call replaces the complete task list. Preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled; do not omit active tasks when adding new work. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply.";
1373
1551
  },
1374
1552
 
1375
1553
  event: async ({