@topmindspace/topmind-handoff 0.6.0 → 0.6.4

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.
@@ -3,10 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
+ from datetime import date
6
7
  from pathlib import Path
7
8
 
8
9
  from topmind_handoff.model import (
9
- SLICE_TITLE,
10
+ CARD_STALE_DAYS,
11
+ REVIEW_MARK,
12
+ SLICES,
10
13
  Card,
11
14
  ClaimResult,
12
15
  Compiled,
@@ -15,13 +18,20 @@ from topmind_handoff.model import (
15
18
  compiler_version,
16
19
  origin_markdown,
17
20
  parse_authority,
21
+ parse_day,
22
+ review_suffix,
18
23
  scenario_filename,
19
24
  parse_card,
20
25
  parse_outbox,
26
+ safe_origin,
21
27
  scenario_names,
28
+ slice_title,
29
+ view_slice_order,
22
30
  )
23
31
 
24
32
  VIEW_ORDER = ("identity", "boundaries", "preference", "project", "fact")
33
+ _REVIEW_AS_OF = re.compile(r"^review_as_of:\s*(\d{4}-\d{2}-\d{2})\s*$", re.M)
34
+ _CITE_EDGE = r"[A-Za-z0-9_.-]"
25
35
 
26
36
 
27
37
  def _ensure_nl(text: str) -> str:
@@ -66,7 +76,9 @@ def _conflict_block(result: ClaimResult) -> list[str]:
66
76
  return lines
67
77
 
68
78
 
69
- def _section_lines(results: list[ClaimResult], *, inline_detail: bool) -> dict[str, list[str]]:
79
+ def _section_lines(
80
+ results: list[ClaimResult], *, inline_detail: bool, today: date | None = None
81
+ ) -> dict[str, list[str]]:
70
82
  sections = {name: [] for name in VIEW_ORDER}
71
83
  for result in sorted(results, key=lambda item: item.claim_id):
72
84
  if result.conflict or result.retracted or not result.heads:
@@ -75,20 +87,25 @@ def _section_lines(results: list[ClaimResult], *, inline_detail: bool) -> dict[s
75
87
  if head.kind != "set":
76
88
  continue
77
89
  bucket = sections.setdefault(head.slice, [])
78
- bucket.append(_fact_line(head))
90
+ bucket.append(_fact_line(head, review_suffix(head, today)))
79
91
  if inline_detail and head.detail:
80
92
  bucket.extend(f" {line}" for line in head.detail.splitlines())
81
93
  return sections
82
94
 
83
95
 
84
- def render_scope_view(title: str, results: list[ClaimResult], *, inline_detail: bool) -> str:
85
- sections = _section_lines(results, inline_detail=inline_detail)
96
+ def render_scope_view(
97
+ title: str, results: list[ClaimResult], *, inline_detail: bool, today: date | None = None
98
+ ) -> str:
99
+ sections = _section_lines(results, inline_detail=inline_detail, today=today)
86
100
  conflicts = [result for result in results if result.conflict]
87
101
  conflicts.sort(key=lambda item: item.claim_id)
88
102
  lines = [f"# {title}", ""]
89
- for slice_name in VIEW_ORDER:
90
- lines.append(f"## {SLICE_TITLE[slice_name]}")
103
+ for slice_name in view_slice_order(sections):
104
+ lines.append(f"## {slice_title(slice_name)}")
91
105
  body = sections.get(slice_name) or []
106
+ # An unknown slice with no rows is not a section. Known slices stay, even when empty.
107
+ if slice_name not in VIEW_ORDER and not body:
108
+ continue
92
109
  lines.extend(body or ["(无)"])
93
110
  lines.append("")
94
111
  lines.append("## 冲突")
@@ -101,9 +118,11 @@ def render_scope_view(title: str, results: list[ClaimResult], *, inline_detail:
101
118
  return _ensure_nl("\n".join(lines))
102
119
 
103
120
 
104
- def render_views(compiled: Compiled, *, inline_detail: bool = True) -> dict[str, str]:
121
+ def render_views(
122
+ compiled: Compiled, *, inline_detail: bool = True, today: date | None = None
123
+ ) -> dict[str, str]:
105
124
  shared = [result for (scope, _cid), result in compiled.claims.items() if scope == "shared"]
106
- rendered = {"shared.md": render_scope_view("共享", shared, inline_detail=inline_detail)}
125
+ rendered = {"shared.md": render_scope_view("共享", shared, inline_detail=inline_detail, today=today)}
107
126
  names = scenario_names(compiled.authority, compiled.cards, _all_ops(compiled))
108
127
  for name in names:
109
128
  filename = scenario_filename(name)
@@ -111,7 +130,9 @@ def render_views(compiled: Compiled, *, inline_detail: bool = True) -> dict[str,
111
130
  continue
112
131
  scope = f"scenario:{name}"
113
132
  results = [result for (item_scope, _cid), result in compiled.claims.items() if item_scope == scope]
114
- rendered[filename] = render_scope_view(f"场景:{name}", results, inline_detail=inline_detail)
133
+ rendered[filename] = render_scope_view(
134
+ f"场景:{name}", results, inline_detail=inline_detail, today=today
135
+ )
115
136
  return rendered
116
137
 
117
138
 
@@ -124,7 +145,7 @@ def _all_ops(compiled: Compiled) -> list[Op]:
124
145
  return ops
125
146
 
126
147
 
127
- def render_status(compiled: Compiled) -> str:
148
+ def render_status(compiled: Compiled, today: date | None = None) -> str:
128
149
  version = compiler_version()
129
150
  lines = [
130
151
  "# STATUS",
@@ -133,8 +154,11 @@ def render_status(compiled: Compiled) -> str:
133
154
  "",
134
155
  f"compiler: topmind-handoff {version}",
135
156
  "",
136
- "## compiled_from",
137
157
  ]
158
+ if today is not None:
159
+ lines.append(f"review_as_of: {today.isoformat()}")
160
+ lines.append("")
161
+ lines.append("## compiled_from")
138
162
  if compiled.tips:
139
163
  for origin in sorted(compiled.tips):
140
164
  tip = compiled.tips[origin] or "-"
@@ -178,6 +202,14 @@ def render_status(compiled: Compiled) -> str:
178
202
  lines.append(f" text: {op.text}")
179
203
  else:
180
204
  lines.append("(无)")
205
+ lines.extend(["", "## 待提炼"])
206
+ refine = refinement_lines(
207
+ list(compiled.claims.values()),
208
+ compiled.cards,
209
+ today,
210
+ known_ids=_current_ids(compiled),
211
+ )
212
+ lines.extend(refine or ["(无)"])
181
213
  lines.extend(["", "## origins"])
182
214
  origins = sorted(set(compiled.tips) | set(compiled.cards))
183
215
  if origins:
@@ -193,19 +225,40 @@ def render_status(compiled: Compiled) -> str:
193
225
  return _ensure_nl("\n".join(lines))
194
226
 
195
227
 
196
- def compile_repo(repo: Path) -> Compiled:
197
- """Regenerate views and STATUS from outboxes. Existing view text is discarded."""
228
+ def stored_review_today(repo: Path) -> date | None:
229
+ """The explicit day a previous compile or organize used. Absent means unlabeled views."""
230
+ path = Path(repo) / "STATUS.md"
231
+ if not path.is_file():
232
+ return None
233
+ match = _REVIEW_AS_OF.search(path.read_text(encoding="utf-8"))
234
+ if not match:
235
+ return None
236
+ return parse_day(match.group(1))
237
+
238
+
239
+ def compile_repo(repo: Path, today: date | None = None) -> Compiled:
240
+ """Regenerate views and STATUS from outboxes. Existing view text is discarded.
241
+
242
+ Review labels are rendered from `at` and `today`. A later compile with no day
243
+ reuses `review_as_of` so it does not wipe labels. The day never picks a head.
244
+ """
198
245
  repo = Path(repo)
246
+ if today is None:
247
+ today = stored_review_today(repo)
199
248
  compiled = build_compiled(repo)
200
249
  views = repo / "views"
201
250
  views.mkdir(parents=True, exist_ok=True)
202
- rendered = render_views(compiled, inline_detail=True)
251
+ rendered = render_views(compiled, inline_detail=True, today=today)
203
252
  for path in views.glob("scenario-*.md"):
204
253
  if path.name not in rendered:
205
254
  path.unlink()
206
255
  for name, text in rendered.items():
207
256
  (views / name).write_text(text, encoding="utf-8")
208
- (repo / "STATUS.md").write_text(render_status(compiled), encoding="utf-8")
257
+ (repo / "STATUS.md").write_text(render_status(compiled, today=today), encoding="utf-8")
258
+ # Local import: admin imports this module. Only a stock README this skill wrote is replaced.
259
+ from topmind_handoff.admin import upgrade_stock_readme
260
+
261
+ upgrade_stock_readme(repo)
209
262
  return compiled
210
263
 
211
264
 
@@ -272,7 +325,9 @@ def save_environment_card(repo: Path, actor: str, origin: str, content: str) ->
272
325
  return dest
273
326
 
274
327
 
275
- _FACT_LINE = re.compile(r"^- (?P<body>.*)\{id:(?P<cid>[^}]+)\}(?P<suffix>\s*(未复核))?\s*$")
328
+ _FACT_LINE = re.compile(
329
+ rf"^- (?P<body>.*){{id:(?P<cid>[^}}]+)}}(?P<suffix>\s*{re.escape(REVIEW_MARK)})?\s*$"
330
+ )
276
331
 
277
332
 
278
333
  def _override_line(line: str, overrides: dict[str, str]) -> str | None:
@@ -358,6 +413,103 @@ def _project_shared(repo: Path, shared: str, overrides: dict[str, str]) -> str:
358
413
  return _ensure_nl("\n".join(output))
359
414
 
360
415
 
416
+ def _results_for(compiled: Compiled, scope: str) -> list[ClaimResult]:
417
+ return [result for (item_scope, _cid), result in compiled.claims.items() if item_scope == scope]
418
+
419
+
420
+ def _current_heads(results: list[ClaimResult]) -> list[Op]:
421
+ heads: list[Op] = []
422
+ for result in results:
423
+ if result.conflict or result.retracted or not result.heads:
424
+ continue
425
+ head = result.heads[0]
426
+ if head.kind == "set":
427
+ heads.append(head)
428
+ return heads
429
+
430
+
431
+ def _current_ids(compiled: Compiled) -> set[str]:
432
+ found: set[str] = set()
433
+ for result in compiled.claims.values():
434
+ for head in _current_heads([result]):
435
+ found.add(head.claim_id)
436
+ return found
437
+
438
+
439
+ def _scope_heads(compiled: Compiled, scope: str) -> dict[str, Op]:
440
+ return {head.claim_id: head for head in _current_heads(_results_for(compiled, scope))}
441
+
442
+
443
+ def cites_claim(text: str, claim_id: str) -> bool:
444
+ """True when text names another claim id as a whole token, not as a prefix."""
445
+ if not text or not claim_id or claim_id not in text:
446
+ return False
447
+ pattern = re.compile(
448
+ r"(?<!" + _CITE_EDGE + r")" + re.escape(claim_id) + r"(?!" + _CITE_EDGE + r")"
449
+ )
450
+ return pattern.search(text) is not None
451
+
452
+
453
+ def card_is_due(card: Card, today: date | None) -> bool:
454
+ if card.stale:
455
+ return True
456
+ if today is None or not card.refreshed:
457
+ return False
458
+ written = parse_day(card.refreshed)
459
+ if written is None:
460
+ return False
461
+ return (today - written).days >= CARD_STALE_DAYS
462
+
463
+
464
+ def refinement_lines(
465
+ results: list[ClaimResult],
466
+ cards: dict[str, Card],
467
+ today: date | None,
468
+ *,
469
+ known_ids: set[str],
470
+ ) -> list[str]:
471
+ """List review work. Does not retract, rewrite, or touch outboxes.
472
+
473
+ Lines are sentences, not `- ` claims, so a package import does not store them as facts.
474
+ """
475
+ heads = _current_heads(results)
476
+ heads.sort(key=lambda op: (op.scope, op.claim_id, op.op_id))
477
+ lines: list[str] = []
478
+ for head in heads:
479
+ if head.slice not in SLICES:
480
+ lines.append(f"维度 `{head.slice}` 单独成节,没有并进事实。id: {head.claim_id}")
481
+ for head in heads:
482
+ blob = f"{head.text}\n{head.detail}"
483
+ for other in sorted(known_ids):
484
+ if other == head.claim_id:
485
+ continue
486
+ if cites_claim(blob, other):
487
+ lines.append(f"引用 `{head.claim_id}` 提到当前说法 `{other}`。不撤回,不改写。")
488
+ for origin in sorted(cards):
489
+ if card_is_due(cards[origin], today):
490
+ lines.append(f"环境卡片 `{origin}` 未刷新。")
491
+ return lines
492
+
493
+
494
+ def _label_line(line: str, heads: dict[str, Op], today: date | None) -> str:
495
+ if today is None:
496
+ return line
497
+ match = _FACT_LINE.match(line.rstrip())
498
+ if not match:
499
+ return line
500
+ head = heads.get(match.group("cid"))
501
+ if head is None:
502
+ return line
503
+ suffix = review_suffix(head, today)
504
+ return f"- {match.group('body').rstrip()} {{id:{match.group('cid')}}}{suffix}"
505
+
506
+
507
+ def _label_text(text: str, heads: dict[str, Op], today: date | None) -> str:
508
+ if today is None:
509
+ return text
510
+ return _ensure_nl("\n".join(_label_line(line, heads, today) for line in text.splitlines()))
511
+
512
+
361
513
  def _conflicts_for_origin(compiled: Compiled, origin: str) -> str:
362
514
  blocks: list[str] = []
363
515
  for result in sorted(compiled.claims.values(), key=lambda item: (item.scope, item.claim_id)):
@@ -370,41 +522,49 @@ def _conflicts_for_origin(compiled: Compiled, origin: str) -> str:
370
522
  return _ensure_nl("\n".join(blocks))
371
523
 
372
524
 
373
- def _origin_projection(compiled: Compiled, origin: str) -> str:
374
- """Sets whose scope is this origin. Conflicts stay in the conflict section."""
525
+ def _origin_projection(compiled: Compiled, origin: str, today: date | None = None) -> str:
526
+ """Sets whose scope is this origin, each slice under its own heading.
527
+
528
+ Conflicts stay in the conflict section. Origin text is not part of the shared view.
529
+ """
375
530
  scope = f"origin:{origin}"
376
- results = [result for (item_scope, _cid), result in compiled.claims.items() if item_scope == scope]
377
- lines: list[str] = []
378
- for result in sorted(results, key=lambda item: item.claim_id):
379
- if result.conflict or result.retracted or not result.heads:
380
- continue
381
- head = result.heads[0]
382
- if head.kind != "set":
383
- continue
384
- lines.append(_fact_line(head))
385
- if head.detail:
386
- lines.extend(f" {line}" for line in head.detail.splitlines())
387
- if not lines:
531
+ results = _results_for(compiled, scope)
532
+ sections = _section_lines(results, inline_detail=True, today=today)
533
+ filled = [name for name in view_slice_order(sections) if sections.get(name)]
534
+ if not filled:
388
535
  return ""
389
- return "## 本环境范围\n\n" + "\n".join(lines) + "\n"
536
+ lines = ["## 本环境范围", ""]
537
+ for name in filled:
538
+ lines.append(f"### {slice_title(name)}")
539
+ lines.extend(sections[name])
540
+ lines.append("")
541
+ return "\n".join(lines).rstrip() + "\n"
390
542
 
391
543
 
392
- def startup_read(repo: Path, origin: str) -> str:
393
- """Shared view, this origin's scenario, its card, and conflicts that involve it.
544
+ def projection_body(repo: Path, origin: str, today: date | None = None) -> str:
545
+ """One origin projection shared by startup and the offline package.
394
546
 
395
- Card overrides are applied only in this projection. views/shared.md is not rewritten.
547
+ Reads views when they are inside the repo. Does not write files.
396
548
  """
397
549
  repo = Path(repo)
550
+ origin = safe_origin(origin)
398
551
  compiled = build_compiled(repo)
552
+ if today is None:
553
+ today = stored_review_today(repo)
399
554
  views = repo / "views"
555
+ rendered = render_views(compiled, today=today)
400
556
  shared_path = views / "shared.md"
401
557
  if shared_path.is_file() and _path_inside(shared_path, views):
402
558
  shared = shared_path.read_text(encoding="utf-8")
403
559
  else:
404
- shared = render_views(compiled)["shared.md"]
560
+ shared = rendered["shared.md"]
405
561
  card = compiled.cards.get(origin)
406
562
  overrides = card.overrides if card else {}
407
- projected = _project_shared(repo, shared, overrides)
563
+ projected = _label_text(
564
+ _project_shared(repo, shared, overrides),
565
+ _scope_heads(compiled, "shared"),
566
+ today,
567
+ )
408
568
  role = card.role if card else ""
409
569
  filename = scenario_filename(role) if role else None
410
570
  scenario_path = views / filename if filename else None
@@ -413,14 +573,18 @@ def startup_read(repo: Path, origin: str) -> str:
413
573
  if scenario_path is not None and scenario_path.is_file():
414
574
  scenario = scenario_path.read_text(encoding="utf-8")
415
575
  elif filename:
416
- scenario = render_views(compiled).get(filename, "# 场景\n\n(无)\n")
576
+ scenario = rendered.get(filename, "# 场景\n\n(无)\n")
417
577
  else:
418
578
  scenario = "(无)\n"
579
+ scenario = _label_text(scenario, _scope_heads(compiled, f"scenario:{role}") if role else {}, today)
419
580
  card_text = card.raw if card else "(无)\n"
420
- origin_text = _origin_projection(compiled, origin).rstrip("\n")
581
+ origin_text = _origin_projection(compiled, origin, today=today).rstrip("\n")
582
+ visible: list[ClaimResult] = list(_results_for(compiled, "shared"))
583
+ if role:
584
+ visible.extend(_results_for(compiled, f"scenario:{role}"))
585
+ visible.extend(_results_for(compiled, f"origin:{origin}"))
586
+ refine = refinement_lines(visible, compiled.cards, today, known_ids=_current_ids(compiled))
421
587
  parts = [
422
- f"# 开工读取({origin})",
423
- "",
424
588
  "## 冲突(与本环境有关)",
425
589
  _conflicts_for_origin(compiled, origin).rstrip("\n"),
426
590
  "",
@@ -433,5 +597,26 @@ def startup_read(repo: Path, origin: str) -> str:
433
597
  ]
434
598
  if origin_text:
435
599
  parts.extend([origin_text, ""])
436
- parts.extend(["## 环境卡片", card_text.rstrip("\n"), ""])
600
+ parts.extend(
601
+ [
602
+ "## 环境卡片",
603
+ card_text.rstrip("\n"),
604
+ "",
605
+ "## 待提炼",
606
+ "\n".join(refine) if refine else "(无)",
607
+ "",
608
+ ]
609
+ )
437
610
  return _ensure_nl("\n".join(parts))
611
+
612
+
613
+ def startup_read(repo: Path, origin: str, today: date | None = None) -> str:
614
+ """Shared view, this origin's scenario, its card, and conflicts that involve it.
615
+
616
+ Card overrides are applied only in this projection. views/shared.md is not rewritten.
617
+ Due labels and the 待提炼 list are printed. Nothing is retracted.
618
+ """
619
+ repo = Path(repo)
620
+ origin = safe_origin(origin)
621
+ body = projection_body(repo, origin, today=today).rstrip("\n")
622
+ return _ensure_nl(f"# 开工读取({origin})\n\n{body}")
@@ -4,7 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import re
7
+ from collections.abc import Iterable
7
8
  from dataclasses import dataclass, field
9
+ from datetime import date, datetime
8
10
  from pathlib import Path
9
11
 
10
12
  ROOT = Path(__file__).resolve().parent.parent
@@ -21,11 +23,44 @@ REVIEW_DAYS = {
21
23
  "boundaries": 180,
22
24
  "preference": 90,
23
25
  "project": 30,
26
+ "fact": 30,
24
27
  }
28
+ CARD_STALE_DAYS = 90
29
+ REVIEW_MARK = "(未复核)"
25
30
  INDEX_LINE_BUDGET = 200
26
31
  INDEX_BYTE_BUDGET = 25 * 1024
27
32
 
28
33
 
34
+ def parse_day(value: str) -> date | None:
35
+ try:
36
+ return datetime.strptime(value, "%Y-%m-%d").date()
37
+ except ValueError:
38
+ return None
39
+
40
+
41
+ def review_suffix(op: Op, today: date | None) -> str:
42
+ """Label a current head from its own `at`. The date never picks a chain head."""
43
+ if today is None:
44
+ return ""
45
+ limit = REVIEW_DAYS.get(op.slice)
46
+ written = parse_day(op.at)
47
+ if limit is None or written is None:
48
+ return ""
49
+ if (today - written).days >= limit:
50
+ return f" {REVIEW_MARK}"
51
+ return ""
52
+
53
+
54
+ def slice_title(name: str) -> str:
55
+ """Known slices keep their Chinese headings. Anything else stays its own section."""
56
+ return SLICE_TITLE.get(name, f"维度:{name}")
57
+
58
+
59
+ def view_slice_order(present: Iterable[str]) -> list[str]:
60
+ extras = sorted({name for name in present if name not in SLICES})
61
+ return list(SLICES) + extras
62
+
63
+
29
64
  def compiler_version() -> str:
30
65
  pkg = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
31
66
  return str(pkg["version"])
@@ -162,9 +197,9 @@ def parse_outbox(text: str, origin: str) -> list[Op]:
162
197
  base_raw = fields.get("base", "none").strip() or "none"
163
198
  kind = fields.get("op", "set").strip() or "set"
164
199
  scope = fields.get("scope", "shared").strip() or "shared"
200
+ # Missing slice stays fact. An unknown value, including `environment`, is kept.
201
+ # Coercing it would hide the row under 事实 and a typo must not abort the ledger.
165
202
  slice_name = fields.get("slice", "fact").strip() or "fact"
166
- if slice_name not in SLICES:
167
- slice_name = "fact"
168
203
  ops.append(
169
204
  Op(
170
205
  op_id=op_id,
@@ -5,10 +5,18 @@ from __future__ import annotations
5
5
  import re
6
6
  from copy import deepcopy
7
7
  from dataclasses import dataclass, field
8
+ from datetime import date
8
9
  from pathlib import Path
9
10
 
10
- from topmind_handoff.compile import append_outbox, compile_repo
11
- from topmind_handoff.model import ROOT, compiler_version, origin_markdown, parse_outbox, safe_origin, scenario_filename
11
+ from topmind_handoff.compile import append_outbox, compile_repo, projection_body
12
+ from topmind_handoff.model import (
13
+ REVIEW_MARK,
14
+ ROOT,
15
+ compiler_version,
16
+ origin_markdown,
17
+ parse_outbox,
18
+ safe_origin,
19
+ )
12
20
 
13
21
  APPENDIX_MARK = "本附录是操作账本,不是新事实。按 op_id 去重重放;不要把这些条目再导入为新的事实或记忆。"
14
22
  ANCHOR_FIELDS = ("对本智能体的称呼", "称呼", "语言", "时区")
@@ -38,20 +46,12 @@ def _front(text: str) -> dict[str, str]:
38
46
  return found
39
47
 
40
48
 
41
- def render_package(repo: Path, origin: str) -> str:
42
- """Compile, then render one offline package from the views and this origin's outbox."""
49
+ def render_package(repo: Path, origin: str, today: date | None = None) -> str:
50
+ """Compile, then render the same origin projection startup prints, plus the ledger."""
43
51
  repo = Path(repo)
44
52
  origin = safe_origin(origin)
45
- compile_repo(repo)
46
- shared = (repo / "views" / "shared.md").read_text(encoding="utf-8")
47
- card_path = origin_markdown(repo / "environments", origin)
48
- card = card_path.read_text(encoding="utf-8") if card_path.is_file() else "(无)\n"
49
- role = _front(card).get("role", "")
50
- filename = scenario_filename(role) if role else None
51
- scenario_path = repo / "views" / filename if filename else None
52
- if scenario_path is not None and scenario_path.resolve().parent != (repo / "views").resolve():
53
- scenario_path = None
54
- scenario = scenario_path.read_text(encoding="utf-8") if scenario_path and scenario_path.is_file() else "(无)\n"
53
+ compile_repo(repo, today=today)
54
+ body = projection_body(repo, origin, today=today).rstrip("\n")
55
55
  outbox_path = origin_markdown(repo / "outbox", origin)
56
56
  ops_text = outbox_path.read_text(encoding="utf-8") if outbox_path.is_file() else ""
57
57
  op_blocks = [op.raw.rstrip("\n") for op in parse_outbox(ops_text, origin)]
@@ -70,15 +70,7 @@ def render_package(repo: Path, origin: str) -> str:
70
70
  "",
71
71
  "# 交接包",
72
72
  "",
73
- "## 视图",
74
- "",
75
- shared.rstrip("\n"),
76
- "",
77
- scenario.rstrip("\n"),
78
- "",
79
- "## 环境卡片",
80
- "",
81
- card.rstrip("\n"),
73
+ body,
82
74
  "",
83
75
  "## 10. 操作附录(已反映在上文视图中)",
84
76
  "",
@@ -191,7 +183,7 @@ def _claim_items(package: str) -> list[tuple[str | None, str]]:
191
183
  continue
192
184
  if skip or not stripped.startswith("- "):
193
185
  continue
194
- body = stripped[2:].strip()
186
+ body = _without_review_mark(stripped[2:].strip())
195
187
  match = re.search(r"\{id:([^}]+)\}\s*$", body)
196
188
  claim_id = match.group(1) if match else None
197
189
  if match:
@@ -200,6 +192,13 @@ def _claim_items(package: str) -> list[tuple[str | None, str]]:
200
192
  return items
201
193
 
202
194
 
195
+ def _without_review_mark(body: str) -> str:
196
+ """A due line ends with the review label after `{id:…}`. The label is not claim text."""
197
+ if body.endswith(REVIEW_MARK):
198
+ return body[: -len(REVIEW_MARK)].rstrip()
199
+ return body
200
+
201
+
203
202
  def _anchor_of(text: str) -> tuple[str, str] | None:
204
203
  """An anchor line starts with the field name. A later mention is not an anchor."""
205
204
  stripped = text.strip()