task-ledger 1.2.9 → 1.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.
Files changed (55) hide show
  1. package/README.md +29 -245
  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 +98 -25
  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 +27 -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 +140 -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 +173 -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 +43 -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 +135 -0
  51. package/scripts/ledgerkit/store/task_store.py +31 -0
  52. package/scripts/ledgerkit/store/worklog_store.py +168 -0
  53. package/scripts/progress.py +8 -3021
  54. package/references/taxonomy.md +0 -26
  55. package/references/worklog.md +0 -39
@@ -0,0 +1,173 @@
1
+ """只读:查已结案(按结案时间过滤,倒序取最近)。不写任何字节、不依赖格式升级。
2
+
3
+ 时间区间一律落在**结案时间** `finished` 上 —— 历史文件本来就按结案月归档
4
+ (`history/<结案年月>.md`),按创建时间过滤会漏掉「上月建、本月结」的记录。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timedelta
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ..core import history_md
14
+ from ..core.identity import Identity, normalize
15
+ from ..core.paths import HISTORY_DIR, LEGACY_HISTORY_REL, month_of, to_repo_rel
16
+ from ..core.time import now, time_key
17
+ from ..errors import Fail
18
+ from ..store.history_store import HistoryStore
19
+ from .query import COUNT_CAP, HistoryQuery
20
+ from .result import HistoryResult, rel
21
+
22
+ #: 一次最多读多少个历史文件(按月分文件;不区分连续 / 分散,只数总数)。
23
+ MAX_HISTORY_FILES = 13
24
+ #: 没给任何时间参数时,默认只看最近几个自然月。
25
+ DEFAULT_HISTORY_MONTHS = 3
26
+
27
+
28
+ def _recent_month_keys(count: int) -> list[str]:
29
+ """最近 count 个自然月的月份键(含当月),按当前 UTC 推,从新到旧。"""
30
+ stamp = now()
31
+ year, month = int(stamp[0:4]), int(stamp[5:7])
32
+ keys: list[str] = []
33
+ for _ in range(max(count, 0)):
34
+ keys.append(f"{year:04d}{month:02d}")
35
+ month -= 1
36
+ if month == 0:
37
+ year, month = year - 1, 12
38
+ return keys
39
+
40
+
41
+ def _shift_month(ym: str, delta: int) -> str:
42
+ """`YYYYMM` 偏移 delta 个月;越过年份边界自动进位 / 借位。"""
43
+ idx = int(ym[0:4]) * 12 + (int(ym[4:6]) - 1) + delta
44
+ return f"{idx // 12:04d}{idx % 12 + 1:02d}"
45
+
46
+
47
+ def _month_keys_between(lo_ym: str, hi_ym: str) -> list[str]:
48
+ """`[lo_ym, hi_ym]` 闭区间内的月份键(升序);两端给反了也能兜住。"""
49
+ if lo_ym > hi_ym:
50
+ lo_ym, hi_ym = hi_ym, lo_ym
51
+ keys: list[str] = []
52
+ cur = lo_ym
53
+ while cur <= hi_ym:
54
+ keys.append(cur)
55
+ cur = _shift_month(cur, 1)
56
+ return keys
57
+
58
+
59
+ def _month_end(ym: str) -> str:
60
+ """`YYYYMM` 所在月的最后一秒(`YYYY-MM-DD HH:MM:SS`),用于把老平铺文件也框进区间。"""
61
+ year, month = int(ym[0:4]), int(ym[4:6])
62
+ nxt = datetime(year + (1 if month == 12 else 0), (month % 12) + 1, 1)
63
+ return (nxt - timedelta(seconds=1)).strftime("%Y-%m-%d %H:%M:%S")
64
+
65
+
66
+ def _target_month_keys(q: HistoryQuery) -> list[str]:
67
+ """由时间参数算出「要读的月份键」;超过上限直接 Fail(不碰磁盘)。
68
+
69
+ - 无任何时间参数 ⇒ 最近 `DEFAULT_HISTORY_MONTHS` 个自然月;
70
+ - `--months N` ⇒ 最近 N 个自然月(与 `--since/--until` 互斥);
71
+ - `--since/--until` ⇒ 结案月区间(只给一端时,另一端按默认口径补齐)。
72
+ """
73
+ if q.months is not None and (q.since or q.until):
74
+ raise Fail("--months 与 --since/--until 不能同时用(时间口径二选一)。")
75
+ if q.months is not None:
76
+ if q.months < 1:
77
+ raise Fail("--months 至少为 1。")
78
+ keys = _recent_month_keys(q.months)
79
+ elif q.since or q.until:
80
+ if q.since:
81
+ lo = month_of(q.since)
82
+ elif q.until:
83
+ # 只给 --until:往前补到默认窗口(与「默认近 3 月」一致)
84
+ lo = _shift_month(month_of(q.until), -(DEFAULT_HISTORY_MONTHS - 1))
85
+ else: # pragma: no cover - 上面已保证至少给了一个
86
+ raise Fail("--since/--until 至少要给一个。")
87
+ hi = month_of(q.until) if q.until else month_of(now())
88
+ if not lo or not hi:
89
+ raise Fail("--since/--until 时间格式不对,算不出月份。")
90
+ keys = _month_keys_between(lo, hi)
91
+ else:
92
+ keys = _recent_month_keys(DEFAULT_HISTORY_MONTHS)
93
+ # 上限卡的是「要读的文件总数」,不是月跨度:按月分文件,一个键一个文件;
94
+ # 连不连续、将来把多段时间拼在一起也都只数总数。
95
+ files = sorted(set(keys))
96
+ if len(files) > MAX_HISTORY_FILES:
97
+ raise Fail(
98
+ f"历史查询一次最多读 {MAX_HISTORY_FILES} 个历史文件(按月分文件,不区分连续/分散),"
99
+ f"本次要读 {len(files)} 个 ⇒ 缩小 --since/--until 或 --months。"
100
+ )
101
+ return files
102
+
103
+
104
+ def _candidate_paths(repo: Path, keys: list[str]) -> list[Path]:
105
+ """按月键【直接拼】`history/<年月>.md`(不扫磁盘);存量平铺文件按固定路径顺带一看。"""
106
+ paths = [repo / to_repo_rel(HISTORY_DIR / f"{ym}.md") for ym in keys]
107
+ paths.append(repo / to_repo_rel(LEGACY_HISTORY_REL))
108
+ return paths
109
+
110
+
111
+ def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
112
+ """`--owner` / `--mine` 一律**精确相等**(不做前缀 / 子串);`--since` / `--until` 比 `finished`(结案时间)。"""
113
+ keys = _target_month_keys(q)
114
+ store = HistoryStore(repo)
115
+ records: list[dict[str, Any]] = []
116
+ warnings: list[str] = []
117
+ for path in _candidate_paths(repo, keys):
118
+ if not path.is_file():
119
+ continue # 该月无记录 / 老平铺文件不存在 ⇒ 跳过,不报错
120
+ rows, warns = history_md.parse_history(store.read(path))
121
+ warnings.extend(f"{rel(repo, path)}:{w}" for w in warns)
122
+ for row in rows:
123
+ row = dict(row)
124
+ row["src"] = rel(repo, path)
125
+ row["created"] = time_key(row.get("created") or "") or row.get("created")
126
+ row["finished"] = time_key(row.get("finished") or "") or row.get("finished")
127
+ records.append(row)
128
+
129
+ if q.mine:
130
+ wanted = {k for k in (me.email, *me.aliases) if k}
131
+ wanted = {normalize(k) for k in wanted}
132
+ records = [r for r in records if normalize(r.get("owner") or "") in wanted]
133
+ elif q.owner:
134
+ key = normalize(q.owner)
135
+ records = [r for r in records if normalize(r.get("owner") or "") == key]
136
+ if q.category:
137
+ records = [r for r in records if (r.get("category") or "") == q.category]
138
+ if q.tag:
139
+ records = [r for r in records if q.tag in (r.get("tags") or ())]
140
+
141
+ # 有效结案区间:显式 --since/--until 优先(精确到秒),缺的一端用月份窗口补,
142
+ # 这样老平铺文件里的行也会被同一条规则框住,不会在月份查询里「漏网」。
143
+ lo_key, hi_key = min(keys), max(keys)
144
+ eff_since = q.since or f"{lo_key[0:4]}-{lo_key[4:6]}-01 00:00:00"
145
+ eff_until = q.until or _month_end(hi_key)
146
+ records = [r for r in records if (r.get("finished") or "") >= eff_since]
147
+ records = [r for r in records if (r.get("finished") or "") <= eff_until]
148
+
149
+ records.sort(key=lambda r: str(r.get("finished") or ""), reverse=True)
150
+ capped = False
151
+ if q.count_only and len(records) > COUNT_CAP:
152
+ capped = True
153
+ records = records[:COUNT_CAP]
154
+ elif q.limit:
155
+ records = records[: int(q.limit)]
156
+
157
+ filters = {
158
+ "owner": q.owner,
159
+ "mine": q.mine,
160
+ "category": q.category,
161
+ "tag": q.tag,
162
+ "since": q.since,
163
+ "until": q.until,
164
+ "limit": q.limit,
165
+ "months": q.months,
166
+ }
167
+ return HistoryResult(
168
+ count=len(records),
169
+ filters=filters,
170
+ records=tuple(records),
171
+ warnings=tuple(warnings),
172
+ capped=capped,
173
+ )
@@ -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