@mindfoldhq/trellis 0.6.8 → 0.6.10
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.
- package/dist/cli/index.js +1 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/channel/agent-loader.d.ts +2 -2
- package/dist/commands/channel/agent-loader.d.ts.map +1 -1
- package/dist/commands/channel/agent-loader.js +9 -5
- package/dist/commands/channel/agent-loader.js.map +1 -1
- package/dist/commands/channel/context-loader.d.ts +1 -1
- package/dist/commands/channel/context-loader.d.ts.map +1 -1
- package/dist/commands/channel/context-loader.js +36 -15
- package/dist/commands/channel/context-loader.js.map +1 -1
- package/dist/commands/channel/context-trust.d.ts +31 -0
- package/dist/commands/channel/context-trust.d.ts.map +1 -0
- package/dist/commands/channel/context-trust.js +139 -0
- package/dist/commands/channel/context-trust.js.map +1 -0
- package/dist/commands/channel/spawn.d.ts.map +1 -1
- package/dist/commands/channel/spawn.js +4 -2
- package/dist/commands/channel/spawn.js.map +1 -1
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +17 -0
- package/dist/commands/update.js.map +1 -1
- package/dist/configurators/codex.d.ts +36 -0
- package/dist/configurators/codex.d.ts.map +1 -1
- package/dist/configurators/codex.js +100 -1
- package/dist/configurators/codex.js.map +1 -1
- package/dist/configurators/index.d.ts.map +1 -1
- package/dist/configurators/index.js +5 -0
- package/dist/configurators/index.js.map +1 -1
- package/dist/configurators/shared.d.ts.map +1 -1
- package/dist/configurators/shared.js +1 -0
- package/dist/configurators/shared.js.map +1 -1
- package/dist/configurators/snow.d.ts +31 -0
- package/dist/configurators/snow.d.ts.map +1 -0
- package/dist/configurators/snow.js +95 -0
- package/dist/configurators/snow.js.map +1 -0
- package/dist/configurators/workflow.d.ts +14 -0
- package/dist/configurators/workflow.d.ts.map +1 -1
- package/dist/configurators/workflow.js +37 -1
- package/dist/configurators/workflow.js.map +1 -1
- package/dist/migrations/manifests/0.6.10.json +9 -0
- package/dist/migrations/manifests/0.6.9.json +9 -0
- package/dist/templates/codex/agents/trellis-check.toml +7 -3
- package/dist/templates/codex/agents/trellis-implement.toml +7 -3
- package/dist/templates/codex/agents/trellis-research.toml +13 -6
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +4 -2
- package/dist/templates/kimi/agents/trellis-research.md +7 -3
- package/dist/templates/omp/extensions/trellis/index.ts.txt +114 -4
- package/dist/templates/opencode/lib/trellis-context.js +263 -43
- package/dist/templates/opencode/plugins/inject-subagent-context.js +89 -35
- package/dist/templates/opencode/plugins/inject-workflow-state.js +90 -5
- package/dist/templates/pi/extensions/trellis/index.ts.txt +270 -22
- package/dist/templates/shared-hooks/inject-subagent-context.py +311 -83
- package/dist/templates/shared-hooks/inject-workflow-state.py +36 -1
- package/dist/templates/snow/SNOW.md +86 -0
- package/dist/templates/snow/agents/trellis-check.md +135 -0
- package/dist/templates/snow/agents/trellis-implement.md +153 -0
- package/dist/templates/snow/agents/trellis-research.md +114 -0
- package/dist/templates/snow/hooks/beforeSubAgentStart.json +15 -0
- package/dist/templates/snow/hooks/onSessionStart.json +15 -0
- package/dist/templates/snow/hooks/onUserMessage.json +15 -0
- package/dist/templates/snow/hooks/write-trellis-context.py +644 -0
- package/dist/templates/snow/index.d.ts +26 -0
- package/dist/templates/snow/index.d.ts.map +1 -0
- package/dist/templates/snow/index.js +36 -0
- package/dist/templates/snow/index.js.map +1 -0
- package/dist/templates/template-utils.d.ts.map +1 -1
- package/dist/templates/template-utils.js +5 -1
- package/dist/templates/template-utils.js.map +1 -1
- package/dist/templates/trellis/config.yaml +46 -0
- package/dist/templates/trellis/gitattributes.txt +9 -0
- package/dist/templates/trellis/index.d.ts +3 -1
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +3 -1
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/add_session.py +94 -28
- package/dist/templates/trellis/scripts/common/active_task.py +9 -2
- package/dist/templates/trellis/scripts/common/config.py +89 -2
- package/dist/templates/trellis/scripts/common/task_context.py +84 -6
- package/dist/templates/trellis/scripts/common/task_store.py +63 -1
- package/dist/templates/trellis/scripts/common/trellis_config.py +2 -1
- package/dist/templates/trellis/scripts/task.py +21 -2
- package/dist/templates/trellis/workflow.md +14 -14
- package/dist/types/ai-tools.d.ts +3 -3
- package/dist/types/ai-tools.d.ts.map +1 -1
- package/dist/types/ai-tools.js +38 -0
- package/dist/types/ai-tools.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
|
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
|
|
531
|
+
readJsonlWithFiles(jsonlPath, limits, budget) {
|
|
532
|
+
const blocks = []
|
|
321
533
|
const content = this.readFile(jsonlPath)
|
|
322
|
-
if (!content) return
|
|
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
|
-
|
|
335
|
-
results.push(...dirEntries)
|
|
547
|
+
blocks.push(...materializeDirectory(this.directory, file, reason, limits, budget))
|
|
336
548
|
} else {
|
|
337
|
-
const
|
|
338
|
-
|
|
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
|
|
556
|
+
return blocks
|
|
348
557
|
}
|
|
349
558
|
|
|
350
|
-
buildContextFromEntries(
|
|
351
|
-
return
|
|
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
|
+
}
|
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readdirSync } from "fs"
|
|
10
10
|
import { join } from "path"
|
|
11
|
-
import {
|
|
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
|
|
39
|
-
const
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
71
|
-
const
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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 })
|