@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.
@@ -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 + experimental.chat.messages.transform hooks.
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 workflow = ctx.readProjectFile(".trellis/workflow.md")
246
- if (workflow) {
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(workflow)
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
- // 5. Session Instructions - try both .claude and .opencode
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 (R3: active, not passive)
320
+ // 7. Final directive
341
321
  parts.push(`<ready>
342
- Context loaded. Steps 1-3 (workflow, context, guidelines) are already injected above — do NOT re-read them.
343
- Start from Step 4. Wait for user's first message, then follow <instructions> to handle their request.
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
- export default async ({ directory }) => {
351
- const ctx = new TrellisContext(directory)
352
- debugLog("session", "Plugin loaded, directory:", directory)
330
+ function getTrellisMetadata(metadata) {
331
+ if (!metadata || typeof metadata !== "object") {
332
+ return {}
333
+ }
353
334
 
354
- return {
355
- // chat.message - triggered when user sends a message
356
- "chat.message": async (input) => {
357
- try {
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
- // Check if we should skip (omo will handle)
369
- if (ctx.shouldSkipHook("session-start")) {
370
- debugLog("session", "Skipping - omo will handle via .claude/hooks/")
371
- return
372
- }
340
+ return trellis
341
+ }
373
342
 
374
- // Only inject on first message
375
- if (contextCollector.isProcessed(sessionID)) {
376
- debugLog("session", "Skipping - session already processed")
377
- return
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
- // Mark session as processed
381
- contextCollector.markProcessed(sessionID)
356
+ function hasSessionStartMarker(part) {
357
+ if (!part || part.type !== "text" || typeof part.text !== "string") {
358
+ return false
359
+ }
382
360
 
383
- // Build and store context
384
- const context = buildSessionContext(ctx)
385
- debugLog("session", "Built context, length:", context.length)
361
+ return getTrellisMetadata(part.metadata).sessionStart === true
362
+ }
386
363
 
387
- contextCollector.store(sessionID, context)
388
- debugLog("session", "Context stored for session:", sessionID)
364
+ export function hasInjectedTrellisContext(messages) {
365
+ if (!Array.isArray(messages)) {
366
+ return false
367
+ }
389
368
 
390
- } catch (error) {
391
- debugLog("session", "Error in chat.message:", error.message, error.stack)
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
- // experimental.chat.messages.transform - modify messages before sending to AI
396
- "experimental.chat.messages.transform": async (input, output) => {
397
- try {
398
- const { messages } = output
399
- debugLog("session", "messages.transform called, messageCount:", messages?.length)
374
+ return message.parts.some(hasSessionStartMarker)
375
+ })
376
+ }
400
377
 
401
- if (!messages || messages.length === 0) {
402
- return
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
- // Find last user message
406
- let lastUserMessageIndex = -1
407
- for (let i = messages.length - 1; i >= 0; i--) {
408
- if (messages[i].info?.role === "user") {
409
- lastUserMessageIndex = i
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
- if (lastUserMessageIndex === -1) {
415
- debugLog("session", "No user message found")
416
- return
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
- const lastUserMessage = messages[lastUserMessageIndex]
420
- const sessionID = lastUserMessage.info?.sessionID
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
- debugLog("session", "Found user message, sessionID:", sessionID)
434
+ // Only inject on first message
435
+ if (contextCollector.isProcessed(sessionID)) {
436
+ debugLog("session", "Skipping - session already processed")
437
+ return
438
+ }
423
439
 
424
- if (!sessionID || !contextCollector.hasPending(sessionID)) {
425
- debugLog("session", "No pending context for session")
426
- return
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
- // Get and consume pending context
430
- const pending = contextCollector.consume(sessionID)
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
- // Find first text part
433
- const textPartIndex = lastUserMessage.parts?.findIndex(
434
- p => p.type === "text" && p.text !== undefined
435
- )
469
+ contextCollector.markProcessed(sessionID)
436
470
 
437
- if (textPartIndex === -1) {
438
- debugLog("session", "No text part found in user message")
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. Write code according to guidelines
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
- 3. Self-test
207
+ 4. Self-test
204
208
  --> Run project's lint/test commands (see spec docs)
205
209
  --> Manual feature testing
206
210
 
207
- 4. Commit code
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
- 5. Record session (one command)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindfoldhq/trellis",
3
- "version": "0.4.0-beta.9",
3
+ "version": "0.4.0-rc.0",
4
4
  "description": "AI capabilities grow like ivy — Trellis provides the structure to guide them along a disciplined path",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",