@mindfoldhq/trellis 0.5.10 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ {
2
+ "version": "0.5.11",
3
+ "description": "Patch: drop 0.5.10 `git add -f` retry + new `session_auto_commit` config + session-start update hint.",
4
+ "breaking": false,
5
+ "recommendMigrate": false,
6
+ "changelog": "**Bug Fixes:**\n- fix(scripts): drop `git add -f` auto-retry from 0.5.10. When `.gitignore` excludes `.trellis/`, `add_session.py` and `task.py archive` print a warning and skip auto-commit instead of force-staging.\n\n**Enhancements:**\n- feat(scripts): new `session_auto_commit: true | false` in `.trellis/config.yaml` (default `true`). Set `false` to skip auto stage + commit — journal / archive files still write to disk. Closes #245.\n- feat(scripts): `get_context.py` shows `Trellis update available: <current> -> <latest>` once per session when local install lags. 1-second timeout, failures silent. Closes #254.",
7
+ "migrations": [],
8
+ "configSectionsAdded": [
9
+ {
10
+ "file": ".trellis/config.yaml",
11
+ "sentinel": "session_auto_commit:",
12
+ "sectionHeading": "Session Auto-Commit"
13
+ }
14
+ ],
15
+ "notes": "Patch on top of 0.5.10. Run `trellis update` — your `.trellis/config.yaml` gets a commented-out `session_auto_commit: true` block appended automatically. Uncomment and flip to `false` if your `.gitignore` excludes `.trellis/` and you want auto-commit off entirely."
16
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "version": "0.6.0-beta.5",
3
+ "description": "Beta patch: cherry-picks v0.5.10 stable fixes (git-add-f prevention + Pi #246/#249) + version-update hint at session start (PR #254).",
4
+ "breaking": false,
5
+ "recommendMigrate": false,
6
+ "changelog": "**Bug Fixes:**\n- fix(scripts): `add_session.py` and `task.py archive` no longer print a generic `git add .trellis && git commit` fallback when the repo's `.gitignore` excludes `.trellis/`. They now stage only specific Trellis-owned paths and auto-retry with `git add -f -- <specific-paths>` only on `ignored by` stderr. The warning text explicitly states `Do NOT use \\`git add -f .trellis/\\``, naming `.trellis/.backup-*`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`, `.trellis/.runtime/`, `.trellis/.cache/` as the subpaths users should ignore individually instead. Helper centralized in `templates/trellis/scripts/common/safe_commit.py`.\n- fix(pi): Pi extension now injects `<workflow-state>` breadcrumb on every `input` and `before_agent_start` event, plus a `<session-overview>` block sourced from `.trellis/scripts/get_context.py`. The `subagent` tool registration carries a `promptSnippet` with the `Active task: <path>` dispatch protocol. Closes #249.\n- fix(pi): Project-level `packages[\"npm:pi-subagents\"]` override added to `.pi/settings.json` so a globally-installed `npm:pi-subagents` cannot inject `extensions / skills / prompts / themes` into the current Trellis project. `scrubPiSettings` reverses the override on `trellis uninstall`. Closes #246.\n\n**Enhancements:**\n- feat(scripts): `get_context.py` default mode now performs a once-per-session `trellis --version` check and prepends `Trellis update available: <current> -> <latest>, run npm install -g @mindfoldhq/trellis@latest` before the context body when the local install lags. Best-effort with a 1-second timeout; failures silently skip. Marker stored under `.trellis/.runtime/` (auto-ignored). Closes #254.",
7
+ "migrations": [],
8
+ "notes": "Beta patch on top of 0.6.0-beta.4. Run `trellis update`. Brings the four 0.5.10-line fixes / features into the 0.6 beta line."
9
+ }
@@ -11,6 +11,8 @@ Identity, git status, current task, active tasks, journal location.
11
11
  {{PYTHON_CMD}} ./.trellis/scripts/get_context.py
12
12
  ```
13
13
 
14
+ If this output includes a line beginning `Trellis update available:`, copy the full line verbatim when summarizing session context. Do not shorten operational command hints.
15
+
14
16
  ## Step 2: Workflow overview
15
17
  Phase Index + skill routing table + DO-NOT-skip rules.
16
18
 
@@ -85,6 +85,45 @@ After implementation:
85
85
 
86
86
  ---
87
87
 
88
+ ## Cross-Platform Template Consistency
89
+
90
+ In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
91
+
92
+ ### Checklist: After Modifying Any Command Template
93
+
94
+ - [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
95
+ - [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
96
+ - [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
97
+ - [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
98
+
99
+ **Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
100
+
101
+ ---
102
+
103
+ ## Mode-Detection Probe Checklist
104
+
105
+ When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
106
+
107
+ ### Before implementing:
108
+ - [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
109
+ - [ ] 404 vs transient error are distinguished — don't treat both as "not found"
110
+ - [ ] Transient errors **abort or retry**, never silently switch modes
111
+ - [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
112
+ - [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
113
+
114
+ ### After implementing:
115
+ - [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
116
+ - [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
117
+ - [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
118
+ - [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
119
+ - [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
120
+
121
+ **Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
122
+
123
+ **Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
124
+
125
+ ---
126
+
88
127
  ## When to Create Flow Documentation
89
128
 
90
129
  Create detailed flow docs when:
@@ -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**: Always use explicit `python3` in documentation, help text, and error messages.
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 - Explicit interpreter
37
- print("Usage: python3 script.py <args>")
38
- print("Run: python3 ./script.py <args>")
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 calling Python from TypeScript/Node.js, detect the available command:
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("python3 --version", { stdio: "pipe" });
47
- return "python3";
81
+ execSync(`${cmd} --version`, { stdio: "pipe" });
48
82
  } catch {
49
- try {
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 3**: When calling Python from Python, use `sys.executable`:
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 "\./" --include="*.py" --include="*.md" | grep -v python3
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
- - [ ] All Python invocations use `python3` explicitly (docs) or `sys.executable` (code)
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
+ ```
@@ -14,6 +14,24 @@ session_commit_message: "chore: record journal"
14
14
  # Maximum lines per journal file before rotating to a new one
15
15
  max_journal_lines: 2000
16
16
 
17
+ #-------------------------------------------------------------------------------
18
+ # Session Auto-Commit
19
+ #-------------------------------------------------------------------------------
20
+
21
+ # Auto-commit behavior for session journal + task archive operations.
22
+ # - true (default): scripts auto-stage and auto-commit journal / task changes
23
+ # after add_session.py / task.py archive runs.
24
+ # - false: scripts do not touch git. Files (journal-*.md, task archive moves)
25
+ # are still written to disk; you decide whether to git add / commit.
26
+ #
27
+ # Use `false` if your project's .gitignore intentionally excludes `.trellis/`
28
+ # and you want session data kept local-only, or if you prefer to review
29
+ # staged changes manually before each commit.
30
+ #
31
+ # Accepts: true / false / yes / no / 1 / 0 / on / off (case-insensitive).
32
+ #
33
+ # session_auto_commit: true
34
+
17
35
  #-------------------------------------------------------------------------------
18
36
  # Task Lifecycle Hooks
19
37
  #-------------------------------------------------------------------------------
@@ -44,6 +44,7 @@ from common.safe_commit import (
44
44
  from common.tasks import load_task
45
45
  from common.config import (
46
46
  get_packages,
47
+ get_session_auto_commit,
47
48
  get_session_commit_message,
48
49
  get_max_journal_lines,
49
50
  is_monorepo,
@@ -322,16 +323,27 @@ def _auto_commit_workspace(repo_root: Path) -> None:
322
323
 
323
324
  Path scope is restricted to specific products (journal files, index.md,
324
325
  active task dirs, the archive subtree). We never `git add` the whole
325
- `.trellis/` tree, and if `.gitignore` blocks the specific paths we retry
326
- with `git add -f <those-specific-paths>` — never `-f .trellis/`.
326
+ `.trellis/` tree, and if `.gitignore` blocks the specific paths we
327
+ warn + skip — never retry with ``-f``.
328
+
329
+ Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to
330
+ ``false``, this function returns immediately without touching git
331
+ (journal/index files are still written to disk by the caller).
327
332
  """
333
+ if not get_session_auto_commit(repo_root):
334
+ print(
335
+ "[OK] session_auto_commit: false — skipping git stage/commit.",
336
+ file=sys.stderr,
337
+ )
338
+ return
339
+
328
340
  commit_msg = get_session_commit_message(repo_root)
329
341
  paths = safe_trellis_paths_to_add(repo_root)
330
342
  if not paths:
331
343
  print("[OK] No workspace changes to commit.", file=sys.stderr)
332
344
  return
333
345
 
334
- success, used_force, err = safe_git_add(paths, repo_root)
346
+ success, _, err = safe_git_add(paths, repo_root)
335
347
  if not success:
336
348
  if err and "ignored by" in err.lower():
337
349
  print_gitignore_warning(paths)
@@ -342,12 +354,6 @@ def _auto_commit_workspace(repo_root: Path) -> None:
342
354
  )
343
355
  return
344
356
 
345
- if used_force:
346
- print(
347
- "[OK] Staged Trellis-owned paths with -f (specific paths, not .trellis/).",
348
- file=sys.stderr,
349
- )
350
-
351
357
  # Check if there are staged changes for the paths we just staged.
352
358
  rc, _, _ = run_git(
353
359
  ["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root
@@ -36,6 +36,29 @@ def _unquote(s: str) -> str:
36
36
  return s
37
37
 
38
38
 
39
+ def _strip_inline_comment(value: str) -> str:
40
+ """Strip ` # …` inline comments while preserving `#` inside quoted strings.
41
+
42
+ YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
43
+ is part of the value. Quoted strings are immune.
44
+
45
+ Mirrors :func:`common.trellis_config._strip_inline_comment` so both
46
+ parsers handle ``key: value # comment`` identically.
47
+ """
48
+ in_quote: str | None = None
49
+ for idx, ch in enumerate(value):
50
+ if in_quote:
51
+ if ch == in_quote:
52
+ in_quote = None
53
+ continue
54
+ if ch in ('"', "'"):
55
+ in_quote = ch
56
+ continue
57
+ if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
58
+ return value[:idx]
59
+ return value
60
+
61
+
39
62
  def parse_simple_yaml(content: str) -> dict:
40
63
  """Parse simple YAML with nested dict support (no dependencies).
41
64
 
@@ -93,7 +116,8 @@ def _parse_yaml_block(
93
116
  elif ":" in stripped:
94
117
  key, _, value = stripped.partition(":")
95
118
  key = key.strip()
96
- value = _unquote(value.strip())
119
+ value = _strip_inline_comment(value).strip()
120
+ value = _unquote(value)
97
121
  current_list = None
98
122
 
99
123
  if value:
@@ -142,6 +166,7 @@ def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
142
166
  # Defaults
143
167
  DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal"
144
168
  DEFAULT_MAX_JOURNAL_LINES = 2000
169
+ DEFAULT_SESSION_AUTO_COMMIT = True
145
170
 
146
171
  CONFIG_FILE = "config.yaml"
147
172
 
@@ -187,6 +212,37 @@ def get_max_journal_lines(repo_root: Path | None = None) -> int:
187
212
  return DEFAULT_MAX_JOURNAL_LINES
188
213
 
189
214
 
215
+ def get_session_auto_commit(repo_root: Path | None = None) -> bool:
216
+ """Whether scripts should auto-stage + auto-commit session/task changes.
217
+
218
+ Governs both ``add_session.py:_auto_commit_workspace`` and
219
+ ``task_store.py:_auto_commit_archive``.
220
+
221
+ Default: ``True`` (existing behavior — auto-stage + auto-commit).
222
+ Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip
223
+ auto-staging entirely; the journal/archive files are still written to
224
+ disk, but the user manages ``git add`` / ``git commit`` themselves.
225
+
226
+ Accepts native YAML booleans (``true`` / ``false``) and the string
227
+ aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive).
228
+ Invalid values fall back to ``True`` with a stderr warning.
229
+ """
230
+ config = _load_config(repo_root)
231
+ raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT)
232
+ if isinstance(raw, bool):
233
+ return raw
234
+ s = str(raw).strip().lower()
235
+ if s in ("true", "yes", "1", "on"):
236
+ return True
237
+ if s in ("false", "no", "0", "off"):
238
+ return False
239
+ print(
240
+ f"[WARN] invalid session_auto_commit value: {raw!r}; using true (default)",
241
+ file=sys.stderr,
242
+ )
243
+ return DEFAULT_SESSION_AUTO_COMMIT
244
+
245
+
190
246
  def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
191
247
  """Get hook commands for a lifecycle event.
192
248
 
@@ -15,14 +15,19 @@ Design
15
15
  ------
16
16
  - Scripts only stage SPECIFIC product paths (journal files, index.md, the
17
17
  current task dir, the archive dir). Never the whole `.trellis/` tree.
18
- - If plain `git add <specific>` fails with "ignored by", retry with
19
- `git add -f <specific>` forcing only the paths the script knows it owns.
20
- This is safe because the paths are narrow; it is NOT equivalent to
21
- `git add -f .trellis/` (which would fan out to backups/worktrees/runtime).
22
- - If the -f retry also fails, print an explicit warning that includes a
23
- negative example: ``Do NOT use `git add -f .trellis/` ...``
24
-
25
- The wider-grain forbidden command stays forbidden.
18
+ - If plain `git add <specific>` fails with "ignored by", DO NOT retry with
19
+ ``-f``. The presence of `.trellis/` in `.gitignore` is treated as user
20
+ intent ("keep .trellis/ local-only"). The script warns and skips the
21
+ auto-commit; users who want auto-staging can either fix their `.gitignore`
22
+ or set ``session_auto_commit: false`` and manage git themselves.
23
+ - The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...``
24
+ so any AI rereading the log doesn't reinvent the bug.
25
+
26
+ History note: 0.5.10 introduced an automatic ``git add -f`` retry on the
27
+ specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the
28
+ user had gitignored violates user intent even when the path list is narrow.
29
+ The wider-grain forbidden command stays forbidden, and the narrow-grain auto
30
+ ``-f`` is gone too.
26
31
  """
27
32
 
28
33
  from __future__ import annotations
@@ -145,20 +150,18 @@ def _stderr_indicates_ignored(stderr: str) -> bool:
145
150
  def safe_git_add(
146
151
  paths: list[str], repo_root: Path
147
152
  ) -> tuple[bool, bool, str]:
148
- """Run `git add` on specific paths, retrying with -f if .gitignore blocks.
153
+ """Run `git add` on specific paths; never retry with -f.
149
154
 
150
- Returns (success, used_force, stderr). On success, callers should still
151
- `git diff --cached` to detect whether anything was actually staged.
155
+ Returns ``(success, used_force, stderr)``. The ``used_force`` field is
156
+ kept for signature compatibility with the 0.5.10 implementation but is
157
+ always ``False`` — we never auto-force.
152
158
 
153
159
  Behavior:
154
160
  - No paths passed → success, no force, empty stderr.
155
- - Plain `git add <paths>` succeeds → return.
156
- - Plain fails with "ignored by" retry with `git add -f <paths>`.
157
- - Retry succeeds return success with used_force=True.
158
- - Retry fails → return failure; caller should print the gitignore
159
- warning (see :func:`print_gitignore_warning`).
160
- - Plain fails with a non-ignored error → return failure; do NOT retry
161
- with -f (we only force when ignore is the cause).
161
+ - Plain ``git add -- <paths>`` succeeds → return success.
162
+ - Plain fails (any reason ignored or otherwise) return failure with
163
+ the stderr. Callers should inspect the stderr (see
164
+ :func:`print_gitignore_warning`) and skip the auto-commit.
162
165
  """
163
166
  if not paths:
164
167
  return True, False, ""
@@ -166,14 +169,7 @@ def safe_git_add(
166
169
  rc, _, err = run_git(["add", "--", *paths], cwd=repo_root)
167
170
  if rc == 0:
168
171
  return True, False, ""
169
-
170
- if not _stderr_indicates_ignored(err):
171
- return False, False, err
172
-
173
- rc2, _, err2 = run_git(["add", "-f", "--", *paths], cwd=repo_root)
174
- if rc2 == 0:
175
- return True, True, err2 or err
176
- return False, True, err2 or err
172
+ return False, False, err
177
173
 
178
174
 
179
175
  def print_gitignore_warning(paths: list[str]) -> None:
@@ -187,6 +183,15 @@ def print_gitignore_warning(paths: list[str]) -> None:
187
183
  "[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.",
188
184
  file=sys.stderr,
189
185
  )
186
+ print(
187
+ "[WARN] Skipping auto-commit. The journal/task files were still written to disk;",
188
+ file=sys.stderr,
189
+ )
190
+ print(
191
+ "[WARN] git was not touched.",
192
+ file=sys.stderr,
193
+ )
194
+ print("[WARN]", file=sys.stderr)
190
195
  print(
191
196
  "[WARN] Trellis manages these specific paths and they should be tracked:",
192
197
  file=sys.stderr,
@@ -219,6 +224,27 @@ def print_gitignore_warning(paths: list[str]) -> None:
219
224
  for sub in TRELLIS_IGNORED_SUBPATHS:
220
225
  print(f"[WARN] {sub}", file=sys.stderr)
221
226
  print("[WARN]", file=sys.stderr)
227
+ print(
228
+ "[WARN] Or, if you intentionally keep .trellis/ local-only, set in",
229
+ file=sys.stderr,
230
+ )
231
+ print(
232
+ "[WARN] .trellis/config.yaml:",
233
+ file=sys.stderr,
234
+ )
235
+ print(
236
+ "[WARN] session_auto_commit: false",
237
+ file=sys.stderr,
238
+ )
239
+ print(
240
+ "[WARN] so the scripts skip git entirely and you can review / commit",
241
+ file=sys.stderr,
242
+ )
243
+ print(
244
+ "[WARN] manually with `git status` / `git add` / `git commit`.",
245
+ file=sys.stderr,
246
+ )
247
+ print("[WARN]", file=sys.stderr)
222
248
  print(
223
249
  "[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,",
224
250
  file=sys.stderr,
@@ -14,8 +14,12 @@ Provides:
14
14
  from __future__ import annotations
15
15
 
16
16
  import json
17
+ import os
18
+ import re
19
+ import subprocess
17
20
  from pathlib import Path
18
21
 
22
+ from .active_task import resolve_context_key
19
23
  from .config import get_git_packages
20
24
  from .git import run_git
21
25
  from .packages_context import get_packages_section
@@ -40,6 +44,14 @@ from .paths import (
40
44
  # Helpers
41
45
  # =============================================================================
42
46
 
47
+ _PACKAGE_NAME = "@mindfoldhq/trellis"
48
+ _UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
49
+ _VERSION_RE = re.compile(
50
+ r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
51
+ )
52
+ _VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
53
+
54
+
43
55
  def _collect_package_git_info(repo_root: Path) -> list[dict]:
44
56
  """Collect git status and recent commits for packages with independent git repos.
45
57
 
@@ -109,6 +121,158 @@ def _append_package_git_context(lines: list[str], package_git_info: list[dict])
109
121
  lines.append("")
110
122
 
111
123
 
124
+ def _read_project_version(repo_root: Path) -> str | None:
125
+ try:
126
+ version = (repo_root / DIR_WORKFLOW / ".version").read_text(
127
+ encoding="utf-8"
128
+ ).strip()
129
+ except OSError:
130
+ return None
131
+ return version or None
132
+
133
+
134
+ def _fetch_trellis_version_output() -> str | None:
135
+ try:
136
+ result = subprocess.run(
137
+ ["trellis", "--version"],
138
+ capture_output=True,
139
+ text=True,
140
+ encoding="utf-8",
141
+ errors="replace",
142
+ timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
143
+ )
144
+ except (OSError, subprocess.SubprocessError, TimeoutError):
145
+ return None
146
+
147
+ if result.returncode != 0:
148
+ return None
149
+ output = f"{result.stdout}\n{result.stderr}".strip()
150
+ return output or None
151
+
152
+
153
+ def _extract_available_update_version(output: str) -> str | None:
154
+ update_match = re.search(
155
+ r"Trellis update available:\s*"
156
+ r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
157
+ output,
158
+ )
159
+ if update_match:
160
+ return update_match.group("latest").strip()
161
+ candidates = _VERSION_TOKEN_RE.findall(output)
162
+ return candidates[-1] if candidates else None
163
+
164
+
165
+ def _resolve_available_update_version() -> str | None:
166
+ output = _fetch_trellis_version_output()
167
+ if not output:
168
+ return None
169
+ return _extract_available_update_version(output)
170
+
171
+
172
+ def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
173
+ match = _VERSION_RE.match(version)
174
+ if not match:
175
+ return None
176
+ major, minor, patch, prerelease = match.groups()
177
+ numbers = (int(major), int(minor or "0"), int(patch or "0"))
178
+ prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
179
+ return numbers, prerelease_parts
180
+
181
+
182
+ def _compare_prerelease(
183
+ left: tuple[str, ...] | None,
184
+ right: tuple[str, ...] | None,
185
+ ) -> int:
186
+ if left is None and right is None:
187
+ return 0
188
+ if left is None:
189
+ return 1
190
+ if right is None:
191
+ return -1
192
+
193
+ for left_part, right_part in zip(left, right):
194
+ if left_part == right_part:
195
+ continue
196
+ left_numeric = left_part.isdigit()
197
+ right_numeric = right_part.isdigit()
198
+ if left_numeric and right_numeric:
199
+ left_int = int(left_part)
200
+ right_int = int(right_part)
201
+ return (left_int > right_int) - (left_int < right_int)
202
+ if left_numeric:
203
+ return -1
204
+ if right_numeric:
205
+ return 1
206
+ return (left_part > right_part) - (left_part < right_part)
207
+
208
+ return (len(left) > len(right)) - (len(left) < len(right))
209
+
210
+
211
+ def _compare_versions(left: str, right: str) -> int | None:
212
+ parsed_left = _parse_version(left)
213
+ parsed_right = _parse_version(right)
214
+ if parsed_left is None or parsed_right is None:
215
+ return None
216
+
217
+ left_numbers, left_prerelease = parsed_left
218
+ right_numbers, right_prerelease = parsed_right
219
+ if left_numbers != right_numbers:
220
+ return (left_numbers > right_numbers) - (left_numbers < right_numbers)
221
+ return _compare_prerelease(left_prerelease, right_prerelease)
222
+
223
+
224
+ def _update_marker_path(repo_root: Path) -> Path:
225
+ context_key = resolve_context_key()
226
+ if not context_key:
227
+ terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
228
+ context_key = terminal_key or f"ppid-{os.getppid()}"
229
+ safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
230
+ if not safe_key:
231
+ safe_key = "session"
232
+ return (
233
+ repo_root
234
+ / DIR_WORKFLOW
235
+ / ".runtime"
236
+ / f"update-check-{safe_key[:160]}.marker"
237
+ )
238
+
239
+
240
+ def _mark_update_check_attempted(repo_root: Path) -> bool:
241
+ marker_path = _update_marker_path(repo_root)
242
+ if marker_path.exists():
243
+ return False
244
+ try:
245
+ marker_path.parent.mkdir(parents=True, exist_ok=True)
246
+ marker_path.write_text("checked\n", encoding="utf-8")
247
+ except OSError:
248
+ pass
249
+ return True
250
+
251
+
252
+ def _get_update_hint(repo_root: Path) -> str | None:
253
+ marker_path = _update_marker_path(repo_root)
254
+ if marker_path.exists():
255
+ return None
256
+
257
+ current_version = _read_project_version(repo_root)
258
+ if not current_version:
259
+ return None
260
+
261
+ latest_version = _resolve_available_update_version()
262
+ if not latest_version:
263
+ return None
264
+
265
+ _mark_update_check_attempted(repo_root)
266
+ comparison = _compare_versions(current_version, latest_version)
267
+ if comparison is None or comparison >= 0:
268
+ return None
269
+
270
+ return (
271
+ f"Trellis update available: {current_version} -> {latest_version}, "
272
+ f"run npm install -g {_PACKAGE_NAME}@latest"
273
+ )
274
+
275
+
112
276
  # =============================================================================
113
277
  # JSON Output
114
278
  # =============================================================================
@@ -571,4 +735,10 @@ def output_text(repo_root: Path | None = None) -> None:
571
735
  Args:
572
736
  repo_root: Repository root path. Defaults to auto-detected.
573
737
  """
738
+ if repo_root is None:
739
+ repo_root = get_repo_root()
740
+ update_hint = _get_update_hint(repo_root)
741
+ if update_hint:
742
+ print(update_hint)
743
+ print("")
574
744
  print(get_context_text(repo_root))
@@ -24,6 +24,7 @@ from pathlib import Path
24
24
 
25
25
  from .config import (
26
26
  get_packages,
27
+ get_session_auto_commit,
27
28
  is_monorepo,
28
29
  resolve_package,
29
30
  validate_package,
@@ -391,16 +392,28 @@ def _auto_commit_archive(task_name: str, repo_root: Path) -> None:
391
392
  """Stage Trellis-owned task paths and commit after archive.
392
393
 
393
394
  Only stages specific subpaths (the archive subtree and active task dirs),
394
- never the whole `.trellis/` tree. If `.gitignore` excludes `.trellis/`,
395
- falls back to `git add -f <specific>` and emits a warning that explicitly
396
- forbids `git add -f .trellis/` (which would fan out to caches/backups).
395
+ never the whole ``.trellis/`` tree. If ``.gitignore`` blocks the paths,
396
+ we warn + skip — we do NOT retry with ``git add -f``. The warning
397
+ explicitly forbids ``git add -f .trellis/`` (which would fan out to
398
+ caches/backups) and points users at ``session_auto_commit: false``.
399
+
400
+ Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to
401
+ ``false``, this function returns immediately without touching git
402
+ (the archive directory move on disk is unaffected).
397
403
  """
404
+ if not get_session_auto_commit(repo_root):
405
+ print(
406
+ "[OK] session_auto_commit: false — skipping git stage/commit.",
407
+ file=sys.stderr,
408
+ )
409
+ return
410
+
398
411
  paths = safe_archive_paths_to_add(repo_root)
399
412
  if not paths:
400
413
  print("[OK] No task changes to commit.", file=sys.stderr)
401
414
  return
402
415
 
403
- success, used_force, err = safe_git_add(paths, repo_root)
416
+ success, _, err = safe_git_add(paths, repo_root)
404
417
  if not success:
405
418
  if err and "ignored by" in err.lower():
406
419
  print_gitignore_warning(paths)
@@ -411,12 +424,6 @@ def _auto_commit_archive(task_name: str, repo_root: Path) -> None:
411
424
  )
412
425
  return
413
426
 
414
- if used_force:
415
- print(
416
- "[OK] Staged Trellis-owned paths with -f (specific paths, not .trellis/).",
417
- file=sys.stderr,
418
- )
419
-
420
427
  rc, _, _ = run_git(
421
428
  ["diff", "--cached", "--quiet", "--", *paths], cwd=repo_root
422
429
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindfoldhq/trellis",
3
- "version": "0.5.10",
3
+ "version": "0.5.11",
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",