File size: 5,839 Bytes
95597da
 
 
 
 
 
 
 
2be3a97
 
 
 
95597da
2be3a97
95597da
2be3a97
95597da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2be3a97
95597da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2be3a97
95597da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
"""Build portable skills and WorkBuddy packages from one canonical skill."""
import argparse, hashlib, json, re, struct, zipfile
from pathlib import Path, PurePosixPath

ROOT = Path(__file__).resolve().parents[1]
VERSION = '0.2.0'
SKILL = 'editorial-guard-zh'

def text_bytes(path):
    # Canonical LF makes package metadata independent of checkout platform.
    return path.read_text(encoding='utf-8').encode('utf-8')

def skill_files(workbuddy=False):
    files = {p.relative_to(ROOT/'skills'/SKILL).as_posix(): text_bytes(p)
             for p in sorted((ROOT/'skills'/SKILL).rglob('*')) if p.is_file()}
    files['LICENSE'] = text_bytes(ROOT/'LICENSE')
    if workbuddy:
        files = {n: v for n,v in files.items() if not n.startswith('agents/')}
        text = files['SKILL.md'].decode('utf-8')
        _, front, body = text.split('---', 2)
        front += ('display_name: 中文改稿与交付检查\ndisplay_name_en: Chinese Editing and Delivery Review\n'
                  'description_zh: 润色已有中文草稿并保护原意与结构,宿主自查不等于独立验收。\n'
                  'description_en: Polish Chinese drafts and preserve source meaning; host review is not independent validation.\n'
                  'category: writing\nversion: "'+VERSION+'"\nauthor: Song Luo\n')
        body = re.sub(r'\[([^\]]+)\]\((references/[^)]+)\)', r'\1:@\2', body)
        files['SKILL.md'] = ('---'+front+'---'+body).encode('utf-8')
    return files

def write_zip(destination, files):
    with zipfile.ZipFile(destination,'w',zipfile.ZIP_DEFLATED,compresslevel=9) as archive:
        for name, data in sorted(files.items()):
            path = PurePosixPath(name)
            if path.is_absolute() or '..' in path.parts or '\\' in name: raise ValueError('Unsafe ZIP entry')
            info=zipfile.ZipInfo(name,(2026,1,1,0,0,0));info.compress_type=zipfile.ZIP_DEFLATED;info.external_attr=0o100644<<16
            archive.writestr(info,data)
    with zipfile.ZipFile(destination) as archive:
        if archive.testzip() or len(archive.namelist())!=len(set(archive.namelist())): raise ValueError('Invalid archive')

def check_avatar(data):
    if not data.startswith(b'\x89PNG\r\n\x1a\n') or struct.unpack('>II',data[16:24])!=(512,512) or len(data)>500_000:
        raise ValueError('Expected 512 x 512 PNG under 500 KB')

def plugin_files(kind):
    folder=ROOT/'platforms/workbuddy'/('editorial-guard-'+kind)
    files={p.relative_to(folder).as_posix():text_bytes(p) for p in sorted(folder.rglob('*')) if p.is_file()}
    meta=json.loads(files['.codebuddy-plugin/plugin.json'])
    ids=[PurePosixPath(a).stem for a in meta['agents']]
    if meta['agentName'] not in ids or meta['defaultInitPrompt']!=meta['quickPrompts'][0]:raise ValueError('Invalid entrypoint')
    if len(meta['tags'])!=3 or len(meta['quickPrompts'])!=3:raise ValueError('Expected three tags and prompts')
    if not 40<=len(re.findall('[\u4e00-\u9fff]',meta['displayDescription']['zh']))<=50:raise ValueError('Description length')
    for agent in meta['agents']:
        text=files[agent.removeprefix('./')].decode('utf-8')
        if '\ntools:' in text or '\nname: '+PurePosixPath(agent).stem+'\n' not in text:raise ValueError('Invalid agent')
    avatar_names=[PurePosixPath(meta['avatar']).name]
    if kind=='team':
        members=meta['members'];team=meta['teamInfo']
        if {x['id'] for x in members}!=set(ids) or set(team['memberAgents'])!=set(ids)-{team['leadAgent']}:raise ValueError('Team roles differ')
        if sum(x['role']=='lead' for x in members)!=1 or meta['agentName']!=team['leadAgent']:raise ValueError('Invalid team lead')
        if files['settings.json']!=files['setting.json'] or json.loads(files['settings.json'])['agent']!=team['leadAgent']:raise ValueError('Settings differ')
        avatar_names += [PurePosixPath(x['avatar']).name for x in members]
    for name in avatar_names:
        data=(ROOT/'platforms/workbuddy/avatars'/name).read_bytes();check_avatar(data);files['avatars/'+name]=data
    files.update({'skills/'+SKILL+'/'+name:data for name,data in skill_files(True).items()})
    files['LICENSE']=text_bytes(ROOT/'LICENSE')
    for path in meta['skills']:
        if path.removeprefix('./')+'/SKILL.md' not in files:raise ValueError('Missing embedded skill')
    return {folder.name+'/'+name:data for name,data in files.items()}

def build(output):
    out=Path(output).resolve()
    if out==ROOT or ROOT in out.parents:raise ValueError('Use output outside source repository')
    if out.exists() and any(out.iterdir()):raise ValueError('Output must be empty')
    out.mkdir(parents=True,exist_ok=True)
    for suffix,files in [
        ('editorial-guard-zh', {SKILL+'/'+n:v for n,v in skill_files().items()}),
        ('editorial-guard-zh-portable-flat',skill_files()),
        ('editorial-guard-workbuddy-skill',skill_files(True)),
        ('editorial-guard-workbuddy-expert',plugin_files('expert')),
        ('editorial-guard-workbuddy-team',plugin_files('team'))]:
        write_zip(out/(suffix+'-v'+VERSION+'.zip'),files)
    avatar=(ROOT/'platforms/workbuddy/avatars/skill.png').read_bytes();check_avatar(avatar)
    (out/'editorial-guard-skill-avatar.png').write_bytes(avatar)
    report={'version':VERSION,'local_package_checks':'pass','platform_import_tested':False,
            'portable_flat_contract':'SKILL.md at ZIP root; no platform-specific manifest asserted',
            'files':{p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(out.iterdir()) if p.is_file()}}
    (out/'platform-package-manifest.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
    return report

if __name__=='__main__':
    parser=argparse.ArgumentParser();parser.add_argument('--output',required=True)
    print(json.dumps(build(parser.parse_args().output),indent=2))