0.8
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble only explicitly ordered manuscript sections into 09_outputs/article.md."""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
section_dir=ROOT/'07_manuscript/sections'
|
||||
order=section_dir/'order.txt'
|
||||
if not order.is_file(): raise SystemExit('Missing sections/order.txt')
|
||||
names=[s.strip() for s in order.read_text(encoding='utf-8').splitlines()
|
||||
if s.strip() and not s.lstrip().startswith('#')]
|
||||
if not names:
|
||||
print('No sections listed; edit 07_manuscript/sections/order.txt first.')
|
||||
raise SystemExit(0)
|
||||
parts=[]
|
||||
for name in names:
|
||||
if not name.endswith('.md') or Path(name).name != name:
|
||||
raise SystemExit('Invalid section basename: '+name)
|
||||
file=section_dir/name
|
||||
if not file.is_file(): raise SystemExit('Missing ordered section: '+name)
|
||||
parts.append(file.read_text(encoding='utf-8').strip())
|
||||
out=ROOT/'09_outputs/article.md'
|
||||
out.parent.mkdir(parents=True,exist_ok=True)
|
||||
out.write_text('\n\n---\n\n'.join(parts)+'\n',encoding='utf-8')
|
||||
print(f'Assembled {len(parts)} sections to {out.relative_to(ROOT)}')
|
||||
print('NOTE: Source citations, factual assertions and submission formatting require manual review.')
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check Code4History's local framework and three repository-level Codex skills."""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILLS = ROOT / '.agents/skills'
|
||||
CHECKS = {
|
||||
'Python 3.10+': sys.version_info >= (3,10),
|
||||
'Code4History framework guide': (ROOT/'code4history.md').is_file(),
|
||||
'User global override file (may be empty)': (ROOT/'USERINFO.md').is_file(),
|
||||
'Editable topic with Q1-Q20': (ROOT/'topic.md').is_file() and all(f'**Q{i}.' in (ROOT/'topic.md').read_text(encoding='utf-8') for i in range(1,21)),
|
||||
'Codex default entry AGENTS.md': (ROOT/'AGENTS.md').is_file(),
|
||||
'No retired legacy agent.md entry': not (ROOT/'agent.md').exists() and not (ROOT/'AGENT.md').exists(),
|
||||
'Main history-writing-workflow-c4h Skill': (SKILLS/'history-writing-workflow-c4h/SKILL.md').is_file(),
|
||||
'Source intake Skill': (SKILLS/'source-intake-c4h/SKILL.md').is_file(),
|
||||
'Discussion-to-topic Skill': (SKILLS/'discussion-to-topic-c4h/SKILL.md').is_file(),
|
||||
'Main Skill nine-stage references': all((SKILLS/'history-writing-workflow-c4h/references'/n).is_file() for n in [
|
||||
'stage1-problem-literature.md', 'stage2-reading-outline.md',
|
||||
'stage3-draft-finalize.md', 'evaluation-rubrics.md',
|
||||
'repository-map.md', 'provenance-and-audit.md']),
|
||||
'User guide for conversation import': (ROOT/'DISCUSSION_TO_TOPIC.md').is_file(),
|
||||
'Human Agent exchange protocol': (ROOT/'INTERACTION.md').is_file(),
|
||||
'Upload inbox with instructions': (ROOT/'upload/readme.md').is_file(),
|
||||
'Requests inbox with archive': (ROOT/'requests/readme.md').is_file() and (ROOT/'requests/archive/readme.md').is_file(),
|
||||
'TODO workspace with archive': (ROOT/'todo/readme.md').is_file() and (ROOT/'todo/archive/readme.md').is_file(),
|
||||
'Handoff workspace': (ROOT/'handoff/readme.md').is_file() and (ROOT/'handoff/latest.md').is_file(),
|
||||
'Interaction CLI': (ROOT/'tools/interaction.py').is_file(),
|
||||
'Human checkpoints': (ROOT/'00_governance/checkpoints.md').is_file(),
|
||||
'Primary source records': (ROOT/'01_sources/records/primary').is_dir(),
|
||||
'Evidence records': (ROOT/'02_evidence').is_dir(),
|
||||
'Card creation CLI': (ROOT/'tools/new_item.py').is_file(),
|
||||
}
|
||||
for description, good in CHECKS.items():
|
||||
print(('OK ' if good else 'FAIL ') + description)
|
||||
print('All Skills are local to this repository; no download or global install is necessary.')
|
||||
print('Doctor only checks framework readiness, not provenance or historical accuracy.')
|
||||
if not all(CHECKS.values()):
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,332 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Code4History cross-version research-data migration (stdlib only).
|
||||
|
||||
Usage:
|
||||
python migrate.py export --source OLD --out research.c4h.zip
|
||||
python migrate.py restore --bundle research.c4h.zip --target NEW
|
||||
python migrate.py upgrade --source OLD --template code4history-v0.8.zip --target NEW
|
||||
|
||||
This tool never deletes the old project, never overwrites existing user-edited
|
||||
content in the target, and never installs captured framework customizations
|
||||
as executable instructions. Review the migration report after every restore.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
||||
|
||||
BUNDLE_TYPE = 'code4history-research-export'
|
||||
FORMAT_VERSION = 1
|
||||
BASELINE_FILE = 'migration_baselines.json'
|
||||
MUTABLE_ROOT = {'topic.md', 'USERINFO.md', 'STATE.md'}
|
||||
MUTABLE_PATHS = {
|
||||
'handoff/latest.md', '00_governance/bibliography.bib', '00_governance/checkpoints.md',
|
||||
'00_governance/decisions.md', '00_governance/research-log.md',
|
||||
'01_sources/catalog.md', '04_context/chronology.md',
|
||||
'05_historiography/debates.md', '07_manuscript/outline.md',
|
||||
'07_manuscript/sections/order.txt',
|
||||
'08_review/citation-audit.md', '08_review/contradictions.md',
|
||||
'08_review/counterevidence.md', '08_review/submission-checklist.md',
|
||||
}
|
||||
FRAMEWORK_ROOTS = {
|
||||
'AGENTS.md', 'agent.md', 'AGENT.md', 'code4history.md', 'README.md',
|
||||
'INTERPRETATION.md', 'ARGUMENT_DENSITY.md', 'SYNTHESIS_AND_PROSE.md',
|
||||
'DISCUSSION_TO_TOPIC.md', 'QUICK_FIX.md', 'PUBLISHING.md', 'MIGRATION.md',
|
||||
'CHANGELOG.md', '.gitignore', 'UPGRADE_v0.4.md', 'UPGRADE_v0.5.md',
|
||||
'UPGRADE_v0.6.md', 'UPGRADE_v0.7.md', 'UPGRADE_v0.8.md', 'INTERACTION.md',
|
||||
}
|
||||
SKIP_DIRS = {
|
||||
'.git', '.venv', 'venv', 'node_modules', '__pycache__', '.pytest_cache',
|
||||
'.mypy_cache', '.ruff_cache', '.idea', '.vscode', '.code4history-migration',
|
||||
}
|
||||
SKIP_NAMES = {'.DS_Store', 'Thumbs.db', '.gitkeep'}
|
||||
SELF_GENERATED_PATHS = {'tools/migration_baselines.json'}
|
||||
BLOCKED_SUFFIXES = {'.pem', '.key', '.p12', '.pfx', '.credentials', '.pyc'}
|
||||
|
||||
|
||||
def timestamp() -> str:
|
||||
return datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||
|
||||
|
||||
def digest_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def digest_file(path: Path) -> str:
|
||||
sha = hashlib.sha256()
|
||||
with path.open('rb') as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b''):
|
||||
sha.update(chunk)
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def validate_relpath(name: str) -> str:
|
||||
# Treat archive paths as POSIX even on Windows; reject Windows drive/UNC.
|
||||
if not isinstance(name, str) or not name or '\\' in name or ':' in name:
|
||||
raise ValueError(f'非法相对路径:{name!r}')
|
||||
p = PurePosixPath(name)
|
||||
if p.is_absolute() or name.startswith('/') or any(s in ('', '.', '..') for s in name.split('/')):
|
||||
raise ValueError(f'路径穿越或绝对路径:{name!r}')
|
||||
return p.as_posix()
|
||||
|
||||
|
||||
def is_sensitive(rel: str) -> bool:
|
||||
p = PurePosixPath(rel)
|
||||
if any(part in SKIP_DIRS for part in p.parts[:-1]):
|
||||
return True
|
||||
n = p.name
|
||||
return n in SKIP_NAMES or n == '.env' or n.startswith('.env.') or p.suffix.lower() in BLOCKED_SUFFIXES
|
||||
|
||||
|
||||
def baseline_data() -> dict:
|
||||
source = Path(__file__).resolve().with_name(BASELINE_FILE)
|
||||
if not source.is_file():
|
||||
raise RuntimeError(f'缺少官方框架基线 {source};请将 migrate.py 与 migration_baselines.json 放在同一目录。')
|
||||
doc = json.loads(source.read_text(encoding='utf-8'))
|
||||
if doc.get('schema') != 1 or not isinstance(doc.get('files'), dict):
|
||||
raise ValueError('不支持的框架基线格式')
|
||||
return doc['files']
|
||||
|
||||
|
||||
def is_framework_area(rel: str) -> bool:
|
||||
p = PurePosixPath(rel)
|
||||
if rel in FRAMEWORK_ROOTS or p.name.lower() == 'readme.md':
|
||||
return True
|
||||
return p.parts[0] in {'.agents', '10_templates', 'tools', 'tests'}
|
||||
|
||||
|
||||
def classify(rel: str, sha: str, baselines: dict) -> str:
|
||||
known = baselines.get(rel)
|
||||
if rel in SELF_GENERATED_PATHS:
|
||||
return 'stock'
|
||||
if known and sha in known:
|
||||
return 'stock' # exact byte match against official releases
|
||||
if rel in MUTABLE_ROOT or rel in MUTABLE_PATHS:
|
||||
return 'research'
|
||||
# A shipped but modified document is a framework customization unless
|
||||
# explicitly listed as a mutable research file above.
|
||||
if known:
|
||||
return 'framework_customization'
|
||||
# All changes in framework / skill / tools areas require manual approval.
|
||||
# Never silently install executable code or agent instructions.
|
||||
if is_framework_area(rel):
|
||||
return 'framework_customization'
|
||||
return 'research'
|
||||
|
||||
|
||||
def project_files(root: Path):
|
||||
if not root.is_dir():
|
||||
raise FileNotFoundError(f'项目目录不存在:{root}')
|
||||
for base, dirs, files in os.walk(root, followlinks=False):
|
||||
base_path = Path(base)
|
||||
dirs[:] = [x for x in sorted(dirs) if x not in SKIP_DIRS and not (base_path / x).is_symlink()]
|
||||
for name in sorted(files):
|
||||
file = base_path / name
|
||||
rel = file.relative_to(root).as_posix()
|
||||
if is_sensitive(rel):
|
||||
continue
|
||||
if file.is_symlink() or not file.is_file():
|
||||
print(f'警告:跳过软链接/非常规文件 {rel}', file=sys.stderr)
|
||||
continue
|
||||
validate_relpath(rel)
|
||||
yield rel, file
|
||||
|
||||
|
||||
def export(source: Path, output: Path, *, dry_run=False, quiet=False) -> dict:
|
||||
source = source.resolve()
|
||||
output = output.absolute()
|
||||
if source == output or source in output.parents:
|
||||
raise ValueError('导出包必须位于旧项目目录之外,避免循环打包或污染研究资料')
|
||||
baselines = baseline_data()
|
||||
manifest = {
|
||||
'format': BUNDLE_TYPE, 'version': FORMAT_VERSION,
|
||||
'created_utc': datetime.now(timezone.utc).isoformat(),
|
||||
'source_dir_name': source.name, 'entries': [], 'excluded': [],
|
||||
}
|
||||
for rel, file in project_files(source):
|
||||
sha = digest_file(file)
|
||||
kind = classify(rel, sha, baselines)
|
||||
if kind == 'stock':
|
||||
continue
|
||||
entry = {'path': rel, 'kind': kind, 'size': file.stat().st_size, 'sha256': sha}
|
||||
manifest['entries'].append(entry)
|
||||
if not quiet:
|
||||
kinds = {k: sum(x['kind'] == k for x in manifest['entries']) for k in ('research', 'research_extension', 'framework_customization')}
|
||||
print(f'导出计划:研究文件 {kinds["research"]},自定义扩展 {kinds["research_extension"]},需手动审阅的框架改动 {kinds["framework_customization"]}')
|
||||
print('注意:.git 历史、.env/密钥、虚拟环境及缓存不在导出包内。原始 PDF 和研究产出会包含在内。')
|
||||
if dry_run:
|
||||
return manifest
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(prefix='.c4h-export-', suffix='.zip', dir=output.parent, delete=False) as f:
|
||||
tmp = Path(f.name)
|
||||
with ZipFile(tmp, 'w', compression=ZIP_DEFLATED, allowZip64=True, compresslevel=6) as archive:
|
||||
for entry in manifest['entries']:
|
||||
rel = entry['path']
|
||||
category = 'research' if entry['kind'] != 'framework_customization' else 'framework_customizations'
|
||||
archive.write(source.joinpath(*PurePosixPath(rel).parts), arcname=f'{category}/{rel}')
|
||||
archive.writestr('manifest.json', json.dumps(manifest, ensure_ascii=False, indent=2) + '\n')
|
||||
os.replace(tmp, output)
|
||||
finally:
|
||||
if tmp is not None and tmp.exists():
|
||||
tmp.unlink()
|
||||
if not quiet:
|
||||
print(f'导出完成:{output}(旧项目未修改)')
|
||||
return manifest
|
||||
|
||||
|
||||
def read_bundle(bundle: Path):
|
||||
with ZipFile(bundle, 'r') as z:
|
||||
members = z.namelist()
|
||||
if members.count('manifest.json') != 1 or len(set(members)) != len(members):
|
||||
raise ValueError('迁移包中的清单不存在或有重复路径')
|
||||
meta = json.loads(z.read('manifest.json'))
|
||||
if meta.get('format') != BUNDLE_TYPE or meta.get('version') != FORMAT_VERSION:
|
||||
raise ValueError('不是受支持的 Code4History 迁移包')
|
||||
entries = meta.get('entries')
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError('无效的清单内容')
|
||||
listed = {'manifest.json'}
|
||||
seen = set()
|
||||
for item in entries:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError('无效的文件条目')
|
||||
rel = validate_relpath(item['path'])
|
||||
if rel in seen or item['kind'] not in ('research', 'research_extension', 'framework_customization'):
|
||||
raise ValueError('重复文件或无效分类')
|
||||
seen.add(rel)
|
||||
pre = 'framework_customizations' if item['kind'] == 'framework_customization' else 'research'
|
||||
archive_name = f'{pre}/{rel}'
|
||||
if archive_name not in members:
|
||||
raise ValueError(f'迁移包缺少文件 {archive_name}')
|
||||
info = z.getinfo(archive_name)
|
||||
if stat.S_ISLNK(info.external_attr >> 16):
|
||||
raise ValueError(f'拒绝迁移包中的软链接 {rel}')
|
||||
if info.file_size != item['size'] or len(item['sha256']) != 64:
|
||||
raise ValueError(f'文件大小或校验值无效:{rel}')
|
||||
listed.add(archive_name)
|
||||
unknown = [x for x in members if x not in listed and not x.endswith('/')]
|
||||
if unknown:
|
||||
raise ValueError(f'迁移包存在未列入清单的文件:{unknown[:3]}')
|
||||
return meta
|
||||
|
||||
|
||||
def secure_target(target: Path, rel: str) -> Path:
|
||||
validate_relpath(rel)
|
||||
cur = target
|
||||
for part in PurePosixPath(rel).parts:
|
||||
cur = cur / part
|
||||
if cur.is_symlink():
|
||||
raise ValueError(f'目标路径含软链接,拒绝写入:{rel}')
|
||||
return cur
|
||||
|
||||
|
||||
def write_verified(zipobj: ZipFile, archive_name: str, dest: Path, entry: dict):
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(prefix='.c4h-', dir=dest.parent, delete=False) as output:
|
||||
temp = Path(output.name)
|
||||
sha = hashlib.sha256()
|
||||
length = 0
|
||||
with zipobj.open(archive_name, 'r') as incoming:
|
||||
for chunk in iter(lambda: incoming.read(1024 * 1024), b''):
|
||||
sha.update(chunk)
|
||||
length += len(chunk)
|
||||
output.write(chunk)
|
||||
if sha.hexdigest() != entry['sha256'] or length != entry['size']:
|
||||
raise ValueError(f'迁移包完整性校验失败:{entry["path"]}')
|
||||
os.replace(temp, dest)
|
||||
finally:
|
||||
if temp is not None and temp.exists():
|
||||
temp.unlink()
|
||||
|
||||
|
||||
def restore(bundle: Path, target: Path, *, dry_run=False, quiet=False) -> dict:
|
||||
meta = read_bundle(bundle)
|
||||
baselines = baseline_data()
|
||||
target = target.resolve()
|
||||
if not target.is_dir() or not (target / 'code4history.md').is_file():
|
||||
raise ValueError('目标目录必须是一个已解压、可读取 code4history.md 的新版框架')
|
||||
# Preverify SHA256 for *all* payloads before writing anything.
|
||||
with ZipFile(bundle, 'r') as z:
|
||||
for item in meta['entries']:
|
||||
rel, kind = item['path'], item['kind']
|
||||
archive_name = ('framework_customizations' if kind == 'framework_customization' else 'research') + '/' + rel
|
||||
sha = hashlib.sha256(); size = 0
|
||||
with z.open(archive_name) as stream:
|
||||
for part in iter(lambda: stream.read(1024*1024), b''):
|
||||
sha.update(part); size += len(part)
|
||||
if size != item['size'] or sha.hexdigest() != item['sha256']:
|
||||
raise ValueError(f'迁移包损坏:{rel},未修改目标项目')
|
||||
|
||||
stamp = timestamp()
|
||||
# Conflict file directory stays outside the tracked research directories.
|
||||
review_root = target / '.code4history-migration' / 'conflicts' / stamp
|
||||
operations = []
|
||||
for item in meta['entries']:
|
||||
rel = item['path']
|
||||
dest = secure_target(target, rel)
|
||||
if item['kind'] == 'framework_customization':
|
||||
action = 'review-framework'
|
||||
elif dest.exists():
|
||||
if not dest.is_file():
|
||||
action = 'conflict'
|
||||
elif digest_file(dest) == item['sha256']:
|
||||
action = 'unchanged'
|
||||
elif digest_file(dest) in baselines.get(rel, []) and (rel in MUTABLE_PATHS or rel in MUTABLE_ROOT):
|
||||
action = 'fill-stock-placeholder'
|
||||
else:
|
||||
action = 'conflict'
|
||||
else:
|
||||
action = 'copy-new'
|
||||
operations.append((item, action))
|
||||
summary = {k: sum(a == k for _, a in operations) for k in ('copy-new','fill-stock-placeholder','unchanged','conflict','review-framework')}
|
||||
if not quiet:
|
||||
print('恢复计划:' + ','.join(f'{k}={v}' for k, v in summary.items()))
|
||||
if any(a in ('conflict', 'review-framework') for _, a in operations):
|
||||
print('已有内容不会被覆盖:冲突与旧框架修改保存到 .code4history-migration/conflicts/ 中供人工处理。')
|
||||
if dry_run:
|
||||
return summary
|
||||
report = {'source_bundle': str(bundle.resolve()), 'timestamp_utc': stamp, 'actions': []}
|
||||
with ZipFile(bundle, 'r') as z:
|
||||
for item, action in operations:
|
||||
rel, kind = item['path'], item['kind']
|
||||
srcname = ('framework_customizations' if kind == 'framework_customization' else 'research') + '/' + rel
|
||||
if action == 'unchanged':
|
||||
report['actions'].append({'path': rel, 'result': 'unchanged'})
|
||||
continue
|
||||
if action == 'review-framework':
|
||||
dest = secure_target(target, f'.code4history-migration/conflicts/{stamp}/framework_customizations/{rel}')
|
||||
elif action == 'conflict':
|
||||
dest = secure_target(target, f'.code4history-migration/conflicts/{stamp}/incoming/{rel}')
|
||||
else:
|
||||
dest = secure_target(target, rel)
|
||||
write_verified(z, srcname, dest, item)
|
||||
report['actions'].append({'path': rel, 'result': action, 'saved_to': str(dest.relative_to(target))})
|
||||
log = target / '.code4history-migration' / 'latest-report.json'
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
log.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
if not quiet:
|
||||
print(f'恢复完成:{target};报告:{log}')
|
||||
return summary
|
||||
|
||||
|
||||
def initialize_template(template: Path, dest: Path):
|
||||
"""Only create a new directory; extract safely from a known scaffold layout."""
|
||||
if dest.exists():
|
||||
raise FileExistsError(f'升级目标已存在:{dest}')
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
stage = Path(tempfile.mkdtemp(prefix='.c4h-upgrade-', dir=dest.parent))
|
||||
try:
|
||||
with ZipFile(template, 'r') as z:
|
||||
allfiles = [x for x in z.namelist() if not x.endswith('/')]
|
||||
names = []
|
||||
for name in allfiles:
|
||||
# ZIP is expected to have a single code4history/ root.
|
||||
if not name.startswith('code4history/'):
|
||||
raise ValueError('不支持的模板根目录')
|
||||
rel = validate_relpath(name[len('code4history/'):])
|
||||
info = z.getinfo(name)
|
||||
if stat.S_ISLNK(info.external_attr >> 16):
|
||||
raise ValueError('框架模板不得包含软链接')
|
||||
names.append((name, rel))
|
||||
for name, rel in names:
|
||||
output = secure_target(stage, rel)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with z.open(name) as incoming, output.open('wb') as f:
|
||||
shutil.copyfileobj(incoming, f)
|
||||
if not (stage/'code4history.md').is_file():
|
||||
raise ValueError('新框架包缺少 code4history.md')
|
||||
stage.rename(dest)
|
||||
finally:
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
|
||||
|
||||
def upgrade(source: Path, template: Path, target: Path, *, dry_run=False):
|
||||
source, template, target = source.resolve(), template.resolve(), target.absolute()
|
||||
if not source.is_dir() or not template.is_file():
|
||||
raise ValueError('旧项目目录或新版框架 ZIP 不存在')
|
||||
if source == target or source in target.parents or target in source.parents:
|
||||
raise ValueError('升级目标应放在旧目录的平级位置,不得是旧目录自身或其内部目录')
|
||||
if target.exists():
|
||||
raise FileExistsError('目标已存在;请使用新的目标目录,或 export + restore 更新已有项目')
|
||||
if dry_run:
|
||||
export(source, target.with_suffix('.preview.zip'), dry_run=True)
|
||||
return
|
||||
with tempfile.TemporaryDirectory(prefix='c4h-bundle-') as d:
|
||||
pkg = Path(d)/'research.c4h.zip'
|
||||
export(source, pkg)
|
||||
initialize_template(template, target)
|
||||
try:
|
||||
restore(pkg, target)
|
||||
except Exception:
|
||||
print(f'迁移中断,新目录仍保留用于检查:{target};旧项目未修改', file=sys.stderr)
|
||||
raise
|
||||
print(f'一键升级完成:{target};旧项目保留:{source}')
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='Code4History 跨版本研究数据迁移(只复制,不删旧研究)')
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
a = sub.add_parser('export', help='从旧项目导出研究内容与框架个性化修改')
|
||||
a.add_argument('--source', type=Path, required=True)
|
||||
a.add_argument('--out', type=Path, required=True)
|
||||
a.add_argument('--dry-run', action='store_true')
|
||||
b = sub.add_parser('restore', help='恢复研究资料至已解压的新框架目录')
|
||||
b.add_argument('--bundle', type=Path, required=True)
|
||||
b.add_argument('--target', type=Path, required=True)
|
||||
b.add_argument('--dry-run', action='store_true')
|
||||
c = sub.add_parser('upgrade', help='一键创建新框架并恢复旧研究')
|
||||
c.add_argument('--source', type=Path, required=True)
|
||||
c.add_argument('--template', type=Path, required=True)
|
||||
c.add_argument('--target', type=Path, required=True)
|
||||
c.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if args.command == 'export': export(args.source, args.out, dry_run=args.dry_run)
|
||||
elif args.command == 'restore': restore(args.bundle, args.target, dry_run=args.dry_run)
|
||||
elif args.command == 'upgrade': upgrade(args.source, args.template, args.target, dry_run=args.dry_run)
|
||||
except (ValueError, OSError, RuntimeError) as e:
|
||||
print(f'错误:{e}', file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,557 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"included_versions": [
|
||||
"0.1",
|
||||
"0.2",
|
||||
"0.3",
|
||||
"0.4",
|
||||
"0.5",
|
||||
"0.6",
|
||||
"0.7",
|
||||
"0.8"
|
||||
],
|
||||
"files": {
|
||||
".agents/readme.md": [
|
||||
"f661efc00bab10b69eb14951d141625a24ece1563b5d79ce231ee8ff5fe94f1e"
|
||||
],
|
||||
".agents/skills/discussion-to-topic-c4h/SKILL.md": [
|
||||
"ce47619ee7c97a9023d5f927cf48acd72ac77c7b617d5fb610388a00d32a5ba8"
|
||||
],
|
||||
".agents/skills/discussion-to-topic-c4h/readme.md": [
|
||||
"fc8b1a2fb0b27857bfc80903a9b35229eda214d68264634543029964170add23"
|
||||
],
|
||||
".agents/skills/discussion-to-topic-c4h/references/question-map.md": [
|
||||
"8891775df99818ece9656469ecc62186f12c3d430230df51a1897c3030a7d5bc"
|
||||
],
|
||||
".agents/skills/discussion-to-topic-c4h/references/readme.md": [
|
||||
"a3ca674cce41eb89702186eb9cb8c8c11f5fb862a3f12cbec8de7b0fde078dda"
|
||||
],
|
||||
".agents/skills/history-writing-workflow-c4h/SKILL.md": [
|
||||
"0b3e1d2cb8d78e3876259db8623f29ee913c5729013f08463df2b8f1931d4853",
|
||||
"516225a3a9d5b04c8a3598948977dc438089673d11cc80418648ce55e3c5e97f",
|
||||
"51bef162a8983c48e09972a91a0027c08713a140a1ab77ae775af3469d1f2994",
|
||||
"72f5fe3ea28ca612fe081bd789a6133fcb4e886bac9913ca744ecc1d312eccd3",
|
||||
"28255e7efb34e6b2033fd30a0368c014ef7bf77d09b27061e20f699e9901709d"
|
||||
],
|
||||
".agents/skills/history-writing-workflow-c4h/UPSTREAM.md": [
|
||||
"6a43f30439dd8753f91b284ead09e849b89d3a7f4c9ff9b4c64f98c225353d92"
|
||||
],
|
||||
".agents/skills/history-writing-workflow-c4h/readme.md": [
|
||||
"ca2ab8c7d16dc45424ace23ebfecd4774312113c153be929507af6226b1a6dca"
|
||||
],
|
||||
".agents/skills/history-writing-workflow-c4h/references/argumentation-and-drafting.md": [
|
||||
"63398286d9415e4a59e494d731ffc1f4ac65a6e1d305bb2e9c32b64358c6fc40",
|
||||
"6e5cedb0ad865ae7ce55f006e9e56e234156d18f276a63d87c642cdb1c5c166a",
|
||||
"f8c2d06f1d1ad167bacf687f4c5c4cc16c0a074a7c1fee89420beacadf772192"
|
||||
],
|
||||
".agents/skills/history-writing-workflow-c4h/references/evaluation-rubrics.md": [
|
||||
"070accefee47cd49b364b8ad95ee7b1469767ebfb1f79898ec14131af676e6c7",
|
||||
"320c0b322fc2e89638709539bb90aff056e50465625f503e15a59772773553d0",
|
||||
"cf3b5e8d14a8e5b3353cda3f55c97d14864bfd84ab72338efc0608eed2c850e3"
|
||||
],
|
||||
".agents/skills/history-writing-workflow-c4h/references/provenance-and-audit.md": [
|
||||
"24bd770e971c0f6419a3e3528cbb3a69e89c7e92f8e94c0e571e81ef18c01d3d",
|
||||
"6eafb1254e54172fe59f8e2e38dac9f146ca95eef9e983dd230c91b98b0edb40"
|
||||
],
|
||||
".agents/skills/history-writing-workflow-c4h/references/readme.md": [
|
||||
"0955ce8765b7d2d1d185fd980303da53d93c56af20a269b23e0c27c81cd95891"
|
||||
],
|
||||
".agents/skills/history-writing-workflow-c4h/references/repository-map.md": [
|
||||
"44421a024fb5bcae083bd7ea48da1d819cca2a56d9e293f1a2e846528a031978"
|
||||
],
|
||||
".agents/skills/history-writing-workflow-c4h/references/stage1-problem-literature.md": [
|
||||
"061fed06c1ab422efc4c4f7126ceef978c16fe3f5c60e20ad8a6cd913943c00d",
|
||||
"ad77336bde31c1e8ddf5303de3c5dec16c753f57d4b5c640b84c4e505de0edeb"
|
||||
],
|
||||
".agents/skills/history-writing-workflow-c4h/references/stage2-reading-outline.md": [
|
||||
"0f2808166ccf60f1f83a1ce9eb31f6ded2224e3c83db479dcd00c40abaaa962f",
|
||||
"337bfe8c2bcd29ce622cd5b9e335a1b71afbf52629566d7adfe0a6c0d9007dff",
|
||||
"b0a79d9bb696e735556a6aafb7215e42107efdf92ddc177856827e0a1b61f3d8"
|
||||
],
|
||||
".agents/skills/history-writing-workflow-c4h/references/stage3-draft-finalize.md": [
|
||||
"2a5f0052fdc0ee17033f0b6ad43f251d99f5111faaa9ab38998ed8a365cbc862",
|
||||
"302e74b1240992b42fe1a4b9674b96cf74543fb679f16c749494791a2c140c50",
|
||||
"53555927d499bd03814b058de98c0e227c8a846912223192ce2858420effb9c4",
|
||||
"eaf8502b125b2b40f3ba880ab9b09b58ff1d67c1734af54bdcb77f3fc1a1a6f1"
|
||||
],
|
||||
".agents/skills/history-writing-workflow-c4h/references/synthesis-logic-quotation.md": [
|
||||
"864a9d63a1665969155c2a199bf7d24a6b3c7965e5ff32e2a2877da0e6f68fce"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/SKILL.md": [
|
||||
"861f9b87f4f3cb6476f7ec1a3b0d7f0379bea2fa6151f11fc706dcece612129f",
|
||||
"a36456d1e97fa2712621468f516f966bda4719f4eea43f1ff1e316cb5778fe56"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/UPSTREAM.md": [
|
||||
"6dac738e934a34c535cf3bc625d644f0b4d4c3e992f6d3644d69050331f9d5ac"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/readme.md": [
|
||||
"29330f3939023cc2e1659166f340d5519a908a0361966c67f73208d7927a3229",
|
||||
"bbf5a9522b242c869c9cf8b3ab987e0df89f69ba97c4a5bae9895576a7ba1b24"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/references/argument-writing-review.md": [
|
||||
"417b1cc36f7a1b33069329ed244db77419b09db75b8ba63951ae4b9fb5b1b470"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/references/evaluation-rubrics.md": [
|
||||
"a2f9b9340139f4c6d6225f17f86ec235ee551d8c9abf8c5d0937c2a324d40f16"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/references/evidence-and-source-criticism.md": [
|
||||
"bb004414685f03441ded1df266ac0e663330da1698f3ce9f2dd63e36c377cdee"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/references/historiography-and-novelty.md": [
|
||||
"2e76663e32ddeef577764ba39f250ccf3c3cd47bd83f5967c8f922d8e14d2bba"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/references/ingest-and-provenance.md": [
|
||||
"eeb30df8edffa1b42684a045557a750a760757f062ee2a63fcb8518ab9909c96"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/references/inquiry-and-context.md": [
|
||||
"a8ed73e3576c58efa23a19315dda3294bdf880f1b38b933bface45f224738ce5"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/references/provenance-and-audit.md": [
|
||||
"24bd770e971c0f6419a3e3528cbb3a69e89c7e92f8e94c0e571e81ef18c01d3d"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/references/readme.md": [
|
||||
"046e7f69fe80eea409005c199c7f69d2140284fef73a162220901589d689c985",
|
||||
"db7d4033f798a95a86300fe8d983352bd306987fb1f73c4119de2f34ceeba8a1"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/references/repository-map.md": [
|
||||
"44421a024fb5bcae083bd7ea48da1d819cca2a56d9e293f1a2e846528a031978"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/references/stage1-problem-literature.md": [
|
||||
"1b4a1d309f8fe77210aa7761746c02e5cd7c691d409be284095ba549dc20b3fd"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/references/stage2-reading-outline.md": [
|
||||
"9f174a2ae995e6cef66bd9a5626b3b5a421f9cbc97ef2a7aa89ca43b4d374530"
|
||||
],
|
||||
".agents/skills/history-writing-workflow/references/stage3-draft-finalize.md": [
|
||||
"507d587d43b633a4fd8f9ca7299c37dd8e849b44781b116f6638b39a1e40442e"
|
||||
],
|
||||
".agents/skills/readme.md": [
|
||||
"306ff6f28c23b2616cf44fda29d22cdea30c8805ecf4a4132a84a7cbbd2fc4d7",
|
||||
"5387f6e3613cceff713aeec122144cf395421c5f6503364f10652039782c1a43"
|
||||
],
|
||||
".agents/skills/source-intake-c4h/SKILL.md": [
|
||||
"3e0e7217ab96b0c6102188f0b8227a59f2f63dd3340dc24f2479ff6b64022baf",
|
||||
"dc7df7b20c0433e06896a982bc490552561fffc33e1ee30fdff65376eedcd529"
|
||||
],
|
||||
".agents/skills/source-intake-c4h/readme.md": [
|
||||
"881e28828eba10bf5076521bc451281a953ad93ae690f9cf165905c1bae76ca9"
|
||||
],
|
||||
".agents/skills/source-intake-c4h/references/intake-map.md": [
|
||||
"367c3a09c23bc1d5199e703290dba171c7f586b676122b8d16ef3a88fb15196f",
|
||||
"65db49d09d64aac3b57b0a57ef9cc6e45ed6c7cc61922bd764169ce598f6499d"
|
||||
],
|
||||
".agents/skills/source-intake-c4h/references/quality-checklist.md": [
|
||||
"7b563776ef6d9e491693814af666d85f0077f5fbe519011185bda17597c7b37f"
|
||||
],
|
||||
".agents/skills/source-intake-c4h/references/readme.md": [
|
||||
"9d8199765c87c1994229074405b45501a79e21de0fc84016360dc77152d055c6"
|
||||
],
|
||||
".gitignore": [
|
||||
"3fc291d69b9eb33dce6480bca35f52b12e3e1574f71cafd6937ecbd27d6d9fd8",
|
||||
"34ba4deb87856bce73f700ec48ddb5ca56ab9aef06e347e55b2efb748ca18f08"
|
||||
],
|
||||
"00_governance/bibliography.bib": [
|
||||
"74efccbf989fd32e21358ba96a760dc5e93f76f6da0411fb9449cd021fc9e020"
|
||||
],
|
||||
"00_governance/checkpoints.md": [
|
||||
"3992c6dbca7a70885a8d41d0e5e845c7ff4c5d3c3923db44ac1366f9538bf000",
|
||||
"fc14be0b7e268ad7fbb8fcd43a7fc450d3f4784506d1ec7841113b25f2723063"
|
||||
],
|
||||
"00_governance/decisions.md": [
|
||||
"fb712651f75b9bd82c4c968237f5dd41d3a11df694719625136ae89a99a487d7"
|
||||
],
|
||||
"00_governance/readme.md": [
|
||||
"381c2b992d2ee7928e4a23fa68147c1a7158f6eeb354fd7aa2cc5f35b4f40a67",
|
||||
"db5c3cb8c020c4e3ffed1a6469c3a263bb505b9f31e17714d80c80d50296a990"
|
||||
],
|
||||
"00_governance/research-log.md": [
|
||||
"28ad96006e5922b835ef54f1bef1ed6b7f9d50f2a14a01c634ae3ffd19abd3f9"
|
||||
],
|
||||
"00_governance/source-policy.md": [
|
||||
"0cad61590119d7f7e4a31f2a92c2dd8262f7baadda34e9b2f208f8b08eecdfd7"
|
||||
],
|
||||
"01_sources/catalog.md": [
|
||||
"f2fc88db8bf19d4d46542cdf74596ca6b866d4abd5d0ca6c9bf13c044634f1c8"
|
||||
],
|
||||
"01_sources/originals/primary/readme.md": [
|
||||
"a17a108a0fddead087855a7f586a02cbb4eb0a4272dffdc2369c8ab6cd39770d"
|
||||
],
|
||||
"01_sources/originals/readme.md": [
|
||||
"f46f8e64414c65b36ff61caa7be0e37ae5a089ee16688daf4218b715de592a26"
|
||||
],
|
||||
"01_sources/originals/secondary/readme.md": [
|
||||
"076004ae5e7155debc4dc8c6ffe2efd816f067463a35296187a158ad3ad73fea"
|
||||
],
|
||||
"01_sources/readme.md": [
|
||||
"8f244bba7ada54c50f45246a57b1bb6d19dc169a102daa4206035831cecea21e"
|
||||
],
|
||||
"01_sources/records/primary/readme.md": [
|
||||
"7a7daa5e08044598035c6ea4ccb7d5bb666ecdc64863f626bbce93521a61ad06"
|
||||
],
|
||||
"01_sources/records/readme.md": [
|
||||
"a0ae64ee20c39ea582cec4df236826f4521e91b9ed5874f6124d0f6f4aa596c2"
|
||||
],
|
||||
"01_sources/records/secondary/readme.md": [
|
||||
"e9cd2c56af0203ec50fc52cc05336aa504b2525b47ffc9bee2d135de804293e4"
|
||||
],
|
||||
"01_sources/transcripts/primary/readme.md": [
|
||||
"e1a7ae7ae1eeae5083be239edda4b3bca1153bedb3d2b34a87c01173bd2683a2"
|
||||
],
|
||||
"01_sources/transcripts/readme.md": [
|
||||
"aeb183b6d3192f4983f592d55dce7abe5f9084a21a5998866efcc74a31e05c5a"
|
||||
],
|
||||
"01_sources/transcripts/secondary/readme.md": [
|
||||
"9cf29a5aa5279f561f0d67387ad22fb30693c158f1666656be13b3e0237de68a"
|
||||
],
|
||||
"02_evidence/readme.md": [
|
||||
"81bfe6519618ecc8932c3a34ed5493cfe4f530a85f16d815667fc50527d39f66"
|
||||
],
|
||||
"03_inquiry/hypotheses/readme.md": [
|
||||
"8934813a175a5391ebf31484b28c65189fb1fd32d14c271f4082c6b56765ed56"
|
||||
],
|
||||
"03_inquiry/leads/readme.md": [
|
||||
"cbb1c0f4fe3a3e693e4a1356beb6cfb2687579d1adaa8d6ccfd70048067f8698"
|
||||
],
|
||||
"03_inquiry/questions/readme.md": [
|
||||
"f1281c45d59248661eeb03fb697ff2363297cb5783d836eb3201ede725d697d0"
|
||||
],
|
||||
"03_inquiry/readme.md": [
|
||||
"d4235b6b8c7707b9c6e48a98db4e4455d4ed9bf70e62d9b15571be3f21452ac2"
|
||||
],
|
||||
"04_context/background/readme.md": [
|
||||
"439ff48982b5ba6eeb53097b26a62469359c69545f3a3d2a98d472e061239078"
|
||||
],
|
||||
"04_context/chronology.md": [
|
||||
"1f108bd7333345313110f442fa16faef093e75ddc5635107aedef8574699abdc"
|
||||
],
|
||||
"04_context/concepts/readme.md": [
|
||||
"b561b784732a9a29ebf2ad5cba59b0ff0c1302857c2c9fb54d820849d24bd8af"
|
||||
],
|
||||
"04_context/institutions/readme.md": [
|
||||
"394279898748cc2d47aa9e60e28b288821dff656f8372421fc0612468b5b5f40"
|
||||
],
|
||||
"04_context/people/readme.md": [
|
||||
"32fb948ab979dfae0e7883d951ad6cb90b7a76491ca2690ce4869d88838215bf"
|
||||
],
|
||||
"04_context/places/readme.md": [
|
||||
"074a16f28d9774cb32079957b6ad829523f6fc3c44d6896f4afd2180ed1ebf39"
|
||||
],
|
||||
"04_context/readme.md": [
|
||||
"e14d68e87efa82ebc5ee86406af37ecab825230827818b21a1d337280e87b253"
|
||||
],
|
||||
"05_historiography/debates.md": [
|
||||
"c90cdd5f52891923af5a0561897b7674021ac11f8bdfa5d6f61038ed5e3327ba"
|
||||
],
|
||||
"05_historiography/debates/readme.md": [
|
||||
"8bcdd5b820e53641c324d012df30b79ee11cbfbeca75990aebdee6bef3dae224"
|
||||
],
|
||||
"05_historiography/gaps/readme.md": [
|
||||
"fdcebdda2e5d51de9bbbf922b9a0115d606d41e0fa5a8be48eaec2757d52f4fb"
|
||||
],
|
||||
"05_historiography/readme.md": [
|
||||
"058ca40433890282f7ad265519fe0e5bee7fe819b524ddcdd673427e02d68a97"
|
||||
],
|
||||
"05_historiography/works/readme.md": [
|
||||
"40bd190a499ab74a68b6e6cff04fe5ec37ee8f8b79650799e39b2698904ac739"
|
||||
],
|
||||
"06_arguments/claims/readme.md": [
|
||||
"a25081e87ee13ac3a5e598af76c1f54c682a9786ea0308b41086c13fd1f96f4e",
|
||||
"cf8b4ad87b25e3ce44e2af34be582dffb30229865dbf03d17fd772d6c140d032"
|
||||
],
|
||||
"06_arguments/conclusions/readme.md": [
|
||||
"d0b656f43a5d7b70bd7b714fbb8b6afd436d1cf1e11a7b15d5c2008facaa94f1"
|
||||
],
|
||||
"06_arguments/readme.md": [
|
||||
"42ea43870009759747ead59ec83a8056409435eb65a40aa607d1e4151a02a740",
|
||||
"62e5d5717081070bf8497556629c4c7c5d05f9141c634c43785001f2c54b97c1"
|
||||
],
|
||||
"06_arguments/syntheses/readme.md": [
|
||||
"6836297412e6306ccd92981b325250ce985b62944c07ce9453913ab69f4c5b37",
|
||||
"8494124213951743e514112504ef7cd01b49e64aa9fbe36d57cfb2c7e34fc00b"
|
||||
],
|
||||
"07_manuscript/outline.md": [
|
||||
"5d43bb311ee9e3ce93786ee82a1ffa0f8bb0b9852b3ac6ad4c96012b519166be",
|
||||
"a5b9bf15ef4796a26cca0b1f2567693ff201d7e3b1cabf863717dbe4cce6fc3a"
|
||||
],
|
||||
"07_manuscript/readme.md": [
|
||||
"6f4499e38307a828e334498e1ade3a24aa47b01d407325b97185ea2a133f33db",
|
||||
"830fbeb8d5195caf455bf4e61b9b311d97e235d666272eeb1b265d6af0a5523b",
|
||||
"875663e57c93a594aaca2a8d23994f7b01768cf3f5c9b966ddb0d71e6daa02c4",
|
||||
"8bb6a20248f9d90015d4b126263a3ddbe6f3e3f67a7540cddc8197e7f724d5c5"
|
||||
],
|
||||
"07_manuscript/sections/order.txt": [
|
||||
"2dcb19fd8f4d7279067ca07deb5eb1c1b69aae3a5eb8f9f6379eafa1e136c831"
|
||||
],
|
||||
"07_manuscript/sections/readme.md": [
|
||||
"930f245717b292f47a48d82ef43e344ba10c845cf8365ff2bfee682cb7eff4f8",
|
||||
"bec58f9e7f4ebe1ab832056d74bf249608f19e0a0e4949df002fe897b586391f"
|
||||
],
|
||||
"08_review/audits/readme.md": [
|
||||
"d9c08f56f305420c69b3de6b823aae79bf7e0c5509ca7ca3fe21fdb383d514dc"
|
||||
],
|
||||
"08_review/citation-audit.md": [
|
||||
"232245ab5d9a1e33a5fa4ed4bdd8c2d087584a1095190b62e5d50b44a7b79c01"
|
||||
],
|
||||
"08_review/contradictions.md": [
|
||||
"33a2347edabf92827b9c50c327c363c7cb64a7019afe6d13f8a3a120779cf320"
|
||||
],
|
||||
"08_review/counterevidence.md": [
|
||||
"d69f7862f5d644f04b7c48bb766ce36aab49fde880b3b2ad0dbd2899f3efa1d2"
|
||||
],
|
||||
"08_review/readme.md": [
|
||||
"6a9605b300314ccc1b0b277a9af8bb6166ee7cd20098da3ed3bcd016dbd6a929",
|
||||
"a979d2a7706ab38990728a14bf37fb7be7246552712b71da713d069ec973d120",
|
||||
"f5fdecc614a1fb96defe1e93ccd2531bcc5ef997eaf71e3ed62decc0bd3053a6"
|
||||
],
|
||||
"08_review/reviewer_reports/readme.md": [
|
||||
"7fff6b3887c3a2bd6bff2df0ff745d92847d7538d303c500737675fea09c6901"
|
||||
],
|
||||
"08_review/submission-checklist.md": [
|
||||
"83c66cc1ef3ac4be260e5e2c108ada595ac564efacecef6df5b428e6f2006516"
|
||||
],
|
||||
"09_outputs/.gitkeep": [
|
||||
"01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b"
|
||||
],
|
||||
"09_outputs/readme.md": [
|
||||
"c46d281b3a66d79c0425059cba3a4c91d4a95554cf8c1c43ac4bd60056ebab17"
|
||||
],
|
||||
"10_templates/claim.md": [
|
||||
"31a0a68f649774b2be66422767e1c3c72eda88866efff90e9aea4edaec9fbb58",
|
||||
"bcb123159113605e5dc29a52f106a651747b15dd4b4eced44f7248d459f2c870",
|
||||
"d8429e3ace915e0ce6184865a668615d596f6a9b876b79e26f53aa87c061e86d",
|
||||
"dc0901e62748c44d134f4854a8b8061c2d7d7c67337aefb2ec3b101c0661e0a2"
|
||||
],
|
||||
"10_templates/conclusion.md": [
|
||||
"aec7da3f89c258051a33fe9dc6fed8ca1c0a9eee859467aaba792e20fe399aff"
|
||||
],
|
||||
"10_templates/decision.md": [
|
||||
"8429341ea096bc654e94bcb9e93a232932d2693c477763929181bbb58b727405"
|
||||
],
|
||||
"10_templates/evidence.md": [
|
||||
"67f72d7cff2cd95b0cf8d30d967445088bdce37d4e0c1e96266a25652279ce0a",
|
||||
"a2e7dc91558c2e2f13fc7698e8765b30d4a2b1751f74792b04f85a97b0264d00"
|
||||
],
|
||||
"10_templates/hypothesis.md": [
|
||||
"9d5c32aad8df4d4c7ebcfcb170209249a1034291738cdcdb25ace56de14ec953",
|
||||
"d0ed40d2c118759e315dcd6c2b26606f810076cceb62018c06581cd6c764e107"
|
||||
],
|
||||
"10_templates/lead.md": [
|
||||
"d9e4e9ecafaa0b2ec15d8660ba9be053c70e2b96e1a7334d0e431b0074a57328"
|
||||
],
|
||||
"10_templates/primary.md": [
|
||||
"263fcb8b82c7ed1a8eeb090a2c96b76e2db9976931f068162d491eb790258c6e"
|
||||
],
|
||||
"10_templates/question.md": [
|
||||
"867378abb9181654fbef6dec4fa5a6169810f363d49b43f2545b22f6025d630a"
|
||||
],
|
||||
"10_templates/readme.md": [
|
||||
"277966c5d574594af3d93fbdb7db17fb4bb972fb6988c83631b89e3130aeba18"
|
||||
],
|
||||
"10_templates/secondary.md": [
|
||||
"ed5cf97b9d395220eb6bf7b362666c367d452fef2a8c5be0e1679cf9851b8daa"
|
||||
],
|
||||
"10_templates/section-plan.md": [
|
||||
"3b935473b7e709692511cb4f221f0b3c416d201095afc96c389bb5cab906d05d"
|
||||
],
|
||||
"10_templates/synthesis.md": [
|
||||
"718a8db9d6fa686e6f849001bfff063c784f41e42b57f2bf1753f54632dc36ae"
|
||||
],
|
||||
"10_templates/work.md": [
|
||||
"568c06df50a61f2f3ecfd1009ccf04a29c12c9506b4d15ab4ca3191bf557b078"
|
||||
],
|
||||
"AGENTS.md": [
|
||||
"1d55a1e9d96e9df37923ff226215c420f1541a9cd3a9330c1152a7e5d0dacbfa",
|
||||
"3760d11db0f0cc157c3686913af59d2341e698e663af562ffd220dda96d2551b",
|
||||
"8710e11a5e5357b7285b84c00573bd54d601c6df9088e35d5017ac15716b2d51",
|
||||
"87307e3fdad9297435e64fd5b0803b7e61b34ff74c9e1335052c68d5d9c3fb22",
|
||||
"8a420da68464a5dc2973822a622d1355278e0822288a70828e6e56620536b191",
|
||||
"b2fb503a1c559868c80d031b733a2acee323d42a3585ce528706de84415f475c",
|
||||
"d83191f40402d81cd336440c49f914ac2a61ce6e4b99b19c550430233aabcc8d",
|
||||
"98edb27b17fdb8a8883cc7ece28ac618094ad9fe3edfc309dc56c52a7b2fcc97",
|
||||
"01e4ca7bd40480c204299c9b3a80e8d62ca105bbaa613e455a872ae12f832d62"
|
||||
],
|
||||
"ARGUMENT_DENSITY.md": [
|
||||
"410e8441d49c919f975920ff21c68c00ec5a9c23cbfa3b67ae7957bb16730c1e",
|
||||
"437f47a840d88bccde2e9d7c0cc7c38d181af30bcd8ab7d313dee7ba35ef9f36"
|
||||
],
|
||||
"CHANGELOG.md": [
|
||||
"069f0789e12bfbb83381180b46c8a21683be5f304f3466ebe21d5638a98b9d02",
|
||||
"0e8f4b23f2273b6d75c2b1f74ae9a7d263c4e90d47ccea18fe57ea191b179a0f",
|
||||
"1e4423e28d105579568f9d89f52190db83e0ef0767c995388f68baba8664c5fd",
|
||||
"95d874b006349d676d8a5f0101672a3217cc77755d05ee5bd9ecfee16a397f96",
|
||||
"b8b3b61c71e4cedd43c2f14e481fa50a62471cccf4947226db987fa4f6b23796",
|
||||
"fdd8379fa405ace33a339695b5ba3cc6abc9262c1245069d60d9dfa7deef5f42",
|
||||
"13a1beeff00985e73f7db92cff4673e2ac0ac60051fb2755972f1324b88ddc8d"
|
||||
],
|
||||
"DISCUSSION_TO_TOPIC.md": [
|
||||
"35aede1ff5a3e820fadea5672f00184fa3aeafbf31e7dd733dfd1c2465d343bd"
|
||||
],
|
||||
"INTERPRETATION.md": [
|
||||
"48cf20c04551e3992c17a5ca9747a1664e37594fcc234dc59f0af75eca8dd707",
|
||||
"577a9a96e341b10d9faf099cf1c6de9ce3f1759b5d01db55e3bf378437dd5b0d",
|
||||
"c849eaa3804d7937a4dfacf624e4701ddfed8716ebb45176e0b353da2041082c"
|
||||
],
|
||||
"MIGRATION.md": [
|
||||
"6f3902fc1e2ee9cab2c3bb85a26962a7dfc99f84c33097ee42ddc6202bcf8415",
|
||||
"f1f10a3bd77f65aaa6cc637d00054409e198ffbb96d5886fdf4b62624f33d676",
|
||||
"4c9023e4d23d930cfd20b4f401d3c4fa66e29ae4f3fda4e0d9596a18acbcd876"
|
||||
],
|
||||
"PUBLISHING.md": [
|
||||
"0e213f6c4f36980193431519c93e9f71669d02b34cbfa90ee3eaf39344ad087a",
|
||||
"56e83c84abc17be1d085f9a39cc0519fbf37c325a939867d54f0ffda6fabf9bb"
|
||||
],
|
||||
"QUICK_FIX.md": [
|
||||
"4bba904e304c7296016c0bcb5e110634cceae04ad09cc6f9caae8c2c4ebac156",
|
||||
"cdaaf5446bee8d8f7c414cd3ad9e87ae6f1c3c1c83d1a1f40af06f05a2f49232",
|
||||
"dac3961287b4b3fe03f2dccac6f2e01a2806c431fe277ce1c75e6dc49e572a72"
|
||||
],
|
||||
"README.md": [
|
||||
"37fd0e0a75193c0c54a069a6542858a467a3f689dec5b49f162ba3573096195c",
|
||||
"4981a3c7eacdab928bf32286d6ea859e3b225402aba8edc801646bb256b0950f",
|
||||
"565a9fd0c1bc2dccbb4b005bd73ffbdb166c0b896a9a544177799456792b95ce",
|
||||
"6b3019f637a694eee370883c44fb2203d5e636a666f5025f4dbee76117091d7e",
|
||||
"74b2bbb5532d6c4448f7cb8f94e5ea1438fa831ff4f2e4f42af67ddcd9e0d61d",
|
||||
"b6987d52428a60d17f38d323ef8f4cbc37ade045ed02e4b76f53572b819504ee",
|
||||
"f6ec7723325740a5ca1d7b6db5af4281ef983e32240d39b64e2267d777e5266d",
|
||||
"4ad38c2ccc02dccc475b62a208500a17588bd059751b352a1aa67dee6f303ec1",
|
||||
"f08fe8ec6468206cfa949c5e66ad18707166f11190846f1e3e7fab7621fc5e9c"
|
||||
],
|
||||
"STATE.md": [
|
||||
"0fb5c4bc7d02feb78348527c9665add5c277f987edb71be0ebf8cfa046877d37",
|
||||
"993d3ffaf95d2160fc4a7136c932d0ecf64806c8cac82a30a255b52711050a51",
|
||||
"e31cefe9670a0bce69eeeb6eb148012fb84a1f0edda5a247f4ef16439634480b",
|
||||
"54d0192a29b4d2c2108bf8012c9f40d8bbf84e9cf721cb763e0d55fc814f3193"
|
||||
],
|
||||
"SYNTHESIS_AND_PROSE.md": [
|
||||
"0206a95039815ad3f478e64140aeb0e553423003f39f434dc155adb1329a0ee2"
|
||||
],
|
||||
"UPGRADE_v0.4.md": [
|
||||
"863e25429224dc401e4df4300409f37bfcae1f34127b061c97991358b6ebd8f1"
|
||||
],
|
||||
"UPGRADE_v0.5.md": [
|
||||
"93211957159d334448d6e7822d8ee185593993bf72f4d762f702d90b505cf78a"
|
||||
],
|
||||
"UPGRADE_v0.6.md": [
|
||||
"80ecce0ae82bd39ed8c1e2f5820e6cab4f6c926728f3c364b1a5f26c6c286ffe"
|
||||
],
|
||||
"UPGRADE_v0.7.md": [
|
||||
"a1887e8ed31af4aa7bb28ff07a5a02ab3968485639b679754865bcefc48c4d33"
|
||||
],
|
||||
"USERINFO.md": [
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
],
|
||||
"agent.md": [
|
||||
"06c0777889bf6969711862bc62ae7bdb6b1a60066894b0dc9b1ee71040dd0e38",
|
||||
"a31717efa0d5fc5dc8eebb9f19d4e301ca252a144bf9699dafd342cbe4d74f8e"
|
||||
],
|
||||
"code4history.md": [
|
||||
"3a0c6e6e7a5fdfb0e559d618329c98e1c2ac044f76f2cca33719b54cab6f240e",
|
||||
"4db901bd43f21939e9eac40c8b5632aaaef04cdd62fbee67ed2679da62223210",
|
||||
"5c45803a270161819fb5903102e3c47f8a7df7aaf658a60209e413bb30e566f2",
|
||||
"75725cf8ab7bb4acc442e35f25b7b7d6d152760acb3d11530dc88a42f66d2609",
|
||||
"7955d67fe6e94a56eef7702ba5751e31e135e652aa8bc73869ad8a6e681f3467",
|
||||
"a82ee5974af855029b40f80ab3c26d1fd198990c689fcac29d7d206cf643f91b",
|
||||
"bfa679a284b0248d80c3324637c2fc6a5aaefa1c6b02ee3a8d828b21f2c9237e",
|
||||
"8ed2e1323f03242f39d192667520702c8584f6b85a8c1df3b6486219b845fc14"
|
||||
],
|
||||
"tests/readme.md": [
|
||||
"a94cb7669e0a3dbf587c0364b7fed6e76fcac6547e514a3f1ea85e369107d248"
|
||||
],
|
||||
"tests/test_argument_density.py": [
|
||||
"b272969fb27796fab622627d732c85f53bcbbcb832a46e91070f9a4896468412"
|
||||
],
|
||||
"tests/test_interpretation.py": [
|
||||
"b9ce5fa07b2a3336e56d43be6b2f2205659fd56c4b9b2d6a7179bdb133953f98"
|
||||
],
|
||||
"tests/test_migration.py": [
|
||||
"cc45d7571ba09ac8cb6504e9134473b9fb4680b7a365fe82f58469e0b776dca9",
|
||||
"47cad5af22f31e6956370330543c41642585a8ff83ec4a15a0337080f6761721"
|
||||
],
|
||||
"tests/test_skill_integration.py": [
|
||||
"0073de371901d1fa418d321538d0468db4ae23104ce247d948a02c937927dcd5",
|
||||
"63b6e8fd694c59cd9e24503f88f3b3dd98e81c7f7621cdb987f3f500ad421246"
|
||||
],
|
||||
"tests/test_synthesis_prose.py": [
|
||||
"8480204d75e4dd7c88c53b5e4fbdcbed36f031b2bbf10f59df7b00fe6fac4fb5"
|
||||
],
|
||||
"tests/test_workflow.py": [
|
||||
"7ebe58c481639fada27bfdce89fdb2a9c63f7253e64dc7918aca2a860293c454"
|
||||
],
|
||||
"tools/assemble.py": [
|
||||
"4d8d3c44a1f2133af125a16402c737f3bbcb8d06dec13afc3aa0aeb2d26e2f86"
|
||||
],
|
||||
"tools/doctor.py": [
|
||||
"0d3be3ee68ae37f234c13bbfff780107fb8f674ae19f1a1a4d0e05d86a0c495b",
|
||||
"7b59f3324b7e1370a7b106fa9858cddaced3945c7fd2e81f561d02a50b22e105",
|
||||
"ec0d4259af346a9c2b089f0a17dd972c86ad57b16c4cf91e608218abe1a7b40f",
|
||||
"0e10719ebda32d3710ba777446c619ec27b5fcf0c9e8283dc7c0d9308fb94c9e"
|
||||
],
|
||||
"tools/migrate.py": [
|
||||
"278843d131a915df682ec387f4651a92eff1e28b41decdcc64e386a94b6019e3",
|
||||
"1ff8804b348324cba66664f0c46cd2466110731798dcbf5057488e4d3871321a"
|
||||
],
|
||||
"tools/new_item.py": [
|
||||
"16ad09d4e6bef32620fce732c193b2878cab7f0f21bb223305181161cd5bdba4",
|
||||
"be38fbafeb01ccfbe00897c1d6ce2b33cbb8698ea339f1631552e610eeea0332"
|
||||
],
|
||||
"tools/readme.md": [
|
||||
"1b1e1722e5aa0b031261396e1d5884cc5fd6fa69dc0d584286cd5713e6ded9cc",
|
||||
"a8bc91ce94d283d82402fd5c447dd15503e9ac9acaadd338affd782a96611125",
|
||||
"508411437aacc9cdec7d5f9d77a21dc00109e25c13f16708a1ea7abde3e8357a"
|
||||
],
|
||||
"tools/validate.py": [
|
||||
"2b0d383d2b4899d598540aa227381e58da6bf7e9e8b91e26b5660e922720ce1e",
|
||||
"8eda7c7c87ebfb263f653502ecdbcec2a5be682ede79221bb5ef00ae5e2e630d",
|
||||
"b193c354673155b354f195100f1d81d9aa39dc978b5dcaaf442a67e642e34dcb",
|
||||
"ab83d09becdae9096ffb277986c3ab9fa93792a33353529778c8f4d7108e9007"
|
||||
],
|
||||
"topic.md": [
|
||||
"6c0e5d567ee8edbd3c61b6287b7ce71b742487b0b89986b78a5a9398e49f2ca1",
|
||||
"82f940a9ae9fc65eae62acd81b36b08387970add4f0b974ff124568183dc6222",
|
||||
"a0cb416d42cbdfc1a042b40fbc9349fe1a812b7b1483e59ba154942a30be84d9"
|
||||
],
|
||||
"UPGRADE_v0.8.md": [
|
||||
"bf2918f2c9fd1f109f56b2aa594e561fd6520330e6f169d229932e32daa62e34"
|
||||
],
|
||||
"INTERACTION.md": [
|
||||
"8c91ce0c4c90cae2f72c545b321f5f13098ccce4ed39e57632960b93f9ba26f5"
|
||||
],
|
||||
"tools/interaction.py": [
|
||||
"d2fb01084aec4fff8da519c542f5beff4a967ff1b3a0bcdff607678cd61ec1c5",
|
||||
"e1b358cd1f498b54e1929d023fe55a4b226b9f75d1b61102a13fb9972e5694a4"
|
||||
],
|
||||
"10_templates/todo.md": [
|
||||
"dbd930af9818e12e8bcbcc45058b69acc12c3dfaa327d0b9fb2145b90090db2c",
|
||||
"9b02b82b5da7f266127965cfe722097b025f6f3c2e2d5073f8e97f59af1ccdb5"
|
||||
],
|
||||
"todo/readme.md": [
|
||||
"e5afdd783983b22769cbb4bde1aac7e419f35a615f1f3c7c174996a108d86b0a",
|
||||
"1d02c994193df6ff74972474f4880b2098aa8ed8542b98b7d5c58ae3fc61f2a8"
|
||||
],
|
||||
"requests/readme.md": [
|
||||
"5c098c16d74d75de5a7a47cff27112fedf63ba7fbab8df6ed8e8e3627a03548a"
|
||||
],
|
||||
"tests/test_interaction.py": [
|
||||
"f579f9c5b23745973a1fbf36da3c390bc5fbd82c9900dbea22425c4897bffb48",
|
||||
"c594bfae59ddd1cdedff57f6658626a9bc21d16a5e332de89e76a6e6ef0ac5a0"
|
||||
],
|
||||
"upload/readme.md": [
|
||||
"4dc7fe1ceb3af22ffc331db0dee4e7d09ca466d8800273430f691cd03a030272"
|
||||
],
|
||||
"handoff/readme.md": [
|
||||
"ec4d2495b2926e52ea23fc8e529717e12ced670ea152e26974de65ccae0a895e"
|
||||
],
|
||||
"handoff/latest.md": [
|
||||
"a5282a857a9df416e0b8c8c01e56af8e39367945f6f3edca5ca9608dfdb40816"
|
||||
],
|
||||
"requests/archive/readme.md": [
|
||||
"2960d3fc005c4771e02ee1a47b9673bc697210f764b8cd9472673162466a6db3"
|
||||
],
|
||||
"01_sources/provenance/readme.md": [
|
||||
"3663e24b40b6845fef6e30dec0579a39005c61bf345e9d4c1e03f0dbbea0440d"
|
||||
],
|
||||
"todo/archive/readme.md": [
|
||||
"aed996c1d37090839033681d521789dc9c0ee97210ac225475ab86a73e67b8e6"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a uniquely numbered, generic Code4History research card. Stdlib only."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
KINDS = {
|
||||
'primary': ('SRC-P', '01_sources/records/primary', 'primary.md'),
|
||||
'secondary': ('SRC-S', '01_sources/records/secondary', 'secondary.md'),
|
||||
'evidence': ('EV', '02_evidence', 'evidence.md'),
|
||||
'lead': ('L', '03_inquiry/leads', 'lead.md'),
|
||||
'question': ('Q', '03_inquiry/questions', 'question.md'),
|
||||
'hypothesis': ('H', '03_inquiry/hypotheses', 'hypothesis.md'),
|
||||
'claim': ('C', '06_arguments/claims', 'claim.md'),
|
||||
'synthesis': ('SYN', '06_arguments/syntheses', 'synthesis.md'),
|
||||
'conclusion': ('CON', '06_arguments/conclusions', 'conclusion.md'),
|
||||
'decision': ('D', '00_governance', 'decision.md'),
|
||||
'work': ('W', '05_historiography/works', 'work.md'),
|
||||
}
|
||||
|
||||
def create(root: Path, kind: str, slug: str, dry_run: bool = False) -> tuple[str, Path]:
|
||||
if kind not in KINDS: raise ValueError(f'Unknown kind: {kind}')
|
||||
if not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', slug):
|
||||
raise ValueError('Slug must be lowercase ASCII kebab-case (e.g. archive-letter).')
|
||||
prefix, folder, template = KINDS[kind]
|
||||
folder_path = root / folder
|
||||
folder_path.mkdir(parents=True, exist_ok=True)
|
||||
# Identify all allocated IDs, including cards no longer in their expected folder.
|
||||
occupied = set()
|
||||
id_pattern = re.compile(rf'^{re.escape(prefix)}-(\d{{4}})(?:-|\.md)')
|
||||
for p in root.rglob('*.md'):
|
||||
if '10_templates' in p.parts or '.agents' in p.parts:
|
||||
continue
|
||||
m = id_pattern.match(p.name)
|
||||
if m: occupied.add(int(m.group(1)))
|
||||
n = max(occupied, default=0) + 1
|
||||
ident = f'{prefix}-{n:04d}'
|
||||
output = folder_path / f'{ident}-{slug}.md'
|
||||
if output.exists(): raise FileExistsError(output)
|
||||
if not dry_run:
|
||||
template_path = root / '10_templates' / template
|
||||
if not template_path.is_file(): raise FileNotFoundError(template_path)
|
||||
text = template_path.read_text(encoding='utf-8').replace('__ID__', ident)
|
||||
output.write_text(text, encoding='utf-8')
|
||||
return ident, output
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--kind', required=True, choices=sorted(KINDS))
|
||||
parser.add_argument('--slug', required=True, help='ASCII kebab-case; not a historical claim')
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
ident, path = create(ROOT, args.kind, args.slug, args.dry_run)
|
||||
except (ValueError, FileNotFoundError, FileExistsError) as exc:
|
||||
parser.error(str(exc))
|
||||
print(f'{"WOULD CREATE" if args.dry_run else "CREATED"}: {ident} → {path.relative_to(ROOT)}')
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
@@ -0,0 +1,21 @@
|
||||
# 辅助工具
|
||||
|
||||
## 职责
|
||||
只负责生成模板、校验结构和组装正文;不自动确定历史事实真伪。
|
||||
|
||||
## 内容
|
||||
按本目录职责存放 Markdown、索引或明确允许的原始资料。研究对象由仓库根目录 `topic.md` 决定,目录本身不绑定任何主题。
|
||||
|
||||
## 约束
|
||||
- 研究断言与其引文应能追溯到 `01_sources` 中的具体来源和位置。
|
||||
- 不得为了符合预设结论而修订史料原文或删除反证。
|
||||
- 文件之间使用稳定 ID;更改解释时保留历史决定。
|
||||
- 若材料无法核实,明确写为 `unverified`、`pending` 或 `unknown`。
|
||||
|
||||
返回项目总说明:[`code4history.md`](../code4history.md)。
|
||||
|
||||
## 版本迁移工具
|
||||
|
||||
`migrate.py` 与 `migration_baselines.json` 配套,用于识别 v0.1—v0.7 官方框架文件,导出项目自有研究资料并恢复至新版本。可分别使用 `export`、`restore` 或一键 `upgrade`,操作指南见根目录 `UPGRADE_v0.7.md`。脚本只使用 Python 标准库。
|
||||
|
||||
新增 `interaction.py`:使用 `scan`/`intake`/`todo-new`/`todo-close`/`request-done`/`finish` 管理人机交接区。详见 [`INTERACTION.md`](../INTERACTION.md)。
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check file structure, card IDs and explicit typed links; never assert historical truth."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ID_FORMAT = re.compile(r'^(?:SRC-[PS]|EV|L|Q|H|C|CON|SYN|D|W)-\d{4}$')
|
||||
ID_PREFIX = re.compile(r'^(SRC-[PS]|EV|L|Q|H|C|CON|SYN|D|W)-\d{4}(?=-|\.md$)')
|
||||
REL_FIELDS = {
|
||||
'source_ids', 'evidence_ids', 'question_ids', 'hypothesis_ids',
|
||||
'claim_ids', 'synthesis_ids', 'related_ids', 'contradiction_ids', 'conclusion_ids',
|
||||
}
|
||||
CARD_DIRS = [
|
||||
'01_sources/records/primary', '01_sources/records/secondary',
|
||||
'02_evidence', '03_inquiry/leads', '03_inquiry/questions',
|
||||
'03_inquiry/hypotheses', '05_historiography/works',
|
||||
'06_arguments/claims', '06_arguments/syntheses', '06_arguments/conclusions',
|
||||
]
|
||||
CARD_SINGLE_DIRS = ['00_governance']
|
||||
|
||||
|
||||
def parse_header(text: str) -> dict[str,str]:
|
||||
if not text.startswith('---\n'): return {}
|
||||
_, sep, after = text.partition('\n---\n')
|
||||
if not sep: return {}
|
||||
first = after # Read before divider, not body
|
||||
header = text[4:].split('\n---\n',1)[0]
|
||||
result = {}
|
||||
for line in header.splitlines():
|
||||
m = re.match(r'^([a-z_]+):\s*(.*)$', line)
|
||||
if m: result[m.group(1)] = m.group(2).split(' #',1)[0].strip()
|
||||
return result
|
||||
|
||||
|
||||
def check(root: Path) -> tuple[list[str],list[str]]:
|
||||
errors=[]; warnings=[]
|
||||
needed=['README.md','code4history.md','AGENTS.md','USERINFO.md','topic.md','STATE.md',
|
||||
'DISCUSSION_TO_TOPIC.md','MIGRATION.md','INTERACTION.md','tools/interaction.py',
|
||||
'upload/readme.md','todo/readme.md','requests/readme.md','handoff/readme.md',
|
||||
'.agents/skills/history-writing-workflow-c4h/SKILL.md',
|
||||
'.agents/skills/source-intake-c4h/SKILL.md',
|
||||
'.agents/skills/discussion-to-topic-c4h/SKILL.md']
|
||||
for file in needed:
|
||||
if not (root/file).is_file(): errors.append(f'Missing required file: {file}')
|
||||
for obsolete in ('agent.md', 'AGENT.md', '.agents/skills/history-writing-workflow/SKILL.md'):
|
||||
if (root/obsolete).exists(): errors.append(f'Retired entry still present: {obsolete}')
|
||||
for folder in root.rglob('*'):
|
||||
if not folder.is_dir() or any(x in folder.parts for x in ('.git','__pycache__','.pytest_cache')): continue
|
||||
if not (folder/'readme.md').is_file(): errors.append(f'Missing readme.md: {folder.relative_to(root)}')
|
||||
seen={}; records=[]
|
||||
for d in CARD_DIRS+CARD_SINGLE_DIRS:
|
||||
p=root/d
|
||||
if not p.exists(): errors.append(f'Missing card folder: {d}'); continue
|
||||
for f in sorted(p.glob('*.md')):
|
||||
if f.name in {'readme.md','research-log.md','decisions.md','bibliography.md','source-policy.md'}: continue
|
||||
# In governance root, only numbered D cards count; ignore other docs.
|
||||
m=ID_PREFIX.match(f.name)
|
||||
if not m: continue
|
||||
head=parse_header(f.read_text(encoding='utf-8'))
|
||||
ident=head.get('id','').strip('"\'')
|
||||
if not ID_FORMAT.fullmatch(ident):
|
||||
errors.append(f'{f.relative_to(root)}: missing/invalid frontmatter id'); continue
|
||||
if not f.name.startswith(ident+'-') and f.name != ident+'.md':
|
||||
errors.append(f'{f.relative_to(root)}: filename and id do not match')
|
||||
if ident in seen: errors.append(f'Duplicate ID {ident}: {seen[ident]}, {f.relative_to(root)}')
|
||||
seen[ident]=str(f.relative_to(root)); records.append((f,ident,head))
|
||||
for f,ident,head in records:
|
||||
for key in REL_FIELDS:
|
||||
if key not in head: continue
|
||||
raw=head[key].strip()
|
||||
if raw in ('','[]','null'): continue
|
||||
if not (raw.startswith('[') and raw.endswith(']')):
|
||||
errors.append(f'{ident}: {key} must be a one-line [ID, ID] array'); continue
|
||||
for token in raw[1:-1].split(','):
|
||||
ref=token.strip(' "\'')
|
||||
if not ref: continue
|
||||
if not ID_FORMAT.fullmatch(ref): errors.append(f'{ident}: malformed linked ID {ref}');continue
|
||||
if ref not in seen: errors.append(f'{ident}: broken {key} link to {ref}')
|
||||
if ident.startswith('EV-'):
|
||||
if not head.get('source_ids','').strip(' []'): warnings.append(f'{ident}: no source_ids yet')
|
||||
if not head.get('source_locator','').strip(' "'): warnings.append(f'{ident}: missing source_locator')
|
||||
if head.get('verification','')=='checked' and not head.get('source_locator','').strip(' "'):
|
||||
errors.append(f'{ident}: verified evidence lacks source_locator')
|
||||
if ident.startswith('C-') and not head.get('evidence_ids','').strip(' []'):
|
||||
warnings.append(f'{ident}: claim has no evidence_ids yet')
|
||||
for skill_name in ('history-writing-workflow-c4h','source-intake-c4h','discussion-to-topic-c4h'):
|
||||
skill=root/'.agents/skills'/skill_name/'SKILL.md'
|
||||
if skill.is_file():
|
||||
head=parse_header(skill.read_text(encoding='utf-8'))
|
||||
if head.get('name')!=skill_name or not head.get('description'):
|
||||
errors.append(f'Skill frontmatter must include name: {skill_name} and description')
|
||||
return errors,warnings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap=argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--strict',action='store_true',help='treat incomplete-card warnings as errors')
|
||||
args=ap.parse_args()
|
||||
errors,warnings=check(ROOT)
|
||||
print(f'Code4History validation: {len(errors)} errors; {len(warnings)} warnings')
|
||||
for msg in errors: print('ERROR:',msg)
|
||||
for msg in warnings: print('WARN: ',msg)
|
||||
if errors or (warnings and args.strict): raise SystemExit(1)
|
||||
|
||||
if __name__=='__main__': main()
|
||||
Reference in New Issue
Block a user