@mindfoldhq/trellis 0.4.0-beta.9 → 0.4.0-rc.0
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/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +132 -4
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +14 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/migrations/manifests/0.4.0-beta.10.json +9 -0
- package/dist/migrations/manifests/0.4.0-rc.0.json +9 -0
- package/dist/templates/claude/hooks/ralph-loop.py +10 -9
- package/dist/templates/claude/hooks/session-start.py +29 -12
- package/dist/templates/claude/hooks/statusline.py +7 -0
- package/dist/templates/codex/hooks/session-start.py +29 -14
- package/dist/templates/copilot/hooks/session-start.py +29 -4
- package/dist/templates/iflow/hooks/session-start.py +29 -12
- package/dist/templates/opencode/lib/trellis-context.js +4 -248
- package/dist/templates/opencode/plugins/inject-subagent-context.js +71 -121
- package/dist/templates/opencode/plugins/session-start.js +143 -119
- package/dist/templates/trellis/workflow.md +17 -4
- package/package.json +1 -1
|
@@ -3,11 +3,7 @@
|
|
|
3
3
|
* Trellis Session Start Plugin
|
|
4
4
|
*
|
|
5
5
|
* Injects context when user sends the first message in a session.
|
|
6
|
-
* Uses OpenCode's chat.message
|
|
7
|
-
*
|
|
8
|
-
* Compatibility:
|
|
9
|
-
* - If oh-my-opencode handles via .claude/hooks/, this plugin skips
|
|
10
|
-
* - Otherwise, this plugin handles injection
|
|
6
|
+
* Uses OpenCode's chat.message hook directly so the context persists in history.
|
|
11
7
|
*/
|
|
12
8
|
|
|
13
9
|
import { existsSync, readFileSync, readdirSync, statSync } from "fs"
|
|
@@ -29,14 +25,12 @@ function getTaskStatus(ctx) {
|
|
|
29
25
|
return "Status: NO ACTIVE TASK\nNext: Describe what you want to work on"
|
|
30
26
|
}
|
|
31
27
|
|
|
32
|
-
// Resolve task directory
|
|
33
28
|
const taskDir = ctx.resolveTaskDir(taskRef)
|
|
34
29
|
|
|
35
30
|
if (!taskDir || !existsSync(taskDir)) {
|
|
36
31
|
return `Status: STALE POINTER\nTask: ${taskRef}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish`
|
|
37
32
|
}
|
|
38
33
|
|
|
39
|
-
// Read task.json
|
|
40
34
|
let taskData = {}
|
|
41
35
|
const taskJsonPath = join(taskDir, "task.json")
|
|
42
36
|
if (existsSync(taskJsonPath)) {
|
|
@@ -55,7 +49,6 @@ function getTaskStatus(ctx) {
|
|
|
55
49
|
return `Status: COMPLETED\nTask: ${taskTitle}\nNext: Archive with \`python3 ./.trellis/scripts/task.py archive ${dirName}\` or start a new task`
|
|
56
50
|
}
|
|
57
51
|
|
|
58
|
-
// Check if context is configured (jsonl files exist and non-empty)
|
|
59
52
|
let hasContext = false
|
|
60
53
|
for (const jsonlName of ["implement.jsonl", "check.jsonl", "spec.jsonl"]) {
|
|
61
54
|
const jsonlPath = join(taskDir, jsonlName)
|
|
@@ -105,7 +98,6 @@ function loadTrellisConfig(directory) {
|
|
|
105
98
|
if (data.mode !== "monorepo") {
|
|
106
99
|
return { isMonorepo: false, packages: {}, specScope: null, activeTaskPackage: null, defaultPackage: null }
|
|
107
100
|
}
|
|
108
|
-
// Convert packages array to dict keyed by name
|
|
109
101
|
const pkgDict = {}
|
|
110
102
|
for (const pkg of (data.packages || [])) {
|
|
111
103
|
pkgDict[pkg.name] = pkg
|
|
@@ -135,7 +127,6 @@ function checkLegacySpec(directory, config) {
|
|
|
135
127
|
const specDir = join(directory, ".trellis", "spec")
|
|
136
128
|
if (!existsSync(specDir)) return null
|
|
137
129
|
|
|
138
|
-
// Check for legacy flat spec dirs
|
|
139
130
|
let hasLegacy = false
|
|
140
131
|
for (const name of ["backend", "frontend"]) {
|
|
141
132
|
if (existsSync(join(specDir, name, "index.md"))) {
|
|
@@ -145,7 +136,6 @@ function checkLegacySpec(directory, config) {
|
|
|
145
136
|
}
|
|
146
137
|
if (!hasLegacy) return null
|
|
147
138
|
|
|
148
|
-
// Check which packages are missing spec/<pkg>/ directory
|
|
149
139
|
const pkgNames = Object.keys(config.packages).sort()
|
|
150
140
|
const missing = pkgNames.filter(name => !existsSync(join(specDir, name)))
|
|
151
141
|
|
|
@@ -193,7 +183,6 @@ function resolveSpecScope(config) {
|
|
|
193
183
|
}
|
|
194
184
|
}
|
|
195
185
|
if (valid.size > 0) return valid
|
|
196
|
-
// All invalid: fallback
|
|
197
186
|
if (activeTaskPackage && activeTaskPackage in packages) return new Set([activeTaskPackage])
|
|
198
187
|
if (defaultPackage && defaultPackage in packages) return new Set([defaultPackage])
|
|
199
188
|
return null
|
|
@@ -209,10 +198,7 @@ function resolveSpecScope(config) {
|
|
|
209
198
|
function buildSessionContext(ctx) {
|
|
210
199
|
const directory = ctx.directory
|
|
211
200
|
const trellisDir = join(directory, ".trellis")
|
|
212
|
-
const claudeDir = join(directory, ".claude")
|
|
213
|
-
const opencodeDir = join(directory, ".opencode")
|
|
214
201
|
|
|
215
|
-
// Load config for scope filtering and legacy detection
|
|
216
202
|
const config = loadTrellisConfig(directory)
|
|
217
203
|
const allowedPkgs = resolveSpecScope(config)
|
|
218
204
|
|
|
@@ -241,11 +227,20 @@ Read and follow all instructions below carefully.
|
|
|
241
227
|
}
|
|
242
228
|
}
|
|
243
229
|
|
|
244
|
-
// 3. Workflow Guide
|
|
245
|
-
const
|
|
246
|
-
if (
|
|
230
|
+
// 3. Workflow Guide (ToC only — lazy-load the full file on demand)
|
|
231
|
+
const workflowContent = ctx.readProjectFile(".trellis/workflow.md")
|
|
232
|
+
if (workflowContent) {
|
|
233
|
+
const tocLines = [
|
|
234
|
+
"# Development Workflow — Section Index",
|
|
235
|
+
"Full guide: .trellis/workflow.md (read on demand)",
|
|
236
|
+
"",
|
|
237
|
+
]
|
|
238
|
+
for (const line of workflowContent.split("\n")) {
|
|
239
|
+
if (line.startsWith("## ")) tocLines.push(line)
|
|
240
|
+
}
|
|
241
|
+
tocLines.push("", "To read a section: use the Read tool on .trellis/workflow.md")
|
|
247
242
|
parts.push("<workflow>")
|
|
248
|
-
parts.push(
|
|
243
|
+
parts.push(tocLines.join("\n"))
|
|
249
244
|
parts.push("</workflow>")
|
|
250
245
|
}
|
|
251
246
|
|
|
@@ -267,7 +262,6 @@ Read and follow all instructions below carefully.
|
|
|
267
262
|
}).sort()
|
|
268
263
|
|
|
269
264
|
for (const sub of subs) {
|
|
270
|
-
// Always include guides/ regardless of scope
|
|
271
265
|
if (sub === "guides") {
|
|
272
266
|
const indexFile = join(specDir, sub, "index.md")
|
|
273
267
|
if (existsSync(indexFile)) {
|
|
@@ -281,14 +275,11 @@ Read and follow all instructions below carefully.
|
|
|
281
275
|
|
|
282
276
|
const indexFile = join(specDir, sub, "index.md")
|
|
283
277
|
if (existsSync(indexFile)) {
|
|
284
|
-
// Flat spec dir: spec/<layer>/index.md (single-repo)
|
|
285
278
|
const content = ctx.readFile(indexFile)
|
|
286
279
|
if (content) {
|
|
287
280
|
parts.push(`## ${sub}\n${content}\n`)
|
|
288
281
|
}
|
|
289
282
|
} else {
|
|
290
|
-
// Nested package dirs (monorepo): spec/<pkg>/<layer>/index.md
|
|
291
|
-
// Apply scope filter
|
|
292
283
|
if (allowedPkgs !== null && !allowedPkgs.has(sub)) {
|
|
293
284
|
continue
|
|
294
285
|
}
|
|
@@ -322,131 +313,164 @@ Read and follow all instructions below carefully.
|
|
|
322
313
|
|
|
323
314
|
parts.push("</guidelines>")
|
|
324
315
|
|
|
325
|
-
//
|
|
326
|
-
let startMd = ctx.readFile(join(claudeDir, "commands", "trellis", "start.md"))
|
|
327
|
-
if (!startMd) {
|
|
328
|
-
startMd = ctx.readFile(join(opencodeDir, "commands", "trellis", "start.md"))
|
|
329
|
-
}
|
|
330
|
-
if (startMd) {
|
|
331
|
-
parts.push("<instructions>")
|
|
332
|
-
parts.push(startMd)
|
|
333
|
-
parts.push("</instructions>")
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// 6. Task status (R2: check task state for session resume)
|
|
316
|
+
// 6. Task status
|
|
337
317
|
const taskStatus = getTaskStatus(ctx)
|
|
338
318
|
parts.push(`<task-status>\n${taskStatus}\n</task-status>`)
|
|
339
319
|
|
|
340
|
-
// 7. Final directive
|
|
320
|
+
// 7. Final directive
|
|
341
321
|
parts.push(`<ready>
|
|
342
|
-
Context loaded.
|
|
343
|
-
|
|
322
|
+
Context loaded. Workflow index, project state, and guidelines are already injected above — do NOT re-read them.
|
|
323
|
+
Wait for the user's first message, then handle it following the workflow guide.
|
|
344
324
|
If there is an active task, ask whether to continue it.
|
|
345
325
|
</ready>`)
|
|
346
326
|
|
|
347
327
|
return parts.join("\n\n")
|
|
348
328
|
}
|
|
349
329
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
330
|
+
function getTrellisMetadata(metadata) {
|
|
331
|
+
if (!metadata || typeof metadata !== "object") {
|
|
332
|
+
return {}
|
|
333
|
+
}
|
|
353
334
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const sessionID = input.sessionID
|
|
359
|
-
const agent = input.agent || "unknown"
|
|
360
|
-
debugLog("session", "chat.message called, sessionID:", sessionID, "agent:", agent)
|
|
361
|
-
|
|
362
|
-
// Skip in non-interactive mode
|
|
363
|
-
if (process.env.OPENCODE_NON_INTERACTIVE === "1") {
|
|
364
|
-
debugLog("session", "Skipping - non-interactive mode")
|
|
365
|
-
return
|
|
366
|
-
}
|
|
335
|
+
const trellis = metadata.trellis
|
|
336
|
+
if (!trellis || typeof trellis !== "object") {
|
|
337
|
+
return {}
|
|
338
|
+
}
|
|
367
339
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
debugLog("session", "Skipping - omo will handle via .claude/hooks/")
|
|
371
|
-
return
|
|
372
|
-
}
|
|
340
|
+
return trellis
|
|
341
|
+
}
|
|
373
342
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
343
|
+
function markPartAsSessionStart(part) {
|
|
344
|
+
const metadata = part.metadata && typeof part.metadata === "object"
|
|
345
|
+
? part.metadata
|
|
346
|
+
: {}
|
|
347
|
+
part.metadata = {
|
|
348
|
+
...metadata,
|
|
349
|
+
trellis: {
|
|
350
|
+
...getTrellisMetadata(metadata),
|
|
351
|
+
sessionStart: true,
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
}
|
|
379
355
|
|
|
380
|
-
|
|
381
|
-
|
|
356
|
+
function hasSessionStartMarker(part) {
|
|
357
|
+
if (!part || part.type !== "text" || typeof part.text !== "string") {
|
|
358
|
+
return false
|
|
359
|
+
}
|
|
382
360
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
debugLog("session", "Built context, length:", context.length)
|
|
361
|
+
return getTrellisMetadata(part.metadata).sessionStart === true
|
|
362
|
+
}
|
|
386
363
|
|
|
387
|
-
|
|
388
|
-
|
|
364
|
+
export function hasInjectedTrellisContext(messages) {
|
|
365
|
+
if (!Array.isArray(messages)) {
|
|
366
|
+
return false
|
|
367
|
+
}
|
|
389
368
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
369
|
+
return messages.some(message => {
|
|
370
|
+
if (!message?.info || message.info.role !== "user" || !Array.isArray(message.parts)) {
|
|
371
|
+
return false
|
|
372
|
+
}
|
|
394
373
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const { messages } = output
|
|
399
|
-
debugLog("session", "messages.transform called, messageCount:", messages?.length)
|
|
374
|
+
return message.parts.some(hasSessionStartMarker)
|
|
375
|
+
})
|
|
376
|
+
}
|
|
400
377
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
378
|
+
async function hasPersistedInjectedContext(client, directory, sessionID) {
|
|
379
|
+
try {
|
|
380
|
+
const response = await client.session.messages({
|
|
381
|
+
path: { id: sessionID },
|
|
382
|
+
query: { directory },
|
|
383
|
+
throwOnError: true,
|
|
384
|
+
})
|
|
385
|
+
return hasInjectedTrellisContext(response.data || [])
|
|
386
|
+
} catch (error) {
|
|
387
|
+
debugLog(
|
|
388
|
+
"session",
|
|
389
|
+
"Failed to read session history for dedupe:",
|
|
390
|
+
error instanceof Error ? error.message : String(error),
|
|
391
|
+
)
|
|
392
|
+
return false
|
|
393
|
+
}
|
|
394
|
+
}
|
|
404
395
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
break
|
|
411
|
-
}
|
|
412
|
-
}
|
|
396
|
+
export default {
|
|
397
|
+
id: "trellis.session-start",
|
|
398
|
+
server: async ({ directory, client }) => {
|
|
399
|
+
const ctx = new TrellisContext(directory)
|
|
400
|
+
debugLog("session", "Plugin loaded, directory:", directory)
|
|
413
401
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
402
|
+
return {
|
|
403
|
+
// Clear in-memory dedupe after compaction so context can be re-injected.
|
|
404
|
+
event: ({ event }) => {
|
|
405
|
+
try {
|
|
406
|
+
if (event?.type === "session.compacted" && event?.properties?.sessionID) {
|
|
407
|
+
const sessionID = event.properties.sessionID
|
|
408
|
+
contextCollector.clear(sessionID)
|
|
409
|
+
debugLog("session", "Cleared processed flag after compaction for session:", sessionID)
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
debugLog(
|
|
413
|
+
"session",
|
|
414
|
+
"Error in event hook:",
|
|
415
|
+
error instanceof Error ? error.message : String(error),
|
|
416
|
+
)
|
|
417
417
|
}
|
|
418
|
+
},
|
|
418
419
|
|
|
419
|
-
|
|
420
|
-
|
|
420
|
+
// chat.message - triggered when user sends a message.
|
|
421
|
+
// Modify the message in-place so the context is persisted with updateMessage/updatePart.
|
|
422
|
+
"chat.message": async (input, output) => {
|
|
423
|
+
try {
|
|
424
|
+
const sessionID = input.sessionID
|
|
425
|
+
const agent = input.agent || "unknown"
|
|
426
|
+
debugLog("session", "chat.message called, sessionID:", sessionID, "agent:", agent)
|
|
427
|
+
|
|
428
|
+
// Skip in non-interactive mode
|
|
429
|
+
if (process.env.OPENCODE_NON_INTERACTIVE === "1") {
|
|
430
|
+
debugLog("session", "Skipping - non-interactive mode")
|
|
431
|
+
return
|
|
432
|
+
}
|
|
421
433
|
|
|
422
|
-
|
|
434
|
+
// Only inject on first message
|
|
435
|
+
if (contextCollector.isProcessed(sessionID)) {
|
|
436
|
+
debugLog("session", "Skipping - session already processed")
|
|
437
|
+
return
|
|
438
|
+
}
|
|
423
439
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
440
|
+
if (await hasPersistedInjectedContext(client, ctx.directory, sessionID)) {
|
|
441
|
+
contextCollector.markProcessed(sessionID)
|
|
442
|
+
debugLog("session", "Skipping - session already contains persisted Trellis context")
|
|
443
|
+
return
|
|
444
|
+
}
|
|
428
445
|
|
|
429
|
-
|
|
430
|
-
|
|
446
|
+
// Build context
|
|
447
|
+
const context = buildSessionContext(ctx)
|
|
448
|
+
debugLog("session", "Built context, length:", context.length)
|
|
449
|
+
|
|
450
|
+
// Inject context directly into output.parts so it gets persisted by updatePart
|
|
451
|
+
const parts = output?.parts || []
|
|
452
|
+
const textPartIndex = parts.findIndex(
|
|
453
|
+
p => p.type === "text" && p.text !== undefined
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
if (textPartIndex !== -1) {
|
|
457
|
+
const originalText = parts[textPartIndex].text || ""
|
|
458
|
+
parts[textPartIndex].text = `${context}\n\n---\n\n${originalText}`
|
|
459
|
+
markPartAsSessionStart(parts[textPartIndex])
|
|
460
|
+
debugLog("session", "Injected context into chat.message text part, length:", context.length)
|
|
461
|
+
} else {
|
|
462
|
+
// No existing text part: prepend a new one
|
|
463
|
+
const injectedPart = { type: "text", text: context }
|
|
464
|
+
markPartAsSessionStart(injectedPart)
|
|
465
|
+
parts.unshift(injectedPart)
|
|
466
|
+
debugLog("session", "Prepended new text part with context, length:", context.length)
|
|
467
|
+
}
|
|
431
468
|
|
|
432
|
-
|
|
433
|
-
const textPartIndex = lastUserMessage.parts?.findIndex(
|
|
434
|
-
p => p.type === "text" && p.text !== undefined
|
|
435
|
-
)
|
|
469
|
+
contextCollector.markProcessed(sessionID)
|
|
436
470
|
|
|
437
|
-
|
|
438
|
-
debugLog("session", "
|
|
439
|
-
return
|
|
471
|
+
} catch (error) {
|
|
472
|
+
debugLog("session", "Error in chat.message:", error.message, error.stack)
|
|
440
473
|
}
|
|
441
|
-
|
|
442
|
-
// Prepend context to the text part (same approach as omo)
|
|
443
|
-
const originalText = lastUserMessage.parts[textPartIndex].text || ""
|
|
444
|
-
lastUserMessage.parts[textPartIndex].text = `${pending.content}\n\n---\n\n${originalText}`
|
|
445
|
-
|
|
446
|
-
debugLog("session", "Injected context by prepending to text, length:", pending.content.length)
|
|
447
|
-
|
|
448
|
-
} catch (error) {
|
|
449
|
-
debugLog("session", "Error in messages.transform:", error.message, error.stack)
|
|
450
474
|
}
|
|
451
475
|
}
|
|
452
476
|
}
|
|
@@ -196,21 +196,30 @@ python3 ./.trellis/scripts/task.py create "<title>" --slug <task-name>
|
|
|
196
196
|
1. Create or select task
|
|
197
197
|
--> python3 ./.trellis/scripts/task.py create "<title>" --slug <name> or list
|
|
198
198
|
|
|
199
|
-
2.
|
|
199
|
+
2. Start task (mark as current)
|
|
200
|
+
--> python3 ./.trellis/scripts/task.py start <name>
|
|
201
|
+
--> Writes .trellis/.current-task; future sessions see it in <current-state>
|
|
202
|
+
|
|
203
|
+
3. Write code according to guidelines
|
|
200
204
|
--> Read .trellis/spec/ docs relevant to your task
|
|
201
205
|
--> For cross-layer: read .trellis/spec/guides/
|
|
202
206
|
|
|
203
|
-
|
|
207
|
+
4. Self-test
|
|
204
208
|
--> Run project's lint/test commands (see spec docs)
|
|
205
209
|
--> Manual feature testing
|
|
206
210
|
|
|
207
|
-
|
|
211
|
+
5. Commit code
|
|
208
212
|
--> git add <files>
|
|
209
213
|
--> git commit -m "type(scope): description"
|
|
210
214
|
Format: feat/fix/docs/refactor/test/chore
|
|
211
215
|
|
|
212
|
-
|
|
216
|
+
6. Record session (one command)
|
|
213
217
|
--> python3 ./.trellis/scripts/add_session.py --title "Title" --commit "hash"
|
|
218
|
+
|
|
219
|
+
7. Finish task (clear current)
|
|
220
|
+
--> python3 ./.trellis/scripts/task.py finish
|
|
221
|
+
--> Only when the task is fully done; otherwise leave it set so the
|
|
222
|
+
next session resumes where you left off
|
|
214
223
|
```
|
|
215
224
|
|
|
216
225
|
### Code Quality Checklist
|
|
@@ -315,11 +324,15 @@ tasks/
|
|
|
315
324
|
**Commands**:
|
|
316
325
|
```bash
|
|
317
326
|
python3 ./.trellis/scripts/task.py create "<title>" [--slug <name>] # Create task directory
|
|
327
|
+
python3 ./.trellis/scripts/task.py start <name> # Set as current task (writes .current-task, triggers after_start hooks)
|
|
328
|
+
python3 ./.trellis/scripts/task.py finish # Clear current task (triggers after_finish hooks)
|
|
318
329
|
python3 ./.trellis/scripts/task.py archive <name> # Archive to archive/{year-month}/
|
|
319
330
|
python3 ./.trellis/scripts/task.py list # List active tasks
|
|
320
331
|
python3 ./.trellis/scripts/task.py list-archive # List archived tasks
|
|
321
332
|
```
|
|
322
333
|
|
|
334
|
+
**Current task mechanism**: `task.py start <name>` writes the selected task path to `.trellis/.current-task`. The SessionStart hook reads this file to inject `## CURRENT TASK` into every new session's context, so the AI immediately knows what you're working on without being told. Run `task.py finish` when you're done — subsequent sessions will show `(none)` until you start another task.
|
|
335
|
+
|
|
323
336
|
---
|
|
324
337
|
|
|
325
338
|
## Best Practices
|
package/package.json
CHANGED