@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.
- package/CHANGELOG.md +24 -0
- package/README.md +7 -6
- package/SKILL.md +52 -14
- package/assets/handoff-example.md +10 -4
- package/assets/handoff-template.md +3 -3
- package/package.json +1 -1
- package/references/compile.md +18 -7
- package/references/environment.md +2 -2
- package/references/export.md +7 -2
- package/references/import.md +5 -10
- package/references/privacy.md +1 -0
- package/references/spec.md +16 -17
- package/topmind_handoff/admin.py +168 -48
- package/topmind_handoff/cli.py +22 -28
- package/topmind_handoff/compile.py +251 -42
- package/topmind_handoff/model.py +37 -2
- package/topmind_handoff/package.py +57 -75
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
85
|
-
|
|
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
|
|
90
|
-
lines.append(f"## {
|
|
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(
|
|
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(
|
|
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 = [
|
|
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
|
|
189
|
-
"""
|
|
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(
|
|
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
|
|
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 =
|
|
353
|
-
|
|
354
|
-
for
|
|
355
|
-
|
|
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
|
-
|
|
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
|
|
369
|
-
"""
|
|
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
|
-
|
|
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 =
|
|
560
|
+
shared = rendered["shared.md"]
|
|
381
561
|
card = compiled.cards.get(origin)
|
|
382
562
|
overrides = card.overrides if card else {}
|
|
383
|
-
projected =
|
|
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 =
|
|
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(
|
|
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}")
|
package/topmind_handoff/model.py
CHANGED
|
@@ -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,
|