@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.
- 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.9.json +9 -0
- package/dist/templates/codex/agents/trellis-check.toml +2 -0
- package/dist/templates/codex/agents/trellis-implement.toml +2 -0
- package/dist/templates/codex/agents/trellis-research.toml +2 -0
- 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 +4 -0
- package/dist/templates/trellis/scripts/common/config.py +89 -2
- package/dist/templates/trellis/scripts/common/task_context.py +82 -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
|
@@ -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 })
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
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
|
|
946
|
-
const
|
|
947
|
-
const
|
|
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
|
-
|
|
1176
|
+
const specBlocks: string[] = [];
|
|
950
1177
|
if (jsonlName) {
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
const
|
|
954
|
-
if (
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
}
|