@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.
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +0 -8
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/workflow.d.ts +6 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +124 -0
- package/dist/commands/workflow.js.map +1 -1
- package/dist/configurators/workflow.d.ts +0 -14
- package/dist/configurators/workflow.d.ts.map +1 -1
- package/dist/configurators/workflow.js +1 -37
- package/dist/configurators/workflow.js.map +1 -1
- package/dist/migrations/manifests/0.6.10.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.0.json +9 -0
- package/dist/templates/claude/settings.json +22 -0
- package/dist/templates/codex/agents/trellis-check.toml +7 -5
- package/dist/templates/codex/agents/trellis-implement.toml +7 -5
- package/dist/templates/codex/agents/trellis-research.toml +13 -8
- package/dist/templates/codex/hooks/session-start.py +20 -1
- package/dist/templates/codex/hooks.json +25 -0
- package/dist/templates/copilot/hooks/session-start.py +20 -1
- package/dist/templates/opencode/plugins/inject-workflow-state.js +111 -10
- package/dist/templates/pi/extensions/trellis/index.ts.txt +4 -3
- package/dist/templates/shared-hooks/index.d.ts +5 -1
- package/dist/templates/shared-hooks/index.d.ts.map +1 -1
- package/dist/templates/shared-hooks/index.js +10 -1
- package/dist/templates/shared-hooks/index.js.map +1 -1
- package/dist/templates/shared-hooks/inject-spec-context.py +840 -0
- package/dist/templates/shared-hooks/inject-subagent-context.py +4 -3
- package/dist/templates/shared-hooks/inject-workflow-state.py +35 -9
- package/dist/templates/shared-hooks/session-start.py +22 -1
- package/dist/templates/trellis/config.yaml +47 -0
- package/dist/templates/trellis/index.d.ts +4 -3
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +7 -3
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/add_session.py +0 -45
- package/dist/templates/trellis/scripts/common/active_task.py +5 -2
- package/dist/templates/trellis/scripts/common/config.py +18 -0
- package/dist/templates/trellis/scripts/common/git_context.py +34 -2
- package/dist/templates/trellis/scripts/common/paths.py +28 -0
- package/dist/templates/trellis/scripts/common/spec_inject.py +439 -0
- package/dist/templates/trellis/scripts/common/spec_match.py +395 -0
- package/dist/templates/trellis/scripts/common/task_context.py +10 -8
- package/dist/templates/trellis/scripts/common/task_store.py +30 -0
- package/dist/templates/trellis/scripts/common/workflow_phase.py +3 -2
- package/dist/templates/trellis/scripts/common/workflow_selection.py +177 -0
- package/dist/templates/trellis/scripts/task.py +82 -0
- package/package.json +2 -2
- 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
|
-
|
|
240
|
-
f"
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
249
|
-
f"
|
|
250
|
-
|
|
251
|
-
|
|
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 .
|
|
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()
|
|
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)
|