@mindfoldhq/trellis 0.4.0-beta.9 → 0.4.0-rc.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.
@@ -265,17 +265,18 @@ def main():
265
265
  sys.exit(0)
266
266
 
267
267
  # Get subagent info
268
- subagent_type = input_data.get("subagent_type", "")
269
- agent_output = input_data.get("agent_output", "")
270
- original_prompt = input_data.get("prompt", "")
268
+ # Field names per Claude Code SubagentStop event schema:
269
+ # agent_type, last_assistant_message, agent_id, agent_transcript_path, cwd
270
+ # The event does NOT carry a `prompt` field, so finish-phase detection
271
+ # based on a `[finish]` marker in the user prompt is no longer possible
272
+ # here; finish-phase skip logic should be reintroduced via task.json
273
+ # state (e.g. current_phase) in a follow-up.
274
+ agent_type = input_data.get("agent_type", "")
275
+ last_assistant_message = input_data.get("last_assistant_message", "")
271
276
  cwd = input_data.get("cwd", os.getcwd())
272
277
 
273
278
  # Only control check agent
274
- if subagent_type != TARGET_AGENT:
275
- sys.exit(0)
276
-
277
- # Skip Ralph Loop for finish phase (already verified in check phase)
278
- if "[finish]" in original_prompt.lower():
279
+ if agent_type != TARGET_AGENT:
279
280
  sys.exit(0)
280
281
 
281
282
  # Find repo root
@@ -357,7 +358,7 @@ def main():
357
358
  else:
358
359
  # No verify commands, fall back to completion markers
359
360
  markers = get_completion_markers(repo_root, task_dir)
360
- all_complete, missing = check_completion(agent_output, markers)
361
+ all_complete, missing = check_completion(last_assistant_message, markers)
361
362
 
362
363
  if all_complete:
363
364
  # All checks complete, allow stop
@@ -288,13 +288,38 @@ def _resolve_spec_scope(
288
288
  return None # Unknown scope type: full scan
289
289
 
290
290
 
291
+ def _build_workflow_toc(workflow_path: Path) -> str:
292
+ """Build a compact section index for workflow.md (lazy-load the full file on demand).
293
+
294
+ Replaces full-file injection to keep additionalContext payload small.
295
+ The full file is accessible via: Read tool on .trellis/workflow.md
296
+ """
297
+ content = read_file(workflow_path)
298
+ if not content:
299
+ return "No workflow.md found"
300
+
301
+ toc_lines = [
302
+ "# Development Workflow — Section Index",
303
+ "Full guide: .trellis/workflow.md (read on demand)",
304
+ "",
305
+ ]
306
+ for line in content.splitlines():
307
+ if line.startswith("## "):
308
+ toc_lines.append(line)
309
+
310
+ toc_lines += [
311
+ "",
312
+ "To read a section: use the Read tool on .trellis/workflow.md",
313
+ ]
314
+ return "\n".join(toc_lines)
315
+
316
+
291
317
  def main():
292
318
  if should_skip_injection():
293
319
  sys.exit(0)
294
320
 
295
321
  project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR", ".")).resolve()
296
322
  trellis_dir = project_dir / ".trellis"
297
- claude_dir = project_dir / ".claude"
298
323
 
299
324
  # Load config for scope filtering and legacy detection
300
325
  is_mono, packages, scope_config, task_pkg, default_pkg = _load_trellis_config(trellis_dir)
@@ -320,8 +345,7 @@ Read and follow all instructions below carefully.
320
345
  output.write("\n</current-state>\n\n")
321
346
 
322
347
  output.write("<workflow>\n")
323
- workflow_content = read_file(trellis_dir / "workflow.md", "No workflow.md found")
324
- output.write(workflow_content)
348
+ output.write(_build_workflow_toc(trellis_dir / "workflow.md"))
325
349
  output.write("\n</workflow>\n\n")
326
350
 
327
351
  output.write("<guidelines>\n")
@@ -365,20 +389,13 @@ Read and follow all instructions below carefully.
365
389
 
366
390
  output.write("</guidelines>\n\n")
367
391
 
368
- output.write("<instructions>\n")
369
- start_md = read_file(
370
- claude_dir / "commands" / "trellis" / "start.md", "No start.md found"
371
- )
372
- output.write(start_md)
373
- output.write("\n</instructions>\n\n")
374
-
375
392
  # Check task status and inject structured tag
376
393
  task_status = _get_task_status(trellis_dir)
377
394
  output.write(f"<task-status>\n{task_status}\n</task-status>\n\n")
378
395
 
379
396
  output.write("""<ready>
380
- Context loaded. Steps 1-3 (workflow, context, guidelines) are already injected above — do NOT re-read them.
381
- Start from Step 4. Wait for user's first message, then follow <instructions> to handle their request.
397
+ Context loaded. Workflow index, project state, and guidelines are already injected above — do NOT re-read them.
398
+ Wait for the user's first message, then handle it following the workflow guide.
382
399
  If there is an active task, ask whether to continue it.
383
400
  </ready>""")
384
401
 
@@ -11,12 +11,19 @@ Info line: model · ctx% · branch · duration · developer · tasks · rate lim
11
11
  """
12
12
  from __future__ import annotations
13
13
 
14
+ import io
14
15
  import json
15
16
  import re
16
17
  import subprocess
17
18
  import sys
18
19
  from pathlib import Path
19
20
 
21
+ # Fix: Windows Python defaults to GBK encoding, which corrupts UTF-8
22
+ # characters like the middle dot (·). Wrap stdout/stderr with UTF-8.
23
+ if sys.platform == "win32":
24
+ sys.stdout = io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8")
25
+ sys.stderr = io.TextIOWrapper(sys.stderr.detach(), encoding="utf-8")
26
+
20
27
 
21
28
  def _read_text(path: Path) -> str:
22
29
  try:
@@ -125,6 +125,32 @@ def _get_task_status(trellis_dir: Path) -> str:
125
125
  return f"Status: READY\nTask: {task_title}\nNext: Continue with implement or check"
126
126
 
127
127
 
128
+ def _build_workflow_toc(workflow_path: Path) -> str:
129
+ """Build a compact section index for workflow.md (lazy-load the full file on demand).
130
+
131
+ Replaces full-file injection to keep additionalContext payload small.
132
+ The full file is accessible via: Read tool on .trellis/workflow.md
133
+ """
134
+ content = read_file(workflow_path)
135
+ if not content:
136
+ return "No workflow.md found"
137
+
138
+ toc_lines = [
139
+ "# Development Workflow — Section Index",
140
+ "Full guide: .trellis/workflow.md (read on demand)",
141
+ "",
142
+ ]
143
+ for line in content.splitlines():
144
+ if line.startswith("## "):
145
+ toc_lines.append(line)
146
+
147
+ toc_lines += [
148
+ "",
149
+ "To read a section: use the Read tool on .trellis/workflow.md",
150
+ ]
151
+ return "\n".join(toc_lines)
152
+
153
+
128
154
  def main() -> None:
129
155
  if should_skip_injection():
130
156
  sys.exit(0)
@@ -137,7 +163,6 @@ def main() -> None:
137
163
  project_dir = Path(".").resolve()
138
164
 
139
165
  trellis_dir = project_dir / ".trellis"
140
- codex_dir = project_dir / ".codex"
141
166
 
142
167
  output = StringIO()
143
168
 
@@ -154,8 +179,7 @@ Read and follow all instructions below carefully.
154
179
  output.write("\n</current-state>\n\n")
155
180
 
156
181
  output.write("<workflow>\n")
157
- workflow_content = read_file(trellis_dir / "workflow.md", "No workflow.md found")
158
- output.write(workflow_content)
182
+ output.write(_build_workflow_toc(trellis_dir / "workflow.md"))
159
183
  output.write("\n</workflow>\n\n")
160
184
 
161
185
  output.write("<guidelines>\n")
@@ -193,21 +217,12 @@ Read and follow all instructions below carefully.
193
217
 
194
218
  output.write("</guidelines>\n\n")
195
219
 
196
- # Inject start skill as instructions (Codex uses skills, not slash commands)
197
- start_skill = codex_dir / "skills" / "start" / "SKILL.md"
198
- if not start_skill.is_file():
199
- start_skill = project_dir / ".agents" / "skills" / "start" / "SKILL.md"
200
- if start_skill.is_file():
201
- output.write("<instructions>\n")
202
- output.write(read_file(start_skill))
203
- output.write("\n</instructions>\n\n")
204
-
205
220
  task_status = _get_task_status(trellis_dir)
206
221
  output.write(f"<task-status>\n{task_status}\n</task-status>\n\n")
207
222
 
208
223
  output.write("""<ready>
209
- Context loaded. Steps 1-3 (workflow, context, guidelines) are already injected above — do NOT re-read them.
210
- Start from Step 4. Wait for user's first message, then follow <instructions> to handle their request.
224
+ Context loaded. Workflow index, project state, and guidelines are already injected above — do NOT re-read them.
225
+ Wait for the user's first message, then handle it following the workflow guide.
211
226
  If there is an active task, ask whether to continue it.
212
227
  </ready>""")
213
228
 
@@ -125,6 +125,32 @@ def _get_task_status(trellis_dir: Path) -> str:
125
125
  return f"Status: READY\nTask: {task_title}\nNext: Continue with implement or check"
126
126
 
127
127
 
128
+ def _build_workflow_toc(workflow_path: Path) -> str:
129
+ """Build a compact section index for workflow.md (lazy-load the full file on demand).
130
+
131
+ Replaces full-file injection to keep additionalContext payload small.
132
+ The full file is accessible via: Read tool on .trellis/workflow.md
133
+ """
134
+ content = read_file(workflow_path)
135
+ if not content:
136
+ return "No workflow.md found"
137
+
138
+ toc_lines = [
139
+ "# Development Workflow — Section Index",
140
+ "Full guide: .trellis/workflow.md (read on demand)",
141
+ "",
142
+ ]
143
+ for line in content.splitlines():
144
+ if line.startswith("## "):
145
+ toc_lines.append(line)
146
+
147
+ toc_lines += [
148
+ "",
149
+ "To read a section: use the Read tool on .trellis/workflow.md",
150
+ ]
151
+ return "\n".join(toc_lines)
152
+
153
+
128
154
  def main() -> None:
129
155
  if should_skip_injection():
130
156
  sys.exit(0)
@@ -153,8 +179,7 @@ Read and follow all instructions below carefully.
153
179
  output.write("\n</current-state>\n\n")
154
180
 
155
181
  output.write("<workflow>\n")
156
- workflow_content = read_file(trellis_dir / "workflow.md", "No workflow.md found")
157
- output.write(workflow_content)
182
+ output.write(_build_workflow_toc(trellis_dir / "workflow.md"))
158
183
  output.write("\n</workflow>\n\n")
159
184
 
160
185
  output.write("<guidelines>\n")
@@ -196,8 +221,8 @@ Read and follow all instructions below carefully.
196
221
  output.write(f"<task-status>\n{task_status}\n</task-status>\n\n")
197
222
 
198
223
  output.write("""<ready>
199
- Context loaded. Steps 1-3 (workflow, context, guidelines) are already injected above — do NOT re-read them.
200
- Start from Step 4. Wait for user's first message, then follow the workflow to handle their request.
224
+ Context loaded. Workflow index, project state, and guidelines are already injected above — do NOT re-read them.
225
+ Wait for the user's first message, then handle it following the workflow guide.
201
226
  If there is an active task, ask whether to continue it.
202
227
  </ready>""")
203
228
 
@@ -278,6 +278,32 @@ def _resolve_spec_scope(
278
278
  return None
279
279
 
280
280
 
281
+ def _build_workflow_toc(workflow_path: Path) -> str:
282
+ """Build a compact section index for workflow.md (lazy-load the full file on demand).
283
+
284
+ Replaces full-file injection to keep additionalContext payload small.
285
+ The full file is accessible via: Read tool on .trellis/workflow.md
286
+ """
287
+ content = read_file(workflow_path)
288
+ if not content:
289
+ return "No workflow.md found"
290
+
291
+ toc_lines = [
292
+ "# Development Workflow — Section Index",
293
+ "Full guide: .trellis/workflow.md (read on demand)",
294
+ "",
295
+ ]
296
+ for line in content.splitlines():
297
+ if line.startswith("## "):
298
+ toc_lines.append(line)
299
+
300
+ toc_lines += [
301
+ "",
302
+ "To read a section: use the Read tool on .trellis/workflow.md",
303
+ ]
304
+ return "\n".join(toc_lines)
305
+
306
+
281
307
  def main():
282
308
  if should_skip_injection():
283
309
  sys.exit(0)
@@ -285,7 +311,6 @@ def main():
285
311
  # iFlow don't have an env for project, use `.` instead
286
312
  project_dir = Path(".").resolve()
287
313
  trellis_dir = project_dir / ".trellis"
288
- iflow_dir = project_dir / ".iflow"
289
314
 
290
315
  # Load config for scope filtering and legacy detection
291
316
  is_mono, packages, scope_config, task_pkg, default_pkg = _load_trellis_config(trellis_dir)
@@ -311,8 +336,7 @@ Read and follow all instructions below carefully.
311
336
  output.write("\n</current-state>\n\n")
312
337
 
313
338
  output.write("<workflow>\n")
314
- workflow_content = read_file(trellis_dir / "workflow.md", "No workflow.md found")
315
- output.write(workflow_content)
339
+ output.write(_build_workflow_toc(trellis_dir / "workflow.md"))
316
340
  output.write("\n</workflow>\n\n")
317
341
 
318
342
  output.write("<guidelines>\n")
@@ -354,20 +378,13 @@ Read and follow all instructions below carefully.
354
378
 
355
379
  output.write("</guidelines>\n\n")
356
380
 
357
- output.write("<instructions>\n")
358
- start_md = read_file(
359
- iflow_dir / "commands" / "trellis" / "start.md", "No start.md found"
360
- )
361
- output.write(start_md)
362
- output.write("\n</instructions>\n\n")
363
-
364
381
  # R2: Check task status and inject structured tag
365
382
  task_status = _get_task_status(trellis_dir)
366
383
  output.write(f"<task-status>\n{task_status}\n</task-status>\n\n")
367
384
 
368
385
  output.write("""<ready>
369
- Context loaded. Steps 1-3 (workflow, context, guidelines) are already injected above — do NOT re-read them.
370
- Start from Step 4. Wait for user's first message, then follow <instructions> to handle their request.
386
+ Context loaded. Workflow index, project state, and guidelines are already injected above — do NOT re-read them.
387
+ Wait for the user's first message, then handle it following the workflow guide.
371
388
  If there is an active task, ask whether to continue it.
372
389
  </ready>""")
373
390
 
@@ -1,23 +1,16 @@
1
1
  /**
2
2
  * Trellis Context Manager
3
3
  *
4
- * Unified context management for OpenCode plugins.
5
- * Handles detection of oh-my-opencode, .claude/hooks/, and other edge cases.
6
- *
7
- * Usage:
8
- * import { TrellisContext } from "./trellis-context.js"
9
- * const ctx = new TrellisContext(directory)
10
- * if (ctx.shouldSkipHook("session-start")) return
4
+ * Utility class for OpenCode plugins providing file reading,
5
+ * JSONL parsing, and context building capabilities.
11
6
  */
12
7
 
13
8
  import { existsSync, readFileSync, appendFileSync, readdirSync } from "fs"
14
9
  import { isAbsolute, join } from "path"
15
- import { homedir, platform } from "os"
10
+ import { platform } from "os"
16
11
  import { execSync } from "child_process"
17
12
 
18
- // Python command: Windows uses 'python', macOS/Linux use 'python3'
19
13
  const PYTHON_CMD = platform() === "win32" ? "python" : "python3"
20
-
21
14
  // Debug logging
22
15
  const DEBUG_LOG = "/tmp/trellis-plugin-debug.log"
23
16
 
@@ -33,151 +26,17 @@ function debugLog(prefix, ...args) {
33
26
 
34
27
  /**
35
28
  * Trellis Context Manager
36
- *
37
- * Centralized logic for:
38
- * - Detecting oh-my-opencode installation
39
- * - Checking .claude/hooks/ presence
40
- * - Determining which plugin should handle each hook
41
29
  */
42
30
  export class TrellisContext {
43
31
  constructor(directory) {
44
32
  this.directory = directory
45
- this._omoInstalled = null
46
- this._omoHooksEnabled = null
47
- this._claudeHooksPresent = {}
48
-
49
33
  debugLog("context", "TrellisContext initialized", { directory })
50
34
  }
51
35
 
52
- // ============================================================
53
- // oh-my-opencode Detection
54
- // ============================================================
55
-
56
- /**
57
- * Check if oh-my-opencode is installed
58
- *
59
- * Detection order:
60
- * 1. Check if oh-my-opencode.json exists (most reliable)
61
- * 2. Fallback: check opencode.json plugin list
62
- */
63
- isOmoInstalled() {
64
- if (this._omoInstalled !== null) {
65
- return this._omoInstalled
66
- }
67
-
68
- try {
69
- const configDir = join(homedir(), ".config", "opencode")
70
-
71
- // Method 1: Check oh-my-opencode.json existence (omo-specific config)
72
- const omoConfigPath = join(configDir, "oh-my-opencode.json")
73
- if (existsSync(omoConfigPath)) {
74
- this._omoInstalled = true
75
- debugLog("context", "omo installed: oh-my-opencode.json exists")
76
- return true
77
- }
78
-
79
- // Method 2: Fallback to plugin list check
80
- const configPath = join(configDir, "opencode.json")
81
- if (!existsSync(configPath)) {
82
- this._omoInstalled = false
83
- debugLog("context", "omo not installed: no config files")
84
- return false
85
- }
86
-
87
- const content = readFileSync(configPath, "utf-8")
88
- const config = JSON.parse(content)
89
- const plugins = config.plugin || []
90
-
91
- this._omoInstalled = plugins.some(p =>
92
- typeof p === "string" && p.toLowerCase().includes("oh-my-opencode")
93
- )
94
-
95
- debugLog("context", "omo installed (plugin list):", this._omoInstalled)
96
- return this._omoInstalled
97
- } catch (e) {
98
- debugLog("context", "omo detection error:", e.message)
99
- this._omoInstalled = false
100
- return false
101
- }
102
- }
103
-
104
- /**
105
- * Check if omo's claude_code.hooks is enabled
106
- * Reads oh-my-opencode.json or defaults to true
107
- */
108
- isOmoHooksEnabled() {
109
- if (this._omoHooksEnabled !== null) {
110
- return this._omoHooksEnabled
111
- }
112
-
113
- if (!this.isOmoInstalled()) {
114
- this._omoHooksEnabled = false
115
- return false
116
- }
117
-
118
- try {
119
- // Check global config
120
- const globalConfig = join(homedir(), ".config", "opencode", "oh-my-opencode.json")
121
- if (existsSync(globalConfig)) {
122
- const content = readFileSync(globalConfig, "utf-8")
123
- const config = JSON.parse(content)
124
- if (config.claude_code?.hooks === false) {
125
- this._omoHooksEnabled = false
126
- debugLog("context", "omo hooks disabled in global config")
127
- return false
128
- }
129
- }
130
-
131
- // Check project config
132
- const projectConfig = join(this.directory, "oh-my-opencode.json")
133
- if (existsSync(projectConfig)) {
134
- const content = readFileSync(projectConfig, "utf-8")
135
- const config = JSON.parse(content)
136
- if (config.claude_code?.hooks === false) {
137
- this._omoHooksEnabled = false
138
- debugLog("context", "omo hooks disabled in project config")
139
- return false
140
- }
141
- }
142
-
143
- // Default: enabled
144
- this._omoHooksEnabled = true
145
- debugLog("context", "omo hooks enabled (default)")
146
- return true
147
- } catch (e) {
148
- debugLog("context", "omo hooks detection error:", e.message)
149
- this._omoHooksEnabled = true // Default to enabled
150
- return true
151
- }
152
- }
153
-
154
- // ============================================================
155
- // .claude/hooks/ Detection
156
- // ============================================================
157
-
158
- /**
159
- * Check if a specific .claude/hooks/ file exists
160
- */
161
- hasClaudeHook(hookName) {
162
- if (hookName in this._claudeHooksPresent) {
163
- return this._claudeHooksPresent[hookName]
164
- }
165
-
166
- const hookPath = join(this.directory, ".claude", "hooks", `${hookName}.py`)
167
- const exists = existsSync(hookPath)
168
-
169
- this._claudeHooksPresent[hookName] = exists
170
- debugLog("context", `claude hook ${hookName}:`, exists)
171
- return exists
172
- }
173
-
174
36
  // ============================================================
175
37
  // Trellis Project Detection
176
38
  // ============================================================
177
39
 
178
- /**
179
- * Check if this is a Trellis-managed project
180
- */
181
40
  isTrellisProject() {
182
41
  return existsSync(join(this.directory, ".trellis"))
183
42
  }
@@ -237,54 +96,10 @@ export class TrellisContext {
237
96
  return join(this.directory, ".trellis", "tasks", normalized)
238
97
  }
239
98
 
240
- // ============================================================
241
- // Hook Decision Logic
242
- // ============================================================
243
-
244
- /**
245
- * Determine if our plugin should skip this hook
246
- * (because omo will handle it via .claude/hooks/)
247
- *
248
- * @param {string} hookName - Hook name without extension (e.g., "session-start")
249
- * @returns {boolean} - true if we should skip, false if we should handle
250
- */
251
- shouldSkipHook(hookName) {
252
- // Not a Trellis project? Skip.
253
- if (!this.isTrellisProject()) {
254
- debugLog("context", `shouldSkipHook(${hookName}): skip - not Trellis project`)
255
- return true
256
- }
257
-
258
- // omo not installed? We handle it.
259
- if (!this.isOmoInstalled()) {
260
- debugLog("context", `shouldSkipHook(${hookName}): handle - omo not installed`)
261
- return false
262
- }
263
-
264
- // omo installed but hooks disabled? We handle it.
265
- if (!this.isOmoHooksEnabled()) {
266
- debugLog("context", `shouldSkipHook(${hookName}): handle - omo hooks disabled`)
267
- return false
268
- }
269
-
270
- // omo installed + hooks enabled + .claude/hooks/ exists? Skip (omo handles).
271
- if (this.hasClaudeHook(hookName)) {
272
- debugLog("context", `shouldSkipHook(${hookName}): skip - omo will handle via .claude/hooks/`)
273
- return true
274
- }
275
-
276
- // omo installed but no .claude/hooks/ file? We handle it.
277
- debugLog("context", `shouldSkipHook(${hookName}): handle - no .claude/hooks/ file`)
278
- return false
279
- }
280
-
281
99
  // ============================================================
282
100
  // File Reading Utilities
283
101
  // ============================================================
284
102
 
285
- /**
286
- * Read a file, return null on error
287
- */
288
103
  readFile(filePath) {
289
104
  try {
290
105
  if (existsSync(filePath)) {
@@ -296,16 +111,10 @@ export class TrellisContext {
296
111
  return null
297
112
  }
298
113
 
299
- /**
300
- * Read a file relative to project directory
301
- */
302
114
  readProjectFile(relativePath) {
303
115
  return this.readFile(join(this.directory, relativePath))
304
116
  }
305
117
 
306
- /**
307
- * Run a Python script and return output
308
- */
309
118
  runScript(scriptPath, cwd = null) {
310
119
  try {
311
120
  const result = execSync(`${PYTHON_CMD} "${scriptPath}"`, {
@@ -324,12 +133,6 @@ export class TrellisContext {
324
133
  // JSONL Reading
325
134
  // ============================================================
326
135
 
327
- /**
328
- * Read all .md files in a directory
329
- * @param {string} dirPath - Directory path relative to project root
330
- * @param {number} maxFiles - Max files to read (prevent huge directories)
331
- * @returns {Array<{path: string, content: string}>}
332
- */
333
136
  readDirectoryMdFiles(dirPath, maxFiles = 20) {
334
137
  const results = []
335
138
  const fullPath = join(this.directory, dirPath)
@@ -379,11 +182,9 @@ export class TrellisContext {
379
182
  if (!file) continue
380
183
 
381
184
  if (entryType === "directory") {
382
- // Read all .md files in directory
383
185
  const dirEntries = this.readDirectoryMdFiles(file)
384
186
  results.push(...dirEntries)
385
187
  } else {
386
- // Read single file
387
188
  const fullPath = join(this.directory, file)
388
189
  const fileContent = this.readFile(fullPath)
389
190
  if (fileContent) {
@@ -397,74 +198,29 @@ export class TrellisContext {
397
198
  return results
398
199
  }
399
200
 
400
- /**
401
- * Build context string from file entries
402
- */
403
201
  buildContextFromEntries(entries) {
404
202
  return entries.map(e => `=== ${e.path} ===\n${e.content}`).join("\n\n")
405
203
  }
406
204
  }
407
205
 
408
206
  // ============================================================
409
- // Context Collector (for synthetic message injection)
207
+ // Context Collector (for session deduplication)
410
208
  // ============================================================
411
209
 
412
- /**
413
- * Simple context collector for cross-hook communication
414
- * Similar to oh-my-opencode's contextCollector
415
- */
416
210
  class ContextCollector {
417
211
  constructor() {
418
- this.pending = new Map()
419
212
  this.processed = new Set()
420
213
  }
421
214
 
422
- /**
423
- * Store context for a session
424
- */
425
- store(sessionID, content) {
426
- this.pending.set(sessionID, {
427
- content,
428
- timestamp: Date.now()
429
- })
430
- debugLog("collector", "stored context for session:", sessionID, "length:", content.length)
431
- }
432
-
433
- /**
434
- * Check if session has pending context
435
- */
436
- hasPending(sessionID) {
437
- return this.pending.has(sessionID)
438
- }
439
-
440
- /**
441
- * Get and consume pending context
442
- */
443
- consume(sessionID) {
444
- const pending = this.pending.get(sessionID)
445
- this.pending.delete(sessionID)
446
- return pending
447
- }
448
-
449
- /**
450
- * Mark session as processed (for first-message-only injection)
451
- */
452
215
  markProcessed(sessionID) {
453
216
  this.processed.add(sessionID)
454
217
  }
455
218
 
456
- /**
457
- * Check if session was already processed
458
- */
459
219
  isProcessed(sessionID) {
460
220
  return this.processed.has(sessionID)
461
221
  }
462
222
 
463
- /**
464
- * Clear session state
465
- */
466
223
  clear(sessionID) {
467
- this.pending.delete(sessionID)
468
224
  this.processed.delete(sessionID)
469
225
  }
470
226
  }