@mindfoldhq/trellis 0.5.12 → 0.5.14

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.
@@ -14,24 +14,39 @@ import { TrellisContext, debugLog } from "../lib/trellis-context.js"
14
14
  const AGENTS_ALL = ["implement", "check", "research"]
15
15
  const AGENTS_REQUIRE_TASK = ["implement", "check"]
16
16
 
17
+ // Match `Active task: <path>` on the first non-empty line of the dispatch
18
+ // prompt. Mirrors the contract in workflow.md's [workflow-state:in_progress]
19
+ // breadcrumb so multi-window users can disambiguate which task is targeted.
20
+ const ACTIVE_TASK_HINT_RE = /^\s*Active task:\s*(\S+)\s*$/m
21
+
22
+ function extractActiveTaskHint(prompt) {
23
+ if (typeof prompt !== "string" || !prompt) return null
24
+ const match = prompt.match(ACTIVE_TASK_HINT_RE)
25
+ return match ? match[1].trim() : null
26
+ }
27
+
17
28
  /**
18
- * Get context for implement agent
29
+ * Get context for implement agent. `taskDir` may be relative
30
+ * (`.trellis/tasks/foo`) or absolute; both are resolved via
31
+ * `ctx.resolveTaskDir`.
19
32
  */
20
33
  function getImplementContext(ctx, taskDir) {
21
34
  const parts = []
35
+ const taskDirFull = ctx.resolveTaskDir(taskDir)
36
+ if (!taskDirFull) return ""
22
37
 
23
- const jsonlPath = join(ctx.directory, taskDir, "implement.jsonl")
38
+ const jsonlPath = join(taskDirFull, "implement.jsonl")
24
39
  const entries = ctx.readJsonlWithFiles(jsonlPath)
25
40
  if (entries.length > 0) {
26
41
  parts.push(ctx.buildContextFromEntries(entries))
27
42
  }
28
43
 
29
- const prd = ctx.readProjectFile(join(taskDir, "prd.md"))
44
+ const prd = ctx.readFile(join(taskDirFull, "prd.md"))
30
45
  if (prd) {
31
46
  parts.push(`=== ${taskDir}/prd.md (Requirements) ===\n${prd}`)
32
47
  }
33
48
 
34
- const info = ctx.readProjectFile(join(taskDir, "info.md"))
49
+ const info = ctx.readFile(join(taskDirFull, "info.md"))
35
50
  if (info) {
36
51
  parts.push(`=== ${taskDir}/info.md (Technical Design) ===\n${info}`)
37
52
  }
@@ -40,18 +55,20 @@ function getImplementContext(ctx, taskDir) {
40
55
  }
41
56
 
42
57
  /**
43
- * Get context for check agent
58
+ * Get context for check agent. `taskDir` may be relative or absolute.
44
59
  */
45
60
  function getCheckContext(ctx, taskDir) {
46
61
  const parts = []
62
+ const taskDirFull = ctx.resolveTaskDir(taskDir)
63
+ if (!taskDirFull) return ""
47
64
 
48
- const jsonlPath = join(ctx.directory, taskDir, "check.jsonl")
65
+ const jsonlPath = join(taskDirFull, "check.jsonl")
49
66
  const entries = ctx.readJsonlWithFiles(jsonlPath)
50
67
  if (entries.length > 0) {
51
68
  parts.push(ctx.buildContextFromEntries(entries))
52
69
  }
53
70
 
54
- const prd = ctx.readProjectFile(join(taskDir, "prd.md"))
71
+ const prd = ctx.readFile(join(taskDirFull, "prd.md"))
55
72
  if (prd) {
56
73
  parts.push(`=== ${taskDir}/prd.md (Requirements) ===\n${prd}`)
57
74
  }
@@ -128,7 +145,8 @@ function getResearchContext(ctx) {
128
145
  */
129
146
  function buildPrompt(agentType, originalPrompt, context, isFinish = false) {
130
147
  const templates = {
131
- implement: `# Implement Agent Task
148
+ implement: `<!-- trellis-hook-injected -->
149
+ # Implement Agent Task
132
150
 
133
151
  You are the Implement Agent in the Multi-Agent Pipeline.
134
152
 
@@ -157,7 +175,8 @@ ${originalPrompt}
157
175
  - Follow all dev specs injected above
158
176
  - Report list of modified/created files when done`,
159
177
 
160
- check: isFinish ? `# Finish Agent Task
178
+ check: isFinish ? `<!-- trellis-hook-injected -->
179
+ # Finish Agent Task
161
180
 
162
181
  You are performing the final check before creating a PR.
163
182
 
@@ -191,7 +210,8 @@ ${originalPrompt}
191
210
  - Do NOT update specs for trivial changes (typos, formatting, obvious fixes)
192
211
  - If critical CODE issues found, report them clearly (fix specs, not code)
193
212
  - Verify all acceptance criteria in prd.md are met` :
194
- `# Check Agent Task
213
+ `<!-- trellis-hook-injected -->
214
+ # Check Agent Task
195
215
 
196
216
  You are the Check Agent in the Multi-Agent Pipeline.
197
217
 
@@ -219,7 +239,8 @@ ${originalPrompt}
219
239
  - Fix issues yourself, don't just report
220
240
  - Must execute complete checklist`,
221
241
 
222
- research: `# Research Agent Task
242
+ research: `<!-- trellis-hook-injected -->
243
+ # Research Agent Task
223
244
 
224
245
  You are the Research Agent in the Multi-Agent Pipeline.
225
246
 
@@ -264,9 +285,29 @@ function powershellQuote(value) {
264
285
  return `'${String(value).replace(/'/g, "''")}'`
265
286
  }
266
287
 
267
- function buildTrellisContextPrefix(contextKey, hostPlatform = process.platform) {
268
- if (hostPlatform === "win32") {
269
- // OpenCode's Windows Bash tool runs through PowerShell, not a POSIX shell.
288
+ function envValue(env, key) {
289
+ const value = env?.[key]
290
+ return typeof value === "string" && value.trim() ? value.trim() : null
291
+ }
292
+
293
+ function shellBasename(value) {
294
+ return value.replace(/\\/g, "/").split("/").pop()?.toLowerCase() || ""
295
+ }
296
+
297
+ function isWindowsPosixShell(env = process.env) {
298
+ if (envValue(env, "MSYSTEM")) return true
299
+ if (envValue(env, "MINGW_PREFIX")) return true
300
+ if (envValue(env, "OPENCODE_GIT_BASH_PATH")) return true
301
+
302
+ const ostype = envValue(env, "OSTYPE")?.toLowerCase() || ""
303
+ if (/(msys|mingw|cygwin)/.test(ostype)) return true
304
+
305
+ const shell = shellBasename(envValue(env, "SHELL") || "")
306
+ return /^(bash|sh|zsh)(\.exe)?$/.test(shell)
307
+ }
308
+
309
+ function buildTrellisContextPrefix(contextKey, hostPlatform = process.platform, env = process.env) {
310
+ if (hostPlatform === "win32" && !isWindowsPosixShell(env)) {
270
311
  return `$env:TRELLIS_CONTEXT_ID = ${powershellQuote(contextKey)}; `
271
312
  }
272
313
 
@@ -285,7 +326,7 @@ function commandStartsWithTrellisContext(command) {
285
326
  return (
286
327
  /^TRELLIS_CONTEXT_ID\s*=/.test(firstCommand) ||
287
328
  /^export\s+TRELLIS_CONTEXT_ID\s*=/.test(firstCommand) ||
288
- /^env\s+(?:[^\s=]+\s+)*TRELLIS_CONTEXT_ID\s*=/.test(firstCommand) ||
329
+ /^env\s+(?:(?:-\S+|[A-Za-z_][A-Za-z0-9_]*=\S*)\s+)*TRELLIS_CONTEXT_ID\s*=/.test(firstCommand) ||
289
330
  /^\$env:TRELLIS_CONTEXT_ID\s*=/i.test(firstCommand)
290
331
  )
291
332
  }
@@ -294,7 +335,7 @@ function commandStartsWithTrellisContext(command) {
294
335
  * OpenCode TUI may not expose OPENCODE_RUN_ID to Bash. The plugin hook still
295
336
  * receives session identity, so inject it into Bash commands before execution.
296
337
  */
297
- function injectTrellisContextIntoBash(ctx, input, output, hostPlatform) {
338
+ function injectTrellisContextIntoBash(ctx, input, output, hostPlatform, env) {
298
339
  const args = output?.args
299
340
  const commandKey = getBashCommandKey(args)
300
341
  if (!commandKey) return false
@@ -306,7 +347,7 @@ function injectTrellisContextIntoBash(ctx, input, output, hostPlatform) {
306
347
  const contextKey = ctx.getContextKey(input)
307
348
  if (!contextKey) return false
308
349
 
309
- args[commandKey] = `${buildTrellisContextPrefix(contextKey, hostPlatform)}${command}`
350
+ args[commandKey] = `${buildTrellisContextPrefix(contextKey, hostPlatform, env)}${command}`
310
351
  return true
311
352
  }
312
353
 
@@ -315,7 +356,7 @@ function injectTrellisContextIntoBash(ctx, input, output, hostPlatform) {
315
356
  // (packages/opencode/src/plugin/index.ts — `for ([_, fn] of Object.entries(mod)) await fn(input)`);
316
357
  // the previous `{ id, server }` object shape failed with
317
358
  // `TypeError: fn is not a function` in 1.2.x.
318
- export default async ({ directory, platform: hostPlatform = process.platform }) => {
359
+ export default async ({ directory, platform: hostPlatform = process.platform, env = process.env }) => {
319
360
  const ctx = new TrellisContext(directory)
320
361
  debugLog("inject", "Plugin loaded, directory:", directory)
321
362
 
@@ -329,7 +370,7 @@ export default async ({ directory, platform: hostPlatform = process.platform })
329
370
 
330
371
  const toolName = input?.tool?.toLowerCase()
331
372
  if (toolName === "bash") {
332
- if (injectTrellisContextIntoBash(ctx, input, output, hostPlatform)) {
373
+ if (injectTrellisContextIntoBash(ctx, input, output, hostPlatform, env)) {
333
374
  debugLog("inject", "Injected TRELLIS_CONTEXT_ID into Bash command")
334
375
  }
335
376
  return
@@ -354,8 +395,53 @@ export default async ({ directory, platform: hostPlatform = process.platform })
354
395
  return
355
396
  }
356
397
 
357
- // Resolve active task through session runtime context.
358
- const taskDir = ctx.getCurrentTask(input)
398
+ // Resolve active task in this priority order (only later steps
399
+ // run when earlier ones miss):
400
+ // 1. Exact session runtime context lookup for input.sessionID
401
+ // 2. `Active task: <path>` hint in the dispatch prompt
402
+ // (explicit per-dispatch override — beats single-session
403
+ // inference so multi-window users can disambiguate)
404
+ // 3. Single-session fallback — only when exactly 1 session
405
+ // runtime file exists locally
406
+ let taskDir = null
407
+ let taskSource = null
408
+
409
+ const contextKey = ctx.getContextKey(input)
410
+ if (contextKey) {
411
+ const context = ctx.readContext(contextKey)
412
+ const exactRef = ctx.normalizeTaskRef(context?.current_task || "")
413
+ if (exactRef) {
414
+ taskDir = exactRef
415
+ taskSource = `session:${contextKey}`
416
+ }
417
+ }
418
+
419
+ if (!taskDir) {
420
+ const hintRef = extractActiveTaskHint(originalPrompt)
421
+ if (hintRef) {
422
+ const hintNormalized = ctx.normalizeTaskRef(hintRef)
423
+ if (hintNormalized) {
424
+ const hintDir = ctx.resolveTaskDir(hintNormalized)
425
+ if (hintDir && existsSync(hintDir)) {
426
+ taskDir = hintNormalized
427
+ taskSource = "prompt-hint"
428
+ debugLog("inject", "Resolved task from Active task: hint:", hintNormalized)
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ if (!taskDir) {
435
+ const fallback = ctx._resolveSingleSessionFallback()
436
+ if (fallback?.taskPath) {
437
+ const fallbackDir = ctx.resolveTaskDir(fallback.taskPath)
438
+ if (fallbackDir && existsSync(fallbackDir)) {
439
+ taskDir = fallback.taskPath
440
+ taskSource = fallback.source
441
+ debugLog("inject", "Resolved task via single-session fallback:", taskDir, "source:", taskSource)
442
+ }
443
+ }
444
+ }
359
445
 
360
446
  // Agents requiring task directory
361
447
  if (AGENTS_REQUIRE_TASK.includes(subagentType)) {
@@ -364,8 +450,8 @@ export default async ({ directory, platform: hostPlatform = process.platform })
364
450
  debugLog("inject", "Skipping - no current task")
365
451
  return
366
452
  }
367
- const taskDirFull = join(directory, taskDir)
368
- if (!existsSync(taskDirFull)) {
453
+ const taskDirFull = ctx.resolveTaskDir(taskDir)
454
+ if (!taskDirFull || !existsSync(taskDirFull)) {
369
455
  debugLog("inject", "Skipping - task directory not found")
370
456
  return
371
457
  }
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { existsSync, readFileSync } from "fs"
27
27
  import { join } from "path"
28
- import { TrellisContext, debugLog } from "../lib/trellis-context.js"
28
+ import { TrellisContext, debugLog, isTrellisSubagent } from "../lib/trellis-context.js"
29
29
 
30
30
  // Supports STATUS values with letters, digits, underscores, hyphens
31
31
  // (so "in-review" / "blocked-by-team" work alongside "in_progress").
@@ -111,6 +111,13 @@ export default async ({ directory }) => {
111
111
  // so it persists in conversation history.
112
112
  "chat.message": async (input, output) => {
113
113
  try {
114
+ // Skip Trellis sub-agent turns — the per-turn breadcrumb is for the
115
+ // main session only; sub-agent context comes from the parent's
116
+ // tool.execute.before injection.
117
+ if (isTrellisSubagent(input)) {
118
+ debugLog("workflow-state", "Skipping trellis subagent turn:", input?.agent)
119
+ return
120
+ }
114
121
  if (process.env.TRELLIS_HOOKS === "0" || process.env.TRELLIS_DISABLE_HOOKS === "1") {
115
122
  return
116
123
  }
@@ -6,7 +6,7 @@
6
6
  * Uses OpenCode's chat.message hook directly so the context persists in history.
7
7
  */
8
8
 
9
- import { TrellisContext, contextCollector, debugLog } from "../lib/trellis-context.js"
9
+ import { TrellisContext, contextCollector, debugLog, isTrellisSubagent } from "../lib/trellis-context.js"
10
10
  import {
11
11
  buildSessionContext,
12
12
  hasPersistedInjectedContext,
@@ -43,6 +43,14 @@ export default async ({ directory, client }) => {
43
43
  const agent = input.agent || "unknown"
44
44
  debugLog("session", "chat.message called, sessionID:", sessionID, "agent:", agent)
45
45
 
46
+ // Skip Trellis sub-agent turns — sub-agent context is injected by
47
+ // `inject-subagent-context.js` on the parent's tool.execute.before;
48
+ // re-injecting the main-session SessionStart here would drown that.
49
+ if (isTrellisSubagent(input)) {
50
+ debugLog("session", "Skipping trellis subagent turn:", agent)
51
+ return
52
+ }
53
+
46
54
  if (process.env.TRELLIS_HOOKS === "0" || process.env.TRELLIS_DISABLE_HOOKS === "1") {
47
55
  debugLog("session", "Skipping - TRELLIS_HOOKS disabled")
48
56
  return
@@ -7,7 +7,7 @@
7
7
  {
8
8
  "type": "command",
9
9
  "command": "{{PYTHON_CMD}} .qoder/hooks/session-start.py",
10
- "timeout": 10
10
+ "timeout": 30
11
11
  }
12
12
  ]
13
13
  },
@@ -17,7 +17,7 @@
17
17
  {
18
18
  "type": "command",
19
19
  "command": "{{PYTHON_CMD}} .qoder/hooks/session-start.py",
20
- "timeout": 10
20
+ "timeout": 30
21
21
  }
22
22
  ]
23
23
  },
@@ -27,7 +27,7 @@
27
27
  {
28
28
  "type": "command",
29
29
  "command": "{{PYTHON_CMD}} .qoder/hooks/session-start.py",
30
- "timeout": 10
30
+ "timeout": 30
31
31
  }
32
32
  ]
33
33
  }
@@ -38,7 +38,7 @@
38
38
  {
39
39
  "type": "command",
40
40
  "command": "{{PYTHON_CMD}} .qoder/hooks/inject-workflow-state.py",
41
- "timeout": 5
41
+ "timeout": 15
42
42
  }
43
43
  ]
44
44
  }
@@ -111,31 +111,61 @@ def safe_trellis_paths_to_add(repo_root: Path) -> list[str]:
111
111
  return paths
112
112
 
113
113
 
114
- def safe_archive_paths_to_add(repo_root: Path) -> list[str]:
114
+ def safe_archive_paths_to_add(
115
+ repo_root: Path,
116
+ task_name: str | None = None,
117
+ modified_children: list[str] | None = None,
118
+ ) -> list[str]:
115
119
  """Return paths to stage after `task.py archive`.
116
120
 
117
- Limited to the archive subtree (where the freshly-moved task lives) plus
118
- the source task directory's parent area to capture the deletion in the
119
- same commit. We pass the whole `.trellis/tasks/` path so deletions of the
120
- pre-move path are tracked, but only as a SPECIFIC subpath — not the whole
121
- `.trellis/` tree.
121
+ Scoped to ONLY the paths the archive operation actually touched:
122
+
123
+ - the archive subtree (where the freshly-moved task lives)
124
+ - the source task directory (for source-side deletes; caller pairs
125
+ this with `git rm --cached` since `git add` won't stage deletes
126
+ for a path that no longer exists in the working tree)
127
+ - any child task directories whose `task.json` was edited to drop
128
+ the archived parent (parent-children relationship update)
129
+
130
+ This narrow scope avoids "scope creep" — dirty changes in OTHER
131
+ active task dirs (parallel-window edits) are NOT bundled into the
132
+ archive commit. Callers handle each kind of change in its own
133
+ commit boundary.
134
+
135
+ Backwards-compat: with no arguments, the function walks the whole
136
+ `.trellis/tasks/` subtree the old way (active tasks + archive). New
137
+ callers should always pass `task_name`.
122
138
  """
123
139
  paths: list[str] = []
124
140
  tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
125
- if tasks_dir.is_dir():
126
- # The archive copy.
127
- archive_dir = tasks_dir / DIR_ARCHIVE
141
+ if not tasks_dir.is_dir():
142
+ return paths
143
+
144
+ archive_dir = tasks_dir / DIR_ARCHIVE
145
+
146
+ if task_name is not None:
147
+ # Narrow scope — only paths that still exist on disk (so
148
+ # `git add` doesn't choke on the moved-away source). The caller
149
+ # handles the source-side deletes via `git rm --cached`
150
+ # explicitly.
128
151
  if archive_dir.is_dir():
129
- paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
130
- # Active tasks (some may have been re-touched, e.g. parent's
131
- # children list). This captures the source-path deletion too because
132
- # `git add` on a directory records removals.
133
- for child in sorted(tasks_dir.iterdir()):
134
- if not child.is_dir():
135
- continue
136
- if child.name == DIR_ARCHIVE:
137
- continue
138
- paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
152
+ paths.append(
153
+ f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}"
154
+ )
155
+ for child_name in modified_children or []:
156
+ paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}")
157
+ return paths
158
+
159
+ # Legacy wide scope (no task_name): preserve old behavior so callers
160
+ # that have not been updated keep working.
161
+ if archive_dir.is_dir():
162
+ paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
163
+ for child in sorted(tasks_dir.iterdir()):
164
+ if not child.is_dir():
165
+ continue
166
+ if child.name == DIR_ARCHIVE:
167
+ continue
168
+ paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
139
169
  return paths
140
170
 
141
171