@mindfoldhq/trellis 0.6.8 → 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 (90) hide show
  1. package/dist/cli/index.js +1 -0
  2. package/dist/cli/index.js.map +1 -1
  3. package/dist/commands/channel/agent-loader.d.ts +2 -2
  4. package/dist/commands/channel/agent-loader.d.ts.map +1 -1
  5. package/dist/commands/channel/agent-loader.js +9 -5
  6. package/dist/commands/channel/agent-loader.js.map +1 -1
  7. package/dist/commands/channel/context-loader.d.ts +1 -1
  8. package/dist/commands/channel/context-loader.d.ts.map +1 -1
  9. package/dist/commands/channel/context-loader.js +36 -15
  10. package/dist/commands/channel/context-loader.js.map +1 -1
  11. package/dist/commands/channel/context-trust.d.ts +31 -0
  12. package/dist/commands/channel/context-trust.d.ts.map +1 -0
  13. package/dist/commands/channel/context-trust.js +139 -0
  14. package/dist/commands/channel/context-trust.js.map +1 -0
  15. package/dist/commands/channel/spawn.d.ts.map +1 -1
  16. package/dist/commands/channel/spawn.js +4 -2
  17. package/dist/commands/channel/spawn.js.map +1 -1
  18. package/dist/commands/init.d.ts +1 -0
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js.map +1 -1
  21. package/dist/commands/update.d.ts.map +1 -1
  22. package/dist/commands/update.js +17 -0
  23. package/dist/commands/update.js.map +1 -1
  24. package/dist/configurators/codex.d.ts +36 -0
  25. package/dist/configurators/codex.d.ts.map +1 -1
  26. package/dist/configurators/codex.js +100 -1
  27. package/dist/configurators/codex.js.map +1 -1
  28. package/dist/configurators/index.d.ts.map +1 -1
  29. package/dist/configurators/index.js +5 -0
  30. package/dist/configurators/index.js.map +1 -1
  31. package/dist/configurators/shared.d.ts.map +1 -1
  32. package/dist/configurators/shared.js +1 -0
  33. package/dist/configurators/shared.js.map +1 -1
  34. package/dist/configurators/snow.d.ts +31 -0
  35. package/dist/configurators/snow.d.ts.map +1 -0
  36. package/dist/configurators/snow.js +95 -0
  37. package/dist/configurators/snow.js.map +1 -0
  38. package/dist/configurators/workflow.d.ts +14 -0
  39. package/dist/configurators/workflow.d.ts.map +1 -1
  40. package/dist/configurators/workflow.js +37 -1
  41. package/dist/configurators/workflow.js.map +1 -1
  42. package/dist/migrations/manifests/0.6.9.json +9 -0
  43. package/dist/templates/codex/agents/trellis-check.toml +2 -0
  44. package/dist/templates/codex/agents/trellis-implement.toml +2 -0
  45. package/dist/templates/codex/agents/trellis-research.toml +2 -0
  46. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +1 -1
  47. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +1 -1
  48. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +4 -2
  49. package/dist/templates/kimi/agents/trellis-research.md +7 -3
  50. package/dist/templates/omp/extensions/trellis/index.ts.txt +114 -4
  51. package/dist/templates/opencode/lib/trellis-context.js +263 -43
  52. package/dist/templates/opencode/plugins/inject-subagent-context.js +89 -35
  53. package/dist/templates/opencode/plugins/inject-workflow-state.js +90 -5
  54. package/dist/templates/pi/extensions/trellis/index.ts.txt +270 -22
  55. package/dist/templates/shared-hooks/inject-subagent-context.py +311 -83
  56. package/dist/templates/shared-hooks/inject-workflow-state.py +36 -1
  57. package/dist/templates/snow/SNOW.md +86 -0
  58. package/dist/templates/snow/agents/trellis-check.md +135 -0
  59. package/dist/templates/snow/agents/trellis-implement.md +153 -0
  60. package/dist/templates/snow/agents/trellis-research.md +114 -0
  61. package/dist/templates/snow/hooks/beforeSubAgentStart.json +15 -0
  62. package/dist/templates/snow/hooks/onSessionStart.json +15 -0
  63. package/dist/templates/snow/hooks/onUserMessage.json +15 -0
  64. package/dist/templates/snow/hooks/write-trellis-context.py +644 -0
  65. package/dist/templates/snow/index.d.ts +26 -0
  66. package/dist/templates/snow/index.d.ts.map +1 -0
  67. package/dist/templates/snow/index.js +36 -0
  68. package/dist/templates/snow/index.js.map +1 -0
  69. package/dist/templates/template-utils.d.ts.map +1 -1
  70. package/dist/templates/template-utils.js +5 -1
  71. package/dist/templates/template-utils.js.map +1 -1
  72. package/dist/templates/trellis/config.yaml +46 -0
  73. package/dist/templates/trellis/gitattributes.txt +9 -0
  74. package/dist/templates/trellis/index.d.ts +3 -1
  75. package/dist/templates/trellis/index.d.ts.map +1 -1
  76. package/dist/templates/trellis/index.js +3 -1
  77. package/dist/templates/trellis/index.js.map +1 -1
  78. package/dist/templates/trellis/scripts/add_session.py +94 -28
  79. package/dist/templates/trellis/scripts/common/active_task.py +4 -0
  80. package/dist/templates/trellis/scripts/common/config.py +89 -2
  81. package/dist/templates/trellis/scripts/common/task_context.py +82 -6
  82. package/dist/templates/trellis/scripts/common/task_store.py +63 -1
  83. package/dist/templates/trellis/scripts/common/trellis_config.py +2 -1
  84. package/dist/templates/trellis/scripts/task.py +21 -2
  85. package/dist/templates/trellis/workflow.md +14 -14
  86. package/dist/types/ai-tools.d.ts +3 -3
  87. package/dist/types/ai-tools.d.ts.map +1 -1
  88. package/dist/types/ai-tools.js +38 -0
  89. package/dist/types/ai-tools.js.map +1 -1
  90. 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>;
@@ -722,6 +730,222 @@ class BBC {
722
730
  }
723
731
  }
724
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
+
725
949
  // ── Trellis Context ────────────────────────────────────────────────────
726
950
  function findRoot(start: string): string {
727
951
  let c = resolve(start);
@@ -942,35 +1166,59 @@ function buildContext(root: string, agent: string, key: string | null): string {
942
1166
  const dir = readTaskDir(root, key);
943
1167
  if (!dir)
944
1168
  return "No active Trellis task found. Read .trellis/ before proceeding.";
945
- const prd = readText(join(dir, "prd.md"));
946
- const design = readText(join(dir, "design.md"));
947
- 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()).
948
1175
  const jsonlName = TRELLIS_AGENT_JSONL[agent] ?? "";
949
- let spec = "";
1176
+ const specBlocks: string[] = [];
950
1177
  if (jsonlName) {
951
- const chunks: string[] = [];
952
- for (const line of readText(join(dir, jsonlName)).split(/\r?\n/)) {
953
- const t = line.trim();
954
- if (!t) continue;
955
- try {
956
- const r = JSON.parse(t) as JsonObject;
957
- const f = typeof r.file === "string" ? r.file : "";
958
- if (f) {
959
- const c = readText(join(root, f));
960
- if (c) chunks.push(`## ${f}\n\n${c}`);
961
- }
962
- } 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);
963
1182
  }
964
- spec = chunks.join("\n\n---\n\n");
965
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.
966
1215
  return [
967
1216
  `## Trellis Task Context`,
968
1217
  `Task directory: ${dir}`,
969
1218
  "",
970
- "### prd.md",
971
- prd || "(missing)",
972
- design ? "\n### design.md\n" + design : "",
973
- impl ? "\n### implement.md\n" + impl : "",
1219
+ prd ?? `(missing) ${relTaskDir}/prd.md`,
1220
+ design ? "\n" + design : "",
1221
+ impl ? "\n" + impl : "",
974
1222
  spec ? "\n### Curated Spec / Research Context\n" + spec : "",
975
1223
  ].join("\n");
976
1224
  }