@mindfoldhq/trellis 0.5.12 → 0.5.13
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/migrations/manifests/0.5.13.json +9 -0
- package/dist/migrations/manifests/0.6.0-beta.7.json +9 -0
- package/dist/migrations/manifests/0.6.0-beta.8.json +9 -0
- package/dist/templates/claude/settings.json +4 -4
- package/dist/templates/codebuddy/settings.json +4 -4
- package/dist/templates/codex/hooks.json +1 -1
- package/dist/templates/copilot/hooks/session-start.py +12 -11
- package/dist/templates/copilot/hooks.json +2 -2
- package/dist/templates/cursor/hooks.json +2 -2
- package/dist/templates/droid/settings.json +4 -4
- package/dist/templates/gemini/settings.json +2 -2
- package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +10 -4
- package/dist/templates/opencode/lib/trellis-context.js +73 -11
- package/dist/templates/opencode/plugins/inject-subagent-context.js +109 -23
- package/dist/templates/opencode/plugins/inject-workflow-state.js +8 -1
- package/dist/templates/opencode/plugins/session-start.js +9 -1
- package/dist/templates/qoder/settings.json +4 -4
- package/dist/templates/trellis/scripts/common/session_context.py +214 -137
- package/package.json +1 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.5.13",
|
|
3
|
+
"description": "Patch: OpenCode context injection, non-Git session context, hook timeouts, and Copilot SessionStart wording.",
|
|
4
|
+
"breaking": false,
|
|
5
|
+
"recommendMigrate": false,
|
|
6
|
+
"changelog": "**Bug Fixes:**\n- fix(opencode): detect Windows POSIX shell dialects before injecting `TRELLIS_CONTEXT_ID`, so Git Bash / MSYS / Cygwin sessions receive `export ...` while PowerShell keeps `$env:...`.\n- fix(context): session context now handles non-Git Trellis roots without reporting fake clean Git state, and falls back to bounded child-repo discovery for unconfigured polyrepo layouts.\n- fix(opencode): Trellis sub-agent dispatch now skips duplicate SessionStart / workflow-state injection, resolves active tasks from session context, `Active task:` hints, or single-session fallback, and marks successfully injected prompts.\n- fix(hooks): default hook timeouts now use 30s for SessionStart and 15s for per-prompt workflow injection across hook-based platforms, avoiding Windows Python cold-start drops.\n- fix(copilot): SessionStart output no longer emits the stale `Copilot currently ignores sessionStart hook output` system message; it keeps `hookSpecificOutput.additionalContext` as the documented payload.\n\n**Internal:**\n- docs(spec): platform and cross-platform spec templates document shell-dialect-aware `TRELLIS_CONTEXT_ID` prefix rules for OpenCode.",
|
|
7
|
+
"migrations": [],
|
|
8
|
+
"notes": "Run `trellis update` to refresh hash-tracked hook, OpenCode, session-context, and spec templates. No migration required."
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.6.0-beta.7",
|
|
3
|
+
"description": "Beta patch: brings the v0.5.12 workflow update fix into the 0.6 beta line.",
|
|
4
|
+
"breaking": false,
|
|
5
|
+
"recommendMigrate": false,
|
|
6
|
+
"changelog": "**Bug Fixes:**\n- fix(update): update `.trellis/workflow.md` as a whole hash-tracked template so runtime phase routing markers, including Codex `codex-inline` / `codex-sub-agent` blocks, refresh during `trellis update`.",
|
|
7
|
+
"migrations": [],
|
|
8
|
+
"notes": "Beta patch on top of 0.6.0-beta.6. Run `trellis update` to refresh hash-tracked workflow templates. No migration required."
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.6.0-beta.8",
|
|
3
|
+
"description": "Beta patch: adds task-creation consent gates and planning artifacts across workflow, hooks, skills, and platform templates.",
|
|
4
|
+
"breaking": false,
|
|
5
|
+
"recommendMigrate": false,
|
|
6
|
+
"changelog": "**Enhancements:**\n- feat(workflow): no-task turns now classify simple conversation, inline small work, and full Trellis tasks before task creation; complex work asks before creating a task and entering planning.\n- feat(task): `task.py create` now creates a default `prd.md`, while complex tasks require `design.md` and `implement.md` before `task.py start`.\n- feat(context): implement/check context loading now uses the same artifact order across hook-push, pull-prelude, Pi, OpenCode, and inline modes: jsonl entries -> `prd.md` -> optional `design.md` -> optional `implement.md`.\n- feat(codex): Codex no-task breadcrumbs include `<trellis-bootstrap>` plus explicit `<codex-mode>` text, and inline mode keeps implementation/check work in the main session.",
|
|
7
|
+
"migrations": [],
|
|
8
|
+
"notes": "Beta patch on top of 0.6.0-beta.7. Run `trellis update` to refresh workflow, hook, skill, agent, and platform templates. No migration required."
|
|
9
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"type": "command",
|
|
12
12
|
"command": "{{PYTHON_CMD}} .claude/hooks/session-start.py",
|
|
13
|
-
"timeout":
|
|
13
|
+
"timeout": 30
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
},
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
{
|
|
21
21
|
"type": "command",
|
|
22
22
|
"command": "{{PYTHON_CMD}} .claude/hooks/session-start.py",
|
|
23
|
-
"timeout":
|
|
23
|
+
"timeout": 30
|
|
24
24
|
}
|
|
25
25
|
]
|
|
26
26
|
},
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
{
|
|
31
31
|
"type": "command",
|
|
32
32
|
"command": "{{PYTHON_CMD}} .claude/hooks/session-start.py",
|
|
33
|
-
"timeout":
|
|
33
|
+
"timeout": 30
|
|
34
34
|
}
|
|
35
35
|
]
|
|
36
36
|
}
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
{
|
|
64
64
|
"type": "command",
|
|
65
65
|
"command": "{{PYTHON_CMD}} .claude/hooks/inject-workflow-state.py",
|
|
66
|
-
"timeout":
|
|
66
|
+
"timeout": 15
|
|
67
67
|
}
|
|
68
68
|
]
|
|
69
69
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
9
|
"command": "{{PYTHON_CMD}} .codebuddy/hooks/session-start.py",
|
|
10
|
-
"timeout":
|
|
10
|
+
"timeout": 30
|
|
11
11
|
}
|
|
12
12
|
]
|
|
13
13
|
},
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
{
|
|
18
18
|
"type": "command",
|
|
19
19
|
"command": "{{PYTHON_CMD}} .codebuddy/hooks/session-start.py",
|
|
20
|
-
"timeout":
|
|
20
|
+
"timeout": 30
|
|
21
21
|
}
|
|
22
22
|
]
|
|
23
23
|
},
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"type": "command",
|
|
29
29
|
"command": "{{PYTHON_CMD}} .codebuddy/hooks/session-start.py",
|
|
30
|
-
"timeout":
|
|
30
|
+
"timeout": 30
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
{
|
|
51
51
|
"type": "command",
|
|
52
52
|
"command": "{{PYTHON_CMD}} .codebuddy/hooks/inject-workflow-state.py",
|
|
53
|
-
"timeout":
|
|
53
|
+
"timeout": 15
|
|
54
54
|
}
|
|
55
55
|
]
|
|
56
56
|
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
"""
|
|
4
|
-
Copilot Session Start Hook - Emit Trellis session-start
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
Copilot Session Start Hook - Emit Trellis session-start context.
|
|
5
|
+
|
|
6
|
+
Microsoft VS Code Agent hooks are in preview and have been documented since
|
|
7
|
+
VS Code 1.110 (February 2026). The official documentation
|
|
8
|
+
(https://code.visualstudio.com/docs/copilot/customization/hooks) defines
|
|
9
|
+
`SessionStart.hookSpecificOutput.additionalContext` as the field used to inject
|
|
10
|
+
additional context into the agent's conversation.
|
|
11
|
+
|
|
12
|
+
This script emits the spec-compliant SessionStart payload. Whether Copilot
|
|
13
|
+
actually consumes `additionalContext` depends on the user's installed VS Code
|
|
14
|
+
and Copilot versions, which is outside Trellis's control. UserPromptSubmit
|
|
15
|
+
breadcrumbs remain available as a per-turn complement.
|
|
11
16
|
"""
|
|
12
17
|
|
|
13
18
|
from __future__ import annotations
|
|
@@ -412,10 +417,6 @@ If a task is READY, execute its Next required action without asking whether to c
|
|
|
412
417
|
context = output.getvalue()
|
|
413
418
|
result = {
|
|
414
419
|
"suppressOutput": True,
|
|
415
|
-
"systemMessage": (
|
|
416
|
-
f"Trellis SessionStart diagnostics emitted ({len(context)} chars); "
|
|
417
|
-
"Copilot currently ignores sessionStart hook output."
|
|
418
|
-
),
|
|
419
420
|
"hookSpecificOutput": {
|
|
420
421
|
"hookEventName": "SessionStart",
|
|
421
422
|
"additionalContext": context,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
{
|
|
5
5
|
"type": "command",
|
|
6
6
|
"command": "{{PYTHON_CMD}} .github/copilot/hooks/session-start.py",
|
|
7
|
-
"timeout":
|
|
7
|
+
"timeout": 30
|
|
8
8
|
}
|
|
9
9
|
],
|
|
10
10
|
"userPromptSubmitted": [
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"type": "command",
|
|
13
13
|
"bash": "{{PYTHON_CMD}} .github/copilot/hooks/inject-workflow-state.py",
|
|
14
14
|
"powershell": "{{PYTHON_CMD}} .github/copilot/hooks/inject-workflow-state.py",
|
|
15
|
-
"timeoutSec":
|
|
15
|
+
"timeoutSec": 15
|
|
16
16
|
}
|
|
17
17
|
]
|
|
18
18
|
}
|
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
"sessionStart": [
|
|
12
12
|
{
|
|
13
13
|
"command": "{{PYTHON_CMD}} .cursor/hooks/session-start.py",
|
|
14
|
-
"timeout":
|
|
14
|
+
"timeout": 30
|
|
15
15
|
}
|
|
16
16
|
],
|
|
17
17
|
"beforeSubmitPrompt": [
|
|
18
18
|
{
|
|
19
19
|
"command": "{{PYTHON_CMD}} .cursor/hooks/inject-workflow-state.py",
|
|
20
|
-
"timeout":
|
|
20
|
+
"timeout": 15
|
|
21
21
|
}
|
|
22
22
|
],
|
|
23
23
|
"beforeShellExecution": [
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
9
|
"command": "{{PYTHON_CMD}} .factory/hooks/session-start.py",
|
|
10
|
-
"timeout":
|
|
10
|
+
"timeout": 30
|
|
11
11
|
}
|
|
12
12
|
]
|
|
13
13
|
},
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
{
|
|
18
18
|
"type": "command",
|
|
19
19
|
"command": "{{PYTHON_CMD}} .factory/hooks/session-start.py",
|
|
20
|
-
"timeout":
|
|
20
|
+
"timeout": 30
|
|
21
21
|
}
|
|
22
22
|
]
|
|
23
23
|
},
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"type": "command",
|
|
29
29
|
"command": "{{PYTHON_CMD}} .factory/hooks/session-start.py",
|
|
30
|
-
"timeout":
|
|
30
|
+
"timeout": 30
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
{
|
|
51
51
|
"type": "command",
|
|
52
52
|
"command": "{{PYTHON_CMD}} .factory/hooks/inject-workflow-state.py",
|
|
53
|
-
"timeout":
|
|
53
|
+
"timeout": 15
|
|
54
54
|
}
|
|
55
55
|
]
|
|
56
56
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
9
|
"command": "{{PYTHON_CMD}} .gemini/hooks/session-start.py",
|
|
10
|
-
"timeout":
|
|
10
|
+
"timeout": 30000
|
|
11
11
|
}
|
|
12
12
|
]
|
|
13
13
|
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
{
|
|
20
20
|
"type": "command",
|
|
21
21
|
"command": "{{PYTHON_CMD}} .gemini/hooks/inject-workflow-state.py",
|
|
22
|
-
"timeout":
|
|
22
|
+
"timeout": 15000
|
|
23
23
|
}
|
|
24
24
|
]
|
|
25
25
|
}
|
|
@@ -215,20 +215,26 @@ home = Path.home()
|
|
|
215
215
|
```
|
|
216
216
|
|
|
217
217
|
**Rule 2**: When injecting environment variables into shell commands, generate
|
|
218
|
-
the prefix for the actual
|
|
219
|
-
AI tool "Bash" surfaces on Windows may execute through
|
|
218
|
+
the prefix for the actual shell that will parse the command. Do not choose
|
|
219
|
+
syntax from OS alone. AI tool "Bash" surfaces on Windows may execute through
|
|
220
|
+
PowerShell, Git Bash, MSYS2, or another POSIX-like shell.
|
|
220
221
|
|
|
221
222
|
```javascript
|
|
222
223
|
// BAD - breaks when the host shell is PowerShell
|
|
223
224
|
command = `export TRELLIS_CONTEXT_ID=${shellQuote(contextKey)}; ${command}`;
|
|
224
225
|
|
|
225
|
-
// GOOD - shell-aware command prefix
|
|
226
|
-
const prefix = process.platform === "win32"
|
|
226
|
+
// GOOD - shell-dialect-aware command prefix
|
|
227
|
+
const prefix = process.platform === "win32" && !isWindowsPosixShell(process.env)
|
|
227
228
|
? `$env:TRELLIS_CONTEXT_ID = ${powershellQuote(contextKey)}; `
|
|
228
229
|
: `export TRELLIS_CONTEXT_ID=${shellQuote(contextKey)}; `;
|
|
229
230
|
command = `${prefix}${command}`;
|
|
230
231
|
```
|
|
231
232
|
|
|
233
|
+
On Windows, treat `MSYSTEM`, `MINGW_PREFIX`, `OSTYPE=msys|mingw|cygwin`,
|
|
234
|
+
`SHELL=...bash`, or a platform-specific Git Bash setting as POSIX-shell
|
|
235
|
+
signals. Keep PowerShell as the Windows default when there is no POSIX-shell
|
|
236
|
+
signal.
|
|
237
|
+
|
|
232
238
|
Also make duplicate-injection detection shell-aware. A guard that only matches
|
|
233
239
|
`export VAR=` will miss PowerShell's `$env:VAR = ...` form and can wrap an
|
|
234
240
|
already-correct command a second time.
|
|
@@ -63,6 +63,21 @@ function buildContextKey(platformName, kind, value) {
|
|
|
63
63
|
return safeValue ? `${platformName}_${safeValue}` : `${platformName}_${hashValue(value)}`
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Matches `trellis-implement`, `trellis-check`, `trellis-research` exactly.
|
|
67
|
+
// Used by chat.message plugins to skip injection inside Trellis sub-agent turns.
|
|
68
|
+
const TRELLIS_SUBAGENT_RE = /^trellis-(implement|check|research)$/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Return true when the OpenCode `chat.message` input represents a Trellis
|
|
72
|
+
* sub-agent turn. `input.agent` is set by OpenCode when a Task tool spawns a
|
|
73
|
+
* child session with a custom agent (see `packages/opencode/src/tool/task.ts`).
|
|
74
|
+
*/
|
|
75
|
+
export function isTrellisSubagent(input) {
|
|
76
|
+
if (!input || typeof input !== "object") return false
|
|
77
|
+
const agent = typeof input.agent === "string" ? input.agent.trim() : ""
|
|
78
|
+
return TRELLIS_SUBAGENT_RE.test(agent)
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
/**
|
|
67
82
|
* Trellis Context Manager
|
|
68
83
|
*/
|
|
@@ -116,27 +131,74 @@ export class TrellisContext {
|
|
|
116
131
|
|
|
117
132
|
/**
|
|
118
133
|
* Get active task from session runtime context.
|
|
134
|
+
*
|
|
135
|
+
* Resolution order (mirrors Python `active_task.resolve_active_task`):
|
|
136
|
+
* 1. Lookup the runtime file for the input-derived context key.
|
|
137
|
+
* 2. If that misses and exactly one session runtime file exists locally,
|
|
138
|
+
* use it (`_resolveSingleSessionFallback`). Refuses to guess when 0 or
|
|
139
|
+
* ≥2 files exist so multi-window isolation holds.
|
|
119
140
|
*/
|
|
120
141
|
getActiveTask(platformInput = null) {
|
|
121
142
|
const contextKey = this.getContextKey(platformInput)
|
|
122
|
-
if (
|
|
123
|
-
|
|
143
|
+
if (contextKey) {
|
|
144
|
+
const context = this.readContext(contextKey)
|
|
145
|
+
const taskRef = this.normalizeTaskRef(context?.current_task || "")
|
|
146
|
+
if (taskRef) {
|
|
147
|
+
const taskDir = this.resolveTaskDir(taskRef)
|
|
148
|
+
return {
|
|
149
|
+
taskPath: taskRef,
|
|
150
|
+
source: `session:${contextKey}`,
|
|
151
|
+
stale: !taskDir || !existsSync(taskDir),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
124
154
|
}
|
|
125
155
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const taskDir = this.resolveTaskDir(taskRef)
|
|
130
|
-
return {
|
|
131
|
-
taskPath: taskRef,
|
|
132
|
-
source: `session:${contextKey}`,
|
|
133
|
-
stale: !taskDir || !existsSync(taskDir),
|
|
134
|
-
}
|
|
156
|
+
const fallback = this._resolveSingleSessionFallback()
|
|
157
|
+
if (fallback) {
|
|
158
|
+
return fallback
|
|
135
159
|
}
|
|
136
160
|
|
|
137
161
|
return { taskPath: null, source: "none", stale: false }
|
|
138
162
|
}
|
|
139
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Mirror of Python `_resolve_single_session_fallback`. Returns the task
|
|
166
|
+
* pointed at by the sole session runtime file when exactly one exists,
|
|
167
|
+
* else null.
|
|
168
|
+
*/
|
|
169
|
+
_resolveSingleSessionFallback() {
|
|
170
|
+
const sessionsDir = join(this.directory, ".trellis", ".runtime", "sessions")
|
|
171
|
+
if (!existsSync(sessionsDir)) return null
|
|
172
|
+
|
|
173
|
+
let files
|
|
174
|
+
try {
|
|
175
|
+
files = readdirSync(sessionsDir)
|
|
176
|
+
.filter(name => name.endsWith(".json"))
|
|
177
|
+
.sort()
|
|
178
|
+
} catch {
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
if (files.length !== 1) return null
|
|
182
|
+
|
|
183
|
+
const sessionFile = join(sessionsDir, files[0])
|
|
184
|
+
let context
|
|
185
|
+
try {
|
|
186
|
+
context = JSON.parse(readFileSync(sessionFile, "utf-8"))
|
|
187
|
+
} catch {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
const taskRef = this.normalizeTaskRef(context?.current_task || "")
|
|
191
|
+
if (!taskRef) return null
|
|
192
|
+
|
|
193
|
+
const taskDir = this.resolveTaskDir(taskRef)
|
|
194
|
+
const fallbackKey = files[0].replace(/\.json$/, "")
|
|
195
|
+
return {
|
|
196
|
+
taskPath: taskRef,
|
|
197
|
+
source: `session-fallback:${fallbackKey}`,
|
|
198
|
+
stale: !taskDir || !existsSync(taskDir),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
140
202
|
getCurrentTask(platformInput = null) {
|
|
141
203
|
return this.getActiveTask(platformInput).taskPath
|
|
142
204
|
}
|
|
@@ -14,24 +14,39 @@ import { TrellisContext, debugLog } from "../lib/trellis-context.js"
|
|
|
14
14
|
const AGENTS_ALL = ["implement", "check", "research"]
|
|
15
15
|
const AGENTS_REQUIRE_TASK = ["implement", "check"]
|
|
16
16
|
|
|
17
|
+
// Match `Active task: <path>` on the first non-empty line of the dispatch
|
|
18
|
+
// prompt. Mirrors the contract in workflow.md's [workflow-state:in_progress]
|
|
19
|
+
// breadcrumb so multi-window users can disambiguate which task is targeted.
|
|
20
|
+
const ACTIVE_TASK_HINT_RE = /^\s*Active task:\s*(\S+)\s*$/m
|
|
21
|
+
|
|
22
|
+
function extractActiveTaskHint(prompt) {
|
|
23
|
+
if (typeof prompt !== "string" || !prompt) return null
|
|
24
|
+
const match = prompt.match(ACTIVE_TASK_HINT_RE)
|
|
25
|
+
return match ? match[1].trim() : null
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
/**
|
|
18
|
-
* Get context for implement agent
|
|
29
|
+
* Get context for implement agent. `taskDir` may be relative
|
|
30
|
+
* (`.trellis/tasks/foo`) or absolute; both are resolved via
|
|
31
|
+
* `ctx.resolveTaskDir`.
|
|
19
32
|
*/
|
|
20
33
|
function getImplementContext(ctx, taskDir) {
|
|
21
34
|
const parts = []
|
|
35
|
+
const taskDirFull = ctx.resolveTaskDir(taskDir)
|
|
36
|
+
if (!taskDirFull) return ""
|
|
22
37
|
|
|
23
|
-
const jsonlPath = join(
|
|
38
|
+
const jsonlPath = join(taskDirFull, "implement.jsonl")
|
|
24
39
|
const entries = ctx.readJsonlWithFiles(jsonlPath)
|
|
25
40
|
if (entries.length > 0) {
|
|
26
41
|
parts.push(ctx.buildContextFromEntries(entries))
|
|
27
42
|
}
|
|
28
43
|
|
|
29
|
-
const prd = ctx.
|
|
44
|
+
const prd = ctx.readFile(join(taskDirFull, "prd.md"))
|
|
30
45
|
if (prd) {
|
|
31
46
|
parts.push(`=== ${taskDir}/prd.md (Requirements) ===\n${prd}`)
|
|
32
47
|
}
|
|
33
48
|
|
|
34
|
-
const info = ctx.
|
|
49
|
+
const info = ctx.readFile(join(taskDirFull, "info.md"))
|
|
35
50
|
if (info) {
|
|
36
51
|
parts.push(`=== ${taskDir}/info.md (Technical Design) ===\n${info}`)
|
|
37
52
|
}
|
|
@@ -40,18 +55,20 @@ function getImplementContext(ctx, taskDir) {
|
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
/**
|
|
43
|
-
* Get context for check agent
|
|
58
|
+
* Get context for check agent. `taskDir` may be relative or absolute.
|
|
44
59
|
*/
|
|
45
60
|
function getCheckContext(ctx, taskDir) {
|
|
46
61
|
const parts = []
|
|
62
|
+
const taskDirFull = ctx.resolveTaskDir(taskDir)
|
|
63
|
+
if (!taskDirFull) return ""
|
|
47
64
|
|
|
48
|
-
const jsonlPath = join(
|
|
65
|
+
const jsonlPath = join(taskDirFull, "check.jsonl")
|
|
49
66
|
const entries = ctx.readJsonlWithFiles(jsonlPath)
|
|
50
67
|
if (entries.length > 0) {
|
|
51
68
|
parts.push(ctx.buildContextFromEntries(entries))
|
|
52
69
|
}
|
|
53
70
|
|
|
54
|
-
const prd = ctx.
|
|
71
|
+
const prd = ctx.readFile(join(taskDirFull, "prd.md"))
|
|
55
72
|
if (prd) {
|
|
56
73
|
parts.push(`=== ${taskDir}/prd.md (Requirements) ===\n${prd}`)
|
|
57
74
|
}
|
|
@@ -128,7 +145,8 @@ function getResearchContext(ctx) {
|
|
|
128
145
|
*/
|
|
129
146
|
function buildPrompt(agentType, originalPrompt, context, isFinish = false) {
|
|
130
147
|
const templates = {
|
|
131
|
-
implement:
|
|
148
|
+
implement: `<!-- trellis-hook-injected -->
|
|
149
|
+
# Implement Agent Task
|
|
132
150
|
|
|
133
151
|
You are the Implement Agent in the Multi-Agent Pipeline.
|
|
134
152
|
|
|
@@ -157,7 +175,8 @@ ${originalPrompt}
|
|
|
157
175
|
- Follow all dev specs injected above
|
|
158
176
|
- Report list of modified/created files when done`,
|
|
159
177
|
|
|
160
|
-
check: isFinish ?
|
|
178
|
+
check: isFinish ? `<!-- trellis-hook-injected -->
|
|
179
|
+
# Finish Agent Task
|
|
161
180
|
|
|
162
181
|
You are performing the final check before creating a PR.
|
|
163
182
|
|
|
@@ -191,7 +210,8 @@ ${originalPrompt}
|
|
|
191
210
|
- Do NOT update specs for trivial changes (typos, formatting, obvious fixes)
|
|
192
211
|
- If critical CODE issues found, report them clearly (fix specs, not code)
|
|
193
212
|
- Verify all acceptance criteria in prd.md are met` :
|
|
194
|
-
|
|
213
|
+
`<!-- trellis-hook-injected -->
|
|
214
|
+
# Check Agent Task
|
|
195
215
|
|
|
196
216
|
You are the Check Agent in the Multi-Agent Pipeline.
|
|
197
217
|
|
|
@@ -219,7 +239,8 @@ ${originalPrompt}
|
|
|
219
239
|
- Fix issues yourself, don't just report
|
|
220
240
|
- Must execute complete checklist`,
|
|
221
241
|
|
|
222
|
-
research:
|
|
242
|
+
research: `<!-- trellis-hook-injected -->
|
|
243
|
+
# Research Agent Task
|
|
223
244
|
|
|
224
245
|
You are the Research Agent in the Multi-Agent Pipeline.
|
|
225
246
|
|
|
@@ -264,9 +285,29 @@ function powershellQuote(value) {
|
|
|
264
285
|
return `'${String(value).replace(/'/g, "''")}'`
|
|
265
286
|
}
|
|
266
287
|
|
|
267
|
-
function
|
|
268
|
-
|
|
269
|
-
|
|
288
|
+
function envValue(env, key) {
|
|
289
|
+
const value = env?.[key]
|
|
290
|
+
return typeof value === "string" && value.trim() ? value.trim() : null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function shellBasename(value) {
|
|
294
|
+
return value.replace(/\\/g, "/").split("/").pop()?.toLowerCase() || ""
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isWindowsPosixShell(env = process.env) {
|
|
298
|
+
if (envValue(env, "MSYSTEM")) return true
|
|
299
|
+
if (envValue(env, "MINGW_PREFIX")) return true
|
|
300
|
+
if (envValue(env, "OPENCODE_GIT_BASH_PATH")) return true
|
|
301
|
+
|
|
302
|
+
const ostype = envValue(env, "OSTYPE")?.toLowerCase() || ""
|
|
303
|
+
if (/(msys|mingw|cygwin)/.test(ostype)) return true
|
|
304
|
+
|
|
305
|
+
const shell = shellBasename(envValue(env, "SHELL") || "")
|
|
306
|
+
return /^(bash|sh|zsh)(\.exe)?$/.test(shell)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function buildTrellisContextPrefix(contextKey, hostPlatform = process.platform, env = process.env) {
|
|
310
|
+
if (hostPlatform === "win32" && !isWindowsPosixShell(env)) {
|
|
270
311
|
return `$env:TRELLIS_CONTEXT_ID = ${powershellQuote(contextKey)}; `
|
|
271
312
|
}
|
|
272
313
|
|
|
@@ -285,7 +326,7 @@ function commandStartsWithTrellisContext(command) {
|
|
|
285
326
|
return (
|
|
286
327
|
/^TRELLIS_CONTEXT_ID\s*=/.test(firstCommand) ||
|
|
287
328
|
/^export\s+TRELLIS_CONTEXT_ID\s*=/.test(firstCommand) ||
|
|
288
|
-
/^env\s+(?:[
|
|
329
|
+
/^env\s+(?:(?:-\S+|[A-Za-z_][A-Za-z0-9_]*=\S*)\s+)*TRELLIS_CONTEXT_ID\s*=/.test(firstCommand) ||
|
|
289
330
|
/^\$env:TRELLIS_CONTEXT_ID\s*=/i.test(firstCommand)
|
|
290
331
|
)
|
|
291
332
|
}
|
|
@@ -294,7 +335,7 @@ function commandStartsWithTrellisContext(command) {
|
|
|
294
335
|
* OpenCode TUI may not expose OPENCODE_RUN_ID to Bash. The plugin hook still
|
|
295
336
|
* receives session identity, so inject it into Bash commands before execution.
|
|
296
337
|
*/
|
|
297
|
-
function injectTrellisContextIntoBash(ctx, input, output, hostPlatform) {
|
|
338
|
+
function injectTrellisContextIntoBash(ctx, input, output, hostPlatform, env) {
|
|
298
339
|
const args = output?.args
|
|
299
340
|
const commandKey = getBashCommandKey(args)
|
|
300
341
|
if (!commandKey) return false
|
|
@@ -306,7 +347,7 @@ function injectTrellisContextIntoBash(ctx, input, output, hostPlatform) {
|
|
|
306
347
|
const contextKey = ctx.getContextKey(input)
|
|
307
348
|
if (!contextKey) return false
|
|
308
349
|
|
|
309
|
-
args[commandKey] = `${buildTrellisContextPrefix(contextKey, hostPlatform)}${command}`
|
|
350
|
+
args[commandKey] = `${buildTrellisContextPrefix(contextKey, hostPlatform, env)}${command}`
|
|
310
351
|
return true
|
|
311
352
|
}
|
|
312
353
|
|
|
@@ -315,7 +356,7 @@ function injectTrellisContextIntoBash(ctx, input, output, hostPlatform) {
|
|
|
315
356
|
// (packages/opencode/src/plugin/index.ts — `for ([_, fn] of Object.entries(mod)) await fn(input)`);
|
|
316
357
|
// the previous `{ id, server }` object shape failed with
|
|
317
358
|
// `TypeError: fn is not a function` in 1.2.x.
|
|
318
|
-
export default async ({ directory, platform: hostPlatform = process.platform }) => {
|
|
359
|
+
export default async ({ directory, platform: hostPlatform = process.platform, env = process.env }) => {
|
|
319
360
|
const ctx = new TrellisContext(directory)
|
|
320
361
|
debugLog("inject", "Plugin loaded, directory:", directory)
|
|
321
362
|
|
|
@@ -329,7 +370,7 @@ export default async ({ directory, platform: hostPlatform = process.platform })
|
|
|
329
370
|
|
|
330
371
|
const toolName = input?.tool?.toLowerCase()
|
|
331
372
|
if (toolName === "bash") {
|
|
332
|
-
if (injectTrellisContextIntoBash(ctx, input, output, hostPlatform)) {
|
|
373
|
+
if (injectTrellisContextIntoBash(ctx, input, output, hostPlatform, env)) {
|
|
333
374
|
debugLog("inject", "Injected TRELLIS_CONTEXT_ID into Bash command")
|
|
334
375
|
}
|
|
335
376
|
return
|
|
@@ -354,8 +395,53 @@ export default async ({ directory, platform: hostPlatform = process.platform })
|
|
|
354
395
|
return
|
|
355
396
|
}
|
|
356
397
|
|
|
357
|
-
// Resolve active task
|
|
358
|
-
|
|
398
|
+
// Resolve active task in this priority order (only later steps
|
|
399
|
+
// run when earlier ones miss):
|
|
400
|
+
// 1. Exact session runtime context lookup for input.sessionID
|
|
401
|
+
// 2. `Active task: <path>` hint in the dispatch prompt
|
|
402
|
+
// (explicit per-dispatch override — beats single-session
|
|
403
|
+
// inference so multi-window users can disambiguate)
|
|
404
|
+
// 3. Single-session fallback — only when exactly 1 session
|
|
405
|
+
// runtime file exists locally
|
|
406
|
+
let taskDir = null
|
|
407
|
+
let taskSource = null
|
|
408
|
+
|
|
409
|
+
const contextKey = ctx.getContextKey(input)
|
|
410
|
+
if (contextKey) {
|
|
411
|
+
const context = ctx.readContext(contextKey)
|
|
412
|
+
const exactRef = ctx.normalizeTaskRef(context?.current_task || "")
|
|
413
|
+
if (exactRef) {
|
|
414
|
+
taskDir = exactRef
|
|
415
|
+
taskSource = `session:${contextKey}`
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!taskDir) {
|
|
420
|
+
const hintRef = extractActiveTaskHint(originalPrompt)
|
|
421
|
+
if (hintRef) {
|
|
422
|
+
const hintNormalized = ctx.normalizeTaskRef(hintRef)
|
|
423
|
+
if (hintNormalized) {
|
|
424
|
+
const hintDir = ctx.resolveTaskDir(hintNormalized)
|
|
425
|
+
if (hintDir && existsSync(hintDir)) {
|
|
426
|
+
taskDir = hintNormalized
|
|
427
|
+
taskSource = "prompt-hint"
|
|
428
|
+
debugLog("inject", "Resolved task from Active task: hint:", hintNormalized)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (!taskDir) {
|
|
435
|
+
const fallback = ctx._resolveSingleSessionFallback()
|
|
436
|
+
if (fallback?.taskPath) {
|
|
437
|
+
const fallbackDir = ctx.resolveTaskDir(fallback.taskPath)
|
|
438
|
+
if (fallbackDir && existsSync(fallbackDir)) {
|
|
439
|
+
taskDir = fallback.taskPath
|
|
440
|
+
taskSource = fallback.source
|
|
441
|
+
debugLog("inject", "Resolved task via single-session fallback:", taskDir, "source:", taskSource)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
359
445
|
|
|
360
446
|
// Agents requiring task directory
|
|
361
447
|
if (AGENTS_REQUIRE_TASK.includes(subagentType)) {
|
|
@@ -364,8 +450,8 @@ export default async ({ directory, platform: hostPlatform = process.platform })
|
|
|
364
450
|
debugLog("inject", "Skipping - no current task")
|
|
365
451
|
return
|
|
366
452
|
}
|
|
367
|
-
const taskDirFull =
|
|
368
|
-
if (!existsSync(taskDirFull)) {
|
|
453
|
+
const taskDirFull = ctx.resolveTaskDir(taskDir)
|
|
454
|
+
if (!taskDirFull || !existsSync(taskDirFull)) {
|
|
369
455
|
debugLog("inject", "Skipping - task directory not found")
|
|
370
456
|
return
|
|
371
457
|
}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { existsSync, readFileSync } from "fs"
|
|
27
27
|
import { join } from "path"
|
|
28
|
-
import { TrellisContext, debugLog } from "../lib/trellis-context.js"
|
|
28
|
+
import { TrellisContext, debugLog, isTrellisSubagent } from "../lib/trellis-context.js"
|
|
29
29
|
|
|
30
30
|
// Supports STATUS values with letters, digits, underscores, hyphens
|
|
31
31
|
// (so "in-review" / "blocked-by-team" work alongside "in_progress").
|
|
@@ -111,6 +111,13 @@ export default async ({ directory }) => {
|
|
|
111
111
|
// so it persists in conversation history.
|
|
112
112
|
"chat.message": async (input, output) => {
|
|
113
113
|
try {
|
|
114
|
+
// Skip Trellis sub-agent turns — the per-turn breadcrumb is for the
|
|
115
|
+
// main session only; sub-agent context comes from the parent's
|
|
116
|
+
// tool.execute.before injection.
|
|
117
|
+
if (isTrellisSubagent(input)) {
|
|
118
|
+
debugLog("workflow-state", "Skipping trellis subagent turn:", input?.agent)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
114
121
|
if (process.env.TRELLIS_HOOKS === "0" || process.env.TRELLIS_DISABLE_HOOKS === "1") {
|
|
115
122
|
return
|
|
116
123
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Uses OpenCode's chat.message hook directly so the context persists in history.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { TrellisContext, contextCollector, debugLog } from "../lib/trellis-context.js"
|
|
9
|
+
import { TrellisContext, contextCollector, debugLog, isTrellisSubagent } from "../lib/trellis-context.js"
|
|
10
10
|
import {
|
|
11
11
|
buildSessionContext,
|
|
12
12
|
hasPersistedInjectedContext,
|
|
@@ -43,6 +43,14 @@ export default async ({ directory, client }) => {
|
|
|
43
43
|
const agent = input.agent || "unknown"
|
|
44
44
|
debugLog("session", "chat.message called, sessionID:", sessionID, "agent:", agent)
|
|
45
45
|
|
|
46
|
+
// Skip Trellis sub-agent turns — sub-agent context is injected by
|
|
47
|
+
// `inject-subagent-context.js` on the parent's tool.execute.before;
|
|
48
|
+
// re-injecting the main-session SessionStart here would drown that.
|
|
49
|
+
if (isTrellisSubagent(input)) {
|
|
50
|
+
debugLog("session", "Skipping trellis subagent turn:", agent)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
if (process.env.TRELLIS_HOOKS === "0" || process.env.TRELLIS_DISABLE_HOOKS === "1") {
|
|
47
55
|
debugLog("session", "Skipping - TRELLIS_HOOKS disabled")
|
|
48
56
|
return
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
9
|
"command": "{{PYTHON_CMD}} .qoder/hooks/session-start.py",
|
|
10
|
-
"timeout":
|
|
10
|
+
"timeout": 30
|
|
11
11
|
}
|
|
12
12
|
]
|
|
13
13
|
},
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
{
|
|
18
18
|
"type": "command",
|
|
19
19
|
"command": "{{PYTHON_CMD}} .qoder/hooks/session-start.py",
|
|
20
|
-
"timeout":
|
|
20
|
+
"timeout": 30
|
|
21
21
|
}
|
|
22
22
|
]
|
|
23
23
|
},
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"type": "command",
|
|
29
29
|
"command": "{{PYTHON_CMD}} .qoder/hooks/session-start.py",
|
|
30
|
-
"timeout":
|
|
30
|
+
"timeout": 30
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
{
|
|
39
39
|
"type": "command",
|
|
40
40
|
"command": "{{PYTHON_CMD}} .qoder/hooks/inject-workflow-state.py",
|
|
41
|
-
"timeout":
|
|
41
|
+
"timeout": 15
|
|
42
42
|
}
|
|
43
43
|
]
|
|
44
44
|
}
|
|
@@ -50,12 +50,140 @@ _VERSION_RE = re.compile(
|
|
|
50
50
|
r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
|
|
51
51
|
)
|
|
52
52
|
_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
|
|
53
|
+
_POLYREPO_IGNORED_DIRS = {
|
|
54
|
+
"node_modules",
|
|
55
|
+
"target",
|
|
56
|
+
"dist",
|
|
57
|
+
"build",
|
|
58
|
+
"out",
|
|
59
|
+
"bin",
|
|
60
|
+
"obj",
|
|
61
|
+
"vendor",
|
|
62
|
+
"coverage",
|
|
63
|
+
"tmp",
|
|
64
|
+
"__pycache__",
|
|
65
|
+
}
|
|
66
|
+
_POLYREPO_SCAN_MAX_DEPTH = 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_git_worktree(path: Path) -> bool:
|
|
70
|
+
"""Return True when path is inside a Git worktree."""
|
|
71
|
+
rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
|
|
72
|
+
return rc == 0 and out.strip().lower() == "true"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_recent_commits(log_output: str) -> list[dict]:
|
|
76
|
+
"""Parse `git log --oneline` output into structured commit entries."""
|
|
77
|
+
commits = []
|
|
78
|
+
for line in log_output.splitlines():
|
|
79
|
+
if not line.strip():
|
|
80
|
+
continue
|
|
81
|
+
parts = line.split(" ", 1)
|
|
82
|
+
if len(parts) >= 2:
|
|
83
|
+
commits.append({"hash": parts[0], "message": parts[1]})
|
|
84
|
+
elif len(parts) == 1:
|
|
85
|
+
commits.append({"hash": parts[0], "message": ""})
|
|
86
|
+
return commits
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None:
|
|
90
|
+
"""Collect Git status for one known repository directory."""
|
|
91
|
+
if not (repo_dir / ".git").exists():
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir)
|
|
95
|
+
branch = branch_out.strip() or "unknown"
|
|
96
|
+
|
|
97
|
+
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir)
|
|
98
|
+
changes = len([l for l in status_out.splitlines() if l.strip()])
|
|
99
|
+
|
|
100
|
+
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir)
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"name": name,
|
|
104
|
+
"path": rel_path,
|
|
105
|
+
"branch": branch,
|
|
106
|
+
"isClean": changes == 0,
|
|
107
|
+
"uncommittedChanges": changes,
|
|
108
|
+
"recentCommits": _parse_recent_commits(log_out),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _collect_root_git_info(repo_root: Path) -> dict:
|
|
113
|
+
"""Collect root Git info without pretending a non-Git root is clean."""
|
|
114
|
+
if not _is_git_worktree(repo_root):
|
|
115
|
+
return {
|
|
116
|
+
"isRepo": False,
|
|
117
|
+
"branch": "",
|
|
118
|
+
"isClean": False,
|
|
119
|
+
"uncommittedChanges": 0,
|
|
120
|
+
"recentCommits": [],
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
|
124
|
+
branch = branch_out.strip() or "unknown"
|
|
125
|
+
|
|
126
|
+
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
|
|
127
|
+
status_lines = [line for line in status_out.splitlines() if line.strip()]
|
|
128
|
+
|
|
129
|
+
_, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
|
|
130
|
+
|
|
131
|
+
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"isRepo": True,
|
|
135
|
+
"branch": branch,
|
|
136
|
+
"isClean": len(status_lines) == 0,
|
|
137
|
+
"uncommittedChanges": len(status_lines),
|
|
138
|
+
"statusShort": short_out.splitlines(),
|
|
139
|
+
"recentCommits": _parse_recent_commits(log_out),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
|
|
144
|
+
"""Discover child Git repositories using the init-time polyrepo heuristic."""
|
|
145
|
+
found: list[str] = []
|
|
53
146
|
|
|
147
|
+
def is_candidate_dir(path: Path) -> bool:
|
|
148
|
+
name = path.name
|
|
149
|
+
return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
|
|
54
150
|
|
|
55
|
-
def
|
|
56
|
-
|
|
151
|
+
def scan(rel_dir: Path, depth: int) -> None:
|
|
152
|
+
if depth >= _POLYREPO_SCAN_MAX_DEPTH:
|
|
153
|
+
return
|
|
154
|
+
abs_dir = repo_root / rel_dir
|
|
155
|
+
try:
|
|
156
|
+
children = sorted(abs_dir.iterdir(), key=lambda p: p.name)
|
|
157
|
+
except OSError:
|
|
158
|
+
return
|
|
57
159
|
|
|
58
|
-
|
|
160
|
+
for child in children:
|
|
161
|
+
if not child.is_dir() or not is_candidate_dir(child):
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
child_rel = (
|
|
165
|
+
rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
|
|
166
|
+
)
|
|
167
|
+
if (child / ".git").exists():
|
|
168
|
+
found.append(child_rel.as_posix())
|
|
169
|
+
continue
|
|
170
|
+
scan(child_rel, depth + 1)
|
|
171
|
+
|
|
172
|
+
scan(Path("."), 0)
|
|
173
|
+
if len(found) < 2:
|
|
174
|
+
return []
|
|
175
|
+
return [(path.replace("/", "_"), path) for path in sorted(found)]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _collect_package_git_info(
|
|
179
|
+
repo_root: Path,
|
|
180
|
+
discover_unconfigured: bool = False,
|
|
181
|
+
) -> list[dict]:
|
|
182
|
+
"""Collect Git status for independent package repositories.
|
|
183
|
+
|
|
184
|
+
Packages marked with ``git: true`` in config.yaml are authoritative.
|
|
185
|
+
When the Trellis root is not a Git repo and no configured package repos are
|
|
186
|
+
available, optionally fall back to the bounded polyrepo child scan.
|
|
59
187
|
|
|
60
188
|
Returns:
|
|
61
189
|
List of dicts with keys: name, path, branch, isClean,
|
|
@@ -63,41 +191,56 @@ def _collect_package_git_info(repo_root: Path) -> list[dict]:
|
|
|
63
191
|
Empty list if no git-repo packages are configured.
|
|
64
192
|
"""
|
|
65
193
|
git_pkgs = get_git_packages(repo_root)
|
|
66
|
-
if not git_pkgs:
|
|
67
|
-
return []
|
|
68
|
-
|
|
69
194
|
result = []
|
|
70
195
|
for pkg_name, pkg_path in git_pkgs.items():
|
|
71
196
|
pkg_dir = repo_root / pkg_path
|
|
72
|
-
|
|
73
|
-
|
|
197
|
+
info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir)
|
|
198
|
+
if info is not None:
|
|
199
|
+
result.append(info)
|
|
74
200
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if line.strip():
|
|
85
|
-
parts = line.split(" ", 1)
|
|
86
|
-
if len(parts) >= 2:
|
|
87
|
-
commits.append({"hash": parts[0], "message": parts[1]})
|
|
88
|
-
elif len(parts) == 1:
|
|
89
|
-
commits.append({"hash": parts[0], "message": ""})
|
|
90
|
-
|
|
91
|
-
result.append({
|
|
92
|
-
"name": pkg_name,
|
|
93
|
-
"path": pkg_path,
|
|
94
|
-
"branch": branch,
|
|
95
|
-
"isClean": changes == 0,
|
|
96
|
-
"uncommittedChanges": changes,
|
|
97
|
-
"recentCommits": commits,
|
|
98
|
-
})
|
|
201
|
+
if result or not discover_unconfigured:
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
discovered = []
|
|
205
|
+
for pkg_name, pkg_path in _discover_child_git_repos(repo_root):
|
|
206
|
+
info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path)
|
|
207
|
+
if info is not None:
|
|
208
|
+
discovered.append(info)
|
|
209
|
+
return discovered
|
|
99
210
|
|
|
100
|
-
|
|
211
|
+
|
|
212
|
+
def _append_root_git_context(lines: list[str], root_git_info: dict) -> None:
|
|
213
|
+
"""Append root Git status without misleading non-Git roots."""
|
|
214
|
+
lines.append("## GIT STATUS")
|
|
215
|
+
if not root_git_info["isRepo"]:
|
|
216
|
+
lines.append("Root is not a Git repository.")
|
|
217
|
+
lines.append("Run Git commands from the package repository paths listed below.")
|
|
218
|
+
else:
|
|
219
|
+
lines.append(f"Branch: {root_git_info['branch']}")
|
|
220
|
+
if root_git_info["isClean"]:
|
|
221
|
+
lines.append("Working directory: Clean")
|
|
222
|
+
else:
|
|
223
|
+
lines.append(
|
|
224
|
+
f"Working directory: {root_git_info['uncommittedChanges']} "
|
|
225
|
+
"uncommitted change(s)"
|
|
226
|
+
)
|
|
227
|
+
lines.append("")
|
|
228
|
+
lines.append("Changes:")
|
|
229
|
+
for line in root_git_info.get("statusShort", [])[:10]:
|
|
230
|
+
lines.append(line)
|
|
231
|
+
lines.append("")
|
|
232
|
+
|
|
233
|
+
lines.append("## RECENT COMMITS")
|
|
234
|
+
if not root_git_info["isRepo"]:
|
|
235
|
+
lines.append(
|
|
236
|
+
"Root has no Git commit history because it is not a Git repository."
|
|
237
|
+
)
|
|
238
|
+
elif root_git_info["recentCommits"]:
|
|
239
|
+
for commit in root_git_info["recentCommits"]:
|
|
240
|
+
lines.append(f"{commit['hash']} {commit['message']}")
|
|
241
|
+
else:
|
|
242
|
+
lines.append("(no commits)")
|
|
243
|
+
lines.append("")
|
|
101
244
|
|
|
102
245
|
|
|
103
246
|
def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None:
|
|
@@ -301,24 +444,7 @@ def get_context_json(repo_root: Path | None = None) -> dict:
|
|
|
301
444
|
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
|
|
302
445
|
)
|
|
303
446
|
|
|
304
|
-
|
|
305
|
-
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
|
306
|
-
branch = branch_out.strip() or "unknown"
|
|
307
|
-
|
|
308
|
-
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
|
|
309
|
-
git_status_count = len([line for line in status_out.splitlines() if line.strip()])
|
|
310
|
-
is_clean = git_status_count == 0
|
|
311
|
-
|
|
312
|
-
# Recent commits
|
|
313
|
-
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
|
|
314
|
-
commits = []
|
|
315
|
-
for line in log_out.splitlines():
|
|
316
|
-
if line.strip():
|
|
317
|
-
parts = line.split(" ", 1)
|
|
318
|
-
if len(parts) >= 2:
|
|
319
|
-
commits.append({"hash": parts[0], "message": parts[1]})
|
|
320
|
-
elif len(parts) == 1:
|
|
321
|
-
commits.append({"hash": parts[0], "message": ""})
|
|
447
|
+
root_git_info = _collect_root_git_info(repo_root)
|
|
322
448
|
|
|
323
449
|
# Tasks
|
|
324
450
|
tasks = [
|
|
@@ -333,15 +459,19 @@ def get_context_json(repo_root: Path | None = None) -> dict:
|
|
|
333
459
|
]
|
|
334
460
|
|
|
335
461
|
# Package git repos (independent sub-repositories)
|
|
336
|
-
pkg_git_info = _collect_package_git_info(
|
|
462
|
+
pkg_git_info = _collect_package_git_info(
|
|
463
|
+
repo_root,
|
|
464
|
+
discover_unconfigured=not root_git_info["isRepo"],
|
|
465
|
+
)
|
|
337
466
|
|
|
338
467
|
result = {
|
|
339
468
|
"developer": developer or "",
|
|
340
469
|
"git": {
|
|
341
|
-
"
|
|
342
|
-
"
|
|
343
|
-
"
|
|
344
|
-
"
|
|
470
|
+
"isRepo": root_git_info["isRepo"],
|
|
471
|
+
"branch": root_git_info["branch"],
|
|
472
|
+
"isClean": root_git_info["isClean"],
|
|
473
|
+
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
|
474
|
+
"recentCommits": root_git_info["recentCommits"],
|
|
345
475
|
},
|
|
346
476
|
"tasks": {
|
|
347
477
|
"active": tasks,
|
|
@@ -405,39 +535,17 @@ def get_context_text(repo_root: Path | None = None) -> str:
|
|
|
405
535
|
lines.append(f"Name: {developer}")
|
|
406
536
|
lines.append("")
|
|
407
537
|
|
|
408
|
-
|
|
409
|
-
lines
|
|
410
|
-
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
|
411
|
-
branch = branch_out.strip() or "unknown"
|
|
412
|
-
lines.append(f"Branch: {branch}")
|
|
413
|
-
|
|
414
|
-
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
|
|
415
|
-
status_lines = [line for line in status_out.splitlines() if line.strip()]
|
|
416
|
-
status_count = len(status_lines)
|
|
417
|
-
|
|
418
|
-
if status_count == 0:
|
|
419
|
-
lines.append("Working directory: Clean")
|
|
420
|
-
else:
|
|
421
|
-
lines.append(f"Working directory: {status_count} uncommitted change(s)")
|
|
422
|
-
lines.append("")
|
|
423
|
-
lines.append("Changes:")
|
|
424
|
-
_, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
|
|
425
|
-
for line in short_out.splitlines()[:10]:
|
|
426
|
-
lines.append(line)
|
|
427
|
-
lines.append("")
|
|
428
|
-
|
|
429
|
-
# Recent commits
|
|
430
|
-
lines.append("## RECENT COMMITS")
|
|
431
|
-
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
|
|
432
|
-
if log_out.strip():
|
|
433
|
-
for line in log_out.splitlines():
|
|
434
|
-
lines.append(line)
|
|
435
|
-
else:
|
|
436
|
-
lines.append("(no commits)")
|
|
437
|
-
lines.append("")
|
|
538
|
+
root_git_info = _collect_root_git_info(repo_root)
|
|
539
|
+
_append_root_git_context(lines, root_git_info)
|
|
438
540
|
|
|
439
541
|
# Package git repos — independent sub-repositories
|
|
440
|
-
_append_package_git_context(
|
|
542
|
+
_append_package_git_context(
|
|
543
|
+
lines,
|
|
544
|
+
_collect_package_git_info(
|
|
545
|
+
repo_root,
|
|
546
|
+
discover_unconfigured=not root_git_info["isRepo"],
|
|
547
|
+
),
|
|
548
|
+
)
|
|
441
549
|
|
|
442
550
|
# Current task
|
|
443
551
|
lines.append("## CURRENT TASK")
|
|
@@ -557,20 +665,7 @@ def get_context_record_json(repo_root: Path | None = None) -> dict:
|
|
|
557
665
|
developer = get_developer(repo_root)
|
|
558
666
|
tasks_dir = get_tasks_dir(repo_root)
|
|
559
667
|
|
|
560
|
-
|
|
561
|
-
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
|
562
|
-
branch = branch_out.strip() or "unknown"
|
|
563
|
-
|
|
564
|
-
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
|
|
565
|
-
git_status_count = len([line for line in status_out.splitlines() if line.strip()])
|
|
566
|
-
|
|
567
|
-
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
|
|
568
|
-
commits = []
|
|
569
|
-
for line in log_out.splitlines():
|
|
570
|
-
if line.strip():
|
|
571
|
-
parts = line.split(" ", 1)
|
|
572
|
-
if len(parts) >= 2:
|
|
573
|
-
commits.append({"hash": parts[0], "message": parts[1]})
|
|
668
|
+
root_git_info = _collect_root_git_info(repo_root)
|
|
574
669
|
|
|
575
670
|
# My tasks (single pass — collect statuses and filter by assignee)
|
|
576
671
|
all_tasks_list = list(iter_active_tasks(tasks_dir))
|
|
@@ -610,15 +705,19 @@ def get_context_record_json(repo_root: Path | None = None) -> dict:
|
|
|
610
705
|
}
|
|
611
706
|
|
|
612
707
|
# Package git repos
|
|
613
|
-
pkg_git_info = _collect_package_git_info(
|
|
708
|
+
pkg_git_info = _collect_package_git_info(
|
|
709
|
+
repo_root,
|
|
710
|
+
discover_unconfigured=not root_git_info["isRepo"],
|
|
711
|
+
)
|
|
614
712
|
|
|
615
713
|
result = {
|
|
616
714
|
"developer": developer or "",
|
|
617
715
|
"git": {
|
|
618
|
-
"
|
|
619
|
-
"
|
|
620
|
-
"
|
|
621
|
-
"
|
|
716
|
+
"isRepo": root_git_info["isRepo"],
|
|
717
|
+
"branch": root_git_info["branch"],
|
|
718
|
+
"isClean": root_git_info["isClean"],
|
|
719
|
+
"uncommittedChanges": root_git_info["uncommittedChanges"],
|
|
720
|
+
"recentCommits": root_git_info["recentCommits"],
|
|
622
721
|
},
|
|
623
722
|
"myTasks": my_tasks,
|
|
624
723
|
"currentTask": current_task_info,
|
|
@@ -673,39 +772,17 @@ def get_context_text_record(repo_root: Path | None = None) -> str:
|
|
|
673
772
|
lines.append("(no active tasks assigned to you)")
|
|
674
773
|
lines.append("")
|
|
675
774
|
|
|
676
|
-
|
|
677
|
-
lines
|
|
678
|
-
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
|
679
|
-
branch = branch_out.strip() or "unknown"
|
|
680
|
-
lines.append(f"Branch: {branch}")
|
|
681
|
-
|
|
682
|
-
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
|
|
683
|
-
status_lines = [line for line in status_out.splitlines() if line.strip()]
|
|
684
|
-
status_count = len(status_lines)
|
|
685
|
-
|
|
686
|
-
if status_count == 0:
|
|
687
|
-
lines.append("Working directory: Clean")
|
|
688
|
-
else:
|
|
689
|
-
lines.append(f"Working directory: {status_count} uncommitted change(s)")
|
|
690
|
-
lines.append("")
|
|
691
|
-
lines.append("Changes:")
|
|
692
|
-
_, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
|
|
693
|
-
for line in short_out.splitlines()[:10]:
|
|
694
|
-
lines.append(line)
|
|
695
|
-
lines.append("")
|
|
696
|
-
|
|
697
|
-
# RECENT COMMITS
|
|
698
|
-
lines.append("## RECENT COMMITS")
|
|
699
|
-
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
|
|
700
|
-
if log_out.strip():
|
|
701
|
-
for line in log_out.splitlines():
|
|
702
|
-
lines.append(line)
|
|
703
|
-
else:
|
|
704
|
-
lines.append("(no commits)")
|
|
705
|
-
lines.append("")
|
|
775
|
+
root_git_info = _collect_root_git_info(repo_root)
|
|
776
|
+
_append_root_git_context(lines, root_git_info)
|
|
706
777
|
|
|
707
778
|
# Package git repos — independent sub-repositories
|
|
708
|
-
_append_package_git_context(
|
|
779
|
+
_append_package_git_context(
|
|
780
|
+
lines,
|
|
781
|
+
_collect_package_git_info(
|
|
782
|
+
repo_root,
|
|
783
|
+
discover_unconfigured=not root_git_info["isRepo"],
|
|
784
|
+
),
|
|
785
|
+
)
|
|
709
786
|
|
|
710
787
|
# CURRENT TASK
|
|
711
788
|
lines.append("## CURRENT TASK")
|
package/package.json
CHANGED