flower-trellis 0.6.9 → 0.6.10-beta.1

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.
@@ -310,6 +310,7 @@
310
310
  "workflow-state-stale-task-status",
311
311
  "workflow-state-untracked-helper",
312
312
  "workflow-state-breadcrumb-subject",
313
+ "workflow-state-conditional-heartbeat",
313
314
  "workflow-state-main-subject-routing"
314
315
  ],
315
316
  "assertion": {
@@ -320,15 +321,20 @@
320
321
  "# BEGIN skill-garden patch workflow-state-stale-task-status v0.6",
321
322
  "# BEGIN skill-garden patch workflow-state-untracked-helper v0.6",
322
323
  "# BEGIN skill-garden patch workflow-state-breadcrumb-subject v0.6",
324
+ "# BEGIN skill-garden patch workflow-state-conditional-heartbeat v0.6",
323
325
  "# BEGIN skill-garden patch workflow-state-main-subject-routing v0.6",
324
326
  "emit_worktree_local_trellis_missing",
325
327
  "DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = \"no-trellis\"",
328
+ "DEFAULT_WORKFLOW_STATE_HEARTBEAT_TURNS = 5",
329
+ "WORKFLOW_STATE_REFRESH_ARG = \"--trellis-session-start-refresh\"",
330
+ "root / \".trellis\" / \".runtime\" / \"workflow-state\"",
331
+ "<workflow-state-heartbeat>",
326
332
  "\"ZCODE_PROJECT_DIR\": \"zcode\"",
327
333
  "Normalize `codex.dispatch_mode` from .trellis/config.yaml to \"auto\" or \"inline\"."
328
334
  ]
329
335
  },
330
336
  "owner": "workflow-state",
331
- "reason": "局部 Patch 必须同时保留 Flower subject 分支与 0.6.14 的 no-trellis、ZCode 和 Codex auto 能力。"
337
+ "reason": "局部 Patch 必须同时保留 Flower subject/条件心跳分支与 0.6.14 的 no-trellis、ZCode 和 Codex auto 能力。"
332
338
  },
333
339
  {
334
340
  "id": "paths-required-clear-result",
@@ -0,0 +1,226 @@
1
+ import hashlib
2
+ import time
3
+
4
+
5
+ CONDITIONAL_WORKFLOW_STATE_PLATFORMS = {"codex", "claude"}
6
+ DEFAULT_WORKFLOW_STATE_HEARTBEAT_TURNS = 5
7
+ WORKFLOW_STATE_TRACKER_VERSION = 1
8
+ WORKFLOW_STATE_REFRESH_ARG = "--trellis-session-start-refresh"
9
+
10
+
11
+ def _resolve_heartbeat_turns(config: dict) -> int:
12
+ """Return the configured unchanged-turn heartbeat interval."""
13
+ raw = DEFAULT_WORKFLOW_STATE_HEARTBEAT_TURNS
14
+ if isinstance(config, dict):
15
+ section = config.get("prompt_injection")
16
+ if isinstance(section, dict):
17
+ raw = section.get("heartbeat_turns", raw)
18
+ if isinstance(raw, bool):
19
+ return DEFAULT_WORKFLOW_STATE_HEARTBEAT_TURNS
20
+ if isinstance(raw, int):
21
+ return raw if raw >= 0 else DEFAULT_WORKFLOW_STATE_HEARTBEAT_TURNS
22
+ if isinstance(raw, str) and re.fullmatch(r"[+-]?\d+", raw.strip()):
23
+ value = int(raw.strip())
24
+ return value if value >= 0 else DEFAULT_WORKFLOW_STATE_HEARTBEAT_TURNS
25
+ return DEFAULT_WORKFLOW_STATE_HEARTBEAT_TURNS
26
+
27
+
28
+ def _resolve_workflow_state_context_key(
29
+ root: Path, input_data: dict, platform: str
30
+ ) -> str | None:
31
+ """Resolve the host session identity through Trellis' shared helper."""
32
+ scripts_dir = root / ".trellis" / "scripts"
33
+ if str(scripts_dir) not in sys.path:
34
+ sys.path.insert(0, str(scripts_dir))
35
+ try:
36
+ from common.active_task import resolve_context_key # type: ignore[import-not-found]
37
+
38
+ context_key = resolve_context_key(input_data, platform)
39
+ except Exception:
40
+ return None
41
+ return context_key if isinstance(context_key, str) and context_key else None
42
+
43
+
44
+ def _workflow_state_tracker_path(
45
+ root: Path, platform: str, context_key: str
46
+ ) -> Path:
47
+ """Return the opaque per-platform, per-session tracker path."""
48
+ identity = hashlib.sha256(f"{platform}:{context_key}".encode("utf-8")).hexdigest()
49
+ return root / ".trellis" / ".runtime" / "workflow-state" / f"{identity}.json"
50
+
51
+
52
+ def _read_workflow_state_tracker(path: Path, platform: str) -> dict | None:
53
+ """Read and strictly validate a workflow-state tracker."""
54
+ try:
55
+ value = json.loads(path.read_text(encoding="utf-8"))
56
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
57
+ return None
58
+ if not isinstance(value, dict):
59
+ return None
60
+ expected_keys = {
61
+ "version",
62
+ "platform",
63
+ "fingerprint",
64
+ "unchangedTurns",
65
+ "heartbeatTurns",
66
+ "updatedAt",
67
+ }
68
+ if set(value) != expected_keys:
69
+ return None
70
+ if value.get("version") != WORKFLOW_STATE_TRACKER_VERSION:
71
+ return None
72
+ if value.get("platform") != platform:
73
+ return None
74
+ fingerprint = value.get("fingerprint")
75
+ if not isinstance(fingerprint, str) or re.fullmatch(r"[0-9a-f]{64}", fingerprint) is None:
76
+ return None
77
+ unchanged_turns = value.get("unchangedTurns")
78
+ heartbeat_turns = value.get("heartbeatTurns")
79
+ if isinstance(unchanged_turns, bool) or not isinstance(unchanged_turns, int):
80
+ return None
81
+ if isinstance(heartbeat_turns, bool) or not isinstance(heartbeat_turns, int):
82
+ return None
83
+ if unchanged_turns < 0 or heartbeat_turns < 0:
84
+ return None
85
+ if heartbeat_turns == 0 and unchanged_turns != 0:
86
+ return None
87
+ if heartbeat_turns > 0 and unchanged_turns >= heartbeat_turns:
88
+ return None
89
+ updated_at = value.get("updatedAt")
90
+ if not isinstance(updated_at, str) or not updated_at:
91
+ return None
92
+ try:
93
+ time.strptime(updated_at, "%Y-%m-%dT%H:%M:%SZ")
94
+ except ValueError:
95
+ return None
96
+ return value
97
+
98
+
99
+ def _workflow_state_record(
100
+ platform: str,
101
+ fingerprint: str,
102
+ unchanged_turns: int,
103
+ heartbeat_turns: int,
104
+ ) -> dict:
105
+ """Build the versioned tracker payload."""
106
+ return {
107
+ "version": WORKFLOW_STATE_TRACKER_VERSION,
108
+ "platform": platform,
109
+ "fingerprint": fingerprint,
110
+ "unchangedTurns": unchanged_turns,
111
+ "heartbeatTurns": heartbeat_turns,
112
+ "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
113
+ }
114
+
115
+
116
+ def _write_workflow_state_tracker(path: Path, value: dict) -> bool:
117
+ """Atomically replace a workflow-state tracker after flushing it."""
118
+ temporary = path.with_name(
119
+ f".{path.name}.{os.getpid()}.{threading.get_ident()}.{time.time_ns()}.tmp"
120
+ )
121
+ try:
122
+ path.parent.mkdir(parents=True, exist_ok=True)
123
+ with temporary.open("x", encoding="utf-8") as handle:
124
+ handle.write(json.dumps(value, indent=2, ensure_ascii=False) + "\n")
125
+ handle.flush()
126
+ os.fsync(handle.fileno())
127
+ os.replace(temporary, path)
128
+ return True
129
+ except OSError:
130
+ try:
131
+ temporary.unlink()
132
+ except OSError:
133
+ pass
134
+ return False
135
+
136
+
137
+ def _workflow_state_body(
138
+ templates: dict[str, str], status: str, breadcrumb_key: str
139
+ ) -> str:
140
+ """Return the exact selected workflow-state body or the standard fallback."""
141
+ body = templates.get(breadcrumb_key)
142
+ if body is None and breadcrumb_key != status:
143
+ body = templates.get(status)
144
+ return body if body is not None else "Refer to workflow.md for current step."
145
+
146
+
147
+ def _workflow_state_action(
148
+ templates: dict[str, str], status: str, breadcrumb_key: str
149
+ ) -> str:
150
+ """Extract the first visible action line from the selected state body."""
151
+ body = _workflow_state_body(templates, status, breadcrumb_key)
152
+ visible = re.sub(r"<!--.*?-->", "", body, flags=re.DOTALL)
153
+ for raw_line in visible.splitlines():
154
+ line = raw_line.strip()
155
+ if line:
156
+ return line
157
+ return "Refer to workflow.md for current step."
158
+
159
+
160
+ def _build_workflow_state_heartbeat(
161
+ subject: str,
162
+ action: str,
163
+ heartbeat_turns: int,
164
+ subject_summary: str | None = None,
165
+ ) -> str:
166
+ """Build an actionable reminder without repeating the full state body."""
167
+ lines = ["<workflow-state-heartbeat>", subject]
168
+ if subject_summary:
169
+ lines.append(f"Summary: {subject_summary}")
170
+ lines.extend(
171
+ [
172
+ f"Action: {action}",
173
+ (
174
+ f"State unchanged for {heartbeat_turns} user turns. "
175
+ "Continue following the latest full <workflow-state> block."
176
+ ),
177
+ "</workflow-state-heartbeat>",
178
+ ]
179
+ )
180
+ return "\n".join(lines)
181
+
182
+
183
+ def _conditional_workflow_state_context(
184
+ root: Path,
185
+ input_data: dict,
186
+ platform: str | None,
187
+ config: dict,
188
+ full_context: str,
189
+ heartbeat: str,
190
+ force_refresh: bool,
191
+ ) -> str | None:
192
+ """Choose a full state, heartbeat, or silent output for the current event."""
193
+ if platform not in CONDITIONAL_WORKFLOW_STATE_PLATFORMS:
194
+ return full_context
195
+
196
+ context_key = _resolve_workflow_state_context_key(root, input_data, platform)
197
+ if context_key is None:
198
+ return full_context
199
+
200
+ heartbeat_turns = _resolve_heartbeat_turns(config)
201
+ fingerprint = hashlib.sha256(full_context.encode("utf-8")).hexdigest()
202
+ tracker_path = _workflow_state_tracker_path(root, platform, context_key)
203
+ tracker = _read_workflow_state_tracker(tracker_path, platform)
204
+
205
+ if force_refresh or tracker is None or tracker["fingerprint"] != fingerprint:
206
+ baseline = _workflow_state_record(platform, fingerprint, 0, heartbeat_turns)
207
+ _write_workflow_state_tracker(tracker_path, baseline)
208
+ return full_context
209
+
210
+ if tracker["heartbeatTurns"] != heartbeat_turns:
211
+ baseline = _workflow_state_record(platform, fingerprint, 0, heartbeat_turns)
212
+ if not _write_workflow_state_tracker(tracker_path, baseline):
213
+ return full_context
214
+ return None
215
+
216
+ unchanged_turns = 0 if heartbeat_turns == 0 else tracker["unchangedTurns"] + 1
217
+ output = None
218
+ if heartbeat_turns > 0 and unchanged_turns >= heartbeat_turns:
219
+ unchanged_turns = 0
220
+ output = heartbeat
221
+ next_record = _workflow_state_record(
222
+ platform, fingerprint, unchanged_turns, heartbeat_turns
223
+ )
224
+ if not _write_workflow_state_tracker(tracker_path, next_record):
225
+ return full_context
226
+ return output
@@ -0,0 +1,3 @@
1
+ # ---------------------------------------------------------------------------
2
+ # Entry
3
+ # ---------------------------------------------------------------------------
@@ -3,6 +3,7 @@ def main() -> int:
3
3
  return 0
4
4
 
5
5
  data = _load_hook_input()
6
+ force_refresh = WORKFLOW_STATE_REFRESH_ARG in sys.argv[1:]
6
7
 
7
8
  cwd_str = data.get("cwd") or os.getcwd()
8
9
  cwd = Path(cwd_str)
@@ -13,40 +14,61 @@ def main() -> int:
13
14
  return 0
14
15
 
15
16
  config = _read_trellis_config(root)
16
- if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)):
17
+ if not force_refresh and prompt_has_skip_keyword(
18
+ data.get("prompt", ""), _resolve_skip_keyword(config)
19
+ ):
17
20
  return 0 # user opted out of the per-turn breadcrumb for this turn
18
21
 
19
22
  templates = load_breadcrumbs(root)
20
23
  platform = _detect_platform(data)
24
+ if force_refresh:
25
+ script_parts = set(Path(sys.argv[0]).parts)
26
+ if ".codex" in script_parts:
27
+ platform = "codex"
28
+ elif ".claude" in script_parts:
29
+ platform = "claude"
21
30
  task = get_active_task(root, data)
31
+ subject_summary = None
22
32
  if task is None:
23
33
  untracked = _get_untracked_work(root, data)
24
34
  if untracked is None:
25
35
  # No active task or untracked work — still emit a breadcrumb nudging
26
36
  # the AI toward intent routing when the user describes real work.
27
- no_task_key = resolve_breadcrumb_key("no_task", platform, config)
37
+ status = "no_task"
38
+ breadcrumb_key = resolve_breadcrumb_key(status, platform, config)
39
+ subject = "Status: no_task"
28
40
  breadcrumb = build_breadcrumb(
29
- None, "no_task", templates, breadcrumb_key=no_task_key
41
+ None, status, templates, breadcrumb_key=breadcrumb_key
30
42
  )
31
43
  else:
32
- work_id, stage, summary = untracked
33
- untracked_status = "untracked" if stage == "implement" else f"untracked_{stage}"
34
- untracked_key = resolve_breadcrumb_key(untracked_status, platform, config)
44
+ work_id, stage, subject_summary = untracked
45
+ status = "untracked" if stage == "implement" else f"untracked_{stage}"
46
+ breadcrumb_key = resolve_breadcrumb_key(status, platform, config)
47
+ subject = f"Untracked work: {work_id} ({stage})"
35
48
  breadcrumb = build_breadcrumb(
36
49
  None,
37
- untracked_status,
50
+ status,
38
51
  templates,
39
- breadcrumb_key=untracked_key,
40
- subject_label=f"Untracked work: {work_id} ({stage})",
41
- subject_summary=summary,
52
+ breadcrumb_key=breadcrumb_key,
53
+ subject_label=subject,
54
+ subject_summary=subject_summary,
42
55
  )
43
56
  else:
44
57
  task_id, status, source = task
45
- status_key = resolve_breadcrumb_key(status, platform, config)
58
+ breadcrumb_key = resolve_breadcrumb_key(status, platform, config)
59
+ subject = f"Task: {task_id} ({status})"
46
60
  source_for_breadcrumb = None if platform == "codex" else source
47
61
  breadcrumb = build_breadcrumb(
48
- task_id, status, templates, source_for_breadcrumb, breadcrumb_key=status_key
62
+ task_id, status, templates, source_for_breadcrumb, breadcrumb_key=breadcrumb_key
49
63
  )
64
+
65
+ action = _workflow_state_action(templates, status, breadcrumb_key)
66
+ heartbeat = _build_workflow_state_heartbeat(
67
+ subject,
68
+ action,
69
+ _resolve_heartbeat_turns(config),
70
+ subject_summary=subject_summary,
71
+ )
50
72
  if platform == "codex":
51
73
  parts: list[str] = []
52
74
  if task is None and not _codex_has_trellis_session_start(root):
@@ -55,6 +77,18 @@ def main() -> int:
55
77
  parts.append(breadcrumb)
56
78
  breadcrumb = "\n\n".join(parts)
57
79
 
80
+ breadcrumb = _conditional_workflow_state_context(
81
+ root,
82
+ data,
83
+ platform,
84
+ config,
85
+ breadcrumb,
86
+ heartbeat,
87
+ force_refresh,
88
+ )
89
+ if breadcrumb is None:
90
+ return 0
91
+
58
92
  # Kiro (CLI userPromptSubmit / IDE promptSubmit) adds a hook's stdout
59
93
  # directly to the conversation context — no JSON envelope. Emit the bare
60
94
  # breadcrumb text. Conditionally isolated: all other platforms keep the
@@ -100,6 +100,26 @@
100
100
  "selector": { "type": "literal", "source": "breadcrumb-selector.py" },
101
101
  "content": { "source": "breadcrumb-content.py" }
102
102
  },
103
+ {
104
+ "id": "workflow-state-conditional-heartbeat",
105
+ "operation": "insert",
106
+ "position": "before",
107
+ "targetPolicy": "each-existing",
108
+ "targets": [
109
+ { "kind": "file", "path": ".codex/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "hash" },
110
+ { "kind": "file", "path": ".claude/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "hash" },
111
+ { "kind": "file", "path": ".gemini/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "hash" },
112
+ { "kind": "file", "path": ".qoder/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "hash" },
113
+ { "kind": "file", "path": ".github/copilot/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "hash" },
114
+ { "kind": "file", "path": ".codebuddy/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "hash" },
115
+ { "kind": "file", "path": ".factory/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "hash" },
116
+ { "kind": "file", "path": ".kiro/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "hash" },
117
+ { "kind": "file", "path": ".trae/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "hash" },
118
+ { "kind": "file", "path": ".zcode/hooks/inject-workflow-state.py", "missing": "skip", "markerStyle": "hash" }
119
+ ],
120
+ "selector": { "type": "literal", "source": "conditional-heartbeat-selector.py" },
121
+ "content": { "source": "conditional-heartbeat-content.py" }
122
+ },
103
123
  {
104
124
  "id": "workflow-state-main-subject-routing",
105
125
  "operation": "replace",
@@ -1,7 +1,7 @@
1
1
  {
2
- "syncedAt": "2026-09-17T03:40:21.148Z",
2
+ "syncedAt": "2026-09-17T06:53:09.720Z",
3
3
  "syncedFrom": "vendor/skill-garden",
4
- "sourceCommit": "94df742fdf6530a2bf8f72df68a5a9a30b929d7d",
4
+ "sourceCommit": "d08ae57c5479a0bef09bc545cf8959d8aedacb9f",
5
5
  "common": {
6
6
  "codexSkills": [
7
7
  "aliyun-ops",
@@ -179,6 +179,8 @@
179
179
  "hooks/inject-workflow-state/shared-runtime/breadcrumb-selector.py",
180
180
  "hooks/inject-workflow-state/shared-runtime/codex-session-start-content.py",
181
181
  "hooks/inject-workflow-state/shared-runtime/codex-session-start-selector.py",
182
+ "hooks/inject-workflow-state/shared-runtime/conditional-heartbeat-content.py",
183
+ "hooks/inject-workflow-state/shared-runtime/conditional-heartbeat-selector.py",
182
184
  "hooks/inject-workflow-state/shared-runtime/main-content.py",
183
185
  "hooks/inject-workflow-state/shared-runtime/main-selector.py",
184
186
  "hooks/inject-workflow-state/shared-runtime/patch.json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flower-trellis",
3
- "version": "0.6.9",
3
+ "version": "0.6.10-beta.1",
4
4
  "description": "一键安装/升级 Trellis 并自动融合 skill-garden 强化包(默认 Claude + agents)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -70,9 +70,9 @@
70
70
  "commit-and-tag-version": "^12.7.3"
71
71
  },
72
72
  "flowerReleaseNotes": {
73
- "version": "0.6.9",
73
+ "version": "0.6.10-beta.1",
74
74
  "source": "CHANGELOG.md",
75
- "body": "### 🐛 修复 Bug Fixes\n\n* **ci:** 修复跨平台初始化、检出与迁移兼容 ([a06facc](https://github.com/SilentFlower/flower-trellis/commit/a06faccdcd7a6e8bd1829f4b41d35c252e814dd5))\n* **ci:** 补齐原生依赖构建并修正 Windows 迁移夹具 ([99ff392](https://github.com/SilentFlower/flower-trellis/commit/99ff392299612912c028686ff9b3c7204bf67aec))\n* **cli:** 修复 Windows 非交互完成后挂起 ([8c71a2b](https://github.com/SilentFlower/flower-trellis/commit/8c71a2b393288b1d7b5530c3d91510d72992fbc1))\n* **compat:** 修复 Windows/Linux Python 运行与跨平台回归 ([311c4ea](https://github.com/SilentFlower/flower-trellis/commit/311c4eac99c7fd9269f62d2b10a36fdda7618b40))\n* **hooks:** 修复跨平台 SessionStart 分段注入 ([5f6a0d4](https://github.com/SilentFlower/flower-trellis/commit/5f6a0d4325e6e8e4d39f230c7ac3a1de9fd0c9af))\n* **test:** 修复 SessionStart CI 对本地身份文件的依赖 ([28d8bcf](https://github.com/SilentFlower/flower-trellis/commit/28d8bcff8bdde2dbcb0b37468ec3937e9d17d543))\n* **update:** 修复共享超时状态与 PTY 受管提示 ([7c2c4dc](https://github.com/SilentFlower/flower-trellis/commit/7c2c4dc8a25c755cfd79864e80bd4a8305f362f5))\n\n\n### ⚡ 性能 Performance\n\n* **update:** 缩短升级等待并补充阶段耗时诊断 ([94788f4](https://github.com/SilentFlower/flower-trellis/commit/94788f4023b2af047e71b4689bc3716bed32fd21))\n\n\n### 📝 文档 Docs\n\n* **spec:** 要求跨平台脚本和指令通过 GitHub Actions 验收 ([87aa9b8](https://github.com/SilentFlower/flower-trellis/commit/87aa9b8936167d422f3752d4bb9f5cd958a41471))",
75
+ "body": "### ✨ 新功能 Features\n\n* **hooks:** 优化 workflow-state 条件注入 ([ecd9555](https://github.com/SilentFlower/flower-trellis/commit/ecd9555f9c8959a3f3c862f9cda8bf8e7153fbd5))",
76
76
  "truncated": false
77
77
  },
78
78
  "optionalDependencies": {
@@ -20,6 +20,7 @@ MAX_PART_CHARS = 8000
20
20
  WORKFLOW_BLOCK = re.compile(r"<trellis-workflow>\n(.*?)\n</trellis-workflow>\n*", re.DOTALL)
21
21
  ASTRA_MODEL = "gpt-6-astra"
22
22
  ASTRA_HINT_MAX_BYTES = 2048
23
+ WORKFLOW_STATE_REFRESH_ARG = "--trellis-session-start-refresh"
23
24
  ASTRA_WORKFLOW_HINT = """<trellis-astra-workflow-hint model="gpt-6-astra" version="1">
24
25
  Applies only while the active model is gpt-6-astra; it does not apply after switching models. Perform checks internally, without a routine checklist report. Keep ordinary answers brief.
25
26
  When executing the current task:
@@ -119,6 +120,50 @@ def _run_native_hook(root: Path, hook: str, hook_input: dict) -> dict | None:
119
120
  return output
120
121
 
121
122
 
123
+ def _run_workflow_state_refresh(root: Path, hook: str, hook_input: dict) -> str:
124
+ """在独立解释器中刷新当前完整 workflow-state 与会话基线。
125
+
126
+ @param root: 当前部署项目根目录。
127
+ @param hook: 已验证的原生平台 SessionStart hook 相对路径。
128
+ @param hook_input: 宿主传入并已规范化 cwd 的事件 JSON。
129
+ @return: workflow-state Hook 返回的完整 additionalContext。
130
+ """
131
+ workflow_hook = hook.replace("session-start.py", "inject-workflow-state.py")
132
+ environment = os.environ.copy()
133
+ environment["PYTHONIOENCODING"] = "utf-8"
134
+ result = subprocess.run(
135
+ [
136
+ sys.executable,
137
+ "-X",
138
+ "utf8",
139
+ str(root / workflow_hook),
140
+ WORKFLOW_STATE_REFRESH_ARG,
141
+ ],
142
+ cwd=root,
143
+ env=environment,
144
+ input=json.dumps(hook_input, ensure_ascii=False).encode("utf-8"),
145
+ capture_output=True,
146
+ check=False,
147
+ )
148
+ stderr = result.stderr.decode("utf-8", errors="replace")
149
+ if stderr:
150
+ sys.stderr.write(stderr)
151
+ sys.stderr.flush()
152
+ if result.returncode != 0:
153
+ raise ValueError(f"workflow-state hook 退出码 {result.returncode}")
154
+ stdout = result.stdout.decode("utf-8-sig")
155
+ if not stdout.strip():
156
+ raise ValueError("workflow-state hook 未返回刷新内容")
157
+ output = json.loads(stdout)
158
+ if not isinstance(output, dict):
159
+ raise ValueError("workflow-state hook 输出必须为 JSON 对象")
160
+ hook_output = output.get("hookSpecificOutput")
161
+ context = hook_output.get("additionalContext") if isinstance(hook_output, dict) else None
162
+ if not isinstance(context, str) or not context:
163
+ raise ValueError("workflow-state hook 缺少 additionalContext")
164
+ return context
165
+
166
+
122
167
  def render_part(root: Path, hook: str, part: str, hook_input: dict) -> dict | None:
123
168
  """生成指定分段,只有 state 执行原生主入口的副作用。
124
169
 
@@ -143,6 +188,15 @@ def render_part(root: Path, hook: str, part: str, hook_input: dict) -> dict | No
143
188
  if re.fullmatch(r"Trellis context injected \(\d+ chars\)", result.get("systemMessage", "")):
144
189
  # 原计数对应拆分前的全文;保留其他原生诊断,避免以后吞掉重要提示。
145
190
  result.pop("systemMessage")
191
+ try:
192
+ workflow_state = _run_workflow_state_refresh(root, hook, hook_input)
193
+ separator = "" if not context or context.endswith("\n") else "\n"
194
+ context = f"{context}{separator}{workflow_state}"
195
+ except Exception as error:
196
+ # 条件注入是可恢复优化;失败时保留原生状态,让下一次用户输入完整降级恢复。
197
+ message = f"workflow-state 基线未刷新:{error}"
198
+ result["systemMessage"] = "\n".join(filter(None, [result.get("systemMessage"), message]))
199
+ print(message, file=sys.stderr)
146
200
  if (hook == HOOKS[0] and hook_input.get("model") == ASTRA_MODEL
147
201
  and hook_input.get("source") in ("startup", "clear", "compact")):
148
202
  try:
@@ -1027,6 +1027,11 @@ function prepareFilePlan(files, targetRoot, targetSpec, operation) {
1027
1027
  targetSpec.path,
1028
1028
  `patch ${operation.id} target.path`,
1029
1029
  );
1030
+ const plannedFile = files.get(targetSpec.path);
1031
+ if (plannedFile) {
1032
+ // 同一轮中前序操作可能刚创建该文件,后续操作应基于内存结果继续计算。
1033
+ return { filePlan: plannedFile };
1034
+ }
1030
1035
  const exists = fs.existsSync(targetFile);
1031
1036
  if (!exists) {
1032
1037
  if (targetSpec.missing === "skip") return { status: "missing-target" };
@@ -1037,22 +1042,19 @@ function prepareFilePlan(files, targetRoot, targetSpec, operation) {
1037
1042
  } else {
1038
1043
  assertExistingPathInside(targetRoot, targetFile, `patch ${operation.id} target`);
1039
1044
  }
1040
- let filePlan = files.get(targetSpec.path);
1041
- if (!filePlan) {
1042
- const original = exists ? fs.readFileSync(targetFile, "utf8") : null;
1043
- filePlan = {
1044
- target: targetSpec.path,
1045
- targetFile,
1046
- original,
1047
- originalExists: exists,
1048
- next: original ?? "",
1049
- operations: [],
1050
- patches: [],
1051
- bundles: [],
1052
- operationEntries: [],
1053
- };
1054
- files.set(targetSpec.path, filePlan);
1055
- }
1045
+ const original = exists ? fs.readFileSync(targetFile, "utf8") : null;
1046
+ const filePlan = {
1047
+ target: targetSpec.path,
1048
+ targetFile,
1049
+ original,
1050
+ originalExists: exists,
1051
+ next: original ?? "",
1052
+ operations: [],
1053
+ patches: [],
1054
+ bundles: [],
1055
+ operationEntries: [],
1056
+ };
1057
+ files.set(targetSpec.path, filePlan);
1056
1058
  return { filePlan };
1057
1059
  }
1058
1060
 
@@ -226,6 +226,29 @@ function parseYamlKeyContent(content) {
226
226
  return { desiredValue: parsed.value, commentSection };
227
227
  }
228
228
 
229
+ function parseYamlCommentSectionContent(content) {
230
+ if (typeof content !== "string" || !content.trimStart().startsWith("{")) {
231
+ return { error: "yaml-comment-section content 必须是合法 JSON 对象" };
232
+ }
233
+ let parsed;
234
+ try {
235
+ parsed = JSON.parse(content);
236
+ } catch {
237
+ return { error: "yaml-comment-section content 必须是合法 JSON 对象" };
238
+ }
239
+ if (
240
+ !isPlainObject(parsed) ||
241
+ typeof parsed.heading !== "string" ||
242
+ !parsed.heading ||
243
+ !Array.isArray(parsed.lines) ||
244
+ !parsed.lines.every((line) => typeof line === "string") ||
245
+ !["skip", "error"].includes(parsed.missing || "error")
246
+ ) {
247
+ return { error: "yaml-comment-section content 配置无效" };
248
+ }
249
+ return { section: parsed };
250
+ }
251
+
229
252
  function replaceYamlCommentSection(lines, section) {
230
253
  if (!section) return { lines };
231
254
  const heading = `# ${section.heading}`;
@@ -254,7 +277,14 @@ function replaceYamlCommentSection(lines, section) {
254
277
  break;
255
278
  }
256
279
  }
257
- if (end < 0) return { error: `YAML 注释段缺少结束分隔线:${section.heading}` };
280
+ if (end < 0) {
281
+ if (dividerCount === 0) {
282
+ return { error: `YAML 注释段缺少标题分隔线:${section.heading}` };
283
+ }
284
+ // 最后一段可能没有下一段分隔线;保留随后的用户顶层配置,否则替换到文件末尾。
285
+ end = lines.findIndex((line, index) => index > headingIndex && isTopLevelKey(line));
286
+ if (end < 0) end = lines.length;
287
+ }
258
288
  lines.splice(start, end - start, ...section.lines);
259
289
  return { lines };
260
290
  }
@@ -329,6 +359,21 @@ function applyYamlKey({ value, operation }) {
329
359
  return { value: desired, source: "structured" };
330
360
  }
331
361
 
362
+ function applyYamlCommentSection({ value, operation }) {
363
+ if (operation.operation !== "replace") {
364
+ return { error: "yaml-comment-section 只支持 replace" };
365
+ }
366
+ const parsed = parseYamlCommentSectionContent(operation.content);
367
+ if (parsed.error) return parsed;
368
+ const normalized = value.replace(/\r\n/g, "\n");
369
+ const hadFinalNewline = normalized.endsWith("\n");
370
+ const lines = normalized ? normalized.replace(/\n$/, "").split("\n") : [];
371
+ const commentResult = replaceYamlCommentSection(lines, parsed.section);
372
+ if (commentResult.error) return { error: commentResult.error };
373
+ const desired = lines.join("\n") + (hadFinalNewline || lines.length > 0 ? "\n" : "");
374
+ return { value: desired, source: "structured" };
375
+ }
376
+
332
377
  function parseTomlSectionHeader(line) {
333
378
  const value = line.trim();
334
379
  if (!value.startsWith("[")) return { header: null };
@@ -412,6 +457,7 @@ function applyTomlSection({ value, operation }) {
412
457
  export function flowerPatchAdapters(pythonCommand = "python3") {
413
458
  return {
414
459
  "json-hook-command": (context) => applyJsonHookCommand({ ...context, pythonCommand }),
460
+ "yaml-comment-section": applyYamlCommentSection,
415
461
  "yaml-key": applyYamlKey,
416
462
  "toml-section": applyTomlSection,
417
463
  };
@@ -6,6 +6,7 @@
6
6
  "patches": [
7
7
  "codex/session-start-hooks",
8
8
  "codex/dispatch-mode",
9
+ "workflow-state-config",
9
10
  "codex/remove-multi-agent-v2",
10
11
  "claude/startup-update-hook",
11
12
  "claude/session-start-hooks",
@@ -0,0 +1,24 @@
1
+ {
2
+ "heading": "Per-turn prompt injection",
3
+ "missing": "skip",
4
+ "lines": [
5
+ "#-------------------------------------------------------------------------------",
6
+ "# Per-turn prompt injection",
7
+ "#-------------------------------------------------------------------------------",
8
+ "# Escape hatch for the per-turn <workflow-state> breadcrumb. When a user",
9
+ "# prompt contains the skip keyword as a standalone word (case-insensitive,",
10
+ "# word-boundary match — \"no-trellisfoo\" does NOT count), the breadcrumb is",
11
+ "# skipped for that turn only. Does not affect SessionStart or sub-agent",
12
+ "# context injection.",
13
+ "#",
14
+ "# Codex and Claude suppress unchanged workflow-state bodies. A short heartbeat",
15
+ "# is emitted after the configured number of unchanged user prompts. The default",
16
+ "# is 5; 0 disables heartbeats; invalid or negative values fall back to 5.",
17
+ "# State changes and startup / clear / compact still emit the full state.",
18
+ "#",
19
+ "# prompt_injection:",
20
+ "# skip_keyword: \"no-trellis\" # \"\" disables the escape hatch entirely",
21
+ "# heartbeat_turns: 5 # 0 disables unchanged-state heartbeats",
22
+ ""
23
+ ]
24
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "id": "flower-workflow-state-config",
4
+ "purpose": "platform_config",
5
+ "operations": [
6
+ {
7
+ "id": "flower-workflow-state-config-codex",
8
+ "operation": "replace",
9
+ "targets": [
10
+ {
11
+ "kind": "yaml",
12
+ "path": ".trellis/config.yaml",
13
+ "missing": "skip",
14
+ "requires": [".codex"]
15
+ }
16
+ ],
17
+ "selector": { "type": "yaml-comment-section" },
18
+ "content": { "source": "comment-section.json.txt" }
19
+ },
20
+ {
21
+ "id": "flower-workflow-state-config-claude",
22
+ "operation": "replace",
23
+ "after": ["flower-workflow-state-config-codex"],
24
+ "targets": [
25
+ {
26
+ "kind": "yaml",
27
+ "path": ".trellis/config.yaml",
28
+ "missing": "skip",
29
+ "requires": [".claude"]
30
+ }
31
+ ],
32
+ "selector": { "type": "yaml-comment-section" },
33
+ "content": { "source": "comment-section.json.txt" }
34
+ }
35
+ ]
36
+ }