@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.
@@ -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.read_text(encoding="utf-8")) if authority_path.is_file() else {}
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.read_text(encoding="utf-8"), path.stem)
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.read_text(encoding="utf-8"), path.stem)
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.stale else 'false'}")
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 / "STATUS.md").write_text(
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
- """A status file is valid only when its tips equal the outbox tips."""
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
- _authority, tips, _cards, _ops = load_pieces(repo)
320
- recorded = _status_tips(status_path.read_text(encoding="utf-8"))
321
- if recorded != tips:
322
- return False, "STATUS 的信箱末尾与 outbox 不一致,视图无效,需要重新编译"
323
- return True, "STATUS 与信箱末尾一致"
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
- overrides = card.overrides if card else {}
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})\n\n{body}")
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
+ )