flower-trellis 0.4.7 → 0.4.9
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/enhancements/0.6/overrides/hooks/shared/inject-workflow-state.py +443 -0
- package/enhancements/0.6/overrides/workflow-states/in_progress-inline.md +4 -3
- package/enhancements/0.6/overrides/workflow-states/in_progress.md +4 -3
- package/enhancements/0.6/overrides/workflow-states/no_task.md +1 -1
- package/enhancements/0.6/overrides/workflow-states/planning-inline.md +1 -1
- package/enhancements/0.6/overrides/workflow-states/planning.md +1 -1
- package/enhancements/MANIFEST.json +7 -2
- package/package.json +3 -3
- package/src/assets/flower_update_hook.py +34 -21
- package/src/lib/apply-enhancements.js +17 -1
- package/src/lib/hook-override-inject.js +94 -0
- package/src/lib/self-check.js +13 -3
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Trellis per-turn breadcrumb hook (UserPromptSubmit / BeforeAgent equivalent).
|
|
3
|
+
|
|
4
|
+
Runs on every user prompt. Resolves the active task through Trellis'
|
|
5
|
+
session-aware active task resolver and emits a short <workflow-state>
|
|
6
|
+
block reminding the main AI what task is active and its expected flow.
|
|
7
|
+
|
|
8
|
+
The emitted ``hookEventName`` field is platform-aware: most hosts expect
|
|
9
|
+
``UserPromptSubmit`` (Claude Code naming, also accepted by Cursor / Qoder /
|
|
10
|
+
CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed
|
|
11
|
+
its per-turn event to ``BeforeAgent`` and its schema validator rejects the
|
|
12
|
+
legacy name. ``_detect_platform`` picks the right value at runtime.
|
|
13
|
+
Breadcrumb text is pulled exclusively from workflow.md
|
|
14
|
+
[workflow-state:STATUS] tag blocks — workflow.md is the single source of
|
|
15
|
+
truth. There are no fallback dicts in this script: when workflow.md is
|
|
16
|
+
missing or a tag is absent, the breadcrumb degrades to a generic
|
|
17
|
+
"Refer to workflow.md for current step." line so users see (and fix)
|
|
18
|
+
the broken state instead of the hook silently masking it.
|
|
19
|
+
|
|
20
|
+
Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder,
|
|
21
|
+
CodeBuddy, Droid, Gemini, Copilot, Kiro). Kiro wires this via the CLI
|
|
22
|
+
custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook``
|
|
23
|
+
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
|
|
24
|
+
(Kiro adds hook stdout directly to the conversation context). Written to
|
|
25
|
+
each platform's hooks directory via writeSharedHooks() at init time.
|
|
26
|
+
|
|
27
|
+
Silent exit 0 cases (no output):
|
|
28
|
+
- No .trellis/ directory found (not a Trellis project)
|
|
29
|
+
- task.json malformed or missing status
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
import queue
|
|
38
|
+
import threading
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
# Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is
|
|
42
|
+
# cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets)
|
|
43
|
+
# both in stdin (hook payload from host CLI) and stdout (our emitted blocks)
|
|
44
|
+
# raises UnicodeDecodeError / UnicodeEncodeError. Equivalent to `python -X utf8`
|
|
45
|
+
# but applied per-stream so we don't depend on host CLI's command wiring.
|
|
46
|
+
if sys.platform.startswith("win"):
|
|
47
|
+
import io as _io
|
|
48
|
+
for _stream_name in ("stdin", "stdout", "stderr"):
|
|
49
|
+
_stream = getattr(sys, _stream_name, None)
|
|
50
|
+
if _stream is None:
|
|
51
|
+
continue
|
|
52
|
+
if hasattr(_stream, "reconfigure"):
|
|
53
|
+
try:
|
|
54
|
+
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
elif hasattr(_stream, "detach"):
|
|
58
|
+
try:
|
|
59
|
+
setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace"))
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
from typing import Optional
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Bootstrap notice for Codex while the session has no active task. Codex does not
|
|
66
|
+
# get the full SessionStart overview; this short reminder points the main session
|
|
67
|
+
# at the start skill once and leaves the per-turn state block compact.
|
|
68
|
+
CODEX_NO_TASK_BOOTSTRAP_NOTICE = """<trellis-bootstrap>
|
|
69
|
+
If you have not already loaded Trellis context this session, read the `trellis-start` skill once.
|
|
70
|
+
</trellis-bootstrap>"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _codex_has_trellis_session_start(root: Path) -> bool:
|
|
74
|
+
"""判断 Codex 主 SessionStart hook 是否已注册并可执行。
|
|
75
|
+
|
|
76
|
+
`trellis-start` bootstrap 是没有 SessionStart hook 时的兜底。flower-managed
|
|
77
|
+
Codex 项目会补 `.codex/hooks/session-start.py`,因此已注册时不应每轮重复提示。
|
|
78
|
+
读取失败时保守返回 False,继续保留兜底提示。
|
|
79
|
+
"""
|
|
80
|
+
session_start = root / ".codex" / "hooks" / "session-start.py"
|
|
81
|
+
if not session_start.is_file():
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
hooks_path = root / ".codex" / "hooks.json"
|
|
85
|
+
try:
|
|
86
|
+
config = json.loads(hooks_path.read_text(encoding="utf-8"))
|
|
87
|
+
except (json.JSONDecodeError, OSError):
|
|
88
|
+
return False
|
|
89
|
+
hooks_config = config.get("hooks")
|
|
90
|
+
if not isinstance(hooks_config, dict):
|
|
91
|
+
return False
|
|
92
|
+
groups = hooks_config.get("SessionStart")
|
|
93
|
+
if not isinstance(groups, list):
|
|
94
|
+
return False
|
|
95
|
+
for group in groups:
|
|
96
|
+
hooks = group.get("hooks") if isinstance(group, dict) else None
|
|
97
|
+
if not isinstance(hooks, list):
|
|
98
|
+
continue
|
|
99
|
+
for hook in hooks:
|
|
100
|
+
if not isinstance(hook, dict):
|
|
101
|
+
continue
|
|
102
|
+
command = hook.get("command")
|
|
103
|
+
if isinstance(command, str) and ".codex/hooks/session-start.py" in command:
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# CWD-robust Trellis root discovery (fixes hook-path-robustness for this hook)
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def find_trellis_root(start: Path) -> Optional[Path]:
|
|
113
|
+
"""Walk up from start to find directory containing .trellis/.
|
|
114
|
+
|
|
115
|
+
Handles CWD drift: subdirectory launches, monorepo packages, etc.
|
|
116
|
+
Returns None if no .trellis/ found (silent no-op).
|
|
117
|
+
"""
|
|
118
|
+
cur = start.resolve()
|
|
119
|
+
while cur != cur.parent:
|
|
120
|
+
if (cur / ".trellis").is_dir():
|
|
121
|
+
return cur
|
|
122
|
+
cur = cur.parent
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Active task discovery
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def _detect_platform(input_data: dict) -> str | None:
|
|
131
|
+
if isinstance(input_data.get("cursor_version"), str):
|
|
132
|
+
return "cursor"
|
|
133
|
+
env_map = {
|
|
134
|
+
"CLAUDE_PROJECT_DIR": "claude",
|
|
135
|
+
"CURSOR_PROJECT_DIR": "cursor",
|
|
136
|
+
"CODEBUDDY_PROJECT_DIR": "codebuddy",
|
|
137
|
+
"FACTORY_PROJECT_DIR": "droid",
|
|
138
|
+
"GEMINI_PROJECT_DIR": "gemini",
|
|
139
|
+
"QODER_PROJECT_DIR": "qoder",
|
|
140
|
+
"KIRO_PROJECT_DIR": "kiro",
|
|
141
|
+
"COPILOT_PROJECT_DIR": "copilot",
|
|
142
|
+
"TRAE_PROJECT_DIR": "trae",
|
|
143
|
+
}
|
|
144
|
+
for env_name, platform in env_map.items():
|
|
145
|
+
if os.environ.get(env_name):
|
|
146
|
+
return platform
|
|
147
|
+
script_parts = set(Path(sys.argv[0]).parts)
|
|
148
|
+
if ".claude" in script_parts:
|
|
149
|
+
return "claude"
|
|
150
|
+
if ".cursor" in script_parts:
|
|
151
|
+
return "cursor"
|
|
152
|
+
if ".codex" in script_parts:
|
|
153
|
+
return "codex"
|
|
154
|
+
if ".gemini" in script_parts:
|
|
155
|
+
return "gemini"
|
|
156
|
+
if ".qoder" in script_parts:
|
|
157
|
+
return "qoder"
|
|
158
|
+
if ".codebuddy" in script_parts:
|
|
159
|
+
return "codebuddy"
|
|
160
|
+
if ".factory" in script_parts:
|
|
161
|
+
return "droid"
|
|
162
|
+
if ".kiro" in script_parts:
|
|
163
|
+
return "kiro"
|
|
164
|
+
if ".trae" in script_parts:
|
|
165
|
+
return "trae"
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _resolve_active_task(root: Path, input_data: dict):
|
|
170
|
+
scripts_dir = root / ".trellis" / "scripts"
|
|
171
|
+
if str(scripts_dir) not in sys.path:
|
|
172
|
+
sys.path.insert(0, str(scripts_dir))
|
|
173
|
+
from common.active_task import resolve_active_task # type: ignore[import-not-found]
|
|
174
|
+
|
|
175
|
+
return resolve_active_task(root, input_data, platform=_detect_platform(input_data))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, str]]:
|
|
179
|
+
"""Return (task_id, status, source) from the current active task."""
|
|
180
|
+
active = _resolve_active_task(root, input_data)
|
|
181
|
+
if not active.task_path:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
task_dir = Path(active.task_path)
|
|
185
|
+
if not task_dir.is_absolute():
|
|
186
|
+
task_dir = root / task_dir
|
|
187
|
+
if active.stale:
|
|
188
|
+
return task_dir.name, f"stale_{active.source_type}", active.source
|
|
189
|
+
|
|
190
|
+
task_json = task_dir / "task.json"
|
|
191
|
+
if not task_json.is_file():
|
|
192
|
+
return None
|
|
193
|
+
try:
|
|
194
|
+
data = json.loads(task_json.read_text(encoding="utf-8"))
|
|
195
|
+
except (json.JSONDecodeError, OSError):
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
task_id = data.get("id") or task_dir.name
|
|
199
|
+
status = data.get("status", "")
|
|
200
|
+
if not isinstance(status, str) or not status:
|
|
201
|
+
return None
|
|
202
|
+
return task_id, status, active.source
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# Breadcrumb loading: parse workflow.md, fall back to hardcoded defaults
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
# Supports STATUS values with letters, digits, underscores, hyphens
|
|
210
|
+
# (so "in-review" / "blocked-by-team" work alongside "in_progress").
|
|
211
|
+
_TAG_RE = re.compile(
|
|
212
|
+
r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n(.*?)\n\s*\[/workflow-state:\1\]",
|
|
213
|
+
re.DOTALL,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def load_breadcrumbs(root: Path) -> dict[str, str]:
|
|
217
|
+
"""Parse workflow.md for [workflow-state:STATUS] blocks.
|
|
218
|
+
|
|
219
|
+
Returns {status: body_text}. workflow.md is the single source of
|
|
220
|
+
truth — there are no fallback dicts in this script. Missing tags
|
|
221
|
+
(or a missing/unreadable workflow.md) fall back to a generic line
|
|
222
|
+
in build_breadcrumb so users see the broken state and fix
|
|
223
|
+
workflow.md, rather than the hook silently masking the issue.
|
|
224
|
+
"""
|
|
225
|
+
workflow = root / ".trellis" / "workflow.md"
|
|
226
|
+
if not workflow.is_file():
|
|
227
|
+
return {}
|
|
228
|
+
try:
|
|
229
|
+
content = workflow.read_text(encoding="utf-8")
|
|
230
|
+
except OSError:
|
|
231
|
+
return {}
|
|
232
|
+
|
|
233
|
+
result: dict[str, str] = {}
|
|
234
|
+
for match in _TAG_RE.finditer(content):
|
|
235
|
+
status = match.group(1)
|
|
236
|
+
body = match.group(2).strip()
|
|
237
|
+
if body:
|
|
238
|
+
result[status] = body
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _read_trellis_config(root: Path) -> dict:
|
|
243
|
+
"""Load .trellis/config.yaml via the bundled trellis_config helper.
|
|
244
|
+
|
|
245
|
+
The helper lives in .trellis/scripts/common; the hook lives outside the
|
|
246
|
+
scripts tree, so we extend sys.path before importing.
|
|
247
|
+
"""
|
|
248
|
+
scripts_dir = root / ".trellis" / "scripts"
|
|
249
|
+
if str(scripts_dir) not in sys.path:
|
|
250
|
+
sys.path.insert(0, str(scripts_dir))
|
|
251
|
+
try:
|
|
252
|
+
from common.trellis_config import read_trellis_config # type: ignore[import-not-found]
|
|
253
|
+
except Exception:
|
|
254
|
+
return {}
|
|
255
|
+
try:
|
|
256
|
+
return read_trellis_config(root)
|
|
257
|
+
except Exception:
|
|
258
|
+
return {}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _codex_mode_banner(config: dict) -> str:
|
|
262
|
+
"""Emit a `<codex-mode>` banner for the additionalContext payload.
|
|
263
|
+
|
|
264
|
+
Reads `codex.dispatch_mode` from .trellis/config.yaml; defaults to
|
|
265
|
+
`inline` when missing or invalid because Codex sub-agents run with
|
|
266
|
+
`fork_turns="none"` isolation and can't inherit the parent session's
|
|
267
|
+
task context. The banner makes the active mode explicit to Codex AI
|
|
268
|
+
per turn, complementing the workflow-state body which is per-status.
|
|
269
|
+
Mode tells AI which dispatch protocol to follow; workflow-state tells
|
|
270
|
+
AI what step it's at.
|
|
271
|
+
"""
|
|
272
|
+
mode = "inline"
|
|
273
|
+
if isinstance(config, dict):
|
|
274
|
+
codex_cfg = config.get("codex")
|
|
275
|
+
if isinstance(codex_cfg, dict):
|
|
276
|
+
cfg_mode = codex_cfg.get("dispatch_mode")
|
|
277
|
+
if cfg_mode in ("inline", "sub-agent"):
|
|
278
|
+
mode = cfg_mode
|
|
279
|
+
if mode == "sub-agent":
|
|
280
|
+
meaning = (
|
|
281
|
+
"sub-agent: implement/check work defaults to Trellis sub-agents; "
|
|
282
|
+
"the main session still coordinates, clarifies, updates specs, commits, and finishes."
|
|
283
|
+
)
|
|
284
|
+
else:
|
|
285
|
+
meaning = (
|
|
286
|
+
"inline: the main session implements/checks directly; "
|
|
287
|
+
"do not dispatch implement/check sub-agents."
|
|
288
|
+
)
|
|
289
|
+
return f"<codex-mode>{meaning}</codex-mode>"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def resolve_breadcrumb_key(
|
|
293
|
+
status: str, platform: str | None, config: dict
|
|
294
|
+
) -> str:
|
|
295
|
+
"""Pick the breadcrumb tag key based on Codex dispatch_mode.
|
|
296
|
+
|
|
297
|
+
Codex defaults to ``inline`` because sub-agents run with ``fork_turns="none"``
|
|
298
|
+
isolation and can't inherit the parent session's task context. Users can
|
|
299
|
+
opt into ``codex.dispatch_mode: sub-agent`` in ``.trellis/config.yaml``
|
|
300
|
+
to use the parallel ``<status>-inline`` tag → ``<status>`` flip. Invalid
|
|
301
|
+
or missing values fall back to inline.
|
|
302
|
+
|
|
303
|
+
Non-codex platforms return the plain status unchanged.
|
|
304
|
+
"""
|
|
305
|
+
if platform == "codex":
|
|
306
|
+
mode = "inline"
|
|
307
|
+
if isinstance(config, dict):
|
|
308
|
+
codex_cfg = config.get("codex")
|
|
309
|
+
if isinstance(codex_cfg, dict):
|
|
310
|
+
cfg_mode = codex_cfg.get("dispatch_mode")
|
|
311
|
+
if cfg_mode in ("inline", "sub-agent"):
|
|
312
|
+
mode = cfg_mode
|
|
313
|
+
return f"{status}-inline" if mode == "inline" else status
|
|
314
|
+
return status
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def build_breadcrumb(
|
|
318
|
+
task_id: Optional[str],
|
|
319
|
+
status: str,
|
|
320
|
+
templates: dict[str, str],
|
|
321
|
+
source: str | None = None,
|
|
322
|
+
breadcrumb_key: str | None = None,
|
|
323
|
+
) -> str:
|
|
324
|
+
"""Build the <workflow-state>...</workflow-state> block.
|
|
325
|
+
|
|
326
|
+
- Known status (tag present in workflow.md) → detailed template body
|
|
327
|
+
- Unknown status (no tag, or workflow.md missing) → generic
|
|
328
|
+
"Refer to workflow.md for current step." line
|
|
329
|
+
- `no_task` pseudo-status (task_id is None) → header omits task info
|
|
330
|
+
"""
|
|
331
|
+
lookup_key = breadcrumb_key or status
|
|
332
|
+
body = templates.get(lookup_key)
|
|
333
|
+
if body is None and lookup_key != status:
|
|
334
|
+
body = templates.get(status)
|
|
335
|
+
if body is None:
|
|
336
|
+
body = "Refer to workflow.md for current step."
|
|
337
|
+
header = f"Status: {status}" if task_id is None else f"Task: {task_id} ({status})"
|
|
338
|
+
return f"<workflow-state>\n{header}\n{body}\n</workflow-state>"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
# Entry
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def _load_hook_input() -> dict:
|
|
346
|
+
"""Read hook JSON without trusting host runners to close stdin.
|
|
347
|
+
|
|
348
|
+
Kiro IDE `runCommand` and similar hook runners can leave stdin open while
|
|
349
|
+
sending no payload. A plain `json.load(sys.stdin)` then blocks forever.
|
|
350
|
+
Normal hook runners write the complete JSON payload and close stdin, so the
|
|
351
|
+
short daemon read preserves that path while failing closed to `{}` for
|
|
352
|
+
non-piping hosts.
|
|
353
|
+
"""
|
|
354
|
+
result_queue: "queue.Queue[str | BaseException]" = queue.Queue(maxsize=1)
|
|
355
|
+
|
|
356
|
+
def _read() -> None:
|
|
357
|
+
try:
|
|
358
|
+
result_queue.put(sys.stdin.read())
|
|
359
|
+
except BaseException as exc:
|
|
360
|
+
result_queue.put(exc)
|
|
361
|
+
|
|
362
|
+
reader = threading.Thread(target=_read, daemon=True)
|
|
363
|
+
reader.start()
|
|
364
|
+
try:
|
|
365
|
+
raw = result_queue.get(timeout=0.2)
|
|
366
|
+
except queue.Empty:
|
|
367
|
+
return {}
|
|
368
|
+
|
|
369
|
+
if isinstance(raw, BaseException):
|
|
370
|
+
return {}
|
|
371
|
+
try:
|
|
372
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
373
|
+
except (json.JSONDecodeError, ValueError):
|
|
374
|
+
return {}
|
|
375
|
+
return data if isinstance(data, dict) else {}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def main() -> int:
|
|
379
|
+
if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1":
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
data = _load_hook_input()
|
|
383
|
+
|
|
384
|
+
cwd_str = data.get("cwd") or os.getcwd()
|
|
385
|
+
cwd = Path(cwd_str)
|
|
386
|
+
|
|
387
|
+
root = find_trellis_root(cwd)
|
|
388
|
+
if root is None:
|
|
389
|
+
return 0 # not a Trellis project
|
|
390
|
+
|
|
391
|
+
templates = load_breadcrumbs(root)
|
|
392
|
+
platform = _detect_platform(data)
|
|
393
|
+
config = _read_trellis_config(root)
|
|
394
|
+
task = get_active_task(root, data)
|
|
395
|
+
if task is None:
|
|
396
|
+
# No active task — still emit a breadcrumb nudging AI toward
|
|
397
|
+
# trellis-brainstorm + task.py create when user describes real work.
|
|
398
|
+
no_task_key = resolve_breadcrumb_key("no_task", platform, config)
|
|
399
|
+
breadcrumb = build_breadcrumb(
|
|
400
|
+
None, "no_task", templates, breadcrumb_key=no_task_key
|
|
401
|
+
)
|
|
402
|
+
else:
|
|
403
|
+
task_id, status, source = task
|
|
404
|
+
status_key = resolve_breadcrumb_key(status, platform, config)
|
|
405
|
+
source_for_breadcrumb = None if platform == "codex" else source
|
|
406
|
+
breadcrumb = build_breadcrumb(
|
|
407
|
+
task_id, status, templates, source_for_breadcrumb, breadcrumb_key=status_key
|
|
408
|
+
)
|
|
409
|
+
if platform == "codex":
|
|
410
|
+
parts: list[str] = []
|
|
411
|
+
if task is None and not _codex_has_trellis_session_start(root):
|
|
412
|
+
parts.append(CODEX_NO_TASK_BOOTSTRAP_NOTICE)
|
|
413
|
+
parts.append(_codex_mode_banner(config))
|
|
414
|
+
parts.append(breadcrumb)
|
|
415
|
+
breadcrumb = "\n\n".join(parts)
|
|
416
|
+
|
|
417
|
+
# Kiro (CLI userPromptSubmit / IDE promptSubmit) adds a hook's stdout
|
|
418
|
+
# directly to the conversation context — no JSON envelope. Emit the bare
|
|
419
|
+
# breadcrumb text. Conditionally isolated: all other platforms keep the
|
|
420
|
+
# hookSpecificOutput JSON path below unchanged.
|
|
421
|
+
if platform == "kiro":
|
|
422
|
+
print(breadcrumb)
|
|
423
|
+
return 0
|
|
424
|
+
|
|
425
|
+
# Gemini CLI 0.40.x rejects "UserPromptSubmit" — its per-turn event is
|
|
426
|
+
# named "BeforeAgent". Other platforms (Claude/Cursor/Qoder/CodeBuddy/
|
|
427
|
+
# Droid/Codex/Copilot) accept the original Claude-style name.
|
|
428
|
+
hook_event_name = (
|
|
429
|
+
"BeforeAgent" if platform == "gemini" else "UserPromptSubmit"
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
output = {
|
|
433
|
+
"hookSpecificOutput": {
|
|
434
|
+
"hookEventName": hook_event_name,
|
|
435
|
+
"additionalContext": breadcrumb,
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
print(json.dumps(output))
|
|
439
|
+
return 0
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
if __name__ == "__main__":
|
|
443
|
+
sys.exit(main())
|
|
@@ -3,11 +3,12 @@ HIGHEST PRIORITY SKILL-GARDEN STATE GUARD (in_progress-inline):
|
|
|
3
3
|
Hub is source of truth for Task Brief, Routing, Post-Check, Commit, and Snapshot gates.
|
|
4
4
|
Before first implement route, restate `<task>/brief.md`; if missing, read artifacts and suggest backfill.
|
|
5
5
|
New work not plainly covered by active task title/brief: stop before route/edits; recommend new task; if declined, confirm untracked work; if it belongs here, update artifacts first.
|
|
6
|
-
At project-local knowledge boundaries, run `spec_router.py`; skip trivial/read-only turns unless local conventions may affect the approach.
|
|
6
|
+
At project-local knowledge boundaries, run `python3 ./.trellis/scripts/spec_router.py "<intended action>"`; read high-confidence matches before acting; read medium-confidence matches only when clearly relevant; skip trivial/read-only turns unless local conventions may affect the approach.
|
|
7
7
|
Inline workflow-state is not an inline route decision. Phase 2.1/2.2 must reuse explicit target-matched `route_decision`; otherwise invoke `trellis-route`. If unavailable, read local `trellis-route/SKILL.md`, show numbered choices, and wait.
|
|
8
8
|
Summaries, preferences, `codex-mode`, raw `.runtime`, and empty/stale prefs are not route evidence unless `trellis-route` validates them; user reselect/override wins.
|
|
9
9
|
Ignore lower direct-edit/check shortcuts. Do not default inline just because this state is inline or helper is unavailable. Dispatch subagents only when route selected subagent.
|
|
10
|
-
After check/check-all
|
|
11
|
-
|
|
10
|
+
After `trellis-check` / `trellis-check-all`, stop and report; point the user to Phase 3.4 `trellis-push` (or commit-only when needed). Do not run `/trellis:finish-work` unless the user explicitly asks after Phase 3.4 is complete.
|
|
11
|
+
This guard overrides any lower `Flow: ... -> /trellis:finish-work` line in this state block.
|
|
12
|
+
At Phase 3.4, code commit/push goes through `trellis-push` (commit-only mode for commit-without-push); never bare `git commit`/`git push` on code (hub: Code Commit Confirmation Gate).
|
|
12
13
|
Push snapshot recovery: follow the hub; use `push_snapshot.py status --json` only when needed.
|
|
13
14
|
<!-- END skill-garden workflow-state in_progress_inline v0.6 -->
|
|
@@ -3,11 +3,12 @@ HIGHEST PRIORITY SKILL-GARDEN STATE GUARD (in_progress):
|
|
|
3
3
|
Hub is source of truth for Task Brief, Routing, Post-Check, Commit, and Snapshot gates.
|
|
4
4
|
Before first implement route, restate `<task>/brief.md`; if missing, read artifacts and suggest backfill.
|
|
5
5
|
New work not plainly covered by active task title/brief: stop before route/edits; recommend new task; if declined, confirm untracked work; if it belongs here, update artifacts first.
|
|
6
|
-
At project-local knowledge boundaries, run `spec_router.py`; skip trivial/read-only turns unless local conventions may affect the approach.
|
|
6
|
+
At project-local knowledge boundaries, run `python3 ./.trellis/scripts/spec_router.py "<intended action>"`; read high-confidence matches before acting; read medium-confidence matches only when clearly relevant; skip trivial/read-only turns unless local conventions may affect the approach.
|
|
7
7
|
Phase 2.1/2.2: reuse only explicit target-matched `route_decision`; otherwise invoke `trellis-route`. If skill invocation is unavailable, read local `trellis-route/SKILL.md`, show numbered choices, and wait.
|
|
8
8
|
Summaries, preferences, `codex-mode`, raw `.runtime`, and empty/stale prefs are not route evidence unless `trellis-route` validates them; user reselect/override wins.
|
|
9
9
|
Ignore lower direct-dispatch shortcuts. Do not spawn `trellis-implement` or `trellis-check*` unless route selected subagent. If route cannot be resolved, do not default inline.
|
|
10
|
-
After check/check-all
|
|
11
|
-
|
|
10
|
+
After `trellis-check` / `trellis-check-all`, stop and report; point the user to Phase 3.4 `trellis-push` (or commit-only when needed). Do not run `/trellis:finish-work` unless the user explicitly asks after Phase 3.4 is complete.
|
|
11
|
+
This guard overrides any lower `Flow: ... -> /trellis:finish-work` line in this state block.
|
|
12
|
+
At Phase 3.4, code commit/push goes through `trellis-push` (commit-only mode for commit-without-push); never bare `git commit`/`git push` on code (hub: Code Commit Confirmation Gate).
|
|
12
13
|
Push snapshot recovery: follow the hub; use `push_snapshot.py status --json` only when needed.
|
|
13
14
|
<!-- END skill-garden workflow-state in_progress v0.6 -->
|
|
@@ -3,7 +3,7 @@ HIGHEST PRIORITY SKILL-GARDEN STATE GUARD (no_task):
|
|
|
3
3
|
Creating or resuming a task is not implementation permission.
|
|
4
4
|
After PRD is ready and the task is started, the next implementation action is Phase 2.1 `trellis-route(implement)` unless a valid current-task implement route decision already exists.
|
|
5
5
|
If no active task exists, use `push_snapshot.py status --json` once per session; if it returns candidates, relay them and suggest rebinding before resuming.
|
|
6
|
-
At project-local knowledge boundaries, run `spec_router.py`; skip trivial/read-only turns unless local conventions may affect the approach.
|
|
6
|
+
At project-local knowledge boundaries, run `python3 ./.trellis/scripts/spec_router.py "<intended action>"`; read high-confidence matches before acting; read medium-confidence matches only when clearly relevant; skip trivial/read-only turns unless local conventions may affect the approach.
|
|
7
7
|
Do NOT call the harness built-in plan mode (`EnterPlanMode` / `ExitPlanMode`) for Trellis planning. It is not a substitute for Trellis task-creation consent, Trellis planning, or the route gate. For new, complex, or unclear work, classify the turn, ask for task-creation consent, then use `trellis-brainstorm`; `task.py create` and the default `prd.md` are not sufficient planning.
|
|
8
8
|
For lightweight Trellis meta edits, ask/confirm skipping Trellis tracking before edits.
|
|
9
9
|
<!-- END skill-garden workflow-state no_task v0.6 -->
|
|
@@ -6,6 +6,6 @@ A created task or existing `prd.md` is not enough to start implementation.
|
|
|
6
6
|
Complete prd.md + required context first.
|
|
7
7
|
If the active workflow later routes to sub-agent execution, required context includes real curated entries in both `implement.jsonl` and `check.jsonl`; the seed `_example` row alone is not ready.
|
|
8
8
|
Before `task.py start`, use `trellis-task-brief` to refresh `brief.md` from the latest task artifacts and display it in chat for review.
|
|
9
|
-
At project-local knowledge boundaries, run `spec_router.py`; skip trivial/read-only turns unless local conventions may affect the approach.
|
|
9
|
+
At project-local knowledge boundaries, run `python3 ./.trellis/scripts/spec_router.py "<intended action>"`; read high-confidence matches before acting; read medium-confidence matches only when clearly relevant; skip trivial/read-only turns unless local conventions may affect the approach.
|
|
10
10
|
After status becomes in_progress, next action = `trellis-route(implement)`, not direct edits.
|
|
11
11
|
<!-- END skill-garden workflow-state planning_inline v0.6 -->
|
|
@@ -6,6 +6,6 @@ A created task or existing `prd.md` is not enough to start implementation.
|
|
|
6
6
|
Complete prd.md + required context first.
|
|
7
7
|
For sub-agent-dispatch platforms, required context includes real curated entries in both `implement.jsonl` and `check.jsonl`; the seed `_example` row alone is not ready.
|
|
8
8
|
Before `task.py start`, use `trellis-task-brief` to refresh `brief.md` from the latest task artifacts and display it in chat for review.
|
|
9
|
-
At project-local knowledge boundaries, run `spec_router.py`; skip trivial/read-only turns unless local conventions may affect the approach.
|
|
9
|
+
At project-local knowledge boundaries, run `python3 ./.trellis/scripts/spec_router.py "<intended action>"`; read high-confidence matches before acting; read medium-confidence matches only when clearly relevant; skip trivial/read-only turns unless local conventions may affect the approach.
|
|
10
10
|
After status becomes in_progress, next action = `trellis-route(implement)`, not direct edits.
|
|
11
11
|
<!-- END skill-garden workflow-state planning v0.6 -->
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"syncedAt": "2026-07-
|
|
2
|
+
"syncedAt": "2026-07-08T02:50:48.728Z",
|
|
3
3
|
"syncedFrom": "vendor/skill-garden",
|
|
4
|
-
"sourceCommit": "
|
|
4
|
+
"sourceCommit": "6ecfb1b46320d82aeb3e83442235f271d92aa398",
|
|
5
5
|
"common": {
|
|
6
6
|
"codexSkills": [
|
|
7
7
|
"craft-rpa",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"overrides": [],
|
|
51
51
|
"workflowStates": [],
|
|
52
52
|
"skillOverrides": [],
|
|
53
|
+
"hookOverrides": [],
|
|
53
54
|
"scripts": []
|
|
54
55
|
},
|
|
55
56
|
"0.5": {
|
|
@@ -89,6 +90,7 @@
|
|
|
89
90
|
],
|
|
90
91
|
"workflowStates": [],
|
|
91
92
|
"skillOverrides": [],
|
|
93
|
+
"hookOverrides": [],
|
|
92
94
|
"scripts": []
|
|
93
95
|
},
|
|
94
96
|
"0.6": {
|
|
@@ -136,6 +138,9 @@
|
|
|
136
138
|
"skillOverrides": [
|
|
137
139
|
"trellis-finish-work.md"
|
|
138
140
|
],
|
|
141
|
+
"hookOverrides": [
|
|
142
|
+
"shared/inject-workflow-state.py"
|
|
143
|
+
],
|
|
139
144
|
"scripts": [
|
|
140
145
|
"auto_loop.py",
|
|
141
146
|
"push_snapshot.py",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flower-trellis",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "一键安装/升级 Trellis 并自动融合 skill-garden 强化包(默认 Claude + agents)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,9 +58,9 @@
|
|
|
58
58
|
"commit-and-tag-version": "^12.7.3"
|
|
59
59
|
},
|
|
60
60
|
"flowerReleaseNotes": {
|
|
61
|
-
"version": "0.4.
|
|
61
|
+
"version": "0.4.9",
|
|
62
62
|
"source": "CHANGELOG.md",
|
|
63
|
-
"body": "### 🐛 修复 Bug Fixes\n\n* **flower:**
|
|
63
|
+
"body": "### 🐛 修复 Bug Fixes\n\n* **flower:** 强化项目知识发现门禁 ([d21b17e](https://github.com/SilentFlower/flower-trellis/commit/d21b17e860d3b91bb5c69c3921620d60b49608d5))\n* **flower:** 恢复 in_progress 完成门禁 ([304f5fa](https://github.com/SilentFlower/flower-trellis/commit/304f5fadfab139638cfa9658f168448a1f26f9e6))",
|
|
64
64
|
"truncated": false
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -80,6 +80,15 @@ def _json_bool(value: object) -> str:
|
|
|
80
80
|
return json.dumps(bool(value), ensure_ascii=False)
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def _has_release_notes(data: dict) -> bool:
|
|
84
|
+
"""判断 self-check 是否提供了可展示的 release notes。"""
|
|
85
|
+
notes = data.get("releaseNotes")
|
|
86
|
+
if not isinstance(notes, dict):
|
|
87
|
+
return False
|
|
88
|
+
versions = notes.get("versions")
|
|
89
|
+
return isinstance(versions, list) and bool(versions)
|
|
90
|
+
|
|
91
|
+
|
|
83
92
|
def _release_notes_lines(data: dict) -> list[str]:
|
|
84
93
|
"""把 self-check 的 releaseNotes 摘要格式化为短字段。"""
|
|
85
94
|
notes = data.get("releaseNotes")
|
|
@@ -109,8 +118,6 @@ def _release_notes_lines(data: dict) -> list[str]:
|
|
|
109
118
|
range_text = f"{note_range.get('from')} -> {note_range.get('to')}"
|
|
110
119
|
if note_range.get("channel"):
|
|
111
120
|
range_text += f" ({note_range.get('channel')})"
|
|
112
|
-
if note_range.get("reason"):
|
|
113
|
-
range_text += f" reason={note_range.get('reason')}"
|
|
114
121
|
lines.append(f"release_notes_range: {range_text}")
|
|
115
122
|
if safe_versions:
|
|
116
123
|
lines.append(
|
|
@@ -119,11 +126,24 @@ def _release_notes_lines(data: dict) -> list[str]:
|
|
|
119
126
|
)
|
|
120
127
|
elif notes.get("unavailable"):
|
|
121
128
|
lines.append("release_notes_unavailable: true")
|
|
122
|
-
|
|
123
|
-
|
|
129
|
+
if notes.get("truncated"):
|
|
130
|
+
lines.append(f"release_notes_truncated: {_json_bool(notes.get('truncated'))}")
|
|
131
|
+
if notes.get("moreVersions"):
|
|
132
|
+
lines.append(f"release_notes_more_versions: {_json_bool(notes.get('moreVersions'))}")
|
|
124
133
|
return lines
|
|
125
134
|
|
|
126
135
|
|
|
136
|
+
def _ai_instruction(data: dict, command: str | None) -> str | None:
|
|
137
|
+
"""生成面向 AI 的单条动作指令,避免重复字段互相打架。"""
|
|
138
|
+
ai = data.get("ai") or {}
|
|
139
|
+
instruction = ai.get("instruction")
|
|
140
|
+
if ai.get("mode") == "ask" and command:
|
|
141
|
+
if _has_release_notes(data):
|
|
142
|
+
return "先展示 release_notes 摘要和 recommended_command,再询问用户确认;确认前禁止执行 recommended_command。"
|
|
143
|
+
return "先展示 recommended_command,再询问用户确认;确认前禁止执行 recommended_command。"
|
|
144
|
+
return instruction if isinstance(instruction, str) and instruction else None
|
|
145
|
+
|
|
146
|
+
|
|
127
147
|
def _format_context(data: dict) -> str:
|
|
128
148
|
"""把 self-check JSON 转成给 AI 读取的短上下文块。"""
|
|
129
149
|
current = data.get("current") or {}
|
|
@@ -132,22 +152,22 @@ def _format_context(data: dict) -> str:
|
|
|
132
152
|
ai = data.get("ai") or {}
|
|
133
153
|
safety = data.get("safety") or {}
|
|
134
154
|
out_of_sync_reasons = project.get("outOfSyncReasons") or []
|
|
155
|
+
current_trellis = current.get("bundledTrellisVersion")
|
|
156
|
+
project_trellis = project.get("trellisVersion")
|
|
135
157
|
lines = [
|
|
136
158
|
"<flower-update>",
|
|
137
159
|
"priority: blocking_confirmation_required",
|
|
138
160
|
"instruction_scope: first_assistant_reply",
|
|
139
161
|
f"status: {data.get('status')}",
|
|
140
|
-
f"policy: {data.get('policy')}",
|
|
141
162
|
f"current_flower: {current.get('flowerVersion')}",
|
|
142
163
|
f"project_flower: {project.get('flowerVersion')}",
|
|
143
|
-
f"bundled_trellis: {current.get('bundledTrellisVersion')}",
|
|
144
|
-
f"project_trellis: {project.get('trellisVersion')}",
|
|
145
164
|
]
|
|
146
|
-
if
|
|
147
|
-
lines.append(f"
|
|
165
|
+
if current_trellis and project_trellis and current_trellis != project_trellis:
|
|
166
|
+
lines.append(f"bundled_trellis: {current_trellis}")
|
|
167
|
+
lines.append(f"project_trellis: {project_trellis}")
|
|
148
168
|
if out_of_sync_reasons:
|
|
149
169
|
lines.append(f"project_out_of_sync_reasons: {', '.join(out_of_sync_reasons)}")
|
|
150
|
-
if remote.get("tags"):
|
|
170
|
+
if data.get("status") == "update_available" and remote.get("tags"):
|
|
151
171
|
lines.append(f"remote: {json.dumps(remote.get('tags'), ensure_ascii=False)}")
|
|
152
172
|
if remote.get("errorCode"):
|
|
153
173
|
lines.append(f"remote_error_code: {remote.get('errorCode')}")
|
|
@@ -157,14 +177,9 @@ def _format_context(data: dict) -> str:
|
|
|
157
177
|
lines.append(f"recommended_command: {command}")
|
|
158
178
|
if safety.get("reasons"):
|
|
159
179
|
lines.append(f"safety_reasons: {', '.join(safety.get('reasons') or [])}")
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
lines.append(
|
|
164
|
-
"ai_required_action: 必须先向用户展示 release_notes 摘要和 recommended_command,再提出明确确认问题;用户确认前禁止执行 recommended_command。"
|
|
165
|
-
)
|
|
166
|
-
if ai.get("instruction"):
|
|
167
|
-
lines.append(f"ai_instruction: {ai.get('instruction')}")
|
|
180
|
+
instruction = _ai_instruction(data, command)
|
|
181
|
+
if instruction:
|
|
182
|
+
lines.append(f"ai_instruction: {instruction}")
|
|
168
183
|
lines.append("</flower-update>")
|
|
169
184
|
return "\n".join(lines)
|
|
170
185
|
|
|
@@ -173,10 +188,8 @@ def _system_message(data: dict) -> str:
|
|
|
173
188
|
"""生成 Codex / Claude Code 更容易注意到的短系统提示。"""
|
|
174
189
|
ai = data.get("ai") or {}
|
|
175
190
|
command = (data.get("commands") or {}).get("recommended") or ai.get("command")
|
|
176
|
-
notes = data.get("releaseNotes") or {}
|
|
177
|
-
has_notes = isinstance(notes, dict) and bool(notes.get("versions"))
|
|
178
191
|
if ai.get("mode") == "ask" and command:
|
|
179
|
-
if
|
|
192
|
+
if _has_release_notes(data):
|
|
180
193
|
return "flower-trellis 发现可执行更新;必须先展示更新摘要并询问用户是否执行 recommended_command,确认前禁止运行。"
|
|
181
194
|
return "flower-trellis 发现可执行更新;必须先询问用户是否执行 recommended_command,确认前禁止运行。"
|
|
182
195
|
if command:
|
|
@@ -4,6 +4,7 @@ import { copySkills } from "./copy-skills.js";
|
|
|
4
4
|
import { copyScriptAssets } from "./copy-scripts.js";
|
|
5
5
|
import { injectWorkflow } from "./workflow-inject.js";
|
|
6
6
|
import { injectSkillOverrides } from "./skill-override-inject.js";
|
|
7
|
+
import { injectHookOverrides } from "./hook-override-inject.js";
|
|
7
8
|
import { applyCodexTweaks } from "./codex-tweaks.js";
|
|
8
9
|
import { applyClaudeTweaks } from "./claude-tweaks.js";
|
|
9
10
|
import { copyFlowerAssets } from "./flower-assets.js";
|
|
@@ -33,7 +34,7 @@ function pruneEmptyDirs(target) {
|
|
|
33
34
|
* 叠加强化包 —— init / update 共享。
|
|
34
35
|
*
|
|
35
36
|
* 流程:校验是 Trellis 项目 → 选变体 → 铺 skill → 升级清理(删过期)
|
|
36
|
-
* → 注入 workflow → 注入 skill override。
|
|
37
|
+
* → 注入 workflow → 注入 skill override → 注入 hook override。
|
|
37
38
|
*
|
|
38
39
|
* 升级清理:用 flower manifest 记录上次全装铺过的精确路径,本次全装时删除
|
|
39
40
|
* 「上次有、这次变体不含」的过期项(覆盖 0.5/old → 0.6 升级)。仅全装(无 --skills)
|
|
@@ -145,6 +146,21 @@ export function applyEnhancements(target, opts = {}) {
|
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
if (skills.length === 0) {
|
|
150
|
+
const r = injectHookOverrides(target, variantDir);
|
|
151
|
+
if (r.skipped) {
|
|
152
|
+
console.log(` · hook override 注入跳过(${r.reason})`);
|
|
153
|
+
} else if (r.changed === 0 && r.unchanged === 0) {
|
|
154
|
+
console.log(` · hook override 注入跳过(目标缺少可覆盖的 hook)`);
|
|
155
|
+
} else if (r.changed === 0) {
|
|
156
|
+
const note = r.backupNotes.length ? r.backupNotes.join("") : "";
|
|
157
|
+
console.log(` ✓ hook override 已是最新(${r.unchanged} 个入口)${note}`);
|
|
158
|
+
} else {
|
|
159
|
+
const note = r.backupNotes.length ? r.backupNotes.join("") : "";
|
|
160
|
+
console.log(` ✓ hook override 已注入 ${r.changed} 个入口${note}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
148
164
|
// codex 平台后处理:旧 multi_agent_v2 兼容清理 + 合并 SessionStart hook + 强制 sub-agent 调度
|
|
149
165
|
const codex = applyCodexTweaks(target);
|
|
150
166
|
if (codex.applied) {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { listFiles } from "./fs-utils.js";
|
|
4
|
+
import { preserveFirstBackup } from "./backup.js";
|
|
5
|
+
|
|
6
|
+
const SHARED_HOOK_TARGETS = {
|
|
7
|
+
"inject-workflow-state.py": [
|
|
8
|
+
".codex/hooks/inject-workflow-state.py",
|
|
9
|
+
".claude/hooks/inject-workflow-state.py",
|
|
10
|
+
],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function toDisplayPath(value) {
|
|
14
|
+
return value.split(path.sep).join("/");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 对单个目标 hook 文件应用 shared hook override。 */
|
|
18
|
+
function applyHookOverride(target, sourceFile, targetRel) {
|
|
19
|
+
const targetFile = path.join(target, ...targetRel.split("/"));
|
|
20
|
+
if (!fs.existsSync(targetFile)) {
|
|
21
|
+
return { status: "missing", target: targetRel };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const current = fs.readFileSync(targetFile, "utf8");
|
|
25
|
+
const next = fs.readFileSync(sourceFile, "utf8");
|
|
26
|
+
if (current === next) {
|
|
27
|
+
return { status: "unchanged", target: targetRel };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { backupNote } = preserveFirstBackup(target, targetFile);
|
|
31
|
+
fs.writeFileSync(targetFile, next);
|
|
32
|
+
return { status: "changed", target: targetRel, backupNote };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 注入 skill-garden 的 shared hook override。
|
|
37
|
+
*
|
|
38
|
+
* override 源放在 `overrides/hooks/shared/<file>`,只覆盖目标项目已有的平台 hook 文件,
|
|
39
|
+
* 不创建未启用的平台目录。hook override 是对 Trellis 原生 hook 的覆盖,不写入
|
|
40
|
+
* `.flower-manifest.json` 的 paths,避免升级清理误删上游 hook。
|
|
41
|
+
*
|
|
42
|
+
* @param {string} target 目标项目根
|
|
43
|
+
* @param {string} variantDir 该变体在 enhancements/ 下的目录
|
|
44
|
+
* @returns {{skipped?:boolean,reason?:string,changed:number,unchanged:number,missing:number,unmapped:number,targets:string[],backupNotes:string[]}}
|
|
45
|
+
*/
|
|
46
|
+
export function injectHookOverrides(target, variantDir) {
|
|
47
|
+
const srcDir = path.join(variantDir, "overrides", "hooks", "shared");
|
|
48
|
+
const files = listFiles(srcDir);
|
|
49
|
+
if (files.length === 0) {
|
|
50
|
+
return {
|
|
51
|
+
skipped: true,
|
|
52
|
+
reason: "该变体无 hook override",
|
|
53
|
+
changed: 0,
|
|
54
|
+
unchanged: 0,
|
|
55
|
+
missing: 0,
|
|
56
|
+
unmapped: 0,
|
|
57
|
+
targets: [],
|
|
58
|
+
backupNotes: [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let changed = 0;
|
|
63
|
+
let unchanged = 0;
|
|
64
|
+
let missing = 0;
|
|
65
|
+
let unmapped = 0;
|
|
66
|
+
const targets = [];
|
|
67
|
+
const backupNotes = new Set();
|
|
68
|
+
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
const targetRels = SHARED_HOOK_TARGETS[file];
|
|
71
|
+
if (!targetRels) {
|
|
72
|
+
unmapped++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const sourceFile = path.join(srcDir, file);
|
|
76
|
+
for (const targetRel of targetRels) {
|
|
77
|
+
const result = applyHookOverride(target, sourceFile, targetRel);
|
|
78
|
+
if (result.status === "changed") changed++;
|
|
79
|
+
else if (result.status === "unchanged") unchanged++;
|
|
80
|
+
else missing++;
|
|
81
|
+
if (result.status !== "missing") targets.push(toDisplayPath(result.target));
|
|
82
|
+
if (result.backupNote) backupNotes.add(result.backupNote);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
changed,
|
|
88
|
+
unchanged,
|
|
89
|
+
missing,
|
|
90
|
+
unmapped,
|
|
91
|
+
targets,
|
|
92
|
+
backupNotes: [...backupNotes],
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/lib/self-check.js
CHANGED
|
@@ -55,12 +55,22 @@ function cachedReleaseNotes(updateCheck, range) {
|
|
|
55
55
|
if (
|
|
56
56
|
cachedRange.from !== range.from ||
|
|
57
57
|
cachedRange.to !== range.to ||
|
|
58
|
-
cachedRange.channel !== range.channel
|
|
59
|
-
cachedRange.reason !== range.reason
|
|
58
|
+
cachedRange.channel !== range.channel
|
|
60
59
|
) {
|
|
61
60
|
return null;
|
|
62
61
|
}
|
|
63
|
-
|
|
62
|
+
if (!Array.isArray(cached.versions) || !cached.versions.length) return null;
|
|
63
|
+
// 同一版本范围的内容相同;reason 只是触发路径,不能让项目追平场景丢失摘要。
|
|
64
|
+
return {
|
|
65
|
+
...cached,
|
|
66
|
+
range: {
|
|
67
|
+
...cachedRange,
|
|
68
|
+
from: range.from,
|
|
69
|
+
to: range.to,
|
|
70
|
+
channel: range.channel,
|
|
71
|
+
reason: range.reason,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
/**
|