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.
@@ -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) && !allowLive) {
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) && !allowLive) {
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
- for (const content of extractEditContent(input)) {
506
- if (isSettingsTamper(content)) {
507
- logBlock(
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
- if (!allowLive) {
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
- logBlock(`[workflow-guard] blocked ${prTool}: merge conflicts detected`);
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
- logBlock(
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
- logBlock(`[workflow-guard] blocked ${prTool}: review approval required`);
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
- logBlock(`[workflow-guard] blocked ${prTool}: documentation update required`);
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
- const prSegments = splitShellSegments(raw)
841
- .filter((segment) => hasPrCreateInvocation(segment));
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
- logBlock(`[workflow-guard] blocked ${prTool}: no changelog found`);
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
- logBlock(`[workflow-guard] blocked ${prTool}: ${lockCheck.reason}`);
858
- return `Blocked: ${lockCheck.reason}`;
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
- const effectiveRoot = ctx.worktree || ctx.directory || process.cwd();
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
- logBlock(`[workflow-guard] completion claim mismatch: ${check.reason}`);
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 the
1370
- // finalization gates so the model is not surprised by verification blocks.
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\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.";
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" ||