owfadev 0.0.0-stage → 0.3.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.
Files changed (99) hide show
  1. package/.agents/skills/asset-read/SKILL.md +39 -0
  2. package/.agents/skills/check/SKILL.md +130 -0
  3. package/.agents/skills/docs/SKILL.md +24 -0
  4. package/.agents/skills/map/SKILL.md +72 -0
  5. package/.agents/skills/plugin/SKILL.md +74 -0
  6. package/.agents/skills/profile/SKILL.md +75 -0
  7. package/.agents/skills/query/SKILL.md +48 -0
  8. package/.agents/skills/save/SKILL.md +94 -0
  9. package/.agents/skills/wiki_plugin_kernel/SKILL.md +104 -0
  10. package/.meta/VERSION +1 -0
  11. package/.meta/command/asset-read/SKILL.md +39 -0
  12. package/.meta/command/check/SKILL.md +130 -0
  13. package/.meta/command/docs/SKILL.md +24 -0
  14. package/.meta/command/map/SKILL.md +72 -0
  15. package/.meta/command/plugin/SKILL.md +74 -0
  16. package/.meta/command/profile/SKILL.md +75 -0
  17. package/.meta/command/query/SKILL.md +48 -0
  18. package/.meta/command/save/SKILL.md +94 -0
  19. package/.meta/command/wiki_plugin_kernel/SKILL.md +104 -0
  20. package/.meta/docs/agent-behavior.md +68 -0
  21. package/.meta/docs/agents-example.md +34 -0
  22. package/.meta/docs/conversion.md +24 -0
  23. package/.meta/docs/deployment.md +311 -0
  24. package/.meta/docs/developer.md +33 -0
  25. package/.meta/docs/distribution.md +132 -0
  26. package/.meta/docs/intro.md +56 -0
  27. package/.meta/docs/log-example.md +19 -0
  28. package/.meta/docs/map.md +23 -0
  29. package/.meta/docs/mechanics.md +180 -0
  30. package/.meta/docs/naming.md +113 -0
  31. package/.meta/docs/packages.md +130 -0
  32. package/.meta/docs/quickstart.md +79 -0
  33. package/.meta/docs/research-landscape.md +132 -0
  34. package/.meta/docs/research-user-profile.md +27 -0
  35. package/.meta/docs/sandbox.md +40 -0
  36. package/.meta/docs/usage.md +45 -0
  37. package/.meta/plugins/core/calendar/PLUGIN.md +35 -0
  38. package/.meta/plugins/core/calendar/PLUGIN.yaml +21 -0
  39. package/.meta/plugins/core/cron/PLUGIN.md +38 -0
  40. package/.meta/plugins/core/cron/PLUGIN.yaml +24 -0
  41. package/.meta/plugins/core/device/PLUGIN.md +38 -0
  42. package/.meta/plugins/core/device/PLUGIN.yaml +21 -0
  43. package/.meta/plugins/core/device/scripts/check.py +29 -0
  44. package/.meta/plugins/core/domain/PLUGIN.md +43 -0
  45. package/.meta/plugins/core/domain/PLUGIN.yaml +8 -0
  46. package/.meta/plugins/core/hot/PLUGIN.md +40 -0
  47. package/.meta/plugins/core/hot/PLUGIN.yaml +15 -0
  48. package/.meta/plugins/core/index/PLUGIN.md +39 -0
  49. package/.meta/plugins/core/index/PLUGIN.yaml +18 -0
  50. package/.meta/plugins/core/instance-git/PLUGIN.md +39 -0
  51. package/.meta/plugins/core/instance-git/PLUGIN.yaml +15 -0
  52. package/.meta/plugins/core/instance-git/scripts/check.py +90 -0
  53. package/.meta/plugins/core/language/PLUGIN.md +38 -0
  54. package/.meta/plugins/core/language/PLUGIN.yaml +23 -0
  55. package/.meta/plugins/core/language/scripts/check.py +23 -0
  56. package/.meta/plugins/core/link/PLUGIN.md +39 -0
  57. package/.meta/plugins/core/link/PLUGIN.yaml +16 -0
  58. package/.meta/plugins/core/link/scripts/check.py +122 -0
  59. package/.meta/plugins/core/log/PLUGIN.md +58 -0
  60. package/.meta/plugins/core/log/PLUGIN.yaml +18 -0
  61. package/.meta/plugins/core/log/scripts/check.py +21 -0
  62. package/.meta/plugins/core/mapping/PLUGIN.md +32 -0
  63. package/.meta/plugins/core/mapping/PLUGIN.yaml +24 -0
  64. package/.meta/plugins/core/mapping/scripts/check.py +90 -0
  65. package/.meta/plugins/core/notes/PLUGIN.md +42 -0
  66. package/.meta/plugins/core/notes/PLUGIN.yaml +19 -0
  67. package/.meta/plugins/core/sessions/PLUGIN.md +63 -0
  68. package/.meta/plugins/core/sessions/PLUGIN.yaml +20 -0
  69. package/.meta/plugins/core/sessions/scripts/check.py +33 -0
  70. package/.meta/plugins/core/structure/PLUGIN.md +29 -0
  71. package/.meta/plugins/core/structure/PLUGIN.yaml +18 -0
  72. package/.meta/plugins/core/structure/scripts/check.py +32 -0
  73. package/.meta/plugins/core/tag/PLUGIN.md +37 -0
  74. package/.meta/plugins/core/tag/PLUGIN.yaml +18 -0
  75. package/.meta/plugins/core/tag/scripts/check.py +28 -0
  76. package/.meta/plugins/core/tmp/PLUGIN.md +26 -0
  77. package/.meta/plugins/core/tmp/PLUGIN.yaml +16 -0
  78. package/.meta/plugins/core/todo/PLUGIN.md +33 -0
  79. package/.meta/plugins/core/todo/PLUGIN.yaml +22 -0
  80. package/.meta/plugins/core/trust/PLUGIN.md +70 -0
  81. package/.meta/plugins/core/trust/PLUGIN.yaml +23 -0
  82. package/.meta/plugins/core/trust/scripts/check.py +77 -0
  83. package/.meta/plugins/core/user-profile/PLUGIN.md +39 -0
  84. package/.meta/plugins/core/user-profile/PLUGIN.yaml +30 -0
  85. package/.meta/plugins/core/vault/PLUGIN.md +28 -0
  86. package/.meta/plugins/core/vault/PLUGIN.yaml +13 -0
  87. package/.meta/plugins/core/wiki/PLUGIN.md +37 -0
  88. package/.meta/plugins/core/wiki/PLUGIN.yaml +8 -0
  89. package/.meta/protocol/actions.md +42 -0
  90. package/.meta/protocol/experiments.md +40 -0
  91. package/.meta/protocol/registry.yaml +75 -0
  92. package/.meta/scripts/pipeline.py +428 -0
  93. package/.meta/scripts/wiki_plugin_kernel.py +1846 -0
  94. package/.meta/scripts/wikilib.py +79 -0
  95. package/LICENSE +21 -0
  96. package/README.md +105 -2
  97. package/bin/owfa.js +540 -0
  98. package/package.json +45 -4
  99. 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())