This commit is contained in:
2026-10-18 16:21:54 +08:00
commit 5921378110
133 changed files with 4682 additions and 0 deletions
+25
View File
@@ -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.')
+40
View File
@@ -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)
+332
View File
@@ -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())
+407
View File
@@ -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())
+557
View File
@@ -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"
]
}
}
+61
View File
@@ -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()
+21
View File
@@ -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)。
+107
View File
@@ -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()