@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.
@@ -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})\n\n{body}")
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
+ )
@@ -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
- "## 10. 操作附录(已反映在上文视图中)",
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 LegacyPlan:
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
- Section 6 and the rendered ## 边界 slice are behavior, not memory.
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 legacy_import_plan(package: str, local: dict) -> LegacyPlan:
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 = LegacyPlan()
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 apply_legacy_plan(plan: LegacyPlan, local: dict, *, confirmed: bool) -> dict:
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", {})