@mindfoldhq/trellis 0.6.0-beta.3 → 0.6.0-beta.5
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/mem.d.ts +2 -2
- package/dist/commands/mem.d.ts.map +1 -1
- package/dist/commands/mem.js +28 -258
- package/dist/commands/mem.js.map +1 -1
- package/dist/migrations/manifests/0.5.10.json +9 -0
- package/dist/migrations/manifests/0.6.0-beta.4.json +9 -0
- package/dist/migrations/manifests/0.6.0-beta.5.json +9 -0
- package/dist/templates/common/commands/start.md +2 -0
- package/dist/templates/markdown/spec/guides/cross-layer-thinking-guide.md.txt +39 -0
- package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +289 -43
- package/dist/templates/pi/extensions/trellis/index.ts.txt +179 -4
- package/dist/templates/pi/settings.json +9 -0
- package/dist/templates/trellis/index.d.ts +1 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +2 -0
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/add_session.py +44 -24
- package/dist/templates/trellis/scripts/common/safe_commit.py +229 -0
- package/dist/templates/trellis/scripts/common/session_context.py +170 -0
- package/dist/templates/trellis/scripts/common/task_store.py +34 -5
- package/dist/utils/uninstall-scrubbers.d.ts +1 -0
- package/dist/utils/uninstall-scrubbers.d.ts.map +1 -1
- package/dist/utils/uninstall-scrubbers.js +21 -0
- package/dist/utils/uninstall-scrubbers.js.map +1 -1
- package/package.json +1 -3
|
@@ -26,37 +26,68 @@
|
|
|
26
26
|
| `python3` command | ✅ Always available | ⚠️ May need `python` |
|
|
27
27
|
| `python` command | ⚠️ May be Python 2 | ✅ Usually Python 3 |
|
|
28
28
|
|
|
29
|
-
**Rule 1**:
|
|
29
|
+
**Rule 1**: For user-facing docs, help text, and error messages, either:
|
|
30
|
+
|
|
31
|
+
- state the platform rule explicitly (`python` on Windows, `python3` elsewhere), or
|
|
32
|
+
- render the command through the same platform-aware helper / placeholder the code uses.
|
|
30
33
|
|
|
31
34
|
```python
|
|
32
35
|
# BAD - Assumes shebang works
|
|
33
36
|
print("Usage: ./script.py <args>")
|
|
34
37
|
print("Run: script.py <args>")
|
|
35
38
|
|
|
36
|
-
# GOOD -
|
|
37
|
-
print("Usage: python3
|
|
38
|
-
print("Run:
|
|
39
|
+
# GOOD - Platform-aware wording
|
|
40
|
+
print("Usage: python on Windows, python3 elsewhere")
|
|
41
|
+
print("Run: {{PYTHON_CMD}} ./.trellis/scripts/task.py <args>")
|
|
39
42
|
```
|
|
40
43
|
|
|
41
|
-
**Rule 2**: When
|
|
44
|
+
**Rule 2**: When generating config files at init time, use placeholder + platform detection:
|
|
42
45
|
|
|
43
46
|
```typescript
|
|
47
|
+
// In template file (settings.json):
|
|
48
|
+
{ "command": "{{PYTHON_CMD}} .claude/hooks/script.py" }
|
|
49
|
+
|
|
50
|
+
// In configurator:
|
|
44
51
|
function getPythonCommand(): string {
|
|
52
|
+
return process.platform === "win32" ? "python" : "python3";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function replacePlaceholders(content: string): string {
|
|
56
|
+
return content.replace(/\{\{PYTHON_CMD\}\}/g, getPythonCommand());
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Rule 3**: When calling Python at runtime from JavaScript, detect platform dynamically:
|
|
61
|
+
|
|
62
|
+
```javascript
|
|
63
|
+
import { platform } from "os"
|
|
64
|
+
|
|
65
|
+
const PYTHON_CMD = platform() === "win32" ? "python" : "python3"
|
|
66
|
+
execSync(`${PYTHON_CMD} "${scriptPath}"`, { ... })
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Rule 4**: If you need to verify Python is actually installed (not just choose
|
|
70
|
+
the command), probe the same platform-selected alias you will later render or
|
|
71
|
+
execute:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
function getPythonCommand(platform = process.platform): string {
|
|
75
|
+
return platform === "win32" ? "python" : "python3";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function warnIfPythonTooOld(): void {
|
|
79
|
+
const cmd = getPythonCommand();
|
|
45
80
|
try {
|
|
46
|
-
execSync(
|
|
47
|
-
return "python3";
|
|
81
|
+
execSync(`${cmd} --version`, { stdio: "pipe" });
|
|
48
82
|
} catch {
|
|
49
|
-
|
|
50
|
-
execSync("python --version", { stdio: "pipe" });
|
|
51
|
-
return "python";
|
|
52
|
-
} catch {
|
|
53
|
-
return "python3"; // Default, will fail with clear error
|
|
54
|
-
}
|
|
83
|
+
// Missing Python is a separate error path; don't silently swap aliases.
|
|
55
84
|
}
|
|
56
85
|
}
|
|
57
86
|
```
|
|
58
87
|
|
|
59
|
-
**Rule
|
|
88
|
+
**Rule 5**: Don't assume the Python version the AI CLI uses matches your shell's `python3`. The user's terminal may resolve `python3` → homebrew 3.11, but AI CLI hosts (including enterprise-forked Claude Code / Cursor distributions) spawn hook subprocesses with a minimal PATH that resolves `python3` → `/usr/bin/python3` → macOS system 3.9. Distributed templates must either target the lowest plausible version or use `from __future__ import annotations` for PEP 604 syntax. See `cli/backend/script-conventions.md` → **CRITICAL: PEP 604 Annotations Require `from __future__ import annotations`** for the hard rule and audit check.
|
|
89
|
+
|
|
90
|
+
**Rule 6**: When calling Python from Python, use `sys.executable`:
|
|
60
91
|
|
|
61
92
|
```python
|
|
62
93
|
import sys
|
|
@@ -69,30 +100,6 @@ subprocess.run(["python3", "other_script.py"])
|
|
|
69
100
|
subprocess.run([sys.executable, "other_script.py"])
|
|
70
101
|
```
|
|
71
102
|
|
|
72
|
-
**Rule 4**: Don't assume the Python version your AI CLI uses matches your shell's `python3`. Your terminal may resolve `python3` → 3.11 (via homebrew/pyenv), but AI CLI hosts often spawn hook subprocesses with a minimal PATH that resolves `python3` → the system Python (3.9 on macOS). Any `.py` file run as an AI-CLI hook must be written for the lowest plausible Python version.
|
|
73
|
-
|
|
74
|
-
Concrete failure: PEP 604 union syntax (`str | None`) requires Python 3.10+. If your hook file uses it, start with `from __future__ import annotations` so annotations become lazy strings and work on Python 3.7+:
|
|
75
|
-
|
|
76
|
-
```python
|
|
77
|
-
#!/usr/bin/env python3
|
|
78
|
-
"""My hook."""
|
|
79
|
-
from __future__ import annotations # REQUIRED for PEP 604 annotations
|
|
80
|
-
|
|
81
|
-
def handler(x: str | None) -> dict | None: # OK — lazy annotation
|
|
82
|
-
...
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
```python
|
|
86
|
-
# BAD — crashes on Python < 3.10:
|
|
87
|
-
# TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'
|
|
88
|
-
def handler(x: str | None) -> dict | None:
|
|
89
|
-
...
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
Note: `from __future__ import annotations` only covers **annotations**. Runtime expressions like `isinstance(x, int | str)` still require Python 3.10+. Avoid them in hook scripts.
|
|
93
|
-
|
|
94
|
-
Applies to anything the AI CLI executes as a hook: `match/case` statements (3.10+), `tomllib` (3.11+), `ExceptionGroup` / `except*` (3.11+) — all crash on older Python regardless of `__future__`.
|
|
95
|
-
|
|
96
103
|
### 2. Path Handling
|
|
97
104
|
|
|
98
105
|
| Assumption | macOS/Linux | Windows |
|
|
@@ -101,7 +108,7 @@ Applies to anything the AI CLI executes as a hook: `match/case` statements (3.10
|
|
|
101
108
|
| `\` separator | ❌ Escape char | ✅ Native |
|
|
102
109
|
| `pathlib.Path` | ✅ Works | ✅ Works |
|
|
103
110
|
|
|
104
|
-
**Rule**: Use `pathlib.Path` for all path operations.
|
|
111
|
+
**Rule (Python)**: Use `pathlib.Path` for all path operations.
|
|
105
112
|
|
|
106
113
|
```python
|
|
107
114
|
# BAD - String concatenation
|
|
@@ -112,6 +119,51 @@ from pathlib import Path
|
|
|
112
119
|
path = Path(base) / filename
|
|
113
120
|
```
|
|
114
121
|
|
|
122
|
+
#### Logical key vs filesystem path (TypeScript)
|
|
123
|
+
|
|
124
|
+
A path string has two distinct roles. **Treat them differently.**
|
|
125
|
+
|
|
126
|
+
| Role | OS-native (`\` on Windows) | Always POSIX (`/`) |
|
|
127
|
+
|------|---------------------------|--------------------|
|
|
128
|
+
| `fs.readFileSync(p)` / `path.join(cwd, x)` for fs call | ✅ Required | ❌ May fail on Windows |
|
|
129
|
+
| `Map<relPath, content>` key, JSON field, hash dictionary key, anything persisted across OS | ❌ Cross-OS mismatch | ✅ Required |
|
|
130
|
+
|
|
131
|
+
**Rule**: Anywhere a path string crosses OS or persists (Map keys consumed by another OS, JSON fields, hash dictionary keys), normalize to POSIX. Anywhere it goes straight to `fs.*`, leave OS-native.
|
|
132
|
+
|
|
133
|
+
**Single source of truth**: `packages/cli/src/utils/posix.ts` exports `toPosix(p)`. Don't sprinkle `replaceAll('\\', '/')` at every `path.join` site — apply `toPosix` **once at the boundary**: collector exit (Map key entering hash dictionary) or write-time (`saveHashes` before `JSON.stringify`).
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// BAD - logical key carries OS-native separator
|
|
137
|
+
function collectTemplates(): Map<string, string> {
|
|
138
|
+
const files = new Map<string, string>();
|
|
139
|
+
for (const entry of walk(dir)) {
|
|
140
|
+
files.set(path.join(".opencode", entry), readFile(entry)); // \ on Windows
|
|
141
|
+
}
|
|
142
|
+
return files;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// GOOD - normalize at the boundary
|
|
146
|
+
import { toPosix } from "../utils/posix.js";
|
|
147
|
+
|
|
148
|
+
function collectTemplates(): Map<string, string> {
|
|
149
|
+
const files = new Map<string, string>();
|
|
150
|
+
for (const entry of walk(dir)) {
|
|
151
|
+
files.set(toPosix(path.join(".opencode", entry)), readFile(entry));
|
|
152
|
+
}
|
|
153
|
+
return files;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ALSO ACCEPTABLE - write-side defense (for storage helpers like saveHashes)
|
|
157
|
+
function saveHashes(cwd: string, hashes: Record<string, string>): void {
|
|
158
|
+
const normalized = Object.fromEntries(
|
|
159
|
+
Object.entries(hashes).map(([k, v]) => [toPosix(k), v])
|
|
160
|
+
);
|
|
161
|
+
fs.writeFileSync(getHashesPath(cwd), JSON.stringify(normalized, null, 2));
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Common offender**: `path.relative(cwd, fullPath)` produces `\` on Windows. If you then use that string as a hash dictionary lookup key (`hashes[relPath]`), `toPosix` it first, or the lookup misses on Windows.
|
|
166
|
+
|
|
115
167
|
### 3. Line Endings
|
|
116
168
|
|
|
117
169
|
| Format | macOS/Linux | Windows | Git |
|
|
@@ -119,7 +171,7 @@ path = Path(base) / filename
|
|
|
119
171
|
| `\n` (LF) | ✅ Native | ⚠️ Some tools | ✅ Normalized |
|
|
120
172
|
| `\r\n` (CRLF) | ⚠️ Extra char | ✅ Native | Converted |
|
|
121
173
|
|
|
122
|
-
**Rule**: Use `.gitattributes` to enforce consistent line endings.
|
|
174
|
+
**Rule 1**: Use `.gitattributes` to enforce consistent line endings.
|
|
123
175
|
|
|
124
176
|
```gitattributes
|
|
125
177
|
* text=auto eol=lf
|
|
@@ -127,6 +179,23 @@ path = Path(base) / filename
|
|
|
127
179
|
*.py text eol=lf
|
|
128
180
|
```
|
|
129
181
|
|
|
182
|
+
**Rule 2**: When hashing or comparing **content** across platforms, normalize line endings before computing the hash. `.gitattributes` only governs git checkout — files written by users, scripts, or `core.autocrlf=true` may still arrive as CRLF, and `sha256(LF)` ≠ `sha256(CRLF)` for otherwise-identical content.
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
// BAD - Windows users with autocrlf=true get a different hash
|
|
186
|
+
export function computeHash(content: string): string {
|
|
187
|
+
return createHash("sha256").update(content, "utf-8").digest("hex");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// GOOD - normalize before hashing so logical content hashes identically
|
|
191
|
+
export function computeHash(content: string): string {
|
|
192
|
+
const normalized = content.replace(/\r\n/g, "\n");
|
|
193
|
+
return createHash("sha256").update(normalized, "utf-8").digest("hex");
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Apply this rule wherever the hash crosses OS boundaries (template hash dictionary, content fingerprints stored in JSON, integrity checks against a remote registry).
|
|
198
|
+
|
|
130
199
|
### 4. Environment Variables
|
|
131
200
|
|
|
132
201
|
| Variable | macOS/Linux | Windows |
|
|
@@ -135,7 +204,7 @@ path = Path(base) / filename
|
|
|
135
204
|
| `PATH` separator | `:` | `;` |
|
|
136
205
|
| Case sensitivity | ✅ Case-sensitive | ❌ Case-insensitive |
|
|
137
206
|
|
|
138
|
-
**Rule**: Use `pathlib.Path.home()` instead of environment variables.
|
|
207
|
+
**Rule 1**: Use `pathlib.Path.home()` instead of environment variables.
|
|
139
208
|
|
|
140
209
|
```python
|
|
141
210
|
# BAD
|
|
@@ -145,6 +214,25 @@ home = os.environ.get("HOME")
|
|
|
145
214
|
home = Path.home()
|
|
146
215
|
```
|
|
147
216
|
|
|
217
|
+
**Rule 2**: When injecting environment variables into shell commands, generate
|
|
218
|
+
the prefix for the actual host shell. Do not assume `export` works everywhere.
|
|
219
|
+
AI tool "Bash" surfaces on Windows may execute through PowerShell.
|
|
220
|
+
|
|
221
|
+
```javascript
|
|
222
|
+
// BAD - breaks when the host shell is PowerShell
|
|
223
|
+
command = `export TRELLIS_CONTEXT_ID=${shellQuote(contextKey)}; ${command}`;
|
|
224
|
+
|
|
225
|
+
// GOOD - shell-aware command prefix
|
|
226
|
+
const prefix = process.platform === "win32"
|
|
227
|
+
? `$env:TRELLIS_CONTEXT_ID = ${powershellQuote(contextKey)}; `
|
|
228
|
+
: `export TRELLIS_CONTEXT_ID=${shellQuote(contextKey)}; `;
|
|
229
|
+
command = `${prefix}${command}`;
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Also make duplicate-injection detection shell-aware. A guard that only matches
|
|
233
|
+
`export VAR=` will miss PowerShell's `$env:VAR = ...` form and can wrap an
|
|
234
|
+
already-correct command a second time.
|
|
235
|
+
|
|
148
236
|
### 5. Command Availability
|
|
149
237
|
|
|
150
238
|
| Command | macOS/Linux | Windows |
|
|
@@ -173,6 +261,25 @@ def tail_follow(file_path: Path) -> None:
|
|
|
173
261
|
time.sleep(0.1)
|
|
174
262
|
```
|
|
175
263
|
|
|
264
|
+
### Optional Advisory Checks in Agent Sandboxes
|
|
265
|
+
|
|
266
|
+
AI CLI subprocesses may run with outbound network disabled even when the user's
|
|
267
|
+
normal terminal has network access. Prefer local CLI probes over optional
|
|
268
|
+
network probes when the local CLI already exposes the needed information.
|
|
269
|
+
|
|
270
|
+
**Rule 1**: Do not let a failed optional advisory check consume a once-per-session
|
|
271
|
+
marker. Write the marker only after the script resolves a usable value and can
|
|
272
|
+
make the intended decision. Otherwise a transient sandbox/network failure hides
|
|
273
|
+
the hint for the rest of the session.
|
|
274
|
+
|
|
275
|
+
**Rule 2**: If a local command can provide the needed value, try it with a short
|
|
276
|
+
timeout and captured output. For example, `trellis --version` already runs the
|
|
277
|
+
CLI's version comparison logic and can support an actionable update prompt
|
|
278
|
+
without duplicating npm registry parsing.
|
|
279
|
+
|
|
280
|
+
**Rule 3**: Keep advisory checks silent on failure. The user-facing context output
|
|
281
|
+
must not fail or become noisy because an advisory check could not complete.
|
|
282
|
+
|
|
176
283
|
### 6. File Encoding
|
|
177
284
|
|
|
178
285
|
| Default Encoding | macOS/Linux | Windows |
|
|
@@ -183,6 +290,9 @@ def tail_follow(file_path: Path) -> None:
|
|
|
183
290
|
|
|
184
291
|
**Rule**: Always explicitly specify `encoding="utf-8"` and use `errors="replace"`.
|
|
185
292
|
|
|
293
|
+
> **Checklist**: When writing scripts that print non-ASCII, did you configure stdout encoding?
|
|
294
|
+
> See `backend/script-conventions.md` for the specific pattern.
|
|
295
|
+
|
|
186
296
|
```python
|
|
187
297
|
# BAD - Relies on system default
|
|
188
298
|
with open(file, "r") as f:
|
|
@@ -223,6 +333,12 @@ result = subprocess.run(
|
|
|
223
333
|
|
|
224
334
|
When making platform-related changes, check **all these locations**:
|
|
225
335
|
|
|
336
|
+
### Commands / Skills Sync
|
|
337
|
+
- [ ] New command/skill added to ALL platforms (claude, cursor, iflow, codex, and any new platform)
|
|
338
|
+
- [ ] Each platform's test file updated with new entry in `EXPECTED_COMMAND_NAMES` / `EXPECTED_SKILL_NAMES`
|
|
339
|
+
- [ ] Platform-integration spec's required command table updated if adding a new required command
|
|
340
|
+
- [ ] Command format matches platform convention (see `platform-integration.md` → Command Format by Platform)
|
|
341
|
+
|
|
226
342
|
### Documentation & Help Text
|
|
227
343
|
- [ ] Docstrings at top of Python files
|
|
228
344
|
- [ ] `--help` output / argparse descriptions
|
|
@@ -239,7 +355,7 @@ When making platform-related changes, check **all these locations**:
|
|
|
239
355
|
```bash
|
|
240
356
|
# Find all places that might need updating
|
|
241
357
|
grep -r "python [a-z]" --include="*.py" --include="*.md"
|
|
242
|
-
grep -r "
|
|
358
|
+
grep -r "{{PYTHON_CMD}}\\|python3\\|python " --include="*.py" --include="*.md"
|
|
243
359
|
```
|
|
244
360
|
|
|
245
361
|
---
|
|
@@ -248,10 +364,15 @@ grep -r "\./" --include="*.py" --include="*.md" | grep -v python3
|
|
|
248
364
|
|
|
249
365
|
Before committing cross-platform code:
|
|
250
366
|
|
|
251
|
-
- [ ]
|
|
367
|
+
- [ ] User-facing Python invocations are platform-aware (`python` on Windows, `python3` elsewhere) or use `{{PYTHON_CMD}}`
|
|
368
|
+
- [ ] Python subprocesses from Python use `sys.executable`
|
|
252
369
|
- [ ] All paths use `pathlib.Path`
|
|
253
370
|
- [ ] No hardcoded path separators (`/` or `\`)
|
|
371
|
+
- [ ] Path strings used as logical/persisted keys (Map keys, JSON fields, hash dictionary keys) are normalized via `toPosix()`; `fs.*` calls keep OS-native paths
|
|
372
|
+
- [ ] Content hashes computed across OSes normalize line endings (`\r\n` → `\n`) before hashing
|
|
373
|
+
- [ ] Cross-OS JSON with potential legacy pollution carries a `__version` sentinel and the loader discards unknown/legacy versions
|
|
254
374
|
- [ ] No platform-specific commands without fallbacks (e.g., `tail -f`)
|
|
375
|
+
- [ ] Optional advisory checks do not burn once-per-session markers on failure
|
|
255
376
|
- [ ] All file I/O specifies `encoding="utf-8"` and `errors="replace"`
|
|
256
377
|
- [ ] All subprocess calls specify `encoding="utf-8"` and `errors="replace"`
|
|
257
378
|
- [ ] Git commands use `-c i18n.logOutputEncoding=UTF-8`
|
|
@@ -283,6 +404,101 @@ output = {
|
|
|
283
404
|
|
|
284
405
|
---
|
|
285
406
|
|
|
407
|
+
## Cross-Platform Persisted JSON: Schema Migration Sentinel
|
|
408
|
+
|
|
409
|
+
When a JSON file may be read/written across OSes (committed to git, synced via cloud, copied between machines) **and an older format may already exist on user disks with cross-platform pollution** (Windows-style keys, CRLF-derived hashes, locale-encoded strings), add a `__version` sentinel and let the loader discard old formats so the writer regenerates clean data.
|
|
410
|
+
|
|
411
|
+
**Why not migrate-in-place?** Path-key migration (`\\` → `/`) plus hash-input migration (CRLF → LF re-hash) plus encoding fixes are correlated — trying to translate the old payload risks producing wrong values. Discarding and regenerating is **safe**: the data is recomputable from disk, and `loadX` returning `{}` triggers the existing init/update path to rebuild canonical entries.
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
const SCHEMA_VERSION = 2;
|
|
415
|
+
type StoredV2 = { __version: number; hashes: Record<string, string> };
|
|
416
|
+
|
|
417
|
+
export function loadHashes(cwd: string): Record<string, string> {
|
|
418
|
+
const file = getHashesPath(cwd);
|
|
419
|
+
if (!fs.existsSync(file)) return {};
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as unknown;
|
|
423
|
+
|
|
424
|
+
// Reject legacy flat format (no __version) and unknown versions.
|
|
425
|
+
// The next saveHashes / initializeHashes will write a fresh v2 file.
|
|
426
|
+
if (
|
|
427
|
+
!parsed ||
|
|
428
|
+
typeof parsed !== "object" ||
|
|
429
|
+
(parsed as StoredV2).__version !== SCHEMA_VERSION ||
|
|
430
|
+
typeof (parsed as StoredV2).hashes !== "object"
|
|
431
|
+
) {
|
|
432
|
+
return {};
|
|
433
|
+
}
|
|
434
|
+
return (parsed as StoredV2).hashes;
|
|
435
|
+
} catch {
|
|
436
|
+
return {};
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function saveHashes(cwd: string, hashes: Record<string, string>): void {
|
|
441
|
+
const payload: StoredV2 = { __version: SCHEMA_VERSION, hashes };
|
|
442
|
+
fs.writeFileSync(getHashesPath(cwd), JSON.stringify(payload, null, 2));
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**When to apply**:
|
|
447
|
+
- Hash dictionaries / content fingerprints (e.g., `.template-hashes.json`)
|
|
448
|
+
- Cache files where stale entries are recomputable from authoritative source
|
|
449
|
+
- Any cross-OS persisted file where format change correlates with cross-platform fixes
|
|
450
|
+
|
|
451
|
+
**When NOT to apply** — if losing the data hurts the user (task state, drafts, settings the user typed). Use real migration there. Sentinel + discard is only safe when data is recomputable.
|
|
452
|
+
|
|
453
|
+
**Reference**: `packages/cli/src/utils/template-hash.ts` v2 envelope.
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## JSON/External Data Defensive Checks
|
|
458
|
+
|
|
459
|
+
When parsing JSON or external data, TypeScript types are **compile-time only**. Runtime data may not match.
|
|
460
|
+
|
|
461
|
+
**Rule**: Always add defensive checks for required fields before using them.
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
// BAD - Trusts TypeScript type definition
|
|
465
|
+
interface MigrationItem {
|
|
466
|
+
from: string; // TypeScript says required
|
|
467
|
+
to?: string;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function process(item: MigrationItem) {
|
|
471
|
+
const path = item.from; // Runtime: could be undefined!
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// GOOD - Defensive check before use
|
|
475
|
+
function process(item: MigrationItem) {
|
|
476
|
+
if (!item.from) return; // Skip invalid data
|
|
477
|
+
const path = item.from; // Now guaranteed
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
**When to apply**:
|
|
482
|
+
- Parsing JSON files (manifests, configs)
|
|
483
|
+
- API responses
|
|
484
|
+
- User input
|
|
485
|
+
- Any data from external sources
|
|
486
|
+
|
|
487
|
+
**Pattern**: Check existence → then use
|
|
488
|
+
|
|
489
|
+
```typescript
|
|
490
|
+
// Filter pattern - skip invalid items
|
|
491
|
+
const validItems = items.filter(item => item.from && item.to);
|
|
492
|
+
|
|
493
|
+
// Early return pattern - bail on invalid
|
|
494
|
+
if (!data.requiredField) {
|
|
495
|
+
console.warn("Missing required field");
|
|
496
|
+
return defaultValue;
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
286
502
|
## Common Mistakes
|
|
287
503
|
|
|
288
504
|
### 1. "It works on my Mac"
|
|
@@ -318,6 +534,9 @@ python3 script.py # Works!
|
|
|
318
534
|
# User's Windows (Python from python.org)
|
|
319
535
|
python3 script.py # 'python3' is not recognized
|
|
320
536
|
python script.py # Works!
|
|
537
|
+
|
|
538
|
+
# Trellis docs/config should say the rule, not guess one alias everywhere
|
|
539
|
+
{{PYTHON_CMD}} script.py
|
|
321
540
|
```
|
|
322
541
|
|
|
323
542
|
### 5. "UTF-8 is the default everywhere"
|
|
@@ -328,6 +547,9 @@ subprocess.run(cmd, capture_output=True, text=True) # Works!
|
|
|
328
547
|
|
|
329
548
|
# User's Windows (GBK/CP1252 default)
|
|
330
549
|
subprocess.run(cmd, capture_output=True, text=True) # Garbled Chinese/Unicode
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
> **Note**: stdout encoding is also affected. See `backend/script-conventions.md` for the fix.
|
|
331
553
|
|
|
332
554
|
---
|
|
333
555
|
|
|
@@ -341,3 +563,27 @@ subprocess.run(cmd, capture_output=True, text=True) # Garbled Chinese/Unicode
|
|
|
341
563
|
---
|
|
342
564
|
|
|
343
565
|
**Core Principle**: If it's not explicit, it's an assumption. And assumptions break.
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
## Release Checklist: Versioned Files
|
|
570
|
+
|
|
571
|
+
When releasing a new version, ensure **all versioned files** are created/updated:
|
|
572
|
+
|
|
573
|
+
- [ ] `src/migrations/manifests/{version}.json` - Migration manifest exists
|
|
574
|
+
- [ ] Manifest has correct version, description, changelog
|
|
575
|
+
- [ ] `pnpm build` copies manifests to `dist/`
|
|
576
|
+
- [ ] Test upgrade path from older versions (not just adjacent)
|
|
577
|
+
|
|
578
|
+
**Why this matters**: Missing manifests cause "path undefined" errors when users upgrade from older versions.
|
|
579
|
+
|
|
580
|
+
```bash
|
|
581
|
+
# Verify all expected manifests exist
|
|
582
|
+
ls src/migrations/manifests/
|
|
583
|
+
|
|
584
|
+
# Test upgrade path
|
|
585
|
+
node -e "
|
|
586
|
+
const { getMigrationsForVersion } = require('./dist/migrations/index.js');
|
|
587
|
+
console.log('From 0.2.12:', getMigrationsForVersion('0.2.12', 'CURRENT').length);
|
|
588
|
+
"
|
|
589
|
+
```
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
|
|
6
6
|
type JsonObject = Record<string, unknown>;
|
|
7
7
|
type TextContent = { type: "text"; text: string };
|
|
@@ -632,6 +632,166 @@ function buildTrellisContext(
|
|
|
632
632
|
].join("\n");
|
|
633
633
|
}
|
|
634
634
|
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
// Workflow-state breadcrumb (TypeScript port of the shared workflow-state
|
|
637
|
+
// hook used by class-1 platforms).
|
|
638
|
+
//
|
|
639
|
+
// Pi is extension-backed and MUST NOT receive Python hook scripts under .pi/.
|
|
640
|
+
// We therefore parse `.trellis/workflow.md` `[workflow-state:STATUS]...
|
|
641
|
+
// [/workflow-state:STATUS]` blocks directly in TypeScript and emit the
|
|
642
|
+
// per-turn `<workflow-state>` breadcrumb in `before_agent_start` and `input`.
|
|
643
|
+
// Tag regex mirrors the shared parser so the breadcrumb body stays
|
|
644
|
+
// byte-identical with hook-driven platforms.
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
const WORKFLOW_STATE_TAG_RE =
|
|
648
|
+
/\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g;
|
|
649
|
+
|
|
650
|
+
function loadWorkflowBreadcrumbs(projectRoot: string): Record<string, string> {
|
|
651
|
+
const workflow = readText(join(projectRoot, ".trellis", "workflow.md"));
|
|
652
|
+
if (!workflow) return {};
|
|
653
|
+
const result: Record<string, string> = {};
|
|
654
|
+
for (const match of workflow.matchAll(WORKFLOW_STATE_TAG_RE)) {
|
|
655
|
+
const status = match[1] ?? "";
|
|
656
|
+
const body = (match[2] ?? "").trim();
|
|
657
|
+
if (status && body) result[status] = body;
|
|
658
|
+
}
|
|
659
|
+
return result;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function readActiveTaskStatus(
|
|
663
|
+
projectRoot: string,
|
|
664
|
+
taskDir: string,
|
|
665
|
+
): { taskId: string; status: string } | null {
|
|
666
|
+
try {
|
|
667
|
+
const data = JSON.parse(
|
|
668
|
+
readText(join(taskDir, "task.json")),
|
|
669
|
+
) as JsonObject;
|
|
670
|
+
const status = stringValue(data.status);
|
|
671
|
+
if (!status) return null;
|
|
672
|
+
const id = stringValue(data.id) ?? taskDir.split(/[\\/]/).pop() ?? "";
|
|
673
|
+
return { taskId: id, status };
|
|
674
|
+
} catch {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function buildWorkflowStateBreadcrumb(
|
|
680
|
+
projectRoot: string,
|
|
681
|
+
contextKey: string | null,
|
|
682
|
+
): string {
|
|
683
|
+
const templates = loadWorkflowBreadcrumbs(projectRoot);
|
|
684
|
+
const taskDir = readCurrentTask(
|
|
685
|
+
projectRoot,
|
|
686
|
+
undefined,
|
|
687
|
+
undefined,
|
|
688
|
+
contextKey,
|
|
689
|
+
);
|
|
690
|
+
let header: string;
|
|
691
|
+
let lookupKey: string;
|
|
692
|
+
if (!taskDir) {
|
|
693
|
+
header = "Status: no_task\nSource: session";
|
|
694
|
+
lookupKey = "no_task";
|
|
695
|
+
} else {
|
|
696
|
+
const info = readActiveTaskStatus(projectRoot, taskDir);
|
|
697
|
+
if (!info) {
|
|
698
|
+
header = "Status: no_task\nSource: session";
|
|
699
|
+
lookupKey = "no_task";
|
|
700
|
+
} else {
|
|
701
|
+
header = `Task: ${info.taskId} (${info.status})\nSource: session`;
|
|
702
|
+
lookupKey = info.status;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const body = templates[lookupKey] ?? "Refer to workflow.md for current step.";
|
|
706
|
+
return `<workflow-state>\n${header}\n${body}\n</workflow-state>`;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// Session overview (developer / git branch / active tasks)
|
|
711
|
+
//
|
|
712
|
+
// Spawns `python3 .trellis/scripts/get_context.py` (the same script other
|
|
713
|
+
// platform session-start hooks invoke) to keep developer/git/active-task
|
|
714
|
+
// summary byte-identical with class-1 platforms. Failure is non-fatal — we
|
|
715
|
+
// emit an empty overview rather than block the conversation.
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
const SESSION_OVERVIEW_TIMEOUT_MS = 5000;
|
|
719
|
+
|
|
720
|
+
function pythonExecutable(): string {
|
|
721
|
+
const override = stringValue(process.env.TRELLIS_PYTHON);
|
|
722
|
+
if (override) return override;
|
|
723
|
+
return process.platform === "win32" ? "python" : "python3";
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function buildSessionOverview(
|
|
727
|
+
projectRoot: string,
|
|
728
|
+
contextKey: string | null,
|
|
729
|
+
): string {
|
|
730
|
+
const script = join(projectRoot, ".trellis", "scripts", "get_context.py");
|
|
731
|
+
if (!isExistingFile(script)) return "";
|
|
732
|
+
try {
|
|
733
|
+
const result = spawnSync(pythonExecutable(), [script], {
|
|
734
|
+
cwd: projectRoot,
|
|
735
|
+
env: contextKey
|
|
736
|
+
? { ...process.env, TRELLIS_CONTEXT_ID: contextKey }
|
|
737
|
+
: process.env,
|
|
738
|
+
encoding: "utf-8",
|
|
739
|
+
timeout: SESSION_OVERVIEW_TIMEOUT_MS,
|
|
740
|
+
windowsHide: true,
|
|
741
|
+
});
|
|
742
|
+
if (result.status !== 0) return "";
|
|
743
|
+
const stdout = (result.stdout ?? "").trim();
|
|
744
|
+
if (!stdout) return "";
|
|
745
|
+
return `<session-overview>\n${stdout}\n</session-overview>`;
|
|
746
|
+
} catch {
|
|
747
|
+
return "";
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Per-turn cache so input + before_agent_start in the same turn don't double-spawn.
|
|
752
|
+
class TurnContextCache {
|
|
753
|
+
private key: string | null = null;
|
|
754
|
+
private timestamp = 0;
|
|
755
|
+
private workflowState = "";
|
|
756
|
+
private sessionOverview = "";
|
|
757
|
+
// Refresh window: per-turn injections that fire close together share a
|
|
758
|
+
// single python3 spawn; anything older than this re-runs the resolver.
|
|
759
|
+
private static readonly TTL_MS = 1500;
|
|
760
|
+
|
|
761
|
+
get(
|
|
762
|
+
projectRoot: string,
|
|
763
|
+
contextKey: string | null,
|
|
764
|
+
): { workflowState: string; sessionOverview: string } {
|
|
765
|
+
const now = Date.now();
|
|
766
|
+
if (this.key === contextKey && now - this.timestamp < TurnContextCache.TTL_MS) {
|
|
767
|
+
return {
|
|
768
|
+
workflowState: this.workflowState,
|
|
769
|
+
sessionOverview: this.sessionOverview,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
this.workflowState = buildWorkflowStateBreadcrumb(projectRoot, contextKey);
|
|
773
|
+
this.sessionOverview = buildSessionOverview(projectRoot, contextKey);
|
|
774
|
+
this.key = contextKey;
|
|
775
|
+
this.timestamp = now;
|
|
776
|
+
return {
|
|
777
|
+
workflowState: this.workflowState,
|
|
778
|
+
sessionOverview: this.sessionOverview,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
// Sub-agent dispatch protocol snippet (registered with the `subagent` tool).
|
|
785
|
+
// Mirrors the [workflow-state:in_progress] dispatch protocol text in
|
|
786
|
+
// trellis/workflow.md so the AI sees the same `Active task: <path>` rule
|
|
787
|
+
// whether it reads workflow.md, the per-turn breadcrumb, or the tool prompt.
|
|
788
|
+
// ---------------------------------------------------------------------------
|
|
789
|
+
|
|
790
|
+
const SUBAGENT_DISPATCH_PROTOCOL = `Sub-agent dispatch protocol (Trellis): your dispatch prompt MUST start with one line "Active task: <task path from \`task.py current\`>" before any other instructions. No exceptions. On class-2 platforms (codex / copilot / gemini / qoder) the sub-agent depends on this line because there is no hook to inject task context. On class-1 platforms (claude / cursor / opencode / kiro / codebuddy / droid) and on Pi, the line is the canonical fallback when hook/extension injection misses. trellis-research uses the line to know which {task_dir}/research/ to write into.
|
|
791
|
+
|
|
792
|
+
Wrong: prompt: "implement the new feature"
|
|
793
|
+
Correct: prompt: "Active task: .trellis/tasks/05-09-pi-workflow-state-injection\\n\\nImplement the new feature ..."`;
|
|
794
|
+
|
|
635
795
|
function normalizeAgentName(agent: string): string {
|
|
636
796
|
return agent.startsWith("trellis-") ? agent : `trellis-${agent}`;
|
|
637
797
|
}
|
|
@@ -886,6 +1046,15 @@ export default function trellisExtension(pi: {
|
|
|
886
1046
|
const projectRoot = findProjectRoot(pi.cwd ?? process.cwd());
|
|
887
1047
|
const processContextKey = createProcessContextKey(projectRoot);
|
|
888
1048
|
let currentContextKey: string | null = null;
|
|
1049
|
+
const turnContextCache = new TurnContextCache();
|
|
1050
|
+
|
|
1051
|
+
const buildPerTurnInjection = (contextKey: string | null): string => {
|
|
1052
|
+
const { workflowState, sessionOverview } = turnContextCache.get(
|
|
1053
|
+
projectRoot,
|
|
1054
|
+
contextKey,
|
|
1055
|
+
);
|
|
1056
|
+
return [workflowState, sessionOverview].filter(Boolean).join("\n\n");
|
|
1057
|
+
};
|
|
889
1058
|
|
|
890
1059
|
const getContextKey = (input?: unknown, ctx?: PiExtensionContext): string => {
|
|
891
1060
|
const resolvedContextKey = resolveContextKey(
|
|
@@ -904,6 +1073,8 @@ export default function trellisExtension(pi: {
|
|
|
904
1073
|
name: "subagent",
|
|
905
1074
|
label: "Subagent",
|
|
906
1075
|
description: "Run a Trellis project sub-agent with active task context.",
|
|
1076
|
+
promptSnippet: SUBAGENT_DISPATCH_PROTOCOL,
|
|
1077
|
+
promptGuidelines: SUBAGENT_DISPATCH_PROTOCOL,
|
|
907
1078
|
parameters: {
|
|
908
1079
|
type: "object",
|
|
909
1080
|
properties: {
|
|
@@ -976,8 +1147,9 @@ export default function trellisExtension(pi: {
|
|
|
976
1147
|
ctx,
|
|
977
1148
|
contextKey,
|
|
978
1149
|
);
|
|
1150
|
+
const perTurn = buildPerTurnInjection(contextKey);
|
|
979
1151
|
return {
|
|
980
|
-
systemPrompt: [current, context].filter(Boolean).join("\n\n"),
|
|
1152
|
+
systemPrompt: [current, context, perTurn].filter(Boolean).join("\n\n"),
|
|
981
1153
|
};
|
|
982
1154
|
});
|
|
983
1155
|
pi.on?.("context", (event, ctx) => {
|
|
@@ -986,8 +1158,11 @@ export default function trellisExtension(pi: {
|
|
|
986
1158
|
return Array.isArray(messages) ? { messages } : undefined;
|
|
987
1159
|
});
|
|
988
1160
|
pi.on?.("input", (event, ctx) => {
|
|
989
|
-
getContextKey(event, ctx);
|
|
990
|
-
|
|
1161
|
+
const contextKey = getContextKey(event, ctx);
|
|
1162
|
+
const additionalContext = buildPerTurnInjection(contextKey);
|
|
1163
|
+
return additionalContext
|
|
1164
|
+
? { action: "continue", additionalContext, systemPrompt: additionalContext }
|
|
1165
|
+
: { action: "continue" };
|
|
991
1166
|
});
|
|
992
1167
|
pi.on?.("tool_call", (event, ctx) => {
|
|
993
1168
|
const contextKey = getContextKey(event, ctx);
|