flower-trellis 0.5.0-beta.2 → 0.5.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/enhancements/0.6/overrides/bundles/intent-routing.json +5 -0
  2. package/enhancements/0.6/overrides/patches/hooks/claude-session-start/missing-task-routing/content.py +6 -0
  3. package/enhancements/0.6/overrides/patches/hooks/claude-session-start/missing-task-routing/patch.json +13 -0
  4. package/enhancements/0.6/overrides/patches/hooks/claude-session-start/missing-task-routing/selector.py +5 -0
  5. package/enhancements/0.6/overrides/patches/hooks/codex-session-start/missing-task-routing/content.py +6 -0
  6. package/enhancements/0.6/overrides/patches/hooks/codex-session-start/missing-task-routing/patch.json +13 -0
  7. package/enhancements/0.6/overrides/patches/hooks/codex-session-start/missing-task-routing/selector.py +4 -0
  8. package/enhancements/0.6/overrides/patches/hooks/inject-workflow-state/shared-runtime/baseline-flower-beta2.py +443 -0
  9. package/enhancements/0.6/overrides/patches/hooks/inject-workflow-state/shared-runtime/baseline-flower-stale-task.py +443 -0
  10. package/enhancements/0.6/overrides/patches/hooks/inject-workflow-state/shared-runtime/content.py +6 -6
  11. package/enhancements/0.6/overrides/patches/hooks/inject-workflow-state/shared-runtime/patch.json +9 -2
  12. package/enhancements/0.6/overrides/patches/workflow/runtime-contract-reference/patch.json +21 -0
  13. package/enhancements/0.6/overrides/patches/workflow/runtime-contract-reference/runtime-reference-content.md +4 -0
  14. package/enhancements/0.6/overrides/patches/workflow/runtime-contract-reference/runtime-reference-selector.md +4 -0
  15. package/enhancements/0.6/overrides/patches/workflow/runtime-contract-reference/state-contract-comment-content.md +46 -0
  16. package/enhancements/0.6/overrides/patches/workflow/runtime-contract-reference/state-contract-comment-selector.md +44 -0
  17. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/active-task-clear-fallback-content.py +28 -0
  18. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/active-task-clear-fallback-selector.py +15 -0
  19. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/content.md +5 -0
  20. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/patch.json +28 -0
  21. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/selector.md +1 -0
  22. package/enhancements/MANIFEST.json +20 -2
  23. package/package.json +3 -3
@@ -7,6 +7,8 @@
7
7
  "workflow/hub",
8
8
  "workflow/phase-ownership",
9
9
  "workflow/state-no-task",
10
+ "workflow/state-missing-task",
11
+ "workflow/runtime-contract-reference",
10
12
  "workflow/states-planning",
11
13
  "workflow/states-in-progress",
12
14
  "workflow/intent-routing/phase-summary",
@@ -16,8 +18,11 @@
16
18
  "workflow/intent-routing/create-task-rule",
17
19
  "workflow/intent-routing/create-task-command",
18
20
  "workflow/intent-routing/customization-intent-invariant",
21
+ "hooks/inject-workflow-state/shared-runtime",
19
22
  "hooks/codex-session-start/no-task-routing",
23
+ "hooks/codex-session-start/missing-task-routing",
20
24
  "hooks/claude-session-start/no-task-routing",
25
+ "hooks/claude-session-start/missing-task-routing",
21
26
  "skills/trellis-start/no-task-routing",
22
27
  "skills/trellis-brainstorm/planning-authorization",
23
28
  "skills/trellis-brainstorm/auto-task-create"
@@ -0,0 +1,6 @@
1
+ return (
2
+ f"Status: MISSING TASK POINTER\nTask: {task_ref}\n"
3
+ "Next-Action: Run `python3 ./.trellis/scripts/task.py finish`. If cleanup fails, report it "
4
+ "and stop. If cleanup succeeds, treat the current request as NO ACTIVE TASK in the same "
5
+ "turn and follow no_task Request Intent Routing before any edit, task creation, or task start."
6
+ )
@@ -0,0 +1,13 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "id": "claude-session-start-missing-task",
4
+ "purpose": "intent_routing",
5
+ "operations": [{
6
+ "id": "claude-session-start-missing-task",
7
+ "operation": "replace",
8
+ "targets": [{ "kind": "hook", "path": ".claude/hooks/session-start.py", "missing": "skip", "markerStyle": "hash" }],
9
+ "selector": { "type": "literal", "source": "selector.py" },
10
+ "content": { "source": "content.py" },
11
+ "legacyMarkers": [{ "namespace": "patch", "id": "claude-session-start-stale-task" }]
12
+ }]
13
+ }
@@ -0,0 +1,5 @@
1
+ return (
2
+ f"Status: STALE POINTER\nTask: {task_ref}\n"
3
+ f"Next-Action: Run `python3 ./.trellis/scripts/task.py finish` to clear the stale pointer, "
4
+ "then ask the user what to work on next."
5
+ )
@@ -0,0 +1,6 @@
1
+ return (
2
+ f"Status: MISSING TASK POINTER\nTask: {task_ref}\n"
3
+ "Next: Run python3 ./.trellis/scripts/task.py finish. If cleanup fails, report it and stop. "
4
+ "If cleanup succeeds, treat the current request as NO ACTIVE TASK in the same turn and "
5
+ "follow no_task Request Intent Routing before any edit, task creation, or task start."
6
+ )
@@ -0,0 +1,13 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "id": "codex-session-start-missing-task",
4
+ "purpose": "intent_routing",
5
+ "operations": [{
6
+ "id": "codex-session-start-missing-task",
7
+ "operation": "replace",
8
+ "targets": [{ "kind": "hook", "path": ".codex/hooks/session-start.py", "missing": "skip", "markerStyle": "hash" }],
9
+ "selector": { "type": "literal", "source": "selector.py" },
10
+ "content": { "source": "content.py" },
11
+ "legacyMarkers": [{ "namespace": "patch", "id": "codex-session-start-stale-task" }]
12
+ }]
13
+ }
@@ -0,0 +1,4 @@
1
+ return (
2
+ f"Status: STALE POINTER\nTask: {task_ref}\n"
3
+ "Next: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish"
4
+ )
@@ -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())
@@ -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 platforms with a per-turn workflow-state hook (Claude, Codex,
21
+ Gemini, Qoder, Copilot, CodeBuddy, Droid, Kiro, Trae). Kiro wires this via the
22
+ CLI 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 each
25
+ 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, "stale_task", 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())
@@ -17,12 +17,12 @@ missing or a tag is absent, the breadcrumb degrades to a generic
17
17
  "Refer to workflow.md for current step." line so users see (and fix)
18
18
  the broken state instead of the hook silently masking it.
19
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``
20
+ Shared across platforms with a per-turn workflow-state hook (Claude, Codex,
21
+ Gemini, Qoder, Copilot, CodeBuddy, Droid, Kiro, Trae). Kiro wires this via the
22
+ CLI custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook``
23
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.
24
+ (Kiro adds hook stdout directly to the conversation context). Written to each
25
+ platform's hooks directory via writeSharedHooks() at init time.
26
26
 
27
27
  Silent exit 0 cases (no output):
28
28
  - No .trellis/ directory found (not a Trellis project)
@@ -185,7 +185,7 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st
185
185
  if not task_dir.is_absolute():
186
186
  task_dir = root / task_dir
187
187
  if active.stale:
188
- return task_dir.name, f"stale_{active.source_type}", active.source
188
+ return task_dir.name, "missing_task", active.source
189
189
 
190
190
  task_json = task_dir / "task.json"
191
191
  if not task_json.is_file():
@@ -8,10 +8,17 @@
8
8
  "targetPolicy": "each-existing",
9
9
  "targets": [
10
10
  { "kind": "file", "path": ".codex/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "none" },
11
- { "kind": "file", "path": ".claude/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "none" }
11
+ { "kind": "file", "path": ".claude/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "none" },
12
+ { "kind": "file", "path": ".gemini/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "none" },
13
+ { "kind": "file", "path": ".qoder/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "none" },
14
+ { "kind": "file", "path": ".github/copilot/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "none" },
15
+ { "kind": "file", "path": ".codebuddy/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "none" },
16
+ { "kind": "file", "path": ".factory/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "none" },
17
+ { "kind": "file", "path": ".kiro/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "none" },
18
+ { "kind": "file", "path": ".trae/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "none" }
12
19
  ],
13
20
  "selector": { "type": "whole-file" },
14
- "baselines": ["selector.py"],
21
+ "baselines": ["selector.py", "baseline-flower-beta2.py", "baseline-flower-stale-task.py"],
15
22
  "content": { "source": "content.py" }
16
23
  }]
17
24
  }
@@ -0,0 +1,21 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "id": "workflow-runtime-contract-reference",
4
+ "purpose": "workflow_state",
5
+ "operations": [
6
+ {
7
+ "id": "workflow-state-contract-comment",
8
+ "operation": "replace",
9
+ "targets": [{ "kind": "workflow", "path": ".trellis/workflow.md", "missing": "error" }],
10
+ "selector": { "type": "literal", "source": "state-contract-comment-selector.md" },
11
+ "content": { "source": "state-contract-comment-content.md" }
12
+ },
13
+ {
14
+ "id": "workflow-runtime-contract-reference",
15
+ "operation": "replace",
16
+ "targets": [{ "kind": "workflow", "path": ".trellis/workflow.md", "missing": "error" }],
17
+ "selector": { "type": "literal", "source": "runtime-reference-selector.md" },
18
+ "content": { "source": "runtime-reference-content.md" }
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,4 @@
1
+ For the workflow state machine's runtime contract, the authoritative runtime inputs are the installed per-turn hook parser and the `[workflow-state:*]` tags in this file. This Flower variant uses fixed pseudo-status tag names `no_task` and `missing_task`; hook diagnostic source types such as `session` or `session-fallback` must not become workflow-state tag names.
2
+
3
+ - Installed `<platform>/hooks/inject-workflow-state.py` copies — parse this workflow and emit the current breadcrumb for platforms with a per-turn hook.
4
+ - `.trellis/spec/` project specs, when present — project-local runtime contract notes and invariants.
@@ -0,0 +1,4 @@
1
+ For the workflow state machine's runtime contract, the locations of all status writers, pseudo-statuses (`no_task` / `stale_<source_type>`), the hook reachability matrix, and other deep details, see:
2
+
3
+ - `.trellis/spec/cli/backend/workflow-state-contract.md` — runtime contract + writer table + test invariants
4
+ - `.trellis/scripts/inject-workflow-state.py` — actual parser (reads workflow.md only, no embedded text)
@@ -0,0 +1,46 @@
1
+ <!--
2
+ WORKFLOW-STATE BREADCRUMB CONTRACT (read this before editing the tag blocks below)
3
+
4
+ The [workflow-state:STATUS] blocks embedded in the ## Phase Index section
5
+ below are the SINGLE source of truth for the per-turn `<workflow-state>`
6
+ breadcrumb that every supported AI platform's UserPromptSubmit hook
7
+ reads. inject-workflow-state.py (Python platforms) and
8
+ inject-workflow-state.js (OpenCode plugin) only parse them — there is no
9
+ fallback dict baked into the scripts after v0.5.0-rc.0.
10
+
11
+ STATUS charset: [A-Za-z0-9_-]+. When the hook can't find a tag, it
12
+ degrades to a generic "Refer to workflow.md for current step." line —
13
+ intentionally visible so users notice and fix a broken workflow.md.
14
+
15
+ INVARIANT (test/regression.test.ts):
16
+ Every workflow-walkthrough step marked `[required · once]` must have a
17
+ matching enforcement line in its phase's [workflow-state:*] block. The
18
+ breadcrumb is the only per-turn channel; if a mandatory step isn't
19
+ mentioned there, the AI silently skips it (Phase 1 planning gate
20
+ skip and Phase 3.4 commit skip both manifested via this gap).
21
+
22
+ TAG ↔ PHASE scoping:
23
+ [workflow-state:no_task] → no active task; before Phase 1
24
+ [workflow-state:missing_task] → missing active-task directory recovery
25
+ [workflow-state:planning] → all of Phase 1 (status='planning')
26
+ [workflow-state:planning-inline] → Codex inline variant of Phase 1
27
+ [workflow-state:in_progress] → Phase 2 + Phase 3.2-3.4
28
+ (status stays 'in_progress' from
29
+ task.py start until task.py archive)
30
+ [workflow-state:in_progress-inline] → Codex inline variant of Phase 2/3
31
+ [workflow-state:completed] → currently DEAD: cmd_archive flips
32
+ status and moves the dir in the same
33
+ call, so the resolver loses the
34
+ pointer (block kept for a future
35
+ explicit in_progress→completed
36
+ transition)
37
+
38
+ Editing checklist:
39
+ - When you change a [workflow-state:STATUS] block, also check the
40
+ matching phase's `[required · once]` walkthrough steps for sync
41
+ - Run `trellis update` after editing to push the new bodies to
42
+ downstream user projects (block-level managed replacement)
43
+ - Runtime pseudo-status names are fixed. This Flower variant defines
44
+ `no_task` and `missing_task`; hook diagnostic source types must not be
45
+ appended to workflow-state tag names.
46
+ -->
@@ -0,0 +1,44 @@
1
+ <!--
2
+ WORKFLOW-STATE BREADCRUMB CONTRACT (read this before editing the tag blocks below)
3
+
4
+ The [workflow-state:STATUS] blocks embedded in the ## Phase Index section
5
+ below are the SINGLE source of truth for the per-turn `<workflow-state>`
6
+ breadcrumb that every supported AI platform's UserPromptSubmit hook
7
+ reads. inject-workflow-state.py (Python platforms) and
8
+ inject-workflow-state.js (OpenCode plugin) only parse them — there is no
9
+ fallback dict baked into the scripts after v0.5.0-rc.0.
10
+
11
+ STATUS charset: [A-Za-z0-9_-]+. When the hook can't find a tag, it
12
+ degrades to a generic "Refer to workflow.md for current step." line —
13
+ intentionally visible so users notice and fix a broken workflow.md.
14
+
15
+ INVARIANT (test/regression.test.ts):
16
+ Every workflow-walkthrough step marked `[required · once]` must have a
17
+ matching enforcement line in its phase's [workflow-state:*] block. The
18
+ breadcrumb is the only per-turn channel; if a mandatory step isn't
19
+ mentioned there, the AI silently skips it (Phase 1 planning gate
20
+ skip and Phase 3.4 commit skip both manifested via this gap).
21
+
22
+ TAG ↔ PHASE scoping:
23
+ [workflow-state:no_task] → no active task; before Phase 1
24
+ [workflow-state:planning] → all of Phase 1 (status='planning')
25
+ [workflow-state:planning-inline] → Codex inline variant of Phase 1
26
+ [workflow-state:in_progress] → Phase 2 + Phase 3.2-3.4
27
+ (status stays 'in_progress' from
28
+ task.py start until task.py archive)
29
+ [workflow-state:in_progress-inline] → Codex inline variant of Phase 2/3
30
+ [workflow-state:completed] → currently DEAD: cmd_archive flips
31
+ status and moves the dir in the same
32
+ call, so the resolver loses the
33
+ pointer (block kept for a future
34
+ explicit in_progress→completed
35
+ transition)
36
+
37
+ Editing checklist:
38
+ - When you change a [workflow-state:STATUS] block, also check the
39
+ matching phase's `[required · once]` walkthrough steps for sync
40
+ - Run `trellis update` after editing to push the new bodies to
41
+ downstream user projects (block-level managed replacement)
42
+ - Full runtime contract:
43
+ .trellis/spec/cli/backend/workflow-state-contract.md
44
+ -->
@@ -0,0 +1,28 @@
1
+ def clear_active_task(
2
+ repo_root: Path,
3
+ platform_input: dict[str, Any] | None = None,
4
+ platform: str | None = None,
5
+ ) -> ActiveTask:
6
+ """Clear the active task for the current or sole fallback session.
7
+
8
+ Args:
9
+ repo_root: Repository root.
10
+ platform_input: Platform hook input.
11
+ platform: Explicit platform name.
12
+
13
+ Returns:
14
+ The resolved task before cleanup, or an empty task when no session is safe to select.
15
+ """
16
+ context_key = resolve_context_key(platform_input, platform)
17
+ previous = resolve_active_task(repo_root, platform_input, platform)
18
+
19
+ # Fallback resolution is safe only when the runtime contains exactly one session file.
20
+ if previous.source_type == "session-fallback" and previous.context_key:
21
+ context_key = previous.context_key
22
+ if not context_key:
23
+ return ActiveTask(None, "none")
24
+
25
+ context_path = _context_path(repo_root, context_key)
26
+ if context_path.is_file():
27
+ _remove_file(context_path)
28
+ return previous
@@ -0,0 +1,15 @@
1
+ def clear_active_task(
2
+ repo_root: Path,
3
+ platform_input: dict[str, Any] | None = None,
4
+ platform: str | None = None,
5
+ ) -> ActiveTask:
6
+ """Clear the active task by deleting the current session context file."""
7
+ context_key = resolve_context_key(platform_input, platform)
8
+ if not context_key:
9
+ return ActiveTask(None, "none")
10
+
11
+ previous = resolve_active_task(repo_root, platform_input, platform)
12
+ context_path = _context_path(repo_root, context_key)
13
+ if context_path.is_file():
14
+ _remove_file(context_path)
15
+ return previous
@@ -0,0 +1,5 @@
1
+ [workflow-state:missing_task]
2
+ An active task pointer that points to a missing task directory is a recovery-only state, not permission to implement, edit, create a task, start a task, or attribute work to the missing task.
3
+ Run `python3 ./.trellis/scripts/task.py finish`. If it fails, report the failure and stop.
4
+ If it succeeds, in the same turn treat the current user request as `no_task` and follow `[workflow-state:no_task]` / Request Intent Routing before any edit or task action.
5
+ [/workflow-state:missing_task]
@@ -0,0 +1,28 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "id": "workflow-state-missing-task",
4
+ "purpose": "workflow_state",
5
+ "operations": [
6
+ {
7
+ "id": "workflow-state-missing-task",
8
+ "operation": "insert",
9
+ "position": "after",
10
+ "targets": [{ "kind": "workflow", "path": ".trellis/workflow.md", "missing": "error" }],
11
+ "selector": { "type": "literal", "source": "selector.md" },
12
+ "content": { "source": "content.md" },
13
+ "legacyMarkers": [{ "namespace": "patch", "id": "workflow-state-stale-task" }]
14
+ },
15
+ {
16
+ "id": "active-task-clear-session-fallback",
17
+ "operation": "replace",
18
+ "targets": [{
19
+ "kind": "file",
20
+ "path": ".trellis/scripts/common/active_task.py",
21
+ "missing": "error",
22
+ "markerStyle": "hash"
23
+ }],
24
+ "selector": { "type": "literal", "source": "active-task-clear-fallback-selector.py" },
25
+ "content": { "source": "active-task-clear-fallback-content.py" }
26
+ }
27
+ ]
28
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
- "syncedAt": "2026-07-19T04:23:35.438Z",
2
+ "syncedAt": "2026-07-20T00:48:18.573Z",
3
3
  "syncedFrom": "vendor/skill-garden",
4
- "sourceCommit": "8b905fe8f69dc606ab9e2523e07a16d1699c7771",
4
+ "sourceCommit": "6ff1dcbcfa9164ab7239374af1071fffe648437e",
5
5
  "common": {
6
6
  "codexSkills": [
7
7
  "craft-rpa",
@@ -130,12 +130,20 @@
130
130
  "commands": [],
131
131
  "overrides": [],
132
132
  "patchFiles": [
133
+ "hooks/claude-session-start/missing-task-routing/content.py",
134
+ "hooks/claude-session-start/missing-task-routing/patch.json",
135
+ "hooks/claude-session-start/missing-task-routing/selector.py",
133
136
  "hooks/claude-session-start/no-task-routing/content.py",
134
137
  "hooks/claude-session-start/no-task-routing/patch.json",
135
138
  "hooks/claude-session-start/no-task-routing/selector.py",
139
+ "hooks/codex-session-start/missing-task-routing/content.py",
140
+ "hooks/codex-session-start/missing-task-routing/patch.json",
141
+ "hooks/codex-session-start/missing-task-routing/selector.py",
136
142
  "hooks/codex-session-start/no-task-routing/content.py",
137
143
  "hooks/codex-session-start/no-task-routing/patch.json",
138
144
  "hooks/codex-session-start/no-task-routing/selector.py",
145
+ "hooks/inject-workflow-state/shared-runtime/baseline-flower-beta2.py",
146
+ "hooks/inject-workflow-state/shared-runtime/baseline-flower-stale-task.py",
139
147
  "hooks/inject-workflow-state/shared-runtime/content.py",
140
148
  "hooks/inject-workflow-state/shared-runtime/patch.json",
141
149
  "hooks/inject-workflow-state/shared-runtime/selector.py",
@@ -189,6 +197,16 @@
189
197
  "workflow/phase-ownership/phase-3-commit-content.md",
190
198
  "workflow/phase-ownership/phase-3-update-spec-baseline.md",
191
199
  "workflow/phase-ownership/phase-3-update-spec-content.md",
200
+ "workflow/runtime-contract-reference/patch.json",
201
+ "workflow/runtime-contract-reference/runtime-reference-content.md",
202
+ "workflow/runtime-contract-reference/runtime-reference-selector.md",
203
+ "workflow/runtime-contract-reference/state-contract-comment-content.md",
204
+ "workflow/runtime-contract-reference/state-contract-comment-selector.md",
205
+ "workflow/state-missing-task/active-task-clear-fallback-content.py",
206
+ "workflow/state-missing-task/active-task-clear-fallback-selector.py",
207
+ "workflow/state-missing-task/content.md",
208
+ "workflow/state-missing-task/patch.json",
209
+ "workflow/state-missing-task/selector.md",
192
210
  "workflow/state-no-task/content.md",
193
211
  "workflow/state-no-task/patch.json",
194
212
  "workflow/state-no-task/selector.md",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flower-trellis",
3
- "version": "0.5.0-beta.2",
3
+ "version": "0.5.0-beta.4",
4
4
  "description": "一键安装/升级 Trellis 并自动融合 skill-garden 强化包(默认 Claude + agents)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,9 +61,9 @@
61
61
  "commit-and-tag-version": "^12.7.3"
62
62
  },
63
63
  "flowerReleaseNotes": {
64
- "version": "0.5.0-beta.2",
64
+ "version": "0.5.0-beta.4",
65
65
  "source": "CHANGELOG.md",
66
- "body": "### ✨ 新功能 Features\n\n* **0.6:** 统一 Patch Engine 与注入流程 ([db4f943](https://github.com/SilentFlower/flower-trellis/commit/db4f9430dfdafd807726df14c0f0b7b91dcf2b83))\n - 将 0.6 的 Workflow、Skill、Hook 与平台配置统一为 `insert / replace / remove` Patch schema v2。\n - 支持 Bundle 选择、全量 preflight、changed-only apply、首次备份、旧 marker 迁移和 manifest provenance。\n - Flower JS 与 Skill-Garden Python consumer 共享 Core Patch 声明和 fixture,并保持结构化结果 parity。\n* **0.6:** 增加 Patch 冲突与兼容门禁 ([2472058](https://github.com/SilentFlower/flower-trellis/commit/2472058714f0986186a84de78b6fd7ad15a2f76b))\n - 对全部 0.6 Patch 目标执行上游 baseline、Patch 与最终产物三方冲突检查。\n - 运行时、`npm test`、`check-snapshot` 与维护者脚本复用同一套 compatibility/conflict evaluator。\n\n\n### 🐛 修复 Bug Fixes\n\n* **workflow:** 清理 route、Check-All、Update-Spec Trellis Push 的互斥协议和重复流程。\n* **config:** 结构化配置只修改受管字段,损坏 JSON/YAML/TOML 时失败且不覆盖用户配置。\n* **install:** required Patch 或冲突检查失败时,Patch、资产、stale 清理和 manifest 保持零写入。\n* **diagnostics:** 将未安装目标记为 info,并将 optional skip 与阻断错误分开报告。\n\n\n### 🔒 兼容与安全\n\n* Trellis `0.6.5` 已登记并通过完整验证。\n* 未登记的同线 `0.6.x` 在完整 Patch 与冲突检查通过后 warning 放行。\n* `0.7+`、`1.x` 或无效版本会阻断强化,并提示使用匹配版本或 `--no-enhance`。\n* `0.5` / `old` 继续使用原有 legacy 注入路径。\n\n\n### 🧰 维护 Maintenance\n\n* **trellis:** 同步项目 Flower 版本状态 ([c4e83ab](https://github.com/SilentFlower/flower-trellis/commit/c4e83ab94df110118129d1bf6f8ad9717454dd65))",
66
+ "body": "### 🐛 修复 Bug Fixes\n\n* **enhancements:** 补齐 session fallback 清理 ([b3884cb](https://github.com/SilentFlower/flower-trellis/commit/b3884cb9e3fac25ef504bbee722333f2d3d98c0e))",
67
67
  "truncated": false
68
68
  }
69
69
  }