task-ledger 1.3.0 → 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.
- package/README.md +29 -247
- package/SKILL.md +173 -88
- package/package.json +22 -22
- package/references/log-note.md +3 -1
- package/references/state-model.md +29 -27
- package/references/troubleshooting.md +97 -32
- package/scripts/ledgerkit/__init__.py +0 -0
- package/scripts/ledgerkit/actions/__init__.py +0 -0
- package/scripts/ledgerkit/actions/atom.py +154 -0
- package/scripts/ledgerkit/actions/bootstrap.py +27 -0
- package/scripts/ledgerkit/actions/claim.py +82 -0
- package/scripts/ledgerkit/actions/close_task.py +180 -0
- package/scripts/ledgerkit/actions/guard.py +140 -0
- package/scripts/ledgerkit/actions/handoff.py +74 -0
- package/scripts/ledgerkit/actions/list_tasks.py +53 -0
- package/scripts/ledgerkit/actions/log_progress.py +60 -0
- package/scripts/ledgerkit/actions/new_task.py +120 -0
- package/scripts/ledgerkit/actions/query.py +33 -0
- package/scripts/ledgerkit/actions/query_history.py +173 -0
- package/scripts/ledgerkit/actions/registry.py +82 -0
- package/scripts/ledgerkit/actions/release.py +51 -0
- package/scripts/ledgerkit/actions/result.py +88 -0
- package/scripts/ledgerkit/actions/set_task.py +43 -0
- package/scripts/ledgerkit/actions/show_task.py +27 -0
- package/scripts/ledgerkit/actions/validate.py +224 -0
- package/scripts/ledgerkit/core/__init__.py +0 -0
- package/scripts/ledgerkit/core/history_md.py +350 -0
- package/scripts/ledgerkit/core/identity.py +43 -0
- package/scripts/ledgerkit/core/ids.py +58 -0
- package/scripts/ledgerkit/core/labels.py +79 -0
- package/scripts/ledgerkit/core/paths.py +93 -0
- package/scripts/ledgerkit/core/time.py +102 -0
- package/scripts/ledgerkit/core/worklog_md.py +232 -0
- package/scripts/ledgerkit/env.py +93 -0
- package/scripts/ledgerkit/errors.py +15 -0
- package/scripts/ledgerkit/notices.py +28 -0
- package/scripts/ledgerkit/shell/__init__.py +0 -0
- package/scripts/ledgerkit/shell/cli.py +259 -0
- package/scripts/ledgerkit/shell/gitops.py +655 -0
- package/scripts/ledgerkit/shell/merge.py +266 -0
- package/scripts/ledgerkit/shell/presync.py +23 -0
- package/scripts/ledgerkit/shell/render.py +165 -0
- package/scripts/ledgerkit/shell/runner.py +225 -0
- package/scripts/ledgerkit/store/__init__.py +0 -0
- package/scripts/ledgerkit/store/atomic.py +130 -0
- package/scripts/ledgerkit/store/dirty.py +108 -0
- package/scripts/ledgerkit/store/history_store.py +189 -0
- package/scripts/ledgerkit/store/json_store.py +166 -0
- package/scripts/ledgerkit/store/locks.py +64 -0
- package/scripts/ledgerkit/store/owners_index.py +135 -0
- package/scripts/ledgerkit/store/task_store.py +31 -0
- package/scripts/ledgerkit/store/worklog_store.py +168 -0
- package/scripts/progress.py +8 -3109
- package/references/taxonomy.md +0 -26
- 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
|