@topmindspace/topmind-handoff 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -83
- package/README.md +18 -1
- package/SKILL.md +20 -12
- package/assets/handoff-example.md +6 -8
- package/assets/handoff-template.md +2 -2
- package/examples/README.md +4 -3
- package/package.json +2 -2
- package/references/compile.md +9 -4
- package/references/environment.md +3 -3
- package/references/export.md +6 -2
- package/references/import.md +6 -5
- package/references/privacy.md +8 -3
- package/references/spec.md +8 -6
- package/topmind_handoff/__init__.py +16 -4
- package/topmind_handoff/admin.py +4 -218
- package/topmind_handoff/cli.py +185 -9
- package/topmind_handoff/compile.py +424 -26
- package/topmind_handoff/model.py +65 -12
- package/topmind_handoff/package.py +24 -26
- package/topmind_handoff/privacy.py +41 -0
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
5
7
|
import re
|
|
6
8
|
from datetime import date
|
|
7
9
|
from pathlib import Path
|
|
@@ -18,6 +20,7 @@ from topmind_handoff.model import (
|
|
|
18
20
|
Op,
|
|
19
21
|
compile_ops,
|
|
20
22
|
compiler_version,
|
|
23
|
+
is_proposal,
|
|
21
24
|
origin_markdown,
|
|
22
25
|
over_budget,
|
|
23
26
|
parse_authority,
|
|
@@ -26,11 +29,16 @@ from topmind_handoff.model import (
|
|
|
26
29
|
scenario_filename,
|
|
27
30
|
parse_card,
|
|
28
31
|
parse_outbox,
|
|
32
|
+
protected_claim_ids,
|
|
33
|
+
safe_claim_id,
|
|
29
34
|
safe_origin,
|
|
35
|
+
safe_scope,
|
|
30
36
|
scenario_names,
|
|
31
37
|
slice_title,
|
|
38
|
+
unique_ops,
|
|
32
39
|
view_slice_order,
|
|
33
40
|
)
|
|
41
|
+
from topmind_handoff.privacy import assert_private_text
|
|
34
42
|
|
|
35
43
|
VIEW_ORDER = ("identity", "boundaries", "preference", "project", "fact")
|
|
36
44
|
_REVIEW_AS_OF = re.compile(r"^review_as_of:\s*(\d{4}-\d{2}-\d{2})\s*$", re.M)
|
|
@@ -44,23 +52,31 @@ def _ensure_nl(text: str) -> str:
|
|
|
44
52
|
|
|
45
53
|
def load_pieces(repo: Path) -> tuple[dict[str, str], dict[str, str], dict[str, Card], list[Op]]:
|
|
46
54
|
authority_path = repo / "authority.md"
|
|
47
|
-
authority = parse_authority(authority_path
|
|
55
|
+
authority = parse_authority(_read_source(authority_path)) if authority_path.is_file() else {}
|
|
48
56
|
tips: dict[str, str] = {}
|
|
49
57
|
ops: list[Op] = []
|
|
50
58
|
outbox = repo / "outbox"
|
|
51
59
|
if outbox.is_dir():
|
|
52
60
|
for path in sorted(outbox.glob("*.md")):
|
|
53
|
-
file_ops = parse_outbox(path
|
|
61
|
+
file_ops = parse_outbox(_read_source(path), path.stem)
|
|
54
62
|
tips[path.stem] = file_ops[-1].op_id if file_ops else ""
|
|
55
63
|
ops.extend(file_ops)
|
|
56
64
|
cards: dict[str, Card] = {}
|
|
57
65
|
environments = repo / "environments"
|
|
58
66
|
if environments.is_dir():
|
|
59
67
|
for path in sorted(environments.glob("*.md")):
|
|
60
|
-
cards[path.stem] = parse_card(path
|
|
68
|
+
cards[path.stem] = parse_card(_read_source(path), path.stem)
|
|
61
69
|
return authority, tips, cards, ops
|
|
62
70
|
|
|
63
71
|
|
|
72
|
+
def _read_source(path: Path) -> str:
|
|
73
|
+
if path.resolve().parent != path.parent.resolve():
|
|
74
|
+
raise ValueError("权威输入路径越界,拒绝读取")
|
|
75
|
+
text = path.read_text(encoding="utf-8")
|
|
76
|
+
assert_private_text(text)
|
|
77
|
+
return text
|
|
78
|
+
|
|
79
|
+
|
|
64
80
|
def build_compiled(repo: Path) -> Compiled:
|
|
65
81
|
authority, tips, cards, ops = load_pieces(repo)
|
|
66
82
|
claims, proposals = compile_ops(ops, authority)
|
|
@@ -176,6 +192,7 @@ def render_status(compiled: Compiled, today: date | None = None, *, index_fit: s
|
|
|
176
192
|
if len(result.heads) == 1 and result.heads[0].kind == "set"
|
|
177
193
|
)
|
|
178
194
|
conflict_count = sum(1 for result in compiled.claims.values() if result.conflict)
|
|
195
|
+
unapplied = [op for result in compiled.claims.values() for op in result.unapplied]
|
|
179
196
|
lines.extend(
|
|
180
197
|
[
|
|
181
198
|
"",
|
|
@@ -183,6 +200,7 @@ def render_status(compiled: Compiled, today: date | None = None, *, index_fit: s
|
|
|
183
200
|
f"- facts: {fact_count}",
|
|
184
201
|
f"- conflicts: {conflict_count}",
|
|
185
202
|
f"- proposals: {len(compiled.proposals)}",
|
|
203
|
+
f"- unapplied: {len(unapplied)}",
|
|
186
204
|
"",
|
|
187
205
|
"## conflicts",
|
|
188
206
|
]
|
|
@@ -207,6 +225,14 @@ def render_status(compiled: Compiled, today: date | None = None, *, index_fit: s
|
|
|
207
225
|
lines.append(f" text: {op.text}")
|
|
208
226
|
else:
|
|
209
227
|
lines.append("(无)")
|
|
228
|
+
lines.extend(["", "## unapplied"])
|
|
229
|
+
for op in sorted(unapplied, key=lambda item: item.op_id):
|
|
230
|
+
lines.extend([
|
|
231
|
+
f"- op_id: {op.op_id}", f" id: {op.claim_id}", f" scope: {op.scope}",
|
|
232
|
+
" reason: base 或 closes 未满足当前因果链,需要核对依赖或裁决",
|
|
233
|
+
])
|
|
234
|
+
if not unapplied:
|
|
235
|
+
lines.append("(无)")
|
|
210
236
|
lines.extend(["", "## 待提炼"])
|
|
211
237
|
refine = refinement_lines(
|
|
212
238
|
list(compiled.claims.values()),
|
|
@@ -223,7 +249,7 @@ def render_status(compiled: Compiled, today: date | None = None, *, index_fit: s
|
|
|
223
249
|
lines.append(f"- id: {origin}")
|
|
224
250
|
lines.append(f" tip: {compiled.tips.get(origin) or '-'}")
|
|
225
251
|
lines.append(f" refreshed: {card.refreshed if card else ''}")
|
|
226
|
-
lines.append(f" stale: {'true' if card and card
|
|
252
|
+
lines.append(f" stale: {'true' if card and card_is_due(card, today) else 'false'}")
|
|
227
253
|
else:
|
|
228
254
|
lines.append("(无)")
|
|
229
255
|
lines.append("")
|
|
@@ -277,13 +303,7 @@ def compile_repo(repo: Path, today: date | None = None) -> Compiled:
|
|
|
277
303
|
path.unlink()
|
|
278
304
|
for name, text in rendered.items():
|
|
279
305
|
(views / name).write_text(text, encoding="utf-8")
|
|
280
|
-
(repo
|
|
281
|
-
render_status(compiled, today=today, index_fit=fit), encoding="utf-8"
|
|
282
|
-
)
|
|
283
|
-
# Local import: admin imports this module. Only a stock README this skill wrote is replaced.
|
|
284
|
-
from topmind_handoff.admin import upgrade_stock_readme
|
|
285
|
-
|
|
286
|
-
upgrade_stock_readme(repo)
|
|
306
|
+
write_compiled_status(repo, compiled, today=today, index_fit=fit)
|
|
287
307
|
return compiled
|
|
288
308
|
|
|
289
309
|
|
|
@@ -310,29 +330,79 @@ def re_tip(line: str) -> tuple[str, str] | None:
|
|
|
310
330
|
return match.group(1).strip(), match.group(2).strip()
|
|
311
331
|
|
|
312
332
|
|
|
333
|
+
def _digest(parts: dict) -> str:
|
|
334
|
+
payload = json.dumps(parts, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
335
|
+
return hashlib.sha256(payload).hexdigest()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _file_digest(path: Path) -> str:
|
|
339
|
+
if path.resolve().parent != path.parent.resolve():
|
|
340
|
+
raise ValueError("编译文件路径越界,视图无效,需要重新编译")
|
|
341
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _input_digest(repo: Path) -> str:
|
|
345
|
+
files = [repo / "authority.md"]
|
|
346
|
+
for directory in ("outbox", "environments"):
|
|
347
|
+
files.extend(sorted((repo / directory).glob("*.md")))
|
|
348
|
+
inputs = {path.relative_to(repo).as_posix(): _file_digest(path) if path.is_file() else None for path in files}
|
|
349
|
+
engine = {path.name: _file_digest(path) for path in sorted(Path(__file__).parent.glob("*.py"))}
|
|
350
|
+
return _digest({"inputs": inputs, "engine": engine, "version": compiler_version()})
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _output_digest(repo: Path, status: str) -> str:
|
|
354
|
+
status = re.sub(r"^output_digest:.*\n?", "", status, flags=re.M)
|
|
355
|
+
outputs = {"STATUS.md": hashlib.sha256(status.encode("utf-8")).hexdigest()}
|
|
356
|
+
for directory in ("views", "facts"):
|
|
357
|
+
for path in sorted((repo / directory).glob("*.md")):
|
|
358
|
+
outputs[path.relative_to(repo).as_posix()] = _file_digest(path)
|
|
359
|
+
return _digest(outputs)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def write_compiled_status(repo: Path, compiled: Compiled, today: date | None, *, index_fit: str) -> None:
|
|
363
|
+
"""Bind all authoritative inputs, engine and generated files to one compilation."""
|
|
364
|
+
status = render_status(compiled, today=today, index_fit=index_fit)
|
|
365
|
+
status += f"\ninput_digest: {_input_digest(repo)}\n"
|
|
366
|
+
status += f"output_digest: {_output_digest(repo, status)}\n"
|
|
367
|
+
(repo / "STATUS.md").write_text(status, encoding="utf-8")
|
|
368
|
+
|
|
369
|
+
|
|
313
370
|
def status_is_valid(repo: Path) -> tuple[bool, str]:
|
|
314
|
-
"""
|
|
371
|
+
"""Validate authoritative content, compiler, STATUS and generated files."""
|
|
315
372
|
repo = Path(repo)
|
|
316
373
|
status_path = repo / "STATUS.md"
|
|
317
374
|
if not status_path.is_file():
|
|
318
375
|
return False, "STATUS.md 不存在,视图无效,需要重新编译"
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
376
|
+
try:
|
|
377
|
+
_authority, tips, _cards, _ops = load_pieces(repo)
|
|
378
|
+
status = status_path.read_text(encoding="utf-8")
|
|
379
|
+
if _status_tips(status) != tips:
|
|
380
|
+
return False, "STATUS 的信箱末尾与 outbox 不一致,视图无效,需要重新编译"
|
|
381
|
+
inputs = re.findall(r"^input_digest: ([0-9a-f]{64})$", status, re.M)
|
|
382
|
+
outputs = re.findall(r"^output_digest: ([0-9a-f]{64})$", status, re.M)
|
|
383
|
+
if len(inputs) != 1 or len(outputs) != 1:
|
|
384
|
+
return False, "STATUS 缺少完整性校验,视图无效,需要重新编译"
|
|
385
|
+
if inputs[0] != _input_digest(repo) or outputs[0] != _output_digest(repo, status):
|
|
386
|
+
return False, "编译输入或产物内容已变化,视图无效,需要重新编译"
|
|
387
|
+
except (ValueError, OSError) as exc:
|
|
388
|
+
return False, f"视图无效:{exc}"
|
|
389
|
+
return True, "STATUS、权威输入与编译产物内容一致"
|
|
324
390
|
|
|
325
391
|
|
|
326
392
|
def append_outbox(repo: Path, actor: str, origin: str, blocks: list[str]) -> Path:
|
|
327
393
|
"""Append to one origin's outbox. A different actor is refused and nothing is written."""
|
|
394
|
+
assert_private_text(actor)
|
|
328
395
|
dest = origin_markdown(Path(repo) / "outbox", origin)
|
|
329
396
|
origin_name = dest.name[: -len(".md")]
|
|
330
397
|
if actor != origin_name:
|
|
331
398
|
raise PermissionError(f"{actor} 只能追加自己的信箱 {origin_name}")
|
|
332
399
|
if not blocks:
|
|
333
400
|
return dest
|
|
334
|
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
335
401
|
existing = dest.read_text(encoding="utf-8") if dest.is_file() else f"# outbox {origin_name}\n"
|
|
402
|
+
assert_private_text(existing + "\n".join(blocks))
|
|
403
|
+
_authority, _tips, _cards, known = load_pieces(Path(repo))
|
|
404
|
+
unique_ops(known + parse_outbox("\n".join(blocks), origin_name))
|
|
405
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
336
406
|
if not existing.endswith("\n"):
|
|
337
407
|
existing += "\n"
|
|
338
408
|
dest.write_text(existing + "\n".join(blocks) + "\n", encoding="utf-8")
|
|
@@ -341,10 +411,12 @@ def append_outbox(repo: Path, actor: str, origin: str, blocks: list[str]) -> Pat
|
|
|
341
411
|
|
|
342
412
|
def save_environment_card(repo: Path, actor: str, origin: str, content: str) -> Path:
|
|
343
413
|
"""Replace one origin's environment card. Other origins are refused."""
|
|
414
|
+
assert_private_text(actor)
|
|
344
415
|
dest = origin_markdown(Path(repo) / "environments", origin)
|
|
345
416
|
origin = dest.name[: -len(".md")]
|
|
346
417
|
if actor != origin:
|
|
347
418
|
raise PermissionError(f"{actor} 只能替换自己的环境卡片 {origin}")
|
|
419
|
+
assert_private_text(content)
|
|
348
420
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
349
421
|
dest.write_text(_ensure_nl(content), encoding="utf-8")
|
|
350
422
|
return dest
|
|
@@ -582,6 +654,16 @@ def _without_claim_details(text: str) -> str:
|
|
|
582
654
|
return _ensure_nl("\n".join(kept))
|
|
583
655
|
|
|
584
656
|
|
|
657
|
+
def _strip_view_title(text: str) -> str:
|
|
658
|
+
"""Remove a leading `# ...` document title when embedding a view inside a projection section."""
|
|
659
|
+
lines = text.splitlines()
|
|
660
|
+
if lines and lines[0].startswith("# "):
|
|
661
|
+
lines = lines[1:]
|
|
662
|
+
while lines and not lines[0].strip():
|
|
663
|
+
lines = lines[1:]
|
|
664
|
+
return _ensure_nl("\n".join(lines)) if lines else "(无)\n"
|
|
665
|
+
|
|
666
|
+
|
|
585
667
|
def organize_advice(shared: str, scenario: str) -> str:
|
|
586
668
|
"""Prompt text when an index is past the startup budget. It does not write or retract."""
|
|
587
669
|
notes: list[str] = []
|
|
@@ -606,18 +688,25 @@ def projection_body(repo: Path, origin: str, today: date | None = None) -> str:
|
|
|
606
688
|
"""
|
|
607
689
|
repo = Path(repo)
|
|
608
690
|
origin = safe_origin(origin)
|
|
691
|
+
use_views = (repo / "STATUS.md").is_file()
|
|
692
|
+
if use_views:
|
|
693
|
+
valid, message = status_is_valid(repo)
|
|
694
|
+
if not valid:
|
|
695
|
+
raise ValueError(message)
|
|
609
696
|
compiled = build_compiled(repo)
|
|
610
697
|
if today is None:
|
|
611
698
|
today = stored_review_today(repo)
|
|
612
699
|
views = repo / "views"
|
|
613
700
|
rendered = render_views(compiled, inline_detail=False, today=today)
|
|
614
701
|
shared_path = views / "shared.md"
|
|
615
|
-
if shared_path.is_file() and _path_inside(shared_path, views):
|
|
702
|
+
if use_views and shared_path.is_file() and _path_inside(shared_path, views):
|
|
616
703
|
shared = shared_path.read_text(encoding="utf-8")
|
|
617
704
|
else:
|
|
618
705
|
shared = rendered["shared.md"]
|
|
619
706
|
card = compiled.cards.get(origin)
|
|
620
|
-
|
|
707
|
+
_authority, _tips, _cards, source_ops = load_pieces(repo)
|
|
708
|
+
protected = protected_claim_ids(source_ops)
|
|
709
|
+
overrides = {cid: text for cid, text in (card.overrides.items() if card else ()) if cid not in protected}
|
|
621
710
|
projected = _without_claim_details(
|
|
622
711
|
_label_text(
|
|
623
712
|
_project_shared(repo, shared, overrides),
|
|
@@ -630,7 +719,7 @@ def projection_body(repo: Path, origin: str, today: date | None = None) -> str:
|
|
|
630
719
|
scenario_path = views / filename if filename else None
|
|
631
720
|
if scenario_path is not None and not _path_inside(scenario_path, views):
|
|
632
721
|
scenario_path = None
|
|
633
|
-
if scenario_path is not None and scenario_path.is_file():
|
|
722
|
+
if use_views and scenario_path is not None and scenario_path.is_file():
|
|
634
723
|
scenario = scenario_path.read_text(encoding="utf-8")
|
|
635
724
|
elif filename:
|
|
636
725
|
scenario = rendered.get(filename, "# 场景\n\n(无)\n")
|
|
@@ -647,14 +736,16 @@ def projection_body(repo: Path, origin: str, today: date | None = None) -> str:
|
|
|
647
736
|
visible.extend(_results_for(compiled, f"origin:{origin}"))
|
|
648
737
|
refine = refinement_lines(visible, compiled.cards, today, known_ids=_current_ids(compiled))
|
|
649
738
|
parts = [
|
|
739
|
+
f"复核基准:{today.isoformat() if today else '未指定'}",
|
|
740
|
+
"",
|
|
650
741
|
"## 冲突(与本环境有关)",
|
|
651
742
|
_conflicts_for_origin(compiled, origin).rstrip("\n"),
|
|
652
743
|
"",
|
|
653
744
|
"## 共享",
|
|
654
|
-
projected.rstrip("\n"),
|
|
745
|
+
_strip_view_title(projected).rstrip("\n"),
|
|
655
746
|
"",
|
|
656
747
|
f"## 场景:{role or '无'}",
|
|
657
|
-
scenario.rstrip("\n"),
|
|
748
|
+
_strip_view_title(scenario).rstrip("\n"),
|
|
658
749
|
"",
|
|
659
750
|
]
|
|
660
751
|
if origin_text:
|
|
@@ -669,6 +760,11 @@ def projection_body(repo: Path, origin: str, today: date | None = None) -> str:
|
|
|
669
760
|
"",
|
|
670
761
|
]
|
|
671
762
|
)
|
|
763
|
+
pending = [op for result in visible for op in result.unapplied]
|
|
764
|
+
if pending:
|
|
765
|
+
parts.extend(["## 未应用", ""])
|
|
766
|
+
parts.extend(f"- {op.op_id}:base 或 closes 未满足当前因果链" for op in pending)
|
|
767
|
+
parts.append("")
|
|
672
768
|
advice = organize_advice(projected, scenario)
|
|
673
769
|
if advice:
|
|
674
770
|
parts.extend([advice.rstrip("\n"), ""])
|
|
@@ -690,7 +786,7 @@ def _also_scenario(repo: Path, origin: str, also: str, today: date | None) -> st
|
|
|
690
786
|
views = repo / "views"
|
|
691
787
|
path = views / filename if filename else None
|
|
692
788
|
rendered = render_views(compiled, inline_detail=False, today=today)
|
|
693
|
-
if path is not None and path.is_file() and _path_inside(path, views):
|
|
789
|
+
if (repo / "STATUS.md").is_file() and path is not None and path.is_file() and _path_inside(path, views):
|
|
694
790
|
text = path.read_text(encoding="utf-8")
|
|
695
791
|
elif filename:
|
|
696
792
|
text = rendered.get(filename, f"# 场景:{also}\n\n(无)\n")
|
|
@@ -699,7 +795,7 @@ def _also_scenario(repo: Path, origin: str, also: str, today: date | None) -> st
|
|
|
699
795
|
text = _without_claim_details(
|
|
700
796
|
_label_text(text, _scope_heads(compiled, f"scenario:{also}"), today)
|
|
701
797
|
)
|
|
702
|
-
return _ensure_nl(f"## 另带场景:{also}\n\n{text.rstrip()}")
|
|
798
|
+
return _ensure_nl(f"## 另带场景:{also}\n\n{_strip_view_title(text).rstrip()}")
|
|
703
799
|
|
|
704
800
|
|
|
705
801
|
def startup_read(repo: Path, origin: str, today: date | None = None, also: str | None = None) -> str:
|
|
@@ -715,7 +811,7 @@ def startup_read(repo: Path, origin: str, today: date | None = None, also: str |
|
|
|
715
811
|
extra = _also_scenario(repo, origin, also, today).rstrip("\n")
|
|
716
812
|
if extra:
|
|
717
813
|
body = f"{body}\n\n{extra}"
|
|
718
|
-
return _ensure_nl(f"# 开工读取({origin}
|
|
814
|
+
return _ensure_nl(f"# 开工读取({origin})\n\n{body}")
|
|
719
815
|
|
|
720
816
|
|
|
721
817
|
def open_claim(repo: Path, claim_id: str) -> str:
|
|
@@ -770,3 +866,305 @@ def _open_head(head: Op, today: date | None) -> list[str]:
|
|
|
770
866
|
lines.append(" detail: |")
|
|
771
867
|
lines.extend(f" {line}" for line in head.detail.splitlines())
|
|
772
868
|
return lines
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def next_op_id(repo: Path, origin: str) -> str:
|
|
872
|
+
"""Compute the next sequential `<origin>#<N>` id without colliding with existing ops."""
|
|
873
|
+
repo = Path(repo)
|
|
874
|
+
origin = safe_origin(origin)
|
|
875
|
+
_authority, _tips, _cards, known = load_pieces(repo)
|
|
876
|
+
used = {op.op_id for op in known}
|
|
877
|
+
pattern = re.compile(rf"^{re.escape(origin)}#(\d+)$")
|
|
878
|
+
max_seq = 0
|
|
879
|
+
for oid in used:
|
|
880
|
+
match = pattern.match(oid)
|
|
881
|
+
if match:
|
|
882
|
+
max_seq = max(max_seq, int(match.group(1)))
|
|
883
|
+
seq = max_seq + 1
|
|
884
|
+
while f"{origin}#{seq}" in used:
|
|
885
|
+
seq += 1
|
|
886
|
+
return f"{origin}#{seq}"
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _format_op_block(
|
|
890
|
+
*,
|
|
891
|
+
op_id: str,
|
|
892
|
+
kind: str,
|
|
893
|
+
claim_id: str,
|
|
894
|
+
scope: str,
|
|
895
|
+
slice_name: str,
|
|
896
|
+
base: str | None,
|
|
897
|
+
closes: tuple[str, ...] = (),
|
|
898
|
+
at: str,
|
|
899
|
+
text: str,
|
|
900
|
+
detail: str = "",
|
|
901
|
+
) -> str:
|
|
902
|
+
cleaned_text = text.strip()
|
|
903
|
+
if not cleaned_text or "\n" in cleaned_text or "\r" in cleaned_text:
|
|
904
|
+
raise ValueError("text 必须是非空的单行文本;多行说明请放入 detail")
|
|
905
|
+
lines = [
|
|
906
|
+
f"- op_id: {op_id}",
|
|
907
|
+
f" op: {kind}",
|
|
908
|
+
f" id: {claim_id}",
|
|
909
|
+
f" scope: {scope}",
|
|
910
|
+
f" slice: {slice_name}",
|
|
911
|
+
f" base: {base or 'none'}",
|
|
912
|
+
]
|
|
913
|
+
if closes:
|
|
914
|
+
lines.append(f" closes: [{', '.join(closes)}]")
|
|
915
|
+
lines.append(f" at: {at}")
|
|
916
|
+
lines.append(f" text: {cleaned_text}")
|
|
917
|
+
cleaned_detail = detail.strip("\n")
|
|
918
|
+
if cleaned_detail.strip():
|
|
919
|
+
lines.append(" detail: |")
|
|
920
|
+
for dline in cleaned_detail.splitlines():
|
|
921
|
+
lines.append(f" {dline}")
|
|
922
|
+
return "\n".join(lines)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def record_set(
|
|
926
|
+
repo: Path,
|
|
927
|
+
*,
|
|
928
|
+
origin: str,
|
|
929
|
+
claim_id: str,
|
|
930
|
+
text: str,
|
|
931
|
+
scope: str = "shared",
|
|
932
|
+
slice_name: str | None = None,
|
|
933
|
+
base: str | None = None,
|
|
934
|
+
detail: str = "",
|
|
935
|
+
today: date | None = None,
|
|
936
|
+
) -> str:
|
|
937
|
+
"""Append a `set` operation with automatic `op_id` and causal `base`, then compile."""
|
|
938
|
+
repo = Path(repo)
|
|
939
|
+
origin = safe_origin(origin)
|
|
940
|
+
claim_id = safe_claim_id(claim_id)
|
|
941
|
+
scope = safe_scope(scope)
|
|
942
|
+
if scope.startswith("origin:") and scope != f"origin:{origin}":
|
|
943
|
+
raise ValueError(f"{origin} 不能写入其他环境的专属范围 {scope}")
|
|
944
|
+
compiled = build_compiled(repo)
|
|
945
|
+
result = compiled.claims.get((scope, claim_id))
|
|
946
|
+
inferred_slice = (
|
|
947
|
+
slice_name.strip()
|
|
948
|
+
if slice_name and slice_name.strip()
|
|
949
|
+
else (result.heads[0].slice if (result is not None and result.heads) else "fact")
|
|
950
|
+
)
|
|
951
|
+
at_day = today or stored_review_today(repo) or date.today()
|
|
952
|
+
if base is None:
|
|
953
|
+
if result is not None and len(result.heads) > 1:
|
|
954
|
+
heads_str = ", ".join(h.op_id for h in result.heads)
|
|
955
|
+
raise ValueError(f"主张 {claim_id} 在 {scope} 存在未裁决冲突({heads_str}),请先用 --resolve 裁决")
|
|
956
|
+
if result is not None and len(result.heads) == 1:
|
|
957
|
+
head = result.heads[0]
|
|
958
|
+
if (
|
|
959
|
+
head.kind == "set"
|
|
960
|
+
and head.text == text.strip()
|
|
961
|
+
and head.detail == detail.strip("\n")
|
|
962
|
+
and head.slice == inferred_slice
|
|
963
|
+
and head.at == at_day.isoformat()
|
|
964
|
+
):
|
|
965
|
+
compile_repo(repo, today=today)
|
|
966
|
+
return head.op_id
|
|
967
|
+
resolved_base: str | None = head.op_id
|
|
968
|
+
else:
|
|
969
|
+
resolved_base = None
|
|
970
|
+
else:
|
|
971
|
+
raw_base = base.strip()
|
|
972
|
+
resolved_base = None if raw_base in ("", "none") else raw_base
|
|
973
|
+
op_id = next_op_id(repo, origin)
|
|
974
|
+
block = _format_op_block(
|
|
975
|
+
op_id=op_id,
|
|
976
|
+
kind="set",
|
|
977
|
+
claim_id=claim_id,
|
|
978
|
+
scope=scope,
|
|
979
|
+
slice_name=inferred_slice,
|
|
980
|
+
base=resolved_base,
|
|
981
|
+
at=at_day.isoformat(),
|
|
982
|
+
text=text,
|
|
983
|
+
detail=detail,
|
|
984
|
+
)
|
|
985
|
+
append_outbox(repo, origin, origin, [block])
|
|
986
|
+
compile_repo(repo, today=today)
|
|
987
|
+
return op_id
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _assert_direct_write_allowed(
|
|
991
|
+
repo: Path,
|
|
992
|
+
compiled: Compiled,
|
|
993
|
+
*,
|
|
994
|
+
origin: str,
|
|
995
|
+
kind: str,
|
|
996
|
+
claim_id: str,
|
|
997
|
+
scope: str,
|
|
998
|
+
slice_name: str,
|
|
999
|
+
) -> None:
|
|
1000
|
+
_authority, _tips, _cards, source_ops = load_pieces(repo)
|
|
1001
|
+
protected = protected_claim_ids(source_ops)
|
|
1002
|
+
probe = Op(
|
|
1003
|
+
op_id=f"{origin}#probe",
|
|
1004
|
+
kind=kind,
|
|
1005
|
+
claim_id=claim_id,
|
|
1006
|
+
scope=scope,
|
|
1007
|
+
slice=slice_name,
|
|
1008
|
+
base=None,
|
|
1009
|
+
closes=(),
|
|
1010
|
+
at="",
|
|
1011
|
+
text="",
|
|
1012
|
+
detail="",
|
|
1013
|
+
origin=origin,
|
|
1014
|
+
raw="",
|
|
1015
|
+
)
|
|
1016
|
+
if is_proposal(probe, compiled.authority, protected):
|
|
1017
|
+
raise ValueError(f"{origin} 无权直接修改 {scope} 的 {claim_id},请使用有权限的 origin(如 human 或场景主人)")
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def record_retract(
|
|
1021
|
+
repo: Path,
|
|
1022
|
+
*,
|
|
1023
|
+
origin: str,
|
|
1024
|
+
claim_id: str,
|
|
1025
|
+
scope: str = "shared",
|
|
1026
|
+
text: str = "已撤回",
|
|
1027
|
+
today: date | None = None,
|
|
1028
|
+
) -> str:
|
|
1029
|
+
"""Append a `retract` operation against current head(s) to retire outdated state, then compile."""
|
|
1030
|
+
repo = Path(repo)
|
|
1031
|
+
origin = safe_origin(origin)
|
|
1032
|
+
claim_id = safe_claim_id(claim_id)
|
|
1033
|
+
scope = safe_scope(scope)
|
|
1034
|
+
compiled = build_compiled(repo)
|
|
1035
|
+
result = compiled.claims.get((scope, claim_id))
|
|
1036
|
+
if result is None or not result.heads or result.retracted:
|
|
1037
|
+
raise ValueError(f"主张 {claim_id} 在 {scope} 当前没有生效内容,无需撤回")
|
|
1038
|
+
inferred_slice = result.heads[0].slice
|
|
1039
|
+
_assert_direct_write_allowed(
|
|
1040
|
+
repo,
|
|
1041
|
+
compiled,
|
|
1042
|
+
origin=origin,
|
|
1043
|
+
kind="retract",
|
|
1044
|
+
claim_id=claim_id,
|
|
1045
|
+
scope=scope,
|
|
1046
|
+
slice_name=inferred_slice,
|
|
1047
|
+
)
|
|
1048
|
+
if len(result.heads) == 1:
|
|
1049
|
+
resolved_base: str | None = result.heads[0].op_id
|
|
1050
|
+
closes: tuple[str, ...] = ()
|
|
1051
|
+
else:
|
|
1052
|
+
resolved_base = None
|
|
1053
|
+
closes = tuple(h.op_id for h in result.heads)
|
|
1054
|
+
op_id = next_op_id(repo, origin)
|
|
1055
|
+
at_day = today or stored_review_today(repo) or date.today()
|
|
1056
|
+
block = _format_op_block(
|
|
1057
|
+
op_id=op_id,
|
|
1058
|
+
kind="retract",
|
|
1059
|
+
claim_id=claim_id,
|
|
1060
|
+
scope=scope,
|
|
1061
|
+
slice_name=inferred_slice,
|
|
1062
|
+
base=resolved_base,
|
|
1063
|
+
closes=closes,
|
|
1064
|
+
at=at_day.isoformat(),
|
|
1065
|
+
text=text,
|
|
1066
|
+
)
|
|
1067
|
+
append_outbox(repo, origin, origin, [block])
|
|
1068
|
+
compile_repo(repo, today=today)
|
|
1069
|
+
return op_id
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def record_resolve(
|
|
1073
|
+
repo: Path,
|
|
1074
|
+
*,
|
|
1075
|
+
origin: str,
|
|
1076
|
+
claim_id: str,
|
|
1077
|
+
text: str,
|
|
1078
|
+
scope: str = "shared",
|
|
1079
|
+
slice_name: str | None = None,
|
|
1080
|
+
detail: str = "",
|
|
1081
|
+
today: date | None = None,
|
|
1082
|
+
) -> str:
|
|
1083
|
+
"""Close all current conflicting heads on `(scope, claim_id)` into one resolved head, then compile."""
|
|
1084
|
+
repo = Path(repo)
|
|
1085
|
+
origin = safe_origin(origin)
|
|
1086
|
+
claim_id = safe_claim_id(claim_id)
|
|
1087
|
+
scope = safe_scope(scope)
|
|
1088
|
+
compiled = build_compiled(repo)
|
|
1089
|
+
result = compiled.claims.get((scope, claim_id))
|
|
1090
|
+
if result is None or len(result.heads) < 2:
|
|
1091
|
+
raise ValueError(f"主张 {claim_id} 在 {scope} 当前没有分叉冲突,无需裁决")
|
|
1092
|
+
inferred_slice = (
|
|
1093
|
+
slice_name.strip()
|
|
1094
|
+
if slice_name and slice_name.strip()
|
|
1095
|
+
else result.heads[0].slice
|
|
1096
|
+
)
|
|
1097
|
+
_assert_direct_write_allowed(
|
|
1098
|
+
repo,
|
|
1099
|
+
compiled,
|
|
1100
|
+
origin=origin,
|
|
1101
|
+
kind="set",
|
|
1102
|
+
claim_id=claim_id,
|
|
1103
|
+
scope=scope,
|
|
1104
|
+
slice_name=inferred_slice,
|
|
1105
|
+
)
|
|
1106
|
+
closes = tuple(h.op_id for h in result.heads)
|
|
1107
|
+
op_id = next_op_id(repo, origin)
|
|
1108
|
+
at_day = today or stored_review_today(repo) or date.today()
|
|
1109
|
+
block = _format_op_block(
|
|
1110
|
+
op_id=op_id,
|
|
1111
|
+
kind="set",
|
|
1112
|
+
claim_id=claim_id,
|
|
1113
|
+
scope=scope,
|
|
1114
|
+
slice_name=inferred_slice,
|
|
1115
|
+
base=None,
|
|
1116
|
+
closes=closes,
|
|
1117
|
+
at=at_day.isoformat(),
|
|
1118
|
+
text=text,
|
|
1119
|
+
detail=detail,
|
|
1120
|
+
)
|
|
1121
|
+
append_outbox(repo, origin, origin, [block])
|
|
1122
|
+
compile_repo(repo, today=today)
|
|
1123
|
+
return op_id
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def record_work(
|
|
1127
|
+
repo: Path,
|
|
1128
|
+
*,
|
|
1129
|
+
origin: str,
|
|
1130
|
+
text: str = "",
|
|
1131
|
+
topic: str | None = None,
|
|
1132
|
+
scope: str | None = None,
|
|
1133
|
+
local: bool = False,
|
|
1134
|
+
done: bool = False,
|
|
1135
|
+
detail: str = "",
|
|
1136
|
+
today: date | None = None,
|
|
1137
|
+
) -> str:
|
|
1138
|
+
"""Record or retire per-origin current work (default) or a named topic/project handoff."""
|
|
1139
|
+
repo = Path(repo)
|
|
1140
|
+
origin = safe_origin(origin)
|
|
1141
|
+
resolved_scope = f"origin:{origin}" if local else safe_scope(scope or "shared")
|
|
1142
|
+
if topic and topic.strip():
|
|
1143
|
+
slug = safe_origin(topic.strip())
|
|
1144
|
+
claim_id = f"project.{slug}"
|
|
1145
|
+
else:
|
|
1146
|
+
claim_id = "work.current" if local else f"work.{origin}"
|
|
1147
|
+
if done:
|
|
1148
|
+
return record_retract(
|
|
1149
|
+
repo,
|
|
1150
|
+
origin=origin,
|
|
1151
|
+
claim_id=claim_id,
|
|
1152
|
+
scope=resolved_scope,
|
|
1153
|
+
text=text.strip() or "已完成并归档",
|
|
1154
|
+
today=today,
|
|
1155
|
+
)
|
|
1156
|
+
if not text or not text.strip():
|
|
1157
|
+
raise ValueError("记录当前工作或专项交接需要提供 --text,或使用 --done 标记完结")
|
|
1158
|
+
formatted = text.strip()
|
|
1159
|
+
if not (topic and topic.strip()) and not local and not formatted.startswith("["):
|
|
1160
|
+
formatted = f"[{origin}] {formatted}"
|
|
1161
|
+
return record_set(
|
|
1162
|
+
repo,
|
|
1163
|
+
origin=origin,
|
|
1164
|
+
claim_id=claim_id,
|
|
1165
|
+
text=formatted,
|
|
1166
|
+
scope=resolved_scope,
|
|
1167
|
+
slice_name="project",
|
|
1168
|
+
detail=detail,
|
|
1169
|
+
today=today,
|
|
1170
|
+
)
|