chore: add agent guidance and ignore local workspaces

Document Unity and Blender MCP workflows and retain workspace initialization tooling and QA evidence. Exclude local resource copies and the credential draft from version control.
This commit is contained in:
2026-11-12 14:31:34 +08:00
parent 92623a1bc8
commit 0857e291d5
8 changed files with 664 additions and 0 deletions
+411
View File
@@ -0,0 +1,411 @@
"""Initialize three ordinary resource copies from a frozen Git commit.
Run from the primary repository. This script creates no branches or worktrees,
does not launch Unity/Blender, and never overwrites an existing resource copy.
"""
import argparse
import ast
from datetime import datetime, timedelta, timezone
import hashlib
import json
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import subprocess
import uuid
import zipfile
ROOT = Path(__file__).resolve().parents[2]
NAMES = ('tree1', 'tree2', 'tree3')
ADAPTED = (
'Tools/Unity/Commands/Build.json',
'Tools/Unity/Commands/LocalizationBuild.json',
'Tools/Unity/Commands/Restart.json',
'Tools/Blender/CreatePrototypeMech.py',
)
GENERATED = ('WORKSPACE.md', 'WorkspaceInfo.json', 'InitializationManifest.json')
FORBIDDEN = {'.git', 'library', 'temp', 'obj', 'logs', 'usersettings', 'builds', '.vs', '.idea', 'memorycaptures', 'recordings'}
HK = timezone(timedelta(hours=8), 'Asia/Hong_Kong')
def git(*args, check=True):
return subprocess.run(['git', '-C', str(ROOT), *args], capture_output=True, check=check)
def git_text(*args):
return git(*args).stdout.decode('utf-8').strip()
def sha(data):
return hashlib.sha256(data).hexdigest()
def file_sha(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def safe_child(base, path):
"""Resolve paths before any directory publication/move or file creation."""
base = base.resolve()
resolved = path.resolve()
if resolved == base or not resolved.is_relative_to(base):
raise RuntimeError(f'Path leaves the allowed directory: {path}')
return resolved
def is_link(path):
attributes = getattr(path.lstat(), 'st_file_attributes', 0)
return path.is_symlink() or bool(attributes & getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400))
def write_json(path, data):
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
def main_assets_state():
return {p.relative_to(ROOT).as_posix(): file_sha(p) for p in sorted((ROOT / 'Assets').rglob('*')) if p.is_file()}
def git_state():
return {
'head': git_text('rev-parse', 'HEAD'),
'branches': git_text('for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads'),
'worktrees': git_text('worktree', 'list', '--porcelain'),
'index': git_text('ls-files', '--stage'),
}
def baseline_files(commit, run):
modes = {}
for record in git('ls-tree', '-r', '-z', commit).stdout.split(b'\0'):
if not record:
continue
meta, raw_path = record.split(b'\t', 1)
mode, kind, _ = meta.decode('ascii').split()
name = raw_path.decode('utf-8')
path = PurePosixPath(name)
if path.is_absolute() or '..' in path.parts or '\\' in name or ':' in name:
raise RuntimeError(f'Invalid archive path: {name}')
if path.parts[0].lower() in FORBIDDEN or path.parts[0] in NAMES:
raise RuntimeError(f'Generated directory is tracked in baseline: {name}')
if mode not in ('100644', '100755') or kind != 'blob':
raise RuntimeError(f'Only ordinary files are supported: {mode} {name}')
modes[name] = mode
archive_path = run / 'baseline.zip'
with archive_path.open('xb') as stream:
subprocess.run(['git', '-C', str(ROOT), 'archive', '--format=zip', commit], stdout=stream, check=True)
with zipfile.ZipFile(archive_path) as archive:
files = {i.filename: archive.read(i) for i in archive.infolist() if not i.is_dir()}
if set(files) != set(modes):
raise RuntimeError('Archive does not match the frozen Git tree.')
if set(GENERATED) & set(files):
raise RuntimeError('Workspace metadata would overwrite baseline files.')
for name in ADAPTED:
if name not in files:
raise RuntimeError(f'Expected tool is missing: {name}')
return files
def source_root(files):
text = files[ADAPTED[3]].decode('utf-8')
match = re.search(r"^root_path = Path\('([^']+)'\)$", text, re.MULTILINE)
if not match:
raise RuntimeError('The Blender root constant is not recognized.')
return match.group(1)
def adapted_files(files, destination):
before_root = source_root(files)
after_root = destination.as_posix()
output = dict(files)
records = []
for name in ADAPTED:
if name.endswith('.json'):
commands = json.loads(files[name].decode('utf-8'))
if len(commands) != 1:
raise RuntimeError(f'Expected one command in {name}')
arguments = commands[0]['arguments']
if name.endswith('/Restart.json'):
previous = arguments['code']
expected = f'UnityEditor.EditorApplication.OpenProject("{before_root}")'
replacement = f'UnityEditor.EditorApplication.OpenProject("{after_root}")'
if previous.count(expected) != 1:
raise RuntimeError(f'Unexpected restart target in {name}')
arguments['code'] = previous.replace(expected, replacement)
clone = json.loads(json.dumps(commands))
clone[0]['arguments']['code'] = clone[0]['arguments']['code'].replace(replacement, expected)
else:
previous = arguments['output_path']
relative = PurePosixPath(previous).relative_to(PurePosixPath(before_root))
if relative.parts[0] != 'Builds' or '..' in relative.parts:
raise RuntimeError(f'Unexpected build output in {name}')
arguments['output_path'] = destination.joinpath(*relative.parts).as_posix()
safe_child(destination, Path(arguments['output_path']))
clone = json.loads(json.dumps(commands))
clone[0]['arguments']['output_path'] = previous
if clone != json.loads(files[name].decode('utf-8')):
raise RuntimeError(f'Unexpected semantic change to {name}')
output[name] = (json.dumps(commands, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
else:
previous = files[name].decode('utf-8')
expected = f"root_path = Path('{before_root}')"
replacement = f"root_path = Path('{after_root}')"
if previous.count(expected) != 1:
raise RuntimeError('Unexpected Blender root constant.')
result = previous.replace(expected, replacement)
ast.parse(result, filename=name)
if result.replace(replacement, expected) != previous:
raise RuntimeError('Unexpected Blender source edit.')
output[name] = result.encode('utf-8')
if output[name] == files[name]:
raise RuntimeError(f'Expected a path adaptation: {name}')
records.append({'path': name, 'fromRoot': before_root, 'toRoot': after_root,
'sourceSha256': sha(files[name]), 'sha256': sha(output[name])})
return output, records
def workspace_info(name, destination, commit, initialized):
return {
'schemaVersion': 1, 'name': name, 'stage': 'resources-only', 'initializedAt': initialized,
'sourceRepository': ROOT.as_posix(), 'sourceCommit': commit,
'projectRoot': destination.as_posix(), 'unityVersion': '6000.6.8f1',
'git': {'kind': 'ordinary-directory', 'branchCreated': False, 'worktreeRegistered': False},
'mcp': {'expectedProjectRoot': destination.as_posix(), 'instanceId': None, 'connected': False},
'runtimeDataIsolation': {'status': 'pending-before-play',
'reason': 'The copied companyName/productName and PlayerPrefs namespace still match the baseline.'},
'unityImport': 'not-run', 'unityCompilation': 'not-run', 'unityBuild': 'not-run',
}
def create_copy(staging, destination, name, files, commit, initialized):
staging.mkdir()
output, adaptations = adapted_files(files, destination)
for relative, data in output.items():
target = safe_child(staging, staging / relative)
target.parent.mkdir(parents=True, exist_ok=True)
with target.open('xb') as stream:
stream.write(data)
# Git cannot store empty folders; Unity folder metadata can describe one.
for relative, data in files.items():
if relative.startswith('Assets/') and relative.endswith('.meta') and re.search(rb'^folderAsset:\s*yes\s*$', data, re.MULTILINE):
safe_child(staging, staging / relative[:-5]).mkdir(parents=True, exist_ok=True)
write_json(staging / 'WorkspaceInfo.json', workspace_info(name, destination, commit, initialized))
(staging / 'WORKSPACE.md').write_text(
f'# {name} 资源目录\n\n'
f'基线:`{commit}`。Unity:`6000.6.8f1`。目标:`{destination.as_posix()}`。\n\n'
'本目录是普通资源副本,未建立独立 Git 仓库、分支或 worktree。'
'目录内部的 Git 命令可能识别到父仓库,不应作为独立版本记录使用。\n\n'
'资源、GUID、制作源文件与许可已复制;四份工具的输出或重开路径已指向本目录。'
'路径适配及逐文件校验见 InitializationManifest.json。历史文档中的路径仍记录原来源。\n\n'
'MCP 尚未连接,Unity 尚未导入、编译或构建。首次使用需要为本目录选择明确的 Unity 实例与完整路径。'
'默认存档和 PlayerPrefs 命名空间尚未隔离,完成隔离前不要并行运行游戏。\n\n'
'后续按主工程 Docs/Production/TreeInitializationPlan.md 迁移至正式开发工作区。\n',
encoding='utf-8')
generated = [{'path': relative, 'bytes': (staging / relative).stat().st_size,
'sha256': file_sha(staging / relative)} for relative in GENERATED[:2]]
manifest = {
'schemaVersion': 1, 'sourceCommit': commit, 'projectRoot': destination.as_posix(),
'sourceFileCount': len(files), 'sourceBytes': sum(map(len, files.values())),
'adaptations': adaptations,
'files': [{'path': path, 'bytes': len(data), 'sourceSha256': sha(files[path]),
'sha256': sha(data), 'adapted': path in ADAPTED} for path, data in sorted(output.items())],
'generatedFiles': generated,
'selfHash': 'Recorded in the primary repository initialization report, not inside this manifest.',
}
write_json(staging / 'InitializationManifest.json', manifest)
def validate_copy(directory, destination, name, files, commit):
output, adaptations = adapted_files(files, destination)
metadata = json.loads((directory / 'WorkspaceInfo.json').read_text(encoding='utf-8'))
if metadata['name'] != name or metadata['sourceCommit'] != commit or metadata['projectRoot'] != destination.as_posix():
raise RuntimeError(f'Existing workspace has a different identity: {directory}')
if metadata['stage'] != 'resources-only' or metadata['git'] != {'kind': 'ordinary-directory', 'branchCreated': False, 'worktreeRegistered': False}:
raise RuntimeError(f'Workspace has advanced beyond resource initialization: {directory}')
if metadata['mcp'] != {'expectedProjectRoot': destination.as_posix(), 'instanceId': None, 'connected': False}:
raise RuntimeError(f'Workspace connection state changed: {directory}')
manifest = json.loads((directory / 'InitializationManifest.json').read_text(encoding='utf-8'))
if manifest['sourceCommit'] != commit or manifest['projectRoot'] != destination.as_posix() or manifest['adaptations'] != adaptations:
raise RuntimeError(f'Manifest baseline or adaptations differ: {directory}')
if manifest['sourceFileCount'] != len(files) or manifest['sourceBytes'] != sum(map(len, files.values())):
raise RuntimeError(f'Manifest baseline count or size differs: {directory}')
recorded = {entry['path']: entry for entry in manifest['files']}
if len(recorded) != len(files) or set(recorded) != set(output):
raise RuntimeError(f'Manifest file set differs: {directory}')
found = []
for path in directory.rglob('*'):
safe_child(directory, path)
if is_link(path):
raise RuntimeError(f'Linked path is not allowed: {path}')
if path.is_file():
if path.stat().st_nlink != 1:
raise RuntimeError(f'Hard-linked file is not allowed: {path}')
found.append(path.relative_to(directory).as_posix())
if set(found) != set(files) | set(GENERATED):
raise RuntimeError(f'Unexpected or missing files in {directory}')
if any((directory / item).exists() for item in ('.git', 'Library', 'Temp', 'Obj', 'Logs', 'UserSettings', 'Builds', '.vs', '.idea')):
raise RuntimeError(f'Generated directory was copied: {directory}')
for relative, expected in output.items():
path = directory / relative
entry = recorded[relative]
if entry != {'path': relative, 'bytes': len(expected), 'sourceSha256': sha(files[relative]),
'sha256': sha(expected), 'adapted': relative in ADAPTED}:
raise RuntimeError(f'Manifest hash is not derived from the baseline: {relative}')
if path.stat().st_size != len(expected) or file_sha(path) != sha(expected):
raise RuntimeError(f'File does not match baseline/adaptation: {path}')
if relative.endswith('.json'):
json.loads(path.read_text(encoding='utf-8-sig'))
if relative.startswith('Tools/') and relative.endswith('.py'):
ast.parse(path.read_text(encoding='utf-8-sig'), filename=relative)
if {entry['path'] for entry in manifest['generatedFiles']} != set(GENERATED[:2]) or len(manifest['generatedFiles']) != 2:
raise RuntimeError('Generated metadata records differ.')
for entry in manifest['generatedFiles']:
path = directory / entry['path']
if path.stat().st_size != entry['bytes'] or file_sha(path) != entry['sha256']:
raise RuntimeError(f'Generated metadata was changed: {path}')
assets = directory / 'Assets'
meta_count = 0
guids = set()
for path in assets.rglob('*'):
if path.name.endswith('.meta'):
meta_count += 1
if not path.with_suffix('').exists():
raise RuntimeError(f'Orphan Unity metadata: {path}')
match = re.search(r'^guid: ([a-f0-9]{32})$', path.read_text(encoding='utf-8'), re.MULTILINE)
if not match or match.group(1) in guids:
raise RuntimeError(f'Missing or duplicate Unity GUID: {path}')
guids.add(match.group(1))
elif not Path(str(path) + '.meta').is_file():
raise RuntimeError(f'Unity asset has no metadata: {path}')
required = (
'Assets/Scenes/Bootstrap.unity', 'Assets/Scenes/MechTestRange.unity', 'Assets/Scenes/WarzonePrototype.unity',
'Assets/Game/UI/Fonts/GameFont.asset', 'Assets/Game/UI/Fonts/GameTextSettings.asset',
'Assets/Game/UI/GamePanelSettings.asset', 'Assets/Game/Localization/LocalizationCatalog.asset',
'Assets/Game/Localization/Tables/en.json', 'Assets/Game/Localization/Tables/zh-Hans.json',
'Assets/ThirdParty/Adobe/NotoSansSC/NotoSansSC-VF.ttf', 'Assets/ThirdParty/Adobe/NotoSansSC/OFL.txt',
'SourceAssets/Blender/Mechs/Mech_Scout_A.blend', 'SourceAssets/ThirdParty/Adobe/NotoSansSC/NotoSansSC-VF.ttf',
'Docs/Production/Licenses/Adobe/NotoSansSC/OFL.txt', 'Packages/manifest.json', 'Packages/packages-lock.json',
)
if any(not (directory / path).is_file() for path in required):
raise RuntimeError('Required engine resource, source or license is missing.')
if 'm_EditorVersion: 6000.6.8f1' not in (directory / 'ProjectSettings/ProjectVersion.txt').read_text():
raise RuntimeError('Unexpected Unity version.')
tables = [json.loads((directory / f'Assets/Game/Localization/Tables/{code}.json').read_text(encoding='utf-8'))['entries'] for code in ('en', 'zh-Hans')]
keys = [{entry['key'] for entry in entries} for entries in tables]
if keys[0] != keys[1] or any(len(entries) != len(key_set) for entries, key_set in zip(tables, keys)):
raise RuntimeError('Localization table keys do not match.')
return {'name': name, 'path': destination.as_posix(), 'status': 'passed', 'sourceCommit': commit,
'sourceFiles': len(files), 'totalFiles': len(found), 'bytes': sum((directory / item).stat().st_size for item in found),
'adaptedFiles': len(ADAPTED), 'unchangedFiles': len(files) - len(ADAPTED),
'unityMetaFiles': meta_count, 'uniqueUnityGuids': len(guids), 'localizationKeysPerLocale': len(keys[0]),
'independentOrdinaryFiles': True, 'cacheAndGitExcluded': True, 'hashValidation': 'passed',
'manifestSha256': file_sha(directory / 'InitializationManifest.json')}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base', default='HEAD', help='HEAD or a hexadecimal Git commit ID.')
args = parser.parse_args()
if not re.fullmatch(r'HEAD|[a-fA-F0-9]{7,40}', args.base):
raise RuntimeError('Use HEAD or a hexadecimal commit ID for --base.')
if Path(git_text('rev-parse', '--show-toplevel')).resolve() != ROOT:
raise RuntimeError('Run this tool from the primary repository, not a resource copy.')
commit = git_text('rev-parse', '--verify', args.base + '^{commit}')
state_before = git_state()
assets_before = main_assets_state()
targets = {}
for name in NAMES:
raw = ROOT / name
if raw.exists() or raw.is_symlink():
if is_link(raw) or not raw.is_dir():
raise RuntimeError(f'Target is not an ordinary directory: {raw}')
target = safe_child(ROOT, raw)
if target.parent != ROOT or target.name != name:
raise RuntimeError(f'Target must be a direct child of the primary repository: {target}')
if git('check-ignore', '-q', '--', name + '/Assets').returncode != 0:
raise RuntimeError(f'Add /{name}/ to the primary .gitignore before initialization.')
if git_text('ls-files', '--', name):
raise RuntimeError(f'Target already contains tracked files: {name}')
targets[name] = target
initialized = datetime.now(HK).isoformat(timespec='seconds')
run_id = datetime.now(HK).strftime('%Y%m%dT%H%M%S') + '-' + uuid.uuid4().hex[:8]
logs = safe_child(ROOT, ROOT / 'Logs/WorkspaceInitialization')
run = safe_child(logs, logs / run_id)
run.mkdir(parents=True)
files = baseline_files(commit, run)
required_bytes = sum(map(len, files.values())) * (sum(not p.exists() for p in targets.values()) + 1) + 64 * 1024 * 1024
free_bytes = shutil.disk_usage(ROOT).free
if free_bytes < required_bytes:
raise RuntimeError(f'Not enough free disk space: {free_bytes} < {required_bytes}')
prepared = {}
outcomes = {}
# All three copies are validated before publishing any new directory.
for name, destination in targets.items():
if destination.exists():
outcomes[name] = 'verified-existing'
validate_copy(destination, destination, name, files, commit)
prepared[name] = destination
else:
staging = safe_child(run, run / name)
create_copy(staging, destination, name, files, commit, initialized)
validate_copy(staging, destination, name, files, commit)
prepared[name] = staging
outcomes[name] = 'created'
results = []
for name, destination in targets.items():
source = prepared[name]
if source != destination:
# Recheck resolved absolute source and destination immediately before moving.
if safe_child(run, source) != source or safe_child(ROOT, destination) != destination:
raise RuntimeError('Publication paths changed.')
if destination.exists() or destination.is_symlink():
raise RuntimeError(f'Target appeared during preparation; refusing to overwrite: {destination}')
source.rename(destination)
result = validate_copy(destination, destination, name, files, commit)
result['operation'] = outcomes[name]
if git('check-ignore', '-q', '--', name + '/WorkspaceInfo.json').returncode != 0:
raise RuntimeError('Published copy is not ignored by the primary repository.')
results.append(result)
if git_state() != state_before:
raise RuntimeError('Primary HEAD, branches, worktrees or index changed during initialization.')
if main_assets_state() != assets_before:
raise RuntimeError('Primary Assets changed during initialization.')
report = {
'schemaVersion': 1, 'initializedAt': initialized, 'runId': run_id,
'sourceRepository': ROOT.as_posix(), 'sourceCommit': commit,
'baselineFileCount': len(files), 'baselineBytes': sum(map(len, files.values())),
'freeDiskBytesAtStart': free_bytes, 'trees': results,
'primaryAssetsUnchanged': True, 'gitHeadUnchanged': True, 'gitBranchesUnchanged': True,
'gitWorktreeRegistrationsUnchanged': True, 'gitIndexUnchanged': True,
'primaryGitState': state_before,
'scope': 'Static resource initialization only; Unity/Blender/MCP were not started.',
'pending': ['Unity package resolution/import/compilation', 'MCP explicit instance binding',
'Separate test saves and PlayerPrefs before parallel play', 'Branches/worktrees only when requested'],
}
write_json(run / 'Result.json', report)
qa = safe_child(ROOT, ROOT / 'Docs/QA/Workspaces')
qa.mkdir(parents=True, exist_ok=True)
write_json(qa / 'Initialization.json', report)
rows = '\n'.join(f"| {r['name']} | {r['totalFiles']} | {r['bytes'] / 1048576:.2f} MiB | {r['unityMetaFiles']} | {r['operation']} / passed |" for r in results)
(qa / 'Initialization.md').write_text(
'# tree1–tree3 资源初始化验收\n\n'
f'时间:{initialized}(Asia/Hong_Kong)。基线:`{commit}`。\n\n'
'| 目录 | 文件数 | 大小 | Unity .meta | 结果 |\n| --- | --- | --- | --- | --- |\n' + rows + '\n\n'
f'每份包含 {len(files)} 个基线文件、4 份工具路径适配及 3 份工作区说明/清单文件。'
f'其中 {len(files) - 4} 个基线文件逐字节保留;适配文件与源、目标路径均按清单验证。\n\n'
'检查通过:逐文件 SHA-256、资产与 .meta 配对、GUID 唯一且与基线一致、'
'中英文表键一致、源模型/字体/许可/包锁完整、无共享可写链接、无缓存或构建包、'
'父仓库忽略规则生效。主工程 Assets、HEAD、分支、worktree 注册及 Git 暂存区均保持原状。\n\n'
'三个目录均为普通资源副本,没有独立 Git 历史。MCP 实例未连接;'
'Unity 导入、编译、播放和构建未执行;并行运行前仍需隔离存档及 PlayerPrefs。\n\n'
'完整记录见 [Initialization.json](Initialization.json),目录内的 InitializationManifest.json '
'提供每个基线文件与适配项的哈希。执行计划见 [TreeInitializationPlan.md](../../Production/TreeInitializationPlan.md)。\n',
encoding='utf-8')
print(json.dumps({key: report[key] for key in ('runId', 'sourceCommit', 'baselineFileCount', 'trees', 'primaryAssetsUnchanged', 'gitBranchesUnchanged', 'gitWorktreeRegistrationsUnchanged')}, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()