@topmindspace/topmind-handoff 0.5.2 → 0.6.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.
@@ -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,9 +145,20 @@ 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
- lines = ["# STATUS", "", f"compiler: topmind-handoff {version}", "", "## compiled_from"]
150
+ lines = [
151
+ "# STATUS",
152
+ "",
153
+ "本文件和 views/ 是本机编译产物,不是权威。权威是 outbox/、authority.md 和 environments/。对不上就丢掉视图,重新编译。",
154
+ "",
155
+ f"compiler: topmind-handoff {version}",
156
+ "",
157
+ ]
158
+ if today is not None:
159
+ lines.append(f"review_as_of: {today.isoformat()}")
160
+ lines.append("")
161
+ lines.append("## compiled_from")
130
162
  if compiled.tips:
131
163
  for origin in sorted(compiled.tips):
132
164
  tip = compiled.tips[origin] or "-"
@@ -170,6 +202,14 @@ def render_status(compiled: Compiled) -> str:
170
202
  lines.append(f" text: {op.text}")
171
203
  else:
172
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 ["(无)"])
173
213
  lines.extend(["", "## origins"])
174
214
  origins = sorted(set(compiled.tips) | set(compiled.cards))
175
215
  if origins:
@@ -185,19 +225,40 @@ def render_status(compiled: Compiled) -> str:
185
225
  return _ensure_nl("\n".join(lines))
186
226
 
187
227
 
188
- def compile_repo(repo: Path) -> Compiled:
189
- """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
+ """
190
245
  repo = Path(repo)
246
+ if today is None:
247
+ today = stored_review_today(repo)
191
248
  compiled = build_compiled(repo)
192
249
  views = repo / "views"
193
250
  views.mkdir(parents=True, exist_ok=True)
194
- rendered = render_views(compiled, inline_detail=True)
251
+ rendered = render_views(compiled, inline_detail=True, today=today)
195
252
  for path in views.glob("scenario-*.md"):
196
253
  if path.name not in rendered:
197
254
  path.unlink()
198
255
  for name, text in rendered.items():
199
256
  (views / name).write_text(text, encoding="utf-8")
200
- (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)
201
262
  return compiled
202
263
 
203
264
 
@@ -237,6 +298,22 @@ def status_is_valid(repo: Path) -> tuple[bool, str]:
237
298
  return True, "STATUS 与信箱末尾一致"
238
299
 
239
300
 
301
+ def append_outbox(repo: Path, actor: str, origin: str, blocks: list[str]) -> Path:
302
+ """Append to one origin's outbox. A different actor is refused and nothing is written."""
303
+ dest = origin_markdown(Path(repo) / "outbox", origin)
304
+ origin_name = dest.name[: -len(".md")]
305
+ if actor != origin_name:
306
+ raise PermissionError(f"{actor} 只能追加自己的信箱 {origin_name}")
307
+ if not blocks:
308
+ return dest
309
+ dest.parent.mkdir(parents=True, exist_ok=True)
310
+ existing = dest.read_text(encoding="utf-8") if dest.is_file() else f"# outbox {origin_name}\n"
311
+ if not existing.endswith("\n"):
312
+ existing += "\n"
313
+ dest.write_text(existing + "\n".join(blocks) + "\n", encoding="utf-8")
314
+ return dest
315
+
316
+
240
317
  def save_environment_card(repo: Path, actor: str, origin: str, content: str) -> Path:
241
318
  """Replace one origin's environment card. Other origins are refused."""
242
319
  dest = origin_markdown(Path(repo) / "environments", origin)
@@ -248,7 +325,9 @@ def save_environment_card(repo: Path, actor: str, origin: str, content: str) ->
248
325
  return dest
249
326
 
250
327
 
251
- _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
+ )
252
331
 
253
332
 
254
333
  def _override_line(line: str, overrides: dict[str, str]) -> str | None:
@@ -334,6 +413,103 @@ def _project_shared(repo: Path, shared: str, overrides: dict[str, str]) -> str:
334
413
  return _ensure_nl("\n".join(output))
335
414
 
336
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
+
337
513
  def _conflicts_for_origin(compiled: Compiled, origin: str) -> str:
338
514
  blocks: list[str] = []
339
515
  for result in sorted(compiled.claims.values(), key=lambda item: (item.scope, item.claim_id)):
@@ -346,41 +522,49 @@ def _conflicts_for_origin(compiled: Compiled, origin: str) -> str:
346
522
  return _ensure_nl("\n".join(blocks))
347
523
 
348
524
 
349
- def _origin_projection(compiled: Compiled, origin: str) -> str:
350
- """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
+ """
351
530
  scope = f"origin:{origin}"
352
- results = [result for (item_scope, _cid), result in compiled.claims.items() if item_scope == scope]
353
- lines: list[str] = []
354
- for result in sorted(results, key=lambda item: item.claim_id):
355
- if result.conflict or result.retracted or not result.heads:
356
- continue
357
- head = result.heads[0]
358
- if head.kind != "set":
359
- continue
360
- lines.append(_fact_line(head))
361
- if head.detail:
362
- lines.extend(f" {line}" for line in head.detail.splitlines())
363
- 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:
364
535
  return ""
365
- 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"
366
542
 
367
543
 
368
- def startup_read(repo: Path, origin: str) -> str:
369
- """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.
370
546
 
371
- 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.
372
548
  """
373
549
  repo = Path(repo)
550
+ origin = safe_origin(origin)
374
551
  compiled = build_compiled(repo)
552
+ if today is None:
553
+ today = stored_review_today(repo)
375
554
  views = repo / "views"
555
+ rendered = render_views(compiled, today=today)
376
556
  shared_path = views / "shared.md"
377
557
  if shared_path.is_file() and _path_inside(shared_path, views):
378
558
  shared = shared_path.read_text(encoding="utf-8")
379
559
  else:
380
- shared = render_views(compiled)["shared.md"]
560
+ shared = rendered["shared.md"]
381
561
  card = compiled.cards.get(origin)
382
562
  overrides = card.overrides if card else {}
383
- projected = _project_shared(repo, shared, overrides)
563
+ projected = _label_text(
564
+ _project_shared(repo, shared, overrides),
565
+ _scope_heads(compiled, "shared"),
566
+ today,
567
+ )
384
568
  role = card.role if card else ""
385
569
  filename = scenario_filename(role) if role else None
386
570
  scenario_path = views / filename if filename else None
@@ -389,14 +573,18 @@ def startup_read(repo: Path, origin: str) -> str:
389
573
  if scenario_path is not None and scenario_path.is_file():
390
574
  scenario = scenario_path.read_text(encoding="utf-8")
391
575
  elif filename:
392
- scenario = render_views(compiled).get(filename, "# 场景\n\n(无)\n")
576
+ scenario = rendered.get(filename, "# 场景\n\n(无)\n")
393
577
  else:
394
578
  scenario = "(无)\n"
579
+ scenario = _label_text(scenario, _scope_heads(compiled, f"scenario:{role}") if role else {}, today)
395
580
  card_text = card.raw if card else "(无)\n"
396
- 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))
397
587
  parts = [
398
- f"# 开工读取({origin})",
399
- "",
400
588
  "## 冲突(与本环境有关)",
401
589
  _conflicts_for_origin(compiled, origin).rstrip("\n"),
402
590
  "",
@@ -409,5 +597,26 @@ def startup_read(repo: Path, origin: str) -> str:
409
597
  ]
410
598
  if origin_text:
411
599
  parts.extend([origin_text, ""])
412
- 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
+ )
413
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,