opencode-workflow-guard 1.8.0 → 1.8.2

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
@@ -1,10 +1,12 @@
1
- # opencode-workflow-guard
1
+ # OpenCode Workflow Guard
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/opencode-workflow-guard.svg)](https://www.npmjs.com/package/opencode-workflow-guard)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
  [![Tests](https://img.shields.io/badge/tests-415%2B%20passing-brightgreen.svg)](test/test.mts)
6
6
 
7
- OpenCode plugin enforcing workflow discipline, agent focus, and deterministic safety boundaries through **hard plugin hooks**, not prompt instructions that LLMs can ignore.
7
+ Deterministic policy and enforcement layer for OpenCode. Workflow Guard enforces workflow and safety invariants through **hard plugin hooks**, not prompt instructions that LLMs can ignore.
8
+
9
+ Workflow Guard is deliberately **not an agent harness**: it can constrain actions, record and explain decisions, and supply bounded context, but it does not plan, prioritize, delegate, or autonomously sequence the agent's work. Bounded continuation may resume an existing session with unfinished owned todos; it never chooses the next task or creates new work.
8
10
 
9
11
  ---
10
12
 
@@ -21,7 +23,9 @@ opencode plugin "opencode-workflow-guard@$VERSION" --global --force
21
23
 
22
24
  *Requires OpenCode >= 1.18.* OpenCode detects the package's server and TUI targets and updates both global configs. Use the same command after a release to upgrade; the explicit version gives OpenCode a fresh package-cache key. Restart OpenCode after installation. See [docs/installation.md](docs/installation.md) for details and links to the OpenCode plugin documentation.
23
25
 
24
- 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.
26
+ For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`. OpenCode resolves the package's exported `./tui` entrypoint automatically for TUI plugins. The companion shows a `Workflow Guard` shield in the prompt bar and reflects blocked state without changing agent behavior. Do not place the TUI module under the server `plugins/` directory.
27
+
28
+ ![Workflow Guard status badge in the OpenCode TUI](docs/assets/workflow-guard-tui-badge.png)
25
29
 
26
30
  ### 2. Verification
27
31
 
@@ -61,70 +65,6 @@ For complete policy specifications and override rules, see [docs/policies.md](do
61
65
 
62
66
  ---
63
67
 
64
- ## Custom Tools Registered in OpenCode
65
-
66
- | Tool | Purpose |
67
- |---|---|
68
- | `guard_next_tasks` | Load `TODO.md`, or fall back to repository roadmap/plan/task Markdown when deciding what to work on next |
69
- | `guard_status` | Inspect active guardrails, current branch protection, and verification/review status |
70
- | `guard_why` | Simulate and explain whether a specific tool call or command would be blocked |
71
- | `guard_audit` | View recent audit log entries recorded in `workflow-guard.jsonl` |
72
- | `guard_review_rubric` | Generate the 5-axis secondary review rubric for the current branch diff |
73
- | `record_review` | Record a secondary reviewer subagent's verdict evaluated against the review rubric |
74
- | `guard_review_followups` | List durable local P2/P3 review findings that remain open across sessions |
75
- | `guard_review_followup_resolve` | Resolve a durable review finding after its underlying issue is fixed and verified |
76
- | `guard_worktree_create` | Create an isolated git worktree directory for concurrent subagent execution |
77
- | `guard_worktree_cleanup` | Snapshot-commit remaining changes and remove an isolated worktree directory |
78
- | `learning_profile` | Inspect the evidence-based local learner profile (when learning mode is enabled) |
79
- | `learning_checkpoint` | Rank high-value Socratic opportunities while respecting the session intervention budget |
80
- | `learning_record` | Persist concise evidence observed during a real learning interaction |
81
- | `project_memory_search` | Search current durable project knowledge in the local index |
82
- | `project_memory_record` | Record a durable fact, decision, constraint, or lesson with provenance |
83
- | `project_memory_export` | Explicitly promote selected records to repo-local human-readable JSONL |
84
- | `project_memory_import` | Import promoted repo-local knowledge into the local index |
85
-
86
- ### Experimental Socratic Learning
87
-
88
- Learning mode is opt-in and advisory: it never blocks tool calls or weakens deterministic guardrails. Enable it with `learning: true` in `.opencode/workflow-guard.json[c]` or through `/guard-options`; `WORKFLOW_GUARD_LEARNING=1` in the user environment also enables it regardless of project configuration. `maxLearningInterventions` defaults to 3 per session and can be set to `0` to suppress checkpoints.
89
-
90
- The learner profile is global to the local user and follows the XDG data convention: `$XDG_DATA_HOME/opencode/workflow-guard/learner-profile.json`, or `~/.local/share/opencode/workflow-guard/learner-profile.json` when `XDG_DATA_HOME` is unset. It stores distilled concept evidence, session/project provenance, and progression (`exposed` through `critique`), not conversation transcripts or numeric grades. Unobserved concepts remain unknown rather than being labeled knowledge gaps. Back up or delete this file independently from project repositories as desired.
91
-
92
- ### Project Memory
93
-
94
- Project memory keeps durable working knowledge between sessions without treating conversation history as project truth. It is enabled by default and can be disabled with `projectMemory: false` or through `/guard-options`. Facts, decisions, constraints, and lessons are stored in a local SQLite/FTS5 index under `$XDG_DATA_HOME/opencode/workflow-guard/project-memory/`, or `~/.local/share/opencode/workflow-guard/project-memory/` when `XDG_DATA_HOME` is unset. Git repositories are identified from their common Git directory, so linked worktrees share the same local project index.
95
-
96
- The local index is private working memory. Nothing from it is committed automatically. `project_memory_export` promotes only the explicitly selected current record IDs to `.opencode/memory/project-memory.jsonl`, a human-readable portable representation that another clone can import. The plugin adds `.opencode/memory/` to the clone-local `.git/info/exclude` by default; teams that deliberately want to version promoted knowledge can remove that exclusion or force-add the JSONL file. SQLite databases themselves should not be committed.
97
-
98
- Memory is supporting context, not authority. Superseded records are excluded from normal retrieval, recording and plugin imports reject content recognized by Workflow Guard's secret detector, and compaction injects at most eight recent current local records. Portable repository records are never injected automatically; they are available through explicit memory search after import. A local record tied to repository paths and a Git commit is omitted from compaction when those paths have committed, staged, or unstaged changes after that commit. Always prefer current repository state when it conflicts with historical memory.
99
-
100
- ---
101
-
102
- ## Repository Tree
103
-
104
- ```
105
- opencode-workflow-guard/
106
- ├── src/ # Production plugin sources
107
- │ ├── workflow-guard.ts # Server plugin entrypoint (hook orchestrator & public exports)
108
- │ ├── workflow-guard-ui.ts # Optional TUI companion prompt badge
109
- │ ├── policies/ # Modular policy implementations (task gate, git, secrets, boundary, ...)
110
- │ └── lib/ # Engine services (state, verify, audit, review, worktree, utils, types)
111
- ├── test/ # Test suites
112
- │ ├── test.mts # 415+ in-memory unit & adversarial tests
113
- │ └── test-e2e.mts # npm tarball resolution + live runtime OpenCode loader tests
114
- ├── docs/ # In-depth documentation & guides
115
- │ ├── policies.md # Complete 24-policy reference & overrides
116
- │ ├── installation.md # Setup options, worktrees & permissions
117
- │ ├── troubleshooting.md # Error diagnosis & bypass explanations
118
- │ └── testing.md # Test architecture & coverage matrix
119
- ├── .changeset/ # Fragment-based versioning & release changelogs
120
- ├── ACKNOWLEDGEMENTS.md # Open-source community credits & inspirations
121
- ├── CONTRIBUTING.md # Development guide, test patterns & PR rules
122
- ├── CHANGELOG.md # Version history
123
- └── SECURITY.md # Vulnerability disclosure policy
124
- ```
125
-
126
- ---
127
-
128
68
  ## License
129
69
 
130
70
  [MIT](LICENSE)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.8.0",
4
- "description": "OpenCode plugin enforcing workflow discipline through deterministic tool.execute.before hooks",
3
+ "version": "1.8.2",
4
+ "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "src/workflow-guard.ts",
package/src/lib/audit.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, readSync, closeSync, renameSync, statSync, readFileSync, writeFileSync } from "node:fs";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
5
6
  import type { AuditEntry, VerifyResult } from "./types.ts";
6
7
  import { asRecord } from "./utils.ts";
7
8
 
@@ -17,6 +18,37 @@ const MAX_AUDIT_BYTES = 4 * 1024 * 1024;
17
18
  const RETAIN_AUDIT_BYTES = 2 * 1024 * 1024;
18
19
  const MAX_VERIFY_HISTORY_BYTES = 1024 * 1024;
19
20
  const RETAIN_VERIFY_HISTORY_BYTES = 512 * 1024;
21
+ let verifiedHistoryState: { size: number; mtimeMs: number; ctimeMs: number; ino: number | bigint } | undefined;
22
+ const lockDatabases = new Map<string, DatabaseSync>();
23
+
24
+ function getVerifyHistoryState(): typeof verifiedHistoryState {
25
+ const stat = statSync(VERIFY_HISTORY_FILE);
26
+ return { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, ino: stat.ino };
27
+ }
28
+
29
+ function sameVerifyHistoryState(a: typeof verifiedHistoryState, b: typeof verifiedHistoryState): boolean {
30
+ return a?.size === b?.size && a?.mtimeMs === b?.mtimeMs && a?.ctimeMs === b?.ctimeMs && a?.ino === b?.ino;
31
+ }
32
+
33
+ function withFileLock<T>(path: string, action: () => T): T {
34
+ let db = lockDatabases.get(path);
35
+ if (!db) {
36
+ const lockPath = `${path}.lock.sqlite`;
37
+ db = new DatabaseSync(lockPath);
38
+ db.exec("PRAGMA busy_timeout = 5000");
39
+ chmodSync(lockPath, 0o600);
40
+ lockDatabases.set(path, db);
41
+ }
42
+ db.exec("BEGIN IMMEDIATE");
43
+ try {
44
+ const result = action();
45
+ db.exec("COMMIT");
46
+ return result;
47
+ } catch (error) {
48
+ try { db.exec("ROLLBACK"); } catch {}
49
+ throw error;
50
+ }
51
+ }
20
52
 
21
53
  export function getAuditFilePath(): string {
22
54
  return AUDIT_FILE;
@@ -30,7 +62,7 @@ export function getVerifyHistoryFilePath(): string {
30
62
  return VERIFY_HISTORY_FILE;
31
63
  }
32
64
 
33
- function appendBoundedJsonl(path: string, value: unknown, maxBytes: number, retainBytes: number): void {
65
+ function appendBoundedJsonlUnlocked(path: string, value: unknown, maxBytes: number, retainBytes: number): void {
34
66
  appendFileSync(path, JSON.stringify(value) + "\n", { encoding: "utf8", mode: 0o600 });
35
67
  chmodSync(path, 0o600);
36
68
  const stat = statSync(path);
@@ -46,31 +78,43 @@ function appendBoundedJsonl(path: string, value: unknown, maxBytes: number, reta
46
78
  const text = buffer.toString("utf8");
47
79
  const firstNewline = text.indexOf("\n");
48
80
  const retained = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
49
- const temp = `${path}.${process.pid}.tmp`;
81
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
50
82
  writeFileSync(temp, retained, { encoding: "utf8", mode: 0o600 });
51
83
  renameSync(temp, path);
52
84
  }
53
85
 
86
+ function appendBoundedJsonl(path: string, value: unknown, maxBytes: number, retainBytes: number): void {
87
+ withFileLock(path, () => appendBoundedJsonlUnlocked(path, value, maxBytes, retainBytes));
88
+ }
89
+
90
+ function verifyHistoryNeedsSanitation(): boolean {
91
+ if (!existsSync(VERIFY_HISTORY_FILE)) return false;
92
+ const state = getVerifyHistoryState();
93
+ if (sameVerifyHistoryState(verifiedHistoryState, state)) return false;
94
+ return readFileSync(VERIFY_HISTORY_FILE, "utf8").split("\n").some((line) => {
95
+ if (!line.trim()) return false;
96
+ try {
97
+ const value = JSON.parse(line) as { command?: unknown; output?: unknown };
98
+ return typeof value.command !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.command) || typeof value.output !== "string" || !/^sha256:[0-9a-f]{64}$/.test(value.output);
99
+ } catch {
100
+ return true;
101
+ }
102
+ });
103
+ }
104
+
54
105
  export function persistVerifyHistory(verifyData: NonNullable<VerifyResult>): void {
55
106
  try {
56
107
  mkdirSync(AUDIT_DIR, { recursive: true });
57
- if (existsSync(VERIFY_HISTORY_FILE)) {
58
- const legacy = readFileSync(VERIFY_HISTORY_FILE, "utf8").split("\n").some((line) => {
59
- if (!line.trim()) return false;
60
- try {
61
- const value = JSON.parse(line) as { command?: unknown; output?: unknown };
62
- return typeof value.command !== "string" || !value.command.startsWith("sha256:") || typeof value.output !== "string" || !value.output.startsWith("sha256:");
63
- } catch {
64
- return true;
65
- }
66
- });
67
- if (legacy) writeFileSync(VERIFY_HISTORY_FILE, "", { encoding: "utf8", mode: 0o600 });
68
- }
69
- appendBoundedJsonl(VERIFY_HISTORY_FILE, {
70
- ...verifyData,
71
- command: `sha256:${createHash("sha256").update(verifyData.command).digest("hex")}`,
72
- output: `sha256:${createHash("sha256").update(verifyData.output).digest("hex")}`,
73
- }, MAX_VERIFY_HISTORY_BYTES, RETAIN_VERIFY_HISTORY_BYTES);
108
+ withFileLock(VERIFY_HISTORY_FILE, () => {
109
+ if (verifyHistoryNeedsSanitation()) writeFileSync(VERIFY_HISTORY_FILE, "", { encoding: "utf8", mode: 0o600 });
110
+ appendBoundedJsonlUnlocked(VERIFY_HISTORY_FILE, {
111
+ ...verifyData,
112
+ command: `sha256:${createHash("sha256").update(verifyData.command).digest("hex")}`,
113
+ output: `sha256:${createHash("sha256").update(verifyData.output).digest("hex")}`,
114
+ }, MAX_VERIFY_HISTORY_BYTES, RETAIN_VERIFY_HISTORY_BYTES);
115
+ const state = getVerifyHistoryState();
116
+ verifiedHistoryState = verifyHistoryNeedsSanitation() ? undefined : state;
117
+ });
74
118
  } catch {}
75
119
  }
76
120
 
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, resolve } from "node:path";
4
- import { spawnSync } from "node:child_process";
4
+ import { execFile, spawnSync } from "node:child_process";
5
5
  import { DatabaseSync } from "node:sqlite";
6
6
  import type { ProjectMemoryInput, ProjectMemoryKind, ProjectMemoryRecord, ProjectMemorySource, ReviewFollowup, ReviewFollowupInput } from "./types.ts";
7
7
 
@@ -45,6 +45,24 @@ export function isProjectMemoryFresh(memory: ProjectMemoryRecord, root: string):
45
45
  return untracked.status === 0 && !untracked.stdout.trim();
46
46
  }
47
47
 
48
+ function execGit(root: string, args: string[]): Promise<{ status: number; stdout: string }> {
49
+ return new Promise((resolve) => {
50
+ execFile("git", ["-C", root, ...args], { encoding: "utf8", timeout: 2_000 }, (error, stdout) => {
51
+ resolve({ status: error ? (typeof error.code === "number" ? error.code : -1) : 0, stdout });
52
+ });
53
+ });
54
+ }
55
+
56
+ export async function isProjectMemoryFreshAsync(memory: ProjectMemoryRecord, root: string): Promise<boolean> {
57
+ if (!memory.commit || memory.paths.length === 0) return true;
58
+ if (!/^[0-9a-f]{7,64}$/i.test(memory.commit)) return false;
59
+ const [diff, untracked] = await Promise.all([
60
+ execGit(root, ["diff", "--quiet", memory.commit, "--", ...memory.paths]),
61
+ execGit(root, ["ls-files", "--others", "--exclude-standard", "--", ...memory.paths]),
62
+ ]);
63
+ return diff.status === 0 && untracked.status === 0 && !untracked.stdout.trim();
64
+ }
65
+
48
66
  export function openProjectMemory(projectId: string, directory = getProjectMemoryDir()): ProjectMemoryStore {
49
67
  if (!projectId || projectId.length > 200) throw new Error("Invalid project memory identity.");
50
68
  mkdirSync(directory, { recursive: true, mode: 0o700 });
@@ -21,8 +21,11 @@ export interface ToolOutcome {
21
21
  repeatedFailureCount?: number;
22
22
  }
23
23
 
24
+ const MAX_TRACKED_CALLS = 4096;
25
+
24
26
  export class ToolOutcomeTracker {
25
27
  private readonly terminalCalls = new Map<string, Set<string>>();
28
+ private readonly fallbackCalls = new Map<string, Set<string>>();
26
29
  private readonly failures = new Map<string, { signature: string; count: number }>();
27
30
 
28
31
  record(part: ToolOutcomePart): ToolOutcome | undefined {
@@ -35,7 +38,13 @@ export class ToolOutcomeTracker {
35
38
  this.terminalCalls.set(part.sessionID, terminalCalls);
36
39
  }
37
40
  if (terminalCalls.has(part.callID)) return undefined;
41
+ const fallbackCalls = this.fallbackCalls.get(part.sessionID);
42
+ if (fallbackCalls?.delete(part.callID) && status === "completed") {
43
+ terminalCalls.add(part.callID);
44
+ return undefined;
45
+ }
38
46
  terminalCalls.add(part.callID);
47
+ if (terminalCalls.size > MAX_TRACKED_CALLS) terminalCalls.delete(terminalCalls.values().next().value!);
39
48
 
40
49
  const start = part.state?.time?.start;
41
50
  const end = part.state?.time?.end;
@@ -53,8 +62,21 @@ export class ToolOutcomeTracker {
53
62
  return { sessionID: part.sessionID, callID: part.callID, tool: part.tool, status, durationMs, repeatedFailureCount: count };
54
63
  }
55
64
 
65
+ recordFallbackCompleted(sessionID: string, callID: string, tool: string, durationMs?: number): ToolOutcome | undefined {
66
+ if (this.terminalCalls.get(sessionID)?.has(callID) || this.fallbackCalls.get(sessionID)?.has(callID)) return undefined;
67
+ let fallbackCalls = this.fallbackCalls.get(sessionID);
68
+ if (!fallbackCalls) {
69
+ fallbackCalls = new Set();
70
+ this.fallbackCalls.set(sessionID, fallbackCalls);
71
+ }
72
+ fallbackCalls.add(callID);
73
+ if (fallbackCalls.size > MAX_TRACKED_CALLS) fallbackCalls.delete(fallbackCalls.values().next().value!);
74
+ return { sessionID, callID, tool, status: "completed", durationMs };
75
+ }
76
+
56
77
  clearSession(sessionID: string): void {
57
78
  this.failures.delete(sessionID);
58
79
  this.terminalCalls.delete(sessionID);
80
+ this.fallbackCalls.delete(sessionID);
59
81
  }
60
82
  }
package/src/lib/types.ts CHANGED
@@ -12,7 +12,7 @@ export interface TodoSdkClient {
12
12
  get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown } }>;
13
13
  promptAsync?: (opts: {
14
14
  path: { id: string };
15
- body: { messageID?: string; parts: Array<{ type: "text"; text: string }> };
15
+ body: { messageID?: string; parts: Array<{ type: "text"; text: string; synthetic?: boolean }> };
16
16
  }) => Promise<unknown>;
17
17
  };
18
18
  tui?: {
@@ -48,6 +48,7 @@ export async function continueUnfinishedSession(sessionID: string): Promise<bool
48
48
  parts: [{
49
49
  type: "text",
50
50
  text: "Workflow Guard: unfinished todos remain. Continue working through them. If you need user input, use the question tool instead of ending the run.",
51
+ synthetic: true,
51
52
  }],
52
53
  },
53
54
  });
@@ -131,7 +131,7 @@ export {
131
131
  exportProjectKnowledge,
132
132
  importProjectKnowledge,
133
133
  } from "./lib/project-memory.ts";
134
- import { ensureProjectMemoryExcluded, getProjectMemoryIdentity, getRecentProjectMemory, importProjectKnowledge, isProjectMemoryFresh, listReviewFollowups, openProjectMemory } from "./lib/project-memory.ts";
134
+ import { ensureProjectMemoryExcluded, getProjectMemoryIdentity, getRecentProjectMemory, importProjectKnowledge, isProjectMemoryFreshAsync, listReviewFollowups, openProjectMemory } from "./lib/project-memory.ts";
135
135
 
136
136
  // ── Audit & durable verification cache ───────────────────────────────────────
137
137
  import {
@@ -416,15 +416,19 @@ export const WorkflowGuard: Plugin = async (ctx) => {
416
416
 
417
417
  "tool.execute.after": async (input, output) => {
418
418
  const startedAt = toolLifecycle.finish(input.sessionID, input.callID);
419
- audit({
420
- ts: new Date().toISOString(),
421
- sessionID: input.sessionID,
422
- callID: input.callID,
423
- tool: input.tool,
424
- decision: "allow",
425
- phase: "outcome",
426
- durationMs: startedAt === undefined ? undefined : Date.now() - startedAt,
427
- });
419
+ const outcome = toolOutcomes.recordFallbackCompleted(input.sessionID, input.callID, input.tool, startedAt === undefined ? undefined : Date.now() - startedAt);
420
+ if (outcome) {
421
+ audit({
422
+ ts: new Date().toISOString(),
423
+ sessionID: outcome.sessionID,
424
+ callID: outcome.callID,
425
+ tool: outcome.tool,
426
+ decision: "allow",
427
+ phase: "outcome",
428
+ durationMs: outcome.durationMs,
429
+ reason: outcome.status,
430
+ });
431
+ }
428
432
  releaseFileClaims(input.sessionID, input.callID);
429
433
  if (input.tool === "read") {
430
434
  const observation = toolLifecycle.takeReadObservation(input.sessionID, input.callID);
@@ -503,8 +507,10 @@ export const WorkflowGuard: Plugin = async (ctx) => {
503
507
  contextBlocks.push(stateLines.join("\n"));
504
508
  let projectKnowledge: ReturnType<typeof getRecentProjectMemory> = [];
505
509
  try {
506
- projectKnowledge = (projectMemory ? getRecentProjectMemory(projectMemory, 8) : [])
507
- .filter((memory) => memory.source !== "portable" && isProjectMemoryFresh(memory, effectiveRoot));
510
+ const candidates = (projectMemory ? getRecentProjectMemory(projectMemory, 8) : [])
511
+ .filter((memory) => memory.source !== "portable");
512
+ const freshness = await Promise.all(candidates.map((memory) => isProjectMemoryFreshAsync(memory, effectiveRoot)));
513
+ projectKnowledge = candidates.filter((_, index) => freshness[index]);
508
514
  } catch {}
509
515
  if (projectKnowledge.length > 0) {
510
516
  const lines = projectKnowledge.map((memory) => `- [${memory.kind}:${memory.id.slice(0, 8)}] ${memory.content.slice(0, 300)}`);