333 lines
16 KiB
Python
333 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Code4History two-way workbench. All operations are local, stdlib-only.
|
|
|
|
Use --root to target a test or separate project. No daemon or background process.
|
|
This tool safely archives upload bytes and moves completed human/agent tasks;
|
|
semantic historical interpretation remains the responsibility of the research skill.
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SRC_KINDS = {'primary': ('SRC-P', 'primary'), 'secondary': ('SRC-S', 'secondary')}
|
|
WORK_FILES = {'readme.md', '.gitkeep'}
|
|
SIDE_SUFFIX = '.source.md'
|
|
TEMP_SUFFIXES = ('.part', '.tmp', '.crdownload', '.download', '.swp')
|
|
|
|
|
|
def stamp():
|
|
return datetime.now(timezone.utc).isoformat(timespec='seconds')
|
|
|
|
|
|
def sha256(path):
|
|
d = hashlib.sha256()
|
|
with path.open('rb') as f:
|
|
for part in iter(lambda: f.read(1024*1024), b''):
|
|
d.update(part)
|
|
return d.hexdigest()
|
|
|
|
|
|
def safe_file(root: Path, relative: str, *, required=True) -> Path:
|
|
# Never accept absolute, dotted segments, traversal, directory / symlink.
|
|
if not relative or '\\' in relative or '/' in relative or relative in {'.', '..'} or Path(relative).is_absolute():
|
|
raise ValueError('Only a direct filename inside the target inbox is allowed.')
|
|
p = root / relative
|
|
if required and (not p.is_file() or p.is_symlink()):
|
|
raise ValueError(f'Not a regular file: {p}')
|
|
if p.exists() and p.is_symlink():
|
|
raise ValueError(f'Unsafe symbolic link: {p}')
|
|
return p
|
|
|
|
|
|
def atomic_bytes(dest: Path, content: bytes):
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
if dest.exists():
|
|
raise FileExistsError(dest)
|
|
with tempfile.NamedTemporaryFile(dir=dest.parent, prefix='.c4h-incoming-', delete=False) as f:
|
|
temp = Path(f.name)
|
|
f.write(content)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
try:
|
|
os.replace(temp, dest)
|
|
finally:
|
|
temp.unlink(missing_ok=True)
|
|
|
|
|
|
def atomic_replace(dest: Path, content: bytes):
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.NamedTemporaryFile(dir=dest.parent, prefix='.c4h-write-', delete=False) as f:
|
|
temp=Path(f.name); f.write(content); f.flush(); os.fsync(f.fileno())
|
|
try:
|
|
os.replace(temp, dest)
|
|
finally:
|
|
temp.unlink(missing_ok=True)
|
|
|
|
|
|
def safe_copy(source: Path, destination: Path, digest: str):
|
|
destination.parent.mkdir(parents=True,exist_ok=True)
|
|
if destination.exists():
|
|
if sha256(destination) == digest:
|
|
return False
|
|
raise FileExistsError(f'Destination exists with different bytes: {destination}')
|
|
with tempfile.NamedTemporaryFile(dir=destination.parent,prefix='.c4h-upload-',delete=False) as f:
|
|
temp=Path(f.name)
|
|
try:
|
|
shutil.copyfile(source,temp)
|
|
if sha256(temp) != digest:
|
|
raise IOError(f'Hash mismatch while staging: {source}')
|
|
os.replace(temp,destination)
|
|
finally:
|
|
temp.unlink(missing_ok=True)
|
|
if sha256(destination) != digest:
|
|
raise IOError(f'Hash mismatch after archive write: {destination}')
|
|
return True
|
|
|
|
|
|
def user_answer(doc: str) -> str:
|
|
if '## 用户回复' not in doc:
|
|
return ''
|
|
reply = doc.split('## 用户回复',1)[1].split('## Agent 处理记录',1)[0].strip()
|
|
if not reply or reply.startswith('(请在此填写'):
|
|
return ''
|
|
return reply
|
|
|
|
|
|
def status(root: Path) -> dict:
|
|
upl=root/'upload'
|
|
files=[p for p in sorted(upl.iterdir()) if p.name not in WORK_FILES] if upl.exists() else []
|
|
attachments=[]; source_notes=[]; blocked=[]
|
|
for p in files:
|
|
if p.is_symlink() or not p.is_file():
|
|
blocked.append(p.name);continue
|
|
if p.name.startswith('.') or p.name.lower().endswith(TEMP_SUFFIXES):
|
|
blocked.append(p.name);continue
|
|
if p.name.endswith(SIDE_SUFFIX):
|
|
source_notes.append(p.name);continue
|
|
companion=p.name+SIDE_SUFFIX
|
|
attachments.append({'file':p.name,'source_note':companion if (upl/companion).is_file() else None,'bytes':p.stat().st_size})
|
|
paired={x['source_note'] for x in attachments if x['source_note']}
|
|
todo=[]
|
|
for p in sorted((root/'todo').glob('TODO-*.md')):
|
|
if p.is_symlink(): blocked.append('todo/'+p.name);continue
|
|
body=p.read_text(encoding='utf-8')
|
|
m=re.search(r'^status:\s*([a-z_-]+)\s*$',body,re.M)
|
|
state=m.group(1) if m else 'invalid'
|
|
if state=='open' and user_answer(body):state='answered'
|
|
todo.append({'file':p.name,'status':state})
|
|
req=[]
|
|
for p in sorted((root/'requests').iterdir()) if (root/'requests').exists() else []:
|
|
if p.name in WORK_FILES or p.name=='archive':continue
|
|
if p.is_file() and not p.is_symlink():req.append(p.name)
|
|
else:blocked.append('requests/'+p.name)
|
|
return {
|
|
'upload':attachments,
|
|
'orphan_source_notes':sorted(set(source_notes)-paired),
|
|
'blocked':blocked,
|
|
'todo':todo,
|
|
'requests':req,
|
|
'summary':{
|
|
'upload_pending':len(attachments)+len(set(source_notes)-paired)+len([x for x in blocked if '/' not in x]),
|
|
'todo_open':sum(x['status']=='open' for x in todo),
|
|
'todo_answered':sum(x['status']=='answered' for x in todo),
|
|
'requests_pending':len(req)
|
|
}
|
|
}
|
|
|
|
|
|
def next_number(folder: Path, prefix: str) -> int:
|
|
seq=[]
|
|
for p in folder.glob(f'{prefix}-*.md'):
|
|
m=re.match(re.escape(prefix)+r'-(\d{4})(?:-|\.md)',p.name)
|
|
if m:seq.append(int(m.group(1)))
|
|
return max(seq,default=0)+1
|
|
|
|
|
|
def next_source_id(root: Path,kind: str):
|
|
prefix, sub=SRC_KINDS[kind]
|
|
folder=root/'01_sources/records'/sub
|
|
return f'{prefix}-{next_number(folder,prefix):04d}'
|
|
|
|
|
|
def yaml_set(doc: str,key: str,val: str) -> str:
|
|
value=json.dumps(val,ensure_ascii=False)
|
|
pattern=re.compile(r'^'+re.escape(key)+r':.*$',re.M)
|
|
if pattern.search(doc.split('\n---\n',1)[0]):
|
|
return pattern.sub(lambda _:f'{key}: {value}',doc,count=1)
|
|
return doc.replace('\n---\n',f'\n{key}: {value}\n---\n',1)
|
|
|
|
|
|
def intake(root:Path,filename:str,kind:str,slug:str,dry_run=False):
|
|
if not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*',slug):
|
|
raise ValueError('slug must be lowercase ASCII kebab-case')
|
|
if filename in WORK_FILES or filename.endswith(SIDE_SUFFIX) or filename.startswith('.') or filename.lower().endswith(TEMP_SUFFIXES):
|
|
raise ValueError('Not an upload attachment filename')
|
|
inbox=root/'upload'
|
|
source=safe_file(inbox,filename)
|
|
if source.stat().st_size==0:
|
|
raise ValueError('Zero-byte upload must be checked, not archived')
|
|
metadata=safe_file(inbox,filename+SIDE_SUFFIX,required=False)
|
|
has_meta=metadata.is_file()
|
|
digest=sha256(source); note_hash=sha256(metadata) if has_meta else None
|
|
# A previous round may have been interrupted after successful archival but
|
|
# before upload cleanup, or the user may have uploaded identical bytes.
|
|
# Reuse an existing verified accession; NEVER discard a changed sidecar.
|
|
for prior in sorted((root/'01_sources/provenance').glob('SRC-*-intake.json')):
|
|
try:
|
|
receipt_old=json.loads(prior.read_text(encoding='utf-8'))
|
|
archived=root/receipt_old['archive_path']
|
|
if (receipt_old.get('sha256')!=digest or not archived.is_file()
|
|
or sha256(archived)!=digest):
|
|
continue
|
|
if receipt_old.get('source_note_sha256')!=note_hash:
|
|
raise ValueError('已归档相同原件,但此次来源说明不同;保留上传件供人工合并新的来源信息。')
|
|
record_id=receipt_old['source_id']
|
|
record_matches=list((root/'01_sources/records'/SRC_KINDS[kind][1]).glob(record_id+'-*.md'))
|
|
if not record_id.startswith(SRC_KINDS[kind][0]+'-') or not record_matches:
|
|
continue
|
|
if dry_run:return {'id':record_id,'reused':True,'sha256':digest}
|
|
if sha256(source)!=digest or (has_meta and sha256(metadata)!=note_hash):
|
|
raise IOError('Upload changed while checking duplicate')
|
|
source.unlink()
|
|
if has_meta:metadata.unlink()
|
|
return {'id':record_id,'reused':True,'sha256':digest}
|
|
except (KeyError, json.JSONDecodeError):
|
|
continue
|
|
ident=next_source_id(root,kind)
|
|
suffix=Path(filename).suffix.lower()
|
|
original=root/'01_sources/originals'/SRC_KINDS[kind][1]/f'{ident}--{filename}'
|
|
meta_dest=root/'01_sources/provenance'/f'{ident}-upload-source.md'
|
|
receipt=root/'01_sources/provenance'/f'{ident}-intake.json'
|
|
record=root/'01_sources/records'/SRC_KINDS[kind][1]/f'{ident}-{slug}.md'
|
|
if any(p.exists() for p in (original,receipt,record,meta_dest)):
|
|
raise FileExistsError('Generated destination is occupied; inspect before retrying')
|
|
plan={'id':ident,'original_path':original.relative_to(root).as_posix(),'record_path':record.relative_to(root).as_posix(),'metadata_path':meta_dest.relative_to(root).as_posix() if has_meta else None,'sha256':digest,'metadata_sha256':note_hash,'metadata_missing':not has_meta}
|
|
if dry_run:return plan
|
|
# Validate all inputs, prepare copies and documentation before removing originals.
|
|
template=root/'10_templates'/('primary.md' if kind=='primary' else 'secondary.md')
|
|
card=template.read_text(encoding='utf-8').replace('__ID__',ident)
|
|
for field,value in [('original_path',plan['original_path']),('sha256',digest),('original_filename',filename),('ingested_utc',stamp()),('upload_source_note',plan['metadata_path'] or ''),('source_metadata_status','provided_unverified' if has_meta else 'missing')]:
|
|
card=yaml_set(card,field,value)
|
|
# Staging and creating records may fail. Until receipt + record + archive checks pass,
|
|
# never remove the user's source. Partial copies are left for recovery.
|
|
safe_copy(source,original,digest)
|
|
if has_meta:safe_copy(metadata,meta_dest,note_hash)
|
|
atomic_bytes(record,card.encode('utf-8'))
|
|
receipt_data={'source_id':ident,'timestamp_utc':stamp(),'upload_name':filename,'archive_path':plan['original_path'],'sha256':digest,'source_note_path':plan['metadata_path'],'source_note_sha256':note_hash,'ingestion':'archived_not_necessarily_read'}
|
|
atomic_bytes(receipt,(json.dumps(receipt_data,ensure_ascii=False,indent=2)+'\n').encode('utf-8'))
|
|
if sha256(original)!=digest or not record.is_file() or not receipt.is_file() or (has_meta and sha256(meta_dest)!=note_hash):
|
|
raise IOError('Archive integrity check failed; upload retained')
|
|
# Raw catalog updates can be retried by Agent if interrupted; receipt is durable.
|
|
catalog=root/'01_sources/catalog.md'
|
|
current=catalog.read_text(encoding='utf-8') if catalog.exists() else '# Source catalog\n'
|
|
line=f'\n- `{ident}` · `{plan["original_path"]}` · sha256 `{digest}` · [SRC card]({record.relative_to(root).as_posix()}) · metadata: {"provided" if has_meta else "pending"}\n'
|
|
atomic_replace(catalog,(current+line).encode('utf-8'))
|
|
# Recheck bytes before removing inbox: if researcher modified during intake, retain.
|
|
if sha256(source)!=digest or (has_meta and sha256(metadata)!=note_hash):
|
|
raise IOError('Upload changed during intake; retaining input to avoid data loss')
|
|
# At this stage all copies & receipt are verified; unlink originals only now.
|
|
source.unlink()
|
|
if has_meta:metadata.unlink()
|
|
return plan
|
|
|
|
|
|
def todo_new(root:Path,title:str,kind:str,body:str,ref:str,dry_run=False):
|
|
if not title.strip() or not body.strip():raise ValueError('TODO title and body required')
|
|
n=next_number(root/'todo','TODO');ident=f'TODO-{n:04d}'
|
|
doc=f'''---
|
|
id: {ident}
|
|
status: open
|
|
kind: {kind}
|
|
created: "{stamp()}"
|
|
reference: {json.dumps(ref,ensure_ascii=False)}
|
|
---
|
|
|
|
# {title}
|
|
|
|
## 为什么需要用户
|
|
{body}
|
|
|
|
## 用户回复
|
|
(请在此填写回复;扫描器会自动识别)
|
|
|
|
## Agent 处理记录
|
|
(处理后说明关联的正式研究记录)
|
|
'''
|
|
dest=root/'todo'/f'{ident}-request.md'
|
|
if not dry_run:atomic_bytes(dest,doc.encode('utf-8'))
|
|
return str(dest.relative_to(root))
|
|
|
|
|
|
def archive_task(root:Path,folder:str,name:str,record:str,require_answer=False,dry_run=False):
|
|
source=safe_file(root/folder,name)
|
|
if source.name=='readme.md' or not source.name.endswith('.md'):
|
|
raise ValueError('Expected a Markdown task file')
|
|
evidence=(root/record).resolve()
|
|
if not evidence.is_file() or (root.resolve()!=evidence and root.resolve() not in evidence.parents):
|
|
raise ValueError('The --record path must be an existing file in this research repository')
|
|
if folder=='todo':
|
|
if not re.fullmatch(r'TODO-\d{4}-[a-zA-Z0-9_-]+\.md',name):
|
|
raise ValueError('Invalid TODO task file')
|
|
doc=source.read_text('utf-8')
|
|
status_line=re.search(r'^status:\s*([a-z_-]+)\s*$',doc,re.M)
|
|
if not status_line or status_line.group(1) not in ('open','answered') or not user_answer(doc):
|
|
raise ValueError('TODO needs an actual user answer in ## 用户回复')
|
|
dest=root/folder/'archive'/name
|
|
if dest.exists():raise FileExistsError(f'Already archived: {dest}')
|
|
if dry_run:return str(dest.relative_to(root))
|
|
doc=source.read_text('utf-8')
|
|
if folder=='todo':
|
|
doc=re.sub(r'^status:\s*open\s*$', 'status: answered', doc, count=1, flags=re.M)
|
|
applied=f'\n\n## 归档说明\n- 时间:{stamp()}\n- Agent 已处理并记录于:`{record}`\n'
|
|
# Preserve the original reply while adding an archival statement.
|
|
atomic_bytes(dest,(doc+applied).encode('utf-8'))
|
|
if not dest.read_bytes().startswith(doc.encode('utf-8')):
|
|
raise IOError('Archive content mismatch')
|
|
source.unlink()
|
|
return str(dest.relative_to(root))
|
|
|
|
|
|
def finish(root:Path)->int:
|
|
s=status(root)
|
|
print(json.dumps(s['summary'],ensure_ascii=False,indent=2))
|
|
if s['summary']['upload_pending'] or s['summary']['todo_answered'] or s['summary']['requests_pending']:
|
|
print('有未完成的交接事项:请更新 handoff/latest.md;未成功归档的上传原件必须保留。')
|
|
return 1
|
|
print('已无待摄入 upload 文件、未处理用户答复或 requests;开放 TODO 可保留等待用户。')
|
|
return 0
|
|
|
|
|
|
def main(argv=None):
|
|
p=argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('--root',type=Path,default=ROOT)
|
|
sub=p.add_subparsers(dest='command',required=True)
|
|
sub.add_parser('scan')
|
|
intake_p=sub.add_parser('intake');intake_p.add_argument('--file',required=True);intake_p.add_argument('--kind',choices=tuple(SRC_KINDS),required=True);intake_p.add_argument('--slug',required=True);intake_p.add_argument('--dry-run',action='store_true')
|
|
todo_p=sub.add_parser('todo-new');todo_p.add_argument('--title',required=True);todo_p.add_argument('--kind',choices=('question','verification','decision','authorization','assistance'),default='question');todo_p.add_argument('--body',required=True);todo_p.add_argument('--ref',default='');todo_p.add_argument('--dry-run',action='store_true')
|
|
close_p=sub.add_parser('todo-close');close_p.add_argument('--id',required=True);close_p.add_argument('--record',required=True);close_p.add_argument('--dry-run',action='store_true')
|
|
req_p=sub.add_parser('request-done');req_p.add_argument('--file',required=True);req_p.add_argument('--record',required=True);req_p.add_argument('--dry-run',action='store_true')
|
|
sub.add_parser('finish')
|
|
args=p.parse_args(argv);root=args.root.resolve()
|
|
try:
|
|
if args.command=='scan':print(json.dumps(status(root),ensure_ascii=False,indent=2))
|
|
elif args.command=='intake':print(json.dumps(intake(root,args.file,args.kind,args.slug,args.dry_run),ensure_ascii=False,indent=2))
|
|
elif args.command=='todo-new':print(todo_new(root,args.title,args.kind,args.body,args.ref,args.dry_run))
|
|
elif args.command=='todo-close':print(archive_task(root,'todo',safe_file(root/'todo',next((x.name for x in (root/'todo').glob(args.id+'-*.md')),args.id)).name,args.record,True,args.dry_run))
|
|
elif args.command=='request-done':print(archive_task(root,'requests',args.file,args.record,False,args.dry_run))
|
|
elif args.command=='finish':return finish(root)
|
|
except (ValueError,OSError,RuntimeError,StopIteration) as ex:
|
|
print(f'交接失败:{ex}',file=sys.stderr)
|
|
return 2
|
|
return 0
|
|
|
|
if __name__=='__main__':sys.exit(main())
|