@mindfoldhq/trellis 0.6.9 → 0.7.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/cli/index.js +2 -0
  2. package/dist/cli/index.js.map +1 -1
  3. package/dist/commands/update.d.ts.map +1 -1
  4. package/dist/commands/update.js +0 -8
  5. package/dist/commands/update.js.map +1 -1
  6. package/dist/commands/workflow.d.ts +6 -0
  7. package/dist/commands/workflow.d.ts.map +1 -1
  8. package/dist/commands/workflow.js +124 -0
  9. package/dist/commands/workflow.js.map +1 -1
  10. package/dist/configurators/workflow.d.ts +0 -14
  11. package/dist/configurators/workflow.d.ts.map +1 -1
  12. package/dist/configurators/workflow.js +1 -37
  13. package/dist/configurators/workflow.js.map +1 -1
  14. package/dist/migrations/manifests/0.6.10.json +9 -0
  15. package/dist/migrations/manifests/0.7.0-beta.0.json +9 -0
  16. package/dist/templates/claude/settings.json +22 -0
  17. package/dist/templates/codex/agents/trellis-check.toml +7 -5
  18. package/dist/templates/codex/agents/trellis-implement.toml +7 -5
  19. package/dist/templates/codex/agents/trellis-research.toml +13 -8
  20. package/dist/templates/codex/hooks/session-start.py +20 -1
  21. package/dist/templates/codex/hooks.json +25 -0
  22. package/dist/templates/copilot/hooks/session-start.py +20 -1
  23. package/dist/templates/opencode/plugins/inject-workflow-state.js +111 -10
  24. package/dist/templates/pi/extensions/trellis/index.ts.txt +4 -3
  25. package/dist/templates/shared-hooks/index.d.ts +5 -1
  26. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  27. package/dist/templates/shared-hooks/index.js +10 -1
  28. package/dist/templates/shared-hooks/index.js.map +1 -1
  29. package/dist/templates/shared-hooks/inject-spec-context.py +840 -0
  30. package/dist/templates/shared-hooks/inject-subagent-context.py +4 -3
  31. package/dist/templates/shared-hooks/inject-workflow-state.py +35 -9
  32. package/dist/templates/shared-hooks/session-start.py +22 -1
  33. package/dist/templates/trellis/config.yaml +47 -0
  34. package/dist/templates/trellis/index.d.ts +4 -3
  35. package/dist/templates/trellis/index.d.ts.map +1 -1
  36. package/dist/templates/trellis/index.js +7 -3
  37. package/dist/templates/trellis/index.js.map +1 -1
  38. package/dist/templates/trellis/scripts/add_session.py +0 -45
  39. package/dist/templates/trellis/scripts/common/active_task.py +5 -2
  40. package/dist/templates/trellis/scripts/common/config.py +18 -0
  41. package/dist/templates/trellis/scripts/common/git_context.py +34 -2
  42. package/dist/templates/trellis/scripts/common/paths.py +28 -0
  43. package/dist/templates/trellis/scripts/common/spec_inject.py +439 -0
  44. package/dist/templates/trellis/scripts/common/spec_match.py +395 -0
  45. package/dist/templates/trellis/scripts/common/task_context.py +10 -8
  46. package/dist/templates/trellis/scripts/common/task_store.py +30 -0
  47. package/dist/templates/trellis/scripts/common/workflow_phase.py +3 -2
  48. package/dist/templates/trellis/scripts/common/workflow_selection.py +177 -0
  49. package/dist/templates/trellis/scripts/task.py +82 -0
  50. package/package.json +2 -2
  51. package/dist/templates/trellis/gitattributes.txt +0 -9
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Path-scoped spec matching for on-demand spec injection.
4
+
5
+ Spec files under `.trellis/spec/**/*.md` MAY start with a YAML-like
6
+ frontmatter block declaring which repo paths they govern:
7
+
8
+ ---
9
+ name: commands-workflow
10
+ description: workflow command conventions
11
+ paths:
12
+ - packages/cli/src/commands/workflow.ts
13
+ - packages/cli/src/utils/workflow-resolver.ts
14
+ ---
15
+
16
+ The parser is hand-rolled (house pattern, modeled on
17
+ ``trellis_config.parse_simple_yaml`` — no YAML dependency) and reads only a
18
+ bounded head of each file (16 KiB / 200 lines, whichever ends first). Only
19
+ files whose first line is exactly ``---`` are considered. ``name:`` /
20
+ ``description:`` single-line strings are recognized (description is reused in
21
+ index lines). ``paths:`` accepts both a block list (``- <glob>`` items) and a
22
+ flow sequence (``paths: [a, b]``).
23
+
24
+ The parser is deliberately tolerant — a spec is prose that happens to carry a
25
+ routing hint, not a config file. Unknown keys, unrecognized line shapes and
26
+ stray ``- item`` lines are ignored; block scalars (``key: >`` / ``key: |``)
27
+ consume their more-indented continuation lines, so a SKILL.md-style
28
+ ``description: >`` paragraph does not disqualify the file. An opening ``---``
29
+ with no recognized key before the closing marker is not frontmatter at all
30
+ (a Markdown horizontal rule opening the prose) and is ignored silently. Two
31
+ things are errors, and both warn + skip the whole file rather than route on a
32
+ half-read block: a malformed ``paths:`` (a scalar where a list belongs — that
33
+ key is the one thing the rest of the pipeline depends on), and a frontmatter
34
+ block that is still open when the head bound is reached.
35
+
36
+ Glob grammar (repo-relative, POSIX separators):
37
+
38
+ - ``*`` matches within a single path segment (never crosses ``/``)
39
+ - ``?`` matches exactly one character within a segment
40
+ - ``**`` as a whole segment matches zero or more segments
41
+ - a trailing ``/`` is sugar for ``/**``
42
+ - ``**`` embedded in a segment with other characters degrades to ``*``
43
+
44
+ Validation rejects only what is unsafe or meaningless: empty globs, a leading
45
+ ``/`` (globs are repo-relative), ``..`` segments, backslashes (POSIX
46
+ separators only) and control characters. Everything else is legal — real
47
+ repositories carry ``@scope`` packages, ``[slug]`` routes, ``(marketing)``
48
+ groups and non-ASCII directories, and the translation escapes literals
49
+ character by character. An invalid glob is skipped with a stderr warning; the
50
+ rest of the file's globs still apply.
51
+
52
+ Translation examples (glob → matches / non-matches):
53
+
54
+ packages/cli/src/commands/update.ts
55
+ matches only that exact file
56
+ packages/cli/src/commands/*.ts
57
+ matches packages/cli/src/commands/update.ts
58
+ not packages/cli/src/commands/channel/spawn.ts
59
+ packages/cli/src/templates/**
60
+ matches packages/cli/src/templates/trellis/index.ts (any depth)
61
+ not packages/cli/src/templates (the directory itself)
62
+ packages/**/index.ts
63
+ matches packages/index.ts and packages/cli/src/index.ts
64
+ src/util?.py
65
+ matches src/utils.py, not src/util.py or src/utilXY.py
66
+ packages/cli/
67
+ same as packages/cli/**
68
+
69
+ Provides:
70
+ SpecMatch - frozen match record (spec_path, rel_path, description)
71
+ match_specs_for_file - map an edited file to the specs that govern it
72
+ normalize_repo_relative - the canonical repo-relative path normalization
73
+ parse_spec_frontmatter - parse the optional frontmatter head block
74
+ glob_to_regex - deterministic glob → compiled regex translation
75
+ """
76
+
77
+ from __future__ import annotations
78
+
79
+ import re
80
+ import sys
81
+ import unicodedata
82
+ from dataclasses import dataclass
83
+ from pathlib import Path
84
+
85
+ from .paths import DIR_SPEC, DIR_WORKFLOW
86
+ from .trellis_config import _strip_inline_comment, _unquote
87
+
88
+ # Bounded head-read limits for frontmatter scanning (design contract).
89
+ HEAD_MAX_BYTES = 16384
90
+ HEAD_MAX_LINES = 200
91
+
92
+ # Recognized frontmatter keys. An opening `---` block that declares none of
93
+ # them is prose under a horizontal rule, not frontmatter.
94
+ _KNOWN_KEYS = ("paths", "name", "description")
95
+
96
+ _GLOB_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
97
+ _KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):(.*)$")
98
+ # YAML block-scalar introducers; the value lives in the indented lines below.
99
+ _BLOCK_SCALARS = ("|", ">", "|-", ">-", "|+", ">+")
100
+
101
+ # macOS and Windows filesystems are case-insensitive: the very same file can
102
+ # be handed to us in a case the glob author never wrote. Match case-insensitively
103
+ # there — over-injecting a spec is the safe side of the asymmetry.
104
+ _CASE_INSENSITIVE_FS = sys.platform == "darwin" or sys.platform.startswith("win")
105
+ _GLOB_FLAGS = re.IGNORECASE if _CASE_INSENSITIVE_FS else 0
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class SpecFrontmatter:
110
+ """Parsed frontmatter head. ``paths`` is None when the key is absent."""
111
+
112
+ paths: tuple[str, ...] | None
113
+ name: str | None
114
+ description: str | None
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class SpecMatch:
119
+ spec_path: Path
120
+ """Absolute path to the spec file."""
121
+ rel_path: str
122
+ """Repo-relative POSIX path, for display."""
123
+ description: str | None
124
+ """Frontmatter ``description:`` value, if declared."""
125
+
126
+
127
+ def _warn(message: str) -> None:
128
+ print(f"[WARN] spec_match: {message}", file=sys.stderr)
129
+
130
+
131
+ def _parse_flow_sequence(value: str) -> list[str]:
132
+ """Split a YAML flow sequence body (``[a, b]``) into unquoted items.
133
+
134
+ Commas separate; each item is trimmed and unquoted. Empty items (a
135
+ trailing comma, ``[]``) collapse away.
136
+ """
137
+ inner = value[1:-1]
138
+ items = (_unquote(part.strip()).strip() for part in inner.split(","))
139
+ return [item for item in items if item]
140
+
141
+
142
+ def _read_head(path: Path) -> str:
143
+ """Read at most HEAD_MAX_BYTES from the file, decoded as UTF-8."""
144
+ with open(path, "rb") as f:
145
+ data = f.read(HEAD_MAX_BYTES)
146
+ return data.decode("utf-8", errors="replace")
147
+
148
+
149
+ def parse_spec_frontmatter(head_text: str) -> SpecFrontmatter | None:
150
+ """Parse the optional frontmatter block from a spec file's head.
151
+
152
+ Returns None when the file has no frontmatter: either the first line is not
153
+ ``---``, or the block declares no recognized key before its closing marker
154
+ (a horizontal rule opening a prose file — silent, not an error).
155
+
156
+ Raises ValueError on a malformed ``paths:`` key (a scalar where a list
157
+ belongs) and on a block that is still open when the head bound
158
+ (HEAD_MAX_LINES / HEAD_MAX_BYTES) is reached — routing on a half-read
159
+ frontmatter would be worse than skipping the file loudly. Every other line
160
+ shape is tolerated and ignored.
161
+ """
162
+ lines = head_text.splitlines()[:HEAD_MAX_LINES]
163
+ if not lines:
164
+ return None
165
+ first = lines[0].lstrip("\ufeff") # tolerate a UTF-8 BOM
166
+ if first != "---":
167
+ return None
168
+
169
+ paths: list[str] | None = None
170
+ name: str | None = None
171
+ description: str | None = None
172
+ pending_key: str | None = None
173
+ block_indent: int | None = None
174
+ saw_known_key = False
175
+ closed = False
176
+
177
+ for line in lines[1:]:
178
+ stripped = line.strip()
179
+ indent = len(line) - len(line.lstrip())
180
+
181
+ if block_indent is not None:
182
+ # Inside a block scalar: everything more indented (and blank lines)
183
+ # is its value. A dedent ends the block; that line still counts.
184
+ if not stripped or indent > block_indent:
185
+ continue
186
+ block_indent = None
187
+
188
+ if stripped == "---":
189
+ closed = True
190
+ break
191
+ if not stripped or stripped.startswith("#"):
192
+ continue
193
+
194
+ if stripped == "-" or stripped.startswith("- "):
195
+ if pending_key == "paths" and paths is not None:
196
+ item = _unquote(_strip_inline_comment(stripped[1:].strip()).strip())
197
+ paths.append(item)
198
+ # List items outside `paths:` are tolerated and ignored.
199
+ continue
200
+
201
+ key_match = _KEY_RE.match(stripped)
202
+ if key_match is None:
203
+ continue # Unrecognized line shape — tolerated and ignored.
204
+
205
+ key = key_match.group(1)
206
+ saw_known_key = saw_known_key or key in _KNOWN_KEYS
207
+ raw_value = key_match.group(2).strip()
208
+ if raw_value in _BLOCK_SCALARS:
209
+ if key == "paths":
210
+ raise ValueError("'paths' must be a list of globs")
211
+ pending_key = None
212
+ block_indent = indent
213
+ continue
214
+
215
+ value = _unquote(_strip_inline_comment(raw_value).strip())
216
+ if value:
217
+ pending_key = None
218
+ if key == "paths":
219
+ if not (value.startswith("[") and value.endswith("]")):
220
+ raise ValueError("'paths' must be a list of globs")
221
+ paths = _parse_flow_sequence(value)
222
+ elif key == "name":
223
+ name = value
224
+ elif key == "description":
225
+ description = value
226
+ # Unknown scalar keys are tolerated and ignored.
227
+ else:
228
+ pending_key = key
229
+ if key == "paths":
230
+ paths = []
231
+
232
+ if not saw_known_key:
233
+ # An opening `---` with no recognized key is a horizontal rule, not a
234
+ # frontmatter block. Silent by design: prose files are not malformed.
235
+ return None
236
+ if not closed:
237
+ raise ValueError(
238
+ f"frontmatter block never closed within the head bound "
239
+ f"({HEAD_MAX_BYTES} bytes / {HEAD_MAX_LINES} lines)"
240
+ )
241
+
242
+ return SpecFrontmatter(
243
+ paths=tuple(paths) if paths is not None else None,
244
+ name=name,
245
+ description=description,
246
+ )
247
+
248
+
249
+ def validate_glob(glob: str) -> str | None:
250
+ """Return an error message for an invalid glob, or None when valid.
251
+
252
+ Deny-list, not allow-list: only what is unsafe or meaningless is rejected
253
+ (see module docstring). Everything else — ``@scope``, ``[slug]``,
254
+ ``(marketing)``, non-ASCII directory names — is a legal path in a real
255
+ repository and translates fine.
256
+ """
257
+ if not glob:
258
+ return "empty glob"
259
+ if glob.startswith("/"):
260
+ return "absolute paths are not allowed (globs are repo-relative)"
261
+ if ".." in glob.split("/"):
262
+ return "'..' segments are not allowed"
263
+ if "\\" in glob:
264
+ return "backslashes are not allowed (globs use POSIX '/' separators)"
265
+ if _GLOB_CONTROL_RE.search(glob):
266
+ return "contains control characters"
267
+ return None
268
+
269
+
270
+ def glob_to_regex(glob: str) -> re.Pattern[str]:
271
+ """Translate a validated glob to a compiled full-match regex.
272
+
273
+ Deterministic, segment-based translation (see module docstring for the
274
+ grammar and examples): ``**`` as a whole segment spans zero or more
275
+ segments; ``*`` becomes ``[^/]*``; ``?`` becomes ``[^/]``; everything
276
+ else is escaped literally. A trailing ``/`` is expanded to ``/**`` first.
277
+ On case-insensitive filesystems (macOS, Windows) the pattern compiles with
278
+ ``re.IGNORECASE`` — see ``_CASE_INSENSITIVE_FS``.
279
+ """
280
+ if glob.endswith("/"):
281
+ glob += "**"
282
+ segments = glob.split("/")
283
+ parts: list[str] = []
284
+ for i, seg in enumerate(segments):
285
+ is_last = i == len(segments) - 1
286
+ if seg == "**":
287
+ # Last: consume the rest of the path (at least the separator
288
+ # boundary is already emitted by the previous segment). Not last:
289
+ # zero or more whole segments including their separators.
290
+ parts.append(".*" if is_last else r"(?:[^/]+/)*")
291
+ continue
292
+ piece = "".join(
293
+ "[^/]*" if ch == "*" else "[^/]" if ch == "?" else re.escape(ch)
294
+ for ch in seg
295
+ )
296
+ parts.append(piece if is_last else piece + "/")
297
+ return re.compile("^" + "".join(parts) + "$", _GLOB_FLAGS)
298
+
299
+
300
+ def normalize_repo_relative(repo_root: Path, file_path: str | Path) -> str | None:
301
+ """Canonical repo-relative POSIX path — the one normalization in the
302
+ pipeline, used both for matching and for display.
303
+
304
+ Root and file are fully resolved (``strict=False``, so a file that no
305
+ longer exists still normalizes): symlinked repo roots, macOS's
306
+ ``/tmp`` → ``/private/tmp`` and ``..`` segments cannot make one file look
307
+ like two different paths. The result is NFC-normalized (macOS hands out
308
+ NFD filenames). Relative inputs are taken as repo-relative. Returns None
309
+ when the file resolves outside the repo.
310
+ """
311
+ try:
312
+ root = Path(repo_root).resolve(strict=False)
313
+ candidate = Path(file_path)
314
+ if not candidate.is_absolute():
315
+ text = str(file_path).replace("\\", "/")
316
+ while text.startswith("./"):
317
+ text = text[2:]
318
+ if not text:
319
+ return None
320
+ candidate = root / text
321
+ rel = candidate.resolve(strict=False).relative_to(root).as_posix()
322
+ except (OSError, ValueError):
323
+ return None
324
+ return unicodedata.normalize("NFC", rel)
325
+
326
+
327
+ def match_specs_for_file(repo_root: Path, file_path: str | Path) -> list[SpecMatch]:
328
+ """Return specs whose frontmatter ``paths:`` globs match file_path.
329
+
330
+ ``file_path`` may be absolute or repo-relative. More specific matching
331
+ globs are returned first; ``rel_path`` is the deterministic tie-break.
332
+ Scans ``.trellis/spec/**/*.md`` with bounded head-reads only. Never raises;
333
+ unreadable or malformed spec files are skipped with a stderr warning.
334
+ """
335
+ try:
336
+ repo_root = Path(repo_root).resolve()
337
+ spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
338
+ if not spec_dir.is_dir():
339
+ return []
340
+ rel = normalize_repo_relative(repo_root, file_path)
341
+ if rel is None:
342
+ return []
343
+
344
+ matches: list[SpecMatch] = []
345
+ specificity: dict[str, tuple[int, int, int, int]] = {}
346
+ for spec_file in spec_dir.rglob("*.md"):
347
+ spec_rel = spec_file.relative_to(repo_root).as_posix()
348
+ try:
349
+ head = _read_head(spec_file)
350
+ except OSError as exc:
351
+ _warn(f"cannot read {spec_rel}: {exc}")
352
+ continue
353
+ try:
354
+ frontmatter = parse_spec_frontmatter(head)
355
+ except ValueError as exc:
356
+ _warn(f"malformed frontmatter in {spec_rel}: {exc}")
357
+ continue
358
+ if frontmatter is None or not frontmatter.paths:
359
+ continue
360
+ for glob in frontmatter.paths:
361
+ error = validate_glob(glob)
362
+ if error is not None:
363
+ _warn(f"invalid glob {glob!r} in {spec_rel}: {error}")
364
+ continue
365
+ if glob_to_regex(glob).match(rel):
366
+ scored_glob = glob + "**" if glob.endswith("/") else glob
367
+ wildcard_count = scored_glob.count("*") + scored_glob.count("?")
368
+ segments = scored_glob.split("/")
369
+ literal_segments = sum(
370
+ "*" not in segment and "?" not in segment
371
+ for segment in segments
372
+ )
373
+ specificity[spec_rel] = (
374
+ 0 if wildcard_count == 0 else 1,
375
+ -literal_segments,
376
+ wildcard_count,
377
+ -(len(scored_glob) - wildcard_count),
378
+ )
379
+ matches.append(
380
+ SpecMatch(
381
+ spec_path=spec_file,
382
+ rel_path=spec_rel,
383
+ description=frontmatter.description,
384
+ )
385
+ )
386
+ break
387
+
388
+ # Payload assembly spends its budget in this order. Exact and narrowly
389
+ # scoped matches must therefore outrank broad tree globs; alphabetic
390
+ # order is only a deterministic tie-break.
391
+ matches.sort(key=lambda m: (*specificity[m.rel_path], m.rel_path))
392
+ return matches
393
+ except Exception as exc: # Never raise — callers are hooks/context tools.
394
+ _warn(f"spec scan failed: {exc}")
395
+ return []
@@ -236,20 +236,22 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
236
236
  if extension in _CODE_FILE_EXTENSIONS and not _is_exempt_from_code_file_warning(
237
237
  file_path, task_rel
238
238
  ):
239
- print(
240
- f" {colored(f'{file_name}:{line_num}: Warning: {file_path} looks like a code file — '
241
- 'implement/check.jsonl should reference spec/research docs; '
242
- 'agents read code themselves', Colors.YELLOW)}"
239
+ warning_message = (
240
+ f"{file_name}:{line_num}: Warning: {file_path} looks like a code file — "
241
+ "implement/check.jsonl should reference spec/research docs; "
242
+ "agents read code themselves"
243
243
  )
244
+ print(f" {colored(warning_message, Colors.YELLOW)}")
244
245
 
245
246
  if max_file_bytes:
246
247
  size = full_path.stat().st_size
247
248
  if size > max_file_bytes:
248
- print(
249
- f" {colored(f'{file_name}:{line_num}: Warning: {file_path} is {size} bytes, '
250
- f'exceeds context_injection.max_file_bytes ({max_file_bytes}); '
251
- 'injection will truncate it', Colors.YELLOW)}"
249
+ warning_message = (
250
+ f"{file_name}:{line_num}: Warning: {file_path} is {size} bytes, "
251
+ f"exceeds context_injection.max_file_bytes ({max_file_bytes}); "
252
+ "injection will truncate it"
252
253
  )
254
+ print(f" {colored(warning_message, Colors.YELLOW)}")
253
255
 
254
256
  if errors == 0:
255
257
  print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
@@ -56,6 +56,7 @@ from .task_utils import (
56
56
  resolve_task_dir,
57
57
  run_task_hooks,
58
58
  )
59
+ from .workflow_selection import DIR_WORKFLOWS, WORKFLOW_ID_RE
59
60
 
60
61
 
61
62
  # =============================================================================
@@ -254,6 +255,31 @@ def cmd_create(args: argparse.Namespace) -> int:
254
255
  # Inferred: default_package → None (no task.json yet for create)
255
256
  package = resolve_package(repo_root=repo_root)
256
257
 
258
+ # Validate --workflow (CLI source: fail-fast on invalid id; a missing
259
+ # library file only warns — it may be saved later via `trellis workflow --save`)
260
+ workflow_id: str | None = getattr(args, "workflow", None)
261
+ if workflow_id:
262
+ if not WORKFLOW_ID_RE.match(workflow_id):
263
+ print(
264
+ colored(
265
+ f"Error: invalid workflow id '{workflow_id}' (allowed: letters, digits, '-', '_')",
266
+ Colors.RED,
267
+ ),
268
+ file=sys.stderr,
269
+ )
270
+ return 1
271
+ workflow_md = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md"
272
+ if not workflow_md.is_file():
273
+ print(
274
+ colored(
275
+ f"Warning: {DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md does not exist yet; "
276
+ "default workflow resolution is used until it is saved "
277
+ "(trellis workflow --save).",
278
+ Colors.YELLOW,
279
+ ),
280
+ file=sys.stderr,
281
+ )
282
+
257
283
  # Default assignee to current developer
258
284
  assignee = args.assignee
259
285
  if not assignee:
@@ -385,6 +411,10 @@ def cmd_create(args: argparse.Namespace) -> int:
385
411
  "notes": "",
386
412
  "meta": meta,
387
413
  }
414
+ # Optional per-task workflow selection: key present only when opted in,
415
+ # so tasks without a selection keep today's task.json shape byte-for-byte.
416
+ if workflow_id:
417
+ task_data["workflow"] = workflow_id
388
418
 
389
419
  write_json(task_json_path, task_data)
390
420
 
@@ -22,11 +22,12 @@ from __future__ import annotations
22
22
 
23
23
  import re
24
24
 
25
- from .paths import DIR_WORKFLOW, get_repo_root
25
+ from . import workflow_selection
26
+ from .paths import get_repo_root
26
27
 
27
28
 
28
29
  def _workflow_md_path():
29
- return get_repo_root() / DIR_WORKFLOW / "workflow.md"
30
+ return workflow_selection.resolve_workflow_md(get_repo_root())
30
31
 
31
32
  # Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
32
33
  _MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Per-task workflow selection.
4
+
5
+ Resolves which workflow markdown file consumers should read. A task may pin
6
+ a workflow variant by storing `"workflow": "<id>"` in its task.json; the
7
+ variant body lives at `.trellis/workflows/<id>.md` (user-managed library).
8
+
9
+ Resolution precedence (single source of truth for all consumers), highest
10
+ to lowest — each layer resolves an id to `.trellis/workflows/<id>.md` and
11
+ falls through when unset, invalid, or pointing at a missing file:
12
+ 1. Per-task pin - active task's task.json `workflow` (session-bound,
13
+ explicit; a bad id or missing file warns once on stderr, then falls
14
+ through rather than aborting).
15
+ 2. Personal - `.developer` `workflow=<id>` (gitignored, per-developer;
16
+ outranks the team default). Silent on miss.
17
+ 3. Team default - config.yaml `default_workflow` (git-tracked, shared).
18
+ Silent on miss.
19
+ 4. Global - `.trellis/workflow.md`.
20
+ With neither a per-task pin nor the personal/team keys set, this is identical
21
+ to reading the global `.trellis/workflow.md`. Never raises.
22
+
23
+ Provides:
24
+ workflow_md_for_task - Full precedence for an already-resolved task dir
25
+ resolve_workflow_md - Session-aware wrapper via the active task resolver
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import re
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ from .paths import DIR_WORKFLOW, FILE_TASK_JSON
36
+
37
+ # Workflow variant library directory under .trellis/ (plural on purpose:
38
+ # `.trellis/workflow/` is reserved by the YAML-manifest migration).
39
+ DIR_WORKFLOWS = "workflows"
40
+
41
+ # Workflow ids must be plain slugs; anything else (path separators, dots)
42
+ # is rejected so a task.json value can never escape .trellis/workflows/.
43
+ WORKFLOW_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
44
+
45
+
46
+ def _global_workflow_md(repo_root: Path) -> Path:
47
+ return repo_root / DIR_WORKFLOW / "workflow.md"
48
+
49
+
50
+ def _library_variant(repo_root: Path, workflow_id: str | None) -> Path | None:
51
+ """Map a workflow id to its library file if valid and present, else None.
52
+
53
+ Shared by every layer (per-task pin, personal, team). An id with path
54
+ separators/dots/blanks, or one whose `.trellis/workflows/<id>.md` file does
55
+ not exist, returns None so the caller falls through. Never raises.
56
+ """
57
+ if not isinstance(workflow_id, str) or not workflow_id:
58
+ return None
59
+ if not WORKFLOW_ID_RE.match(workflow_id):
60
+ return None
61
+ variant = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md"
62
+ return variant if variant.is_file() else None
63
+
64
+
65
+ def _developer_workflow_id(repo_root: Path) -> str | None:
66
+ """Personal override id from the gitignored .developer file (fail-open)."""
67
+ try:
68
+ from .paths import get_developer_workflow
69
+
70
+ return get_developer_workflow(repo_root)
71
+ except Exception:
72
+ return None
73
+
74
+
75
+ def _config_default_id(repo_root: Path) -> str | None:
76
+ """Team-shared default id from config.yaml `default_workflow` (fail-open)."""
77
+ try:
78
+ from .config import get_default_workflow
79
+
80
+ return get_default_workflow(repo_root)
81
+ except Exception:
82
+ return None
83
+
84
+
85
+ def _default_workflow_md(repo_root: Path) -> Path:
86
+ """Resolve the non-per-task default: personal -> team -> global.
87
+
88
+ Personal (`.developer` `workflow=`) outranks the team-shared
89
+ (config.yaml `default_workflow`) layer; both fall through to the global
90
+ `.trellis/workflow.md` when unset, invalid, or naming a missing file. These
91
+ layers are silent on miss (they are defaults, not an explicit per-task
92
+ choice — a per-turn warning would be noise).
93
+ """
94
+ for get_id in (_developer_workflow_id, _config_default_id):
95
+ variant = _library_variant(repo_root, get_id(repo_root))
96
+ if variant is not None:
97
+ return variant
98
+ return _global_workflow_md(repo_root)
99
+
100
+
101
+ def _task_pin_variant(repo_root: Path, task_dir: Path | None) -> Path | None:
102
+ """Return the per-task pinned variant path, or None to fall through.
103
+
104
+ Emits a stderr warning on an invalid id or a missing variant file (an
105
+ explicit per-task choice that cannot be honored), then returns None so
106
+ resolution continues with the personal/team defaults. Never raises.
107
+ """
108
+ if task_dir is None:
109
+ return None
110
+
111
+ try:
112
+ raw = json.loads((task_dir / FILE_TASK_JSON).read_text(encoding="utf-8"))
113
+ if not isinstance(raw, dict):
114
+ return None
115
+
116
+ workflow_id = raw.get("workflow")
117
+ if not isinstance(workflow_id, str) or not workflow_id:
118
+ return None
119
+
120
+ if not WORKFLOW_ID_RE.match(workflow_id):
121
+ print(
122
+ f"Warning: task '{task_dir.name}' has invalid workflow id "
123
+ f"{workflow_id!r}; using default workflow resolution",
124
+ file=sys.stderr,
125
+ )
126
+ return None
127
+
128
+ variant = _library_variant(repo_root, workflow_id)
129
+ if variant is not None:
130
+ return variant
131
+
132
+ print(
133
+ f"Warning: task '{task_dir.name}' selects workflow '{workflow_id}' but "
134
+ f"{DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md is missing; "
135
+ f"using default workflow resolution",
136
+ file=sys.stderr,
137
+ )
138
+ return None
139
+ except Exception:
140
+ return None
141
+
142
+
143
+ def workflow_md_for_task(repo_root: Path, task_dir: Path | None) -> Path:
144
+ """Return the workflow.md path for an already-resolved task dir (or None).
145
+
146
+ Applies the full precedence documented in the module docstring:
147
+ per-task pin -> personal (.developer) -> team (config.yaml) -> global.
148
+ Never raises; any failure falls through toward the global workflow path.
149
+ """
150
+ pin = _task_pin_variant(repo_root, task_dir)
151
+ if pin is not None:
152
+ return pin
153
+ return _default_workflow_md(repo_root)
154
+
155
+
156
+ def resolve_workflow_md(
157
+ repo_root: Path,
158
+ input_data: dict | None = None,
159
+ platform: str | None = None,
160
+ ) -> Path:
161
+ """Resolve the session-aware active task, then apply the resolution rule.
162
+
163
+ ``input_data`` is the raw hook payload (session/conversation identity);
164
+ CLI callers may omit it — the active-task resolver then falls back to
165
+ environment context. Never raises; any failure resolves to the global
166
+ `.trellis/workflow.md`.
167
+ """
168
+ try:
169
+ from .active_task import resolve_active_task, resolve_task_ref
170
+
171
+ active = resolve_active_task(repo_root, input_data, platform)
172
+ task_dir: Path | None = None
173
+ if active.task_path:
174
+ task_dir = resolve_task_ref(active.task_path, repo_root)
175
+ return workflow_md_for_task(repo_root, task_dir)
176
+ except Exception:
177
+ return _global_workflow_md(repo_root)