@mindfoldhq/trellis 0.6.5 → 0.6.7

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.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/index.js +1 -0
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/commands/channel/guard.d.ts.map +1 -1
  5. package/dist/commands/channel/guard.js +7 -2
  6. package/dist/commands/channel/guard.js.map +1 -1
  7. package/dist/commands/channel/store/paths.d.ts +14 -0
  8. package/dist/commands/channel/store/paths.d.ts.map +1 -1
  9. package/dist/commands/channel/store/paths.js +26 -0
  10. package/dist/commands/channel/store/paths.js.map +1 -1
  11. package/dist/commands/channel/supervisor/stdout.d.ts +7 -5
  12. package/dist/commands/channel/supervisor/stdout.d.ts.map +1 -1
  13. package/dist/commands/channel/supervisor/stdout.js +65 -18
  14. package/dist/commands/channel/supervisor/stdout.js.map +1 -1
  15. package/dist/commands/channel/supervisor.d.ts +13 -0
  16. package/dist/commands/channel/supervisor.d.ts.map +1 -1
  17. package/dist/commands/channel/supervisor.js +36 -16
  18. package/dist/commands/channel/supervisor.js.map +1 -1
  19. package/dist/commands/init.d.ts +1 -0
  20. package/dist/commands/init.d.ts.map +1 -1
  21. package/dist/commands/init.js.map +1 -1
  22. package/dist/commands/uninstall.d.ts +9 -0
  23. package/dist/commands/uninstall.d.ts.map +1 -1
  24. package/dist/commands/uninstall.js +74 -4
  25. package/dist/commands/uninstall.js.map +1 -1
  26. package/dist/commands/update.d.ts +29 -1
  27. package/dist/commands/update.d.ts.map +1 -1
  28. package/dist/commands/update.js +128 -58
  29. package/dist/commands/update.js.map +1 -1
  30. package/dist/configurators/copilot.d.ts +1 -0
  31. package/dist/configurators/copilot.d.ts.map +1 -1
  32. package/dist/configurators/copilot.js +4 -1
  33. package/dist/configurators/copilot.js.map +1 -1
  34. package/dist/configurators/index.d.ts.map +1 -1
  35. package/dist/configurators/index.js +7 -1
  36. package/dist/configurators/index.js.map +1 -1
  37. package/dist/configurators/omp.d.ts +11 -0
  38. package/dist/configurators/omp.d.ts.map +1 -0
  39. package/dist/configurators/omp.js +51 -0
  40. package/dist/configurators/omp.js.map +1 -0
  41. package/dist/configurators/shared.d.ts +7 -0
  42. package/dist/configurators/shared.d.ts.map +1 -1
  43. package/dist/configurators/shared.js +27 -0
  44. package/dist/configurators/shared.js.map +1 -1
  45. package/dist/configurators/zcode.d.ts +3 -3
  46. package/dist/configurators/zcode.js +12 -12
  47. package/dist/configurators/zcode.js.map +1 -1
  48. package/dist/migrations/manifests/0.6.6.json +17 -0
  49. package/dist/migrations/manifests/0.6.7.json +9 -0
  50. package/dist/templates/codex/hooks/session-start.py +6 -6
  51. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-agents.md +1 -1
  52. package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-skills-or-commands.md +1 -1
  53. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/bundled-skills.md +2 -1
  54. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/generated-files.md +2 -2
  55. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +1 -1
  56. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +3 -3
  57. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +19 -2
  58. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md +1 -1
  59. package/dist/templates/common/bundled-skills/trellis-session-insight/SKILL.md +1 -1
  60. package/dist/templates/copilot/copilot-instructions.md +11 -0
  61. package/dist/templates/copilot/hooks/session-start.py +6 -6
  62. package/dist/templates/copilot/index.d.ts +6 -1
  63. package/dist/templates/copilot/index.d.ts.map +1 -1
  64. package/dist/templates/copilot/index.js +8 -1
  65. package/dist/templates/copilot/index.js.map +1 -1
  66. package/dist/templates/omp/agents/trellis-check.md +41 -0
  67. package/dist/templates/omp/agents/trellis-implement.md +45 -0
  68. package/dist/templates/omp/agents/trellis-research.md +30 -0
  69. package/dist/templates/omp/extensions/trellis/index.ts.txt +462 -0
  70. package/dist/templates/omp/index.d.ts +4 -0
  71. package/dist/templates/omp/index.d.ts.map +1 -0
  72. package/dist/templates/omp/index.js +9 -0
  73. package/dist/templates/omp/index.js.map +1 -0
  74. package/dist/templates/pi/extensions/trellis/index.ts.txt +47 -21
  75. package/dist/templates/shared-hooks/inject-workflow-state.py +5 -5
  76. package/dist/templates/shared-hooks/session-start.py +7 -7
  77. package/dist/templates/trellis/scripts/add_session.py +65 -17
  78. package/dist/templates/trellis/scripts/common/cli_adapter.py +36 -1
  79. package/dist/templates/trellis/scripts/common/config.py +29 -0
  80. package/dist/templates/trellis/scripts/common/io.py +20 -3
  81. package/dist/templates/trellis/scripts/common/session_context.py +1 -1
  82. package/dist/templates/trellis/scripts/common/task_store.py +90 -19
  83. package/dist/templates/trellis/scripts/common/task_utils.py +22 -0
  84. package/dist/templates/trellis/scripts/task.py +8 -2
  85. package/dist/templates/trellis/workflow.md +13 -13
  86. package/dist/templates/zcode/agents/trellis-research.md +133 -0
  87. package/dist/templates/zcode/index.d.ts +6 -5
  88. package/dist/templates/zcode/index.d.ts.map +1 -1
  89. package/dist/templates/zcode/index.js +6 -5
  90. package/dist/templates/zcode/index.js.map +1 -1
  91. package/dist/types/ai-tools.d.ts +3 -3
  92. package/dist/types/ai-tools.d.ts.map +1 -1
  93. package/dist/types/ai-tools.js +29 -2
  94. package/dist/types/ai-tools.js.map +1 -1
  95. package/dist/utils/atomic-write.d.ts +12 -0
  96. package/dist/utils/atomic-write.d.ts.map +1 -0
  97. package/dist/utils/atomic-write.js +30 -0
  98. package/dist/utils/atomic-write.js.map +1 -0
  99. package/dist/utils/file-writer.d.ts.map +1 -1
  100. package/dist/utils/file-writer.js +6 -5
  101. package/dist/utils/file-writer.js.map +1 -1
  102. package/dist/utils/registry-config.d.ts.map +1 -1
  103. package/dist/utils/registry-config.js +3 -2
  104. package/dist/utils/registry-config.js.map +1 -1
  105. package/dist/utils/template-fetcher.d.ts.map +1 -1
  106. package/dist/utils/template-fetcher.js +36 -4
  107. package/dist/utils/template-fetcher.js.map +1 -1
  108. package/dist/utils/template-hash.d.ts.map +1 -1
  109. package/dist/utils/template-hash.js +2 -1
  110. package/dist/utils/template-hash.js.map +1 -1
  111. package/dist/utils/uninstall-scrubbers.d.ts +1 -0
  112. package/dist/utils/uninstall-scrubbers.d.ts.map +1 -1
  113. package/dist/utils/uninstall-scrubbers.js +19 -0
  114. package/dist/utils/uninstall-scrubbers.js.map +1 -1
  115. package/package.json +2 -2
@@ -53,12 +53,12 @@ if sys.platform.startswith("win"):
53
53
  try:
54
54
  _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
55
55
  except Exception:
56
- pass
56
+ pass # Optional Windows stream setup; keep hook startup non-fatal.
57
57
  elif hasattr(_stream, "detach"):
58
58
  try:
59
59
  setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace"))
60
60
  except Exception:
61
- pass
61
+ pass # Optional Windows stream setup; keep hook startup non-fatal.
62
62
  from typing import Optional
63
63
 
64
64
 
@@ -316,12 +316,12 @@ def _load_hook_input() -> dict:
316
316
  short daemon read preserves that path while failing closed to `{}` for
317
317
  non-piping hosts.
318
318
  """
319
- result_queue: "queue.Queue[str | BaseException]" = queue.Queue(maxsize=1)
319
+ result_queue: "queue.Queue[str | Exception]" = queue.Queue(maxsize=1)
320
320
 
321
321
  def _read() -> None:
322
322
  try:
323
323
  result_queue.put(sys.stdin.read())
324
- except BaseException as exc:
324
+ except Exception as exc:
325
325
  result_queue.put(exc)
326
326
 
327
327
  reader = threading.Thread(target=_read, daemon=True)
@@ -331,7 +331,7 @@ def _load_hook_input() -> dict:
331
331
  except queue.Empty:
332
332
  return {}
333
333
 
334
- if isinstance(raw, BaseException):
334
+ if isinstance(raw, Exception):
335
335
  return {}
336
336
  try:
337
337
  data = json.loads(raw) if raw.strip() else {}
@@ -87,12 +87,12 @@ if sys.platform.startswith("win"):
87
87
  try:
88
88
  _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
89
89
  except Exception:
90
- pass
90
+ pass # Optional Windows stream setup; keep hook startup non-fatal.
91
91
  elif hasattr(_stream, "detach"):
92
92
  try:
93
93
  setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace"))
94
94
  except Exception:
95
- pass
95
+ pass # Optional Windows stream setup; keep hook startup non-fatal.
96
96
 
97
97
 
98
98
 
@@ -249,7 +249,7 @@ def _persist_context_key_for_bash(context_key: str | None) -> None:
249
249
  with open(env_file, "a", encoding="utf-8") as handle:
250
250
  handle.write(f"export TRELLIS_CONTEXT_ID={shlex.quote(context_key)}\n")
251
251
  except OSError:
252
- pass
252
+ pass # Optional shell bridge; keep session-start non-fatal.
253
253
 
254
254
 
255
255
  def _resolve_active_task(trellis_dir: Path, input_data: dict):
@@ -351,7 +351,7 @@ def _get_task_status(trellis_dir: Path, input_data: dict) -> str:
351
351
  try:
352
352
  task_data = json.loads(task_json_path.read_text(encoding="utf-8"))
353
353
  except (json.JSONDecodeError, PermissionError):
354
- pass
354
+ pass # Optional task metadata; fall back to generic status.
355
355
 
356
356
  task_title = task_data.get("title", task_ref)
357
357
  task_status = task_data.get("status", "unknown")
@@ -453,7 +453,7 @@ def _load_trellis_config(trellis_dir: Path, input_data: dict) -> tuple:
453
453
  if isinstance(tp, str) and tp:
454
454
  task_pkg = tp
455
455
  except (json.JSONDecodeError, OSError):
456
- pass
456
+ pass # Optional package metadata; fall back to default scope.
457
457
 
458
458
  default_pkg = get_default_package(repo_root)
459
459
  return is_mono, packages, scope, task_pkg, default_pkg
@@ -630,7 +630,7 @@ def _build_compact_current_state(
630
630
  if isinstance(data, dict):
631
631
  status = str(data.get("status") or "unknown")
632
632
  except (json.JSONDecodeError, OSError):
633
- pass
633
+ pass # Optional task metadata; fall back to generic status.
634
634
  lines.append(f"Current task: {_repo_relative(repo_root, task_dir)}; status={status}.")
635
635
  else:
636
636
  lines.append("Current task: none.")
@@ -642,7 +642,7 @@ def _build_compact_current_state(
642
642
  f"Active tasks: {task_count} total. Use `python3 ./.trellis/scripts/task.py list --mine` only if needed."
643
643
  )
644
644
  except Exception:
645
- pass
645
+ pass # Optional task summary; keep compact state available.
646
646
 
647
647
  if get_active_journal_file and count_lines:
648
648
  journal = get_active_journal_file(repo_root)
@@ -14,7 +14,7 @@ Usage:
14
14
 
15
15
  Branch resolution order:
16
16
  1. --branch CLI arg (explicit)
17
- 2. task.json branch field (from active task)
17
+ 2. task.json branch field (from active task, if still exists)
18
18
  3. git branch --show-current (auto-detect)
19
19
  4. None (omitted gracefully)
20
20
  """
@@ -44,6 +44,7 @@ from common.safe_commit import (
44
44
  safe_trellis_paths_to_add,
45
45
  )
46
46
  from common.tasks import load_task
47
+ from common.types import TaskInfo
47
48
  from common.config import (
48
49
  get_packages,
49
50
  get_session_auto_commit,
@@ -55,6 +56,12 @@ from common.config import (
55
56
  )
56
57
 
57
58
 
59
+ DEFAULT_MAIN_CHANGES = (
60
+ "- Detailed change bullets were not supplied; see the summary above."
61
+ )
62
+ DEFAULT_TESTING = "- Validation was not recorded for this session."
63
+
64
+
58
65
  # =============================================================================
59
66
  # Helper Functions
60
67
  # =============================================================================
@@ -126,6 +133,56 @@ def count_journal_files(dev_dir: Path, active_num: int) -> str:
126
133
  return "\n".join(result_lines)
127
134
 
128
135
 
136
+ def get_current_git_branch(repo_root: Path) -> str | None:
137
+ """Return the current checkout branch, or None for detached/non-git states."""
138
+ rc, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
139
+ if rc != 0:
140
+ return None
141
+ detected = branch_out.strip()
142
+ return detected or None
143
+
144
+
145
+ def branch_ref_exists(repo_root: Path, branch: str) -> bool:
146
+ """Return True when branch exists locally or as the local origin ref."""
147
+ for ref in (f"refs/heads/{branch}", f"refs/remotes/origin/{branch}"):
148
+ rc, _, _ = run_git(["show-ref", "--verify", "--quiet", ref], cwd=repo_root)
149
+ if rc == 0:
150
+ return True
151
+ return False
152
+
153
+
154
+ def resolve_session_branch(
155
+ repo_root: Path,
156
+ cli_branch: str | None,
157
+ task_data: TaskInfo | None,
158
+ ) -> str | None:
159
+ """Resolve journal branch without trusting stale task.json branch fields."""
160
+ if cli_branch:
161
+ return cli_branch
162
+
163
+ current_branch = get_current_git_branch(repo_root)
164
+ raw_task_branch = task_data.raw.get("branch") if task_data else None
165
+ task_branch = raw_task_branch.strip() if isinstance(raw_task_branch, str) else ""
166
+ if not task_branch:
167
+ return current_branch
168
+
169
+ if branch_ref_exists(repo_root, task_branch):
170
+ return task_branch
171
+
172
+ if current_branch:
173
+ print(
174
+ f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; using current branch '{current_branch}'.",
175
+ file=sys.stderr,
176
+ )
177
+ return current_branch
178
+
179
+ print(
180
+ f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; omitting branch.",
181
+ file=sys.stderr,
182
+ )
183
+ return None
184
+
185
+
129
186
  def create_new_journal_file(
130
187
  dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000,
131
188
  ) -> Path:
@@ -154,6 +211,7 @@ def generate_session_content(
154
211
  today: str,
155
212
  package: str | None = None,
156
213
  branch: str | None = None,
214
+ testing_content: str = DEFAULT_TESTING,
157
215
  ) -> str:
158
216
  """Generate session content."""
159
217
  if commit and commit != "-":
@@ -189,7 +247,7 @@ def generate_session_content(
189
247
 
190
248
  ### Testing
191
249
 
192
- - [OK] (Add test results)
250
+ {testing_content}
193
251
 
194
252
  ### Status
195
253
 
@@ -395,8 +453,8 @@ def _auto_commit_workspace(repo_root: Path) -> None:
395
453
  def add_session(
396
454
  title: str,
397
455
  commit: str = "-",
398
- summary: str = "(Add summary)",
399
- extra_content: str = "(Add details)",
456
+ summary: str = "Session summary was not supplied.",
457
+ extra_content: str = DEFAULT_MAIN_CHANGES,
400
458
  auto_commit: bool = True,
401
459
  package: str | None = None,
402
460
  branch: str | None = None,
@@ -503,7 +561,7 @@ def main() -> int:
503
561
  )
504
562
  parser.add_argument("--title", required=True, help="Session title")
505
563
  parser.add_argument("--commit", default="-", help="Comma-separated commit hashes")
506
- parser.add_argument("--summary", default="(Add summary)", help="Brief summary")
564
+ parser.add_argument("--summary", default="Session summary was not supplied.", help="Brief summary")
507
565
  parser.add_argument("--content-file", help="Path to file with detailed content")
508
566
  parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)")
509
567
  parser.add_argument("--branch", help="Branch name (auto-detected if omitted)")
@@ -514,7 +572,7 @@ def main() -> int:
514
572
 
515
573
  args = parser.parse_args()
516
574
 
517
- extra_content = "(Add details)"
575
+ extra_content = DEFAULT_MAIN_CHANGES
518
576
  if args.content_file:
519
577
  content_path = Path(args.content_file)
520
578
  if content_path.is_file():
@@ -543,17 +601,7 @@ def main() -> int:
543
601
  task_package = task_data.package if task_data else None
544
602
  package = resolve_package(task_package, repo_root)
545
603
 
546
- # Resolve branch: CLI task.json → git auto-detect → None
547
- branch = args.branch
548
-
549
- if not branch:
550
- if task_data and task_data.raw.get("branch"):
551
- branch = task_data.raw["branch"]
552
- else:
553
- _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
554
- detected = branch_out.strip()
555
- if detected:
556
- branch = detected
604
+ branch = resolve_session_branch(repo_root, args.branch, task_data)
557
605
 
558
606
  return add_session(
559
607
  args.title, args.commit, args.summary, extra_content,
@@ -55,6 +55,7 @@ Platform = Literal[
55
55
  "droid",
56
56
  "pi",
57
57
  "trae",
58
+ "omp",
58
59
  ]
59
60
 
60
61
 
@@ -131,6 +132,8 @@ class CLIAdapter:
131
132
  return ".pi"
132
133
  elif self.platform == "trae":
133
134
  return ".trae"
135
+ elif self.platform == "omp":
136
+ return ".omp"
134
137
  else:
135
138
  return ".claude"
136
139
 
@@ -188,6 +191,17 @@ class CLIAdapter:
188
191
  filename = filename[:-3]
189
192
  return prompts_dir / f"trellis-{filename}.md"
190
193
  return prompts_dir / Path(*parts)
194
+ if self.platform == "omp":
195
+ commands_dir = self.get_config_dir(project_root) / "commands"
196
+ if not parts:
197
+ return commands_dir
198
+ if len(parts) >= 2 and parts[0] == "trellis":
199
+ filename = parts[-1]
200
+ if filename.endswith(".md"):
201
+ filename = filename[:-3]
202
+ return commands_dir / f"trellis-{filename}.md"
203
+ return commands_dir / Path(*parts)
204
+
191
205
 
192
206
  if self.platform == "devin":
193
207
  workflow_dir = self.get_config_dir(project_root) / "workflows"
@@ -272,6 +286,8 @@ class CLIAdapter:
272
286
  return f".factory/commands/trellis/{name}.md"
273
287
  elif self.platform == "pi":
274
288
  return f".pi/prompts/trellis-{name}.md"
289
+ elif self.platform == "omp":
290
+ return f".omp/commands/trellis-{name}.md"
275
291
  else:
276
292
  return f"{self.config_dir_name}/commands/trellis/{name}.md"
277
293
 
@@ -311,6 +327,8 @@ class CLIAdapter:
311
327
  return {}
312
328
  elif self.platform == "trae":
313
329
  return {}
330
+ elif self.platform == "omp":
331
+ return {}
314
332
  else:
315
333
  return {"CLAUDE_NON_INTERACTIVE": "1"}
316
334
 
@@ -400,6 +418,10 @@ class CLIAdapter:
400
418
  raise ValueError(
401
419
  "Trae is IDE-only; CLI agent run is not supported."
402
420
  )
421
+ elif self.platform == "omp":
422
+ raise ValueError(
423
+ "OMP uses native task tool for agent runs; CLI agent run is not supported."
424
+ )
403
425
 
404
426
  else: # claude
405
427
  cmd = ["claude", "-p"]
@@ -470,6 +492,10 @@ class CLIAdapter:
470
492
  raise ValueError(
471
493
  "Trae is IDE-only; CLI resume is not supported."
472
494
  )
495
+ elif self.platform == "omp":
496
+ raise ValueError(
497
+ "OMP uses native task tool for agent runs; CLI resume is not supported."
498
+ )
473
499
  else:
474
500
  return ["claude", "--resume", session_id]
475
501
 
@@ -546,6 +572,8 @@ class CLIAdapter:
546
572
  return "pi"
547
573
  elif self.platform == "trae":
548
574
  return "trae"
575
+ elif self.platform == "omp":
576
+ return "omp"
549
577
  else:
550
578
  return "claude"
551
579
 
@@ -642,9 +670,10 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter:
642
670
  "droid",
643
671
  "pi",
644
672
  "trae",
673
+ "omp",
645
674
  ):
646
675
  raise ValueError(
647
- f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or 'trae')"
676
+ f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', or 'omp')"
648
677
  )
649
678
 
650
679
  return CLIAdapter(platform=platform) # type: ignore
@@ -668,6 +697,7 @@ _ALL_PLATFORM_CONFIG_DIRS = (
668
697
  ".factory",
669
698
  ".pi",
670
699
  ".trae",
700
+ ".omp",
671
701
  )
672
702
  """Platform-specific config directory names used by detect_platform exclusion
673
703
  checks. `.agents/skills/` is NOT listed here: it is a shared cross-platform
@@ -737,6 +767,7 @@ def detect_platform(project_root: Path) -> Platform:
737
767
  "droid",
738
768
  "pi",
739
769
  "trae",
770
+ "omp",
740
771
  ):
741
772
  return env_platform # type: ignore
742
773
 
@@ -817,6 +848,10 @@ def detect_platform(project_root: Path) -> Platform:
817
848
  if (project_root / ".trae").is_dir():
818
849
  return "trae"
819
850
 
851
+ # Check for .omp directory (OMP-specific)
852
+ if (project_root / ".omp").is_dir():
853
+ return "omp"
854
+
820
855
  # Fallback: checkout only has the Codex shared-skills layer
821
856
  # (.agents/skills/trellis-* dirs) and no explicit platform config dir.
822
857
  # Happens on fresh clones where .codex/ is gitignored/absent but the
@@ -167,6 +167,7 @@ def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
167
167
  DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal"
168
168
  DEFAULT_MAX_JOURNAL_LINES = 2000
169
169
  DEFAULT_SESSION_AUTO_COMMIT = True
170
+ DEFAULT_CODEX_DISPATCH_MODE = "inline"
170
171
 
171
172
  CONFIG_FILE = "config.yaml"
172
173
 
@@ -243,6 +244,34 @@ def get_session_auto_commit(repo_root: Path | None = None) -> bool:
243
244
  return DEFAULT_SESSION_AUTO_COMMIT
244
245
 
245
246
 
247
+ def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
248
+ """Return Codex dispatch mode.
249
+
250
+ Default is ``inline``. ``sub-agent`` is an explicit opt-in because Codex
251
+ sub-agents do not inherit the parent session context.
252
+ """
253
+ config = _load_config(repo_root)
254
+ codex = config.get("codex")
255
+ if codex is None:
256
+ return DEFAULT_CODEX_DISPATCH_MODE
257
+ if not isinstance(codex, dict):
258
+ print(
259
+ f"[WARN] invalid codex config: {codex!r}; using {DEFAULT_CODEX_DISPATCH_MODE}",
260
+ file=sys.stderr,
261
+ )
262
+ return DEFAULT_CODEX_DISPATCH_MODE
263
+
264
+ raw = codex.get("dispatch_mode", DEFAULT_CODEX_DISPATCH_MODE)
265
+ mode = str(raw).strip().lower()
266
+ if mode in ("inline", "sub-agent"):
267
+ return mode
268
+ print(
269
+ f"[WARN] invalid codex.dispatch_mode value: {raw!r}; using {DEFAULT_CODEX_DISPATCH_MODE}",
270
+ file=sys.stderr,
271
+ )
272
+ return DEFAULT_CODEX_DISPATCH_MODE
273
+
274
+
246
275
  def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
247
276
  """Get hook commands for a lifecycle event.
248
277
 
@@ -8,6 +8,8 @@ for JSON file operations across all Trellis scripts.
8
8
  from __future__ import annotations
9
9
 
10
10
  import json
11
+ import os
12
+ import tempfile
11
13
  from pathlib import Path
12
14
 
13
15
 
@@ -25,13 +27,28 @@ def read_json(path: Path) -> dict | None:
25
27
  def write_json(path: Path, data: dict) -> bool:
26
28
  """Write dict to JSON file with pretty formatting.
27
29
 
30
+ The write is atomic: content goes to a temp file in the same directory
31
+ and is then renamed over the target. A crash or Ctrl-C mid-write leaves
32
+ the existing file intact rather than truncated, so a corrupted task.json
33
+ can never make a task silently vanish from `task.py list`.
34
+
28
35
  Returns True on success, False on error.
29
36
  """
37
+ payload = json.dumps(data, indent=2, ensure_ascii=False)
30
38
  try:
31
- path.write_text(
32
- json.dumps(data, indent=2, ensure_ascii=False),
33
- encoding="utf-8",
39
+ fd, tmp = tempfile.mkstemp(
40
+ dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
34
41
  )
42
+ try:
43
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
44
+ f.write(payload)
45
+ os.replace(tmp, path)
46
+ except BaseException:
47
+ try:
48
+ os.unlink(tmp)
49
+ except OSError:
50
+ pass
51
+ raise
35
52
  return True
36
53
  except (OSError, IOError):
37
54
  return False
@@ -412,7 +412,7 @@ def _get_update_hint(repo_root: Path) -> str | None:
412
412
 
413
413
  return (
414
414
  f"Trellis update available: {current_version} -> {latest_version}, "
415
- "run trellis upgrade"
415
+ "run trellis update"
416
416
  )
417
417
 
418
418
 
@@ -23,6 +23,7 @@ from datetime import datetime
23
23
  from pathlib import Path
24
24
 
25
25
  from .config import (
26
+ get_codex_dispatch_mode,
26
27
  get_packages,
27
28
  get_session_auto_commit,
28
29
  is_monorepo,
@@ -50,6 +51,7 @@ from .safe_commit import (
50
51
  from .task_utils import (
51
52
  archive_task_complete,
52
53
  find_task_by_name,
54
+ is_within_tasks_dir,
53
55
  resolve_task_dir,
54
56
  run_task_hooks,
55
57
  )
@@ -113,13 +115,13 @@ def _repo_relative_path(path: Path, repo_root: Path) -> str:
113
115
 
114
116
  # Config directories of platforms that consume implement.jsonl / check.jsonl.
115
117
  # Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the
116
- # platforms listed in workflow.md's "agent-capable" Skill Routing block
117
- # (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity /
118
- # Devin are NOT in this list: they do not consume JSONL.
118
+ # platforms listed in workflow.md's "agent-capable" Skill Routing block.
119
+ # Codex is checked separately because default inline mode does not consume
120
+ # JSONL. Kilo / Antigravity / Devin are NOT in this list either: they load
121
+ # specs through skills instead of JSONL.
119
122
  _SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
120
123
  ".claude",
121
124
  ".cursor",
122
- ".codex",
123
125
  ".kiro",
124
126
  ".gemini",
125
127
  ".opencode",
@@ -129,7 +131,10 @@ _SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
129
131
  ".github/copilot",
130
132
  ".pi", # Pi Agent
131
133
  ".trae", # Trae IDE
134
+ ".omp", # Oh My Pi
135
+ ".zcode", # ZCode
132
136
  )
137
+ _CODEX_CONFIG_DIR = ".codex"
133
138
 
134
139
  _SEED_EXAMPLE = (
135
140
  "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. "
@@ -142,13 +147,15 @@ _SEED_EXAMPLE = (
142
147
  def _has_subagent_platform(repo_root: Path) -> bool:
143
148
  """Return True if any sub-agent-capable platform is configured.
144
149
 
145
- Detected by probing well-known config directories at the repo root. Used
146
- only to decide whether ``task.py create`` should seed empty
147
- ``implement.jsonl`` / ``check.jsonl`` files.
150
+ Detected by probing well-known config directories at the repo root. Codex
151
+ only counts when ``codex.dispatch_mode`` explicitly opts into
152
+ ``sub-agent``; inline mode loads context through skills, not JSONL.
148
153
  """
149
154
  for config_dir in _SUBAGENT_CONFIG_DIRS:
150
155
  if (repo_root / config_dir).is_dir():
151
156
  return True
157
+ if (repo_root / _CODEX_CONFIG_DIR).is_dir():
158
+ return get_codex_dispatch_mode(repo_root) == "sub-agent"
152
159
  return False
153
160
 
154
161
 
@@ -240,6 +247,34 @@ def cmd_create(args: argparse.Namespace) -> int:
240
247
  # Create task directory with MM-DD-slug format
241
248
  tasks_dir = get_tasks_dir(repo_root)
242
249
  date_prefix = generate_task_date_prefix()
250
+
251
+ # Guard against date-prefixed --slug (e.g. a full task dir name pasted in),
252
+ # which would otherwise produce MM-DD-MM-DD-slug (issue #377). Only an
253
+ # explicit --slug is guarded; title-derived slugs are left untouched.
254
+ if args.slug:
255
+ m = re.match(r"^(\d{2})-(\d{2})-(.+)$", slug)
256
+ if m and 1 <= int(m.group(1)) <= 12 and 1 <= int(m.group(2)) <= 31:
257
+ slug_prefix = f"{m.group(1)}-{m.group(2)}"
258
+ if slug_prefix == date_prefix:
259
+ slug = m.group(3)
260
+ print(
261
+ colored(
262
+ f'warning: --slug should not include the MM-DD prefix; normalized to "{slug}"',
263
+ Colors.YELLOW,
264
+ ),
265
+ file=sys.stderr,
266
+ )
267
+ else:
268
+ print(
269
+ colored(
270
+ f"Error: --slug starts with a date prefix ({slug_prefix}-), but task.py create always uses today's date ({date_prefix}).",
271
+ Colors.RED,
272
+ ),
273
+ file=sys.stderr,
274
+ )
275
+ print(f"Pass only the slug body, e.g. --slug {m.group(3)}", file=sys.stderr)
276
+ return 1
277
+
243
278
  dir_name = f"{date_prefix}-{slug}"
244
279
  task_dir = tasks_dir / dir_name
245
280
  task_json_path = task_dir / FILE_TASK_JSON
@@ -262,11 +297,21 @@ def cmd_create(args: argparse.Namespace) -> int:
262
297
  _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
263
298
  current_branch = branch_out.strip() or "main"
264
299
 
300
+ description = (args.description or "").strip()
301
+ if not description.strip():
302
+ print(
303
+ colored(
304
+ "warning: task description is empty; pass --description to improve search and later audits.",
305
+ Colors.YELLOW,
306
+ ),
307
+ file=sys.stderr,
308
+ )
309
+
265
310
  task_data = {
266
311
  "id": slug,
267
312
  "name": slug,
268
313
  "title": args.title,
269
- "description": args.description or "",
314
+ "description": description,
270
315
  "status": "planning",
271
316
  "dev_type": None,
272
317
  "scope": None,
@@ -294,7 +339,7 @@ def cmd_create(args: argparse.Namespace) -> int:
294
339
  prd_path = task_dir / "prd.md"
295
340
  if not prd_path.exists():
296
341
  prd_path.write_text(
297
- _default_prd_content(args.title, args.description),
342
+ _default_prd_content(args.title, description),
298
343
  encoding="utf-8",
299
344
  )
300
345
 
@@ -337,16 +382,31 @@ def cmd_create(args: argparse.Namespace) -> int:
337
382
  # outside an AI session) — the task is still created, the user can run
338
383
  # task.py start later. Pointer is session-scoped so this never affects
339
384
  # other AI sessions.
340
- try:
341
- from .active_task import resolve_context_key, set_active_task
342
- if resolve_context_key():
343
- try:
344
- rel_dir = task_dir.relative_to(repo_root).as_posix()
345
- except ValueError:
346
- rel_dir = str(task_dir)
347
- set_active_task(rel_dir, repo_root)
348
- except Exception:
349
- pass
385
+ if getattr(args, "no_start", False):
386
+ print(
387
+ colored(
388
+ "Skipped session activation (--no-start); run task.py start when ready.",
389
+ Colors.YELLOW,
390
+ ),
391
+ file=sys.stderr,
392
+ )
393
+ else:
394
+ try:
395
+ from .active_task import resolve_context_key, set_active_task
396
+ if resolve_context_key():
397
+ try:
398
+ rel_dir = task_dir.relative_to(repo_root).as_posix()
399
+ except ValueError:
400
+ rel_dir = str(task_dir)
401
+ active = set_active_task(rel_dir, repo_root)
402
+ if active:
403
+ print(
404
+ colored(f"Activated task for this session: {active.task_path}", Colors.GREEN),
405
+ file=sys.stderr,
406
+ )
407
+ print(f"Source: {active.source}", file=sys.stderr)
408
+ except Exception:
409
+ pass
350
410
 
351
411
  print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr)
352
412
  print("", file=sys.stderr)
@@ -396,6 +456,17 @@ def cmd_archive(args: argparse.Namespace) -> int:
396
456
  print(f" - {t.dir_name}/", file=sys.stderr)
397
457
  return 1
398
458
 
459
+ # Refuse to archive anything that isn't a real task directly under
460
+ # .trellis/tasks/. A mistyped name (e.g. "src") resolves to repo_root/src,
461
+ # which is a dir but not a task — without this guard archive would move the
462
+ # user's source directory out of the repo.
463
+ if not is_within_tasks_dir(task_dir, repo_root):
464
+ print(colored(
465
+ f"Error: refusing to archive '{task_name}': "
466
+ f"{task_dir} is not a task under {tasks_dir}",
467
+ Colors.RED), file=sys.stderr)
468
+ return 1
469
+
399
470
  dir_name = task_dir.name
400
471
  task_json_path = task_dir / FILE_TASK_JSON
401
472
 
@@ -69,6 +69,28 @@ def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool:
69
69
  return True
70
70
 
71
71
 
72
+ def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bool:
73
+ """Check that a resolved task directory really is a task under the tasks dir.
74
+
75
+ A real task lives directly at ``.trellis/tasks/<name>``. This returns True
76
+ only when ``task_dir_abs`` is an immediate child of the tasks directory.
77
+
78
+ Guards archive: ``resolve_task_dir`` falls back to ``repo_root/<name>`` for
79
+ an unknown name, so a mistyped ``task.py archive src`` resolves to the real
80
+ ``src/`` source directory. Without this check archive would ``shutil.move``
81
+ it out of the repo. Also rejects the tasks dir itself and anything nested
82
+ under ``archive/`` (already-archived tasks).
83
+ """
84
+ if repo_root is None:
85
+ repo_root = get_repo_root()
86
+ try:
87
+ resolved = task_dir_abs.resolve()
88
+ tasks_resolved = get_tasks_dir(repo_root).resolve()
89
+ except (OSError, RuntimeError):
90
+ return False
91
+ return resolved.parent == tasks_resolved
92
+
93
+
72
94
  # =============================================================================
73
95
  # Task Lookup
74
96
  # =============================================================================