task-ledger 1.3.0 → 1.3.2

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 (55) hide show
  1. package/README.md +29 -247
  2. package/SKILL.md +173 -88
  3. package/package.json +22 -22
  4. package/references/log-note.md +3 -1
  5. package/references/state-model.md +29 -27
  6. package/references/troubleshooting.md +97 -32
  7. package/scripts/ledgerkit/__init__.py +0 -0
  8. package/scripts/ledgerkit/actions/__init__.py +0 -0
  9. package/scripts/ledgerkit/actions/atom.py +154 -0
  10. package/scripts/ledgerkit/actions/bootstrap.py +26 -0
  11. package/scripts/ledgerkit/actions/claim.py +82 -0
  12. package/scripts/ledgerkit/actions/close_task.py +180 -0
  13. package/scripts/ledgerkit/actions/guard.py +136 -0
  14. package/scripts/ledgerkit/actions/handoff.py +74 -0
  15. package/scripts/ledgerkit/actions/list_tasks.py +53 -0
  16. package/scripts/ledgerkit/actions/log_progress.py +60 -0
  17. package/scripts/ledgerkit/actions/new_task.py +120 -0
  18. package/scripts/ledgerkit/actions/query.py +33 -0
  19. package/scripts/ledgerkit/actions/query_history.py +172 -0
  20. package/scripts/ledgerkit/actions/registry.py +82 -0
  21. package/scripts/ledgerkit/actions/release.py +51 -0
  22. package/scripts/ledgerkit/actions/result.py +88 -0
  23. package/scripts/ledgerkit/actions/set_task.py +43 -0
  24. package/scripts/ledgerkit/actions/show_task.py +27 -0
  25. package/scripts/ledgerkit/actions/validate.py +224 -0
  26. package/scripts/ledgerkit/core/__init__.py +0 -0
  27. package/scripts/ledgerkit/core/history_md.py +350 -0
  28. package/scripts/ledgerkit/core/identity.py +27 -0
  29. package/scripts/ledgerkit/core/ids.py +58 -0
  30. package/scripts/ledgerkit/core/labels.py +79 -0
  31. package/scripts/ledgerkit/core/paths.py +93 -0
  32. package/scripts/ledgerkit/core/time.py +102 -0
  33. package/scripts/ledgerkit/core/worklog_md.py +232 -0
  34. package/scripts/ledgerkit/env.py +93 -0
  35. package/scripts/ledgerkit/errors.py +15 -0
  36. package/scripts/ledgerkit/notices.py +28 -0
  37. package/scripts/ledgerkit/shell/__init__.py +0 -0
  38. package/scripts/ledgerkit/shell/cli.py +259 -0
  39. package/scripts/ledgerkit/shell/gitops.py +655 -0
  40. package/scripts/ledgerkit/shell/merge.py +266 -0
  41. package/scripts/ledgerkit/shell/presync.py +23 -0
  42. package/scripts/ledgerkit/shell/render.py +165 -0
  43. package/scripts/ledgerkit/shell/runner.py +225 -0
  44. package/scripts/ledgerkit/store/__init__.py +0 -0
  45. package/scripts/ledgerkit/store/atomic.py +130 -0
  46. package/scripts/ledgerkit/store/dirty.py +108 -0
  47. package/scripts/ledgerkit/store/history_store.py +189 -0
  48. package/scripts/ledgerkit/store/json_store.py +166 -0
  49. package/scripts/ledgerkit/store/locks.py +64 -0
  50. package/scripts/ledgerkit/store/owners_index.py +114 -0
  51. package/scripts/ledgerkit/store/task_store.py +28 -0
  52. package/scripts/ledgerkit/store/worklog_store.py +168 -0
  53. package/scripts/progress.py +8 -3109
  54. package/references/taxonomy.md +0 -26
  55. package/references/worklog.md +0 -39
@@ -0,0 +1,82 @@
1
+ """动作名册:命令写法 → `ActionSpec`。只有 `shell/cli.py` 与 `shell/runner.py` 会 import 它。
2
+
3
+ **渲染函数只存名字**,不 import `shell/render.py` —— 否则 actions → shell 的依赖就反了;
4
+ 由 runner 用 `getattr(render, spec.render)` 取到函数。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass, field
11
+
12
+ from ..errors import Fail
13
+ from . import (
14
+ bootstrap,
15
+ claim,
16
+ close_task,
17
+ list_tasks,
18
+ log_progress,
19
+ new_task,
20
+ query_history,
21
+ set_task,
22
+ show_task,
23
+ validate,
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ActionSpec:
29
+ func: Callable # (repo, me, **params) -> Result | 视图对象
30
+ render: str # 渲染函数**名**;runner 用 getattr 取
31
+ readonly: bool = False
32
+ lock_action: str = "" # 交给 lock_scope 的动作名;默认同命令名
33
+ params: tuple[str, ...] = field(default=()) # 从参数里挑哪些键传给 func
34
+
35
+
36
+ ACTIONS: dict[str, ActionSpec] = {
37
+ "new": ActionSpec(
38
+ new_task.new_task,
39
+ "result",
40
+ params=("title", "tid", "created", "category", "tags", "mine"),
41
+ ),
42
+ "claim": ActionSpec(claim.claim, "result", params=("tid",)),
43
+ "set": ActionSpec(
44
+ set_task.set_task,
45
+ "result",
46
+ params=("tid", "owner"),
47
+ lock_action="set",
48
+ ),
49
+ "log": ActionSpec(log_progress.log_progress, "result", params=("tid", "note")),
50
+ "close": ActionSpec(
51
+ close_task.close_task,
52
+ "result",
53
+ params=("tid", "summary", "finished", "category", "tags"),
54
+ ),
55
+ "list": ActionSpec(
56
+ list_tasks.list_tasks, "list_view", readonly=True, params=("q",)
57
+ ),
58
+ "show": ActionSpec(
59
+ show_task.show_task, "show_view", readonly=True, params=("tid",)
60
+ ),
61
+ "history": ActionSpec(
62
+ query_history.query_history, "history_view", readonly=True, params=("q",)
63
+ ),
64
+ "validate": ActionSpec(
65
+ validate.validate, "validate_view", readonly=True, params=("deep",)
66
+ ),
67
+ }
68
+
69
+ READONLY = tuple(name for name, spec in ACTIONS.items() if spec.readonly)
70
+
71
+
72
+ def spec_of(command: str) -> ActionSpec:
73
+ """按命令写法取名册项;不在名册里就是用法错(业务错,退 1 人话)。"""
74
+ spec = ACTIONS.get(command)
75
+ if spec is None:
76
+ raise Fail(f"未知命令:{command}")
77
+ return spec
78
+
79
+
80
+ def bootstrap_func():
81
+ """自举不是名册里的命令(runner 单独调),这里只给它的入口,避免 shell 直接 import 动作。"""
82
+ return bootstrap.bootstrap
@@ -0,0 +1,51 @@
1
+ """释放:写回公共池,再删我分片那行,再记一笔。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..core.identity import Identity
8
+ from ..core.paths import POOL_REL, shard_rel
9
+ from ..core.time import now
10
+ from ..core.worklog_md import exec_line
11
+ from ..errors import Fail
12
+ from ..store.task_store import TaskStore
13
+ from ..store.worklog_store import WorklogStore
14
+ from .result import Result, rel
15
+
16
+
17
+ def release(repo: Path, me: Identity, tid: str) -> Result:
18
+ """写序:写回池(已有同号就不覆盖)→ 删我分片那行 → 过程文档记一笔。"""
19
+ pool = TaskStore(repo, POOL_REL)
20
+ mine = TaskStore(repo, shard_rel(me.email), identity=me)
21
+ task = mine.get(tid) # 锁内重读
22
+ if task is None:
23
+ if pool.get(tid) is not None:
24
+ return Result(
25
+ already_done=True,
26
+ where=rel(repo, pool.path),
27
+ notices=(f"任务 {tid} 已经在公共池里,无需重复释放。",),
28
+ )
29
+ raise Fail(f"任务 {tid} 不在我的分片里,也没在公共池里。")
30
+
31
+ back = dict(task)
32
+ back["owner"] = None
33
+ back["status"] = "open"
34
+ back["updated"] = now()
35
+ if pool.get(tid) is None:
36
+ pool.put(tid, back)
37
+ mine.drop(tid)
38
+
39
+ logs = WorklogStore(repo)
40
+ path = logs.find(tid, back.get("worklog"))
41
+ note = None
42
+ if path is None:
43
+ note = f"任务 {tid} 已退回公共池,但过程文档没找到,跳过执行记录。"
44
+ else:
45
+ logs.append_exec(path, exec_line(now(), me.email, "release", "释放回公共池"))
46
+ return Result(
47
+ changed=("owner", "status", "updated"),
48
+ where=rel(repo, pool.path),
49
+ message=f"释放 {tid}:{back.get('title') or ''}".strip(),
50
+ notices=(note,) if note else (),
51
+ )
@@ -0,0 +1,88 @@
1
+ """动作的返回值长什么样。字段名是**公开契约**,插件看板直接读,外壳不许重新算。
2
+
3
+ 写动作返回 `Result`,只读动作返回各自的视图对象(不再套一层统一的壳)。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ def rel(repo: Path, path: Path) -> str:
14
+ """绝对路径 → 相对 repo 的字符串(输出给人 / 给界面的一律这个基准)。"""
15
+ try:
16
+ return str(Path(path).resolve().relative_to(Path(repo).resolve()))
17
+ except ValueError:
18
+ return str(path)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class WorklogView:
23
+ """「这条任务的过程文档要怎么显示」。"""
24
+
25
+ path: str | None = None # 相对 repo
26
+ text: str | None = None # 全文;读不到时为 None
27
+ error: str | None = None # 读不到的原因(缺失 / 读不了),给人看
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Result:
32
+ """写动作的返回。"""
33
+
34
+ changed: tuple[str, ...] = () # 改了哪些**字段名**(不是文件名、不是任务号)
35
+ where: str | None = None # 主要落在哪个文件(**相对 repo**)
36
+ already_done: bool = False # 重跑发现已经做过 ⇒ 退出码仍是 0
37
+ message: str | None = None # git 提交说明;None ⇒ 外壳用默认模板
38
+ worklog: WorklogView | None = None
39
+ payload: dict[str, Any] = field(default_factory=dict)
40
+ notices: tuple[str, ...] = ()
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class TaskRow:
45
+ """人话表格的一行(9 字段投影)。`--json` 吐的是 `rows`(原样任务对象),不是它。"""
46
+
47
+ tid: str
48
+ title: str
49
+ status: str
50
+ owner: str | None = None # 显示名
51
+ created: str | None = None
52
+ started: str | None = None
53
+ category: str | None = None
54
+ tags: tuple[str, ...] = ()
55
+ where: str | None = None # 所在文件(相对 repo)
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ListResult:
60
+ rows: dict[str, dict[str, Any]] = field(default_factory=dict) # tid → 原样任务对象
61
+ table: tuple[TaskRow, ...] = ()
62
+ notices: tuple[str, ...] = ()
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ShowResult:
67
+ task: dict[str, Any] = field(default_factory=dict)
68
+ worklog: WorklogView | None = None
69
+ others: tuple[str, ...] = () # 同一个任务号还出现在哪些文件里
70
+ notices: tuple[str, ...] = ()
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class HistoryResult:
75
+ count: int = 0
76
+ filters: dict[str, Any] = field(default_factory=dict) # 回显生效的筛选
77
+ records: tuple[dict[str, Any], ...] = ()
78
+ warnings: tuple[str, ...] = ()
79
+ capped: bool = False
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class ValidationReport:
84
+ problems: tuple[str, ...] = () # 只有它影响退出码
85
+ notices: tuple[str, ...] = () # 只提示,不改退出码
86
+ checked_files: int = 0
87
+ total_tasks: int = 0
88
+ deep: bool = False # 本次是深度检查(--deep)还是默认浅度
@@ -0,0 +1,43 @@
1
+ """改归属的入口:只看 `--owner`,决定走哪个动作,**自己不加锁、不改任何文件**。
2
+
3
+ 它是全树唯一的跨动作引用点(import `release` / `handoff` / `claim`)。
4
+
5
+ 任务状态只随命令语义变(认领 → 进行中、释放 → 待认领、结案 → 历史),
6
+ **没有单独的「改状态」入口**:`--status` 于 2026-10-23 删除 —— 全仓没有任何
7
+ 产品调用方(看板只有 claim / release / close 三个写路由),且它能制造
8
+ 「归我名下、状态却还是待认领」的坏状态(看板上显示成没人认领)。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from ..core.identity import Identity, is_me
16
+ from ..core.paths import shard_rel
17
+ from ..errors import Fail
18
+ from ..store.task_store import TaskStore
19
+ from . import claim as claim_mod
20
+ from . import handoff as handoff_mod
21
+ from . import release as release_mod
22
+ from .result import Result, rel
23
+
24
+
25
+ def set_task(repo: Path, me: Identity, tid: str, *, owner: str | None) -> Result:
26
+ """路由:空串 = 释放;别人 = 交接;自己 = 已认领则空转、没认领先认领。"""
27
+ if owner is None:
28
+ raise Fail(
29
+ "set 必须带 --owner:空串 = 释放回公共池;对方身份 = 交接(预留通道,agent 不用)。"
30
+ )
31
+ target = owner.strip()
32
+ if not target:
33
+ return release_mod.release(repo, me, tid)
34
+ if not is_me(me, target):
35
+ return handoff_mod.handoff(repo, me, tid, to=target)
36
+ mine = TaskStore(repo, shard_rel(me.email), identity=me)
37
+ if mine.get(tid) is not None:
38
+ return Result(
39
+ already_done=True,
40
+ where=rel(repo, mine.path),
41
+ notices=(f"任务 {tid} 已经在你名下,无需操作。",),
42
+ )
43
+ return claim_mod.claim(repo, me, tid)
@@ -0,0 +1,27 @@
1
+ """只读:看一条任务(池 + 索引登记的全部分片都扫,别人的也要能看)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..core.identity import Identity
8
+ from ..store.worklog_store import WorklogStore
9
+ from .guard import load_task
10
+ from .result import ShowResult, WorklogView, rel
11
+
12
+
13
+ def show_task(repo: Path, me: Identity, tid: str) -> ShowResult:
14
+ task, where = load_task(repo, tid)
15
+ logs = WorklogStore(repo)
16
+ path = logs.find(tid, task.get("worklog"))
17
+ if path is None:
18
+ view = WorklogView(
19
+ path=None, text=None, error="这条任务没有过程文档(或文档已不在原处)。"
20
+ )
21
+ else:
22
+ view = WorklogView(path=rel(repo, path), text=logs.read(path), error=None)
23
+ return ShowResult(
24
+ task=task,
25
+ worklog=view,
26
+ others=tuple(rel(repo, p) for p in where),
27
+ )
@@ -0,0 +1,224 @@
1
+ """只读:台账体检。两档:默认浅度只查格式,`--deep` 才读全部历史与过程文档正文。
2
+
3
+ 浅度(默认):池 + 各分片的字段格式、索引与文件对不对得上、**当月**历史、每篇过程文档的模板格式。
4
+ 深度(`--deep`):历史不止当月(全部月份),过程文档再判骨架(正文填了没)。
5
+
6
+ 只查格式,不查业务(该谁结案、逾期与否都不管);也不查「同一任务号落在多个文件」——
7
+ 读侧只显示一次(`show` 会点名)、写侧已有防护,撞上了不关体检的事(2026-10-24 用户拍板)。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from ..core import history_md
15
+ from ..core.identity import Identity
16
+ from ..core.labels import STATUSES
17
+ from ..core.paths import LEGACY_HISTORY_REL, OWNERS_REL, POOL_REL, to_repo_rel
18
+ from ..core.time import now
19
+ from ..core.worklog_md import (
20
+ duplicated_sections,
21
+ format_problems,
22
+ is_skeleton,
23
+ lacks_exec_section,
24
+ )
25
+ from ..store.history_store import HistoryStore
26
+ from ..store.owners_index import OwnersIndex
27
+ from ..store.task_store import TaskStore
28
+ from ..store.worklog_store import WorklogStore
29
+ from .guard import owner_of, status_of
30
+ from .result import ValidationReport, rel
31
+
32
+
33
+ def _read_tasks(repo: Path, store: TaskStore, problems: list[str]) -> dict:
34
+ """读一个任务文件;撞上**程序自留**的冲突标记 ⇒ 记一条 problem 继续。
35
+
36
+ 推送被拒后 rebase 给 JSON 留的 `<<<<<<<` 是程序自己产的坏数据,validate
37
+ 要能指出来(退 1 给清单),而不是整条命令退 3(2026-10-24 修)。
38
+ 其余读错误(用户手改坏)不在此列,照旧抛出。
39
+ """
40
+ try:
41
+ return store.all()
42
+ except ValueError:
43
+ text = store.path.read_text(encoding="utf-8", errors="replace")
44
+ if "<<<<<<<" in text:
45
+ problems.append(
46
+ f"{rel(repo, store.path)} 带着冲突标记"
47
+ "(推送被拒后 rebase 没解完):先解决冲突,再跑 validate。"
48
+ )
49
+ return {}
50
+ raise
51
+
52
+
53
+ def _check_task_files(
54
+ repo: Path, index: OwnersIndex, problems: list[str]
55
+ ) -> tuple[int, int]:
56
+ """池 + 各分片的字段格式(owner / status / 分片归属);返回 (查了几个文件, 几条任务)。"""
57
+ checked = 0
58
+ total = 0
59
+
60
+ pool = TaskStore(repo, POOL_REL)
61
+ if pool.path.exists():
62
+ checked += 1
63
+ for tid, task in _read_tasks(repo, pool, problems).items():
64
+ total += 1
65
+ if owner_of(task):
66
+ problems.append(
67
+ f"公共池里的 {tid} 带着 owner(池里的行必须无人认领)。"
68
+ )
69
+ if task.get("status") not in STATUSES:
70
+ problems.append(f"公共池里的 {tid} 状态 {task.get('status')!r} 非法。")
71
+
72
+ for key, path in index.shards().items():
73
+ if not path.exists():
74
+ problems.append(f"索引登记了 {key},但分片文件 {path.name} 不存在。")
75
+ continue
76
+ checked += 1
77
+ store = TaskStore(repo, Path(path).relative_to(repo / "ledger"))
78
+ for tid, task in _read_tasks(repo, store, problems).items():
79
+ total += 1
80
+ owner = owner_of(task)
81
+ if not owner:
82
+ problems.append(f"分片 {key} 里的 {tid} 没有 owner。")
83
+ elif owner != key:
84
+ problems.append(
85
+ f"分片 {key} 里的 {tid} 的 owner 是 {owner}(分片主人与分区键不一致)。"
86
+ )
87
+ status = status_of(task)
88
+ if status not in STATUSES:
89
+ problems.append(f"分片 {key} 里的 {tid} 状态 {status!r} 非法。")
90
+
91
+ return checked, total
92
+
93
+
94
+ def _check_orphan_shards(index: OwnersIndex, problems: list[str]) -> None:
95
+ """目录里有、索引没登记的分片(全仓唯一扫目录的地方)。"""
96
+ registered = {p.name for p in index.shards().values()}
97
+ for path in index.disk_shards():
98
+ if path.name not in registered:
99
+ problems.append(
100
+ f"owners/ 里的 {path.name} 没有在索引里登记(对它人等于不存在)。"
101
+ )
102
+
103
+
104
+ def _history_paths(store: HistoryStore, *, deep: bool) -> list[Path]:
105
+ """要读的历史文件:浅度只读当月那个月文件(存量平铺文件不读),深度读全部。"""
106
+ sources = store.sources()
107
+ if deep:
108
+ return sources
109
+ month = now()[0:4] + now()[5:7]
110
+ return [p for p in sources if store.month_key(p) == month]
111
+
112
+
113
+ def _check_history(
114
+ repo: Path,
115
+ paths: list[Path],
116
+ open_docs: set[str],
117
+ store: HistoryStore,
118
+ notices: list[str],
119
+ ) -> int:
120
+ """历史文件的格式与对账:解析警告、行尾文档链接对得上在办任务没。"""
121
+ checked = 0
122
+ for path in paths:
123
+ checked += 1
124
+ rows, warns = history_md.parse_history(store.read(path))
125
+ notices.extend(f"{rel(repo, path)}:{w}" for w in warns)
126
+ for row in rows:
127
+ name = Path(row.get("worklog") or "").name
128
+ if name and name in open_docs:
129
+ notices.append(
130
+ f"历史里有「{row.get('title') or name}」的结案行,"
131
+ "但它还挂在在办台账里。"
132
+ )
133
+ return checked
134
+
135
+
136
+ def _check_worklog(
137
+ repo: Path,
138
+ logs: WorklogStore,
139
+ problems: list[str],
140
+ notices: list[str],
141
+ *,
142
+ deep: bool,
143
+ ) -> int:
144
+ """每篇过程文档:模板格式(浅度就查)+ 空骨架判定(只有 `--deep` 才判)。
145
+
146
+ 一趟读完两件事都做 —— 格式要看全文找节标题,骨架吃同一份文本,别为此读第二遍。
147
+ """
148
+ checked = 0
149
+ for path in logs.all():
150
+ checked += 1
151
+ try:
152
+ text = logs.read(path)
153
+ except ValueError:
154
+ continue
155
+ if not text:
156
+ continue
157
+ where = rel(repo, path)
158
+ problems.extend(f"{where} 格式:{line}" for line in format_problems(text))
159
+ if lacks_exec_section(text):
160
+ notices.append(
161
+ f"{where} 没有「执行记录」节(老文档;写一条记录会自动补上)。"
162
+ )
163
+ dup = duplicated_sections(text)
164
+ if dup:
165
+ notices.append(
166
+ f"{where} 有重复的节:{'、'.join(f'`## {s}`' for s in dup)}。"
167
+ )
168
+ if deep and is_skeleton(text):
169
+ notices.append(f"{where} 还是骨架(正文没填)。")
170
+ return checked
171
+
172
+
173
+ def validate(repo: Path, me: Identity, deep: bool = False) -> ValidationReport:
174
+ problems: list[str] = []
175
+ notices: list[str] = []
176
+ checked = 0
177
+ total = 0
178
+
179
+ index = OwnersIndex(repo)
180
+ history = HistoryStore(repo)
181
+ logs = WorklogStore(repo)
182
+
183
+ files, tasks = _check_task_files(repo, index, problems)
184
+ checked += files
185
+ total += tasks
186
+ _check_orphan_shards(index, problems)
187
+
188
+ checked += _check_history(
189
+ repo,
190
+ _history_paths(history, deep=deep),
191
+ _open_worklog_names(repo, index),
192
+ history,
193
+ notices,
194
+ )
195
+ checked += _check_worklog(repo, logs, problems, notices, deep=deep)
196
+
197
+ legacy = repo / to_repo_rel(LEGACY_HISTORY_REL)
198
+ if legacy.exists():
199
+ notices.append(f"存量平铺文件 {rel(repo, legacy)} 只读兼容,不会重写。")
200
+ if not (repo / to_repo_rel(OWNERS_REL)).exists():
201
+ notices.append("还没有 owners.json(索引):跑一条写命令会自动建。")
202
+
203
+ return ValidationReport(
204
+ problems=tuple(problems),
205
+ notices=tuple(notices),
206
+ checked_files=checked,
207
+ total_tasks=total,
208
+ deep=deep,
209
+ )
210
+
211
+
212
+ def _open_worklog_names(repo: Path, index: OwnersIndex) -> set[str]:
213
+ """在办任务的过程文档**文件名**集合(池 + 全部分片):历史行按它对账。"""
214
+ stores = [TaskStore(repo, POOL_REL)]
215
+ for path in index.shards().values():
216
+ if path.exists():
217
+ stores.append(TaskStore(repo, Path(path).relative_to(repo / "ledger")))
218
+ out: set[str] = set()
219
+ for store in stores:
220
+ for task in store.all().values():
221
+ name = Path((task.get("worklog") or "").strip()).name
222
+ if name:
223
+ out.add(name)
224
+ return out
File without changes