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.
- package/README.md +29 -245
- 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 +98 -25
- 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 -3021
- package/references/taxonomy.md +0 -26
- 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
|