@topmindspace/topmind-handoff 0.4.10 → 0.5.0
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 +17 -149
- package/README.md +36 -96
- package/SKILL.md +80 -96
- package/assets/handoff-example.md +68 -109
- package/assets/handoff-template.md +23 -129
- package/package.json +1 -1
- package/references/compile.md +110 -0
- package/references/environment.md +48 -81
- package/references/export.md +33 -164
- package/references/import.md +31 -217
- package/references/privacy.md +10 -10
- package/references/spec.md +59 -360
- package/references/tool-adapters.md +48 -112
- package/scripts/compile_memory.py +16 -0
- package/topmind_handoff/__init__.py +35 -0
- package/topmind_handoff/admin.py +337 -0
- package/topmind_handoff/cli.py +157 -0
- package/topmind_handoff/compile.py +413 -0
- package/topmind_handoff/model.py +353 -0
- package/topmind_handoff/package.py +255 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Command line for the local memory compiler. No network."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from topmind_handoff.admin import PublicRepoRefused, init_memory_repo, organize_repo, set_scenario_owner
|
|
10
|
+
from topmind_handoff.compile import compile_repo, startup_read, status_is_valid
|
|
11
|
+
from topmind_handoff.package import render_legacy_package, render_package
|
|
12
|
+
|
|
13
|
+
USAGE = """\
|
|
14
|
+
用法:
|
|
15
|
+
python3 scripts/compile_memory.py <记忆目录>
|
|
16
|
+
python3 scripts/compile_memory.py --check <记忆目录>
|
|
17
|
+
python3 scripts/compile_memory.py --init <目录> --private --origin <origin_id> [--role 场景] [--today YYYY-MM-DD]
|
|
18
|
+
python3 scripts/compile_memory.py --init <目录> --public --origin <origin_id>
|
|
19
|
+
python3 scripts/compile_memory.py --organize <记忆目录> [--today YYYY-MM-DD]
|
|
20
|
+
python3 scripts/compile_memory.py --render <记忆目录> --origin <origin_id> [--out 文件]
|
|
21
|
+
python3 scripts/compile_memory.py --render-legacy --out 文件
|
|
22
|
+
python3 scripts/compile_memory.py --own <记忆目录> --scenario <场景> --origin <origin_id>
|
|
23
|
+
python3 scripts/compile_memory.py --startup <记忆目录> --origin <origin_id>
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _day(value: str):
|
|
28
|
+
return datetime.strptime(value, "%Y-%m-%d").date()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: list[str]) -> int:
|
|
32
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
33
|
+
print(USAGE)
|
|
34
|
+
return 0 if argv else 2
|
|
35
|
+
if argv[0] == "--check":
|
|
36
|
+
ok, message = status_is_valid(Path(argv[1]))
|
|
37
|
+
print(message)
|
|
38
|
+
return 0 if ok else 1
|
|
39
|
+
if argv[0] == "--init":
|
|
40
|
+
return _init(argv[1:])
|
|
41
|
+
if argv[0] == "--organize":
|
|
42
|
+
return _organize(argv[1:])
|
|
43
|
+
if argv[0] == "--render":
|
|
44
|
+
return _render(argv[1:])
|
|
45
|
+
if argv[0] == "--render-legacy":
|
|
46
|
+
return _render_legacy(argv[1:])
|
|
47
|
+
if argv[0] == "--own":
|
|
48
|
+
return _own(argv[1:])
|
|
49
|
+
if argv[0] == "--startup":
|
|
50
|
+
return _startup(argv[1:])
|
|
51
|
+
if argv[0].startswith("--"):
|
|
52
|
+
print(USAGE)
|
|
53
|
+
return 2
|
|
54
|
+
compile_repo(Path(argv[0]))
|
|
55
|
+
print(f"compiled {argv[0]}")
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _init(argv: list[str]) -> int:
|
|
60
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
61
|
+
parser.add_argument("path")
|
|
62
|
+
parser.add_argument("--origin", required=True)
|
|
63
|
+
parser.add_argument("--role", default="general")
|
|
64
|
+
parser.add_argument("--today")
|
|
65
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
66
|
+
group.add_argument("--private", action="store_true")
|
|
67
|
+
group.add_argument("--public", action="store_true")
|
|
68
|
+
args = parser.parse_args(argv)
|
|
69
|
+
try:
|
|
70
|
+
init_memory_repo(
|
|
71
|
+
Path(args.path),
|
|
72
|
+
private=not args.public,
|
|
73
|
+
origin=args.origin,
|
|
74
|
+
role=args.role,
|
|
75
|
+
today=args.today,
|
|
76
|
+
)
|
|
77
|
+
except (PublicRepoRefused, ValueError) as exc:
|
|
78
|
+
print(str(exc))
|
|
79
|
+
return 1
|
|
80
|
+
print(f"initialized {args.path}")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _organize(argv: list[str]) -> int:
|
|
85
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
86
|
+
parser.add_argument("path")
|
|
87
|
+
parser.add_argument("--today")
|
|
88
|
+
args = parser.parse_args(argv)
|
|
89
|
+
today = _day(args.today) if args.today else None
|
|
90
|
+
organize_repo(Path(args.path), today)
|
|
91
|
+
print(f"organized {args.path}")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _render(argv: list[str]) -> int:
|
|
96
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
97
|
+
parser.add_argument("path")
|
|
98
|
+
parser.add_argument("--origin", required=True)
|
|
99
|
+
parser.add_argument("--out")
|
|
100
|
+
args = parser.parse_args(argv)
|
|
101
|
+
try:
|
|
102
|
+
text = render_package(Path(args.path), args.origin)
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
print(str(exc))
|
|
105
|
+
return 1
|
|
106
|
+
if args.out:
|
|
107
|
+
Path(args.out).write_text(text, encoding="utf-8")
|
|
108
|
+
print(args.out)
|
|
109
|
+
else:
|
|
110
|
+
print(text)
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _own(argv: list[str]) -> int:
|
|
115
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
116
|
+
parser.add_argument("path")
|
|
117
|
+
parser.add_argument("--scenario", required=True)
|
|
118
|
+
parser.add_argument("--origin", required=True)
|
|
119
|
+
args = parser.parse_args(argv)
|
|
120
|
+
try:
|
|
121
|
+
set_scenario_owner(Path(args.path), args.scenario, args.origin)
|
|
122
|
+
except ValueError as exc:
|
|
123
|
+
print(str(exc))
|
|
124
|
+
return 1
|
|
125
|
+
print(f"{args.origin} owns scenario.{args.scenario}")
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _startup(argv: list[str]) -> int:
|
|
130
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
131
|
+
parser.add_argument("path")
|
|
132
|
+
parser.add_argument("--origin", required=True)
|
|
133
|
+
args = parser.parse_args(argv)
|
|
134
|
+
try:
|
|
135
|
+
print(startup_read(Path(args.path), args.origin), end="")
|
|
136
|
+
except ValueError as exc:
|
|
137
|
+
print(str(exc))
|
|
138
|
+
return 1
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _render_legacy(argv: list[str]) -> int:
|
|
143
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
144
|
+
parser.add_argument("--out", required=True)
|
|
145
|
+
parser.add_argument("--mode", default="migration")
|
|
146
|
+
args = parser.parse_args(argv)
|
|
147
|
+
text = render_legacy_package(
|
|
148
|
+
[
|
|
149
|
+
{"section": "1. 这个人是谁(Identity)", "text": "称呼:示例", "id": "anchor-name"},
|
|
150
|
+
{"section": "1. 这个人是谁(Identity)", "text": "时区:Asia/Shanghai", "id": "anchor-tz"},
|
|
151
|
+
{"section": "5. 事实与记忆(Facts & memories)", "text": "已撤回:示例条目", "id": "pref-vim"},
|
|
152
|
+
],
|
|
153
|
+
mode=args.mode,
|
|
154
|
+
)
|
|
155
|
+
Path(args.out).write_text(text, encoding="utf-8")
|
|
156
|
+
print(args.out)
|
|
157
|
+
return 0
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Read a memory directory, compile views, and check status tips."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from topmind_handoff.model import (
|
|
9
|
+
SLICE_TITLE,
|
|
10
|
+
Card,
|
|
11
|
+
ClaimResult,
|
|
12
|
+
Compiled,
|
|
13
|
+
Op,
|
|
14
|
+
compile_ops,
|
|
15
|
+
compiler_version,
|
|
16
|
+
origin_markdown,
|
|
17
|
+
parse_authority,
|
|
18
|
+
scenario_filename,
|
|
19
|
+
parse_card,
|
|
20
|
+
parse_outbox,
|
|
21
|
+
scenario_names,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
VIEW_ORDER = ("identity", "boundaries", "preference", "project", "fact")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ensure_nl(text: str) -> str:
|
|
28
|
+
return text if text.endswith("\n") else text + "\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_pieces(repo: Path) -> tuple[dict[str, str], dict[str, str], dict[str, Card], list[Op]]:
|
|
32
|
+
authority_path = repo / "authority.md"
|
|
33
|
+
authority = parse_authority(authority_path.read_text(encoding="utf-8")) if authority_path.is_file() else {}
|
|
34
|
+
tips: dict[str, str] = {}
|
|
35
|
+
ops: list[Op] = []
|
|
36
|
+
outbox = repo / "outbox"
|
|
37
|
+
if outbox.is_dir():
|
|
38
|
+
for path in sorted(outbox.glob("*.md")):
|
|
39
|
+
file_ops = parse_outbox(path.read_text(encoding="utf-8"), path.stem)
|
|
40
|
+
tips[path.stem] = file_ops[-1].op_id if file_ops else ""
|
|
41
|
+
ops.extend(file_ops)
|
|
42
|
+
cards: dict[str, Card] = {}
|
|
43
|
+
environments = repo / "environments"
|
|
44
|
+
if environments.is_dir():
|
|
45
|
+
for path in sorted(environments.glob("*.md")):
|
|
46
|
+
cards[path.stem] = parse_card(path.read_text(encoding="utf-8"), path.stem)
|
|
47
|
+
return authority, tips, cards, ops
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_compiled(repo: Path) -> Compiled:
|
|
51
|
+
authority, tips, cards, ops = load_pieces(repo)
|
|
52
|
+
claims, proposals = compile_ops(ops, authority)
|
|
53
|
+
return Compiled(claims=claims, proposals=proposals, tips=tips, cards=cards, authority=authority)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fact_line(op: Op, suffix: str = "") -> str:
|
|
57
|
+
return f"- {op.text} {{id:{op.claim_id}}}{suffix}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _conflict_block(result: ClaimResult) -> list[str]:
|
|
61
|
+
lines = [f"- {{id:{result.claim_id}}} 未裁决"]
|
|
62
|
+
for head in result.heads:
|
|
63
|
+
when = head.at or "无日期"
|
|
64
|
+
label = "撤回" if head.kind == "retract" else head.text
|
|
65
|
+
lines.append(f" - {head.op_id}({when}):{label}")
|
|
66
|
+
return lines
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _section_lines(results: list[ClaimResult], *, inline_detail: bool) -> dict[str, list[str]]:
|
|
70
|
+
sections = {name: [] for name in VIEW_ORDER}
|
|
71
|
+
for result in sorted(results, key=lambda item: item.claim_id):
|
|
72
|
+
if result.conflict or result.retracted or not result.heads:
|
|
73
|
+
continue
|
|
74
|
+
head = result.heads[0]
|
|
75
|
+
if head.kind != "set":
|
|
76
|
+
continue
|
|
77
|
+
bucket = sections.setdefault(head.slice, [])
|
|
78
|
+
bucket.append(_fact_line(head))
|
|
79
|
+
if inline_detail and head.detail:
|
|
80
|
+
bucket.extend(f" {line}" for line in head.detail.splitlines())
|
|
81
|
+
return sections
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render_scope_view(title: str, results: list[ClaimResult], *, inline_detail: bool) -> str:
|
|
85
|
+
sections = _section_lines(results, inline_detail=inline_detail)
|
|
86
|
+
conflicts = [result for result in results if result.conflict]
|
|
87
|
+
conflicts.sort(key=lambda item: item.claim_id)
|
|
88
|
+
lines = [f"# {title}", ""]
|
|
89
|
+
for slice_name in VIEW_ORDER:
|
|
90
|
+
lines.append(f"## {SLICE_TITLE[slice_name]}")
|
|
91
|
+
body = sections.get(slice_name) or []
|
|
92
|
+
lines.extend(body or ["(无)"])
|
|
93
|
+
lines.append("")
|
|
94
|
+
lines.append("## 冲突")
|
|
95
|
+
if conflicts:
|
|
96
|
+
for result in conflicts:
|
|
97
|
+
lines.extend(_conflict_block(result))
|
|
98
|
+
else:
|
|
99
|
+
lines.append("(无)")
|
|
100
|
+
lines.append("")
|
|
101
|
+
return _ensure_nl("\n".join(lines))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def render_views(compiled: Compiled, *, inline_detail: bool = True) -> dict[str, str]:
|
|
105
|
+
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)}
|
|
107
|
+
names = scenario_names(compiled.authority, compiled.cards, _all_ops(compiled))
|
|
108
|
+
for name in names:
|
|
109
|
+
filename = scenario_filename(name)
|
|
110
|
+
if filename is None:
|
|
111
|
+
continue
|
|
112
|
+
scope = f"scenario:{name}"
|
|
113
|
+
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)
|
|
115
|
+
return rendered
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _all_ops(compiled: Compiled) -> list[Op]:
|
|
119
|
+
ops: list[Op] = []
|
|
120
|
+
for result in compiled.claims.values():
|
|
121
|
+
ops.extend(result.heads)
|
|
122
|
+
ops.extend(result.unapplied)
|
|
123
|
+
ops.extend(compiled.proposals)
|
|
124
|
+
return ops
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render_status(compiled: Compiled) -> str:
|
|
128
|
+
version = compiler_version()
|
|
129
|
+
lines = ["# STATUS", "", f"compiler: topmind-handoff {version}", "", "## compiled_from"]
|
|
130
|
+
if compiled.tips:
|
|
131
|
+
for origin in sorted(compiled.tips):
|
|
132
|
+
tip = compiled.tips[origin] or "-"
|
|
133
|
+
lines.append(f"- {origin}: {tip}")
|
|
134
|
+
else:
|
|
135
|
+
lines.append("(无)")
|
|
136
|
+
fact_count = sum(
|
|
137
|
+
1
|
|
138
|
+
for result in compiled.claims.values()
|
|
139
|
+
if len(result.heads) == 1 and result.heads[0].kind == "set"
|
|
140
|
+
)
|
|
141
|
+
conflict_count = sum(1 for result in compiled.claims.values() if result.conflict)
|
|
142
|
+
lines.extend(
|
|
143
|
+
[
|
|
144
|
+
"",
|
|
145
|
+
"## counts",
|
|
146
|
+
f"- facts: {fact_count}",
|
|
147
|
+
f"- conflicts: {conflict_count}",
|
|
148
|
+
f"- proposals: {len(compiled.proposals)}",
|
|
149
|
+
"",
|
|
150
|
+
"## conflicts",
|
|
151
|
+
]
|
|
152
|
+
)
|
|
153
|
+
conflicts = [result for result in compiled.claims.values() if result.conflict]
|
|
154
|
+
if conflicts:
|
|
155
|
+
for result in sorted(conflicts, key=lambda item: (item.scope, item.claim_id)):
|
|
156
|
+
heads = ", ".join(head.op_id for head in result.heads)
|
|
157
|
+
lines.append(f"- id: {result.claim_id}")
|
|
158
|
+
lines.append(f" scope: {result.scope}")
|
|
159
|
+
lines.append(f" heads: {heads}")
|
|
160
|
+
else:
|
|
161
|
+
lines.append("(无)")
|
|
162
|
+
lines.extend(["", "## proposals"])
|
|
163
|
+
if compiled.proposals:
|
|
164
|
+
for op in sorted(compiled.proposals, key=lambda item: item.op_id):
|
|
165
|
+
lines.append(f"- op_id: {op.op_id}")
|
|
166
|
+
lines.append(f" id: {op.claim_id}")
|
|
167
|
+
lines.append(f" scope: {op.scope}")
|
|
168
|
+
lines.append(f" origin: {op.origin}")
|
|
169
|
+
lines.append(f" op: {op.kind}")
|
|
170
|
+
lines.append(f" text: {op.text}")
|
|
171
|
+
else:
|
|
172
|
+
lines.append("(无)")
|
|
173
|
+
lines.extend(["", "## origins"])
|
|
174
|
+
origins = sorted(set(compiled.tips) | set(compiled.cards))
|
|
175
|
+
if origins:
|
|
176
|
+
for origin in origins:
|
|
177
|
+
card = compiled.cards.get(origin)
|
|
178
|
+
lines.append(f"- id: {origin}")
|
|
179
|
+
lines.append(f" tip: {compiled.tips.get(origin) or '-'}")
|
|
180
|
+
lines.append(f" refreshed: {card.refreshed if card else ''}")
|
|
181
|
+
lines.append(f" stale: {'true' if card and card.stale else 'false'}")
|
|
182
|
+
else:
|
|
183
|
+
lines.append("(无)")
|
|
184
|
+
lines.append("")
|
|
185
|
+
return _ensure_nl("\n".join(lines))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def compile_repo(repo: Path) -> Compiled:
|
|
189
|
+
"""Regenerate views and STATUS from outboxes. Existing view text is discarded."""
|
|
190
|
+
repo = Path(repo)
|
|
191
|
+
compiled = build_compiled(repo)
|
|
192
|
+
views = repo / "views"
|
|
193
|
+
views.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
rendered = render_views(compiled, inline_detail=True)
|
|
195
|
+
for path in views.glob("scenario-*.md"):
|
|
196
|
+
if path.name not in rendered:
|
|
197
|
+
path.unlink()
|
|
198
|
+
for name, text in rendered.items():
|
|
199
|
+
(views / name).write_text(text, encoding="utf-8")
|
|
200
|
+
(repo / "STATUS.md").write_text(render_status(compiled), encoding="utf-8")
|
|
201
|
+
return compiled
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _status_tips(text: str) -> dict[str, str]:
|
|
205
|
+
tips: dict[str, str] = {}
|
|
206
|
+
in_section = False
|
|
207
|
+
for line in text.splitlines():
|
|
208
|
+
if line.startswith("## "):
|
|
209
|
+
in_section = line.strip() == "## compiled_from"
|
|
210
|
+
continue
|
|
211
|
+
if not in_section:
|
|
212
|
+
continue
|
|
213
|
+
match = re_tip(line)
|
|
214
|
+
if match:
|
|
215
|
+
origin, tip = match
|
|
216
|
+
tips[origin] = "" if tip == "-" else tip
|
|
217
|
+
return tips
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def re_tip(line: str) -> tuple[str, str] | None:
|
|
221
|
+
match = re.match(r"^- ([^:]+):\s*(.*)$", line)
|
|
222
|
+
if not match:
|
|
223
|
+
return None
|
|
224
|
+
return match.group(1).strip(), match.group(2).strip()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def status_is_valid(repo: Path) -> tuple[bool, str]:
|
|
228
|
+
"""A status file is valid only when its tips equal the outbox tips."""
|
|
229
|
+
repo = Path(repo)
|
|
230
|
+
status_path = repo / "STATUS.md"
|
|
231
|
+
if not status_path.is_file():
|
|
232
|
+
return False, "STATUS.md 不存在,视图无效,需要重新编译"
|
|
233
|
+
_authority, tips, _cards, _ops = load_pieces(repo)
|
|
234
|
+
recorded = _status_tips(status_path.read_text(encoding="utf-8"))
|
|
235
|
+
if recorded != tips:
|
|
236
|
+
return False, "STATUS 的信箱末尾与 outbox 不一致,视图无效,需要重新编译"
|
|
237
|
+
return True, "STATUS 与信箱末尾一致"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def save_environment_card(repo: Path, actor: str, origin: str, content: str) -> Path:
|
|
241
|
+
"""Replace one origin's environment card. Other origins are refused."""
|
|
242
|
+
dest = origin_markdown(Path(repo) / "environments", origin)
|
|
243
|
+
origin = dest.name[: -len(".md")]
|
|
244
|
+
if actor != origin:
|
|
245
|
+
raise PermissionError(f"{actor} 只能替换自己的环境卡片 {origin}")
|
|
246
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
dest.write_text(_ensure_nl(content), encoding="utf-8")
|
|
248
|
+
return dest
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
_FACT_LINE = re.compile(r"^- (?P<body>.*)\{id:(?P<cid>[^}]+)\}(?P<suffix>\s*(未复核))?\s*$")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _override_line(line: str, overrides: dict[str, str]) -> str | None:
|
|
255
|
+
match = _FACT_LINE.match(line.rstrip())
|
|
256
|
+
if not match or match.group("cid") not in overrides:
|
|
257
|
+
return None
|
|
258
|
+
claim_id = match.group("cid")
|
|
259
|
+
suffix = match.group("suffix") or ""
|
|
260
|
+
return f"- {overrides[claim_id]} {{id:{claim_id}}}{suffix}"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _is_detail_line(line: str) -> bool:
|
|
264
|
+
return line.startswith(" ") or line.startswith("\t")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _apply_overrides(shared: str, overrides: dict[str, str]) -> str:
|
|
268
|
+
if not overrides:
|
|
269
|
+
return shared
|
|
270
|
+
source = shared.splitlines()
|
|
271
|
+
kept: list[str] = []
|
|
272
|
+
index = 0
|
|
273
|
+
while index < len(source):
|
|
274
|
+
replacement = _override_line(source[index], overrides)
|
|
275
|
+
if replacement is None:
|
|
276
|
+
kept.append(source[index])
|
|
277
|
+
index += 1
|
|
278
|
+
continue
|
|
279
|
+
kept.append(replacement)
|
|
280
|
+
index += 1
|
|
281
|
+
while index < len(source) and _is_detail_line(source[index]):
|
|
282
|
+
index += 1
|
|
283
|
+
return _ensure_nl("\n".join(kept))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
_FACT_POINTER = re.compile(r"^- 见 facts/(?P<name>[^/\s\\]+)\.md")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _path_inside(path: Path, directory: Path) -> bool:
|
|
290
|
+
try:
|
|
291
|
+
return path.resolve().parent == directory.resolve()
|
|
292
|
+
except OSError:
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _archived_override_lines(facts: Path, name: str, overrides: dict[str, str], seen: set[str]) -> list[str]:
|
|
297
|
+
"""Override lines from one facts archive. Other lines, including details, stay out."""
|
|
298
|
+
if not name or name in {".", ".."} or any(mark in name for mark in ("/", "\\", "\x00")):
|
|
299
|
+
return []
|
|
300
|
+
candidate = facts / f"{name}.md"
|
|
301
|
+
if not facts.is_dir() or not _path_inside(candidate, facts) or not candidate.is_file():
|
|
302
|
+
return []
|
|
303
|
+
found: list[str] = []
|
|
304
|
+
for archived in candidate.read_text(encoding="utf-8").splitlines():
|
|
305
|
+
replacement = _override_line(archived, overrides)
|
|
306
|
+
if replacement is None:
|
|
307
|
+
continue
|
|
308
|
+
match = _FACT_LINE.match(replacement.rstrip())
|
|
309
|
+
if match is None or match.group("cid") in seen:
|
|
310
|
+
continue
|
|
311
|
+
seen.add(match.group("cid"))
|
|
312
|
+
found.append(replacement)
|
|
313
|
+
return found
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _project_shared(repo: Path, shared: str, overrides: dict[str, str]) -> str:
|
|
317
|
+
"""Apply card overrides to the shared index and to due lines archived behind a pointer."""
|
|
318
|
+
projected = _apply_overrides(shared, overrides)
|
|
319
|
+
if not overrides:
|
|
320
|
+
return projected
|
|
321
|
+
seen: set[str] = set()
|
|
322
|
+
for line in projected.splitlines():
|
|
323
|
+
match = _FACT_LINE.match(line.rstrip())
|
|
324
|
+
if match and match.group("cid") in overrides:
|
|
325
|
+
seen.add(match.group("cid"))
|
|
326
|
+
facts = repo / "facts"
|
|
327
|
+
output: list[str] = []
|
|
328
|
+
for line in projected.splitlines():
|
|
329
|
+
output.append(line)
|
|
330
|
+
pointer = _FACT_POINTER.match(line.rstrip())
|
|
331
|
+
if pointer is None:
|
|
332
|
+
continue
|
|
333
|
+
output.extend(_archived_override_lines(facts, pointer.group("name"), overrides, seen))
|
|
334
|
+
return _ensure_nl("\n".join(output))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _conflicts_for_origin(compiled: Compiled, origin: str) -> str:
|
|
338
|
+
blocks: list[str] = []
|
|
339
|
+
for result in sorted(compiled.claims.values(), key=lambda item: (item.scope, item.claim_id)):
|
|
340
|
+
if not result.conflict:
|
|
341
|
+
continue
|
|
342
|
+
if any(head.origin == origin for head in result.heads):
|
|
343
|
+
blocks.extend(_conflict_block(result))
|
|
344
|
+
if not blocks:
|
|
345
|
+
return "(无)\n"
|
|
346
|
+
return _ensure_nl("\n".join(blocks))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _origin_projection(compiled: Compiled, origin: str) -> str:
|
|
350
|
+
"""Sets whose scope is this origin. Conflicts stay in the conflict section."""
|
|
351
|
+
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:
|
|
364
|
+
return ""
|
|
365
|
+
return "## 本环境范围\n\n" + "\n".join(lines) + "\n"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def startup_read(repo: Path, origin: str) -> str:
|
|
369
|
+
"""Shared view, this origin's scenario, its card, and conflicts that involve it.
|
|
370
|
+
|
|
371
|
+
Card overrides are applied only in this projection. views/shared.md is not rewritten.
|
|
372
|
+
"""
|
|
373
|
+
repo = Path(repo)
|
|
374
|
+
compiled = build_compiled(repo)
|
|
375
|
+
views = repo / "views"
|
|
376
|
+
shared_path = views / "shared.md"
|
|
377
|
+
if shared_path.is_file() and _path_inside(shared_path, views):
|
|
378
|
+
shared = shared_path.read_text(encoding="utf-8")
|
|
379
|
+
else:
|
|
380
|
+
shared = render_views(compiled)["shared.md"]
|
|
381
|
+
card = compiled.cards.get(origin)
|
|
382
|
+
overrides = card.overrides if card else {}
|
|
383
|
+
projected = _project_shared(repo, shared, overrides)
|
|
384
|
+
role = card.role if card else ""
|
|
385
|
+
filename = scenario_filename(role) if role else None
|
|
386
|
+
scenario_path = views / filename if filename else None
|
|
387
|
+
if scenario_path is not None and not _path_inside(scenario_path, views):
|
|
388
|
+
scenario_path = None
|
|
389
|
+
if scenario_path is not None and scenario_path.is_file():
|
|
390
|
+
scenario = scenario_path.read_text(encoding="utf-8")
|
|
391
|
+
elif filename:
|
|
392
|
+
scenario = render_views(compiled).get(filename, "# 场景\n\n(无)\n")
|
|
393
|
+
else:
|
|
394
|
+
scenario = "(无)\n"
|
|
395
|
+
card_text = card.raw if card else "(无)\n"
|
|
396
|
+
origin_text = _origin_projection(compiled, origin).rstrip("\n")
|
|
397
|
+
parts = [
|
|
398
|
+
f"# 开工读取({origin})",
|
|
399
|
+
"",
|
|
400
|
+
"## 冲突(与本环境有关)",
|
|
401
|
+
_conflicts_for_origin(compiled, origin).rstrip("\n"),
|
|
402
|
+
"",
|
|
403
|
+
"## 共享",
|
|
404
|
+
projected.rstrip("\n"),
|
|
405
|
+
"",
|
|
406
|
+
f"## 场景:{role or '无'}",
|
|
407
|
+
scenario.rstrip("\n"),
|
|
408
|
+
"",
|
|
409
|
+
]
|
|
410
|
+
if origin_text:
|
|
411
|
+
parts.extend([origin_text, ""])
|
|
412
|
+
parts.extend(["## 环境卡片", card_text.rstrip("\n"), ""])
|
|
413
|
+
return _ensure_nl("\n".join(parts))
|