flower-trellis 0.5.2-beta.1 → 0.5.2

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.
@@ -5,6 +5,7 @@
5
5
  "installMode": "full-only",
6
6
  "patches": [
7
7
  "scripts/task-store-write-integrity",
8
- "scripts/runtime-state-integrity"
8
+ "scripts/runtime-state-integrity",
9
+ "scripts/session-context-update-boundary"
9
10
  ]
10
11
  }
@@ -1101,6 +1101,28 @@
1101
1101
  },
1102
1102
  "owner": "trellis-route",
1103
1103
  "reason": "Claude workflow meta 必须与 shared route owner 模型一致。"
1104
+ },
1105
+ {
1106
+ "id": "session-context-no-legacy-update-check",
1107
+ "severity": "error",
1108
+ "target": ".trellis/scripts/common/session_context.py",
1109
+ "whenOperations": [
1110
+ "session-context-update-imports",
1111
+ "session-context-update-constants",
1112
+ "session-context-update-helpers",
1113
+ "session-context-update-output"
1114
+ ],
1115
+ "assertion": {
1116
+ "type": "absent-literal",
1117
+ "values": [
1118
+ "def _get_update_hint(",
1119
+ "def _mark_update_check_attempted(",
1120
+ "def _update_marker_path(",
1121
+ "[\"trellis\", \"--version\"]"
1122
+ ]
1123
+ },
1124
+ "owner": "flower-update-hook",
1125
+ "reason": "默认 Session Context 只负责项目上下文;更新检查必须统一由 Flower SessionStart Hook 进入。"
1104
1126
  }
1105
1127
  ]
1106
1128
  }
@@ -0,0 +1,6 @@
1
+ _PACKAGE_NAME = "@mindfoldhq/trellis"
2
+ _UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
3
+ _VERSION_RE = re.compile(
4
+ r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
5
+ )
6
+ _VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
@@ -0,0 +1,150 @@
1
+ def _read_project_version(repo_root: Path) -> str | None:
2
+ try:
3
+ version = (repo_root / DIR_WORKFLOW / ".version").read_text(
4
+ encoding="utf-8"
5
+ ).strip()
6
+ except OSError:
7
+ return None
8
+ return version or None
9
+
10
+
11
+ def _fetch_trellis_version_output() -> str | None:
12
+ try:
13
+ result = subprocess.run(
14
+ ["trellis", "--version"],
15
+ capture_output=True,
16
+ text=True,
17
+ encoding="utf-8",
18
+ errors="replace",
19
+ timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
20
+ )
21
+ except (OSError, subprocess.SubprocessError, TimeoutError):
22
+ return None
23
+
24
+ if result.returncode != 0:
25
+ return None
26
+ output = f"{result.stdout}\n{result.stderr}".strip()
27
+ return output or None
28
+
29
+
30
+ def _extract_available_update_version(output: str) -> str | None:
31
+ update_match = re.search(
32
+ r"Trellis update available:\s*"
33
+ r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
34
+ output,
35
+ )
36
+ if update_match:
37
+ return update_match.group("latest").strip()
38
+ candidates = _VERSION_TOKEN_RE.findall(output)
39
+ return candidates[-1] if candidates else None
40
+
41
+
42
+ def _resolve_available_update_version() -> str | None:
43
+ output = _fetch_trellis_version_output()
44
+ if not output:
45
+ return None
46
+ return _extract_available_update_version(output)
47
+
48
+
49
+ def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
50
+ match = _VERSION_RE.match(version)
51
+ if not match:
52
+ return None
53
+ major, minor, patch, prerelease = match.groups()
54
+ numbers = (int(major), int(minor or "0"), int(patch or "0"))
55
+ prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
56
+ return numbers, prerelease_parts
57
+
58
+
59
+ def _compare_prerelease(
60
+ left: tuple[str, ...] | None,
61
+ right: tuple[str, ...] | None,
62
+ ) -> int:
63
+ if left is None and right is None:
64
+ return 0
65
+ if left is None:
66
+ return 1
67
+ if right is None:
68
+ return -1
69
+
70
+ for left_part, right_part in zip(left, right):
71
+ if left_part == right_part:
72
+ continue
73
+ left_numeric = left_part.isdigit()
74
+ right_numeric = right_part.isdigit()
75
+ if left_numeric and right_numeric:
76
+ left_int = int(left_part)
77
+ right_int = int(right_part)
78
+ return (left_int > right_int) - (left_int < right_int)
79
+ if left_numeric:
80
+ return -1
81
+ if right_numeric:
82
+ return 1
83
+ return (left_part > right_part) - (left_part < right_part)
84
+
85
+ return (len(left) > len(right)) - (len(left) < len(right))
86
+
87
+
88
+ def _compare_versions(left: str, right: str) -> int | None:
89
+ parsed_left = _parse_version(left)
90
+ parsed_right = _parse_version(right)
91
+ if parsed_left is None or parsed_right is None:
92
+ return None
93
+
94
+ left_numbers, left_prerelease = parsed_left
95
+ right_numbers, right_prerelease = parsed_right
96
+ if left_numbers != right_numbers:
97
+ return (left_numbers > right_numbers) - (left_numbers < right_numbers)
98
+ return _compare_prerelease(left_prerelease, right_prerelease)
99
+
100
+
101
+ def _update_marker_path(repo_root: Path) -> Path:
102
+ context_key = resolve_context_key()
103
+ if not context_key:
104
+ terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
105
+ context_key = terminal_key or f"ppid-{os.getppid()}"
106
+ safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
107
+ if not safe_key:
108
+ safe_key = "session"
109
+ return (
110
+ repo_root
111
+ / DIR_WORKFLOW
112
+ / ".runtime"
113
+ / f"update-check-{safe_key[:160]}.marker"
114
+ )
115
+
116
+
117
+ def _mark_update_check_attempted(repo_root: Path) -> bool:
118
+ marker_path = _update_marker_path(repo_root)
119
+ if marker_path.exists():
120
+ return False
121
+ try:
122
+ marker_path.parent.mkdir(parents=True, exist_ok=True)
123
+ marker_path.write_text("checked\n", encoding="utf-8")
124
+ except OSError:
125
+ pass
126
+ return True
127
+
128
+
129
+ def _get_update_hint(repo_root: Path) -> str | None:
130
+ marker_path = _update_marker_path(repo_root)
131
+ if marker_path.exists():
132
+ return None
133
+
134
+ current_version = _read_project_version(repo_root)
135
+ if not current_version:
136
+ return None
137
+
138
+ latest_version = _resolve_available_update_version()
139
+ if not latest_version:
140
+ return None
141
+
142
+ _mark_update_check_attempted(repo_root)
143
+ comparison = _compare_versions(current_version, latest_version)
144
+ if comparison is None or comparison >= 0:
145
+ return None
146
+
147
+ return (
148
+ f"Trellis update available: {current_version} -> {latest_version}, "
149
+ "run trellis upgrade"
150
+ )
@@ -0,0 +1,4 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from .config import get_git_packages
@@ -0,0 +1,8 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from .active_task import resolve_context_key
8
+ from .config import get_git_packages
@@ -0,0 +1,7 @@
1
+ def output_text(repo_root: Path | None = None) -> None:
2
+ """Output context in text format.
3
+
4
+ Args:
5
+ repo_root: Repository root path. Defaults to auto-detected.
6
+ """
7
+ print(get_context_text(repo_root))
@@ -0,0 +1,13 @@
1
+ def output_text(repo_root: Path | None = None) -> None:
2
+ """Output context in text format.
3
+
4
+ Args:
5
+ repo_root: Repository root path. Defaults to auto-detected.
6
+ """
7
+ if repo_root is None:
8
+ repo_root = get_repo_root()
9
+ update_hint = _get_update_hint(repo_root)
10
+ if update_hint:
11
+ print(update_hint)
12
+ print("")
13
+ print(get_context_text(repo_root))
@@ -0,0 +1,53 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "id": "session-context-update-boundary",
4
+ "purpose": "control_plane_integrity",
5
+ "operations": [
6
+ {
7
+ "id": "session-context-update-imports",
8
+ "operation": "replace",
9
+ "targets": [{
10
+ "kind": "file",
11
+ "path": ".trellis/scripts/common/session_context.py",
12
+ "missing": "error",
13
+ "markerStyle": "hash"
14
+ }],
15
+ "selector": { "type": "literal", "source": "imports-selector.py" },
16
+ "content": { "source": "imports-content.py" }
17
+ },
18
+ {
19
+ "id": "session-context-update-constants",
20
+ "operation": "remove",
21
+ "targets": [{
22
+ "kind": "file",
23
+ "path": ".trellis/scripts/common/session_context.py",
24
+ "missing": "error",
25
+ "markerStyle": "hash"
26
+ }],
27
+ "selector": { "type": "literal", "source": "constants-selector.py" }
28
+ },
29
+ {
30
+ "id": "session-context-update-helpers",
31
+ "operation": "remove",
32
+ "targets": [{
33
+ "kind": "file",
34
+ "path": ".trellis/scripts/common/session_context.py",
35
+ "missing": "error",
36
+ "markerStyle": "hash"
37
+ }],
38
+ "selector": { "type": "literal", "source": "helpers-selector.py" }
39
+ },
40
+ {
41
+ "id": "session-context-update-output",
42
+ "operation": "replace",
43
+ "targets": [{
44
+ "kind": "file",
45
+ "path": ".trellis/scripts/common/session_context.py",
46
+ "missing": "error",
47
+ "markerStyle": "hash"
48
+ }],
49
+ "selector": { "type": "literal", "source": "output-selector.py" },
50
+ "content": { "source": "output-content.py" }
51
+ }
52
+ ]
53
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
- "syncedAt": "2026-07-29T10:02:05.260Z",
2
+ "syncedAt": "2026-07-29T11:34:14.834Z",
3
3
  "syncedFrom": "vendor/skill-garden",
4
- "sourceCommit": "1812258cdd48d99b97e8dbab41f5203134e411df",
4
+ "sourceCommit": "9c35c52409d53198738405861b730d942de7efb0",
5
5
  "common": {
6
6
  "codexSkills": [
7
7
  "craft-rpa",
@@ -161,6 +161,13 @@
161
161
  "scripts/runtime-state-integrity/active-task-set-content.py",
162
162
  "scripts/runtime-state-integrity/active-task-set-selector.py",
163
163
  "scripts/runtime-state-integrity/patch.json",
164
+ "scripts/session-context-update-boundary/constants-selector.py",
165
+ "scripts/session-context-update-boundary/helpers-selector.py",
166
+ "scripts/session-context-update-boundary/imports-content.py",
167
+ "scripts/session-context-update-boundary/imports-selector.py",
168
+ "scripts/session-context-update-boundary/output-content.py",
169
+ "scripts/session-context-update-boundary/output-selector.py",
170
+ "scripts/session-context-update-boundary/patch.json",
164
171
  "scripts/task-start-brief-gate/degraded-content.py",
165
172
  "scripts/task-start-brief-gate/degraded-selector.py",
166
173
  "scripts/task-start-brief-gate/guard-content.py",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flower-trellis",
3
- "version": "0.5.2-beta.1",
3
+ "version": "0.5.2",
4
4
  "description": "一键安装/升级 Trellis 并自动融合 skill-garden 强化包(默认 Claude + agents)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -68,9 +68,9 @@
68
68
  "commit-and-tag-version": "^12.7.3"
69
69
  },
70
70
  "flowerReleaseNotes": {
71
- "version": "0.5.2-beta.1",
71
+ "version": "0.5.2",
72
72
  "source": "CHANGELOG.md",
73
- "body": "### ✨ 新功能 Features\n\n* **0.6:** 同步 Push 门禁与意图识别 ([8abf3b1](https://github.com/SilentFlower/flower-trellis/commit/8abf3b1439d7a5dd2af0d698c4094aa6c7207fb3))\n* **brief:** 同步 Brief 显式预授权流程 ([7c179af](https://github.com/SilentFlower/flower-trellis/commit/7c179affd7389cac053d28104e0d624773159c72))\n* **spec-router:** 同步章节感知加载能力 ([0d51a24](https://github.com/SilentFlower/flower-trellis/commit/0d51a24e6136bb9b167cd5716eeb9958b5d8412e))\n* **trellis:** 补全 Meta 架构并修复 Auto-Loop 自恢复 ([943179f](https://github.com/SilentFlower/flower-trellis/commit/943179f42e3e3d068bf98f8ff60b2aa324acc4dd))\n\n\n### 🐛 修复 Bug Fixes\n\n* **trellis:** 移除旧更新检查 marker ([a33f376](https://github.com/SilentFlower/flower-trellis/commit/a33f376d7efe15441cba8fbe0b0a67c6f1a15f6c))",
73
+ "body": "### ✨ 新功能 Features\n\n* **0.6:** 同步 Push 门禁与意图识别 ([8abf3b1](https://github.com/SilentFlower/flower-trellis/commit/8abf3b1439d7a5dd2af0d698c4094aa6c7207fb3))\n* **brief:** 同步 Brief 显式预授权流程 ([7c179af](https://github.com/SilentFlower/flower-trellis/commit/7c179affd7389cac053d28104e0d624773159c72))\n* **spec-router:** 同步章节感知加载能力 ([0d51a24](https://github.com/SilentFlower/flower-trellis/commit/0d51a24e6136bb9b167cd5716eeb9958b5d8412e))\n* **trellis:** 补全 Meta 架构并修复 Auto-Loop 自恢复 ([943179f](https://github.com/SilentFlower/flower-trellis/commit/943179f42e3e3d068bf98f8ff60b2aa324acc4dd))\n* **plugin:** 建立契约与项目状态存储 ([3c664c3](https://github.com/SilentFlower/flower-trellis/commit/3c664c337806849d7bf040f269eb0f1ebed115f0))\n* **plugin:** 支持 GitHub 外部格式与交互来源管理 ([8701039](https://github.com/SilentFlower/flower-trellis/commit/8701039ea8f8018e7e8d017c0ac5f545b9b2549b))\n* **plugin:** 新增 GitLab 市场外部来源 ([3688744](https://github.com/SilentFlower/flower-trellis/commit/3688744d7663952d437f4bc4e5c8187798c45453))\n* **plugin:** 新增插件能力授权策略 ([32ec993](https://github.com/SilentFlower/flower-trellis/commit/32ec993de77686fb411451c4165ca17367cf5e33))\n* **plugin:** 新增插件运行时生命周期 ([0d7c5b1](https://github.com/SilentFlower/flower-trellis/commit/0d7c5b1ae4821e8cbadcd857608399c2e725c560))\n* **plugin:** 添加作者工具与 CI 契约 ([4e34b87](https://github.com/SilentFlower/flower-trellis/commit/4e34b878f37fddb3732b2264b90f79b3610c82d0))\n* **plugin:** 迁移 skill-garden 到插件运行时 ([2e9fd18](https://github.com/SilentFlower/flower-trellis/commit/2e9fd18123e3164a68971725269619401b2625b4))\n* 增加 Check-All 下一步引导 ([fa488f8](https://github.com/SilentFlower/flower-trellis/commit/fa488f83c85983b8b8c545b85e4e42d20b290bdf))\n\n\n### 🐛 修复 Bug Fixes\n\n* **trellis:** 移除旧更新检查 marker ([a33f376](https://github.com/SilentFlower/flower-trellis/commit/a33f376d7efe15441cba8fbe0b0a67c6f1a15f6c))\n* **plugin:** 优化外部来源交互与 archive 安全检测 ([4699a22](https://github.com/SilentFlower/flower-trellis/commit/4699a2211cb280e5a085c852950fda52af28c456))\n* **plugin:** 修复远程更新并完成集成验收 ([3db8320](https://github.com/SilentFlower/flower-trellis/commit/3db83208a6da12c3bbcad1730e144271ff02a96a))\n* **trellis:** 优化 auto-loop 重试预算与跟踪状态 ([19d7a1a](https://github.com/SilentFlower/flower-trellis/commit/19d7a1ae5302af56085e1053706442ad7cb347b0))\n* **trellis:** 支持跳过 Check-All 并修复 update -y ([1bd0a12](https://github.com/SilentFlower/flower-trellis/commit/1bd0a126f91f6b915d2253f1a497bda40fc41edb))\n* 修复 Windows Trellis Patch 命令漂移 ([5892e7f](https://github.com/SilentFlower/flower-trellis/commit/5892e7f7629be0a7ee075a3758f594a220c7db1f))\n\n\n### ♻️ 重构 Refactor\n\n* **trellis:** 拆分 Check-All 检查档位并同步快照 ([ef1a8c4](https://github.com/SilentFlower/flower-trellis/commit/ef1a8c434244c06cf565c449173c6d6763a0a160))\n\n\n### 🧰 维护 Maintenance\n\n* **trellis:** 同步 Flower 0.5.1-beta.1 清单 ([d3dfc27](https://github.com/SilentFlower/flower-trellis/commit/d3dfc2759927b0759b10e2ae246b68438dc3d339))\n* **trellis:** 执行 Flower 自更新同步 ([f79ce33](https://github.com/SilentFlower/flower-trellis/commit/f79ce33bcc409129a054f1b59a016cdbe1ea991b))",
74
74
  "truncated": false
75
75
  },
76
76
  "optionalDependencies": {
@@ -20,6 +20,7 @@ import { PKG_ROOT } from "../../lib/paths.js";
20
20
  import { injectWorkflow } from "../../lib/workflow-inject.js";
21
21
  import { applyCodexTweaks } from "../../lib/codex-tweaks.js";
22
22
  import { applyClaudeTweaks } from "../../lib/claude-tweaks.js";
23
+ import { materializeTrellisPythonText } from "../../lib/trellis-python-command.js";
23
24
  import { describeInstalledCommonSkillSync } from "../../lib/skill-catalog.js";
24
25
  import {
25
26
  PLUGIN_RUNTIME_ERROR_CODES,
@@ -46,6 +47,7 @@ const SCRIPT_ALIASES = Object.freeze({
46
47
  "intent-routing", "auto-loop", "auto-loop-runner", "trellis-auto-loop",
47
48
  ],
48
49
  });
50
+ const TRELLIS_TEXT_EXTENSIONS = new Set([".json", ".md", ".toml", ".txt", ".yaml", ".yml"]);
49
51
 
50
52
  /**
51
53
  * 列出目标目录中的普通文件。
@@ -66,7 +68,7 @@ function listExistingFiles(directory) {
66
68
  */
67
69
  export function projectSkillGardenContent(options) {
68
70
  const { projectRoot, resolved, pluginPackage } = options;
69
- const { variantDir, skills } = pluginPackage.skillGarden;
71
+ const { variantDir, skills, pythonCommand } = pluginPackage.skillGarden;
70
72
  const payloads = new Map();
71
73
  const mutations = [];
72
74
  const directoryRemovals = [];
@@ -86,20 +88,24 @@ export function projectSkillGardenContent(options) {
86
88
  * @param {Buffer} content 最终字节
87
89
  * @param {string} source 来源说明
88
90
  * @param {"exclusive"|"shared"} ownership 所有权
91
+ * @param {boolean} materializePython 是否按目标项目命令物化明确文本载荷
89
92
  */
90
- function addFile(target, content, source, ownership = "exclusive") {
93
+ function addFile(target, content, source, ownership = "exclusive", materializePython = false) {
91
94
  assertSafePosixRelativePath(target, "skill-garden 投影目标");
92
95
  const absoluteTarget = path.join(projectRoot, ...target.split("/"));
96
+ const payload = materializePython && TRELLIS_TEXT_EXTENSIONS.has(path.extname(target))
97
+ ? Buffer.from(materializeTrellisPythonText(content.toString("utf8"), pythonCommand))
98
+ : content;
93
99
  const mutation = {
94
100
  owner: resolved.id,
95
101
  target,
96
102
  operation: "write",
97
103
  beforeHash: hashFileIfExists(absoluteTarget),
98
- afterHash: hashContent(content),
104
+ afterHash: hashContent(payload),
99
105
  source,
100
106
  allowUnownedWrite: true,
101
107
  };
102
- payloads.set(contentMutationKey(mutation), content);
108
+ payloads.set(contentMutationKey(mutation), payload);
103
109
  mutations.push(mutation);
104
110
  paths.set(target, {
105
111
  path: target,
@@ -116,13 +122,26 @@ export function projectSkillGardenContent(options) {
116
122
  * @param {string} targetRoot 目标目录
117
123
  * @param {string} sourceLabel 来源标签
118
124
  * @param {"exclusive"|"shared"} ownership 所有权
125
+ * @param {boolean} materializePython 是否按目标项目命令物化明确文本载荷
119
126
  */
120
- function addTree(sourceRoot, targetRoot, sourceLabel, ownership = "exclusive") {
127
+ function addTree(
128
+ sourceRoot,
129
+ targetRoot,
130
+ sourceLabel,
131
+ ownership = "exclusive",
132
+ materializePython = false,
133
+ ) {
121
134
  const desired = new Set();
122
135
  for (const file of listCanonicalTreeFiles(sourceRoot)) {
123
136
  const target = `${targetRoot}/${file.path}`;
124
137
  desired.add(file.path);
125
- addFile(target, fs.readFileSync(file.absolutePath), `${sourceLabel}:${file.path}`, ownership);
138
+ addFile(
139
+ target,
140
+ fs.readFileSync(file.absolutePath),
141
+ `${sourceLabel}:${file.path}`,
142
+ ownership,
143
+ materializePython,
144
+ );
126
145
  }
127
146
  for (const file of listExistingFiles(path.join(projectRoot, ...targetRoot.split("/")))) {
128
147
  if (desired.has(file.path)) continue;
@@ -192,6 +211,8 @@ export function projectSkillGardenContent(options) {
192
211
  path.join(sourceRoot, entry.name),
193
212
  `${target.root}/${entry.name}`,
194
213
  `skill-garden:${pluginPackage.skillGarden.variant}:${target.source}:${entry.name}`,
214
+ "exclusive",
215
+ pluginPackage.skillGarden.variant === "0.6",
195
216
  );
196
217
  installed.add(entry.name);
197
218
  }
@@ -208,6 +229,8 @@ export function projectSkillGardenContent(options) {
208
229
  `.claude/commands/trellis/${file.name}`,
209
230
  fs.readFileSync(path.join(commandRoot, file.name)),
210
231
  `skill-garden:${pluginPackage.skillGarden.variant}:command:${file.name}`,
232
+ "exclusive",
233
+ pluginPackage.skillGarden.variant === "0.6",
211
234
  );
212
235
  installed.add(name);
213
236
  }
@@ -9,6 +9,7 @@ import { validatePluginManifest } from "../../plugin/schemas/plugin-manifest.js"
9
9
  import { markRuntimeBuiltinProvider } from "../../plugin/runtime-extensions.js";
10
10
  import { BuiltinSourceProvider } from "../../plugin/sources/builtin-provider.js";
11
11
  import { flowerPatchAdapters } from "../../lib/platform-patch-adapters.js";
12
+ import { resolveTrellisPythonCommand } from "../../lib/trellis-python-command.js";
12
13
  import { projectSkillGardenContent } from "./content-adapter.js";
13
14
 
14
15
  /** 内置 skill-garden canonical Plugin ID。 */
@@ -97,6 +98,7 @@ function runtimeManifest(variant) {
97
98
  */
98
99
  function ensureSkillGardenReady(provider) {
99
100
  if (provider.snapshot) return;
101
+ provider.pythonCommand = resolveTrellisPythonCommand(provider.projectRoot);
100
102
  provider.snapshot = resolveEnhancementSnapshot(provider.projectRoot, provider.variantOverride);
101
103
  provider.manifest = runtimeManifest(provider.snapshot.variant);
102
104
  provider.integrity = stablePayloadDigest([
@@ -145,6 +147,7 @@ export class SkillGardenBuiltinProvider {
145
147
  this.manifest = null;
146
148
  this.integrity = null;
147
149
  this.resolutionManifest = null;
150
+ this.pythonCommand = null;
148
151
  if (fs.existsSync(path.join(this.projectRoot, ".trellis", ".version"))) ensureSkillGardenReady(this);
149
152
  markRuntimeBuiltinProvider(this);
150
153
  }
@@ -208,11 +211,13 @@ export class SkillGardenBuiltinProvider {
208
211
  compatibilityFile: path.join(this.snapshot.variantDir, "overrides", "compatibility.json"),
209
212
  conflictsFile: path.join(this.snapshot.variantDir, "overrides", "conflicts.json"),
210
213
  },
214
+ textMaterialization: { trellisPythonCommand: this.pythonCommand.command },
211
215
  },
212
216
  {
213
217
  id: "flower",
214
218
  patchesDir: path.join(PKG_ROOT, "src", "patches", "platforms"),
215
219
  bundlesDir: path.join(PKG_ROOT, "src", "patches", "bundles"),
220
+ textMaterialization: { trellisPythonCommand: this.pythonCommand.command },
216
221
  },
217
222
  ] : [];
218
223
  return {
@@ -220,10 +225,16 @@ export class SkillGardenBuiltinProvider {
220
225
  manifest: this.resolutionManifest,
221
226
  integrity: acceptedIntegrity,
222
227
  catalogs,
223
- systemAdapters: this.snapshot.variant === "0.6" ? flowerPatchAdapters() : {},
228
+ systemAdapters: this.snapshot.variant === "0.6"
229
+ ? flowerPatchAdapters(this.pythonCommand.command)
230
+ : {},
224
231
  patchOptions: { skills: this.skills },
225
232
  allowContentPatchOverlap: true,
226
- skillGarden: { ...this.snapshot, skills: this.skills },
233
+ skillGarden: {
234
+ ...this.snapshot,
235
+ skills: this.skills,
236
+ pythonCommand: this.pythonCommand.command,
237
+ },
227
238
  };
228
239
  }
229
240
 
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { resolvePatchCatalogPolicy } from "./patch-engine.js";
4
+ import { materializeTrellisPythonText } from "./trellis-python-command.js";
4
5
 
5
6
  const SEVERITIES = new Set(["error", "warning", "info"]);
6
7
  const ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -155,6 +156,27 @@ function validateConflicts(raw) {
155
156
  return raw;
156
157
  }
157
158
 
159
+ function materializeConflictAssertions(conflicts, command) {
160
+ if (command === undefined) return conflicts;
161
+ return {
162
+ ...conflicts,
163
+ rules: conflicts.rules.map((rule) => ({
164
+ ...rule,
165
+ assertion: rule.assertion.type === "max-occurrences"
166
+ ? {
167
+ ...rule.assertion,
168
+ value: materializeTrellisPythonText(rule.assertion.value, command),
169
+ }
170
+ : {
171
+ ...rule.assertion,
172
+ values: rule.assertion.values.map((value) =>
173
+ materializeTrellisPythonText(value, command)
174
+ ),
175
+ },
176
+ })),
177
+ };
178
+ }
179
+
158
180
  function parseVersion(value) {
159
181
  if (typeof value !== "string") return null;
160
182
  const match = value.match(SEMVER_RE);
@@ -253,7 +275,7 @@ export function loadPatchPolicy(overridesDir, catalogId = "skill-garden") {
253
275
  /**
254
276
  * 按 catalog descriptor 加载并校验其声明式 Patch policy。
255
277
  *
256
- * @param {Array<{id:string,policy?:{compatibilityFile?:string,conflictsFile?:string}}>} catalogs catalog 描述符
278
+ * @param {Array<{id:string,policy?:{compatibilityFile?:string,conflictsFile?:string},textMaterialization?:{trellisPythonCommand:string}}>} catalogs catalog 描述符
257
279
  * @returns {Array<{catalog:string,compatibility?:object,conflicts?:object}>} 已校验的 policy 列表
258
280
  */
259
281
  export function loadPatchPolicies(catalogs) {
@@ -271,10 +293,14 @@ export function loadPatchPolicies(catalogs) {
271
293
  ));
272
294
  }
273
295
  if (paths.conflictsFile) {
274
- policy.conflicts = validateConflicts(readJson(
296
+ const conflicts = validateConflicts(readJson(
275
297
  paths.conflictsFile,
276
298
  `${catalogId} conflict policy`,
277
299
  ));
300
+ policy.conflicts = materializeConflictAssertions(
301
+ conflicts,
302
+ catalog.textMaterialization?.trellisPythonCommand,
303
+ );
278
304
  }
279
305
  if (!policy.compatibility && !policy.conflicts) {
280
306
  throw new Error(`catalog ${catalogId} policy 至少声明一个文件`);
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { preserveFirstBackup } from "./backup.js";
5
5
  import { shouldInstallName } from "./skill-filter.js";
6
+ import { materializeTrellisPythonText } from "./trellis-python-command.js";
6
7
 
7
8
  const PATCH_SCHEMA_VERSION = 2;
8
9
  const BUNDLE_SCHEMA_VERSION = 1;
@@ -691,9 +692,45 @@ function resolveCatalogPolicy(catalog, catalogFiles) {
691
692
  return policy;
692
693
  }
693
694
 
695
+ function resolveCatalogPythonCommand(catalog) {
696
+ if (catalog.textMaterialization === undefined) return null;
697
+ assertPlainObject(catalog.textMaterialization, `catalog ${catalog.id} textMaterialization`);
698
+ const keys = Object.keys(catalog.textMaterialization);
699
+ if (keys.length !== 1 || keys[0] !== "trellisPythonCommand") {
700
+ throw new Error(`catalog ${catalog.id} textMaterialization 只支持 trellisPythonCommand`);
701
+ }
702
+ const command = catalog.textMaterialization.trellisPythonCommand;
703
+ if (typeof command !== "string" || !command.trim()) {
704
+ throw new Error(`catalog ${catalog.id} textMaterialization.trellisPythonCommand 必须是非空字符串`);
705
+ }
706
+ return command;
707
+ }
708
+
709
+ function materializeOperationPythonCommand(operation, command) {
710
+ if (!command) return operation;
711
+ const selectorText = typeof operation.selectorText === "string"
712
+ ? materializeTrellisPythonText(operation.selectorText, command)
713
+ : operation.selectorText;
714
+ return {
715
+ ...operation,
716
+ selector: {
717
+ ...operation.selector,
718
+ ...(typeof operation.selector.text === "string" ? { text: selectorText } : {}),
719
+ },
720
+ selectorText,
721
+ content: typeof operation.content === "string"
722
+ ? materializeTrellisPythonText(operation.content, command)
723
+ : operation.content,
724
+ baselines: operation.baselines.map((baseline) =>
725
+ materializeTrellisPythonText(baseline, command)
726
+ ),
727
+ };
728
+ }
729
+
694
730
  function loadCatalog(catalog, skills, allowedSelectors) {
695
731
  assertPlainObject(catalog, "Patch catalog");
696
732
  assertId(catalog.id, "Patch catalog.id");
733
+ const pythonCommand = resolveCatalogPythonCommand(catalog);
697
734
  const patchesDir = path.resolve(catalog.patchesDir);
698
735
  const bundlesDir = path.resolve(catalog.bundlesDir);
699
736
  const catalogRoot = path.dirname(patchesDir);
@@ -742,9 +779,10 @@ function loadCatalog(catalog, skills, allowedSelectors) {
742
779
  qualifiedId: qualifyId(catalog.id, raw.id),
743
780
  markerIdentity,
744
781
  };
745
- patch.operations = raw.operations.map((operation) =>
746
- normalizeOperation(operation, leafDir, patch, seenOperationIds, allowedSelectors)
747
- );
782
+ patch.operations = raw.operations.map((operation) => materializeOperationPythonCommand(
783
+ normalizeOperation(operation, leafDir, patch, seenOperationIds, allowedSelectors),
784
+ pythonCommand,
785
+ ));
748
786
  patchByRef.set(ref, patch);
749
787
  catalogFiles.push(file, ...listRecursive(leafDir, (candidate) => candidate !== file));
750
788
  }
@@ -25,9 +25,9 @@ function findHookCommand(config, event, needle) {
25
25
  return null;
26
26
  }
27
27
 
28
- function resolveCommand(name, config) {
28
+ function resolveCommand(name, config, pythonCommand) {
29
29
  const codexBase = findHookCommand(config, "UserPromptSubmit", CODEX_WORKFLOW_HOOK) ||
30
- `python3 -X utf8 ${CODEX_WORKFLOW_HOOK}`;
30
+ `${pythonCommand} -X utf8 ${CODEX_WORKFLOW_HOOK}`;
31
31
  if (name === "codex-session-start") {
32
32
  return codexBase.replace(CODEX_WORKFLOW_HOOK, CODEX_SESSION_START);
33
33
  }
@@ -35,14 +35,14 @@ function resolveCommand(name, config) {
35
35
  return codexBase.replace(CODEX_WORKFLOW_HOOK, FLOWER_UPDATE_HOOK_REL);
36
36
  }
37
37
  const claudeBase = findHookCommand(config, "UserPromptSubmit", CLAUDE_WORKFLOW_HOOK) ||
38
- `python3 ${CLAUDE_WORKFLOW_HOOK}`;
38
+ `${pythonCommand} ${CLAUDE_WORKFLOW_HOOK}`;
39
39
  if (name === "claude-flower-update") {
40
40
  return claudeBase.replace(CLAUDE_WORKFLOW_HOOK, FLOWER_UPDATE_HOOK_REL);
41
41
  }
42
42
  throw new Error(`未知 Hook commandResolver:${name}`);
43
43
  }
44
44
 
45
- function applyJsonHookCommand({ value, operation }) {
45
+ function applyJsonHookCommand({ value, operation, pythonCommand = "python3" }) {
46
46
  let config;
47
47
  try {
48
48
  config = value.trim() ? JSON.parse(value) : {};
@@ -86,7 +86,7 @@ function applyJsonHookCommand({ value, operation }) {
86
86
  }
87
87
  let command;
88
88
  try {
89
- command = resolveCommand(content.commandResolver, config);
89
+ command = resolveCommand(content.commandResolver, config, pythonCommand);
90
90
  } catch (error) {
91
91
  return { error: error.message };
92
92
  }
@@ -304,11 +304,12 @@ function applyTomlSection({ value, operation }) {
304
304
  /**
305
305
  * Flower 自有 Patch Adapter 注册表。
306
306
  *
307
+ * @param {string} [pythonCommand] 目标 Trellis 项目实际使用的 Python 命令
307
308
  * @returns {Record<string, Function>} selector type 到受控 Adapter 的映射
308
309
  */
309
- export function flowerPatchAdapters() {
310
+ export function flowerPatchAdapters(pythonCommand = "python3") {
310
311
  return {
311
- "json-hook-command": applyJsonHookCommand,
312
+ "json-hook-command": (context) => applyJsonHookCommand({ ...context, pythonCommand }),
312
313
  "yaml-key": applyYamlKey,
313
314
  "toml-section": applyTomlSection,
314
315
  };
@@ -0,0 +1,95 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const TARGET_EVIDENCE_FILES = Object.freeze([
5
+ ".codex/hooks.json",
6
+ ".claude/settings.json",
7
+ ".trellis/workflow.md",
8
+ ]);
9
+ const GENERATED_COMMAND_RE = /(?:^|[\s`"'([{])(py -3|python3|python)(?=\s+(?:-X\s+utf8\s+)?(?:\.\/)?(?:\.trellis\/scripts|\.codex\/hooks|\.claude\/hooks)\/)/m;
10
+
11
+ /**
12
+ * 校验可用于 Trellis 文本物化的 Python 命令。
13
+ *
14
+ * @param {unknown} value 待校验命令
15
+ * @param {string} label 错误字段说明
16
+ * @returns {string} 去除首尾空白后的命令
17
+ */
18
+ function normalizePythonCommand(value, label) {
19
+ if (typeof value !== "string" || !value.trim()) {
20
+ throw new Error(`${label} 必须是非空字符串`);
21
+ }
22
+ const command = value.trim();
23
+ if (command.includes("\0") || /[\r\n]/.test(command)) {
24
+ throw new Error(`${label} 不能包含换行或 NUL`);
25
+ }
26
+ return command;
27
+ }
28
+
29
+ /**
30
+ * 从 Trellis 已生成文件中识别实际使用的 Python 命令。
31
+ *
32
+ * @param {string} targetRoot 目标项目根目录
33
+ * @returns {{command:string,source:string}|null} 命令及证据来源;没有证据时返回 null
34
+ */
35
+ function resolveGeneratedCommand(targetRoot) {
36
+ for (const relative of TARGET_EVIDENCE_FILES) {
37
+ const file = path.join(targetRoot, ...relative.split("/"));
38
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) continue;
39
+ let value;
40
+ try {
41
+ value = fs.readFileSync(file, "utf8");
42
+ } catch {
43
+ continue;
44
+ }
45
+ const match = value.match(GENERATED_COMMAND_RE);
46
+ if (match) return { command: match[1], source: relative };
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * 解析目标 Trellis 项目实际使用的 Python 命令。
53
+ *
54
+ * 已生成目标文件是最高优先级证据;仅在目标中没有证据时才读取环境变量和平台回退。
55
+ *
56
+ * @param {string} target 目标 Trellis 项目根目录
57
+ * @param {{env?:Record<string,string|undefined>,platform?:string}} [options] 环境与平台覆盖,主要用于测试
58
+ * @returns {{command:string,source:string}} 命令及解析来源
59
+ */
60
+ export function resolveTrellisPythonCommand(target, options = {}) {
61
+ const targetRoot = path.resolve(target);
62
+ const generated = resolveGeneratedCommand(targetRoot);
63
+ if (generated) return generated;
64
+
65
+ const env = options.env || process.env;
66
+ if (env.TRELLIS_PYTHON_CMD !== undefined) {
67
+ return {
68
+ command: normalizePythonCommand(env.TRELLIS_PYTHON_CMD, "TRELLIS_PYTHON_CMD"),
69
+ source: "env:TRELLIS_PYTHON_CMD",
70
+ };
71
+ }
72
+
73
+ const platform = options.platform || process.platform;
74
+ return {
75
+ command: platform === "win32" ? "python" : "python3",
76
+ source: `platform:${platform}`,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * 按 Trellis 模板渲染规则物化文本中的 canonical `python3` 命令。
82
+ *
83
+ * @param {string} value canonical Trellis 文本
84
+ * @param {string} command 目标项目 Python 命令
85
+ * @returns {string} 已物化文本
86
+ */
87
+ export function materializeTrellisPythonText(value, command) {
88
+ if (typeof value !== "string") throw new Error("Trellis Python 文本必须是字符串");
89
+ const normalizedCommand = normalizePythonCommand(command, "Trellis Python 命令");
90
+ if (normalizedCommand === "python3") return value;
91
+ return value
92
+ .split("\n")
93
+ .map((line) => line.startsWith("#!") ? line : line.replaceAll("python3", normalizedCommand))
94
+ .join("\n");
95
+ }