owfadev 0.0.1 → 0.3.1
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/.agents/skills/asset-read/SKILL.md +39 -0
- package/.agents/skills/check/SKILL.md +130 -0
- package/.agents/skills/docs/SKILL.md +24 -0
- package/.agents/skills/map/SKILL.md +72 -0
- package/.agents/skills/plugin/SKILL.md +74 -0
- package/.agents/skills/profile/SKILL.md +75 -0
- package/.agents/skills/query/SKILL.md +48 -0
- package/.agents/skills/save/SKILL.md +94 -0
- package/.agents/skills/wiki_plugin_kernel/SKILL.md +104 -0
- package/.meta/VERSION +1 -0
- package/.meta/command/asset-read/SKILL.md +39 -0
- package/.meta/command/check/SKILL.md +130 -0
- package/.meta/command/docs/SKILL.md +24 -0
- package/.meta/command/map/SKILL.md +72 -0
- package/.meta/command/plugin/SKILL.md +74 -0
- package/.meta/command/profile/SKILL.md +75 -0
- package/.meta/command/query/SKILL.md +48 -0
- package/.meta/command/save/SKILL.md +94 -0
- package/.meta/command/wiki_plugin_kernel/SKILL.md +104 -0
- package/.meta/docs/agent-behavior.md +68 -0
- package/.meta/docs/agents-example.md +34 -0
- package/.meta/docs/conversion.md +24 -0
- package/.meta/docs/deployment.md +311 -0
- package/.meta/docs/developer.md +33 -0
- package/.meta/docs/distribution.md +132 -0
- package/.meta/docs/intro.md +56 -0
- package/.meta/docs/log-example.md +19 -0
- package/.meta/docs/map.md +23 -0
- package/.meta/docs/mechanics.md +180 -0
- package/.meta/docs/naming.md +113 -0
- package/.meta/docs/packages.md +140 -0
- package/.meta/docs/quickstart.md +79 -0
- package/.meta/docs/research-landscape.md +132 -0
- package/.meta/docs/research-user-profile.md +27 -0
- package/.meta/docs/sandbox.md +40 -0
- package/.meta/docs/usage.md +45 -0
- package/.meta/plugins/core/calendar/PLUGIN.md +35 -0
- package/.meta/plugins/core/calendar/PLUGIN.yaml +21 -0
- package/.meta/plugins/core/cron/PLUGIN.md +38 -0
- package/.meta/plugins/core/cron/PLUGIN.yaml +24 -0
- package/.meta/plugins/core/device/PLUGIN.md +38 -0
- package/.meta/plugins/core/device/PLUGIN.yaml +21 -0
- package/.meta/plugins/core/device/scripts/check.py +29 -0
- package/.meta/plugins/core/domain/PLUGIN.md +43 -0
- package/.meta/plugins/core/domain/PLUGIN.yaml +8 -0
- package/.meta/plugins/core/hot/PLUGIN.md +40 -0
- package/.meta/plugins/core/hot/PLUGIN.yaml +15 -0
- package/.meta/plugins/core/index/PLUGIN.md +39 -0
- package/.meta/plugins/core/index/PLUGIN.yaml +18 -0
- package/.meta/plugins/core/instance-git/PLUGIN.md +39 -0
- package/.meta/plugins/core/instance-git/PLUGIN.yaml +15 -0
- package/.meta/plugins/core/instance-git/scripts/check.py +90 -0
- package/.meta/plugins/core/language/PLUGIN.md +38 -0
- package/.meta/plugins/core/language/PLUGIN.yaml +23 -0
- package/.meta/plugins/core/language/scripts/check.py +23 -0
- package/.meta/plugins/core/link/PLUGIN.md +39 -0
- package/.meta/plugins/core/link/PLUGIN.yaml +16 -0
- package/.meta/plugins/core/link/scripts/check.py +122 -0
- package/.meta/plugins/core/log/PLUGIN.md +58 -0
- package/.meta/plugins/core/log/PLUGIN.yaml +18 -0
- package/.meta/plugins/core/log/scripts/check.py +21 -0
- package/.meta/plugins/core/mapping/PLUGIN.md +32 -0
- package/.meta/plugins/core/mapping/PLUGIN.yaml +24 -0
- package/.meta/plugins/core/mapping/scripts/check.py +90 -0
- package/.meta/plugins/core/notes/PLUGIN.md +42 -0
- package/.meta/plugins/core/notes/PLUGIN.yaml +19 -0
- package/.meta/plugins/core/sessions/PLUGIN.md +63 -0
- package/.meta/plugins/core/sessions/PLUGIN.yaml +20 -0
- package/.meta/plugins/core/sessions/scripts/check.py +33 -0
- package/.meta/plugins/core/structure/PLUGIN.md +29 -0
- package/.meta/plugins/core/structure/PLUGIN.yaml +18 -0
- package/.meta/plugins/core/structure/scripts/check.py +32 -0
- package/.meta/plugins/core/tag/PLUGIN.md +37 -0
- package/.meta/plugins/core/tag/PLUGIN.yaml +18 -0
- package/.meta/plugins/core/tag/scripts/check.py +28 -0
- package/.meta/plugins/core/tmp/PLUGIN.md +26 -0
- package/.meta/plugins/core/tmp/PLUGIN.yaml +16 -0
- package/.meta/plugins/core/todo/PLUGIN.md +33 -0
- package/.meta/plugins/core/todo/PLUGIN.yaml +22 -0
- package/.meta/plugins/core/trust/PLUGIN.md +70 -0
- package/.meta/plugins/core/trust/PLUGIN.yaml +23 -0
- package/.meta/plugins/core/trust/scripts/check.py +77 -0
- package/.meta/plugins/core/user-profile/PLUGIN.md +39 -0
- package/.meta/plugins/core/user-profile/PLUGIN.yaml +30 -0
- package/.meta/plugins/core/vault/PLUGIN.md +28 -0
- package/.meta/plugins/core/vault/PLUGIN.yaml +13 -0
- package/.meta/plugins/core/wiki/PLUGIN.md +37 -0
- package/.meta/plugins/core/wiki/PLUGIN.yaml +8 -0
- package/.meta/protocol/actions.md +42 -0
- package/.meta/protocol/experiments.md +40 -0
- package/.meta/protocol/registry.yaml +75 -0
- package/.meta/scripts/pipeline.py +428 -0
- package/.meta/scripts/wiki_plugin_kernel.py +1891 -0
- package/.meta/scripts/wikilib.py +79 -0
- package/.meta/tests/test_install_preflight.py +177 -0
- package/LICENSE +21 -21
- package/README.md +96 -15
- package/README.zh-CN.md +103 -0
- package/bin/owfa.js +594 -0
- package/package.json +36 -7
- package/templates/AGENTS.template.md +19 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Derived-layer post-write pipeline: index / tags / hot / log / verify.
|
|
4
|
+
|
|
5
|
+
Positioning (.meta/protocol/actions.md execution principles): deterministic structural
|
|
6
|
+
operations go to this script; semantic judgment goes to the LLM. Rebuild and rolling
|
|
7
|
+
rules and window parameters are governed by this file's source (the script source is
|
|
8
|
+
the rule list); PLUGIN.md keeps the semantic explanation; commands uniformly end by
|
|
9
|
+
invoking this pipeline instead of hand-writing derived pages themselves. verify is
|
|
10
|
+
post-write self-attestation (the minimal attester form): the LLM executes, the script
|
|
11
|
+
certifies.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
python .meta/scripts/pipeline.py index # rebuild indexes (overflow-offloading scheme, idempotent; includes deleting surplus indexes after merging back)
|
|
15
|
+
python .meta/scripts/pipeline.py tags # rebuild wiki/tags.md (idempotent)
|
|
16
|
+
python .meta/scripts/pipeline.py hot <type> <one-liner> # evict out-of-window entries + prepend a hot-cache entry
|
|
17
|
+
python .meta/scripts/pipeline.py log <type> <one-liner> [--domain domain] # capacity archive + prepend a log line (domain tag optional — domainless transactions)
|
|
18
|
+
python .meta/scripts/pipeline.py verify # post-write self-attestation: attached audits + derived-area drift check
|
|
19
|
+
|
|
20
|
+
Type enumeration: map / save / query / check / plugin / todo / profile / other (the log plugin).
|
|
21
|
+
Concept-page determination (what enters the index): all .md under wiki/, excluding —
|
|
22
|
+
reserved names (index.md, log.md), wiki-root derived pages (hot.md, tags.md), the
|
|
23
|
+
archive/ subtree (immutable region, never rewritten by this script), and the tmp/
|
|
24
|
+
subtree (temporary zone: invisible to the derived layer, no retention promise, see the
|
|
25
|
+
tmp plugin). Index overflow-offloading scheme: a single index list holds at most
|
|
26
|
+
INDEX_MAX_ENTRIES entries (page entries + directory entries); the root index always
|
|
27
|
+
exists; on overflow, split off the subdirectories with the most pages (ties by name)
|
|
28
|
+
into their own indexes until it fits; pure-function rebuild (same structure, same result).
|
|
29
|
+
"""
|
|
30
|
+
import datetime
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
37
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
38
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
41
|
+
import wikilib
|
|
42
|
+
|
|
43
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
44
|
+
WIKI = os.path.join(ROOT, "wiki")
|
|
45
|
+
FORMAT_VERSION = "0.2"
|
|
46
|
+
DESC_MAX = 80
|
|
47
|
+
ROOT_RESERVED = {"hot.md", "tags.md"} # wiki-root derived pages (log.md/index.md are reserved names, excluded uniformly)
|
|
48
|
+
|
|
49
|
+
# rolling-window parameters (mechanical authoritative source for the hot / log plugins; PLUGIN.md keeps the semantic explanation)
|
|
50
|
+
HOT_MAX_ENTRIES = 25
|
|
51
|
+
HOT_MAX_DAYS = 5
|
|
52
|
+
HOT_MAX_CHARS = 200
|
|
53
|
+
LOG_MAX_ENTRIES = 100
|
|
54
|
+
INDEX_MAX_ENTRIES = 25 # single index-list window (page entries + directory entries; overflow-offloading split basis)
|
|
55
|
+
TYPE_ORDER = ["map", "save", "query", "check", "plugin", "todo", "profile", "other"]
|
|
56
|
+
HOT_HEADER = ("# 热缓存\n\n> 最近变更摘要;≤25 条且 <5 日,窗外即删;可整体再生。"
|
|
57
|
+
"规则见 `.meta/plugins/hot/`,写走 `pipeline.py hot`。\n")
|
|
58
|
+
LOG_HEADER = ("# 运行日志\n\n> 置顶追加、只增不删;条目 = `- YYYY-MM-DD <类型> [<域>]:一句话`(域标可缺省——无域事务,log 桥)。"
|
|
59
|
+
"规则见 `.meta/plugins/log/`,写走 `pipeline.py log`。\n")
|
|
60
|
+
DATE_RE = re.compile(r"^- (\d{4}-\d{2}-\d{2}) ")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _today():
|
|
64
|
+
return datetime.date.today().isoformat()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------- concept pages and index / tags ----------
|
|
68
|
+
|
|
69
|
+
def _cut(text):
|
|
70
|
+
return text if len(text) <= DESC_MAX else text[: DESC_MAX - 1] + "…"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _desc(fm, body):
|
|
74
|
+
d = fm.get("description")
|
|
75
|
+
if d:
|
|
76
|
+
return _cut(str(d))
|
|
77
|
+
for line in body.splitlines():
|
|
78
|
+
t = line.strip()
|
|
79
|
+
if t and not t.startswith(("#", ">", "|", "```")):
|
|
80
|
+
return _cut(t)
|
|
81
|
+
return "(无描述)"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def concept_pages():
|
|
85
|
+
"""[(page full name = path minus .md, containing dir, frontmatter, description)], sorted by full name."""
|
|
86
|
+
out = []
|
|
87
|
+
for rel, fm, body in wikilib.walk_pages(ROOT):
|
|
88
|
+
d, _, fn = rel.rpartition("/")
|
|
89
|
+
if fn in ("index.md", "log.md"):
|
|
90
|
+
continue # reserved names: not concept pages
|
|
91
|
+
if d == "" and fn in ROOT_RESERVED:
|
|
92
|
+
continue
|
|
93
|
+
if rel.startswith("archive/") or d.startswith("archive/") or d == "archive":
|
|
94
|
+
continue
|
|
95
|
+
if rel.startswith("tmp/") or d.startswith("tmp/") or d == "tmp":
|
|
96
|
+
continue # temporary zone: invisible to the derived layer
|
|
97
|
+
out.append((rel[:-3], d, fm, _desc(fm, body)))
|
|
98
|
+
return sorted(out)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def all_dirs(pages):
|
|
102
|
+
"""The set of directories under wiki/ that should get an index (including empty ones; excluding the archive/ subtree)."""
|
|
103
|
+
dirs = {""}
|
|
104
|
+
for _, d, _, _ in pages:
|
|
105
|
+
parts = d.split("/") if d else []
|
|
106
|
+
for i in range(len(parts) + 1):
|
|
107
|
+
dirs.add("/".join(parts[:i]))
|
|
108
|
+
for dirpath, subdirs, _files in os.walk(WIKI):
|
|
109
|
+
if "archive" in subdirs:
|
|
110
|
+
subdirs.remove("archive") # do not descend into the immutable region
|
|
111
|
+
if "tmp" in subdirs:
|
|
112
|
+
subdirs.remove("tmp") # no index for the temporary zone
|
|
113
|
+
rel = os.path.relpath(dirpath, WIKI).replace(os.sep, "/")
|
|
114
|
+
dirs.add("" if rel == "." else rel)
|
|
115
|
+
return dirs
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def subdirs_of(d, dirs):
|
|
119
|
+
"""Direct subdirectory names of d (sorted)."""
|
|
120
|
+
prefix = d + "/" if d else ""
|
|
121
|
+
depth = d.count("/") + 1 if d else 0
|
|
122
|
+
return sorted(x[len(prefix):] for x in dirs
|
|
123
|
+
if x != d and x.startswith(prefix) and x.count("/") == depth)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _count_subtree(full, pages):
|
|
127
|
+
return sum(1 for name, d, _, _ in pages if d == full or d.startswith(full + "/"))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def plan_index_dirs(pages, dirs):
|
|
131
|
+
"""Overflow-offloading plan: returns the set of directories that should get an index.md (the root always present).
|
|
132
|
+
|
|
133
|
+
A directory's list = its own concept pages + pages of un-split subtrees (transparent
|
|
134
|
+
inlining) + directory entry lines; on overflow, split off the subdirectories with the
|
|
135
|
+
most pages (ties by name) into their own indexes until it fits. A split saves at least
|
|
136
|
+
S-1 lines, so a subdirectory with S<2 is never split; an empty subtree (S=0) costs
|
|
137
|
+
one inline line (the visibility line). Pure function: same structure guarantees the
|
|
138
|
+
same result, no historical state.
|
|
139
|
+
"""
|
|
140
|
+
own = {}
|
|
141
|
+
for _, d, _, _ in pages:
|
|
142
|
+
own[d] = own.get(d, 0) + 1
|
|
143
|
+
|
|
144
|
+
planned = set()
|
|
145
|
+
|
|
146
|
+
def plan(d):
|
|
147
|
+
planned.add(d)
|
|
148
|
+
kids = subdirs_of(d, dirs)
|
|
149
|
+
sizes = {c: _count_subtree((d + "/" + c) if d else c, pages) for c in kids}
|
|
150
|
+
total = own.get(d, 0) + sum(s if s > 0 else 1 for s in sizes.values())
|
|
151
|
+
if total <= INDEX_MAX_ENTRIES:
|
|
152
|
+
return
|
|
153
|
+
for c in sorted((k for k in kids if sizes[k] >= 2),
|
|
154
|
+
key=lambda c: (-sizes[c], c)):
|
|
155
|
+
if total <= INDEX_MAX_ENTRIES:
|
|
156
|
+
break
|
|
157
|
+
plan((d + "/" + c) if d else c)
|
|
158
|
+
total -= sizes[c] - 1
|
|
159
|
+
|
|
160
|
+
plan("")
|
|
161
|
+
return planned
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def visible_pages(d, pages, planned):
|
|
165
|
+
"""Pages listed directly in d's index: this directory's pages + all descendant pages whose path crosses no split directory (transparent inlining)."""
|
|
166
|
+
pre = d + "/" if d else ""
|
|
167
|
+
out = []
|
|
168
|
+
for name, dd, fm, desc in pages:
|
|
169
|
+
if dd == d:
|
|
170
|
+
out.append((name, fm, desc))
|
|
171
|
+
elif dd.startswith(pre):
|
|
172
|
+
parts = dd[len(pre):].split("/")
|
|
173
|
+
if all((pre + "/".join(parts[:i])) not in planned for i in range(1, len(parts) + 1)):
|
|
174
|
+
out.append((name, fm, desc))
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def render_index(d, pages, dirs, planned):
|
|
179
|
+
"""Full text of directory d's index.md (overflow-offloading scheme: reachable pages listed directly; split subdirectories as entry lines)."""
|
|
180
|
+
is_root = d == ""
|
|
181
|
+
lines = []
|
|
182
|
+
if is_root:
|
|
183
|
+
lines += ["---", f"format_version: {FORMAT_VERSION}", "---", ""]
|
|
184
|
+
lines.append("# 索引" if is_root else f"# {d.rsplit('/', 1)[-1]} 索引")
|
|
185
|
+
lines.append("")
|
|
186
|
+
tip = ("溢出减负:本页直列可达页面;清单超窗时子目录自立索引(入口行带页数),下钻读之。"
|
|
187
|
+
if is_root else "本目录索引:直列未切子树页面,已切子目录见入口行。")
|
|
188
|
+
lines.append(f"> {tip}只聚合、永不手编,由 index 插件经 `pipeline.py index` 重建。")
|
|
189
|
+
lines.append("")
|
|
190
|
+
entries = []
|
|
191
|
+
for s in subdirs_of(d, dirs):
|
|
192
|
+
full = (d + "/" + s) if d else s
|
|
193
|
+
n = _count_subtree(full, pages)
|
|
194
|
+
if full in planned:
|
|
195
|
+
entries.append(f"- [[{full}/index|{s}/]]({n} 页)")
|
|
196
|
+
elif n == 0:
|
|
197
|
+
entries.append(f"- {s}/(0 页)") # empty-subtree visibility line: no index to link, excluded from the broken-link graph
|
|
198
|
+
if entries:
|
|
199
|
+
lines += ["## 子目录", ""] + entries + [""]
|
|
200
|
+
groups = {}
|
|
201
|
+
for name, fm, desc in visible_pages(d, pages, planned):
|
|
202
|
+
groups.setdefault(str(fm.get("type") or "未分类"), []).append((name, desc))
|
|
203
|
+
for t in sorted(groups):
|
|
204
|
+
lines.append(f"## {t}")
|
|
205
|
+
lines.append("")
|
|
206
|
+
for name, desc in sorted(groups[t]):
|
|
207
|
+
lines.append(f"- [[{name}]] —— {desc}")
|
|
208
|
+
lines.append("")
|
|
209
|
+
if not entries and not groups:
|
|
210
|
+
lines.append("(暂无页面)")
|
|
211
|
+
lines.append("")
|
|
212
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def render_tags(pages):
|
|
216
|
+
tag_map = {}
|
|
217
|
+
for name, d, fm, _ in pages:
|
|
218
|
+
tags = fm.get("tags")
|
|
219
|
+
if tags is None:
|
|
220
|
+
continue
|
|
221
|
+
if not isinstance(tags, list):
|
|
222
|
+
tags = [tags]
|
|
223
|
+
for t in tags:
|
|
224
|
+
tag_map.setdefault(str(t), []).append(name)
|
|
225
|
+
lines = ["# tag 索引", "",
|
|
226
|
+
"> tag → 页面反向索引;只聚合、永不手编,由 index 插件经 `pipeline.py tags` 重建。", ""]
|
|
227
|
+
if not tag_map:
|
|
228
|
+
lines.append("(暂无)")
|
|
229
|
+
lines.append("")
|
|
230
|
+
for t in sorted(tag_map):
|
|
231
|
+
lines.append(f"## {t}")
|
|
232
|
+
lines.append("")
|
|
233
|
+
for name in sorted(tag_map[t]):
|
|
234
|
+
lines.append(f"- [[{name}]]")
|
|
235
|
+
lines.append("")
|
|
236
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def index_targets():
|
|
240
|
+
pages = concept_pages()
|
|
241
|
+
dirs = all_dirs(pages)
|
|
242
|
+
planned = plan_index_dirs(pages, dirs)
|
|
243
|
+
targets = {(d + "/" if d else "") + "index.md": render_index(d, pages, dirs, planned)
|
|
244
|
+
for d in planned}
|
|
245
|
+
return planned, targets
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def write_changed(path, content):
|
|
249
|
+
if os.path.exists(path) and open(path, encoding="utf-8").read() == content:
|
|
250
|
+
return False
|
|
251
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
252
|
+
open(path, "w", encoding="utf-8", newline="\n").write(content)
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def do_index(check_only=False):
|
|
257
|
+
_, targets = index_targets()
|
|
258
|
+
drift, changed = [], 0
|
|
259
|
+
for rel, content in sorted(targets.items()):
|
|
260
|
+
path = os.path.join(WIKI, rel)
|
|
261
|
+
cur = open(path, encoding="utf-8").read() if os.path.exists(path) else None
|
|
262
|
+
if cur == content:
|
|
263
|
+
continue
|
|
264
|
+
if check_only:
|
|
265
|
+
drift.append(rel)
|
|
266
|
+
elif write_changed(path, content):
|
|
267
|
+
changed += 1
|
|
268
|
+
print(f"[index] rebuilt wiki/{rel}")
|
|
269
|
+
# unplanned index.md files (old indexes after offloading merges back): delete / report drift; archive/ and tmp/ subtrees untouched
|
|
270
|
+
for dirpath, subdirs, files in os.walk(WIKI):
|
|
271
|
+
if "archive" in subdirs:
|
|
272
|
+
subdirs.remove("archive") # do not descend into the immutable region
|
|
273
|
+
if "tmp" in subdirs:
|
|
274
|
+
subdirs.remove("tmp") # the temporary zone is outside this pipeline's jurisdiction
|
|
275
|
+
if "index.md" not in files:
|
|
276
|
+
continue
|
|
277
|
+
rel = os.path.relpath(os.path.join(dirpath, "index.md"), WIKI).replace(os.sep, "/")
|
|
278
|
+
if rel not in targets:
|
|
279
|
+
if check_only:
|
|
280
|
+
drift.append(rel)
|
|
281
|
+
else:
|
|
282
|
+
os.remove(os.path.join(WIKI, rel))
|
|
283
|
+
changed += 1
|
|
284
|
+
print(f"[index] removed wiki/{rel} (merged back into parent)")
|
|
285
|
+
if not check_only:
|
|
286
|
+
print(f"[index] done, {changed} file(s) changed (idempotent)")
|
|
287
|
+
return drift
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def do_tags(check_only=False):
|
|
291
|
+
path = os.path.join(WIKI, "tags.md")
|
|
292
|
+
content = render_tags(concept_pages())
|
|
293
|
+
cur = open(path, encoding="utf-8").read() if os.path.exists(path) else None
|
|
294
|
+
if cur == content:
|
|
295
|
+
if not check_only:
|
|
296
|
+
print("[tags] unchanged")
|
|
297
|
+
return []
|
|
298
|
+
if check_only:
|
|
299
|
+
return ["tags.md"]
|
|
300
|
+
write_changed(path, content)
|
|
301
|
+
print("[tags] rebuilt wiki/tags.md")
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ---------- hot / log rolling ----------
|
|
306
|
+
|
|
307
|
+
def do_hot(kind, text):
|
|
308
|
+
if kind not in TYPE_ORDER:
|
|
309
|
+
print(f"[hot] type must be one of {'/'.join(TYPE_ORDER)} (got {kind})")
|
|
310
|
+
return False
|
|
311
|
+
entry = f"- {_today()} {text}"[:HOT_MAX_CHARS]
|
|
312
|
+
# collect existing entries (with section membership), mechanically evict out-of-window ones
|
|
313
|
+
sections = {t: [] for t in TYPE_ORDER}
|
|
314
|
+
hot_path = os.path.join(WIKI, "hot.md")
|
|
315
|
+
if os.path.exists(hot_path):
|
|
316
|
+
cur = None
|
|
317
|
+
for raw in open(hot_path, encoding="utf-8").read().splitlines():
|
|
318
|
+
m = re.match(r"^##\s*Recent (\S+)", raw)
|
|
319
|
+
if m and m.group(1) in TYPE_ORDER:
|
|
320
|
+
cur = m.group(1)
|
|
321
|
+
continue
|
|
322
|
+
dm = DATE_RE.match(raw)
|
|
323
|
+
if dm and cur:
|
|
324
|
+
if (datetime.date.today() - datetime.date.fromisoformat(dm.group(1))).days < HOT_MAX_DAYS:
|
|
325
|
+
sections[cur].append(raw[:HOT_MAX_CHARS])
|
|
326
|
+
sections[kind].insert(0, entry) # prepend
|
|
327
|
+
flat = [e for t in TYPE_ORDER for e in sections[t]]
|
|
328
|
+
if len(flat) > HOT_MAX_ENTRIES: # overall overflow: keep the 25 most recent by date uniformly (stable sort, same-day keeps section order)
|
|
329
|
+
keep = set(sorted(flat, key=lambda e: e[2:12], reverse=True)[:HOT_MAX_ENTRIES])
|
|
330
|
+
for t in TYPE_ORDER:
|
|
331
|
+
sections[t] = [e for e in sections[t] if e in keep]
|
|
332
|
+
body = [HOT_HEADER.rstrip()]
|
|
333
|
+
any_entry = False
|
|
334
|
+
for t in TYPE_ORDER:
|
|
335
|
+
if not sections[t]:
|
|
336
|
+
continue
|
|
337
|
+
any_entry = True
|
|
338
|
+
body += ["", f"## Recent {t}", ""] + sections[t]
|
|
339
|
+
if not any_entry:
|
|
340
|
+
body += ["", "(暂无)"]
|
|
341
|
+
write_changed(hot_path, "\n".join(body) + "\n")
|
|
342
|
+
print(f"[hot] written ({kind}), {min(len(flat), HOT_MAX_ENTRIES)} entries")
|
|
343
|
+
return True
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _split_domain(args):
|
|
347
|
+
"""Extract --domain <domain> / --domain=<domain> from the argument list (the log bridge's domain tag); the remaining args are returned in order."""
|
|
348
|
+
dom, rest, i = None, [], 0
|
|
349
|
+
while i < len(args):
|
|
350
|
+
if args[i] == "--domain" and i + 1 < len(args):
|
|
351
|
+
dom = args[i + 1]
|
|
352
|
+
i += 2
|
|
353
|
+
continue
|
|
354
|
+
if args[i].startswith("--domain="):
|
|
355
|
+
dom = args[i].split("=", 1)[1]
|
|
356
|
+
i += 1
|
|
357
|
+
continue
|
|
358
|
+
rest.append(args[i])
|
|
359
|
+
i += 1
|
|
360
|
+
return dom, rest
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def do_log(kind, text, domain=None):
|
|
364
|
+
if kind not in TYPE_ORDER:
|
|
365
|
+
print(f"[log] type must be one of {'/'.join(TYPE_ORDER)} (got {kind})")
|
|
366
|
+
return False
|
|
367
|
+
log_path = os.path.join(WIKI, "log.md")
|
|
368
|
+
entries = []
|
|
369
|
+
if os.path.exists(log_path):
|
|
370
|
+
for raw in open(log_path, encoding="utf-8").read().splitlines():
|
|
371
|
+
if DATE_RE.match(raw):
|
|
372
|
+
entries.append(raw)
|
|
373
|
+
tag = f" [{domain}]" if domain else ""
|
|
374
|
+
entries.insert(0, f"- {_today()} {kind}{tag}:{text}") # prepend; existing entries are never rewritten
|
|
375
|
+
overflow = entries[LOG_MAX_ENTRIES:]
|
|
376
|
+
if overflow: # window overflow: the oldest stretch is grouped by entry month into archive/<month>/log.md (position move only)
|
|
377
|
+
for month in sorted({e[2:9] for e in overflow}):
|
|
378
|
+
moved = [e for e in overflow if e[2:9] == month]
|
|
379
|
+
adir = os.path.join(WIKI, "archive", month)
|
|
380
|
+
apath = os.path.join(adir, "log.md")
|
|
381
|
+
old = open(apath, encoding="utf-8").read() if os.path.exists(apath) else f"# 归档 {month}\n"
|
|
382
|
+
write_changed(apath, old.rstrip() + "\n" + "\n".join(moved) + "\n")
|
|
383
|
+
print(f"[log] archived {len(moved)} entries to wiki/archive/{month}/log.md")
|
|
384
|
+
write_changed(log_path, LOG_HEADER + "\n" + "\n".join(entries[:LOG_MAX_ENTRIES]) + "\n")
|
|
385
|
+
print(f"[log] written ({kind}), main file {min(len(entries), LOG_MAX_ENTRIES)} entries")
|
|
386
|
+
return True
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ---------- post-write self-attestation ----------
|
|
390
|
+
|
|
391
|
+
def do_verify():
|
|
392
|
+
ok = True
|
|
393
|
+
r = subprocess.run(
|
|
394
|
+
[sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), "wiki_plugin_kernel.py"), "audit"])
|
|
395
|
+
if r.returncode != 0:
|
|
396
|
+
ok = False
|
|
397
|
+
print("[verify] plugin audit has errors")
|
|
398
|
+
drift = do_index(check_only=True) + do_tags(check_only=True)
|
|
399
|
+
if drift:
|
|
400
|
+
ok = False
|
|
401
|
+
print("[verify] derived drift: " + ", ".join("wiki/" + d for d in drift) + " (run pipeline.py index / tags to fix)")
|
|
402
|
+
else:
|
|
403
|
+
print("[verify] derived area consistent")
|
|
404
|
+
print("[verify] " + ("passed" if ok else "failed"))
|
|
405
|
+
return 0 if ok else 1
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def main():
|
|
409
|
+
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
410
|
+
if cmd == "index":
|
|
411
|
+
do_index()
|
|
412
|
+
return 0
|
|
413
|
+
if cmd == "tags":
|
|
414
|
+
do_tags()
|
|
415
|
+
return 0
|
|
416
|
+
if cmd == "hot":
|
|
417
|
+
return 0 if do_hot(sys.argv[2], " ".join(sys.argv[3:])) else 1
|
|
418
|
+
if cmd == "log":
|
|
419
|
+
dom, rest = _split_domain(sys.argv[3:])
|
|
420
|
+
return 0 if do_log(sys.argv[2], " ".join(rest), domain=dom) else 1
|
|
421
|
+
if cmd == "verify":
|
|
422
|
+
return do_verify()
|
|
423
|
+
print(__doc__)
|
|
424
|
+
return 2
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == "__main__":
|
|
428
|
+
sys.exit(main())
|