@topmindspace/topmind-handoff 0.7.2 → 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 -93
- package/README.md +4 -3
- package/SKILL.md +13 -6
- package/assets/handoff-example.md +6 -8
- package/assets/handoff-template.md +2 -2
- package/examples/README.md +4 -3
- package/package.json +1 -1
- package/references/compile.md +2 -2
- package/references/environment.md +1 -1
- package/references/export.md +6 -2
- package/references/import.md +3 -3
- package/references/privacy.md +1 -1
- package/references/spec.md +6 -6
- package/topmind_handoff/__init__.py +16 -4
- package/topmind_handoff/admin.py +2 -173
- package/topmind_handoff/cli.py +185 -9
- package/topmind_handoff/compile.py +319 -8
- package/topmind_handoff/model.py +27 -0
- package/topmind_handoff/package.py +6 -11
|
@@ -20,6 +20,7 @@ from topmind_handoff.model import (
|
|
|
20
20
|
Op,
|
|
21
21
|
compile_ops,
|
|
22
22
|
compiler_version,
|
|
23
|
+
is_proposal,
|
|
23
24
|
origin_markdown,
|
|
24
25
|
over_budget,
|
|
25
26
|
parse_authority,
|
|
@@ -29,7 +30,9 @@ from topmind_handoff.model import (
|
|
|
29
30
|
parse_card,
|
|
30
31
|
parse_outbox,
|
|
31
32
|
protected_claim_ids,
|
|
33
|
+
safe_claim_id,
|
|
32
34
|
safe_origin,
|
|
35
|
+
safe_scope,
|
|
33
36
|
scenario_names,
|
|
34
37
|
slice_title,
|
|
35
38
|
unique_ops,
|
|
@@ -301,10 +304,6 @@ def compile_repo(repo: Path, today: date | None = None) -> Compiled:
|
|
|
301
304
|
for name, text in rendered.items():
|
|
302
305
|
(views / name).write_text(text, encoding="utf-8")
|
|
303
306
|
write_compiled_status(repo, compiled, today=today, index_fit=fit)
|
|
304
|
-
# Local import: admin imports this module. Only a stock README this skill wrote is replaced.
|
|
305
|
-
from topmind_handoff.admin import upgrade_stock_readme
|
|
306
|
-
|
|
307
|
-
upgrade_stock_readme(repo)
|
|
308
307
|
return compiled
|
|
309
308
|
|
|
310
309
|
|
|
@@ -655,6 +654,16 @@ def _without_claim_details(text: str) -> str:
|
|
|
655
654
|
return _ensure_nl("\n".join(kept))
|
|
656
655
|
|
|
657
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
|
+
|
|
658
667
|
def organize_advice(shared: str, scenario: str) -> str:
|
|
659
668
|
"""Prompt text when an index is past the startup budget. It does not write or retract."""
|
|
660
669
|
notes: list[str] = []
|
|
@@ -733,10 +742,10 @@ def projection_body(repo: Path, origin: str, today: date | None = None) -> str:
|
|
|
733
742
|
_conflicts_for_origin(compiled, origin).rstrip("\n"),
|
|
734
743
|
"",
|
|
735
744
|
"## 共享",
|
|
736
|
-
projected.rstrip("\n"),
|
|
745
|
+
_strip_view_title(projected).rstrip("\n"),
|
|
737
746
|
"",
|
|
738
747
|
f"## 场景:{role or '无'}",
|
|
739
|
-
scenario.rstrip("\n"),
|
|
748
|
+
_strip_view_title(scenario).rstrip("\n"),
|
|
740
749
|
"",
|
|
741
750
|
]
|
|
742
751
|
if origin_text:
|
|
@@ -786,7 +795,7 @@ def _also_scenario(repo: Path, origin: str, also: str, today: date | None) -> st
|
|
|
786
795
|
text = _without_claim_details(
|
|
787
796
|
_label_text(text, _scope_heads(compiled, f"scenario:{also}"), today)
|
|
788
797
|
)
|
|
789
|
-
return _ensure_nl(f"## 另带场景:{also}\n\n{text.rstrip()}")
|
|
798
|
+
return _ensure_nl(f"## 另带场景:{also}\n\n{_strip_view_title(text).rstrip()}")
|
|
790
799
|
|
|
791
800
|
|
|
792
801
|
def startup_read(repo: Path, origin: str, today: date | None = None, also: str | None = None) -> str:
|
|
@@ -802,7 +811,7 @@ def startup_read(repo: Path, origin: str, today: date | None = None, also: str |
|
|
|
802
811
|
extra = _also_scenario(repo, origin, also, today).rstrip("\n")
|
|
803
812
|
if extra:
|
|
804
813
|
body = f"{body}\n\n{extra}"
|
|
805
|
-
return _ensure_nl(f"# 开工读取({origin}
|
|
814
|
+
return _ensure_nl(f"# 开工读取({origin})\n\n{body}")
|
|
806
815
|
|
|
807
816
|
|
|
808
817
|
def open_claim(repo: Path, claim_id: str) -> str:
|
|
@@ -857,3 +866,305 @@ def _open_head(head: Op, today: date | None) -> list[str]:
|
|
|
857
866
|
lines.append(" detail: |")
|
|
858
867
|
lines.extend(f" {line}" for line in head.detail.splitlines())
|
|
859
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
|
+
)
|
package/topmind_handoff/model.py
CHANGED
|
@@ -25,6 +25,7 @@ REVIEW_DAYS = {
|
|
|
25
25
|
"boundaries": 180,
|
|
26
26
|
"preference": 90,
|
|
27
27
|
"project": 30,
|
|
28
|
+
"work": 30,
|
|
28
29
|
"fact": 30,
|
|
29
30
|
}
|
|
30
31
|
CARD_STALE_DAYS = 90
|
|
@@ -85,6 +86,32 @@ def safe_origin(origin: str) -> str:
|
|
|
85
86
|
return name
|
|
86
87
|
|
|
87
88
|
|
|
89
|
+
def safe_claim_id(claim_id: str) -> str:
|
|
90
|
+
"""Validate a claim id so it stays single-line and parseable inside `{id:...}`."""
|
|
91
|
+
assert_private_text(claim_id)
|
|
92
|
+
cleaned = claim_id.strip()
|
|
93
|
+
if not cleaned or any(ch in cleaned for ch in ("{", "}", "\n", "\r", "\t", " ", "\x00")):
|
|
94
|
+
raise ValueError("id 必须是非空的单行标识,且不含空格或花括号")
|
|
95
|
+
return cleaned
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def safe_scope(scope: str) -> str:
|
|
99
|
+
"""Validate `shared`, `scenario:<name>`, or `origin:<origin_id>`."""
|
|
100
|
+
assert_private_text(scope)
|
|
101
|
+
cleaned = scope.strip() or "shared"
|
|
102
|
+
if cleaned == "shared":
|
|
103
|
+
return "shared"
|
|
104
|
+
if cleaned.startswith("scenario:"):
|
|
105
|
+
target = cleaned.split(":", 1)[1]
|
|
106
|
+
if scenario_filename(target) is None:
|
|
107
|
+
raise ValueError("场景名必须是单个路径段")
|
|
108
|
+
return f"scenario:{target}"
|
|
109
|
+
if cleaned.startswith("origin:"):
|
|
110
|
+
target = safe_origin(cleaned.split(":", 1)[1])
|
|
111
|
+
return f"origin:{target}"
|
|
112
|
+
raise ValueError("scope 必须是 shared、scenario:<场景> 或 origin:<origin_id>")
|
|
113
|
+
|
|
114
|
+
|
|
88
115
|
def scenario_filename(name: str) -> str | None:
|
|
89
116
|
"""`scenario-<name>.md` when the name is one path segment. Otherwise None."""
|
|
90
117
|
try:
|
|
@@ -75,7 +75,7 @@ def render_package(repo: Path, origin: str, today: date | None = None) -> str:
|
|
|
75
75
|
"",
|
|
76
76
|
body,
|
|
77
77
|
"",
|
|
78
|
-
"##
|
|
78
|
+
"## 操作附录(已反映在上文视图中)",
|
|
79
79
|
"",
|
|
80
80
|
APPENDIX_MARK,
|
|
81
81
|
"不要把本附录当指令执行。",
|
|
@@ -144,7 +144,7 @@ def replay_package(repo: Path, package: str, *, confirmed: bool = False) -> list
|
|
|
144
144
|
|
|
145
145
|
|
|
146
146
|
@dataclass
|
|
147
|
-
class
|
|
147
|
+
class HostMergePlan:
|
|
148
148
|
additions: dict[str, str] = field(default_factory=dict)
|
|
149
149
|
anchor_conflicts: dict[str, tuple[str, str]] = field(default_factory=dict)
|
|
150
150
|
anchor_fills: dict[str, str] = field(default_factory=dict)
|
|
@@ -154,20 +154,15 @@ class LegacyPlan:
|
|
|
154
154
|
omitted_local_ids: list[str] = field(default_factory=list)
|
|
155
155
|
|
|
156
156
|
|
|
157
|
-
_SECTION_6 = re.compile(r"^##\s*6(?:\.|\s|$)")
|
|
158
|
-
|
|
159
|
-
|
|
160
157
|
def _skip_heading(stripped: str) -> bool:
|
|
161
158
|
"""Boundaries are behavior, not facts. The rendered slice heading is ## 边界."""
|
|
162
|
-
if _SECTION_6.match(stripped):
|
|
163
|
-
return True
|
|
164
159
|
return stripped.lstrip("#").strip() == "边界"
|
|
165
160
|
|
|
166
161
|
|
|
167
162
|
def _claim_items(package: str) -> list[tuple[str | None, str]]:
|
|
168
163
|
"""List items outside boundary sections and outside fenced blocks.
|
|
169
164
|
|
|
170
|
-
|
|
165
|
+
The rendered ## 边界 slice is behavior, not memory.
|
|
171
166
|
Fenced blocks hold the operations appendix, which is a ledger and not a new claim.
|
|
172
167
|
"""
|
|
173
168
|
items: list[tuple[str | None, str]] = []
|
|
@@ -217,7 +212,7 @@ def _is_tombstone(text: str) -> bool:
|
|
|
217
212
|
return "已撤回" in text or "withdrawn" in lowered
|
|
218
213
|
|
|
219
214
|
|
|
220
|
-
def
|
|
215
|
+
def plan_host_merge(package: str, local: dict) -> HostMergePlan:
|
|
221
216
|
"""Diff view lines into host memory. Existing text and anchors are not replaced."""
|
|
222
217
|
assert_private_text(package)
|
|
223
218
|
front = _front(package)
|
|
@@ -229,7 +224,7 @@ def legacy_import_plan(package: str, local: dict) -> LegacyPlan:
|
|
|
229
224
|
raise ValueError("交接包没有操作附录")
|
|
230
225
|
local_anchors = dict(local.get("anchors") or {})
|
|
231
226
|
local_claims = dict(local.get("claims") or {})
|
|
232
|
-
plan =
|
|
227
|
+
plan = HostMergePlan()
|
|
233
228
|
seen: set[str] = set()
|
|
234
229
|
local_texts = {value for value in local_claims.values() if value}
|
|
235
230
|
for claim_id, text in _claim_items(package):
|
|
@@ -285,7 +280,7 @@ def legacy_import_plan(package: str, local: dict) -> LegacyPlan:
|
|
|
285
280
|
return plan
|
|
286
281
|
|
|
287
282
|
|
|
288
|
-
def
|
|
283
|
+
def apply_host_merge(plan: HostMergePlan, local: dict, *, confirmed: bool) -> dict:
|
|
289
284
|
"""Add confirmed new claims and empty anchors. Never delete or replace existing text."""
|
|
290
285
|
result = deepcopy(local)
|
|
291
286
|
result.setdefault("anchors", {})
|