opencode-workflow-guard 1.6.0 → 1.7.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 CHANGED
@@ -12,7 +12,13 @@ OpenCode plugin enforcing workflow discipline, agent focus, and deterministic sa
12
12
 
13
13
  ### 1. Installation
14
14
 
15
- Add to your project's `opencode.json` or global `~/.config/opencode/opencode.json`:
15
+ Run the setup command to register both the server guard and TUI companion globally:
16
+
17
+ ```bash
18
+ npx opencode-workflow-guard setup
19
+ ```
20
+
21
+ For server-only setup, add the package to your project's `opencode.json` or global `~/.config/opencode/opencode.json`:
16
22
 
17
23
  ```json
18
24
  {
@@ -23,9 +29,9 @@ Add to your project's `opencode.json` or global `~/.config/opencode/opencode.jso
23
29
  }
24
30
  ```
25
31
 
26
- *Requires OpenCode >= 1.18.* See [docs/installation.md](docs/installation.md) for full setup options and TUI companion badge configuration.
32
+ *Requires OpenCode >= 1.18.* Restart OpenCode after setup. See [docs/installation.md](docs/installation.md) for full setup options.
27
33
 
28
- For the optional TUI badge, configure `"opencode-workflow-guard/tui"` in `tui.json`. The package root is the server plugin; TUI plugin specs are resolved as written and must use the exported `./tui` entrypoint. Do not place the TUI module under the server `plugins/` directory.
34
+ For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`. OpenCode resolves the package's exported `./tui` entrypoint automatically for TUI plugins. Do not place the TUI module under the server `plugins/` directory.
29
35
 
30
36
  ### 2. Verification
31
37
 
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { parse, modify, applyEdits } from "jsonc-parser";
7
+
8
+ const SERVER_SPEC = "opencode-workflow-guard";
9
+ const TUI_SPEC = "opencode-workflow-guard";
10
+ const LEGACY_TUI_SPEC = "opencode-workflow-guard/tui";
11
+
12
+ function configHome() {
13
+ return process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
14
+ }
15
+
16
+ function existingConfig(base, name) {
17
+ const json = join(base, name + ".json");
18
+ const jsonc = join(base, name + ".jsonc");
19
+ return existsSync(json) ? json : existsSync(jsonc) ? jsonc : json;
20
+ }
21
+
22
+ function preparePluginUpdate(path, schema, spec, legacySpec) {
23
+ const source = existsSync(path) ? readFileSync(path, "utf8") : `{
24
+ "$schema": "${schema}"
25
+ }\n`;
26
+ const errors = [];
27
+ const config = parse(source, errors, { allowTrailingComma: true, disallowComments: false });
28
+ if (errors.length > 0 || !config || typeof config !== "object" || Array.isArray(config)) {
29
+ throw new Error(`Cannot update invalid config: ${path}`);
30
+ }
31
+ if (config.plugin !== undefined && !Array.isArray(config.plugin)) {
32
+ throw new Error(`Cannot update ${path}: "plugin" must be an array`);
33
+ }
34
+ const plugins = config.plugin ?? [];
35
+ const matches = (entry, target) => entry === target || (Array.isArray(entry) && entry[0] === target);
36
+ if (!legacySpec) {
37
+ if (plugins.some((entry) => matches(entry, spec))) return undefined;
38
+ return applyEdits(source, modify(source, ["plugin", plugins.length], spec, {
39
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
40
+ }));
41
+ }
42
+ const updatedPlugins = plugins.filter((entry) => !matches(entry, legacySpec));
43
+ if (!updatedPlugins.some((entry) => matches(entry, spec))) updatedPlugins.push(spec);
44
+ if (updatedPlugins.length === plugins.length && updatedPlugins.every((entry, index) => entry === plugins[index])) return undefined;
45
+ return applyEdits(source, modify(source, ["plugin"], updatedPlugins, {
46
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
47
+ }));
48
+ }
49
+
50
+ if (process.argv[2] !== "setup" || process.argv.length > 3) {
51
+ console.error("Usage: opencode-workflow-guard setup");
52
+ process.exitCode = 1;
53
+ } else {
54
+ try {
55
+ const base = join(configHome(), "opencode");
56
+ const serverPath = existingConfig(base, "opencode");
57
+ const tuiPath = existingConfig(base, "tui");
58
+ const serverUpdate = preparePluginUpdate(serverPath, "https://opencode.ai/config.json", SERVER_SPEC);
59
+ const tuiUpdate = preparePluginUpdate(tuiPath, "https://opencode.ai/tui.json", TUI_SPEC, LEGACY_TUI_SPEC);
60
+ mkdirSync(base, { recursive: true });
61
+ if (serverUpdate !== undefined) writeFileSync(serverPath, serverUpdate);
62
+ if (tuiUpdate !== undefined) writeFileSync(tuiPath, tuiUpdate);
63
+ console.log(`Workflow Guard configured in ${serverPath} and ${tuiPath}.`);
64
+ console.log("Restart OpenCode to load both plugins.");
65
+ } catch (error) {
66
+ console.error(error instanceof Error ? error.message : String(error));
67
+ process.exitCode = 1;
68
+ }
69
+ }
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "OpenCode plugin enforcing workflow discipline through deterministic tool.execute.before hooks",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "src/workflow-guard.ts",
8
+ "bin": {
9
+ "opencode-workflow-guard": "./bin/opencode-workflow-guard.mjs"
10
+ },
8
11
  "exports": {
9
12
  ".": "./src/workflow-guard.ts",
10
13
  "./server": "./src/workflow-guard.ts",
@@ -18,6 +21,7 @@
18
21
  "url": "git+https://github.com/ultus-net/opencode-workflow-guard.git"
19
22
  },
20
23
  "files": [
24
+ "bin/",
21
25
  "src/",
22
26
  "README.md",
23
27
  "LICENSE",
@@ -183,7 +183,7 @@ export function createCustomTools(options: {
183
183
  execute: async (args, toolContext) => {
184
184
  const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree creation with no active todo item. Break the request down with todowrite first, then create worktrees.";
185
185
  const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot); if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}`;
186
- recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID); return `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
186
+ recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID); return `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
187
187
  },
188
188
  }),
189
189
  guard_worktree_cleanup: tool({
@@ -191,7 +191,7 @@ export function createCustomTools(options: {
191
191
  execute: async (args, toolContext) => {
192
192
  const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree cleanup with no active todo item. Break the request down with todowrite first, then clean up worktrees.";
193
193
  const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = cleanupGitWorktree(args.worktreePath, toolRoot); if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
194
- recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID); return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
194
+ recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID); return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
195
195
  },
196
196
  }),
197
197
  };
@@ -24,6 +24,7 @@ import {
24
24
  unwrapShellCommand,
25
25
  } from "./shell.ts";
26
26
  import { asRecord, extractTargetPath } from "./utils.ts";
27
+ import { getProjectMemoryIdentity } from "./project-memory.ts";
27
28
  import {
28
29
  detectVerifyCommand,
29
30
  getCurrentGitCommitHash,
@@ -272,7 +273,7 @@ export async function guardToolCallImpl(
272
273
  return claimReason;
273
274
  }
274
275
  }
275
- recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
276
+ recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
276
277
  return undefined;
277
278
  }
278
279
 
@@ -285,6 +286,11 @@ export async function guardToolCallImpl(
285
286
  }
286
287
  }
287
288
  if (!SHELL_TOOL_NAMES.has(toolName)) return undefined;
289
+ const shellWorkdir = asRecord(input)?.workdir;
290
+ const requestedShellRoot = typeof shellWorkdir === "string" ? resolve(currentRoot, shellWorkdir) : currentRoot;
291
+ const shellRoot = !isPathOutsideWorkspace(requestedShellRoot, currentRoot) || getProjectMemoryIdentity(requestedShellRoot) === getProjectMemoryIdentity(currentRoot)
292
+ ? requestedShellRoot
293
+ : currentRoot;
288
294
 
289
295
  for (const raw of extractCommands(input)) {
290
296
  const dynamicSyntax = dynamicShellSyntaxIn(raw);
@@ -348,7 +354,7 @@ export async function guardToolCallImpl(
348
354
  if (onProtectedBranch(currentRoot)) return branchGuardReason();
349
355
  const todos = await effectiveTodos(context?.sessionID);
350
356
  if (todos !== undefined && !hasActiveTodo(todos)) return "Blocked: inline interpreter file mutation with no active todo item.";
351
- recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
357
+ recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
352
358
  }
353
359
  if (!allowLive) {
354
360
  const liveCheck = liveMutationIn(normalizeGitCommands(normalize(payload)));
@@ -402,24 +408,25 @@ export async function guardToolCallImpl(
402
408
  }
403
409
  }
404
410
  if (hasPrCreateInvocation(raw)) {
411
+ const prRoot = shellRoot;
405
412
  const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
406
413
  const prTool = isAz ? "az repos pr create" : "gh pr create";
407
414
  const descFlag = isAz ? "--description" : "--body";
408
415
  const preflightFailures: string[] = [];
409
- const conflictCheck = checkMergeConflicts(currentRoot);
416
+ const conflictCheck = checkMergeConflicts(prRoot);
410
417
  if (conflictCheck.hasConflicts) preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
411
- const branch = currentGitBranch(currentRoot);
418
+ const branch = currentGitBranch(prRoot);
412
419
  if (branch) {
413
- const mergedStatus = isBranchAlreadyMergedOrClosed(currentRoot, branch);
420
+ const mergedStatus = isBranchAlreadyMergedOrClosed(prRoot, branch);
414
421
  if (mergedStatus.merged) preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
415
422
  }
416
- if (isReviewRequired(currentRoot)) {
423
+ if (isReviewRequired(prRoot)) {
417
424
  const review = context?.sessionID ? (sessionReviews.get(context.sessionID) ?? getLastReviewResult()) : getLastReviewResult();
418
- const reviewMatchesContext = review?.passed === true && review.workspace === resolve(currentRoot) && (!review.targetSessionID || review.targetSessionID === context?.sessionID) && review.commitHash === getCurrentGitCommitHash(currentRoot) && review.gitStatus === getGitStatusSummary(currentRoot) && review.worktreeFingerprint === getGitWorktreeFingerprint(currentRoot);
425
+ const reviewMatchesContext = review?.passed === true && review.workspace === resolve(prRoot) && (!review.targetSessionID || review.targetSessionID === context?.sessionID) && review.commitHash === getCurrentGitCommitHash(prRoot) && review.gitStatus === getGitStatusSummary(prRoot) && review.worktreeFingerprint === getGitWorktreeFingerprint(prRoot);
419
426
  if (!reviewMatchesContext) preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
420
427
  }
421
- if (isDocumentationRequired(currentRoot) && !branchHasDocumentationChange(currentRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
422
- const branchChangelog = branchHasChangelogChange(currentRoot);
428
+ if (isDocumentationRequired(prRoot) && !branchHasDocumentationChange(prRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
429
+ const branchChangelog = branchHasChangelogChange(prRoot);
423
430
  let shellCwdKnown = true;
424
431
  const prSegments: Array<{ segment: string; invocationRoot: string | null }> = [];
425
432
  for (const segment of splitShellSegments(raw)) {
@@ -427,12 +434,12 @@ export async function guardToolCallImpl(
427
434
  if (/(?:^|\s)(?:(?:builtin|command)\s+)?(?:cd|pushd|popd)(?:\s|$)/.test(unwrapped)) shellCwdKnown = false;
428
435
  if (hasPrCreateInvocation(segment)) {
429
436
  const wrapperChangesCwd = shellWrappersChangeCwd(segment);
430
- prSegments.push({ segment, invocationRoot: shellCwdKnown && !wrapperChangesCwd ? currentRoot : null });
437
+ prSegments.push({ segment, invocationRoot: shellCwdKnown && !wrapperChangesCwd ? prRoot : null });
431
438
  }
432
439
  }
433
440
  const hasChangelog = branchChangelog || prSegments.every(({ segment, invocationRoot }) => prBodyIncludesChangelog(segment, invocationRoot));
434
441
  if (!hasChangelog) preflightFailures.push(`Release information is required; update a CHANGELOG/changeset file or include a Summary, Changes, Release notes, or Changelog section in the PR description (${descFlag}).`);
435
- const lockCheck = checkLockfileSync(currentRoot);
442
+ const lockCheck = checkLockfileSync(prRoot);
436
443
  if (lockCheck.isOutOfSync) preflightFailures.push(lockCheck.reason ?? "Dependency lockfile is out of sync with its manifest.");
437
444
  if (preflightFailures.length > 0) {
438
445
  const reason = `Blocked: PR preflight failed:\n${preflightFailures.map((failure) => `- ${failure}`).join("\n")}`;
@@ -465,7 +472,7 @@ export async function guardToolCallImpl(
465
472
  }
466
473
  }
467
474
  }
468
- if (hasGitMutation) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
475
+ if (hasGitMutation) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
469
476
  }
470
477
  return undefined;
471
478
  }
@@ -39,9 +39,9 @@ export function ensureProjectMemoryExcluded(root: string): boolean {
39
39
  export function isProjectMemoryFresh(memory: ProjectMemoryRecord, root: string): boolean {
40
40
  if (!memory.commit || memory.paths.length === 0) return true;
41
41
  if (!/^[0-9a-f]{7,64}$/i.test(memory.commit)) return false;
42
- const result = spawnSync("git", ["-C", root, "diff", "--quiet", memory.commit, "--", ...memory.paths], { encoding: "utf8" });
42
+ const result = spawnSync("git", ["-C", root, "diff", "--quiet", memory.commit, "--", ...memory.paths], { encoding: "utf8", timeout: 2_000 });
43
43
  if (result.status !== 0) return false;
44
- const untracked = spawnSync("git", ["-C", root, "ls-files", "--others", "--exclude-standard", "--", ...memory.paths], { encoding: "utf8" });
44
+ const untracked = spawnSync("git", ["-C", root, "ls-files", "--others", "--exclude-standard", "--", ...memory.paths], { encoding: "utf8", timeout: 2_000 });
45
45
  return untracked.status === 0 && !untracked.stdout.trim();
46
46
  }
47
47
 
package/src/lib/state.ts CHANGED
@@ -79,16 +79,16 @@ export const sessionVerifyResults = new Map<string, NonNullable<VerifyResult>>()
79
79
  export let lastReview: ReviewResult | undefined;
80
80
  export const sessionReviews = new Map<string, ReviewResult>();
81
81
 
82
- export function recordMutation(sessionID?: string): void {
82
+ export function recordMutation(sessionID?: string, actorSessionID?: string): void {
83
83
  lastMutationTimestamp = Date.now();
84
84
  mutationCount++;
85
- if (sessionID) {
86
- sessionMutationTimestamps.set(sessionID, lastMutationTimestamp);
87
- sessionMutationCounts.set(sessionID, (sessionMutationCounts.get(sessionID) ?? 0) + 1);
88
- sessionVerifyResults.delete(sessionID);
85
+ for (const id of new Set([sessionID, actorSessionID].filter((value): value is string => Boolean(value)))) {
86
+ sessionMutationTimestamps.set(id, lastMutationTimestamp);
87
+ sessionMutationCounts.set(id, (sessionMutationCounts.get(id) ?? 0) + 1);
88
+ sessionVerifyResults.delete(id);
89
+ sessionReviews.delete(id);
89
90
  }
90
- if (sessionID) sessionReviews.delete(sessionID);
91
- if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID) {
91
+ if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID || lastReview.targetSessionID === actorSessionID) {
92
92
  lastReview = undefined;
93
93
  }
94
94
  }
@@ -100,6 +100,10 @@ export function getMutationCount(sessionID?: string): number {
100
100
  return mutationCount;
101
101
  }
102
102
 
103
+ export function getSessionMutationCount(sessionID: string): number {
104
+ return sessionMutationCounts.get(sessionID) ?? 0;
105
+ }
106
+
103
107
  export function getLastMutationTimestamp(): number {
104
108
  return lastMutationTimestamp;
105
109
  }
@@ -384,7 +384,7 @@ export async function guardShellMutation(
384
384
  }
385
385
  }
386
386
  if (hasMutation) {
387
- recordMutation(await effectiveTodoOwnerSessionID(sessionID));
387
+ recordMutation(await effectiveTodoOwnerSessionID(sessionID), sessionID);
388
388
  }
389
389
  return undefined;
390
390
  }
@@ -1,5 +1,5 @@
1
1
  import type { TodoItem } from "../lib/types.ts";
2
- import { getMutationCount, getSdkClient, getSubagentMutationBudget } from "../lib/state.ts";
2
+ import { getSessionMutationCount, getSdkClient, getSubagentMutationBudget } from "../lib/state.ts";
3
3
 
4
4
  export const EDIT_TOOL_NAMES = new Set(["edit", "write", "patch", "apply_patch"]);
5
5
  export const ACTIVE_TODO_STATUSES = new Set(["pending", "in_progress"]);
@@ -28,7 +28,7 @@ export async function subagentMutationBudgetReason(
28
28
  root: string,
29
29
  ): Promise<string | undefined> {
30
30
  if (!await fetchParentSessionID(sessionID)) return undefined;
31
- const count = getMutationCount(sessionID);
31
+ const count = getSessionMutationCount(sessionID);
32
32
  const budget = getSubagentMutationBudget(root);
33
33
  if (count < budget) return undefined;
34
34
  return `Blocked: subagent session '${sessionID}' has reached its mutation budget (${count}/${budget}). Hand work back to parent orchestrator.`;
@@ -29,7 +29,7 @@ function text(props: Record<string, unknown>, children: Child[]) {
29
29
  return element("text", props, children);
30
30
  }
31
31
 
32
- const BADGE_ACTIVE = "[Workflow Guard: Active]";
32
+ const BADGE_ACTIVE = "Workflow Guard 🛡️";
33
33
  let lastBlockedReason: string | undefined;
34
34
  const sessionBlockedReasons = new Map<string, string>();
35
35
  const badgeNodes = new Map<string, unknown>();
@@ -47,6 +47,7 @@ import {
47
47
  lastMutationTimestamp,
48
48
  getLastMutationTimestamp,
49
49
  getMutationCount,
50
+ getSessionMutationCount,
50
51
  lastVerify,
51
52
  getLastVerifyResult,
52
53
  recordMutation,
@@ -447,20 +448,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
447
448
  return s === "pending" || s === "in_progress";
448
449
  });
449
450
  const branch = currentGitBranch(effectiveRoot) ?? "unknown";
450
- const isProtected = onProtectedBranch(effectiveRoot);
451
+ const isProtected = branch !== "unknown" && isProtectedBranchName(branch, effectiveRoot);
452
+ const sessionMutationCount = sessionID ? getSessionMutationCount(sessionID) : 0;
451
453
  const lastV = sessionID
452
- ? (sessionVerifyResults.get(sessionID) ?? lastVerify)
454
+ ? (sessionVerifyResults.get(sessionID) ?? (sessionMutationCount === 0 && parentID ? sessionVerifyResults.get(parentID) : undefined))
453
455
  : lastVerify;
454
- const lastR = getLastReviewResult();
455
- const mutationCountVal = getMutationCount(sessionID);
456
+ const lastR = sessionID
457
+ ? (sessionReviews.get(sessionID) ?? (sessionMutationCount === 0 && parentID ? sessionReviews.get(parentID) : undefined))
458
+ : getLastReviewResult();
459
+ const mutationCountVal = sessionID ? sessionMutationCount : getMutationCount();
456
460
 
457
461
  const contextBlocks: string[] = [];
458
462
 
459
463
  if (active && active.length > 0) {
460
- const lines = active.map(
464
+ const lines = active.slice(0, 20).map(
461
465
  (t) =>
462
- `- [${String(t.status) === "in_progress" ? "IN PROGRESS" : "PENDING"}] ${String(t.content ?? "")}`,
466
+ `- [${String(t.status) === "in_progress" ? "IN PROGRESS" : "PENDING"}] ${String(t.content ?? "").slice(0, 300)}`,
463
467
  );
468
+ if (active.length > 20) lines.push(`- ... ${active.length - 20} more active task(s) omitted`);
464
469
  const attribution = parentID
465
470
  ? ` (Subagent session: ${sessionID}, Parent: ${parentID})`
466
471
  : sessionID
@@ -488,15 +493,18 @@ export const WorkflowGuard: Plugin = async (ctx) => {
488
493
  `- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
489
494
  );
490
495
  }
491
- const openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : [];
496
+ let openFollowups: ReturnType<typeof listReviewFollowups> = [];
497
+ try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
492
498
  if (openFollowups.length > 0) {
493
499
  stateLines.push(`- Open Review Follow-ups: ${openFollowups.length} local P2/P3 item(s)`);
494
500
  contextBlocks.push(`## Review Follow-ups\n${openFollowups.map((item) => `- [${item.severity}:${item.id.slice(0, 8)}] ${item.summary.slice(0, 300)}`).join("\n")}\nTreat these as durable technical debt: address relevant items when practical and resolve them explicitly after verification.`);
495
501
  }
496
502
  contextBlocks.push(stateLines.join("\n"));
497
- const projectKnowledge = (projectMemory ? getRecentProjectMemory(projectMemory, 20) : [])
498
- .filter((memory) => memory.source !== "portable" && isProjectMemoryFresh(memory, effectiveRoot))
499
- .slice(0, 8);
503
+ let projectKnowledge: ReturnType<typeof getRecentProjectMemory> = [];
504
+ try {
505
+ projectKnowledge = (projectMemory ? getRecentProjectMemory(projectMemory, 8) : [])
506
+ .filter((memory) => memory.source !== "portable" && isProjectMemoryFresh(memory, effectiveRoot));
507
+ } catch {}
500
508
  if (projectKnowledge.length > 0) {
501
509
  const lines = projectKnowledge.map((memory) => `- [${memory.kind}:${memory.id.slice(0, 8)}] ${memory.content.slice(0, 300)}`);
502
510
  contextBlocks.push(`## Project Memory\n${lines.join("\n")}\nTreat these as historical project knowledge; verify against current repository state when relevant files have changed.`);