@pennixrv/trellis 0.6.45 → 0.7.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +21 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/mem.d.ts.map +1 -1
- package/dist/commands/mem.js +6 -13
- package/dist/commands/mem.js.map +1 -1
- package/dist/commands/workflow.d.ts +17 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +222 -0
- package/dist/commands/workflow.js.map +1 -1
- package/dist/configurators/claude.d.ts.map +1 -1
- package/dist/configurators/claude.js.map +1 -1
- package/dist/configurators/dsh.d.ts +16 -19
- package/dist/configurators/dsh.d.ts.map +1 -1
- package/dist/configurators/dsh.js +38 -26
- package/dist/configurators/dsh.js.map +1 -1
- package/dist/configurators/gemini.d.ts.map +1 -1
- package/dist/configurators/gemini.js.map +1 -1
- package/dist/configurators/opencode.d.ts.map +1 -1
- package/dist/configurators/opencode.js +4 -0
- package/dist/configurators/opencode.js.map +1 -1
- package/dist/migrations/manifests/0.6.17.json +2 -2
- package/dist/migrations/manifests/0.7.0-beta.4.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.4.pennix.1.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.5.json +9 -0
- package/dist/templates/claude/hooks/statusline.py +7 -0
- package/dist/templates/claude/settings.json +22 -0
- package/dist/templates/codex/hooks/session-start.py +20 -1
- package/dist/templates/codex/hooks.json +25 -0
- package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +2 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +2 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/hooks-and-settings.md +3 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +5 -4
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md +1 -0
- package/dist/templates/common/bundled-skills/trellis-session-insight/SKILL.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +2 -1
- package/dist/templates/copilot/hooks/session-start.py +20 -1
- package/dist/templates/dsh/DSH.md +61 -37
- package/dist/templates/dsh/agents/trellis-check.md +99 -0
- package/dist/templates/dsh/agents/trellis-implement.md +106 -0
- package/dist/templates/dsh/agents/trellis-research.md +134 -0
- package/dist/templates/dsh/index.d.ts +12 -11
- package/dist/templates/dsh/index.d.ts.map +1 -1
- package/dist/templates/dsh/index.js +14 -12
- package/dist/templates/dsh/index.js.map +1 -1
- package/dist/templates/opencode/plugins/inject-spec-context.js +121 -0
- package/dist/templates/opencode/plugins/inject-workflow-state.js +111 -10
- package/dist/templates/pi/extensions/trellis/index.ts.txt +164 -10
- package/dist/templates/shared-hooks/index.d.ts +8 -2
- package/dist/templates/shared-hooks/index.d.ts.map +1 -1
- package/dist/templates/shared-hooks/index.js +13 -1
- package/dist/templates/shared-hooks/index.js.map +1 -1
- package/dist/templates/shared-hooks/inject-spec-context.py +844 -0
- 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 +3 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +6 -0
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/common/active_task.py +24 -2
- package/dist/templates/trellis/scripts/common/cli_adapter.py +38 -6
- package/dist/templates/trellis/scripts/common/config.py +18 -0
- package/dist/templates/trellis/scripts/common/context_projection.py +2 -2
- 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_store.py +88 -1
- package/dist/templates/trellis/scripts/common/trellis_config.py +46 -7
- 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 +83 -0
- package/dist/templates/trellis/workflow.md +35 -30
- package/dist/types/ai-tools.d.ts.map +1 -1
- package/dist/types/ai-tools.js +26 -13
- package/dist/types/ai-tools.js.map +1 -1
- package/package.json +2 -2
|
@@ -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 []
|
|
@@ -68,6 +68,7 @@ from .task_utils import (
|
|
|
68
68
|
resolve_task_dir,
|
|
69
69
|
run_task_hooks,
|
|
70
70
|
)
|
|
71
|
+
from .workflow_selection import DIR_WORKFLOWS, WORKFLOW_ID_RE
|
|
71
72
|
|
|
72
73
|
|
|
73
74
|
# =============================================================================
|
|
@@ -196,6 +197,40 @@ def _report_write_failure(path: Path) -> None:
|
|
|
196
197
|
)
|
|
197
198
|
|
|
198
199
|
|
|
200
|
+
def _restore_child_links(unlinked: dict[Path, str | None]) -> None:
|
|
201
|
+
"""Put back the parent links this archive attempt already removed.
|
|
202
|
+
|
|
203
|
+
The unlink loop below walks a parent's children one at a time, so a
|
|
204
|
+
failure part-way through leaves the earlier children carrying
|
|
205
|
+
``parent: null`` while their parent is still in the active tree. That is
|
|
206
|
+
the mirror image of the dangling reference the same loop already refuses
|
|
207
|
+
to create, and nothing repairs it later either, so undo it before the
|
|
208
|
+
failure is reported.
|
|
209
|
+
|
|
210
|
+
Restoring is best-effort: if a child cannot be written back, name it so
|
|
211
|
+
the caller can re-link it by hand instead of guessing which one broke.
|
|
212
|
+
"""
|
|
213
|
+
broken: list[str] = []
|
|
214
|
+
for child_json, original_parent in unlinked.items():
|
|
215
|
+
child_data, _ = read_json_checked(child_json)
|
|
216
|
+
if child_data is None:
|
|
217
|
+
broken.append(child_json.parent.name)
|
|
218
|
+
continue
|
|
219
|
+
child_data["parent"] = original_parent
|
|
220
|
+
if not write_json(child_json, child_data):
|
|
221
|
+
broken.append(child_json.parent.name)
|
|
222
|
+
if broken:
|
|
223
|
+
print(
|
|
224
|
+
colored(
|
|
225
|
+
f"Warning: could not restore the parent link on: {', '.join(broken)}. "
|
|
226
|
+
"Re-link each one with `python3 .trellis/scripts/task.py "
|
|
227
|
+
"add-subtask <parent> <child>`.",
|
|
228
|
+
Colors.RED,
|
|
229
|
+
),
|
|
230
|
+
file=sys.stderr,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
199
234
|
# =============================================================================
|
|
200
235
|
# Sub-agent platform detection + JSONL context files
|
|
201
236
|
# =============================================================================
|
|
@@ -222,6 +257,7 @@ _SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
|
|
|
222
257
|
".zcode", # ZCode
|
|
223
258
|
".grok", # Grok Build
|
|
224
259
|
".kimi-code", # Kimi Code
|
|
260
|
+
".dsh", # DeepSeek Harness
|
|
225
261
|
)
|
|
226
262
|
_CODEX_CONFIG_DIR = ".codex"
|
|
227
263
|
|
|
@@ -341,6 +377,31 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
341
377
|
# Inferred: default_package → None (no task.json yet for create)
|
|
342
378
|
package = resolve_package(repo_root=repo_root)
|
|
343
379
|
|
|
380
|
+
# Validate --workflow (CLI source: fail-fast on invalid id; a missing
|
|
381
|
+
# library file only warns — it may be saved later via `trellis workflow --save`)
|
|
382
|
+
workflow_id: str | None = getattr(args, "workflow", None)
|
|
383
|
+
if workflow_id:
|
|
384
|
+
if not WORKFLOW_ID_RE.match(workflow_id):
|
|
385
|
+
print(
|
|
386
|
+
colored(
|
|
387
|
+
f"Error: invalid workflow id '{workflow_id}' (allowed: letters, digits, '-', '_')",
|
|
388
|
+
Colors.RED,
|
|
389
|
+
),
|
|
390
|
+
file=sys.stderr,
|
|
391
|
+
)
|
|
392
|
+
return 1
|
|
393
|
+
workflow_md = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md"
|
|
394
|
+
if not workflow_md.is_file():
|
|
395
|
+
print(
|
|
396
|
+
colored(
|
|
397
|
+
f"Warning: {DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md does not exist yet; "
|
|
398
|
+
"default workflow resolution is used until it is saved "
|
|
399
|
+
"(trellis workflow --save).",
|
|
400
|
+
Colors.YELLOW,
|
|
401
|
+
),
|
|
402
|
+
file=sys.stderr,
|
|
403
|
+
)
|
|
404
|
+
|
|
344
405
|
# Default assignee to current developer
|
|
345
406
|
assignee = args.assignee
|
|
346
407
|
if not assignee:
|
|
@@ -517,6 +578,10 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
517
578
|
"notes": "",
|
|
518
579
|
"meta": meta,
|
|
519
580
|
}
|
|
581
|
+
# Optional per-task workflow selection: key present only when opted in,
|
|
582
|
+
# so tasks without a selection keep today's task.json shape byte-for-byte.
|
|
583
|
+
if workflow_id:
|
|
584
|
+
task_data["workflow"] = workflow_id
|
|
520
585
|
|
|
521
586
|
# A directory without task.json is not a task: `list` hides it and every
|
|
522
587
|
# lifecycle command refuses it. Fail here rather than printing "Created
|
|
@@ -1332,7 +1397,13 @@ def cmd_archive(args: argparse.Namespace) -> int:
|
|
|
1332
1397
|
# missing from the active set are treated as completed.
|
|
1333
1398
|
task_children = data.get("children", [])
|
|
1334
1399
|
|
|
1335
|
-
# If this is a parent, clear parent field in all children
|
|
1400
|
+
# If this is a parent, clear parent field in all children.
|
|
1401
|
+
# Remember each link removed so a later failure in this loop can
|
|
1402
|
+
# put it back (see _restore_child_links). Keyed by the child's
|
|
1403
|
+
# task.json: a `children` list that names the same child twice
|
|
1404
|
+
# would otherwise record a second, already-cleared snapshot and
|
|
1405
|
+
# the restore would write that `null` back over the real parent.
|
|
1406
|
+
unlinked_children: dict[Path, str | None] = {}
|
|
1336
1407
|
if task_children:
|
|
1337
1408
|
for child_name in task_children:
|
|
1338
1409
|
child_dir_path = find_task_by_name(child_name, tasks_dir)
|
|
@@ -1351,13 +1422,29 @@ def cmd_archive(args: argparse.Namespace) -> int:
|
|
|
1351
1422
|
file=sys.stderr,
|
|
1352
1423
|
)
|
|
1353
1424
|
continue
|
|
1425
|
+
# Only the first visit to a child records its original
|
|
1426
|
+
# parent: a `children` list naming the same child twice
|
|
1427
|
+
# would otherwise snapshot the already-cleared value and
|
|
1428
|
+
# the restore would write that `null` back over the link.
|
|
1429
|
+
first_visit = child_json not in unlinked_children
|
|
1430
|
+
original_parent = child_data.get("parent")
|
|
1431
|
+
if first_visit:
|
|
1432
|
+
unlinked_children[child_json] = original_parent
|
|
1354
1433
|
child_data["parent"] = None
|
|
1355
1434
|
if not write_json(child_json, child_data):
|
|
1356
1435
|
# Stop before the move: a child pointing at a
|
|
1357
1436
|
# parent that has left .trellis/tasks/ is a
|
|
1358
1437
|
# dangling reference nothing repairs later.
|
|
1438
|
+
# Put back the children already unlinked above —
|
|
1439
|
+
# their parent is staying, so losing the link the
|
|
1440
|
+
# other way round is just as unrecoverable.
|
|
1359
1441
|
# Retrying is safe — every step so far is
|
|
1360
1442
|
# idempotent.
|
|
1443
|
+
if first_visit:
|
|
1444
|
+
# The link never came off, so there is nothing
|
|
1445
|
+
# to put back for this child.
|
|
1446
|
+
del unlinked_children[child_json]
|
|
1447
|
+
_restore_child_links(unlinked_children)
|
|
1361
1448
|
_report_write_failure(child_json)
|
|
1362
1449
|
print(
|
|
1363
1450
|
f"Not archived: {_repo_relative_path(task_dir, repo_root)} is "
|
|
@@ -9,11 +9,13 @@ one parser cannot drift from another. Returns an empty dict on
|
|
|
9
9
|
missing/malformed files so callers stay simple.
|
|
10
10
|
|
|
11
11
|
Supported subset: ``key: value`` scalars (everything is a string), nested
|
|
12
|
-
mappings by indentation, ``- `` lists of scalars,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
mappings by indentation, ``- `` lists of scalars, simple flow sequences of
|
|
13
|
+
scalars (``tools: []`` / ``tools: [Edit, Write]``), ``#`` comments
|
|
14
|
+
(whole-line and inline outside quotes), and one layer of matching surrounding
|
|
15
|
+
quotes. Constructs outside that subset — block scalars, anchors, aliases,
|
|
16
|
+
merge keys, nested flow collections, and mappings nested inside a list — are
|
|
17
|
+
reported on stderr and skipped rather than parsed into a plausible-looking
|
|
18
|
+
wrong value.
|
|
17
19
|
"""
|
|
18
20
|
|
|
19
21
|
from __future__ import annotations
|
|
@@ -93,12 +95,32 @@ def _unsupported_value(key: str, value: str) -> str | None:
|
|
|
93
95
|
if value.startswith("*"):
|
|
94
96
|
return "YAML aliases are not supported"
|
|
95
97
|
if value.startswith("["):
|
|
96
|
-
|
|
98
|
+
if _parse_flow_sequence(value) is None:
|
|
99
|
+
return "nested or invalid flow sequences are not supported (use `- ` list items)"
|
|
100
|
+
return None
|
|
97
101
|
if value.startswith("{"):
|
|
98
102
|
return "flow mappings are not supported (use an indented mapping)"
|
|
99
103
|
return None
|
|
100
104
|
|
|
101
105
|
|
|
106
|
+
def _parse_flow_sequence(value: str) -> list[str] | None:
|
|
107
|
+
"""Parse ``[]`` / ``[a, b]`` of scalars. None if nested or malformed."""
|
|
108
|
+
if not (value.startswith("[") and value.endswith("]")):
|
|
109
|
+
return None
|
|
110
|
+
inner = value[1:-1].strip()
|
|
111
|
+
if not inner:
|
|
112
|
+
return []
|
|
113
|
+
if any(ch in inner for ch in "[]{}"):
|
|
114
|
+
return None
|
|
115
|
+
items: list[str] = []
|
|
116
|
+
for part in inner.split(","):
|
|
117
|
+
token = part.strip()
|
|
118
|
+
if not token:
|
|
119
|
+
continue
|
|
120
|
+
items.append(_unquote(token))
|
|
121
|
+
return items
|
|
122
|
+
|
|
123
|
+
|
|
102
124
|
def _skip_indented_body(lines: list[str], start: int, indent: int) -> int:
|
|
103
125
|
"""Skip the continuation lines of a rejected key (block scalar body etc.)."""
|
|
104
126
|
i = start
|
|
@@ -162,6 +184,22 @@ def _parse_yaml_block(
|
|
|
162
184
|
current_list = None
|
|
163
185
|
i = _skip_indented_body(lines, i + 1, indent)
|
|
164
186
|
continue
|
|
187
|
+
if value.startswith("["):
|
|
188
|
+
parsed_flow = _parse_flow_sequence(value)
|
|
189
|
+
if parsed_flow is None:
|
|
190
|
+
_warn_unsupported(
|
|
191
|
+
source,
|
|
192
|
+
i + 1,
|
|
193
|
+
line,
|
|
194
|
+
"nested or invalid flow sequences are not supported (use `- ` list items)",
|
|
195
|
+
)
|
|
196
|
+
current_list = None
|
|
197
|
+
i = _skip_indented_body(lines, i + 1, indent)
|
|
198
|
+
continue
|
|
199
|
+
target[key] = parsed_flow
|
|
200
|
+
current_list = None
|
|
201
|
+
i += 1
|
|
202
|
+
continue
|
|
165
203
|
|
|
166
204
|
value = _unquote(value)
|
|
167
205
|
current_list = None
|
|
@@ -208,7 +246,8 @@ def parse_simple_yaml(content: str, source: str = "config.yaml") -> dict:
|
|
|
208
246
|
- item
|
|
209
247
|
|
|
210
248
|
Uses indentation to detect nesting (2+ spaces deeper = child). Every value
|
|
211
|
-
is a string; consumers coerce.
|
|
249
|
+
is a string; consumers coerce. Simple flow sequences of scalars become
|
|
250
|
+
``list[str]``. Unsupported constructs are reported on
|
|
212
251
|
stderr against ``source`` and skipped — see the module docstring.
|
|
213
252
|
|
|
214
253
|
Args:
|
|
@@ -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*$")
|