@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
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Kimi Code template module.
3
+ *
4
+ * Kimi Code is a class-2 pull-based platform:
5
+ * - Workflow/bundled skills go to the shared `.agents/skills/` root via the
6
+ * neutral resolver (byte-identical to Codex/Gemini/Pi writes).
7
+ * - User-invocable entry points (`trellis-start` / `trellis-continue` /
8
+ * `trellis-finish-work`, invoked as `/skill:trellis-<name>`) and the Trellis
9
+ * agent prompts live under `.kimi-code/skills/<name>/SKILL.md`.
10
+ *
11
+ * Kimi has no project-level hooks/settings file Trellis may write and no
12
+ * custom sub-agent definitions (built-in coder/explore/plan only), so the
13
+ * Trellis agent prompts ship as skills; trellis-implement / trellis-check get
14
+ * the pull-based prelude, trellis-research stays standalone.
15
+ */
16
+ import { createTemplateReader } from "../template-utils.js";
17
+ const { listMdAgents } = createTemplateReader(import.meta.url);
18
+ /** Trellis agent prompts (trellis-implement, trellis-check, trellis-research), installed as Kimi skills. */
19
+ export function getAllAgents() {
20
+ return listMdAgents();
21
+ }
22
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/templates/kimi/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,oBAAoB,EAAsB,MAAM,sBAAsB,CAAC;AAEhF,MAAM,EAAE,YAAY,EAAE,GAAG,oBAAoB,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAE/D,4GAA4G;AAC5G,MAAM,UAAU,YAAY;IAC1B,OAAO,YAAY,EAAE,CAAC;AACxB,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
- import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
3
  import { join, dirname, isAbsolute, relative, resolve } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { createHash } from "node:crypto";
@@ -40,7 +40,7 @@ function buildContextKey(platformName: string, kind: string, value: string): str
40
40
  return safeValue ? `${platformName}_${safeValue}` : `${platformName}_${hashValue(value)}`;
41
41
  }
42
42
 
43
- function deriveContextKey(ctx?: { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string } }): string | null {
43
+ function deriveContextKey(ctx?: { sessionManager?: { getSessionId?: () => string | undefined; getSessionFile?: () => string | undefined } }): string | null {
44
44
  const sessionId = ctx?.sessionManager?.getSessionId?.();
45
45
  if (sessionId) {
46
46
  return buildContextKey("omp", "session", sessionId);
@@ -58,11 +58,118 @@ function isInsideRoot(root: string, candidate: string): boolean {
58
58
  return rel === "" || (rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\") && !isAbsolute(rel));
59
59
  }
60
60
 
61
- function resolveProjectFile(projectRoot: string, file: string): string | null {
61
+ // ---------------------------------------------------------------------------
62
+ // Trusted context roots (mirrors packages/cli/src/commands/channel/context-trust.ts;
63
+ // standalone copy since templates don't import from the CLI package).
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const AUTO_TRUST_ENTRIES = ["tasks", "workspace"];
67
+
68
+ function stripTrustValue(s: string): string {
69
+ return s.trim().replace(/\s*#.*$/, "").trim().replace(/^['"]|['"]$/g, "");
70
+ }
71
+
72
+ function parseChannelTrustSection(content: string): { trustedDirs: string[]; autoTrustSymlinks?: boolean } {
73
+ const lines = content.split("\n");
74
+ const trustedDirs: string[] = [];
75
+ let autoTrustSymlinks: boolean | undefined;
76
+ let inChannel = false;
77
+ let inList = false;
78
+
79
+ for (const raw of lines) {
80
+ const line = raw.replace(/\r$/, "");
81
+ const trimmed = line.trimEnd();
82
+ if (trimmed.trim().startsWith("#")) continue;
83
+
84
+ if (/^channel:\s*$/.test(trimmed)) {
85
+ inChannel = true;
86
+ inList = false;
87
+ continue;
88
+ }
89
+ if (!inChannel) continue;
90
+
91
+ if (trimmed.trim() !== "" && /^\S/.test(line)) {
92
+ inChannel = false;
93
+ inList = false;
94
+ continue;
95
+ }
96
+ if (trimmed.trim() === "") continue;
97
+
98
+ if (inList) {
99
+ const item = trimmed.match(/^ {4}-\s*(.+)$/);
100
+ if (item) {
101
+ const val = stripTrustValue(item[1]!);
102
+ if (val) trustedDirs.push(val);
103
+ continue;
104
+ }
105
+ inList = false;
106
+ }
107
+
108
+ if (/^ {2}trusted_context_dirs:\s*$/.test(trimmed)) {
109
+ inList = true;
110
+ continue;
111
+ }
112
+
113
+ const boolMatch = trimmed.match(/^ {2}auto_trust_trellis_symlinks:\s*(.+)$/);
114
+ if (boolMatch) {
115
+ const val = stripTrustValue(boolMatch[1]!).toLowerCase();
116
+ if (val === "false") autoTrustSymlinks = false;
117
+ else if (val === "true") autoTrustSymlinks = true;
118
+ else process.stderr.write(`[channel] channel.auto_trust_trellis_symlinks: invalid value '${val}', ignoring\n`);
119
+ continue;
120
+ }
121
+ }
122
+
123
+ return { trustedDirs, autoTrustSymlinks };
124
+ }
125
+
126
+ function resolveTrustedRoots(projectRoot: string): string[] {
127
+ const configPath = join(projectRoot, ".trellis", "config.yaml");
128
+ let config: { trustedDirs: string[]; autoTrustSymlinks?: boolean } = { trustedDirs: [] };
129
+ if (existsSync(configPath)) {
130
+ try {
131
+ config = parseChannelTrustSection(readFileSync(configPath, "utf-8"));
132
+ } catch {
133
+ // ignore
134
+ }
135
+ }
136
+
137
+ const roots: string[] = [];
138
+ for (const entry of config.trustedDirs) {
139
+ try {
140
+ roots.push(realpathSync(resolve(projectRoot, entry)));
141
+ } catch {
142
+ // entry not found or invalid — skip
143
+ }
144
+ }
145
+
146
+ if (config.autoTrustSymlinks !== false) {
147
+ for (const entryName of AUTO_TRUST_ENTRIES) {
148
+ const entryPath = join(projectRoot, ".trellis", entryName);
149
+ try {
150
+ if (lstatSync(entryPath).isSymbolicLink()) {
151
+ roots.push(realpathSync(entryPath));
152
+ }
153
+ } catch {
154
+ // missing / broken symlink — nothing to trust
155
+ }
156
+ }
157
+ }
158
+
159
+ return [...new Set(roots)];
160
+ }
161
+
162
+ function resolveProjectFile(
163
+ projectRoot: string,
164
+ file: string,
165
+ trustedRoots: string[],
166
+ ): string | null {
62
167
  try {
63
168
  const rootReal = realpathSync(projectRoot);
64
169
  const targetReal = realpathSync(resolve(projectRoot, file));
65
- return isInsideRoot(rootReal, targetReal) ? targetReal : null;
170
+ if (isInsideRoot(rootReal, targetReal)) return targetReal;
171
+ if (trustedRoots.some((root) => isInsideRoot(root, targetReal))) return targetReal;
172
+ return null;
66
173
  } catch {
67
174
  return null;
68
175
  }
@@ -171,6 +278,9 @@ type AgentType = "trellis-implement" | "trellis-check" | "trellis-research" | nu
171
278
 
172
279
  function buildTaskContext(projectRoot: string, taskDir: string, agentType?: AgentType): string {
173
280
  const parts: string[] = [];
281
+ // Resolved once per call (not per referenced file) — avoids re-parsing
282
+ // config.yaml for every jsonl row.
283
+ const trustedRoots = resolveTrustedRoots(projectRoot);
174
284
 
175
285
  // prd.md and info.md — always included
176
286
  let prd = "";
@@ -212,7 +322,7 @@ function buildTaskContext(projectRoot: string, taskDir: string, agentType?: Agen
212
322
  const row = JSON.parse(trimmed) as Record<string, unknown>;
213
323
  const file = typeof row.file === "string" ? row.file.trim() : "";
214
324
  if (!file) continue;
215
- const targetPath = resolveProjectFile(projectRoot, file);
325
+ const targetPath = resolveProjectFile(projectRoot, file, trustedRoots);
216
326
  if (!targetPath) continue;
217
327
  let content = "";
218
328
  try { content = readFileSync(targetPath, "utf-8"); } catch { }
@@ -336,7 +446,7 @@ export default function(pi: ExtensionAPI): void {
336
446
  let lastCompactionTs = 0;
337
447
  let lastInjectionTs = 0;
338
448
 
339
- const rememberContextKey = (ctx?: { sessionManager?: { getSessionId?: () => string; getSessionFile?: () => string } }): string | null => {
449
+ const rememberContextKey = (ctx?: { sessionManager?: { getSessionId?: () => string | undefined; getSessionFile?: () => string | undefined } }): string | null => {
340
450
  const key = deriveContextKey(ctx);
341
451
  if (!key) return null;
342
452
  return key;
@@ -439,24 +549,38 @@ export default function(pi: ExtensionAPI): void {
439
549
  messages: [
440
550
  ...event.messages,
441
551
  {
442
- role: "custom",
552
+ role: "custom" as const,
443
553
  customType: "trellis-workflow-state",
444
554
  content: cached.workflowMsg,
555
+ display: false,
445
556
  timestamp: Date.now(),
446
557
  },
447
558
  ],
448
559
  };
449
560
  });
450
561
 
562
+ // OMP passes Bash event.input through to the tool execution parameters, so
563
+ // inject the session key through the shell-agnostic env field. An explicit
564
+ // per-call value wins over the derived key.
565
+ pi.on("tool_call", (event, ctx) => {
566
+ if (event.toolName !== "bash") return;
567
+ const contextKey = rememberContextKey(ctx);
568
+ if (!contextKey) return;
569
+ const input = event.input as { env?: Record<string, string> };
570
+ input.env = {
571
+ TRELLIS_CONTEXT_ID: contextKey,
572
+ ...input.env,
573
+ };
574
+ });
575
+
451
576
  pi.on("input", async (_event, ctx) => {
452
577
  if (!projectRoot) {
453
578
  projectRoot = findProjectRoot(ctx.cwd);
454
579
  }
455
580
  // Resolve projectRoot on first input if session_start missed it
456
- if (!projectRoot) return { action: "continue" };
581
+ if (!projectRoot) return;
457
582
  const contextKey = rememberContextKey(ctx);
458
583
  // Pre-warm the cache so before_agent_start and context can use it
459
584
  turnCache.get(projectRoot, contextKey);
460
- return { action: "continue" };
461
585
  });
462
586
  }
@@ -8,8 +8,14 @@ import { debugLog } from "./trellis-context.js"
8
8
  const PYTHON_CMD = platform() === "win32" ? "python" : "python3"
9
9
 
10
10
  const FIRST_REPLY_NOTICE = `<first-reply-notice>
11
- First visible reply: say once in Chinese that Trellis SessionStart context is loaded, then answer directly.
12
- This notice is one-shot: do not repeat it after the first assistant reply in the same session.
11
+ On the first visible assistant reply in this session, briefly acknowledge that Trellis SessionStart context loaded.
12
+ Choose the acknowledgment language in this order:
13
+ 1. Use the language of the user's current request (the user message that triggered this reply).
14
+ 2. If that request has no clear natural language, use an explicitly established project communication language.
15
+ 3. If neither provides a language, output the language-neutral fallback exactly: \`Trellis SessionStart ✓\`.
16
+ Continue directly with the user's request after the acknowledgment.
17
+ The acknowledgment must not alter the language used for the remainder of the response.
18
+ This notice is one-shot: do not repeat it after the first visible assistant reply in this session.
13
19
  </first-reply-notice>`
14
20
 
15
21
  function hasCuratedJsonlEntry(jsonlPath) {
@@ -5,11 +5,12 @@
5
5
  * JSONL parsing, and context building capabilities.
6
6
  */
7
7
 
8
- import { existsSync, readFileSync, appendFileSync, readdirSync } from "fs"
8
+ import { existsSync, readFileSync, appendFileSync, readdirSync, statSync } from "fs"
9
9
  import { isAbsolute, join } from "path"
10
10
  import { platform } from "os"
11
11
  import { execSync } from "child_process"
12
12
  import { createHash } from "crypto"
13
+ import { Buffer, isUtf8 } from "buffer"
13
14
  import process from "process"
14
15
 
15
16
  const PYTHON_CMD = platform() === "win32" ? "python" : "python3"
@@ -78,6 +79,240 @@ export function isTrellisSubagent(input) {
78
79
  return TRELLIS_SUBAGENT_RE.test(agent)
79
80
  }
80
81
 
82
+ // ============================================================
83
+ // Context Injection Limits (issue #441)
84
+ //
85
+ // Notice text and behavior mirrored byte-for-byte from the shared-hooks
86
+ // Python sub-agent context injection hook and the Pi extension. Changing
87
+ // wording here requires changing it there too.
88
+ // ============================================================
89
+
90
+ const DEFAULT_CONTEXT_INJECTION_LIMITS = {
91
+ max_file_bytes: 32768,
92
+ max_artifact_bytes: 65536,
93
+ max_total_bytes: 131072,
94
+ }
95
+
96
+ /**
97
+ * Truncate `buf` to at most `cap` bytes without splitting a UTF-8
98
+ * multi-byte sequence. `cap <= 0` means "no limit".
99
+ */
100
+ function truncateUtf8(buf, cap) {
101
+ if (cap <= 0 || buf.length <= cap) return buf
102
+ let i = cap
103
+ // Back off over continuation bytes (10xxxxxx) to find the lead byte.
104
+ while (i > 0 && (buf[i - 1] & 0xc0) === 0x80) i--
105
+ if (i === 0) return Buffer.alloc(0)
106
+ const lead = buf[i - 1]
107
+ if (lead & 0x80) {
108
+ let seqLen = 1
109
+ if ((lead & 0xe0) === 0xc0) seqLen = 2
110
+ else if ((lead & 0xf0) === 0xe0) seqLen = 3
111
+ else if ((lead & 0xf8) === 0xf0) seqLen = 4
112
+ // Drop the lead byte too if its full sequence didn't fit.
113
+ if (i - 1 + seqLen > cap) i--
114
+ }
115
+ return buf.subarray(0, i)
116
+ }
117
+
118
+ function stripInlineComment(value) {
119
+ let inQuote = null
120
+ for (let idx = 0; idx < value.length; idx++) {
121
+ const ch = value[idx]
122
+ if (inQuote) {
123
+ if (ch === inQuote) inQuote = null
124
+ continue
125
+ }
126
+ if (ch === '"' || ch === "'") {
127
+ inQuote = ch
128
+ continue
129
+ }
130
+ if (ch === "#" && (idx === 0 || /\s/.test(value[idx - 1]))) {
131
+ return value.slice(0, idx)
132
+ }
133
+ }
134
+ return value
135
+ }
136
+
137
+ function unquoteYaml(s) {
138
+ if (s.length >= 2 && s[0] === s[s.length - 1] && (s[0] === '"' || s[0] === "'")) {
139
+ return s.slice(1, -1)
140
+ }
141
+ return s
142
+ }
143
+
144
+ /**
145
+ * Line-based parser for ONLY the `context_injection:` block of
146
+ * `.trellis/config.yaml`. Not a general YAML parser — mirrors
147
+ * `common.config.get_context_injection_limits()` semantics for this
148
+ * section only (missing keys keep the default; invalid/negative values
149
+ * fall back to the default for that key with a debugLog warning).
150
+ */
151
+ function readContextInjectionLimits(repoRoot) {
152
+ const limits = { ...DEFAULT_CONTEXT_INJECTION_LIMITS }
153
+ let text = null
154
+ try {
155
+ text = readFileSync(join(repoRoot, ".trellis", "config.yaml"), "utf-8")
156
+ } catch {
157
+ return limits
158
+ }
159
+ if (!text) return limits
160
+
161
+ let inSection = false
162
+ let sectionIndent = -1
163
+ for (const rawLine of text.split(/\r?\n/)) {
164
+ const trimmed = rawLine.trim()
165
+ if (!inSection) {
166
+ if (/^context_injection\s*:\s*(#.*)?$/.test(trimmed)) {
167
+ inSection = true
168
+ sectionIndent = rawLine.length - rawLine.trimStart().length
169
+ }
170
+ continue
171
+ }
172
+ if (!trimmed || trimmed.startsWith("#")) continue
173
+ const indent = rawLine.length - rawLine.trimStart().length
174
+ if (indent <= sectionIndent) break
175
+ const m = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/)
176
+ if (!m) continue
177
+ const key = m[1]
178
+ if (!(key in limits)) continue
179
+ const raw = unquoteYaml(stripInlineComment(m[2]).trim()).trim()
180
+ if (!/^-?\d+$/.test(raw) || parseInt(raw, 10) < 0) {
181
+ // invalid/negative -> keep default (Python warns on stderr)
182
+ debugLog("context", `invalid context_injection.${key} value: ${raw}; using default ${limits[key]}`)
183
+ continue
184
+ }
185
+ limits[key] = parseInt(raw, 10)
186
+ }
187
+ return limits
188
+ }
189
+
190
+ /** Tracks the running total of bytes emitted into the sub-agent context. */
191
+ class ContextBudget {
192
+ constructor(maxTotalBytes) {
193
+ this.maxTotalBytes = maxTotalBytes
194
+ this.used = 0
195
+ }
196
+
197
+ hasRoom(size) {
198
+ if (this.maxTotalBytes <= 0) return true
199
+ return this.used + size <= this.maxTotalBytes
200
+ }
201
+
202
+ add(size) {
203
+ this.used += size
204
+ }
205
+ }
206
+
207
+ function truncateNotice(path, cap) {
208
+ return `\n[Trellis: truncated at ${cap} bytes — read ${path} for the full content]`
209
+ }
210
+
211
+ function isBinaryContent(data) {
212
+ return data.includes(0) || !isUtf8(data)
213
+ }
214
+
215
+ function binaryNotice(path, size, reason) {
216
+ return `[Trellis: not inlined (binary file) — ${path} (${size} bytes): ${reason}]`
217
+ }
218
+
219
+ function indexNotice(path, size, reason) {
220
+ return `[Trellis: not inlined (total context limit reached) — ${path} (${size} bytes): ${reason}]`
221
+ }
222
+
223
+ /**
224
+ * Return an inlined `=== header ===` block, or degrade to an index
225
+ * notice once the total context budget is exhausted.
226
+ */
227
+ function budgetedBlock(budget, header, plainPath, content, reason, sizeForIndex) {
228
+ const block = `=== ${header} ===\n${content}`
229
+ const blockBytes = Buffer.byteLength(block, "utf-8")
230
+ if (!budget.hasRoom(blockBytes)) {
231
+ const notice = indexNotice(plainPath, sizeForIndex, reason)
232
+ budget.add(Buffer.byteLength(notice, "utf-8"))
233
+ return notice
234
+ }
235
+ budget.add(blockBytes)
236
+ return block
237
+ }
238
+
239
+ /** Read raw file bytes, return null if file doesn't exist. */
240
+ function readFileBytes(basePath, filePath) {
241
+ const fullPath = isAbsolute(filePath) ? filePath : join(basePath, filePath)
242
+ try {
243
+ if (!statSync(fullPath).isFile()) return null
244
+ } catch {
245
+ return null
246
+ }
247
+ try {
248
+ return readFileSync(fullPath)
249
+ } catch {
250
+ return null
251
+ }
252
+ }
253
+
254
+ /** Read a JSONL-referenced file, apply the per-file cap, then budget it. */
255
+ function materializeFile(basePath, filePath, reason, limits, budget) {
256
+ const data = readFileBytes(basePath, filePath)
257
+ if (data === null) return null
258
+
259
+ const size = data.length
260
+ if (isBinaryContent(data)) {
261
+ const notice = binaryNotice(filePath, size, reason)
262
+ budget.add(Buffer.byteLength(notice, "utf-8"))
263
+ return notice
264
+ }
265
+ const cap = limits.max_file_bytes
266
+ const truncated = truncateUtf8(data, cap)
267
+ let content = truncated.toString("utf-8")
268
+ if (truncated.length < size) content += truncateNotice(filePath, cap)
269
+
270
+ return budgetedBlock(budget, filePath, filePath, content, reason, size)
271
+ }
272
+
273
+ /**
274
+ * Read all .md files in a directory, applying the same per-file and
275
+ * total caps as a single-file JSONL entry.
276
+ */
277
+ function materializeDirectory(basePath, dirPath, reason, limits, budget, maxFiles = 20) {
278
+ const blocks = []
279
+ const fullPath = isAbsolute(dirPath) ? dirPath : join(basePath, dirPath)
280
+
281
+ let files
282
+ try {
283
+ if (!statSync(fullPath).isDirectory()) return blocks
284
+ files = readdirSync(fullPath)
285
+ .filter(f => f.endsWith(".md") && statSync(join(fullPath, f)).isFile())
286
+ .sort()
287
+ } catch {
288
+ return blocks
289
+ }
290
+
291
+ for (const filename of files.slice(0, maxFiles)) {
292
+ const relativePath = join(dirPath, filename)
293
+ const block = materializeFile(basePath, relativePath, reason, limits, budget)
294
+ if (block) blocks.push(block)
295
+ }
296
+ return blocks
297
+ }
298
+
299
+ /**
300
+ * Read a task artifact (prd/design/implement.md), apply the per-artifact
301
+ * cap, then budget it.
302
+ */
303
+ function materializeArtifact(basePath, filePath, headerLabel, reason, limits, budget) {
304
+ const data = readFileBytes(basePath, filePath)
305
+ if (data === null) return null
306
+
307
+ const size = data.length
308
+ const cap = limits.max_artifact_bytes
309
+ const truncated = truncateUtf8(data, cap)
310
+ let content = truncated.toString("utf-8")
311
+ if (truncated.length < size) content += truncateNotice(filePath, cap)
312
+
313
+ return budgetedBlock(budget, headerLabel, filePath, content, reason, size)
314
+ }
315
+
81
316
  /**
82
317
  * Trellis Context Manager
83
318
  */
@@ -282,44 +517,21 @@ export class TrellisContext {
282
517
  // JSONL Reading
283
518
  // ============================================================
284
519
 
285
- readDirectoryMdFiles(dirPath, maxFiles = 20) {
286
- const results = []
287
- const fullPath = join(this.directory, dirPath)
288
-
289
- if (!existsSync(fullPath)) {
290
- return results
291
- }
292
-
293
- try {
294
- const files = readdirSync(fullPath)
295
- .filter(f => f.endsWith(".md"))
296
- .sort()
297
- .slice(0, maxFiles)
298
-
299
- for (const filename of files) {
300
- const filePath = join(dirPath, filename)
301
- const content = this.readProjectFile(filePath)
302
- if (content) {
303
- results.push({ path: filePath, content })
304
- }
305
- }
306
- } catch {
307
- // Ignore directory read errors
308
- }
309
-
310
- return results
311
- }
312
-
313
520
  /**
314
- * Read a JSONL file and load referenced files/directories
521
+ * Read a JSONL file and materialize referenced files/directories into
522
+ * context blocks, applying per-file caps and the shared total budget
523
+ * (issue #441). Mirrors Python `_materialize_jsonl_entries`.
315
524
  * Supports:
316
525
  * {"file": "path/to/file.md", "reason": "..."}
317
526
  * {"file": "path/to/dir/", "type": "directory", "reason": "..."}
527
+ *
528
+ * Missing referenced files are skipped silently (Python `_materialize_file`
529
+ * returns None for them).
318
530
  */
319
- readJsonlWithFiles(jsonlPath) {
320
- const results = []
531
+ readJsonlWithFiles(jsonlPath, limits, budget) {
532
+ const blocks = []
321
533
  const content = this.readFile(jsonlPath)
322
- if (!content) return results
534
+ if (!content) return blocks
323
535
 
324
536
  for (const line of content.split("\n")) {
325
537
  if (!line.trim()) continue
@@ -327,28 +539,25 @@ export class TrellisContext {
327
539
  const item = JSON.parse(line)
328
540
  const file = item.file || item.path
329
541
  const entryType = item.type || "file"
542
+ const reason = item.reason || "-"
330
543
 
331
544
  if (!file) continue
332
545
 
333
546
  if (entryType === "directory") {
334
- const dirEntries = this.readDirectoryMdFiles(file)
335
- results.push(...dirEntries)
547
+ blocks.push(...materializeDirectory(this.directory, file, reason, limits, budget))
336
548
  } else {
337
- const fullPath = join(this.directory, file)
338
- const fileContent = this.readFile(fullPath)
339
- if (fileContent) {
340
- results.push({ path: file, content: fileContent })
341
- }
549
+ const block = materializeFile(this.directory, file, reason, limits, budget)
550
+ if (block) blocks.push(block)
342
551
  }
343
552
  } catch {
344
553
  // Ignore parse errors for individual lines
345
554
  }
346
555
  }
347
- return results
556
+ return blocks
348
557
  }
349
558
 
350
- buildContextFromEntries(entries) {
351
- return entries.map(e => `=== ${e.path} ===\n${e.content}`).join("\n\n")
559
+ buildContextFromEntries(blocks) {
560
+ return blocks.join("\n\n")
352
561
  }
353
562
  }
354
563
 
@@ -379,3 +588,14 @@ export const contextCollector = new ContextCollector()
379
588
 
380
589
  // Export debug log for plugins
381
590
  export { debugLog }
591
+
592
+ // Context injection limits (issue #441) — exported for plugins and tests
593
+ export {
594
+ DEFAULT_CONTEXT_INJECTION_LIMITS,
595
+ truncateUtf8,
596
+ readContextInjectionLimits,
597
+ ContextBudget,
598
+ materializeFile,
599
+ materializeDirectory,
600
+ materializeArtifact,
601
+ }