@mindfoldhq/trellis 0.6.7 → 0.6.9

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.
Files changed (174) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +38 -0
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/commands/channel/adapters/codex.d.ts +7 -1
  6. package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
  7. package/dist/commands/channel/adapters/codex.js +19 -2
  8. package/dist/commands/channel/adapters/codex.js.map +1 -1
  9. package/dist/commands/channel/adapters/index.d.ts +3 -1
  10. package/dist/commands/channel/adapters/index.d.ts.map +1 -1
  11. package/dist/commands/channel/adapters/index.js +1 -1
  12. package/dist/commands/channel/adapters/index.js.map +1 -1
  13. package/dist/commands/channel/agent-loader.d.ts +2 -2
  14. package/dist/commands/channel/agent-loader.d.ts.map +1 -1
  15. package/dist/commands/channel/agent-loader.js +9 -5
  16. package/dist/commands/channel/agent-loader.js.map +1 -1
  17. package/dist/commands/channel/context-loader.d.ts +1 -1
  18. package/dist/commands/channel/context-loader.d.ts.map +1 -1
  19. package/dist/commands/channel/context-loader.js +36 -15
  20. package/dist/commands/channel/context-loader.js.map +1 -1
  21. package/dist/commands/channel/context-trust.d.ts +31 -0
  22. package/dist/commands/channel/context-trust.d.ts.map +1 -0
  23. package/dist/commands/channel/context-trust.js +139 -0
  24. package/dist/commands/channel/context-trust.js.map +1 -0
  25. package/dist/commands/channel/index.d.ts.map +1 -1
  26. package/dist/commands/channel/index.js +4 -0
  27. package/dist/commands/channel/index.js.map +1 -1
  28. package/dist/commands/channel/spawn.d.ts +3 -0
  29. package/dist/commands/channel/spawn.d.ts.map +1 -1
  30. package/dist/commands/channel/spawn.js +5 -2
  31. package/dist/commands/channel/spawn.js.map +1 -1
  32. package/dist/commands/channel/supervisor.d.ts +3 -0
  33. package/dist/commands/channel/supervisor.d.ts.map +1 -1
  34. package/dist/commands/channel/supervisor.js +1 -0
  35. package/dist/commands/channel/supervisor.js.map +1 -1
  36. package/dist/commands/init.d.ts +3 -0
  37. package/dist/commands/init.d.ts.map +1 -1
  38. package/dist/commands/init.js.map +1 -1
  39. package/dist/commands/mem.d.ts.map +1 -1
  40. package/dist/commands/mem.js +30 -9
  41. package/dist/commands/mem.js.map +1 -1
  42. package/dist/commands/update.d.ts +12 -1
  43. package/dist/commands/update.d.ts.map +1 -1
  44. package/dist/commands/update.js +75 -7
  45. package/dist/commands/update.js.map +1 -1
  46. package/dist/configurators/codex.d.ts +36 -0
  47. package/dist/configurators/codex.d.ts.map +1 -1
  48. package/dist/configurators/codex.js +111 -13
  49. package/dist/configurators/codex.js.map +1 -1
  50. package/dist/configurators/grok.d.ts +22 -0
  51. package/dist/configurators/grok.d.ts.map +1 -0
  52. package/dist/configurators/grok.js +58 -0
  53. package/dist/configurators/grok.js.map +1 -0
  54. package/dist/configurators/index.d.ts.map +1 -1
  55. package/dist/configurators/index.js +17 -2
  56. package/dist/configurators/index.js.map +1 -1
  57. package/dist/configurators/kimi.d.ts +29 -0
  58. package/dist/configurators/kimi.d.ts.map +1 -0
  59. package/dist/configurators/kimi.js +81 -0
  60. package/dist/configurators/kimi.js.map +1 -0
  61. package/dist/configurators/pi.js +9 -9
  62. package/dist/configurators/pi.js.map +1 -1
  63. package/dist/configurators/shared.d.ts.map +1 -1
  64. package/dist/configurators/shared.js +12 -4
  65. package/dist/configurators/shared.js.map +1 -1
  66. package/dist/configurators/snow.d.ts +31 -0
  67. package/dist/configurators/snow.d.ts.map +1 -0
  68. package/dist/configurators/snow.js +95 -0
  69. package/dist/configurators/snow.js.map +1 -0
  70. package/dist/configurators/workflow.d.ts +14 -0
  71. package/dist/configurators/workflow.d.ts.map +1 -1
  72. package/dist/configurators/workflow.js +37 -1
  73. package/dist/configurators/workflow.js.map +1 -1
  74. package/dist/configurators/zcode.d.ts +9 -4
  75. package/dist/configurators/zcode.d.ts.map +1 -1
  76. package/dist/configurators/zcode.js +41 -12
  77. package/dist/configurators/zcode.js.map +1 -1
  78. package/dist/migrations/manifests/0.5.0-beta.0.json +1 -1
  79. package/dist/migrations/manifests/0.6.8.json +17 -0
  80. package/dist/migrations/manifests/0.6.9.json +9 -0
  81. package/dist/templates/codex/agents/trellis-check.toml +7 -12
  82. package/dist/templates/codex/agents/trellis-implement.toml +7 -12
  83. package/dist/templates/codex/agents/trellis-research.toml +15 -15
  84. package/dist/templates/codex/config.toml +11 -0
  85. package/dist/templates/codex/hooks/session-start.py +9 -3
  86. package/dist/templates/codex/hooks.json +12 -0
  87. package/dist/templates/codex/skills/brainstorm/SKILL.md +40 -13
  88. package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +2 -2
  89. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-skills-or-commands.md +1 -1
  90. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +1 -0
  91. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/hooks-and-settings.md +1 -1
  92. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +1 -1
  93. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +10 -4
  94. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md +2 -1
  95. package/dist/templates/common/bundled-skills/trellis-session-insight/SKILL.md +1 -1
  96. package/dist/templates/common/skills/brainstorm.md +40 -13
  97. package/dist/templates/copilot/prompts/brainstorm.prompt.md +40 -13
  98. package/dist/templates/grok/agents/trellis-check.md +105 -0
  99. package/dist/templates/grok/agents/trellis-implement.md +116 -0
  100. package/dist/templates/grok/agents/trellis-research.md +146 -0
  101. package/dist/templates/grok/index.d.ts +16 -0
  102. package/dist/templates/grok/index.d.ts.map +1 -0
  103. package/dist/templates/grok/index.js +19 -0
  104. package/dist/templates/grok/index.js.map +1 -0
  105. package/dist/templates/kimi/agents/trellis-check.md +103 -0
  106. package/dist/templates/kimi/agents/trellis-implement.md +114 -0
  107. package/dist/templates/kimi/agents/trellis-research.md +144 -0
  108. package/dist/templates/kimi/index.d.ts +19 -0
  109. package/dist/templates/kimi/index.d.ts.map +1 -0
  110. package/dist/templates/kimi/index.js +22 -0
  111. package/dist/templates/kimi/index.js.map +1 -0
  112. package/dist/templates/omp/extensions/trellis/index.ts.txt +133 -9
  113. package/dist/templates/opencode/lib/session-utils.js +8 -2
  114. package/dist/templates/opencode/lib/trellis-context.js +263 -43
  115. package/dist/templates/opencode/plugins/inject-subagent-context.js +89 -35
  116. package/dist/templates/opencode/plugins/inject-workflow-state.js +90 -5
  117. package/dist/templates/pi/extensions/trellis/index.ts.txt +278 -24
  118. package/dist/templates/pi/settings.json +0 -3
  119. package/dist/templates/shared-hooks/index.d.ts +11 -5
  120. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  121. package/dist/templates/shared-hooks/index.js +16 -5
  122. package/dist/templates/shared-hooks/index.js.map +1 -1
  123. package/dist/templates/shared-hooks/inject-subagent-context.py +466 -104
  124. package/dist/templates/shared-hooks/inject-workflow-state.py +86 -29
  125. package/dist/templates/shared-hooks/session-start.py +26 -6
  126. package/dist/templates/snow/SNOW.md +86 -0
  127. package/dist/templates/snow/agents/trellis-check.md +135 -0
  128. package/dist/templates/snow/agents/trellis-implement.md +153 -0
  129. package/dist/templates/snow/agents/trellis-research.md +114 -0
  130. package/dist/templates/snow/hooks/beforeSubAgentStart.json +15 -0
  131. package/dist/templates/snow/hooks/onSessionStart.json +15 -0
  132. package/dist/templates/snow/hooks/onUserMessage.json +15 -0
  133. package/dist/templates/snow/hooks/write-trellis-context.py +644 -0
  134. package/dist/templates/snow/index.d.ts +26 -0
  135. package/dist/templates/snow/index.d.ts.map +1 -0
  136. package/dist/templates/snow/index.js +36 -0
  137. package/dist/templates/snow/index.js.map +1 -0
  138. package/dist/templates/template-utils.d.ts.map +1 -1
  139. package/dist/templates/template-utils.js +5 -1
  140. package/dist/templates/template-utils.js.map +1 -1
  141. package/dist/templates/trellis/config.yaml +55 -7
  142. package/dist/templates/trellis/gitattributes.txt +9 -0
  143. package/dist/templates/trellis/index.d.ts +3 -1
  144. package/dist/templates/trellis/index.d.ts.map +1 -1
  145. package/dist/templates/trellis/index.js +3 -1
  146. package/dist/templates/trellis/index.js.map +1 -1
  147. package/dist/templates/trellis/scripts/add_session.py +94 -28
  148. package/dist/templates/trellis/scripts/common/active_task.py +44 -13
  149. package/dist/templates/trellis/scripts/common/cli_adapter.py +69 -5
  150. package/dist/templates/trellis/scripts/common/config.py +105 -10
  151. package/dist/templates/trellis/scripts/common/git.py +36 -0
  152. package/dist/templates/trellis/scripts/common/io.py +16 -9
  153. package/dist/templates/trellis/scripts/common/task_context.py +101 -7
  154. package/dist/templates/trellis/scripts/common/task_store.py +149 -22
  155. package/dist/templates/trellis/scripts/common/task_utils.py +3 -1
  156. package/dist/templates/trellis/scripts/common/trellis_config.py +2 -1
  157. package/dist/templates/trellis/scripts/common/workflow_phase.py +19 -12
  158. package/dist/templates/trellis/scripts/task.py +106 -10
  159. package/dist/templates/trellis/workflow.md +20 -19
  160. package/dist/templates/zcode/agents/trellis-check.md +7 -0
  161. package/dist/templates/zcode/agents/trellis-implement.md +13 -4
  162. package/dist/templates/zcode/config.json +42 -0
  163. package/dist/templates/zcode/index.d.ts +10 -1
  164. package/dist/templates/zcode/index.d.ts.map +1 -1
  165. package/dist/templates/zcode/index.js +12 -2
  166. package/dist/templates/zcode/index.js.map +1 -1
  167. package/dist/types/ai-tools.d.ts +4 -4
  168. package/dist/types/ai-tools.d.ts.map +1 -1
  169. package/dist/types/ai-tools.js +114 -2
  170. package/dist/types/ai-tools.js.map +1 -1
  171. package/dist/utils/template-fetcher.d.ts.map +1 -1
  172. package/dist/utils/template-fetcher.js +0 -3
  173. package/dist/utils/template-fetcher.js.map +1 -1
  174. package/package.json +2 -2
@@ -8,7 +8,13 @@
8
8
 
9
9
  import { existsSync, readdirSync } from "fs"
10
10
  import { join } from "path"
11
- import { TrellisContext, debugLog } from "../lib/trellis-context.js"
11
+ import {
12
+ TrellisContext,
13
+ debugLog,
14
+ readContextInjectionLimits,
15
+ ContextBudget,
16
+ materializeArtifact,
17
+ } from "../lib/trellis-context.js"
12
18
 
13
19
  // Supported subagent types
14
20
  const AGENTS_ALL = ["implement", "check", "research"]
@@ -29,64 +35,112 @@ function extractActiveTaskHint(prompt) {
29
35
  * Get context for implement agent. `taskDir` may be relative
30
36
  * (`.trellis/tasks/foo`) or absolute; both are resolved via
31
37
  * `ctx.resolveTaskDir`.
38
+ *
39
+ * Read order (mirrors Python `get_implement_context`):
40
+ * 1. All files in implement.jsonl (spec/research manifests)
41
+ * 2. prd.md (requirements)
42
+ * 3. design.md if present (technical design)
43
+ * 4. implement.md if present (execution plan)
44
+ * All blocks share one total budget (issue #441).
32
45
  */
33
46
  function getImplementContext(ctx, taskDir) {
34
47
  const parts = []
35
48
  const taskDirFull = ctx.resolveTaskDir(taskDir)
36
49
  if (!taskDirFull) return ""
37
50
 
38
- const jsonlPath = join(taskDirFull, "implement.jsonl")
39
- const entries = ctx.readJsonlWithFiles(jsonlPath)
40
- if (entries.length > 0) {
41
- parts.push(ctx.buildContextFromEntries(entries))
42
- }
43
-
44
- const prd = ctx.readFile(join(taskDirFull, "prd.md"))
45
- if (prd) {
46
- parts.push(`=== ${taskDir}/prd.md (Requirements) ===\n${prd}`)
47
- }
51
+ const limits = readContextInjectionLimits(ctx.directory)
52
+ const budget = new ContextBudget(limits.max_total_bytes)
48
53
 
49
- const design = ctx.readFile(join(taskDirFull, "design.md"))
50
- if (design) {
51
- parts.push(`=== ${taskDir}/design.md (Technical Design) ===\n${design}`)
54
+ // 1. Read implement.jsonl
55
+ const jsonlPath = join(taskDirFull, "implement.jsonl")
56
+ const blocks = ctx.readJsonlWithFiles(jsonlPath, limits, budget)
57
+ if (blocks.length > 0) {
58
+ parts.push(ctx.buildContextFromEntries(blocks))
52
59
  }
53
60
 
54
- const implementPlan = ctx.readFile(join(taskDirFull, "implement.md"))
55
- if (implementPlan) {
56
- parts.push(`=== ${taskDir}/implement.md (Execution Plan) ===\n${implementPlan}`)
57
- }
61
+ // 2. Requirements document
62
+ const prdBlock = materializeArtifact(
63
+ ctx.directory,
64
+ `${taskDir}/prd.md`,
65
+ `${taskDir}/prd.md (Requirements)`,
66
+ "Requirements document",
67
+ limits,
68
+ budget,
69
+ )
70
+ if (prdBlock) parts.push(prdBlock)
71
+
72
+ // 3. Technical design for complex tasks
73
+ const designBlock = materializeArtifact(
74
+ ctx.directory,
75
+ `${taskDir}/design.md`,
76
+ `${taskDir}/design.md (Technical Design)`,
77
+ "Technical design document",
78
+ limits,
79
+ budget,
80
+ )
81
+ if (designBlock) parts.push(designBlock)
82
+
83
+ // 4. Execution plan for complex tasks
84
+ const implementPlanBlock = materializeArtifact(
85
+ ctx.directory,
86
+ `${taskDir}/implement.md`,
87
+ `${taskDir}/implement.md (Execution Plan)`,
88
+ "Execution plan document",
89
+ limits,
90
+ budget,
91
+ )
92
+ if (implementPlanBlock) parts.push(implementPlanBlock)
58
93
 
59
94
  return parts.join("\n\n")
60
95
  }
61
96
 
62
97
  /**
63
98
  * Get context for check agent. `taskDir` may be relative or absolute.
99
+ * Same read order and shared budget as the implement context.
64
100
  */
65
101
  function getCheckContext(ctx, taskDir) {
66
102
  const parts = []
67
103
  const taskDirFull = ctx.resolveTaskDir(taskDir)
68
104
  if (!taskDirFull) return ""
69
105
 
70
- const jsonlPath = join(taskDirFull, "check.jsonl")
71
- const entries = ctx.readJsonlWithFiles(jsonlPath)
72
- if (entries.length > 0) {
73
- parts.push(ctx.buildContextFromEntries(entries))
74
- }
106
+ const limits = readContextInjectionLimits(ctx.directory)
107
+ const budget = new ContextBudget(limits.max_total_bytes)
75
108
 
76
- const prd = ctx.readFile(join(taskDirFull, "prd.md"))
77
- if (prd) {
78
- parts.push(`=== ${taskDir}/prd.md (Requirements) ===\n${prd}`)
79
- }
80
-
81
- const design = ctx.readFile(join(taskDirFull, "design.md"))
82
- if (design) {
83
- parts.push(`=== ${taskDir}/design.md (Technical Design) ===\n${design}`)
109
+ const jsonlPath = join(taskDirFull, "check.jsonl")
110
+ const blocks = ctx.readJsonlWithFiles(jsonlPath, limits, budget)
111
+ if (blocks.length > 0) {
112
+ parts.push(ctx.buildContextFromEntries(blocks))
84
113
  }
85
114
 
86
- const implementPlan = ctx.readFile(join(taskDirFull, "implement.md"))
87
- if (implementPlan) {
88
- parts.push(`=== ${taskDir}/implement.md (Execution Plan) ===\n${implementPlan}`)
89
- }
115
+ const prdBlock = materializeArtifact(
116
+ ctx.directory,
117
+ `${taskDir}/prd.md`,
118
+ `${taskDir}/prd.md (Requirements)`,
119
+ "Requirements document",
120
+ limits,
121
+ budget,
122
+ )
123
+ if (prdBlock) parts.push(prdBlock)
124
+
125
+ const designBlock = materializeArtifact(
126
+ ctx.directory,
127
+ `${taskDir}/design.md`,
128
+ `${taskDir}/design.md (Technical Design)`,
129
+ "Technical design document",
130
+ limits,
131
+ budget,
132
+ )
133
+ if (designBlock) parts.push(designBlock)
134
+
135
+ const implementPlanBlock = materializeArtifact(
136
+ ctx.directory,
137
+ `${taskDir}/implement.md`,
138
+ `${taskDir}/implement.md (Execution Plan)`,
139
+ "Execution plan document",
140
+ limits,
141
+ budget,
142
+ )
143
+ if (implementPlanBlock) parts.push(implementPlanBlock)
90
144
 
91
145
  return parts.join("\n\n")
92
146
  }
@@ -31,6 +31,82 @@ import { TrellisContext, debugLog, isTrellisSubagent } from "../lib/trellis-cont
31
31
  // (so "in-review" / "blocked-by-team" work alongside "in_progress").
32
32
  const TAG_RE = /\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g
33
33
 
34
+ // Escape hatch for the per-turn breadcrumb (issue #427). Mirrors
35
+ // `common.config.get_prompt_injection_config()` / the shared Python hook's
36
+ // `_resolve_skip_keyword()` + `prompt_has_skip_keyword()`.
37
+ const DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis"
38
+
39
+ function stripInlineComment(value) {
40
+ let inQuote = null
41
+ for (let idx = 0; idx < value.length; idx++) {
42
+ const ch = value[idx]
43
+ if (inQuote) {
44
+ if (ch === inQuote) inQuote = null
45
+ continue
46
+ }
47
+ if (ch === '"' || ch === "'") {
48
+ inQuote = ch
49
+ continue
50
+ }
51
+ if (ch === "#" && (idx === 0 || /\s/.test(value[idx - 1]))) return value.slice(0, idx)
52
+ }
53
+ return value
54
+ }
55
+
56
+ function unquoteYaml(s) {
57
+ if (s.length >= 2 && s[0] === s[s.length - 1] && (s[0] === '"' || s[0] === "'")) return s.slice(1, -1)
58
+ return s
59
+ }
60
+
61
+ /**
62
+ * Line-based parser for ONLY the `prompt_injection:` block of
63
+ * `.trellis/config.yaml`. Not a general YAML parser — mirrors
64
+ * `common.config.get_prompt_injection_config()` semantics for this section
65
+ * only (missing key keeps the default; non-string value keeps the default).
66
+ */
67
+ function readSkipKeyword(directory) {
68
+ const path = join(directory, ".trellis", "config.yaml")
69
+ if (!existsSync(path)) return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD
70
+ let text
71
+ try {
72
+ text = readFileSync(path, "utf-8")
73
+ } catch {
74
+ return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD
75
+ }
76
+
77
+ let inSection = false
78
+ let sectionIndent = -1
79
+ for (const rawLine of text.split(/\r?\n/)) {
80
+ const trimmed = rawLine.trim()
81
+ if (!inSection) {
82
+ if (/^prompt_injection\s*:\s*(#.*)?$/.test(trimmed)) {
83
+ inSection = true
84
+ sectionIndent = rawLine.length - rawLine.trimStart().length
85
+ }
86
+ continue
87
+ }
88
+ if (!trimmed || trimmed.startsWith("#")) continue
89
+ const indent = rawLine.length - rawLine.trimStart().length
90
+ if (indent <= sectionIndent) break
91
+ const m = trimmed.match(/^skip_keyword\s*:\s*(.*)$/)
92
+ if (!m) continue
93
+ return unquoteYaml(stripInlineComment(m[1]).trim())
94
+ }
95
+ return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD
96
+ }
97
+
98
+ /**
99
+ * Case-insensitive, word-boundary match of `keyword` in `text`. Hyphen
100
+ * counts as a word char so "no-trellisx" / "xno-trellis" don't match, but
101
+ * punctuation/whitespace boundaries do. Empty keyword never matches.
102
+ */
103
+ function promptHasSkipKeyword(text, keyword) {
104
+ if (!keyword || typeof text !== "string") return false
105
+ const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
106
+ const pattern = new RegExp(`(?<![\\w-])${escaped}(?![\\w-])`, "i")
107
+ return pattern.test(text)
108
+ }
109
+
34
110
  /**
35
111
  * Parse workflow.md for [workflow-state:STATUS] blocks.
36
112
  *
@@ -124,18 +200,27 @@ export default async ({ directory }) => {
124
200
  if (!ctx.isTrellisProject()) {
125
201
  return
126
202
  }
203
+
204
+ const parts = output?.parts || []
205
+ const textPartIndex = parts.findIndex(
206
+ p => p.type === "text" && p.text !== undefined,
207
+ )
208
+ const originalText = textPartIndex !== -1 ? (parts[textPartIndex].text || "") : ""
209
+
210
+ // Escape hatch (issue #427): user prompt contains the skip keyword
211
+ // as a standalone word — emit nothing for this turn only.
212
+ if (promptHasSkipKeyword(originalText, readSkipKeyword(directory))) {
213
+ debugLog("workflow-state", "Skipping turn: skip keyword present in prompt")
214
+ return
215
+ }
216
+
127
217
  const templates = loadBreadcrumbs(directory)
128
218
  const task = getActiveTask(ctx, input)
129
219
  const breadcrumb = task
130
220
  ? buildBreadcrumb(task.id, task.status, templates, task.source)
131
221
  : buildBreadcrumb(null, "no_task", templates)
132
222
 
133
- const parts = output?.parts || []
134
- const textPartIndex = parts.findIndex(
135
- p => p.type === "text" && p.text !== undefined,
136
- )
137
223
  if (textPartIndex !== -1) {
138
- const originalText = parts[textPartIndex].text || ""
139
224
  parts[textPartIndex].text = `${breadcrumb}\n\n${originalText}`
140
225
  } else {
141
226
  parts.unshift({ type: "text", text: breadcrumb })
@@ -1,7 +1,15 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
3
+ import {
4
+ delimiter,
5
+ dirname,
6
+ isAbsolute,
7
+ join,
8
+ relative,
9
+ resolve,
10
+ } from "node:path";
4
11
  import { spawn, spawnSync } from "node:child_process";
12
+ import { isUtf8 } from "node:buffer";
5
13
 
6
14
  // ── Types ──────────────────────────────────────────────────────────────
7
15
  type JsonObject = Record<string, unknown>;
@@ -84,8 +92,14 @@ const ABORT_KILL_GRACE_MS = 1500;
84
92
  const SESSION_OVERVIEW_TIMEOUT_MS = 1500;
85
93
  const THROTTLE_MS = 500;
86
94
  const FIRST_REPLY_NOTICE = `<first-reply-notice>
87
- First visible reply: say once in Chinese that Trellis SessionStart context is loaded, then answer directly.
88
- This notice is one-shot: do not repeat it after the first assistant reply in the same session.
95
+ On the first visible assistant reply in this session, briefly acknowledge that Trellis SessionStart context loaded.
96
+ Choose the acknowledgment language in this order:
97
+ 1. Use the language of the user's current request (the user message that triggered this reply).
98
+ 2. If that request has no clear natural language, use an explicitly established project communication language.
99
+ 3. If neither provides a language, output the language-neutral fallback exactly: \`Trellis SessionStart ✓\`.
100
+ Continue directly with the user's request after the acknowledgment.
101
+ The acknowledgment must not alter the language used for the remainder of the response.
102
+ This notice is one-shot: do not repeat it after the first visible assistant reply in this session.
89
103
  </first-reply-notice>`;
90
104
 
91
105
  // ── State types ───────────────────────────────────────────────────────
@@ -716,6 +730,222 @@ class BBC {
716
730
  }
717
731
  }
718
732
 
733
+ // ── Context Injection Limits (issue #441) ───────────────────────────────
734
+ //
735
+ // Notice text and behavior mirrored byte-for-byte from the shared-hooks
736
+ // Python sub-agent context injection hook. Changing wording there requires
737
+ // changing it here too.
738
+ interface ContextInjectionLimits {
739
+ max_file_bytes: number;
740
+ max_artifact_bytes: number;
741
+ max_total_bytes: number;
742
+ }
743
+ const DEFAULT_CONTEXT_INJECTION_LIMITS: ContextInjectionLimits = {
744
+ max_file_bytes: 32768,
745
+ max_artifact_bytes: 65536,
746
+ max_total_bytes: 131072,
747
+ };
748
+
749
+ function truncateUtf8(buf: Buffer, cap: number): Buffer {
750
+ if (cap <= 0 || buf.length <= cap) return buf;
751
+ let i = cap;
752
+ // Back off over continuation bytes (10xxxxxx) to find the lead byte.
753
+ while (i > 0 && (buf[i - 1]! & 0xc0) === 0x80) i--;
754
+ if (i === 0) return Buffer.alloc(0);
755
+ const lead = buf[i - 1]!;
756
+ if (lead & 0x80) {
757
+ let seqLen = 1;
758
+ if ((lead & 0xe0) === 0xc0) seqLen = 2;
759
+ else if ((lead & 0xf0) === 0xe0) seqLen = 3;
760
+ else if ((lead & 0xf8) === 0xf0) seqLen = 4;
761
+ // Drop the lead byte too if its full sequence didn't fit.
762
+ if (i - 1 + seqLen > cap) i--;
763
+ }
764
+ return buf.subarray(0, i);
765
+ }
766
+
767
+ function stripInlineComment(value: string): string {
768
+ let inQuote: string | null = null;
769
+ for (let idx = 0; idx < value.length; idx++) {
770
+ const ch = value[idx]!;
771
+ if (inQuote) {
772
+ if (ch === inQuote) inQuote = null;
773
+ continue;
774
+ }
775
+ if (ch === '"' || ch === "'") {
776
+ inQuote = ch;
777
+ continue;
778
+ }
779
+ if (ch === "#" && (idx === 0 || /\s/.test(value[idx - 1]!)))
780
+ return value.slice(0, idx);
781
+ }
782
+ return value;
783
+ }
784
+ function unquoteYaml(s: string): string {
785
+ if (s.length >= 2 && s[0] === s[s.length - 1] && (s[0] === '"' || s[0] === "'"))
786
+ return s.slice(1, -1);
787
+ return s;
788
+ }
789
+
790
+ /** Line-based parser for ONLY the `context_injection:` block of
791
+ * `.trellis/config.yaml`. Not a general YAML parser — mirrors
792
+ * `common.config.get_context_injection_limits()` semantics for this
793
+ * section only (missing keys keep the default; invalid/negative values
794
+ * fall back to the default for that key). */
795
+ function readContextInjectionLimits(repoRoot: string): ContextInjectionLimits {
796
+ const limits: ContextInjectionLimits = { ...DEFAULT_CONTEXT_INJECTION_LIMITS };
797
+ const text = readText(join(repoRoot, ".trellis", "config.yaml"));
798
+ if (!text) return limits;
799
+
800
+ let inSection = false;
801
+ let sectionIndent = -1;
802
+ for (const rawLine of text.split(/\r?\n/)) {
803
+ const trimmed = rawLine.trim();
804
+ if (!inSection) {
805
+ if (/^context_injection\s*:\s*(#.*)?$/.test(trimmed)) {
806
+ inSection = true;
807
+ sectionIndent = rawLine.length - rawLine.trimStart().length;
808
+ }
809
+ continue;
810
+ }
811
+ if (!trimmed || trimmed.startsWith("#")) continue;
812
+ const indent = rawLine.length - rawLine.trimStart().length;
813
+ if (indent <= sectionIndent) break;
814
+ const m = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/);
815
+ if (!m) continue;
816
+ const key = m[1]!;
817
+ if (!(key in limits)) continue;
818
+ const raw = unquoteYaml(stripInlineComment(m[2]!).trim()).trim();
819
+ if (!/^-?\d+$/.test(raw)) continue; // invalid -> keep default
820
+ const value = parseInt(raw, 10);
821
+ if (value < 0) continue; // negative -> keep default
822
+ (limits as unknown as Record<string, number>)[key] = value;
823
+ }
824
+ return limits;
825
+ }
826
+
827
+ class ContextBudget {
828
+ used = 0;
829
+ constructor(private maxTotalBytes: number) {}
830
+ hasRoom(size: number): boolean {
831
+ if (this.maxTotalBytes <= 0) return true;
832
+ return this.used + size <= this.maxTotalBytes;
833
+ }
834
+ add(size: number): void {
835
+ this.used += size;
836
+ }
837
+ }
838
+
839
+ function truncateNotice(path: string, cap: number): string {
840
+ return `\n[Trellis: truncated at ${cap} bytes — read ${path} for the full content]`;
841
+ }
842
+ function isBinaryContent(data: Buffer): boolean {
843
+ return data.includes(0) || !isUtf8(data);
844
+ }
845
+ function binaryNotice(path: string, size: number, reason: string): string {
846
+ return `[Trellis: not inlined (binary file) — ${path} (${size} bytes): ${reason}]`;
847
+ }
848
+ function indexNotice(path: string, size: number, reason: string): string {
849
+ return `[Trellis: not inlined (total context limit reached) — ${path} (${size} bytes): ${reason}]`;
850
+ }
851
+ function budgetedBlock(
852
+ budget: ContextBudget,
853
+ header: string,
854
+ plainPath: string,
855
+ content: string,
856
+ reason: string,
857
+ sizeForIndex: number,
858
+ ): string {
859
+ const block = `=== ${header} ===\n${content}`;
860
+ const blockBytes = Buffer.byteLength(block, "utf-8");
861
+ if (!budget.hasRoom(blockBytes)) {
862
+ const notice = indexNotice(plainPath, sizeForIndex, reason);
863
+ budget.add(Buffer.byteLength(notice, "utf-8"));
864
+ return notice;
865
+ }
866
+ budget.add(blockBytes);
867
+ return block;
868
+ }
869
+ function readFileBytes(basePath: string, filePath: string): Buffer | null {
870
+ const full = join(basePath, filePath);
871
+ try {
872
+ if (!statSync(full).isFile()) return null;
873
+ } catch {
874
+ return null;
875
+ }
876
+ try {
877
+ return readFileSync(full);
878
+ } catch {
879
+ return null;
880
+ }
881
+ }
882
+ function materializeFile(
883
+ basePath: string,
884
+ filePath: string,
885
+ reason: string,
886
+ limits: ContextInjectionLimits,
887
+ budget: ContextBudget,
888
+ ): string | null {
889
+ const data = readFileBytes(basePath, filePath);
890
+ if (data === null) return null;
891
+ const size = data.length;
892
+ if (isBinaryContent(data)) {
893
+ const notice = binaryNotice(filePath, size, reason);
894
+ budget.add(Buffer.byteLength(notice, "utf-8"));
895
+ return notice;
896
+ }
897
+ const cap = limits.max_file_bytes;
898
+ const truncated = truncateUtf8(data, cap);
899
+ let content = truncated.toString("utf-8");
900
+ if (truncated.length < size) content += truncateNotice(filePath, cap);
901
+ return budgetedBlock(budget, filePath, filePath, content, reason, size);
902
+ }
903
+ function materializeArtifact(
904
+ basePath: string,
905
+ filePath: string,
906
+ headerLabel: string,
907
+ reason: string,
908
+ limits: ContextInjectionLimits,
909
+ budget: ContextBudget,
910
+ ): string | null {
911
+ const data = readFileBytes(basePath, filePath);
912
+ if (data === null) return null;
913
+ const size = data.length;
914
+ const cap = limits.max_artifact_bytes;
915
+ const truncated = truncateUtf8(data, cap);
916
+ let content = truncated.toString("utf-8");
917
+ if (truncated.length < size) content += truncateNotice(filePath, cap);
918
+ return budgetedBlock(budget, headerLabel, filePath, content, reason, size);
919
+ }
920
+ interface JsonlEntry {
921
+ file: string;
922
+ type: string;
923
+ reason: string;
924
+ }
925
+ function readJsonlEntries(basePath: string, jsonlPath: string): JsonlEntry[] {
926
+ const text = readText(join(basePath, jsonlPath));
927
+ if (!text) return [];
928
+ const entries: JsonlEntry[] = [];
929
+ for (const line of text.split(/\r?\n/)) {
930
+ const t = line.trim();
931
+ if (!t) continue;
932
+ try {
933
+ const item = JSON.parse(t) as JsonObject;
934
+ const filePath =
935
+ (typeof item.file === "string" && item.file) ||
936
+ (typeof item.path === "string" && item.path) ||
937
+ "";
938
+ if (!filePath) continue;
939
+ entries.push({
940
+ file: filePath,
941
+ type: typeof item.type === "string" ? item.type : "file",
942
+ reason: (typeof item.reason === "string" && item.reason) || "-",
943
+ });
944
+ } catch {}
945
+ }
946
+ return entries;
947
+ }
948
+
719
949
  // ── Trellis Context ────────────────────────────────────────────────────
720
950
  function findRoot(start: string): string {
721
951
  let c = resolve(start);
@@ -936,35 +1166,59 @@ function buildContext(root: string, agent: string, key: string | null): string {
936
1166
  const dir = readTaskDir(root, key);
937
1167
  if (!dir)
938
1168
  return "No active Trellis task found. Read .trellis/ before proceeding.";
939
- const prd = readText(join(dir, "prd.md"));
940
- const design = readText(join(dir, "design.md"));
941
- const impl = readText(join(dir, "implement.md"));
1169
+ const relTaskDir = relative(root, dir).replace(/\\/g, "/");
1170
+ const limits = readContextInjectionLimits(root);
1171
+ const budget = new ContextBudget(limits.max_total_bytes);
1172
+
1173
+ // 1. Curated spec/research files from {agent}.jsonl (same order, budget
1174
+ // processed first, matching Python's get_agent_context()).
942
1175
  const jsonlName = TRELLIS_AGENT_JSONL[agent] ?? "";
943
- let spec = "";
1176
+ const specBlocks: string[] = [];
944
1177
  if (jsonlName) {
945
- const chunks: string[] = [];
946
- for (const line of readText(join(dir, jsonlName)).split(/\r?\n/)) {
947
- const t = line.trim();
948
- if (!t) continue;
949
- try {
950
- const r = JSON.parse(t) as JsonObject;
951
- const f = typeof r.file === "string" ? r.file : "";
952
- if (f) {
953
- const c = readText(join(root, f));
954
- if (c) chunks.push(`## ${f}\n\n${c}`);
955
- }
956
- } catch {}
1178
+ for (const entry of readJsonlEntries(dir, jsonlName)) {
1179
+ if (entry.type === "directory") continue;
1180
+ const block = materializeFile(root, entry.file, entry.reason, limits, budget);
1181
+ if (block) specBlocks.push(block);
957
1182
  }
958
- spec = chunks.join("\n\n---\n\n");
959
1183
  }
1184
+ const spec = specBlocks.join("\n\n");
1185
+
1186
+ // 2-4. Task artifacts, in order: prd.md -> design.md -> implement.md.
1187
+ const prd = materializeArtifact(
1188
+ root,
1189
+ `${relTaskDir}/prd.md`,
1190
+ `${relTaskDir}/prd.md (Requirements)`,
1191
+ "Requirements document",
1192
+ limits,
1193
+ budget,
1194
+ );
1195
+ const design = materializeArtifact(
1196
+ root,
1197
+ `${relTaskDir}/design.md`,
1198
+ `${relTaskDir}/design.md (Technical Design)`,
1199
+ "Technical design document",
1200
+ limits,
1201
+ budget,
1202
+ );
1203
+ const impl = materializeArtifact(
1204
+ root,
1205
+ `${relTaskDir}/implement.md`,
1206
+ `${relTaskDir}/implement.md (Execution Plan)`,
1207
+ "Execution plan document",
1208
+ limits,
1209
+ budget,
1210
+ );
1211
+
1212
+ // prd/design/impl already carry their own "=== path (label) ===" header
1213
+ // (from materializeArtifact) — no extra "### x.md" wrapper needed, that
1214
+ // would just double the header.
960
1215
  return [
961
1216
  `## Trellis Task Context`,
962
1217
  `Task directory: ${dir}`,
963
1218
  "",
964
- "### prd.md",
965
- prd || "(missing)",
966
- design ? "\n### design.md\n" + design : "",
967
- impl ? "\n### implement.md\n" + impl : "",
1219
+ prd ?? `(missing) ${relTaskDir}/prd.md`,
1220
+ design ? "\n" + design : "",
1221
+ impl ? "\n" + impl : "",
968
1222
  spec ? "\n### Curated Spec / Research Context\n" + spec : "",
969
1223
  ].join("\n");
970
1224
  }
@@ -3,9 +3,6 @@
3
3
  "extensions": [
4
4
  "./extensions/trellis/index.ts"
5
5
  ],
6
- "skills": [
7
- "./skills"
8
- ],
9
6
  "prompts": [
10
7
  "./prompts"
11
8
  ]
@@ -12,7 +12,7 @@ export interface HookScript {
12
12
  content: string;
13
13
  }
14
14
  export type SharedHookName = "session-start.py" | "inject-shell-session-context.py" | "inject-workflow-state.py" | "inject-subagent-context.py";
15
- export type SharedHookPlatform = "claude" | "cursor" | "codex" | "gemini" | "qoder" | "copilot" | "codebuddy" | "droid" | "kiro" | "trae";
15
+ export type SharedHookPlatform = "claude" | "cursor" | "codex" | "gemini" | "qoder" | "copilot" | "codebuddy" | "droid" | "kiro" | "trae" | "zcode";
16
16
  /**
17
17
  * Which shared hooks each platform actually invokes. Single source of truth
18
18
  * for shared-hook distribution — both `writeSharedHooks` (runtime install)
@@ -25,10 +25,11 @@ export type SharedHookPlatform = "claude" | "cursor" | "codex" | "gemini" | "qod
25
25
  * - `inject-workflow-state.py` — every platform with a UserPromptSubmit
26
26
  * (or equivalent) event. Kiro + codex self-included; platforms without
27
27
  * per-turn main-session hooks are excluded.
28
- * - `inject-subagent-context.py` — class-1 (push-based) platforms only.
29
- * Class-2 (pull-based) platforms (codex, copilot, gemini, qoder) can't
30
- * have hooks mutate sub-agent prompts — their sub-agents load context
31
- * via a prelude instead.
28
+ * - `inject-subagent-context.py` — platforms with native sub-agent context
29
+ * delivery. Most use a PreToolUse prompt mutation; Codex uses its
30
+ * SubagentStart `additionalContext` event. Class-2 (pull-based) platforms
31
+ * such as copilot, gemini, qoder, and trae still load context from a
32
+ * self-loading agent profile.
32
33
  * - Kiro supports per-turn + spawn hooks on both surfaces (per the official
33
34
  * docs https://kiro.dev/docs/cli/hooks/): the CLI custom agent declares
34
35
  * `hooks.userPromptSubmit` + `hooks.agentSpawn`, and the IDE declares a
@@ -42,6 +43,11 @@ export type SharedHookPlatform = "claude" | "cursor" | "codex" | "gemini" | "qod
42
43
  * or opt in to the Trellis one via `trellis init --with-statusline`
43
44
  * (installed from `templates/claude/hooks/`, not from this table — no
44
45
  * other platform has a statusLine event).
46
+ * - ZCode (3.x) ships a workspace hook config at `.zcode/config.json` covering
47
+ * SessionStart + UserPromptSubmit in the main session, plus PreToolUse for
48
+ * `Agent|Task`. Live probing confirmed `hookSpecificOutput.updatedInput`
49
+ * reaches the sub-agent prompt, so ZCode is class-1 and ships
50
+ * `inject-subagent-context.py`.
45
51
  */
46
52
  export declare const SHARED_HOOKS_BY_PLATFORM: Record<SharedHookPlatform, readonly SharedHookName[]>;
47
53
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/templates/shared-hooks/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAaH,MAAM,WAAW,UAAU;IACzB,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,cAAc,GACtB,kBAAkB,GAClB,iCAAiC,GACjC,0BAA0B,GAC1B,4BAA4B,CAAC;AAEjC,MAAM,MAAM,kBAAkB,GAC1B,QAAQ,GACR,QAAQ,GACR,OAAO,GACP,QAAQ,GACR,OAAO,GACP,SAAS,GACT,WAAW,GACX,OAAO,GACP,MAAM,GACN,MAAM,CAAC;AAEX;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,wBAAwB,EAAE,MAAM,CAC3C,kBAAkB,EAClB,SAAS,cAAc,EAAE,CAgC1B,CAAC;AAEF;;;GAGG;AACH,wBAAgB,oBAAoB,IAAI,UAAU,EAAE,CAWnD;AAED;;;;GAIG;AACH,wBAAgB,+BAA+B,CAC7C,QAAQ,EAAE,kBAAkB,GAC3B,UAAU,EAAE,CAGd"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/templates/shared-hooks/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAaH,MAAM,WAAW,UAAU;IACzB,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,gEAAgE;IAChE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,cAAc,GACtB,kBAAkB,GAClB,iCAAiC,GACjC,0BAA0B,GAC1B,4BAA4B,CAAC;AAEjC,MAAM,MAAM,kBAAkB,GAC1B,QAAQ,GACR,QAAQ,GACR,OAAO,GACP,QAAQ,GACR,OAAO,GACP,SAAS,GACT,WAAW,GACX,OAAO,GACP,MAAM,GACN,MAAM,GACN,OAAO,CAAC;AAEZ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,wBAAwB,EAAE,MAAM,CAC3C,kBAAkB,EAClB,SAAS,cAAc,EAAE,CAqC1B,CAAC;AAEF;;;GAGG;AACH,wBAAgB,oBAAoB,IAAI,UAAU,EAAE,CAWnD;AAED;;;;GAIG;AACH,wBAAgB,+BAA+B,CAC7C,QAAQ,EAAE,kBAAkB,GAC3B,UAAU,EAAE,CAGd"}