@sakanano-moe/agenthelm 6.5.2 → 6.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/agenthelm.js +1 -1
- package/package.json +2 -2
- package/runtime/index.json +80 -0
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +19 -0
- package/runtime/plugins/agent-pack/.mcp.json +12 -0
- package/runtime/plugins/agent-pack/hooks/hooks.json +16 -0
- package/runtime/plugins/agent-pack/mcp.json +14 -0
- package/runtime/plugins/agent-pack/plugin.json +20 -0
- package/runtime/plugins/agent-pack/references/capability-config.md +124 -0
- package/runtime/{agenthelm.py → plugins/agent-pack/runtime/agenthelm.py} +111 -28
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +388 -0
- package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +46 -0
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +22 -0
- package/runtime/plugins/agents-init/content.json +31 -0
- package/runtime/plugins/agents-init/plugin.json +19 -0
- package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +376 -0
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +119 -0
- package/runtime/plugins/agents-init/skills/agents-init/index.html +357 -0
- package/runtime/plugins/agents-init/skills/agents-init/launcher.sh +93 -0
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +171 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +1190 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +678 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +122 -0
- package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +26 -0
- package/runtime/plugins/opinion-manager/catalog/README.md +15 -0
- package/runtime/plugins/opinion-manager/catalog/rules/.gitkeep +0 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.align-requirements/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.assumptions-authority/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.batch-questions/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.bilingual-terms/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.calculations/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.chinese-response/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.clean-deliverable/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.complete-options/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.conclusion-first/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.concrete-titles/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.consistent-items/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.device-readable/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.editable-diagrams/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.evidence-inference/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-chinese/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-project-language/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.markdown-structure/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.portable-files/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-context/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-formats/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.report-review/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.response-brief/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.response-dense/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.rules-tools/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.semantic-wrap/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-proportional/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-three-level/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.tables-compact/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.tables-list-first/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.test-evidence/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/rules/om.versioned-files/versions/1.0.0.json +11 -0
- package/runtime/plugins/opinion-manager/catalog/templates/.gitkeep +0 -0
- package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/compact-tables/versions/1.0.0.json +21 -0
- package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/list-first/versions/1.0.0.json +21 -0
- package/runtime/plugins/opinion-manager/catalog/templates/development-standard/chinese-maintenance/versions/1.0.0.json +15 -0
- package/runtime/plugins/opinion-manager/catalog/templates/development-standard/project-language/versions/1.0.0.json +15 -0
- package/runtime/plugins/opinion-manager/catalog/templates/response-style/brief-chinese/versions/1.0.0.json +19 -0
- package/runtime/plugins/opinion-manager/catalog/templates/response-style/dense-chinese/versions/1.0.0.json +19 -0
- package/runtime/plugins/opinion-manager/catalog/templates/scenario/concise-project/versions/1.0.0.json +39 -0
- package/runtime/plugins/opinion-manager/catalog/templates/scenario/reviewed-project/versions/1.0.0.json +39 -0
- package/runtime/plugins/opinion-manager/catalog/templates/work-method/proportional/versions/1.0.0.json +20 -0
- package/runtime/plugins/opinion-manager/catalog/templates/work-method/three-level/versions/1.0.0.json +20 -0
- package/runtime/plugins/opinion-manager/content.json +63 -0
- package/runtime/plugins/opinion-manager/plugin.json +18 -0
- package/runtime/plugins/opinion-manager/references/lock.schema.json +14 -0
- package/runtime/plugins/opinion-manager/references/onboarding.md +57 -0
- package/runtime/plugins/opinion-manager/references/profile.schema.json +56 -0
- package/runtime/plugins/opinion-manager/references/rule.schema.json +16 -0
- package/runtime/plugins/opinion-manager/references/template.schema.json +71 -0
- package/runtime/plugins/opinion-manager/references/versioned-template.schema.json +19 -0
- package/runtime/plugins/opinion-manager/references/versioning.md +263 -0
- package/runtime/plugins/opinion-manager/runtime/constants.py +7 -0
- package/runtime/plugins/opinion-manager/runtime/context.py +180 -0
- package/runtime/plugins/opinion-manager/runtime/lifecycle.py +395 -0
- package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +419 -0
- package/runtime/plugins/opinion-manager/runtime/versioned.py +705 -0
- package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +62 -0
- package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +4 -0
- package/runtime/plugins/opinion-manager/templates/.gitkeep +1 -0
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +18 -0
- package/runtime/plugins/project-orchestrator/content.json +37 -0
- package/runtime/plugins/project-orchestrator/plugin.json +19 -0
- package/runtime/plugins/project-orchestrator/references/asset-synonyms.example.json +8 -0
- package/runtime/plugins/project-orchestrator/references/case-workspaces.md +159 -0
- package/runtime/plugins/project-orchestrator/references/case.schema.json +31 -0
- package/runtime/plugins/project-orchestrator/runtime/assets.py +1288 -0
- package/runtime/plugins/project-orchestrator/runtime/case.py +557 -0
- package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +350 -0
- package/runtime/plugins/project-orchestrator/runtime/memory.py +363 -0
- package/runtime/plugins/project-orchestrator/runtime/project_base.py +107 -0
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +599 -0
- package/runtime/plugins/project-orchestrator/runtime/project_demo.py +504 -0
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +470 -0
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +394 -0
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +522 -0
- package/runtime/plugins/project-orchestrator/runtime/project_write.py +329 -0
- package/runtime/plugins/project-orchestrator/runtime/wf.py +439 -0
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +230 -0
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +4 -0
- package/runtime/plugins/project-orchestrator/templates/CASE.md +39 -0
- /package/runtime/{agent_pack_config.py → plugins/agent-pack/runtime/agent_pack_config.py} +0 -0
- /package/runtime/{mcp_env_launcher.py → plugins/agent-pack/runtime/mcp_env_launcher.py} +0 -0
- /package/runtime/{mcp_server.py → plugins/agent-pack/runtime/mcp_server.py} +0 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Case 目录盘点、检索与已审阅映射的无损迁移;不代替项目状态工具。"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import ctypes
|
|
7
|
+
import errno
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import re
|
|
13
|
+
import stat
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
BLOCKED = {"local-ai", "confidential", ".git", ".agents", "credential"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WorkspaceError(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def rename_exclusive(source: Path, target: Path) -> None:
|
|
25
|
+
"""原子拒绝覆盖,避免 exists/rename 之间的并发目标碰撞。"""
|
|
26
|
+
if os.name == 'nt':
|
|
27
|
+
os.rename(source, target)
|
|
28
|
+
return
|
|
29
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
30
|
+
if sys.platform == 'darwin':
|
|
31
|
+
function, args = libc.renamex_np, (os.fsencode(source), os.fsencode(target), 4)
|
|
32
|
+
function.argtypes = (ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint)
|
|
33
|
+
elif sys.platform.startswith('linux') and hasattr(libc, 'renameat2'):
|
|
34
|
+
function = libc.renameat2
|
|
35
|
+
args = (-100, os.fsencode(source), -100, os.fsencode(target), 1)
|
|
36
|
+
function.argtypes = (ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint)
|
|
37
|
+
else:
|
|
38
|
+
raise WorkspaceError('当前平台缺少原子禁止覆盖的 rename')
|
|
39
|
+
if function(*args) != 0:
|
|
40
|
+
code = ctypes.get_errno()
|
|
41
|
+
if code == errno.EEXIST:
|
|
42
|
+
raise WorkspaceError('目标在移动时出现,拒绝覆盖')
|
|
43
|
+
raise OSError(code, os.strerror(code))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def safe_path(root: Path, value: str) -> Path:
|
|
47
|
+
if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
|
|
48
|
+
raise WorkspaceError("非法相对路径")
|
|
49
|
+
relative = Path(value)
|
|
50
|
+
if relative.is_absolute() or ".." in relative.parts or "." == value or re.match(r"^[A-Za-z]:", value):
|
|
51
|
+
raise WorkspaceError("路径必须处于项目内")
|
|
52
|
+
if set(relative.parts) & BLOCKED:
|
|
53
|
+
raise WorkspaceError("迁移范围包含受保护路径")
|
|
54
|
+
path = root
|
|
55
|
+
for part in relative.parts:
|
|
56
|
+
path /= part
|
|
57
|
+
if path.is_symlink():
|
|
58
|
+
raise WorkspaceError("拒绝符号链接")
|
|
59
|
+
return path
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def case_root_path(root: Path) -> Path:
|
|
63
|
+
profile = root / '.agents/moe.sakanano.agent-pack/project.json'
|
|
64
|
+
for parent in (profile, profile.parent, profile.parent.parent):
|
|
65
|
+
if parent.is_symlink():
|
|
66
|
+
raise WorkspaceError('Project Profile 不允许符号链接')
|
|
67
|
+
name = 'cases'
|
|
68
|
+
if profile.exists():
|
|
69
|
+
if not profile.is_file() or profile.stat().st_size > 512 * 1024:
|
|
70
|
+
raise WorkspaceError('非法 Project Profile')
|
|
71
|
+
fd = os.open(profile, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0))
|
|
72
|
+
with os.fdopen(fd, 'r', encoding='utf-8') as stream:
|
|
73
|
+
data = json.load(stream)
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
raise WorkspaceError('Project Profile 必须是对象')
|
|
76
|
+
work = data.get('work') or {}
|
|
77
|
+
if not isinstance(work, dict) or not isinstance(work.get('case') or {}, dict):
|
|
78
|
+
raise WorkspaceError('非法 Case 配置')
|
|
79
|
+
name = (work.get('case') or {}).get('root', name)
|
|
80
|
+
return safe_path(root, name)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def opaque(path: Path) -> bool:
|
|
84
|
+
name = path.name.lower()
|
|
85
|
+
return (name == ".env" or name.startswith(".env.") or name in {"id_rsa", "id_ed25519", "credentials"}
|
|
86
|
+
or any(token in name for token in ("secret", "credential", "cookie", "token", "private-key"))
|
|
87
|
+
or path.suffix.lower() in {".pem", ".key", ".p12", ".pfx"})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def digest_file(path: Path) -> str:
|
|
91
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, 'O_NONBLOCK', 0))
|
|
92
|
+
with os.fdopen(fd, "rb") as stream:
|
|
93
|
+
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
94
|
+
raise WorkspaceError("拒绝非普通文件")
|
|
95
|
+
h = hashlib.sha256()
|
|
96
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
97
|
+
h.update(chunk)
|
|
98
|
+
return h.hexdigest()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def inventory(path: Path) -> dict:
|
|
102
|
+
if path.is_symlink():
|
|
103
|
+
raise WorkspaceError("拒绝符号链接")
|
|
104
|
+
if not path.exists() or not (path.is_dir() or path.is_file()):
|
|
105
|
+
raise WorkspaceError("源目录或文件不存在")
|
|
106
|
+
files = []
|
|
107
|
+
directories = []
|
|
108
|
+
traversal = os.walk(path, followlinks=False) if path.is_dir() else [(path.parent, [], [path.name])]
|
|
109
|
+
for parent, dirs, names in traversal:
|
|
110
|
+
for name in sorted(dirs):
|
|
111
|
+
child = Path(parent) / name
|
|
112
|
+
if child.is_symlink():
|
|
113
|
+
raise WorkspaceError("目录中存在符号链接")
|
|
114
|
+
directories.append(child.relative_to(path).as_posix())
|
|
115
|
+
for name in sorted(names):
|
|
116
|
+
child = Path(parent) / name
|
|
117
|
+
meta = child.lstat()
|
|
118
|
+
if not stat.S_ISREG(meta.st_mode):
|
|
119
|
+
raise WorkspaceError("目录中存在非普通文件")
|
|
120
|
+
item = {"path": child.relative_to(path).as_posix() if path.is_dir() else '.', "bytes": meta.st_size}
|
|
121
|
+
private_ancestor = any(opaque(part) or part.name in BLOCKED for part in (child, *child.parents) if part != path.parent and path.parent in part.parents)
|
|
122
|
+
if private_ancestor or opaque(path):
|
|
123
|
+
item.update(opaque=True, inode=meta.st_ino, device=meta.st_dev)
|
|
124
|
+
else:
|
|
125
|
+
item["sha256"] = digest_file(child)
|
|
126
|
+
files.append(item)
|
|
127
|
+
files.sort(key=lambda item: item["path"])
|
|
128
|
+
payload = {"files": files, "directories": sorted(directories)}
|
|
129
|
+
return {**payload, "file_count": len(files), "bytes": sum(item["bytes"] for item in files),
|
|
130
|
+
"opaque_files": sum(item.get("opaque", False) for item in files),
|
|
131
|
+
"fingerprint": hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def plan_migration(root: Path, mapping: dict) -> dict:
|
|
135
|
+
if not isinstance(mapping, dict) or mapping.get("schema_version") != "1.0":
|
|
136
|
+
raise WorkspaceError("映射必须声明 schema_version=1.0")
|
|
137
|
+
moves = mapping.get("moves")
|
|
138
|
+
records = mapping.get("records", [])
|
|
139
|
+
if not isinstance(moves, list) or not moves or not isinstance(records, list):
|
|
140
|
+
raise WorkspaceError("映射需要非空 moves 数组")
|
|
141
|
+
source_paths, target_paths, planned = [], [], []
|
|
142
|
+
case_parts = case_root_path(root).relative_to(root).parts
|
|
143
|
+
for move in moves:
|
|
144
|
+
if not isinstance(move, dict):
|
|
145
|
+
raise WorkspaceError("非法映射项")
|
|
146
|
+
source = safe_path(root, move.get("source", ""))
|
|
147
|
+
target = safe_path(root, move.get("target", ""))
|
|
148
|
+
relative = target.relative_to(root)
|
|
149
|
+
if len(source.relative_to(root).parts) < 2 or len(relative.parts) < len(case_parts) + 1 or relative.parts[:len(case_parts)] != case_parts:
|
|
150
|
+
raise WorkspaceError("源需要明确子目录,目标需要位于 cases 的工作目录")
|
|
151
|
+
if target.exists():
|
|
152
|
+
raise WorkspaceError("目标已存在,拒绝合并或覆盖")
|
|
153
|
+
manifest = inventory(source)
|
|
154
|
+
if move.get("fingerprint") and move["fingerprint"] != manifest["fingerprint"]:
|
|
155
|
+
raise WorkspaceError("预演后源文件发生变化")
|
|
156
|
+
source_paths.append(source)
|
|
157
|
+
target_paths.append(target)
|
|
158
|
+
planned.append({"source": move["source"], "target": move["target"], "manifest": manifest})
|
|
159
|
+
for collection in (source_paths, target_paths):
|
|
160
|
+
for index, path in enumerate(collection):
|
|
161
|
+
for other in collection[index + 1:]:
|
|
162
|
+
if path == other or path in other.parents or other in path.parents:
|
|
163
|
+
raise WorkspaceError("映射存在重复或嵌套目录")
|
|
164
|
+
for source in source_paths:
|
|
165
|
+
for target in target_paths:
|
|
166
|
+
if source == target or source in target.parents or target in source.parents:
|
|
167
|
+
raise WorkspaceError("源与目标重叠")
|
|
168
|
+
record_paths = []
|
|
169
|
+
for record in records:
|
|
170
|
+
path = safe_path(root, record.get("path", ""))
|
|
171
|
+
parts = path.relative_to(root).parts
|
|
172
|
+
content = record.get("content")
|
|
173
|
+
if len(parts) != len(case_parts) + 2 or parts[:len(case_parts)] != case_parts or parts[-1] != "CASE.md" or not isinstance(content, str):
|
|
174
|
+
raise WorkspaceError("新增记录只能是完整 CASE.md")
|
|
175
|
+
if path.exists() or path in record_paths or len(content.encode()) > 2 * 1024 * 1024:
|
|
176
|
+
raise WorkspaceError("新增记录冲突或超出大小限制")
|
|
177
|
+
if path.parent not in target_paths:
|
|
178
|
+
raise WorkspaceError("新增 CASE.md 必须属于本次迁移目录")
|
|
179
|
+
source = source_paths[target_paths.index(path.parent)]
|
|
180
|
+
if not source.is_dir() or (source / "CASE.md").exists():
|
|
181
|
+
raise WorkspaceError("源已有 CASE.md,请使用项目工具维护现有记录")
|
|
182
|
+
record_paths.append(path)
|
|
183
|
+
return {"ok": True, "applied": False, "moves": planned, "records": [r["path"] for r in records],
|
|
184
|
+
"file_count": sum(item["manifest"]["file_count"] for item in planned),
|
|
185
|
+
"bytes": sum(item["manifest"]["bytes"] for item in planned),
|
|
186
|
+
"opaque_files": sum(item["manifest"]["opaque_files"] for item in planned)}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def migrate(root: Path, mapping: dict, *, apply: bool = False) -> dict:
|
|
190
|
+
result = plan_migration(root, mapping)
|
|
191
|
+
if not apply:
|
|
192
|
+
return result
|
|
193
|
+
moved, created, directories = [], [], []
|
|
194
|
+
try:
|
|
195
|
+
for move in result["moves"]:
|
|
196
|
+
source = safe_path(root, move["source"])
|
|
197
|
+
target = safe_path(root, move["target"])
|
|
198
|
+
# 再核对源指纹,防止预演后并发变动被静默接纳。
|
|
199
|
+
if inventory(source) != move["manifest"]:
|
|
200
|
+
raise WorkspaceError("源目录在应用前发生变化")
|
|
201
|
+
missing = []
|
|
202
|
+
parent = target.parent
|
|
203
|
+
while not parent.exists():
|
|
204
|
+
missing.append(parent)
|
|
205
|
+
parent = parent.parent
|
|
206
|
+
for parent in reversed(missing):
|
|
207
|
+
parent.mkdir()
|
|
208
|
+
directories.append(parent)
|
|
209
|
+
if target.exists():
|
|
210
|
+
raise WorkspaceError("目标在应用前出现")
|
|
211
|
+
if source.stat().st_dev != target.parent.stat().st_dev:
|
|
212
|
+
raise WorkspaceError("跨文件系统移动不受支持")
|
|
213
|
+
rename_exclusive(source, target)
|
|
214
|
+
moved.append((source, target))
|
|
215
|
+
if inventory(target) != move["manifest"]:
|
|
216
|
+
raise WorkspaceError("移动后内容核验失败")
|
|
217
|
+
for record in mapping.get("records", []):
|
|
218
|
+
path = safe_path(root, record["path"])
|
|
219
|
+
with path.open("x", encoding="utf-8") as stream:
|
|
220
|
+
created.append(path)
|
|
221
|
+
stream.write(record["content"])
|
|
222
|
+
stream.flush()
|
|
223
|
+
os.fsync(stream.fileno())
|
|
224
|
+
result["applied"] = True
|
|
225
|
+
return result
|
|
226
|
+
except BaseException:
|
|
227
|
+
for path in reversed(created):
|
|
228
|
+
path.unlink()
|
|
229
|
+
for source, target in reversed(moved):
|
|
230
|
+
if source.exists():
|
|
231
|
+
raise WorkspaceError("回滚源路径冲突,请按映射人工恢复")
|
|
232
|
+
rename_exclusive(target, source)
|
|
233
|
+
for directory in reversed(directories):
|
|
234
|
+
try:
|
|
235
|
+
directory.rmdir()
|
|
236
|
+
except OSError:
|
|
237
|
+
pass
|
|
238
|
+
raise
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def metadata(path: Path) -> dict:
|
|
242
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_size > 2 * 1024 * 1024:
|
|
243
|
+
raise WorkspaceError("非法或过大的 Case 记录")
|
|
244
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0))
|
|
245
|
+
with os.fdopen(fd, 'r', encoding='utf-8') as stream:
|
|
246
|
+
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
247
|
+
raise WorkspaceError('Case 记录必须是普通文件')
|
|
248
|
+
source = stream.read(2 * 1024 * 1024 + 1)
|
|
249
|
+
if len(source.encode()) > 2 * 1024 * 1024:
|
|
250
|
+
raise WorkspaceError('Case 记录过大')
|
|
251
|
+
if not source.startswith("---\n") or "\n---" not in source[4:]:
|
|
252
|
+
raise WorkspaceError("Case 缺少 Frontmatter")
|
|
253
|
+
header = source.split("\n---", 1)[0][4:]
|
|
254
|
+
data, key = {}, None
|
|
255
|
+
for line in header.splitlines():
|
|
256
|
+
if line.startswith(" - ") and key:
|
|
257
|
+
raw = line[4:].strip()
|
|
258
|
+
try:
|
|
259
|
+
value = json.loads(raw)
|
|
260
|
+
except json.JSONDecodeError:
|
|
261
|
+
value = raw.strip("\"'")
|
|
262
|
+
if not isinstance(data[key], list):
|
|
263
|
+
raise WorkspaceError("非法数组字段")
|
|
264
|
+
data[key].append(value)
|
|
265
|
+
elif re.match(r"^[a-z_]+:", line):
|
|
266
|
+
key, raw = line.split(":", 1)
|
|
267
|
+
if key in data:
|
|
268
|
+
raise WorkspaceError("Case 存在重复字段")
|
|
269
|
+
raw = raw.strip()
|
|
270
|
+
try:
|
|
271
|
+
data[key] = json.loads(raw) if raw else []
|
|
272
|
+
except json.JSONDecodeError:
|
|
273
|
+
data[key] = raw.strip("\"'")
|
|
274
|
+
if not all(isinstance(data.get(k), str) and data[k] for k in ("id", "title", "status")):
|
|
275
|
+
raise WorkspaceError("Case 缺少身份与状态")
|
|
276
|
+
return data
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def inspect_cases(root: Path, terms: list[str] | None = None) -> dict:
|
|
280
|
+
case_root = case_root_path(root)
|
|
281
|
+
cases, invalid = [], []
|
|
282
|
+
if not case_root.exists():
|
|
283
|
+
return {"ok": True, "cases": [], "invalid": []}
|
|
284
|
+
words = [term.casefold() for term in terms or []]
|
|
285
|
+
for directory in sorted(case_root.iterdir()):
|
|
286
|
+
if directory.is_symlink():
|
|
287
|
+
invalid.append({"path": directory.relative_to(root).as_posix(), "error": "符号链接"})
|
|
288
|
+
continue
|
|
289
|
+
if not directory.is_dir():
|
|
290
|
+
continue
|
|
291
|
+
if opaque(directory) or directory.name in BLOCKED:
|
|
292
|
+
invalid.append({'path': directory.relative_to(root).as_posix(), 'error': '隐私路径'})
|
|
293
|
+
continue
|
|
294
|
+
try:
|
|
295
|
+
record = directory / "CASE.md"
|
|
296
|
+
data = metadata(record)
|
|
297
|
+
if data['id'] != directory.name or data.get('sensitivity', 'shared') != 'shared':
|
|
298
|
+
raise WorkspaceError('Case 身份或隐私位置不一致')
|
|
299
|
+
files = []
|
|
300
|
+
size = 0
|
|
301
|
+
for parent, dirs, names in os.walk(directory, followlinks=False):
|
|
302
|
+
dirs[:] = [name for name in dirs if not (Path(parent) / name).is_symlink() and name not in BLOCKED and not opaque(Path(name))]
|
|
303
|
+
for name in names:
|
|
304
|
+
path = Path(parent) / name
|
|
305
|
+
if path.is_symlink() or not path.is_file() or opaque(path):
|
|
306
|
+
continue
|
|
307
|
+
files.append(path.relative_to(directory).as_posix())
|
|
308
|
+
size += path.stat().st_size
|
|
309
|
+
haystack = " ".join(str(data.get(key, "")) for key in ("title", "id", "aliases", "legacy_ids", "tags", "goal", "related_projects"))
|
|
310
|
+
haystack = (haystack + " " + " ".join(files)).casefold()
|
|
311
|
+
matches = [word for word in words if word in haystack]
|
|
312
|
+
if words and len(matches) != len(words):
|
|
313
|
+
continue
|
|
314
|
+
cases.append({"path": record.relative_to(root).as_posix(),
|
|
315
|
+
**{key: data.get(key, []) for key in ("id", "title", "status", "legacy_ids", "aliases", "tags", "deliverables")},
|
|
316
|
+
"file_count": len(files), "bytes": size, "matched_terms": matches})
|
|
317
|
+
except (OSError, ValueError) as error:
|
|
318
|
+
invalid.append({"path": directory.relative_to(root).as_posix(), "error": type(error).__name__})
|
|
319
|
+
return {"ok": not invalid, "cases": cases, "invalid": invalid}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def main(argv=None) -> int:
|
|
323
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
324
|
+
parser.add_argument("command", choices=("inspect", "search", "migrate"))
|
|
325
|
+
parser.add_argument("--project", type=Path, required=True)
|
|
326
|
+
parser.add_argument("--map", type=Path)
|
|
327
|
+
parser.add_argument("--term", action="append", default=[])
|
|
328
|
+
parser.add_argument("--apply", action="store_true")
|
|
329
|
+
args = parser.parse_args(argv)
|
|
330
|
+
try:
|
|
331
|
+
if args.project.is_symlink() or not args.project.is_dir():
|
|
332
|
+
raise WorkspaceError("项目根目录必须是实际目录")
|
|
333
|
+
root = args.project.resolve()
|
|
334
|
+
if args.command == "migrate":
|
|
335
|
+
if not args.map or args.map.is_symlink():
|
|
336
|
+
raise WorkspaceError("迁移需要普通映射文件")
|
|
337
|
+
result = migrate(root, json.loads(args.map.read_text(encoding="utf-8")), apply=args.apply)
|
|
338
|
+
else:
|
|
339
|
+
if args.apply or args.map:
|
|
340
|
+
raise WorkspaceError("只读命令不支持 apply 或 map")
|
|
341
|
+
result = inspect_cases(root, args.term if args.command == "search" else None)
|
|
342
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
343
|
+
return 0 if result["ok"] else 1
|
|
344
|
+
except (OSError, ValueError, KeyError, TypeError) as error:
|
|
345
|
+
print(json.dumps({"ok": False, "error": str(error)}, ensure_ascii=False))
|
|
346
|
+
return 2
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
if __name__ == "__main__":
|
|
350
|
+
sys.exit(main())
|