@mindfoldhq/trellis 0.5.0-rc.0 → 0.5.0-rc.1
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.
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.5.0-rc.1",
|
|
3
|
+
"description": "Patches rc.0 with two OpenCode fixes: trellis-research subagent gains write permission and persists findings to {TASK_DIR}/research/ (#211); session-start.js extracts named exports to lib/session-utils.js so the OpenCode 1.2.x loader reaches export default (#212). No new migrations.",
|
|
4
|
+
"breaking": false,
|
|
5
|
+
"recommendMigrate": false,
|
|
6
|
+
"changelog": "**Bug Fixes:**\n- fix(opencode): `trellis-research` subagent template grants `write: allow` / `edit: allow` and the prompt body now matches the cursor/claude shape (Core Principle, Workflow Step 1–5 with `mkdir -p {TASK_DIR}/research/`, Scope Limits, File Format, DO/DON'T). The redundant 'Context Self-Loading' section is removed; `inject-subagent-context.js` already pre-loads spec dir context (#211).\n- fix(opencode): extract `buildSessionContext` and `hasInjectedTrellisContext` from `.opencode/plugins/session-start.js` into `lib/session-utils.js` so each plugin file has only `export default`. The OpenCode 1.2.x loader iterates `Object.entries(mod)` and invokes every export as a factory; the named exports caused the loader to abort before reaching the default export (#212).\n\n**Testing:**\n- test(opencode): walk `templates/opencode/plugins/*.js` and assert each file has exactly one top-level export matching `^export\\s+default\\s/` to prevent #212-class regressions.\n- test(opencode): extend the `research agent persists findings to task dir` regression group with an OpenCode-specific case asserting YAML `permission:` frontmatter and body contents, closing the gap that masked #211.",
|
|
7
|
+
"migrations": [],
|
|
8
|
+
"notes": "RC install: `npm install -g @mindfoldhq/trellis@rc`. Projects on 0.5 (beta or rc.0) run `trellis update`. Projects upgrading from 0.4.x run `trellis update --migrate`; the 0.5 migration chain begins at 0.5.0-beta.0. rc.1 adds no new migration entries."
|
|
9
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: |
|
|
3
|
-
Code and tech search expert.
|
|
3
|
+
Code and tech search expert. Finds files, patterns, and tech solutions, and PERSISTS every finding to the current task's research/ directory. No code modifications outside that directory.
|
|
4
4
|
mode: subagent
|
|
5
5
|
permission:
|
|
6
6
|
read: allow
|
|
7
|
-
write:
|
|
8
|
-
edit:
|
|
7
|
+
write: allow
|
|
8
|
+
edit: allow
|
|
9
9
|
bash: allow
|
|
10
10
|
glob: allow
|
|
11
11
|
grep: allow
|
|
@@ -16,114 +16,113 @@ permission:
|
|
|
16
16
|
|
|
17
17
|
You are the Research Agent in the Trellis workflow.
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## Core Principle
|
|
20
|
+
|
|
21
|
+
**You do one thing: find, explain, and PERSIST information.**
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
Conversations get compacted; files don't. Every research output MUST end up as a file under `{TASK_DIR}/research/`. Returning findings only through the chat reply is a failure — the caller cannot read them next session.
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
---
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
2. For each entry in JSONL (if task dir exists):
|
|
27
|
-
- If `path` is a file → Read it
|
|
28
|
-
- If `path` is a directory → Read all `.md` files in it
|
|
27
|
+
## Core Responsibilities
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
1. **Internal Search** — locate files/components, understand code logic, discover patterns (Glob, Grep, Read)
|
|
30
|
+
2. **External Search** — library docs, API references, best practices (web search)
|
|
31
|
+
3. **Persist** — write each research topic to `{TASK_DIR}/research/<topic>.md`
|
|
32
|
+
4. **Report** — return file paths + one-line summaries to the main agent (not full content)
|
|
34
33
|
|
|
35
34
|
---
|
|
36
35
|
|
|
37
|
-
##
|
|
36
|
+
## Workflow
|
|
38
37
|
|
|
39
|
-
|
|
38
|
+
### Step 1: Resolve Current Task
|
|
40
39
|
|
|
41
|
-
|
|
40
|
+
Run `python3 ./.trellis/scripts/task.py current --source` → active task path. If no active task is set, ask the user where to write output; do NOT guess.
|
|
42
41
|
|
|
43
|
-
|
|
42
|
+
Ensure `{TASK_DIR}/research/` exists:
|
|
44
43
|
|
|
45
|
-
|
|
44
|
+
```bash
|
|
45
|
+
mkdir -p <TASK_DIR>/research
|
|
46
|
+
```
|
|
46
47
|
|
|
47
|
-
###
|
|
48
|
+
### Step 2: Understand Search Request
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|-------------|------|-------|
|
|
51
|
-
| **WHERE** | Locate files/components | Glob, Grep |
|
|
52
|
-
| **HOW** | Understand code logic | Read, Grep |
|
|
53
|
-
| **PATTERN** | Discover existing patterns | Grep, Read |
|
|
50
|
+
Classify: internal / external / mixed. Determine scope (global / specific directory) and expected shape (file list / pattern notes / tech comparison).
|
|
54
51
|
|
|
55
|
-
###
|
|
52
|
+
### Step 3: Execute Search
|
|
56
53
|
|
|
57
|
-
|
|
54
|
+
Run independent searches in parallel (Glob + Grep + web) for efficiency.
|
|
58
55
|
|
|
59
|
-
|
|
56
|
+
### Step 4: Persist Each Topic
|
|
60
57
|
|
|
61
|
-
|
|
58
|
+
For each distinct research topic, Write a markdown file at `{TASK_DIR}/research/<topic-slug>.md`. Use the File Format below.
|
|
62
59
|
|
|
63
|
-
###
|
|
60
|
+
### Step 5: Report to Main Agent
|
|
64
61
|
|
|
65
|
-
|
|
66
|
-
- Describe **where it is**
|
|
67
|
-
- Describe **how it works**
|
|
68
|
-
- Describe **how components interact**
|
|
62
|
+
Reply with ONLY:
|
|
69
63
|
|
|
70
|
-
|
|
64
|
+
- List of files written (paths relative to repo root)
|
|
65
|
+
- One-line summary per file
|
|
66
|
+
- Any critical caveats that the main agent needs to know right now
|
|
71
67
|
|
|
72
|
-
|
|
73
|
-
- Criticize implementation
|
|
74
|
-
- Recommend refactoring
|
|
75
|
-
- Modify any files
|
|
76
|
-
- Execute git commands
|
|
68
|
+
Do NOT paste full research content into the reply. The files are the contract.
|
|
77
69
|
|
|
78
70
|
---
|
|
79
71
|
|
|
80
|
-
##
|
|
81
|
-
|
|
82
|
-
### Step 1: Understand Search Request
|
|
72
|
+
## Scope Limits (Strict)
|
|
83
73
|
|
|
84
|
-
|
|
74
|
+
### Write ALLOWED
|
|
85
75
|
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
- Expected output (file list/code patterns/tech solutions)
|
|
76
|
+
- `{TASK_DIR}/research/*.md` — your own output
|
|
77
|
+
- Creating `{TASK_DIR}/research/` if it doesn't exist (via `mkdir -p`)
|
|
89
78
|
|
|
90
|
-
###
|
|
79
|
+
### Write FORBIDDEN
|
|
91
80
|
|
|
92
|
-
|
|
81
|
+
- Code files (`src/`, `lib/`, …)
|
|
82
|
+
- Spec files (`.trellis/spec/`) — main agent should use `update-spec` skill instead
|
|
83
|
+
- `.trellis/scripts/`, `.trellis/workflow.md`, platform config (`.claude/`, `.cursor/`, `.opencode/`, etc.)
|
|
84
|
+
- Other task directories
|
|
85
|
+
- Any git operation (commit / push / branch / merge)
|
|
93
86
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
Output structured results in report format.
|
|
87
|
+
If the user asks you to edit code, decline and suggest spawning `implement` instead.
|
|
97
88
|
|
|
98
89
|
---
|
|
99
90
|
|
|
100
|
-
##
|
|
91
|
+
## File Format
|
|
92
|
+
|
|
93
|
+
Each `{TASK_DIR}/research/<topic>.md` should follow:
|
|
101
94
|
|
|
102
95
|
```markdown
|
|
103
|
-
|
|
96
|
+
# Research: <topic>
|
|
104
97
|
|
|
105
|
-
|
|
98
|
+
- **Query**: <original query>
|
|
99
|
+
- **Scope**: <internal / external / mixed>
|
|
100
|
+
- **Date**: <YYYY-MM-DD>
|
|
106
101
|
|
|
107
|
-
|
|
102
|
+
## Findings
|
|
108
103
|
|
|
109
104
|
### Files Found
|
|
110
105
|
|
|
111
106
|
| File Path | Description |
|
|
112
|
-
|
|
107
|
+
|---|---|
|
|
113
108
|
| `src/services/xxx.ts` | Main implementation |
|
|
114
109
|
| `src/types/xxx.ts` | Type definitions |
|
|
115
110
|
|
|
116
|
-
### Code
|
|
111
|
+
### Code Patterns
|
|
112
|
+
|
|
113
|
+
<describe patterns, cite file:line>
|
|
114
|
+
|
|
115
|
+
### External References
|
|
117
116
|
|
|
118
|
-
|
|
117
|
+
- [Library X docs](url) — <why relevant, version constraints>
|
|
119
118
|
|
|
120
|
-
### Related
|
|
119
|
+
### Related Specs
|
|
121
120
|
|
|
122
|
-
- `.trellis/spec/xxx.md`
|
|
121
|
+
- `.trellis/spec/xxx.md` — <description>
|
|
123
122
|
|
|
124
|
-
|
|
123
|
+
## Caveats / Not Found
|
|
125
124
|
|
|
126
|
-
|
|
125
|
+
<anything incomplete or uncertain>
|
|
127
126
|
```
|
|
128
127
|
|
|
129
128
|
---
|
|
@@ -134,12 +133,13 @@ Output structured results in report format.
|
|
|
134
133
|
|
|
135
134
|
- Provide specific file paths and line numbers
|
|
136
135
|
- Quote actual code snippets
|
|
137
|
-
-
|
|
138
|
-
-
|
|
136
|
+
- Persist every topic to its own file
|
|
137
|
+
- Return file paths in your reply, not the full content
|
|
138
|
+
- Mark "not found" explicitly when searches come up empty
|
|
139
139
|
|
|
140
140
|
### DON'T
|
|
141
141
|
|
|
142
|
+
- Don't write code or modify files outside `{TASK_DIR}/research/`
|
|
142
143
|
- Don't guess uncertain info
|
|
143
|
-
- Don't
|
|
144
|
-
- Don't
|
|
145
|
-
- Don't modify any files
|
|
144
|
+
- Don't paste full research text into the reply (files are the deliverable)
|
|
145
|
+
- Don't propose improvements or critique implementation (that's not your role)
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/* global process */
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs"
|
|
3
|
+
import { basename, join } from "path"
|
|
4
|
+
import { execFileSync } from "child_process"
|
|
5
|
+
import { platform } from "os"
|
|
6
|
+
import { debugLog } from "./trellis-context.js"
|
|
7
|
+
|
|
8
|
+
const PYTHON_CMD = platform() === "win32" ? "python" : "python3"
|
|
9
|
+
|
|
10
|
+
const FIRST_REPLY_NOTICE = `<first-reply-notice>
|
|
11
|
+
On the first visible assistant reply in this session, begin with exactly one short Chinese sentence:
|
|
12
|
+
Trellis SessionStart 已注入:workflow、当前任务状态、开发者身份、git 状态、active tasks、spec 索引已加载。
|
|
13
|
+
Then continue directly with the user's request. This notice is one-shot: do not repeat it after the first assistant reply in the same session.
|
|
14
|
+
</first-reply-notice>`
|
|
15
|
+
|
|
16
|
+
function hasCuratedJsonlEntry(jsonlPath) {
|
|
17
|
+
try {
|
|
18
|
+
const content = readFileSync(jsonlPath, "utf-8")
|
|
19
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
20
|
+
const line = rawLine.trim()
|
|
21
|
+
if (!line) continue
|
|
22
|
+
try {
|
|
23
|
+
const row = JSON.parse(line)
|
|
24
|
+
if (row && typeof row === "object" && typeof row.file === "string" && row.file) {
|
|
25
|
+
return true
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// Ignore malformed line
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getTaskStatus(ctx, platformInput = null) {
|
|
38
|
+
const active = ctx.getActiveTask(platformInput)
|
|
39
|
+
const taskRef = active.taskPath
|
|
40
|
+
if (!taskRef) {
|
|
41
|
+
return `Status: NO ACTIVE TASK\nSource: ${active.source}\nNext: Describe what you want to work on`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const taskDir = ctx.resolveTaskDir(taskRef)
|
|
45
|
+
|
|
46
|
+
if (active.stale || !taskDir || !existsSync(taskDir)) {
|
|
47
|
+
return `Status: STALE POINTER\nTask: ${taskRef}\nSource: ${active.source}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let taskData = {}
|
|
51
|
+
const taskJsonPath = join(taskDir, "task.json")
|
|
52
|
+
if (existsSync(taskJsonPath)) {
|
|
53
|
+
try {
|
|
54
|
+
taskData = JSON.parse(readFileSync(taskJsonPath, "utf-8"))
|
|
55
|
+
} catch {
|
|
56
|
+
// Ignore parse errors
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const taskTitle = taskData.title || taskRef
|
|
61
|
+
const taskStatus = taskData.status || "unknown"
|
|
62
|
+
|
|
63
|
+
if (taskStatus === "completed") {
|
|
64
|
+
const dirName = basename(taskDir)
|
|
65
|
+
return `Status: COMPLETED\nTask: ${taskTitle}\nSource: ${active.source}\nNext: Archive with \`python3 ./.trellis/scripts/task.py archive ${dirName}\` or start a new task`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let hasContext = false
|
|
69
|
+
for (const jsonlName of ["implement.jsonl", "check.jsonl"]) {
|
|
70
|
+
const jsonlPath = join(taskDir, jsonlName)
|
|
71
|
+
if (existsSync(jsonlPath) && hasCuratedJsonlEntry(jsonlPath)) {
|
|
72
|
+
hasContext = true
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const hasPrd = existsSync(join(taskDir, "prd.md"))
|
|
78
|
+
|
|
79
|
+
if (!hasPrd) {
|
|
80
|
+
return `Status: NOT READY\nTask: ${taskTitle}\nSource: ${active.source}\nMissing: prd.md not created\nNext: Write PRD (see workflow.md Phase 1.1) then curate implement.jsonl per Phase 1.3`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!hasContext) {
|
|
84
|
+
return `Status: NOT READY\nTask: ${taskTitle}\nSource: ${active.source}\nMissing: implement.jsonl / check.jsonl missing or empty\nNext: Curate entries per workflow.md Phase 1.3 (spec + research files only), then \`task.py start\``
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
`Status: READY\nTask: ${taskTitle}\n` +
|
|
89
|
+
`Source: ${active.source}\n` +
|
|
90
|
+
"Next required action: dispatch `trellis-implement` per Phase 2.1. " +
|
|
91
|
+
"For agent-capable platforms, the default is to NOT edit code in the main session. " +
|
|
92
|
+
"After implementation, dispatch `trellis-check` per Phase 2.2 before reporting completion.\n" +
|
|
93
|
+
"User override (per-turn escape hatch): if the user's CURRENT message explicitly tells the " +
|
|
94
|
+
"main session to handle it directly (\"你直接改\" / \"别派 sub-agent\" / \"main session 写就行\" / " +
|
|
95
|
+
"\"do it inline\" / \"不用 sub-agent\"), honor it for this turn and edit code directly. " +
|
|
96
|
+
"Per-turn only; do NOT invent an override the user did not say."
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function loadTrellisConfig(directory, contextKey = null) {
|
|
101
|
+
const scriptPath = join(directory, ".trellis", "scripts", "get_context.py")
|
|
102
|
+
if (!existsSync(scriptPath)) {
|
|
103
|
+
return { isMonorepo: false, packages: {}, specScope: null, activeTaskPackage: null, defaultPackage: null }
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const output = execFileSync(PYTHON_CMD, [scriptPath, "--mode", "packages", "--json"], {
|
|
107
|
+
cwd: directory,
|
|
108
|
+
timeout: 5000,
|
|
109
|
+
encoding: "utf-8",
|
|
110
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
111
|
+
env: {
|
|
112
|
+
...process.env,
|
|
113
|
+
...(contextKey ? { TRELLIS_CONTEXT_ID: contextKey } : {}),
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
const data = JSON.parse(output)
|
|
117
|
+
if (data.mode !== "monorepo") {
|
|
118
|
+
return { isMonorepo: false, packages: {}, specScope: null, activeTaskPackage: null, defaultPackage: null }
|
|
119
|
+
}
|
|
120
|
+
const pkgDict = {}
|
|
121
|
+
for (const pkg of (data.packages || [])) {
|
|
122
|
+
pkgDict[pkg.name] = pkg
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
isMonorepo: true,
|
|
126
|
+
packages: pkgDict,
|
|
127
|
+
specScope: data.specScope || null,
|
|
128
|
+
activeTaskPackage: data.activeTaskPackage || null,
|
|
129
|
+
defaultPackage: data.defaultPackage || null,
|
|
130
|
+
}
|
|
131
|
+
} catch (e) {
|
|
132
|
+
debugLog("session", "loadTrellisConfig error:", e.message)
|
|
133
|
+
return { isMonorepo: false, packages: {}, specScope: null, activeTaskPackage: null, defaultPackage: null }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function checkLegacySpec(directory, config) {
|
|
138
|
+
if (!config.isMonorepo || Object.keys(config.packages).length === 0) {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const specDir = join(directory, ".trellis", "spec")
|
|
143
|
+
if (!existsSync(specDir)) return null
|
|
144
|
+
|
|
145
|
+
let hasLegacy = false
|
|
146
|
+
for (const name of ["backend", "frontend"]) {
|
|
147
|
+
if (existsSync(join(specDir, name, "index.md"))) {
|
|
148
|
+
hasLegacy = true
|
|
149
|
+
break
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!hasLegacy) return null
|
|
153
|
+
|
|
154
|
+
const pkgNames = Object.keys(config.packages).sort()
|
|
155
|
+
const missing = pkgNames.filter(name => !existsSync(join(specDir, name)))
|
|
156
|
+
|
|
157
|
+
if (missing.length === 0) return null
|
|
158
|
+
|
|
159
|
+
if (missing.length === pkgNames.length) {
|
|
160
|
+
return (
|
|
161
|
+
`[!] Legacy spec structure detected: found \`spec/backend/\` or \`spec/frontend/\` ` +
|
|
162
|
+
`but no package-scoped \`spec/<package>/\` directories.\n` +
|
|
163
|
+
`Monorepo packages: ${pkgNames.join(", ")}\n` +
|
|
164
|
+
`Please reorganize: \`spec/backend/\` -> \`spec/<package>/backend/\``
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
return (
|
|
168
|
+
`[!] Partial spec migration detected: packages ${missing.join(", ")} ` +
|
|
169
|
+
`still missing \`spec/<pkg>/\` directory.\n` +
|
|
170
|
+
`Please complete migration for all packages.`
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resolveSpecScope(config) {
|
|
175
|
+
if (!config.isMonorepo || Object.keys(config.packages).length === 0) {
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { specScope, activeTaskPackage, defaultPackage, packages } = config
|
|
180
|
+
if (specScope == null) return null
|
|
181
|
+
|
|
182
|
+
if (specScope === "active_task") {
|
|
183
|
+
if (activeTaskPackage && activeTaskPackage in packages) return new Set([activeTaskPackage])
|
|
184
|
+
if (defaultPackage && defaultPackage in packages) return new Set([defaultPackage])
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (Array.isArray(specScope)) {
|
|
189
|
+
const valid = new Set()
|
|
190
|
+
for (const entry of specScope) {
|
|
191
|
+
if (entry in packages) {
|
|
192
|
+
valid.add(entry)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (valid.size > 0) return valid
|
|
196
|
+
if (activeTaskPackage && activeTaskPackage in packages) return new Set([activeTaskPackage])
|
|
197
|
+
if (defaultPackage && defaultPackage in packages) return new Set([defaultPackage])
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function buildSessionContext(ctx, platformInput = null) {
|
|
205
|
+
const directory = ctx.directory
|
|
206
|
+
const trellisDir = join(directory, ".trellis")
|
|
207
|
+
const contextKey = typeof ctx.getContextKey === "function"
|
|
208
|
+
? ctx.getContextKey(platformInput)
|
|
209
|
+
: null
|
|
210
|
+
|
|
211
|
+
const config = loadTrellisConfig(directory, contextKey)
|
|
212
|
+
const allowedPkgs = resolveSpecScope(config)
|
|
213
|
+
|
|
214
|
+
const parts = []
|
|
215
|
+
|
|
216
|
+
parts.push(`<trellis-context>
|
|
217
|
+
You are starting a new session in a Trellis-managed project.
|
|
218
|
+
Read and follow all instructions below carefully.
|
|
219
|
+
</trellis-context>`)
|
|
220
|
+
parts.push(FIRST_REPLY_NOTICE)
|
|
221
|
+
|
|
222
|
+
const legacyWarning = checkLegacySpec(directory, config)
|
|
223
|
+
if (legacyWarning) {
|
|
224
|
+
parts.push(`<migration-warning>\n${legacyWarning}\n</migration-warning>`)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const contextScript = join(trellisDir, "scripts", "get_context.py")
|
|
228
|
+
if (existsSync(contextScript)) {
|
|
229
|
+
const output = ctx.runScript(contextScript, undefined, contextKey)
|
|
230
|
+
if (output) {
|
|
231
|
+
parts.push("<current-state>")
|
|
232
|
+
parts.push(output)
|
|
233
|
+
parts.push("</current-state>")
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const workflowContent = ctx.readProjectFile(".trellis/workflow.md")
|
|
238
|
+
if (workflowContent) {
|
|
239
|
+
const allLines = workflowContent.split("\n")
|
|
240
|
+
const overviewLines = [
|
|
241
|
+
"# Development Workflow — Section Index",
|
|
242
|
+
"Full guide: .trellis/workflow.md (read on demand)",
|
|
243
|
+
"",
|
|
244
|
+
"## Table of Contents",
|
|
245
|
+
]
|
|
246
|
+
for (const line of allLines) {
|
|
247
|
+
if (line.startsWith("## ")) overviewLines.push(line)
|
|
248
|
+
}
|
|
249
|
+
overviewLines.push("", "---", "")
|
|
250
|
+
|
|
251
|
+
let rangeStart = -1
|
|
252
|
+
let rangeEnd = allLines.length
|
|
253
|
+
for (let i = 0; i < allLines.length; i++) {
|
|
254
|
+
const stripped = allLines[i].trim()
|
|
255
|
+
if (rangeStart === -1 && stripped === "## Phase Index") {
|
|
256
|
+
rangeStart = i
|
|
257
|
+
} else if (rangeStart !== -1 && stripped === "## Workflow State Breadcrumbs") {
|
|
258
|
+
rangeEnd = i
|
|
259
|
+
break
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (rangeStart !== -1) {
|
|
263
|
+
overviewLines.push(...allLines.slice(rangeStart, rangeEnd))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
parts.push("<workflow>")
|
|
267
|
+
parts.push(overviewLines.join("\n").trimEnd())
|
|
268
|
+
parts.push("</workflow>")
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
parts.push("<guidelines>")
|
|
272
|
+
parts.push(
|
|
273
|
+
"Project spec indexes are listed by path below. Each index contains a " +
|
|
274
|
+
"**Pre-Development Checklist** listing the specific guideline files to " +
|
|
275
|
+
"read before coding.\n\n" +
|
|
276
|
+
"- If you're spawning an implement/check sub-agent, context is injected " +
|
|
277
|
+
"automatically via `{task}/implement.jsonl` / `check.jsonl`. You do NOT " +
|
|
278
|
+
"need to read these indexes yourself.\n" +
|
|
279
|
+
"- For agent-capable platforms, do NOT edit code directly in the main " +
|
|
280
|
+
"session; dispatch `trellis-implement` and `trellis-check` so JSONL " +
|
|
281
|
+
"context is loaded by the sub-agents.\n"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
const specDir = join(directory, ".trellis", "spec")
|
|
285
|
+
|
|
286
|
+
const guidesIndex = join(specDir, "guides", "index.md")
|
|
287
|
+
if (existsSync(guidesIndex)) {
|
|
288
|
+
const content = ctx.readFile(guidesIndex)
|
|
289
|
+
if (content) {
|
|
290
|
+
parts.push(`## guides (inlined — cross-package thinking guides)\n${content}\n`)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const paths = []
|
|
295
|
+
if (existsSync(specDir)) {
|
|
296
|
+
try {
|
|
297
|
+
const subs = readdirSync(specDir).filter(name => {
|
|
298
|
+
if (name.startsWith(".")) return false
|
|
299
|
+
try {
|
|
300
|
+
return statSync(join(specDir, name)).isDirectory()
|
|
301
|
+
} catch {
|
|
302
|
+
return false
|
|
303
|
+
}
|
|
304
|
+
}).sort()
|
|
305
|
+
|
|
306
|
+
for (const sub of subs) {
|
|
307
|
+
if (sub === "guides") continue
|
|
308
|
+
|
|
309
|
+
const indexFile = join(specDir, sub, "index.md")
|
|
310
|
+
if (existsSync(indexFile)) {
|
|
311
|
+
paths.push(`.trellis/spec/${sub}/index.md`)
|
|
312
|
+
} else {
|
|
313
|
+
if (allowedPkgs !== null && !allowedPkgs.has(sub)) continue
|
|
314
|
+
try {
|
|
315
|
+
const nested = readdirSync(join(specDir, sub)).filter(name => {
|
|
316
|
+
try {
|
|
317
|
+
return statSync(join(specDir, sub, name)).isDirectory()
|
|
318
|
+
} catch {
|
|
319
|
+
return false
|
|
320
|
+
}
|
|
321
|
+
}).sort()
|
|
322
|
+
for (const layer of nested) {
|
|
323
|
+
const nestedIndex = join(specDir, sub, layer, "index.md")
|
|
324
|
+
if (existsSync(nestedIndex)) {
|
|
325
|
+
paths.push(`.trellis/spec/${sub}/${layer}/index.md`)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
// Ignore directory read errors
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
// Ignore spec directory read errors
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (paths.length > 0) {
|
|
339
|
+
parts.push("## Available spec indexes (read on demand)")
|
|
340
|
+
for (const p of paths) {
|
|
341
|
+
parts.push(`- ${p}`)
|
|
342
|
+
}
|
|
343
|
+
parts.push("")
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
parts.push(
|
|
347
|
+
"Discover more via: " +
|
|
348
|
+
"`python3 ./.trellis/scripts/get_context.py --mode packages`"
|
|
349
|
+
)
|
|
350
|
+
parts.push("</guidelines>")
|
|
351
|
+
|
|
352
|
+
const taskStatus = getTaskStatus(ctx, platformInput)
|
|
353
|
+
parts.push(`<task-status>\n${taskStatus}\n</task-status>`)
|
|
354
|
+
|
|
355
|
+
parts.push(`<ready>
|
|
356
|
+
Context loaded. Workflow index, project state, and guidelines are already injected above — do NOT re-read them.
|
|
357
|
+
When the user sends the first message, follow <task-status> and the workflow guide.
|
|
358
|
+
If a task is READY, execute its Next required action without asking whether to continue.
|
|
359
|
+
</ready>`)
|
|
360
|
+
|
|
361
|
+
return parts.join("\n\n")
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function getTrellisMetadata(metadata) {
|
|
365
|
+
if (!metadata || typeof metadata !== "object") {
|
|
366
|
+
return {}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const trellis = metadata.trellis
|
|
370
|
+
if (!trellis || typeof trellis !== "object") {
|
|
371
|
+
return {}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return trellis
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function markPartAsSessionStart(part) {
|
|
378
|
+
const metadata = part.metadata && typeof part.metadata === "object"
|
|
379
|
+
? part.metadata
|
|
380
|
+
: {}
|
|
381
|
+
part.metadata = {
|
|
382
|
+
...metadata,
|
|
383
|
+
trellis: {
|
|
384
|
+
...getTrellisMetadata(metadata),
|
|
385
|
+
sessionStart: true,
|
|
386
|
+
},
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function hasSessionStartMarker(part) {
|
|
391
|
+
if (!part || part.type !== "text" || typeof part.text !== "string") {
|
|
392
|
+
return false
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return getTrellisMetadata(part.metadata).sessionStart === true
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function hasInjectedTrellisContext(messages) {
|
|
399
|
+
if (!Array.isArray(messages)) {
|
|
400
|
+
return false
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return messages.some(message => {
|
|
404
|
+
if (!message?.info || message.info.role !== "user" || !Array.isArray(message.parts)) {
|
|
405
|
+
return false
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return message.parts.some(hasSessionStartMarker)
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export async function hasPersistedInjectedContext(client, directory, sessionID) {
|
|
413
|
+
try {
|
|
414
|
+
const response = await client.session.messages({
|
|
415
|
+
path: { id: sessionID },
|
|
416
|
+
query: { directory },
|
|
417
|
+
throwOnError: true,
|
|
418
|
+
})
|
|
419
|
+
return hasInjectedTrellisContext(response.data || [])
|
|
420
|
+
} catch (error) {
|
|
421
|
+
debugLog(
|
|
422
|
+
"session",
|
|
423
|
+
"Failed to read session history for dedupe:",
|
|
424
|
+
error instanceof Error ? error.message : String(error),
|
|
425
|
+
)
|
|
426
|
+
return false
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function markContextInjected(part) {
|
|
431
|
+
markPartAsSessionStart(part)
|
|
432
|
+
}
|
|
@@ -6,476 +6,12 @@
|
|
|
6
6
|
* Uses OpenCode's chat.message hook directly so the context persists in history.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "fs"
|
|
10
|
-
import { basename, join } from "path"
|
|
11
|
-
import { execFileSync } from "child_process"
|
|
12
|
-
import { platform } from "os"
|
|
13
9
|
import { TrellisContext, contextCollector, debugLog } from "../lib/trellis-context.js"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Trellis SessionStart 已注入:workflow、当前任务状态、开发者身份、git 状态、active tasks、spec 索引已加载。
|
|
20
|
-
Then continue directly with the user's request. This notice is one-shot: do not repeat it after the first assistant reply in the same session.
|
|
21
|
-
</first-reply-notice>`
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Return true iff jsonl has at least one row with a `file` field.
|
|
26
|
-
* A freshly seeded jsonl only contains a `{"_example": ...}` row (no `file`
|
|
27
|
-
* key) — that is NOT "ready". Readiness requires at least one curated entry.
|
|
28
|
-
* Matches the contract used by `shared-hooks/inject-subagent-context.py`.
|
|
29
|
-
*/
|
|
30
|
-
function hasCuratedJsonlEntry(jsonlPath) {
|
|
31
|
-
try {
|
|
32
|
-
const content = readFileSync(jsonlPath, "utf-8")
|
|
33
|
-
for (const rawLine of content.split(/\r?\n/)) {
|
|
34
|
-
const line = rawLine.trim()
|
|
35
|
-
if (!line) continue
|
|
36
|
-
try {
|
|
37
|
-
const row = JSON.parse(line)
|
|
38
|
-
if (row && typeof row === "object" && typeof row.file === "string" && row.file) {
|
|
39
|
-
return true
|
|
40
|
-
}
|
|
41
|
-
} catch {
|
|
42
|
-
// Ignore malformed line — move on.
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
} catch {
|
|
46
|
-
return false
|
|
47
|
-
}
|
|
48
|
-
return false
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Check current task status and return structured status string.
|
|
53
|
-
* JavaScript equivalent of _get_task_status in Claude's session-start.py.
|
|
54
|
-
*/
|
|
55
|
-
function getTaskStatus(ctx, platformInput = null) {
|
|
56
|
-
const active = ctx.getActiveTask(platformInput)
|
|
57
|
-
const taskRef = active.taskPath
|
|
58
|
-
if (!taskRef) {
|
|
59
|
-
return `Status: NO ACTIVE TASK\nSource: ${active.source}\nNext: Describe what you want to work on`
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const taskDir = ctx.resolveTaskDir(taskRef)
|
|
63
|
-
|
|
64
|
-
if (active.stale || !taskDir || !existsSync(taskDir)) {
|
|
65
|
-
return `Status: STALE POINTER\nTask: ${taskRef}\nSource: ${active.source}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish`
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
let taskData = {}
|
|
69
|
-
const taskJsonPath = join(taskDir, "task.json")
|
|
70
|
-
if (existsSync(taskJsonPath)) {
|
|
71
|
-
try {
|
|
72
|
-
taskData = JSON.parse(readFileSync(taskJsonPath, "utf-8"))
|
|
73
|
-
} catch {
|
|
74
|
-
// Ignore parse errors
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const taskTitle = taskData.title || taskRef
|
|
79
|
-
const taskStatus = taskData.status || "unknown"
|
|
80
|
-
|
|
81
|
-
if (taskStatus === "completed") {
|
|
82
|
-
const dirName = basename(taskDir)
|
|
83
|
-
return `Status: COMPLETED\nTask: ${taskTitle}\nSource: ${active.source}\nNext: Archive with \`python3 ./.trellis/scripts/task.py archive ${dirName}\` or start a new task`
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
let hasContext = false
|
|
87
|
-
for (const jsonlName of ["implement.jsonl", "check.jsonl"]) {
|
|
88
|
-
const jsonlPath = join(taskDir, jsonlName)
|
|
89
|
-
if (existsSync(jsonlPath) && hasCuratedJsonlEntry(jsonlPath)) {
|
|
90
|
-
hasContext = true
|
|
91
|
-
break
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const hasPrd = existsSync(join(taskDir, "prd.md"))
|
|
96
|
-
|
|
97
|
-
if (!hasPrd) {
|
|
98
|
-
return `Status: NOT READY\nTask: ${taskTitle}\nSource: ${active.source}\nMissing: prd.md not created\nNext: Write PRD (see workflow.md Phase 1.1) then curate implement.jsonl per Phase 1.3`
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (!hasContext) {
|
|
102
|
-
return `Status: NOT READY\nTask: ${taskTitle}\nSource: ${active.source}\nMissing: implement.jsonl / check.jsonl missing or empty\nNext: Curate entries per workflow.md Phase 1.3 (spec + research files only), then \`task.py start\``
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return (
|
|
106
|
-
`Status: READY\nTask: ${taskTitle}\n` +
|
|
107
|
-
`Source: ${active.source}\n` +
|
|
108
|
-
"Next required action: dispatch `trellis-implement` per Phase 2.1. " +
|
|
109
|
-
"For agent-capable platforms, the default is to NOT edit code in the main session. " +
|
|
110
|
-
"After implementation, dispatch `trellis-check` per Phase 2.2 before reporting completion.\n" +
|
|
111
|
-
"User override (per-turn escape hatch): if the user's CURRENT message explicitly tells the " +
|
|
112
|
-
"main session to handle it directly (\"你直接改\" / \"别派 sub-agent\" / \"main session 写就行\" / " +
|
|
113
|
-
"\"do it inline\" / \"不用 sub-agent\"), honor it for this turn and edit code directly. " +
|
|
114
|
-
"Per-turn only; do NOT invent an override the user did not say."
|
|
115
|
-
)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Load Trellis config for session-start decisions.
|
|
120
|
-
* Calls get_context.py --mode packages --json for reliable config data.
|
|
121
|
-
*/
|
|
122
|
-
function loadTrellisConfig(directory, contextKey = null) {
|
|
123
|
-
const scriptPath = join(directory, ".trellis", "scripts", "get_context.py")
|
|
124
|
-
if (!existsSync(scriptPath)) {
|
|
125
|
-
return { isMonorepo: false, packages: {}, specScope: null, activeTaskPackage: null, defaultPackage: null }
|
|
126
|
-
}
|
|
127
|
-
try {
|
|
128
|
-
const output = execFileSync(PYTHON_CMD, [scriptPath, "--mode", "packages", "--json"], {
|
|
129
|
-
cwd: directory,
|
|
130
|
-
timeout: 5000,
|
|
131
|
-
encoding: "utf-8",
|
|
132
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
133
|
-
env: {
|
|
134
|
-
...process.env,
|
|
135
|
-
...(contextKey ? { TRELLIS_CONTEXT_ID: contextKey } : {}),
|
|
136
|
-
},
|
|
137
|
-
})
|
|
138
|
-
const data = JSON.parse(output)
|
|
139
|
-
if (data.mode !== "monorepo") {
|
|
140
|
-
return { isMonorepo: false, packages: {}, specScope: null, activeTaskPackage: null, defaultPackage: null }
|
|
141
|
-
}
|
|
142
|
-
const pkgDict = {}
|
|
143
|
-
for (const pkg of (data.packages || [])) {
|
|
144
|
-
pkgDict[pkg.name] = pkg
|
|
145
|
-
}
|
|
146
|
-
return {
|
|
147
|
-
isMonorepo: true,
|
|
148
|
-
packages: pkgDict,
|
|
149
|
-
specScope: data.specScope || null,
|
|
150
|
-
activeTaskPackage: data.activeTaskPackage || null,
|
|
151
|
-
defaultPackage: data.defaultPackage || null,
|
|
152
|
-
}
|
|
153
|
-
} catch (e) {
|
|
154
|
-
debugLog("session", "loadTrellisConfig error:", e.message)
|
|
155
|
-
return { isMonorepo: false, packages: {}, specScope: null, activeTaskPackage: null, defaultPackage: null }
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Check for legacy spec directory structure in monorepo.
|
|
162
|
-
*/
|
|
163
|
-
function checkLegacySpec(directory, config) {
|
|
164
|
-
if (!config.isMonorepo || Object.keys(config.packages).length === 0) {
|
|
165
|
-
return null
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const specDir = join(directory, ".trellis", "spec")
|
|
169
|
-
if (!existsSync(specDir)) return null
|
|
170
|
-
|
|
171
|
-
let hasLegacy = false
|
|
172
|
-
for (const name of ["backend", "frontend"]) {
|
|
173
|
-
if (existsSync(join(specDir, name, "index.md"))) {
|
|
174
|
-
hasLegacy = true
|
|
175
|
-
break
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
if (!hasLegacy) return null
|
|
179
|
-
|
|
180
|
-
const pkgNames = Object.keys(config.packages).sort()
|
|
181
|
-
const missing = pkgNames.filter(name => !existsSync(join(specDir, name)))
|
|
182
|
-
|
|
183
|
-
if (missing.length === 0) return null
|
|
184
|
-
|
|
185
|
-
if (missing.length === pkgNames.length) {
|
|
186
|
-
return (
|
|
187
|
-
`[!] Legacy spec structure detected: found \`spec/backend/\` or \`spec/frontend/\` ` +
|
|
188
|
-
`but no package-scoped \`spec/<package>/\` directories.\n` +
|
|
189
|
-
`Monorepo packages: ${pkgNames.join(", ")}\n` +
|
|
190
|
-
`Please reorganize: \`spec/backend/\` -> \`spec/<package>/backend/\``
|
|
191
|
-
)
|
|
192
|
-
}
|
|
193
|
-
return (
|
|
194
|
-
`[!] Partial spec migration detected: packages ${missing.join(", ")} ` +
|
|
195
|
-
`still missing \`spec/<pkg>/\` directory.\n` +
|
|
196
|
-
`Please complete migration for all packages.`
|
|
197
|
-
)
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Resolve which packages should have their specs injected.
|
|
203
|
-
* Returns a Set of allowed package names, or null for full scan.
|
|
204
|
-
*/
|
|
205
|
-
function resolveSpecScope(config) {
|
|
206
|
-
if (!config.isMonorepo || Object.keys(config.packages).length === 0) {
|
|
207
|
-
return null
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const { specScope, activeTaskPackage, defaultPackage, packages } = config
|
|
211
|
-
if (specScope == null) return null
|
|
212
|
-
|
|
213
|
-
if (specScope === "active_task") {
|
|
214
|
-
if (activeTaskPackage && activeTaskPackage in packages) return new Set([activeTaskPackage])
|
|
215
|
-
if (defaultPackage && defaultPackage in packages) return new Set([defaultPackage])
|
|
216
|
-
return null
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (Array.isArray(specScope)) {
|
|
220
|
-
const valid = new Set()
|
|
221
|
-
for (const entry of specScope) {
|
|
222
|
-
if (entry in packages) {
|
|
223
|
-
valid.add(entry)
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
if (valid.size > 0) return valid
|
|
227
|
-
if (activeTaskPackage && activeTaskPackage in packages) return new Set([activeTaskPackage])
|
|
228
|
-
if (defaultPackage && defaultPackage in packages) return new Set([defaultPackage])
|
|
229
|
-
return null
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return null
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Build session context for injection
|
|
238
|
-
*/
|
|
239
|
-
export function buildSessionContext(ctx, platformInput = null) {
|
|
240
|
-
const directory = ctx.directory
|
|
241
|
-
const trellisDir = join(directory, ".trellis")
|
|
242
|
-
const contextKey = typeof ctx.getContextKey === "function"
|
|
243
|
-
? ctx.getContextKey(platformInput)
|
|
244
|
-
: null
|
|
245
|
-
|
|
246
|
-
const config = loadTrellisConfig(directory, contextKey)
|
|
247
|
-
const allowedPkgs = resolveSpecScope(config)
|
|
248
|
-
|
|
249
|
-
const parts = []
|
|
250
|
-
|
|
251
|
-
// 1. Header
|
|
252
|
-
parts.push(`<trellis-context>
|
|
253
|
-
You are starting a new session in a Trellis-managed project.
|
|
254
|
-
Read and follow all instructions below carefully.
|
|
255
|
-
</trellis-context>`)
|
|
256
|
-
parts.push(FIRST_REPLY_NOTICE)
|
|
257
|
-
|
|
258
|
-
// Legacy migration warning
|
|
259
|
-
const legacyWarning = checkLegacySpec(directory, config)
|
|
260
|
-
if (legacyWarning) {
|
|
261
|
-
parts.push(`<migration-warning>\n${legacyWarning}\n</migration-warning>`)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// 2. Current Context (dynamic)
|
|
265
|
-
const contextScript = join(trellisDir, "scripts", "get_context.py")
|
|
266
|
-
if (existsSync(contextScript)) {
|
|
267
|
-
const output = ctx.runScript(contextScript, undefined, contextKey)
|
|
268
|
-
if (output) {
|
|
269
|
-
parts.push("<current-state>")
|
|
270
|
-
parts.push(output)
|
|
271
|
-
parts.push("</current-state>")
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// 3. Workflow Guide — TOC + Phase Index + Phase 1/2/3 step details.
|
|
276
|
-
// Meta sections (Core Principles / Trellis System / Breadcrumbs) are NOT
|
|
277
|
-
// injected: Core Principles is short prose; Trellis System duplicates
|
|
278
|
-
// commands in step bodies; Breadcrumbs are consumed by UserPromptSubmit hook.
|
|
279
|
-
const workflowContent = ctx.readProjectFile(".trellis/workflow.md")
|
|
280
|
-
if (workflowContent) {
|
|
281
|
-
const allLines = workflowContent.split("\n")
|
|
282
|
-
const overviewLines = [
|
|
283
|
-
"# Development Workflow — Section Index",
|
|
284
|
-
"Full guide: .trellis/workflow.md (read on demand)",
|
|
285
|
-
"",
|
|
286
|
-
"## Table of Contents",
|
|
287
|
-
]
|
|
288
|
-
for (const line of allLines) {
|
|
289
|
-
if (line.startsWith("## ")) overviewLines.push(line)
|
|
290
|
-
}
|
|
291
|
-
overviewLines.push("", "---", "")
|
|
292
|
-
|
|
293
|
-
// Extract range from "## Phase Index" up to (but excluding)
|
|
294
|
-
// "## Workflow State Breadcrumbs". Captures Phase Index + Phase 1/2/3.
|
|
295
|
-
let rangeStart = -1
|
|
296
|
-
let rangeEnd = allLines.length
|
|
297
|
-
for (let i = 0; i < allLines.length; i++) {
|
|
298
|
-
const stripped = allLines[i].trim()
|
|
299
|
-
if (rangeStart === -1 && stripped === "## Phase Index") {
|
|
300
|
-
rangeStart = i
|
|
301
|
-
} else if (rangeStart !== -1 && stripped === "## Workflow State Breadcrumbs") {
|
|
302
|
-
rangeEnd = i
|
|
303
|
-
break
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
if (rangeStart !== -1) {
|
|
307
|
-
overviewLines.push(...allLines.slice(rangeStart, rangeEnd))
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
parts.push("<workflow>")
|
|
311
|
-
parts.push(overviewLines.join("\n").trimEnd())
|
|
312
|
-
parts.push("</workflow>")
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// 4. Guidelines — paths-only for most indexes; guides/ inlined (cross-package,
|
|
316
|
-
// broadly useful). Sub-agents get their specific specs via jsonl injection.
|
|
317
|
-
parts.push("<guidelines>")
|
|
318
|
-
parts.push(
|
|
319
|
-
"Project spec indexes are listed by path below. Each index contains a " +
|
|
320
|
-
"**Pre-Development Checklist** listing the specific guideline files to " +
|
|
321
|
-
"read before coding.\n\n" +
|
|
322
|
-
"- If you're spawning an implement/check sub-agent, context is injected " +
|
|
323
|
-
"automatically via `{task}/implement.jsonl` / `check.jsonl`. You do NOT " +
|
|
324
|
-
"need to read these indexes yourself.\n" +
|
|
325
|
-
"- For agent-capable platforms, do NOT edit code directly in the main " +
|
|
326
|
-
"session; dispatch `trellis-implement` and `trellis-check` so JSONL " +
|
|
327
|
-
"context is loaded by the sub-agents.\n"
|
|
328
|
-
)
|
|
329
|
-
|
|
330
|
-
const specDir = join(directory, ".trellis", "spec")
|
|
331
|
-
|
|
332
|
-
// guides/ inlined
|
|
333
|
-
const guidesIndex = join(specDir, "guides", "index.md")
|
|
334
|
-
if (existsSync(guidesIndex)) {
|
|
335
|
-
const content = ctx.readFile(guidesIndex)
|
|
336
|
-
if (content) {
|
|
337
|
-
parts.push(`## guides (inlined — cross-package thinking guides)\n${content}\n`)
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Other indexes — paths only
|
|
342
|
-
const paths = []
|
|
343
|
-
if (existsSync(specDir)) {
|
|
344
|
-
try {
|
|
345
|
-
const subs = readdirSync(specDir).filter(name => {
|
|
346
|
-
if (name.startsWith(".")) return false
|
|
347
|
-
try {
|
|
348
|
-
return statSync(join(specDir, name)).isDirectory()
|
|
349
|
-
} catch {
|
|
350
|
-
return false
|
|
351
|
-
}
|
|
352
|
-
}).sort()
|
|
353
|
-
|
|
354
|
-
for (const sub of subs) {
|
|
355
|
-
if (sub === "guides") continue // already inlined above
|
|
356
|
-
|
|
357
|
-
const indexFile = join(specDir, sub, "index.md")
|
|
358
|
-
if (existsSync(indexFile)) {
|
|
359
|
-
paths.push(`.trellis/spec/${sub}/index.md`)
|
|
360
|
-
} else {
|
|
361
|
-
if (allowedPkgs !== null && !allowedPkgs.has(sub)) continue
|
|
362
|
-
try {
|
|
363
|
-
const nested = readdirSync(join(specDir, sub)).filter(name => {
|
|
364
|
-
try {
|
|
365
|
-
return statSync(join(specDir, sub, name)).isDirectory()
|
|
366
|
-
} catch {
|
|
367
|
-
return false
|
|
368
|
-
}
|
|
369
|
-
}).sort()
|
|
370
|
-
for (const layer of nested) {
|
|
371
|
-
const nestedIndex = join(specDir, sub, layer, "index.md")
|
|
372
|
-
if (existsSync(nestedIndex)) {
|
|
373
|
-
paths.push(`.trellis/spec/${sub}/${layer}/index.md`)
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
} catch {
|
|
377
|
-
// Ignore directory read errors
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
} catch {
|
|
382
|
-
// Ignore spec directory read errors
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
if (paths.length > 0) {
|
|
387
|
-
parts.push("## Available spec indexes (read on demand)")
|
|
388
|
-
for (const p of paths) {
|
|
389
|
-
parts.push(`- ${p}`)
|
|
390
|
-
}
|
|
391
|
-
parts.push("")
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
parts.push(
|
|
395
|
-
"Discover more via: " +
|
|
396
|
-
"`python3 ./.trellis/scripts/get_context.py --mode packages`"
|
|
397
|
-
)
|
|
398
|
-
parts.push("</guidelines>")
|
|
399
|
-
|
|
400
|
-
// 6. Task status
|
|
401
|
-
const taskStatus = getTaskStatus(ctx, platformInput)
|
|
402
|
-
parts.push(`<task-status>\n${taskStatus}\n</task-status>`)
|
|
403
|
-
|
|
404
|
-
// 7. Final directive
|
|
405
|
-
parts.push(`<ready>
|
|
406
|
-
Context loaded. Workflow index, project state, and guidelines are already injected above — do NOT re-read them.
|
|
407
|
-
When the user sends the first message, follow <task-status> and the workflow guide.
|
|
408
|
-
If a task is READY, execute its Next required action without asking whether to continue.
|
|
409
|
-
</ready>`)
|
|
410
|
-
|
|
411
|
-
return parts.join("\n\n")
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function getTrellisMetadata(metadata) {
|
|
415
|
-
if (!metadata || typeof metadata !== "object") {
|
|
416
|
-
return {}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
const trellis = metadata.trellis
|
|
420
|
-
if (!trellis || typeof trellis !== "object") {
|
|
421
|
-
return {}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
return trellis
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function markPartAsSessionStart(part) {
|
|
428
|
-
const metadata = part.metadata && typeof part.metadata === "object"
|
|
429
|
-
? part.metadata
|
|
430
|
-
: {}
|
|
431
|
-
part.metadata = {
|
|
432
|
-
...metadata,
|
|
433
|
-
trellis: {
|
|
434
|
-
...getTrellisMetadata(metadata),
|
|
435
|
-
sessionStart: true,
|
|
436
|
-
},
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function hasSessionStartMarker(part) {
|
|
441
|
-
if (!part || part.type !== "text" || typeof part.text !== "string") {
|
|
442
|
-
return false
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
return getTrellisMetadata(part.metadata).sessionStart === true
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
export function hasInjectedTrellisContext(messages) {
|
|
449
|
-
if (!Array.isArray(messages)) {
|
|
450
|
-
return false
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
return messages.some(message => {
|
|
454
|
-
if (!message?.info || message.info.role !== "user" || !Array.isArray(message.parts)) {
|
|
455
|
-
return false
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
return message.parts.some(hasSessionStartMarker)
|
|
459
|
-
})
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
async function hasPersistedInjectedContext(client, directory, sessionID) {
|
|
463
|
-
try {
|
|
464
|
-
const response = await client.session.messages({
|
|
465
|
-
path: { id: sessionID },
|
|
466
|
-
query: { directory },
|
|
467
|
-
throwOnError: true,
|
|
468
|
-
})
|
|
469
|
-
return hasInjectedTrellisContext(response.data || [])
|
|
470
|
-
} catch (error) {
|
|
471
|
-
debugLog(
|
|
472
|
-
"session",
|
|
473
|
-
"Failed to read session history for dedupe:",
|
|
474
|
-
error instanceof Error ? error.message : String(error),
|
|
475
|
-
)
|
|
476
|
-
return false
|
|
477
|
-
}
|
|
478
|
-
}
|
|
10
|
+
import {
|
|
11
|
+
buildSessionContext,
|
|
12
|
+
hasPersistedInjectedContext,
|
|
13
|
+
markContextInjected,
|
|
14
|
+
} from "../lib/session-utils.js"
|
|
479
15
|
|
|
480
16
|
// OpenCode 1.2.x expects plugins to be factory functions (see inject-subagent-context.js comment).
|
|
481
17
|
export default async ({ directory, client }) => {
|
|
@@ -483,77 +19,70 @@ export default async ({ directory, client }) => {
|
|
|
483
19
|
debugLog("session", "Plugin loaded, directory:", directory)
|
|
484
20
|
|
|
485
21
|
return {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
debugLog("session", "Cleared processed flag after compaction for session:", sessionID)
|
|
493
|
-
}
|
|
494
|
-
} catch (error) {
|
|
495
|
-
debugLog(
|
|
496
|
-
"session",
|
|
497
|
-
"Error in event hook:",
|
|
498
|
-
error instanceof Error ? error.message : String(error),
|
|
499
|
-
)
|
|
22
|
+
event: ({ event }) => {
|
|
23
|
+
try {
|
|
24
|
+
if (event?.type === "session.compacted" && event?.properties?.sessionID) {
|
|
25
|
+
const sessionID = event.properties.sessionID
|
|
26
|
+
contextCollector.clear(sessionID)
|
|
27
|
+
debugLog("session", "Cleared processed flag after compaction for session:", sessionID)
|
|
500
28
|
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
debugLog("session", "chat.message called, sessionID:", sessionID, "agent:", agent)
|
|
510
|
-
|
|
511
|
-
// Skip in non-interactive mode
|
|
512
|
-
if (process.env.OPENCODE_NON_INTERACTIVE === "1") {
|
|
513
|
-
debugLog("session", "Skipping - non-interactive mode")
|
|
514
|
-
return
|
|
515
|
-
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
debugLog(
|
|
31
|
+
"session",
|
|
32
|
+
"Error in event hook:",
|
|
33
|
+
error instanceof Error ? error.message : String(error),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
},
|
|
516
37
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
38
|
+
// chat.message - triggered when user sends a message.
|
|
39
|
+
// Modify the message in-place so the context is persisted with updateMessage/updatePart.
|
|
40
|
+
"chat.message": async (input, output) => {
|
|
41
|
+
try {
|
|
42
|
+
const sessionID = input.sessionID
|
|
43
|
+
const agent = input.agent || "unknown"
|
|
44
|
+
debugLog("session", "chat.message called, sessionID:", sessionID, "agent:", agent)
|
|
522
45
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
46
|
+
if (process.env.OPENCODE_NON_INTERACTIVE === "1") {
|
|
47
|
+
debugLog("session", "Skipping - non-interactive mode")
|
|
48
|
+
return
|
|
49
|
+
}
|
|
528
50
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
51
|
+
if (contextCollector.isProcessed(sessionID)) {
|
|
52
|
+
debugLog("session", "Skipping - session already processed")
|
|
53
|
+
return
|
|
54
|
+
}
|
|
532
55
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
56
|
+
if (await hasPersistedInjectedContext(client, ctx.directory, sessionID)) {
|
|
57
|
+
contextCollector.markProcessed(sessionID)
|
|
58
|
+
debugLog("session", "Skipping - session already contains persisted Trellis context")
|
|
59
|
+
return
|
|
60
|
+
}
|
|
538
61
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
parts[textPartIndex].text = `${context}\n\n---\n\n${originalText}`
|
|
542
|
-
markPartAsSessionStart(parts[textPartIndex])
|
|
543
|
-
debugLog("session", "Injected context into chat.message text part, length:", context.length)
|
|
544
|
-
} else {
|
|
545
|
-
// No existing text part: prepend a new one
|
|
546
|
-
const injectedPart = { type: "text", text: context }
|
|
547
|
-
markPartAsSessionStart(injectedPart)
|
|
548
|
-
parts.unshift(injectedPart)
|
|
549
|
-
debugLog("session", "Prepended new text part with context, length:", context.length)
|
|
550
|
-
}
|
|
62
|
+
const context = buildSessionContext(ctx, input)
|
|
63
|
+
debugLog("session", "Built context, length:", context.length)
|
|
551
64
|
|
|
552
|
-
|
|
65
|
+
const parts = output?.parts || []
|
|
66
|
+
const textPartIndex = parts.findIndex(
|
|
67
|
+
p => p.type === "text" && p.text !== undefined
|
|
68
|
+
)
|
|
553
69
|
|
|
554
|
-
|
|
555
|
-
|
|
70
|
+
if (textPartIndex !== -1) {
|
|
71
|
+
const originalText = parts[textPartIndex].text || ""
|
|
72
|
+
parts[textPartIndex].text = `${context}\n\n---\n\n${originalText}`
|
|
73
|
+
markContextInjected(parts[textPartIndex])
|
|
74
|
+
debugLog("session", "Injected context into chat.message text part, length:", context.length)
|
|
75
|
+
} else {
|
|
76
|
+
const injectedPart = { type: "text", text: context }
|
|
77
|
+
markContextInjected(injectedPart)
|
|
78
|
+
parts.unshift(injectedPart)
|
|
79
|
+
debugLog("session", "Prepended new text part with context, length:", context.length)
|
|
556
80
|
}
|
|
81
|
+
|
|
82
|
+
contextCollector.markProcessed(sessionID)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
debugLog("session", "Error in chat.message:", error.message, error.stack)
|
|
557
85
|
}
|
|
86
|
+
},
|
|
558
87
|
}
|
|
559
88
|
}
|
package/package.json
CHANGED