dsh-task-ledger 0.0.3 → 0.0.6

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.
@@ -0,0 +1,2012 @@
1
+ #!/usr/bin/env python3
2
+ # pyright: standard
3
+ """task-ledger — 管理项目任务进度。
4
+
5
+ ledger/progress.json 在办任务的状态
6
+ ledger/worklog/<年月>/<日>-<时分>-<说明>-<随机码>.md
7
+ 单个任务的过程(路径另记在状态表 worklog 字段)
8
+ ledger/history/<年月>.md 已结案任务,一行一条(8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / 完成了什么 / 过程文档);
9
+ 按结案时间分月归档。存量 ledger/PROGRESS-HISTORY.md 只读兼容、永不重写
10
+
11
+ 只用标准库,无第三方依赖。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import contextlib
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import re
22
+ import secrets
23
+ import subprocess
24
+ import sys
25
+ import tempfile
26
+ from collections.abc import Iterator
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ try:
32
+ import fcntl
33
+ except ImportError: # Windows
34
+ fcntl = None
35
+ try:
36
+ import msvcrt
37
+ except ImportError:
38
+ msvcrt = None
39
+
40
+ STATUSES = ("open", "doing", "blocked", "done")
41
+ PROGRESS_REL = Path("ledger/progress.json")
42
+ # 存量:改造前的单文件历史层,只读兼容、永不重写
43
+ HISTORY_REL = Path("ledger/PROGRESS-HISTORY.md")
44
+ HISTORY_DIR = Path(
45
+ "ledger/history"
46
+ ) # 新历史层:按结案时间分月,ledger/history/<YYYYMM>.md
47
+ WORKLOG_DIR = Path("ledger/worklog")
48
+ HISTORY_TITLE = "# 进度历史(已结案)"
49
+ # 8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / 完成了什么 / 过程文档。
50
+ # owner 独立成列 —— 按人检索是精确列比较,不靠文本切分。
51
+ # 分类/标签列(2026-10-15 存量改造时加入):close 从 task 直写;旧 4/5/6 列由
52
+ # upgrade_history 升级为 8 列(回链 worklog 头部补值),parse_history 三时代兼容读取。
53
+ HISTORY_HEADER = (
54
+ "| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 | owner | 完成了什么 | 过程文档 |"
55
+ )
56
+ HISTORY_SEP = "|---|---|---|---|---|---|---|---|"
57
+ FROZEN_MARK = "✅ 完成封卷"
58
+ ID_RE = re.compile(r"[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*\Z")
59
+ # 读侧兼容:秒可有可无(旧数据只到分)。写侧一律到秒,见 normalize_time。
60
+ TIME_RE = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?\Z")
61
+ # 分类 / 标签口径:值由 Agent 在 new 时按 SKILL.md 规则自行总结(不设枚举),
62
+ # 脚本只硬校验形态 —— 含 CJK 按字符数 ≤4,纯 ASCII 按单词数 ≤3;标签数量只设上限,
63
+ # 设下限会逼 Agent 凑数,凑出来的正是噪声。
64
+ LABEL_MAX_CHARS = 4
65
+ LABEL_MAX_WORDS_EN = 3
66
+ TAGS_MAX_COUNT = 5
67
+ CJK_RE = re.compile(r"[\u4e00-\u9fff]")
68
+
69
+ SKILL_DIR = Path(__file__).resolve().parent.parent
70
+ WORKLOG_TEMPLATE = SKILL_DIR / "assets" / "worklog.md"
71
+
72
+ FALLBACK_TEMPLATE = """# <id>:<title>
73
+
74
+ > **创建**:<created>(@<creator>)
75
+
76
+ ## 背景
77
+
78
+ ## 待办
79
+
80
+ ## 下一步
81
+
82
+ ## 过程
83
+
84
+ ## 结论
85
+ """
86
+
87
+
88
+ class Fail(Exception):
89
+ """业务错误:退出码非 0,只打印消息。"""
90
+
91
+
92
+ # ---------- 基础设施 ----------
93
+
94
+
95
+ def now() -> str:
96
+ """当前时刻,精确到秒:YYYY-MM-DD HH:MM:SS。"""
97
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
98
+
99
+
100
+ # 时间字面量:日期 + 可选「时:分[:秒]」。容忍 `/` 与 `T` 分隔(归一化后再匹配)。
101
+ _TIME_IN_RE = re.compile(
102
+ r"(\d{4})-(\d{1,2})-(\d{1,2})(?: +(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?\Z"
103
+ )
104
+
105
+
106
+ def normalize_time(
107
+ value: str | datetime,
108
+ *,
109
+ field: str = "时间",
110
+ tid: str | None = None,
111
+ date_only_ref: str | None = None,
112
+ ) -> str:
113
+ """把「正确但不完整」的时间写法归一化到 YYYY-MM-DD HH:MM:SS。
114
+
115
+ 接受 `YYYY-MM-DD`、`YYYY-MM-DD HH:MM`、`YYYY-MM-DD HH:MM:SS`,容忍 `/` 与 `T`。
116
+ 缺秒补 `:00`,缺分秒补 `00:00`(即 `00:00:00`)。非法值报错。
117
+ date_only_ref:只给了日期(没给时分)时,用这个时刻顶替(如 `--until` 想「含当天整天」传 `23:59:59`)。
118
+ """
119
+ where = f"{tid}: " if tid else ""
120
+ if isinstance(value, datetime):
121
+ return value.strftime("%Y-%m-%d %H:%M:%S")
122
+ if not isinstance(value, str) or not value.strip():
123
+ raise Fail(f"{where}{field} 要是一个时间字符串,收到 {value!r}")
124
+ s = " ".join(
125
+ value.strip().replace("/", "-").replace("T", " ").replace("t", " ").split()
126
+ )
127
+ m = _TIME_IN_RE.fullmatch(s)
128
+ if not m:
129
+ raise Fail(
130
+ f"{where}{field} 时间格式不对:{value!r}"
131
+ "(可用 YYYY-MM-DD、YYYY-MM-DD HH:MM、YYYY-MM-DD HH:MM:SS)"
132
+ )
133
+ y, mo, d, hh, mm, ss = m.groups()
134
+ if date_only_ref is not None and hh is None:
135
+ out = f"{int(y):04d}-{int(mo):02d}-{int(d):02d} {date_only_ref}"
136
+ else:
137
+ out = (
138
+ f"{int(y):04d}-{int(mo):02d}-{int(d):02d} "
139
+ f"{int(hh or 0):02d}:{int(mm or 0):02d}:{int(ss or 0):02d}"
140
+ )
141
+ try:
142
+ datetime.strptime(out, "%Y-%m-%d %H:%M:%S")
143
+ except ValueError as exc:
144
+ raise Fail(f"{where}{field} 不是真实时间:{value!r}({exc})")
145
+ return out
146
+
147
+
148
+ def time_key(value: object) -> str | None:
149
+ """已存时间(可能只到分)→ 秒精度字符串,用于比较/排序;不合法返回 None。"""
150
+ if not isinstance(value, str) or not TIME_RE.fullmatch(value):
151
+ return None
152
+ return value if len(value) == 19 else value + ":00"
153
+
154
+
155
+ def find_repo(explicit: str | None) -> Path:
156
+ """项目根:--repo > 向上找含 ledger/progress.json 的目录 > 向上找含 ledger/ 的目录。
157
+
158
+ git 根只作为一个候选,不无条件优先 —— 否则 monorepo 子目录会定位到整个仓库根。
159
+ """
160
+ if explicit:
161
+ p = Path(explicit).expanduser().resolve()
162
+ if not p.is_dir():
163
+ raise Fail(f"--repo 不是目录:{p}")
164
+ return p
165
+
166
+ bases: list[Path] = []
167
+ try:
168
+ out = subprocess.run(
169
+ ["git", "rev-parse", "--show-toplevel"],
170
+ capture_output=True,
171
+ text=True,
172
+ check=True,
173
+ ).stdout.strip()
174
+ if out:
175
+ bases.append(Path(out).resolve())
176
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
177
+ pass
178
+
179
+ try:
180
+ here = Path.cwd().resolve()
181
+ except OSError as exc:
182
+ raise Fail(f"拿不到当前目录:{exc}")
183
+ if here not in bases:
184
+ bases.append(here)
185
+
186
+ ordered: list[Path] = []
187
+ for base in bases:
188
+ for p in (base, *base.parents):
189
+ if p not in ordered:
190
+ ordered.append(p)
191
+
192
+ for p in ordered:
193
+ if (p / PROGRESS_REL).exists():
194
+ return p
195
+ for p in ordered:
196
+ if (p / "docs").is_dir():
197
+ return p
198
+ raise Fail(
199
+ "定位不到项目根:向上找不到 ledger/progress.json 或 ledger/。用 --repo 指定。"
200
+ )
201
+
202
+
203
+ def git_user() -> str:
204
+ """owner 身份:只认 git config user.name。取不到就报错,不做任何兜底。"""
205
+ try:
206
+ proc = subprocess.run( # noqa: PLW1510 —— 要看 stdout,不抛异常
207
+ ["git", "config", "user.name"], capture_output=True, text=True
208
+ )
209
+ except FileNotFoundError:
210
+ raise Fail("需要用户名才能执行:本机没有 git,读不到 git config user.name。")
211
+ name = proc.stdout.strip()
212
+ if not name:
213
+ raise Fail(
214
+ "需要用户名才能执行:读不到本机 git 身份(git config user.name 为空)。\n"
215
+ '先配置:git config --global user.name "你的名字"'
216
+ )
217
+ return name
218
+
219
+
220
+ def read_json(path: Path) -> dict[str, Any]:
221
+ if not path.exists():
222
+ return {"tasks": {}}
223
+ try:
224
+ text = path.read_text(encoding="utf-8-sig")
225
+ except (OSError, UnicodeDecodeError) as exc:
226
+ raise Fail(f"读不了 {path}:{exc}")
227
+ try:
228
+ data = json.loads(text)
229
+ except json.JSONDecodeError as exc:
230
+ raise Fail(f"{path} 不是合法 JSON:{exc}")
231
+ if not isinstance(data, dict) or not isinstance(data.get("tasks"), dict):
232
+ raise Fail(f'{path} 结构不对,顶层应为 {{"tasks": {{...}}}}')
233
+ return data
234
+
235
+
236
+ def write_text_atomic(path: Path, text: str) -> None:
237
+ """原子写文本:同目录唯一临时名 + fsync + os.replace,写完清理临时文件。
238
+
239
+ 整段都在 try 内 —— mkdir / mkstemp 的 OSError 同样要转成 Fail,
240
+ 否则会漏成裸异常,上层的「失败就撤销」也就收不到。
241
+ """
242
+ fd = None
243
+ tmp = None
244
+ try:
245
+ path.parent.mkdir(parents=True, exist_ok=True)
246
+ fd, tmp_name = tempfile.mkstemp(
247
+ dir=str(path.parent), prefix=path.name + ".", suffix=".tmp"
248
+ )
249
+ tmp = Path(tmp_name)
250
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
251
+ fd = None # 所有权已交给文件对象
252
+ fh.write(text)
253
+ fh.flush()
254
+ os.fsync(fh.fileno())
255
+ os.replace(tmp, path)
256
+ tmp = None
257
+ except OSError as exc:
258
+ raise Fail(f"写 {path} 失败:{exc}")
259
+ finally:
260
+ if fd is not None:
261
+ try:
262
+ os.close(fd)
263
+ except OSError:
264
+ pass
265
+ if tmp is not None and tmp.exists():
266
+ try:
267
+ tmp.unlink()
268
+ except OSError:
269
+ pass
270
+
271
+
272
+ def write_json(path: Path, data: dict[str, Any]) -> None:
273
+ """原子写状态表。key 按字母序,减少并发新增时的文本冲突。"""
274
+ write_text_atomic(
275
+ path,
276
+ json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
277
+ )
278
+
279
+
280
+ def stat_key(path: Path) -> str | tuple[str, ...] | None:
281
+ """文件内容的指纹;不存在返回 None。
282
+
283
+ 刻意不用 (mtime, size):并发写入可能落在同一时间精度内、且字节数恰好相同,
284
+ 那样指纹不变、写前复查就形同虚设(实测踩过)。用内容哈希,代价是多读一次小文件。
285
+ """
286
+ try:
287
+ raw = path.read_bytes()
288
+ except FileNotFoundError:
289
+ return None
290
+ except OSError:
291
+ return ("unreadable",)
292
+ return hashlib.sha256(raw).hexdigest()
293
+
294
+
295
+ @contextlib.contextmanager
296
+ def progress_lock(path: Path) -> Iterator[None]:
297
+ """把状态表的「读-改-写」串行化,避免并发丢更新。
298
+
299
+ 锁文件放系统临时目录(按项目绝对路径取名),不污染项目;进程退出时内核自动释放。
300
+ 无 fcntl / msvcrt 的平台上退化为不加锁。
301
+ """
302
+ if fcntl is None and msvcrt is None:
303
+ yield
304
+ return
305
+ key = hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:16]
306
+ lock_path = Path(tempfile.gettempdir()) / f"koc-progress-{key}.lock"
307
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR)
308
+ try:
309
+ if fcntl is not None:
310
+ fcntl.flock(fd, fcntl.LOCK_EX)
311
+ elif msvcrt is not None:
312
+ # typeshed 把 msvcrt.locking / LK_* 门控在 win32,非 Windows 平台跑 pyright
313
+ # 会报「未知属性」;这段只有 Windows 真能执行到,故局部忽略(import 处是 try 容错)
314
+ msvcrt.locking(fd, msvcrt.LK_LOCK, 1) # pyright: ignore[reportAttributeAccessIssue]
315
+ yield
316
+ finally:
317
+ try:
318
+ if fcntl is not None:
319
+ fcntl.flock(fd, fcntl.LOCK_UN)
320
+ elif msvcrt is not None:
321
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) # pyright: ignore[reportAttributeAccessIssue]
322
+ finally:
323
+ os.close(fd)
324
+
325
+
326
+ def read_guarded(path: Path):
327
+ """读状态表并记下指纹;配合 commit_guarded 做写前复查。"""
328
+ return stat_key(path), read_json(path)
329
+
330
+
331
+ def commit_guarded(
332
+ path: Path, before: str | tuple[str, ...] | None, data: dict[str, Any]
333
+ ) -> None:
334
+ """写前复查指纹:被并发改动过就报错,不覆盖别人的改动。"""
335
+ if stat_key(path) != before:
336
+ raise Fail(f"{path.name} 在操作期间被改动过,已中止。重新执行一次。")
337
+ write_json(path, data)
338
+
339
+
340
+ def require_task_id(tid: str) -> str:
341
+ """任务号会参与拼路径,任何命令拿到它都要先验一遍。"""
342
+ if not ID_RE.fullmatch(tid):
343
+ raise Fail(
344
+ f"任务号 {tid!r} 不合规:只用小写字母、数字、连字符和汉字,且以字母、数字或汉字开头。"
345
+ )
346
+ return tid
347
+
348
+
349
+ def normalize_label(value: str, *, field: str = "分类", tid: str | None = None) -> str:
350
+ """分类 / 标签单值形态校验:拒控制字符与换行(防注入 worklog 头部行)、折叠内部空白。
351
+
352
+ 含 CJK 按字符数 ≤4;纯 ASCII 按单词数 ≤3 —— 中英混排按字符数卡,逼 Agent 写短。
353
+ """
354
+ where = f"{tid}: " if tid else ""
355
+ if any(ord(c) < 32 or ord(c) == 127 for c in value):
356
+ raise Fail(f"{where}{field}不能含控制字符或换行:{value!r}")
357
+ s = " ".join(value.split())
358
+ if not s:
359
+ raise Fail(f"{where}{field}不能为空。")
360
+ if CJK_RE.search(s):
361
+ if len(s) > LABEL_MAX_CHARS:
362
+ raise Fail(
363
+ f"{where}{field}「{s}」超长:含中文按字符数最多 {LABEL_MAX_CHARS} 个。"
364
+ )
365
+ elif len(s.split()) > LABEL_MAX_WORDS_EN:
366
+ raise Fail(
367
+ f"{where}{field}「{s}」超长:纯英文按单词数最多 {LABEL_MAX_WORDS_EN} 个。"
368
+ )
369
+ return s
370
+
371
+
372
+ def parse_tags(raw: str, *, field: str = "标签", tid: str | None = None) -> list[str]:
373
+ """标签串 → 列表:逗号 / 顿号切分(**不按空格**——空格留给英文多词标签自身),
374
+ 去空、去重保序;数量 ≤5,逐个过 normalize_label。"""
375
+ where = f"{tid}: " if tid else ""
376
+ tags: list[str] = []
377
+ for part in re.split(r"[,,、]", raw):
378
+ if not part.strip():
379
+ continue
380
+ tag = normalize_label(part, field=field, tid=tid)
381
+ if tag not in tags:
382
+ tags.append(tag)
383
+ if not tags:
384
+ raise Fail(f"{where}{field}不能为空。")
385
+ if len(tags) > TAGS_MAX_COUNT:
386
+ raise Fail(
387
+ f"{where}{field}最多 {TAGS_MAX_COUNT} 个,收到 {len(tags)} 个:{'、'.join(tags)}"
388
+ )
389
+ return tags
390
+
391
+
392
+ def esc_cell(text: str) -> str:
393
+ """表格单元格:折叠空白并转义竖线,避免把历史表格写坏。"""
394
+ return " ".join(text.split()).replace("|", "\\|")
395
+
396
+
397
+ def owner_cell(owner: str | None) -> str:
398
+ """owner 列的展示值:人名前加 @(一眼看出这是个人),空给空串。
399
+
400
+ @ 只是展示层约定:存储(progress.json)与 --owner 匹配始终用裸名字,
401
+ parse_history 读回来时会把 @ 剥掉。
402
+ """
403
+ return f"@{owner}" if owner else ""
404
+
405
+
406
+ def history_line(
407
+ finished: str,
408
+ created: str | None,
409
+ title: str,
410
+ owner: str | None,
411
+ summary: str,
412
+ log_rel: str,
413
+ category: str | None = None,
414
+ tags: list[str] | None = None,
415
+ ) -> str:
416
+ """历史层的一行(8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / 完成了什么 / 过程文档)。
417
+
418
+ close 专用,避免两处格式漂移。owner 是独立列、单独过 esc_cell ——
419
+ 它来自 git config user.name,含 `|` 也不稀奇。分类/标签由 close 传 task 终值。
420
+ """
421
+ cat_cell = esc_cell(category or "")
422
+ tag_cell = esc_cell("、".join(tags) if tags else "")
423
+ return (
424
+ f"| {finished} | {created or ''} | {esc_cell(title)} | {cat_cell} | {tag_cell} "
425
+ f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
426
+ )
427
+
428
+
429
+ def history_line_for_cols(
430
+ cols: int,
431
+ finished: str,
432
+ created: str | None,
433
+ title: str,
434
+ owner: str | None,
435
+ summary: str,
436
+ log_rel: str,
437
+ category: str | None = None,
438
+ tags: list[str] | None = None,
439
+ ) -> str:
440
+ """「历史层有无法解析的行、不敢整体升级」时,按现有列数追加一行,避免列错位。
441
+
442
+ GFM 按首行列数渲染,列数写错会被静默丢弃 / 错位。cols<=4 ⇒ 旧 4 列(owner 缝在
443
+ 工作包末尾);5 ⇒ 无创建时间列;6 ⇒ 无分类/标签列(存量改造前的最后形态);
444
+ 否则 8 列。分类/标签只在 8 列里出现 —— 少列的文件硬塞会把整行挤歪。
445
+ """
446
+ if cols <= 4:
447
+ who = f" @{owner}" if owner else ""
448
+ return (
449
+ f"| {finished} | {esc_cell(title + who)} | {esc_cell(summary)} "
450
+ f"| [{log_rel}]({log_rel}) |\n"
451
+ )
452
+ if cols == 5:
453
+ return (
454
+ f"| {finished} | {esc_cell(title)} | {esc_cell(owner_cell(owner))} "
455
+ f"| {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
456
+ )
457
+ if cols == 6:
458
+ return (
459
+ f"| {finished} | {created or ''} | {esc_cell(title)} "
460
+ f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
461
+ )
462
+ return history_line(
463
+ finished, created, title, owner, summary, log_rel, category, tags
464
+ )
465
+
466
+
467
+ def history_cols(text: str) -> int:
468
+ """现有历史表格数据行的列数(取众数、平局取较大);无数据行时按 6。"""
469
+ counts: dict[int, int] = {}
470
+ for raw in text.splitlines():
471
+ if not raw.strip().startswith("|"):
472
+ continue
473
+ cells = split_row(raw)
474
+ if not cells or _is_sep_row(cells) or _looks_like_header(cells):
475
+ continue
476
+ n = len(cells)
477
+ if n >= 4:
478
+ counts[n] = counts.get(n, 0) + 1
479
+ if not counts:
480
+ return 6
481
+ return max(counts, key=lambda k: (counts[k], k))
482
+
483
+
484
+ # 旧行切 owner:最右一个「空格 + @」之后的整段是 owner(owner 可含空格,git user.name 合法)。
485
+ # 要求 @ 前有空白 ⇒ 标题里的 `rulemux@0.1.0` 不会误伤;贪婪 title 保证最后一个「 @」赢。
486
+ HISTORY_OLD_OWNER_RE = re.compile(r"^(?P<title>.*\S)\s+@(?P<owner>\S.*)$")
487
+
488
+
489
+ def split_row(line: str) -> list[str]:
490
+ """按 | 切表格行。反顾后视让转义竖线 \\| 不当分隔符;切完再反转义、去首尾空单元。"""
491
+ cells = re.split(r"(?<!\\)\|", line.strip())
492
+ if cells and cells[0] == "":
493
+ cells = cells[1:]
494
+ if cells and cells[-1] == "":
495
+ cells = cells[:-1]
496
+ return [c.strip().replace("\\|", "|") for c in cells]
497
+
498
+
499
+ def _is_sep_row(cells: list[str]) -> bool:
500
+ return bool(cells) and all(re.fullmatch(r":?-+:?", c) for c in cells)
501
+
502
+
503
+ def _looks_like_header(cells: list[str]) -> bool:
504
+ # 按内容判、不按位置判:无表头的老文件首行就是数据,位置判断会把第一条记录当表头删掉。
505
+ # v6 表头首列是「结案时间」,「工作包」在第 3 列;v4/v5 首列是「时间」、「工作包」在第 2 列。
506
+ return bool(cells) and (cells[0] in ("时间", "结案时间") or "工作包" in cells[:3])
507
+
508
+
509
+ def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
510
+ """解析历史层,返回 (records, warnings)。
511
+
512
+ 多格式兼容:8 列 = v8(结案时间 / 创建时间 / 工作包 / 分类 / 标签 / …);6 列 = v6
513
+ (存量改造前最后形态,无分类/标签);5 列且第 2 列是时间 = 旧 ledger 格式
514
+ (有创建时间列、无 owner 列);5 列 = 无创建时间列;4 列 = 最旧(owner 缝在工作包末尾)。
515
+ **逐行按自己的列数判别**,表头只用来跳过 —— 旧文件追加新列
516
+ 后是混排态,按表头判列数会切错。坏行(列数不在 4/5/6/8、时间列不合法)不中断:跳过 + 警告。
517
+ """
518
+ records: list[dict[str, Any]] = []
519
+ warnings: list[str] = []
520
+ for lineno, raw in enumerate(text.splitlines(), 1):
521
+ if not raw.strip().startswith("|"):
522
+ continue
523
+ cells = split_row(raw)
524
+ if not cells or _is_sep_row(cells) or _looks_like_header(cells):
525
+ continue
526
+ created = ""
527
+ category: str | None = None
528
+ tags: list[str] = []
529
+ if len(cells) == 8:
530
+ finished, created, workpackage, category, tag_cell, owner, summary, doc = (
531
+ cells
532
+ )
533
+ fmt = "v8"
534
+ category = category.strip() or None
535
+ tags = [t.strip() for t in re.split(r"[,,、]", tag_cell) if t.strip()]
536
+ elif len(cells) == 6:
537
+ finished, created, workpackage, owner, summary, doc = cells
538
+ fmt = "v6"
539
+ elif len(cells) == 5 and TIME_RE.fullmatch(cells[1]):
540
+ # 旧 ledger 格式(结案时间 / 创建时间 / 工作包 / 摘要 / 过程文档):无 owner 列。
541
+ # 与下面的 v5(无创建时间列、owner 缝在中间)靠「第 2 列是不是时间」区分 ——
542
+ # v5 的第 2 列是工作包(标题),不会是时间,判别无歧义。
543
+ finished, created, workpackage, summary, doc = cells
544
+ owner = ""
545
+ fmt = "v5ledger"
546
+ elif len(cells) == 5:
547
+ finished, workpackage, owner, summary, doc = cells
548
+ fmt = "v5"
549
+ elif len(cells) == 4:
550
+ finished, workpackage, summary, doc = cells
551
+ m = HISTORY_OLD_OWNER_RE.match(workpackage)
552
+ if m:
553
+ workpackage, owner = m.group("title"), m.group("owner")
554
+ else:
555
+ owner = ""
556
+ fmt = "v4"
557
+ else:
558
+ warnings.append(f"第 {lineno} 行列数不是 4 / 5 / 6 / 8,已跳过")
559
+ continue
560
+ if not TIME_RE.fullmatch(finished):
561
+ warnings.append(f"第 {lineno} 行时间列 {finished!r} 不合法,已跳过")
562
+ continue
563
+ m = re.fullmatch(r"\[[^\]]*\]\(([^)]*)\)", doc)
564
+ log = m.group(1) if m else doc
565
+ # 展示层写入带 @;存储与匹配一律裸名字
566
+ own = (owner or "").strip().removeprefix("@")
567
+ records.append(
568
+ {
569
+ "time": finished,
570
+ "created": time_key(created) if created else None,
571
+ "workpackage": workpackage,
572
+ "category": category,
573
+ "tags": tags,
574
+ "owner": own or None,
575
+ "summary": summary,
576
+ "log": log or None,
577
+ "fmt": fmt,
578
+ "line": lineno,
579
+ }
580
+ )
581
+ return records, warnings
582
+
583
+
584
+ # worklog 头部「创建」行:`> **创建**:2026-10-06 00:00(@某人)`。用于给旧历史行回填创建时间。
585
+ WORKLOG_CREATED_RE = re.compile(
586
+ r"^\s*>\s*\*\*创建\*\*\s*[::]\s*"
587
+ r"(\d{4}-\d{2}-\d{2}(?: +\d{1,2}:\d{1,2}(?::\d{1,2})?)?)"
588
+ )
589
+
590
+
591
+ def created_for_history(log_cell: str, history_path: Path) -> str | None:
592
+ """顺着历史行的文档链接找到 worklog,读它头部「创建」行的时间;找不到返回 None。
593
+
594
+ 链接相对历史文件所在目录解析:存量平铺文件里是 `worklog/…`,按月文件里是 `../worklog/…`。
595
+ """
596
+ m = re.fullmatch(r"\[[^\]]*\]\(([^)]*)\)", log_cell.strip())
597
+ rel = (m.group(1) if m else log_cell).strip()
598
+ if not rel:
599
+ return None
600
+ try:
601
+ text = (history_path.parent / rel).read_text(encoding="utf-8")
602
+ except (OSError, UnicodeDecodeError):
603
+ return None
604
+ for ln in text.splitlines():
605
+ mm = WORKLOG_CREATED_RE.match(ln)
606
+ if mm:
607
+ try:
608
+ return normalize_time(mm.group(1), field="创建")
609
+ except Fail:
610
+ return None
611
+ return None
612
+
613
+
614
+ def labels_for_log(
615
+ log_cell: str | None, history_path: Path
616
+ ) -> tuple[str | None, list[str]]:
617
+ """顺着历史行的文档链接找 worklog,解析头部「分类/标签」行;读不到返回空。
618
+
619
+ 接受纯路径或 markdown 链接单元格(`[x](y)`)—— cmd_history 传解好的路径,
620
+ upgrade_history 传原始单元格,两处都走这里。
621
+ """
622
+ if not log_cell:
623
+ return (None, [])
624
+ m = re.fullmatch(r"\[[^\]]*\]\(([^)]*)\)", log_cell.strip())
625
+ rel = (m.group(1) if m else log_cell).strip()
626
+ if not rel:
627
+ return (None, [])
628
+ try:
629
+ text = (history_path.parent / rel).read_text(encoding="utf-8")
630
+ except (OSError, UnicodeDecodeError):
631
+ return (None, [])
632
+ return parse_label_line(text)
633
+
634
+
635
+ def upgrade_history(text: str, history_path: Path) -> tuple[str, int] | None:
636
+ """旧格式(4 / 5 / 6 列)→ v8(8 列)。全部数据行可解析才返回 (新文本, 转换行数),否则 None。
637
+
638
+ None 的语义是「不敢动」:调用方必须退回按原列数追加并明示,绝不半升级、绝不猜。
639
+ 分类/标签列:回链每行的 worklog 头部读「分类/标签」行(读不到留空,不编造)——
640
+ 与 created 的回填同一模式,依赖 Step 2 已给存量 worklog 补行。
641
+ 缺创建时间的旧行:先读它链接的 worklog 头部「创建」行,找不到用升级时刻 —— 不允许留空。
642
+ 5 列有两种(判别与 parse_history 一致,靠「第 2 列是不是时间」):旧 ledger 格式
643
+ (结案 / 创建 / 工作包 / 摘要 / 文档,无 owner)行内自带创建时间,优先用它;
644
+ v5(结案 / 工作包 / owner / 摘要 / 文档,无创建时间)才去 worklog 头找。
645
+ 只重排表格行;标题、空行、表头外的散文原样保留。
646
+ history_path 只用于按文件所在目录解析文档链接。
647
+ """
648
+ fallback = now()
649
+ out: list[str] = []
650
+ converted = 0
651
+ for raw in text.splitlines():
652
+ if not raw.strip().startswith("|"):
653
+ out.append(raw)
654
+ continue
655
+ cells = split_row(raw)
656
+ if not cells:
657
+ out.append(raw)
658
+ continue
659
+ if _is_sep_row(cells):
660
+ out.append(HISTORY_SEP)
661
+ continue
662
+ if _looks_like_header(cells):
663
+ out.append(HISTORY_HEADER)
664
+ continue
665
+ if len(cells) == 8:
666
+ # v8 行原样回写:内容已是最终格式(含分类/标签)
667
+ if time_key(cells[0]) is None:
668
+ return None
669
+ out.append("| " + " | ".join(cells) + " |")
670
+ continue
671
+ explicit_created: str | None = None
672
+ if len(cells) == 6:
673
+ finished, created, workpackage, owner, summary, doc = cells
674
+ # v6 升级:owner 单元格已是展示格式(带 @),剥掉再走 owner_cell,否则升级出 @@
675
+ owner = owner.lstrip("@")
676
+ explicit_created = time_key(created.strip())
677
+ elif len(cells) == 5 and TIME_RE.fullmatch(cells[1]):
678
+ # 旧 ledger 格式(结案时间 / 创建时间 / 工作包 / 摘要 / 过程文档):无 owner 列。
679
+ # 与下面的 v5(无创建时间列、owner 缝在中间)靠「第 2 列是不是时间」区分 ——
680
+ # v5 的第 2 列是工作包(标题),不会是时间,判别无歧义。
681
+ finished, created_cell, workpackage, summary, doc = cells
682
+ owner = ""
683
+ explicit_created = time_key(created_cell.strip())
684
+ elif len(cells) == 5:
685
+ finished, workpackage, owner, summary, doc = cells
686
+ # v5 行 owner 列可能已是展示格式(带 @),剥掉再走 owner_cell,否则升级出 @@
687
+ owner = owner.lstrip("@")
688
+ elif len(cells) == 4:
689
+ finished, workpackage, summary, doc = cells
690
+ m = HISTORY_OLD_OWNER_RE.match(workpackage)
691
+ owner = m.group("owner") if m else ""
692
+ if m:
693
+ workpackage = m.group("title")
694
+ else:
695
+ return None
696
+ finished_key = time_key(finished)
697
+ if finished_key is None:
698
+ return None
699
+ created = explicit_created or created_for_history(doc, history_path) or fallback
700
+ cat, tags = labels_for_log(doc, history_path)
701
+ out.append(
702
+ f"| {finished_key} | {created} | {esc_cell(workpackage)} "
703
+ f"| {esc_cell(cat or '')} | {esc_cell('、'.join(tags) if tags else '')} "
704
+ f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | {doc} |"
705
+ )
706
+ converted += 1
707
+ return "\n".join(out) + "\n", converted
708
+
709
+
710
+ # ---------- 历史层:按结案时间分月归档 ----------
711
+
712
+
713
+ def history_path_for(repo: Path, finished: str) -> Path:
714
+ """结案时间(YYYY-MM-DD HH:MM:SS)→ ledger/history/<YYYYMM>.md。
715
+
716
+ 与 worklog 的 ledger/worklog/<年月>/ 同构:一个按创建月、一个按结案月。
717
+ """
718
+ month = re.sub(r"[^0-9]", "", finished[:7])[:6]
719
+ if len(month) != 6:
720
+ raise Fail(f"结案时间 {finished!r} 取不到年月,无法路由历史文件。")
721
+ return repo / HISTORY_DIR / f"{month}.md"
722
+
723
+
724
+ def history_sources(repo: Path) -> list[Path]:
725
+ """全部历史来源(读侧用):存量平铺文件 + ledger/history/*.md,按路径排序。
726
+
727
+ 存量文件只读 —— 写侧永远不碰它,它停在原地、链接基准(相对 ledger/)不变。
728
+ """
729
+ paths: list[Path] = []
730
+ legacy = repo / HISTORY_REL
731
+ if legacy.exists():
732
+ paths.append(legacy)
733
+ hdir = repo / HISTORY_DIR
734
+ if hdir.is_dir():
735
+ paths.extend(sorted(p for p in hdir.glob("*.md") if p.is_file()))
736
+ return paths
737
+
738
+
739
+ # 数行计数的封顶值(2026-10-17 为 dsh-task-ledger 看板统计卡新增):显示级统计封顶,
740
+ # 超出即 capped,精确值走 history --json / 列表。
741
+ COUNT_CAP = 10000
742
+
743
+
744
+ def history_row_count(
745
+ sources: list[Path], since: str | None, until: str | None, cap: int = COUNT_CAP
746
+ ) -> dict[str, Any]:
747
+ """数行计数(只读;2026-10-17 新增,服务看板「本周 / 近一年完成」统计卡)。
748
+
749
+ 只数数据行(`| 2` 开头 = 结案时间以年份开头),到 cap 封顶返回 capped=True。
750
+ 给了 since/until 时按创建时间列(第 2 列)粗筛——取日期部分做字符串比较
751
+ (ISO 日期字典序即时间序),不建对象、不判格式,比 parse_history 便宜一个量级。
752
+ ⚠️ 显示级统计:坏行 / 旧格式行可能多数少数,精确数以 history --json 为准。
753
+ """
754
+ since_d = (since or "")[:10]
755
+ until_d = (until or "")[:10]
756
+ total = 0
757
+ for hp in sources:
758
+ try:
759
+ text = hp.read_text(encoding="utf-8-sig")
760
+ except (OSError, UnicodeDecodeError):
761
+ continue
762
+ for line in text.splitlines():
763
+ if not line.startswith("| 2"):
764
+ continue
765
+ if since_d or until_d:
766
+ cells = line.split("|")
767
+ if len(cells) < 4:
768
+ continue
769
+ created = cells[2].strip()[:10]
770
+ if since_d and created < since_d:
771
+ continue
772
+ if until_d and created > until_d:
773
+ continue
774
+ total += 1
775
+ if total >= cap:
776
+ return {"count": total, "capped": True}
777
+ return {"count": total, "capped": False}
778
+
779
+
780
+ def worklog_link(log_path: Path, history_path: Path) -> str:
781
+ """worklog 相对历史文件所在目录的 posix 链接。
782
+
783
+ 存量平铺文件得 `worklog/<月>/<文件>.md`;按月文件得 `../worklog/<月>/<文件>.md`
784
+ —— 链接跟着所在文件走,两边都可点击、可解析。
785
+ """
786
+ return os.path.relpath(log_path, history_path.parent).replace(os.sep, "/")
787
+
788
+
789
+ def append_history_line(
790
+ history_path: Path,
791
+ finished: str,
792
+ created: str | None,
793
+ title: str,
794
+ owner: str | None,
795
+ summary: str,
796
+ log_rel: str,
797
+ category: str | None = None,
798
+ tags: list[str] | None = None,
799
+ ) -> tuple[str, int]:
800
+ """把一行历史追加到指定历史文件,返回 (新全文, 升级行数)。
801
+
802
+ 读目标 → upgrade_history → 解析不了就按现有列数追加(绝不把 8 列写进少列表头)
803
+ → 文件不存在则带 8 列表头新建。close 专用:「行落哪个月」之外,
804
+ 历史行的落盘逻辑只此一处。分类/标签由 close 传 task 终值。
805
+ """
806
+ if not history_path.exists():
807
+ return (
808
+ f"{HISTORY_TITLE}\n\n{HISTORY_HEADER}\n{HISTORY_SEP}\n"
809
+ + history_line(
810
+ finished, created, title, owner, summary, log_rel, category, tags
811
+ ),
812
+ 0,
813
+ )
814
+ try:
815
+ text = history_path.read_text(encoding="utf-8")
816
+ except (OSError, UnicodeDecodeError) as exc:
817
+ raise Fail(f"读不了 {history_path}:{exc}")
818
+ if not text.endswith("\n"):
819
+ text += "\n"
820
+ upgraded = upgrade_history(text, history_path)
821
+ if upgraded is None:
822
+ # 有解析不了的行:不升级、不猜,退回「按现有列数」追加
823
+ print(
824
+ f"警告:{history_path.name} 有无法解析的行,未做格式升级,本次按现有列数追加。",
825
+ file=sys.stderr,
826
+ )
827
+ return (
828
+ text
829
+ + history_line_for_cols(
830
+ history_cols(text),
831
+ finished,
832
+ created,
833
+ title,
834
+ owner,
835
+ summary,
836
+ log_rel,
837
+ category,
838
+ tags,
839
+ ),
840
+ 0,
841
+ )
842
+ new_text, n = upgraded
843
+ return (
844
+ new_text
845
+ + history_line(
846
+ finished, created, title, owner, summary, log_rel, category, tags
847
+ ),
848
+ n,
849
+ )
850
+
851
+
852
+ def slugify(title: str) -> str:
853
+ """任务号用:保留 ASCII 字母数字与中文汉字,其余转连字符。"""
854
+ s = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", title.strip().lower())
855
+ return s.strip("-")[:40].strip("-")
856
+
857
+
858
+ def id_stamp(created: str) -> str:
859
+ """从 created(YYYY-MM-DD HH:MM:SS)取任务号前缀 YYYYMMDD-HHMM(任务号只到分)。"""
860
+ return (
861
+ f"{created[0:4]}{created[5:7]}{created[8:10]}-{created[11:13]}{created[14:16]}"
862
+ )
863
+
864
+
865
+ def make_id(title: str, tasks: dict[str, Any], created: str | None = None) -> str:
866
+ slug = slugify(title)
867
+ if not slug:
868
+ raise Fail(
869
+ "标题里没有可用字符(字母、数字或汉字),无法自动生成任务号。"
870
+ f'用 --id 指定一个,例如 --id "task-{secrets.token_hex(2)}"。'
871
+ )
872
+ # 前置创建时间:年月日-时分(任务号 / 文件名只到分,与记录时间到秒分开)。随机码不能省
873
+ # —— make_id 只在在办任务里去重,而 worklog 结案后仍留在磁盘上,去掉随机码会让新任务与
874
+ # 已结案的过程文档同名、覆盖历史。
875
+ stamp = id_stamp(created) if created else datetime.now().strftime("%Y%m%d-%H%M")
876
+ for _ in range(20):
877
+ tid = f"{stamp}-{slug}-{secrets.token_hex(2)}"
878
+ if tid not in tasks:
879
+ return tid
880
+ raise Fail("任务号连续冲突 20 次,请检查状态表是否异常。")
881
+
882
+
883
+ def load_task(repo: Path, tid: str) -> dict[str, Any]:
884
+ data = read_json(repo / PROGRESS_REL)
885
+ task = data["tasks"].get(tid)
886
+ if task is None:
887
+ raise Fail(f"没有任务 {tid}(可能已被结案或删除)。用 list 看现有任务。")
888
+ return task
889
+
890
+
891
+ def require_owner(task: dict, tid: str, me: str) -> None:
892
+ """只有当前 owner 能变更该任务;owner 取变更前的值,不放行就报错。"""
893
+ owner = task.get("owner")
894
+ if not owner:
895
+ status = task.get("status")
896
+ if status == "open":
897
+ raise Fail(f"任务 {tid} 还没人认领(open)。先认领再操作:claim --id {tid}")
898
+ raise Fail(
899
+ f"任务 {tid} 的 status 是 {status} 却没有 owner,状态表不一致。先跑 validate。"
900
+ )
901
+ if owner != me:
902
+ raise Fail(
903
+ f"任务 {tid} 的 owner 是 {owner},不是你({me})。\n"
904
+ f'要接手得让 {owner} 先交接:set --id {tid} --owner "{me}"'
905
+ )
906
+
907
+
908
+ # 头部「分类/标签」行(label_line 生成、parse_label_line 解析,两处共用一个格式):
909
+ # `> **分类**:缺陷修复 | **标签**:镜像拉取、Hindsight`
910
+ LABEL_CAT_RE = re.compile(r"分类\*\*\s*[::]\s*([^||]+)")
911
+ LABEL_TAG_RE = re.compile(r"标签\*\*\s*[::]\s*(.+)$")
912
+
913
+
914
+ def label_line(category: str | None, tags: list[str] | None) -> str:
915
+ """worklog 头部的「分类/标签」行;两者皆空返回空串(此行不写)。"""
916
+ segs = []
917
+ if category:
918
+ segs.append(f"**分类**:{category}")
919
+ if tags:
920
+ segs.append(f"**标签**:{'、'.join(tags)}")
921
+ return "> " + " | ".join(segs) if segs else ""
922
+
923
+
924
+ def _insert_after_head(lines: list[str], line: str) -> list[str]:
925
+ """把一行插到「创建」行之后(头部引用块内);找不到创建行就插标题后。"""
926
+ at = next((i for i, ln in enumerate(lines) if WORKLOG_CREATED_RE.match(ln)), None)
927
+ if at is None:
928
+ at = 1 if lines and lines[0].startswith("# ") else 0
929
+ lines.insert(at + 1, line)
930
+ return lines
931
+
932
+
933
+ def parse_label_line(text: str) -> tuple[str | None, list[str]]:
934
+ """从 worklog 头部(第一个 `## ` 小节之前)解析「分类/标签」行;解析不到返回空。"""
935
+ for ln in text.splitlines():
936
+ if ln.startswith("## "):
937
+ break
938
+ mc = LABEL_CAT_RE.search(ln)
939
+ mt = LABEL_TAG_RE.search(ln)
940
+ if not (mc or mt):
941
+ continue
942
+ cat = mc.group(1).strip() if mc else None
943
+ tags = (
944
+ [t.strip() for t in re.split(r"[,,、]", mt.group(1).strip()) if t.strip()]
945
+ if mt
946
+ else []
947
+ )
948
+ return (cat or None, tags)
949
+ return (None, [])
950
+
951
+
952
+ def rewrite_label_line(text: str, category: str | None, tags: list[str]) -> str:
953
+ """改写 worklog 头部的「分类/标签」行:有就地替换,没有就插到创建行后(close 覆盖用)。"""
954
+ line = label_line(category, tags)
955
+ lines = text.splitlines()
956
+ for i, ln in enumerate(lines):
957
+ if ln.startswith("## "):
958
+ break
959
+ if LABEL_CAT_RE.search(ln) or LABEL_TAG_RE.search(ln):
960
+ if line:
961
+ lines[i] = line
962
+ else:
963
+ del lines[i]
964
+ return "\n".join(lines) + "\n"
965
+ if not line:
966
+ return text
967
+ return "\n".join(_insert_after_head(lines, line)) + "\n"
968
+
969
+
970
+ def render_worklog(tid: str, task: dict[str, Any]) -> str:
971
+ template = FALLBACK_TEMPLATE
972
+ try:
973
+ if WORKLOG_TEMPLATE.exists():
974
+ template = WORKLOG_TEMPLATE.read_text(encoding="utf-8")
975
+ except (OSError, UnicodeDecodeError):
976
+ template = FALLBACK_TEMPLATE
977
+ text = (
978
+ template.replace("<id>", tid)
979
+ .replace("<title>", task.get("title") or "")
980
+ .replace("<created>", task.get("created") or "")
981
+ .replace("<creator>", task.get("creator") or "")
982
+ )
983
+ # 分类/标签行走动态插入(不占模板占位符):两个模板都不用改,空值时自动不写行
984
+ meta = label_line(task.get("category"), task.get("tags") or [])
985
+ if not meta:
986
+ return text
987
+ return "\n".join(_insert_after_head(text.splitlines(), meta)) + "\n"
988
+
989
+
990
+ def worklog_dir_for(task: dict[str, Any]) -> Path:
991
+ """按月归档目录:ledger/worklog/<YYYYMM>/。取任务创建月份,取不到就退回当前月。"""
992
+ created = (task.get("created") or "").strip()
993
+ month = re.sub(r"[^0-9]", "", created[:7])[:6]
994
+ if len(month) != 6:
995
+ month = datetime.now().strftime("%Y%m")
996
+ return WORKLOG_DIR / month
997
+
998
+
999
+ def worklog_name(tid: str) -> str:
1000
+ """过程文档的文件名:任务号剥掉前缀的年月(YYYYMM)。
1001
+
1002
+ 父目录 worklog/<YYYYMM>/ 已经带了年月,文件名里再写一遍是冗余,所以只留
1003
+ 日-时分-说明-随机码:任务号 20261006-0000-doc-arch-review-7a3c(前 8 位
1004
+ 是年月日)得到 06-0000-doc-arch-review-7a3c.md。随机码保留:结案文件长期
1005
+ 留在磁盘上,同一分钟内建两个同标题任务仍要能不撞名。
1006
+ 自定义 --id 不以「8 位数字-」开头时,文件名就是任务号本身。
1007
+ """
1008
+ if len(tid) > 9 and tid[:8].isdigit() and tid[8] == "-":
1009
+ return f"{tid[6:]}.md"
1010
+ return f"{tid}.md"
1011
+
1012
+
1013
+ def worklog_rel(tid: str, task: dict[str, Any]) -> str:
1014
+ """过程文档相对 ledger/ 的路径。状态表记它,历史层也记它,两处同口径。"""
1015
+ return (
1016
+ (worklog_dir_for(task) / worklog_name(tid))
1017
+ .relative_to(WORKLOG_DIR.parent)
1018
+ .as_posix()
1019
+ )
1020
+
1021
+
1022
+ def find_worklog(
1023
+ repo: Path, tid: str, task: dict[str, Any] | None = None
1024
+ ) -> Path | None:
1025
+ """定位任务的过程文档。
1026
+
1027
+ 先信状态表里记的路径(记了就不猜);没记再按新命名推算;最后回退到
1028
+ 「文件名就是任务号」的老命名,并兼顾平铺旧位置 —— 规则改动前建的文件
1029
+ 留在原地不动,照样能读能封卷。
1030
+ """
1031
+ if task:
1032
+ recorded = task.get("worklog")
1033
+ if recorded:
1034
+ cand = repo / WORKLOG_DIR.parent / recorded
1035
+ if cand.exists():
1036
+ return cand
1037
+ parent = repo / WORKLOG_DIR
1038
+ names = (worklog_name(tid), f"{tid}.md")
1039
+ dirs = [parent]
1040
+ if parent.is_dir():
1041
+ dirs += [d for d in sorted(parent.iterdir()) if d.is_dir()]
1042
+ for d in dirs:
1043
+ for name in names:
1044
+ cand = d / name
1045
+ if cand.exists():
1046
+ return cand
1047
+ return None
1048
+
1049
+
1050
+ def write_worklog(repo: Path, tid: str, task: dict[str, Any]) -> Path:
1051
+ log = repo / worklog_dir_for(task) / worklog_name(tid)
1052
+ write_text_atomic(log, render_worklog(tid, task))
1053
+ return log
1054
+
1055
+
1056
+ def print_worklog(repo: Path, tid: str, task: dict[str, Any] | None = None) -> None:
1057
+ """把该任务的过程文档全文打出来;读不到只提示,不拖垮主操作。"""
1058
+ log = find_worklog(repo, tid, task)
1059
+ if log is None:
1060
+ print(
1061
+ f"\n--- 缺少 {WORKLOG_DIR}/<年月>/{worklog_name(tid)}(跑 validate 看看) ---"
1062
+ )
1063
+ return
1064
+ try:
1065
+ body = log.read_text(encoding="utf-8").rstrip()
1066
+ except (OSError, UnicodeDecodeError) as exc:
1067
+ print(f"\n--- 读不了 {log.relative_to(repo)}:{exc} ---", file=sys.stderr)
1068
+ return
1069
+ print(f"\n--- {log.relative_to(repo)} ---")
1070
+ print(body)
1071
+ print("--- 以上是该任务的过程文档,直接编辑这个文件即可 ---")
1072
+
1073
+
1074
+ def stamp_frozen(text: str, finished: str, finisher: str | None = "") -> str:
1075
+ """把 worklog 标成已封卷:封卷标记并进头部引用块。"""
1076
+ lines = text.splitlines()
1077
+ who = f"(@{finisher})" if finisher else ""
1078
+ marker = f"> **{FROZEN_MARK}**:{finished}{who}"
1079
+ at = next((i for i, ln in enumerate(lines) if ln.startswith(">")), None)
1080
+ if at is None:
1081
+ # 只认一级标题;`## 背景` 这类小节标题不算,否则标记会插进正文里面。
1082
+ at = 1 if lines and lines[0].startswith("# ") else 0
1083
+ lines.insert(at, marker)
1084
+ return "\n".join(lines) + "\n"
1085
+
1086
+
1087
+ # ---------- 子命令 ----------
1088
+
1089
+
1090
+ def cmd_init(args: argparse.Namespace) -> None:
1091
+ repo = find_repo(args.repo)
1092
+ progress = repo / PROGRESS_REL
1093
+ with progress_lock(progress):
1094
+ if progress.exists():
1095
+ print(f"已存在,未改动:{progress.relative_to(repo)}")
1096
+ return
1097
+ before, data = read_guarded(progress)
1098
+ data.setdefault("tasks", {})
1099
+ commit_guarded(progress, before, data)
1100
+ print(f"已创建 {progress.relative_to(repo)}")
1101
+ print("按需创建的目录(worklog/、history/ 等)留到实际有内容时再建。")
1102
+
1103
+
1104
+ def cmd_new(args: argparse.Namespace) -> None:
1105
+ repo = find_repo(args.repo)
1106
+ path = repo / PROGRESS_REL
1107
+ if not path.exists():
1108
+ raise Fail(f"{PROGRESS_REL} 不存在,先跑 init。")
1109
+ creator = git_user()
1110
+ created = normalize_time(args.created, field="--created") if args.created else now()
1111
+ # 参数自检前移:分类/标签形态校验在锁外做,失败的调用不该占着锁
1112
+ category = (
1113
+ normalize_label(args.category, field="--category") if args.category else None
1114
+ )
1115
+ tags = parse_tags(args.tags, field="--tags") if args.tags else []
1116
+
1117
+ with progress_lock(path):
1118
+ before, data = read_guarded(path)
1119
+ tasks = data["tasks"]
1120
+
1121
+ tid = args.id or make_id(args.title, tasks, created)
1122
+ require_task_id(tid)
1123
+ if tid in tasks:
1124
+ raise Fail(f"任务号 {tid} 已存在。")
1125
+
1126
+ stamp = created
1127
+ task = {
1128
+ "title": args.title,
1129
+ "creator": creator,
1130
+ "owner": creator if args.self else None,
1131
+ "status": "doing" if args.self else "open",
1132
+ "created": stamp,
1133
+ "started": stamp if args.self else None,
1134
+ "finished": None,
1135
+ "category": category,
1136
+ "tags": tags,
1137
+ }
1138
+ # 路径先算好再落盘:文件名不等于任务号,状态表不记就只能靠约定去猜。
1139
+ task["worklog"] = worklog_rel(tid, task)
1140
+ tasks[tid] = task
1141
+ commit_guarded(path, before, data)
1142
+ try:
1143
+ log = write_worklog(repo, tid, task)
1144
+ except Fail as exc:
1145
+ # 过程文档建不出来就把刚写进去的任务撤掉,别留下「有任务、没文档」
1146
+ tasks.pop(tid, None)
1147
+ try:
1148
+ write_json(path, data)
1149
+ except Fail:
1150
+ pass
1151
+ raise Fail(f"创建失败,已从状态表撤销该任务:{exc}")
1152
+ print(f"已创建任务 {tid}")
1153
+ print(f" 标题 {task['title']}")
1154
+ print(f" 状态 {task['status']}")
1155
+ print(f" creator @{creator}")
1156
+ owner_show = f"@{task['owner']}" if task["owner"] else "(待认领)"
1157
+ print(f" owner {owner_show}")
1158
+ if category or tags:
1159
+ print(f" 分类 {category or '—'}")
1160
+ if tags:
1161
+ print(f" 标签 {'、'.join(tags)}")
1162
+ print(f" worklog {log.relative_to(repo)}")
1163
+ if not args.self:
1164
+ print(f"\n空开状态。谁做谁认领:progress.py claim --id {tid}")
1165
+ print_worklog(repo, tid, task)
1166
+
1167
+
1168
+ def cmd_claim(args: argparse.Namespace) -> None:
1169
+ repo = find_repo(args.repo)
1170
+ tid = require_task_id(args.id)
1171
+ owner = git_user() # 先取身份:拿不到就直接报错,不进入后续判断
1172
+ path = repo / PROGRESS_REL
1173
+ with progress_lock(path):
1174
+ before, data = read_guarded(path)
1175
+ task = data["tasks"].get(tid)
1176
+ if task is None:
1177
+ raise Fail(f"没有任务 {tid}。")
1178
+ if task.get("owner"):
1179
+ raise Fail(
1180
+ f"任务 {tid} 状态不一致:owner 已是 {task['owner']},但 status 是 {task.get('status')}。先跑 validate。"
1181
+ )
1182
+ if task.get("status") != "open":
1183
+ raise Fail(
1184
+ f"任务 {tid} 当前状态是 {task.get('status')},只有 open 才能认领。"
1185
+ )
1186
+
1187
+ task["owner"] = owner
1188
+ task["status"] = "doing"
1189
+ task["started"] = task.get("started") or now()
1190
+ commit_guarded(path, before, data)
1191
+ print(f"已认领 {tid} → doing,owner = @{owner}")
1192
+ print_worklog(repo, tid, task)
1193
+
1194
+
1195
+ def cmd_set(args: argparse.Namespace) -> None:
1196
+ # 参数自检全部前移:必然失败的调用不该先去起 git 子进程、读文件
1197
+ if args.status is None and args.owner is None:
1198
+ raise Fail("至少要给 --status 或 --owner 之一。结案请用 close。")
1199
+ if args.status is not None and not args.status.strip():
1200
+ raise Fail(f"--status 不能为空;可选 {' / '.join(STATUSES)}")
1201
+ if args.owner is not None and not args.owner.strip():
1202
+ raise Fail("--owner 不能为空。")
1203
+ status = args.status.strip() if args.status else None
1204
+ if status == "open" and args.owner is not None:
1205
+ raise Fail(
1206
+ "--status open(退回未认领)不能同时给 --owner;要交给别人就别改回 open。"
1207
+ )
1208
+ if status is not None and status not in STATUSES:
1209
+ raise Fail(f"--status 只能是 {' / '.join(STATUSES)};结案请用 close。")
1210
+ if status == "done":
1211
+ raise Fail(
1212
+ "不要把状态直接改成 done —— 用 close 结案(它会同时移出状态、追加历史、封卷 worklog)。"
1213
+ )
1214
+
1215
+ repo = find_repo(args.repo)
1216
+ tid = require_task_id(args.id)
1217
+ me = git_user()
1218
+ path = repo / PROGRESS_REL
1219
+ with progress_lock(path):
1220
+ before, data = read_guarded(path)
1221
+ task = data["tasks"].get(tid)
1222
+ if task is None:
1223
+ raise Fail(f"没有任务 {tid}。")
1224
+ require_owner(task, tid, me)
1225
+
1226
+ changed = []
1227
+ if status is not None:
1228
+ task["status"] = status
1229
+ if status == "open":
1230
+ task["owner"] = None
1231
+ task["started"] = None
1232
+ changed.append(f"status={status}")
1233
+
1234
+ if args.owner is not None:
1235
+ old = task.get("owner")
1236
+ task["owner"] = args.owner.strip().lstrip(
1237
+ "@"
1238
+ ) # 容忍「@名字」写法;存储与匹配用裸名
1239
+ if task.get("status") == "open":
1240
+ task["status"] = "doing"
1241
+ task["started"] = task.get("started") or now()
1242
+ changed.append(f"owner {old or '(无)'} → {task['owner']}")
1243
+
1244
+ commit_guarded(path, before, data)
1245
+ print(f"已更新 {tid}:" + ",".join(changed))
1246
+ print_worklog(repo, tid, task)
1247
+
1248
+
1249
+ def cmd_close(args: argparse.Namespace) -> None:
1250
+ repo = find_repo(args.repo)
1251
+ tid = require_task_id(args.id)
1252
+ me = git_user()
1253
+ progress_path = repo / PROGRESS_REL
1254
+ # 参数自检前移:--category / --tags 的形态校验在锁外做(None = 没传,沿用建档值)
1255
+ close_category = (
1256
+ normalize_label(args.category, field="--category") if args.category else None
1257
+ )
1258
+ close_tags = parse_tags(args.tags, field="--tags") if args.tags else None
1259
+
1260
+ with progress_lock(progress_path):
1261
+ before, data = read_guarded(progress_path)
1262
+ task = data["tasks"].get(tid)
1263
+ if task is None:
1264
+ raise Fail(f"没有任务 {tid}(可能已结案)。")
1265
+ require_owner(task, tid, me)
1266
+ if task.get("finished"):
1267
+ raise Fail(f"任务 {tid} 已经结案过了。")
1268
+
1269
+ finished = (
1270
+ normalize_time(args.finished, field="--finished")
1271
+ if args.finished
1272
+ else now()
1273
+ )
1274
+ title = " ".join((task.get("title") or tid).split())
1275
+ summary = " ".join(args.summary.split())
1276
+ if not summary:
1277
+ raise Fail("--summary 不能为空:写清楚「做成了什么」。")
1278
+
1279
+ owner = task.get("owner") or me
1280
+ # 老文件留在平铺位置,新文件在按月子目录;找不到就按新规则推算目标路径。
1281
+ log_path = find_worklog(repo, tid, task) or (
1282
+ repo / worklog_dir_for(task) / worklog_name(tid)
1283
+ )
1284
+ # 历史行按结案时间落月文件;文档链接相对该文件所在目录算(../worklog/…)。
1285
+ history_path = history_path_for(repo, finished)
1286
+ log_rel = worklog_link(log_path, history_path)
1287
+ created_key = time_key(task.get("created")) or ""
1288
+ hist_before = stat_key(history_path)
1289
+ log_before = stat_key(log_path)
1290
+
1291
+ # 三个文件的新内容先在内存里备好
1292
+ new_tasks = dict(data["tasks"])
1293
+ new_tasks.pop(tid)
1294
+
1295
+ bak_path: Path | None = None
1296
+ # 历史行的分类/标签取终值:--category/--tags 覆盖过就用覆盖值,否则用建档值
1297
+ final_cat = (
1298
+ close_category if close_category is not None else task.get("category")
1299
+ )
1300
+ final_tags = close_tags if close_tags is not None else (task.get("tags") or [])
1301
+ new_history, history_upgrade_n = append_history_line(
1302
+ history_path,
1303
+ finished,
1304
+ created_key,
1305
+ title,
1306
+ owner,
1307
+ summary,
1308
+ log_rel,
1309
+ final_cat,
1310
+ final_tags,
1311
+ )
1312
+
1313
+ if log_path.exists():
1314
+ try:
1315
+ log_text = log_path.read_text(encoding="utf-8")
1316
+ except (OSError, UnicodeDecodeError) as exc:
1317
+ raise Fail(f"读不了 {log_path}:{exc}")
1318
+ if close_category is not None or close_tags is not None:
1319
+ # 覆盖走改写头部「分类/标签」行(在封卷之前),真源与展示副本同口径
1320
+ log_text = rewrite_label_line(
1321
+ log_text,
1322
+ close_category
1323
+ if close_category is not None
1324
+ else task.get("category"),
1325
+ close_tags
1326
+ if close_tags is not None
1327
+ else list(task.get("tags") or []),
1328
+ )
1329
+ new_log = stamp_frozen(log_text, finished, owner)
1330
+ else:
1331
+ new_log = stamp_frozen(
1332
+ render_worklog(
1333
+ tid,
1334
+ dict(
1335
+ task,
1336
+ status="done",
1337
+ category=(
1338
+ close_category
1339
+ if close_category is not None
1340
+ else task.get("category")
1341
+ ),
1342
+ tags=(
1343
+ close_tags
1344
+ if close_tags is not None
1345
+ else list(task.get("tags") or [])
1346
+ ),
1347
+ ),
1348
+ ),
1349
+ finished,
1350
+ owner,
1351
+ )
1352
+
1353
+ # 落盘前复查:三个文件都没被别人动过
1354
+ for p, mark in (
1355
+ (progress_path, before),
1356
+ (history_path, hist_before),
1357
+ (log_path, log_before),
1358
+ ):
1359
+ if stat_key(p) != mark:
1360
+ raise Fail(
1361
+ f"{p.relative_to(repo)} 在操作期间被改动过,已中止。重新执行一次。"
1362
+ )
1363
+
1364
+ if history_upgrade_n:
1365
+ # 复查通过才落备份:并发中止时不留孤儿 .bak。成功后删除(内容没丢,只是 owner 换了列)。
1366
+ bak_path = history_path.with_name(history_path.name + ".pre-upgrade.bak")
1367
+ write_text_atomic(bak_path, history_path.read_text(encoding="utf-8"))
1368
+ print(
1369
+ f"历史层已升级为 8 列(补分类/标签,{history_upgrade_n} 行转换),原文件备份在 {bak_path.name}",
1370
+ file=sys.stderr,
1371
+ )
1372
+
1373
+ backups: dict[Path, bytes] = {}
1374
+ created: list[Path] = []
1375
+ for p in (progress_path, history_path, log_path):
1376
+ try:
1377
+ backups[p] = p.read_bytes()
1378
+ except FileNotFoundError:
1379
+ created.append(p)
1380
+ except OSError as exc:
1381
+ raise Fail(f"读不了 {p} 用于备份:{exc}")
1382
+
1383
+ # 顺序:先写历史与过程文档,**最后**才从状态表移除。
1384
+ # 中途崩溃最坏是「历史多一行、状态还留着」,可重跑识别,不会丢记录。
1385
+ try:
1386
+ write_text_atomic(history_path, new_history)
1387
+ write_text_atomic(log_path, new_log)
1388
+ write_json(
1389
+ progress_path, {"tasks": {k: v for k, v in sorted(new_tasks.items())}}
1390
+ )
1391
+ except (OSError, Fail) as exc:
1392
+ restored, failed = [], []
1393
+ history_restored = False
1394
+ for p, content in backups.items():
1395
+ try:
1396
+ p.write_bytes(content)
1397
+ restored.append(str(p.relative_to(repo)))
1398
+ if p == history_path:
1399
+ history_restored = True
1400
+ except OSError as rerr:
1401
+ failed.append(f"{p.relative_to(repo)}({rerr})")
1402
+ for p in created:
1403
+ try:
1404
+ if p.exists():
1405
+ p.unlink()
1406
+ except OSError as rerr:
1407
+ failed.append(f"{p.relative_to(repo)} 清理失败({rerr})")
1408
+ if bak_path is not None and bak_path.exists():
1409
+ if history_restored:
1410
+ try:
1411
+ bak_path.unlink()
1412
+ except OSError:
1413
+ pass
1414
+ else:
1415
+ # 历史层没恢复成功 ⇒ .bak 是最后保障,留着并报告
1416
+ failed.append(
1417
+ f"{bak_path.name}(历史层未恢复成功,升级备份保留待人工处理)"
1418
+ )
1419
+ msg = f"结案失败:{exc}"
1420
+ if restored:
1421
+ msg += f"\n已恢复:{'、'.join(restored)}"
1422
+ if failed:
1423
+ msg += f"\n回滚未完成,请手工检查:{'、'.join(failed)}"
1424
+ raise Fail(msg)
1425
+
1426
+ if bak_path is not None and bak_path.exists():
1427
+ try:
1428
+ bak_path.unlink()
1429
+ except OSError:
1430
+ pass
1431
+
1432
+ print(f"已结案 {tid}")
1433
+ print(f" 从 {PROGRESS_REL} 移出")
1434
+ print(f" 追加到 {history_path.relative_to(repo)}")
1435
+ print(f" 封卷 {log_path.relative_to(repo)}")
1436
+ if final_cat or final_tags:
1437
+ print(f" 分类 {final_cat or '—'}")
1438
+ if final_tags:
1439
+ print(f" 标签 {'、'.join(final_tags)}")
1440
+ print(f"\n摘要:{summary}")
1441
+
1442
+
1443
+ def cmd_list(args: argparse.Namespace) -> None:
1444
+ repo = find_repo(args.repo)
1445
+ path = repo / PROGRESS_REL
1446
+ if not path.exists():
1447
+ raise Fail(f"{PROGRESS_REL} 不存在:这个项目还没初始化,先跑 init。")
1448
+ data = read_json(path)
1449
+
1450
+ wanted = None
1451
+ if args.status is not None:
1452
+ wanted = [s.strip() for s in args.status.split(",") if s.strip()]
1453
+ if not wanted:
1454
+ raise Fail("--status 不能为空;逗号分隔,如 doing,blocked")
1455
+ bad = [s for s in wanted if s not in STATUSES]
1456
+ if bad:
1457
+ raise Fail(
1458
+ f"--status 含非法值 {', '.join(bad)};可选 {' / '.join(STATUSES)}"
1459
+ )
1460
+ if "done" in wanted:
1461
+ print(
1462
+ "提示:done 的任务已结案并移出状态表,用 history 子命令查"
1463
+ "(已结案在 ledger/history/<年月>.md,存量在 ledger/PROGRESS-HISTORY.md)。",
1464
+ file=sys.stderr,
1465
+ )
1466
+
1467
+ since = normalize_time(args.since, field="--since") if args.since else None
1468
+ until = (
1469
+ normalize_time(args.until, field="--until", date_only_ref="23:59:59")
1470
+ if args.until
1471
+ else None
1472
+ )
1473
+
1474
+ me = git_user() if args.mine else None
1475
+
1476
+ rows = []
1477
+ for tid, task in sorted(data["tasks"].items()):
1478
+ if me is not None and task.get("owner") != me:
1479
+ continue
1480
+ if args.owner and task.get("owner") != args.owner.strip().lstrip("@"):
1481
+ continue
1482
+ if wanted is not None and task.get("status") not in wanted:
1483
+ continue
1484
+ # 分类精确匹配(统计轴,同值才能比较);标签包含匹配(容错下钻,吃掉同义变体)
1485
+ if args.category and (task.get("category") or "") != args.category.strip():
1486
+ continue
1487
+ if args.tag:
1488
+ q = args.tag.strip()
1489
+ if not any(q in t for t in (task.get("tags") or [])):
1490
+ continue
1491
+ created = time_key(task.get("created"))
1492
+ if since and (created is None or created < since):
1493
+ continue
1494
+ if until and (created is None or created > until):
1495
+ continue
1496
+ rows.append((tid, task))
1497
+
1498
+ if args.json:
1499
+ print(
1500
+ json.dumps({tid: task for tid, task in rows}, ensure_ascii=False, indent=2)
1501
+ )
1502
+ return
1503
+
1504
+ if not rows:
1505
+ print("没有符合条件的在办任务。")
1506
+ return
1507
+
1508
+ print(f"在办任务 {len(rows)} 条:\n")
1509
+ for tid, task in rows:
1510
+ who = f"@{task['owner']}" if task.get("owner") else "(待认领)"
1511
+ print(f" [{task.get('status')}] {tid}")
1512
+ print(f" {task.get('title')} — owner={who},created={task.get('created')}")
1513
+ if task.get("category") or task.get("tags"):
1514
+ cat = task.get("category") or "—"
1515
+ line = f" 分类:{cat}"
1516
+ if task.get("tags"):
1517
+ line += f" | 标签:{'、'.join(task['tags'])}"
1518
+ print(line)
1519
+ print(
1520
+ "\n细节在 worklog 里(路径记在状态表 worklog 字段);已结案的用 history 子命令查"
1521
+ "(ledger/history/<年月>.md)。"
1522
+ )
1523
+
1524
+
1525
+ def cmd_show(args: argparse.Namespace) -> None:
1526
+ repo = find_repo(args.repo)
1527
+ tid = require_task_id(args.id)
1528
+ task = load_task(repo, tid)
1529
+ print(json.dumps(task, ensure_ascii=False, indent=2))
1530
+ print_worklog(repo, tid, task)
1531
+
1532
+
1533
+ def cmd_tags(args: argparse.Namespace) -> None:
1534
+ """分类/标签巡检与归并。
1535
+
1536
+ --all:全量频次表(扫全部 worklog 头部,每任务恰好一次;已结案也含在内)—— 让漂移可见。
1537
+ --rename 旧 新:把在办任务里等于「旧」的 category / tag 改成「新」,同步改在办 worklog
1538
+ 头部;已封卷文档不动(封卷即不可变,看板侧自行归并)。
1539
+ """
1540
+ if args.all and args.rename:
1541
+ raise Fail("--all 和 --rename 只能给一个。")
1542
+ if not args.all and not args.rename:
1543
+ raise Fail("至少给一个:--all(巡检)或 --rename 旧 新(归并)。")
1544
+ repo = find_repo(args.repo)
1545
+
1546
+ if args.rename:
1547
+ old, new = args.rename
1548
+ old = old.strip()
1549
+ new_label = normalize_label(new, field="新标签")
1550
+ path = repo / PROGRESS_REL
1551
+ with progress_lock(path):
1552
+ before, data = read_guarded(path)
1553
+ tasks = data["tasks"]
1554
+ changed: list[str] = []
1555
+ for tid, task in tasks.items():
1556
+ cat = task.get("category")
1557
+ tags = list(task.get("tags") or [])
1558
+ new_cat = new_label if cat == old else cat
1559
+ new_tags = [new_label if t == old else t for t in tags]
1560
+ if new_cat != cat or new_tags != tags:
1561
+ task["category"] = new_cat
1562
+ task["tags"] = new_tags
1563
+ changed.append(tid)
1564
+ if not changed:
1565
+ print(f"没有在办任务的分类/标签等于「{old}」。")
1566
+ return
1567
+ # 先改在办 worklog 头部(展示副本与真源同口径),最后才提交状态表:
1568
+ # 中途失败时 json 未动、worklog 改写幂等,重跑一次即可收敛
1569
+ backups: dict[Path, bytes] = {}
1570
+ try:
1571
+ for tid in changed:
1572
+ task = tasks[tid]
1573
+ log = find_worklog(repo, tid, task)
1574
+ if log is None:
1575
+ continue
1576
+ backups[log] = log.read_bytes()
1577
+ write_text_atomic(
1578
+ log,
1579
+ rewrite_label_line(
1580
+ backups[log].decode("utf-8"),
1581
+ task.get("category"),
1582
+ task.get("tags") or [],
1583
+ ),
1584
+ )
1585
+ except (OSError, Fail, UnicodeDecodeError) as exc:
1586
+ for log, raw in backups.items():
1587
+ try:
1588
+ log.write_bytes(raw)
1589
+ except OSError:
1590
+ pass
1591
+ raise Fail(f"归并失败,已恢复已改文档,重跑一次即可:{exc}")
1592
+ commit_guarded(path, before, data)
1593
+ print(f"已归并「{old}」→「{new_label}」,涉及 {len(changed)} 个在办任务:")
1594
+ for tid in changed:
1595
+ print(f" {tid}")
1596
+ return
1597
+
1598
+ # --all:worklog 目录是唯一来源 —— 在办任务的 worklog 也在里面(new 建档即写),
1599
+ # 与状态表合并会重复计数;已结案任务状态表已移除、只剩 worklog,天然只计一次。
1600
+ cat_count: dict[str, int] = {}
1601
+ tag_count: dict[str, int] = {}
1602
+ wdir = repo / WORKLOG_DIR
1603
+ n_logs = 0
1604
+ if wdir.is_dir():
1605
+ for log in sorted(wdir.rglob("*.md")):
1606
+ try:
1607
+ text = log.read_text(encoding="utf-8")
1608
+ except (OSError, UnicodeDecodeError):
1609
+ continue
1610
+ cat, tags = parse_label_line(text)
1611
+ n_logs += 1
1612
+ if cat:
1613
+ cat_count[cat] = cat_count.get(cat, 0) + 1
1614
+ for t in tags:
1615
+ tag_count[t] = tag_count.get(t, 0) + 1
1616
+ if not cat_count and not tag_count:
1617
+ print("还没有任何分类/标签:new 时用 --category / --tags 让 Agent 自行总结。")
1618
+ return
1619
+ print(f"扫描 {n_logs} 份 worklog:\n")
1620
+ print("分类(看板统计轴,精确匹配):")
1621
+ for cat, n in sorted(cat_count.items(), key=lambda kv: (-kv[1], kv[0])):
1622
+ print(f" {n:>4} {cat}")
1623
+ print("\n标签(下钻用,包含匹配):")
1624
+ for t, n in sorted(tag_count.items(), key=lambda kv: (-kv[1], kv[0])):
1625
+ print(f" {n:>4} {t}")
1626
+ low = sorted(t for t, n in tag_count.items() if n <= 2)
1627
+ if low:
1628
+ print(
1629
+ f"\n低频标签(≤2 次,疑似变体):{'、'.join(low)}\n"
1630
+ "归并:tags --rename <旧> <新>(只改在办任务;已封卷文档不动)"
1631
+ )
1632
+
1633
+
1634
+ def cmd_validate(args: argparse.Namespace) -> None:
1635
+ repo = find_repo(args.repo)
1636
+ progress_path = repo / PROGRESS_REL
1637
+ problems: list[str] = []
1638
+
1639
+ if not progress_path.exists():
1640
+ raise Fail(f"{PROGRESS_REL} 不存在,先跑 init。")
1641
+
1642
+ data = read_json(progress_path)
1643
+ for tid, task in sorted(data["tasks"].items()):
1644
+ if not ID_RE.fullmatch(tid):
1645
+ problems.append(f"{tid}: 任务号不合规(小写字母、数字、连字符、汉字)")
1646
+ if not task.get("title"):
1647
+ problems.append(f"{tid}: 缺 title")
1648
+ status = task.get("status")
1649
+ if status not in STATUSES:
1650
+ problems.append(f"{tid}: status={status!r} 非法")
1651
+ if status == "open" and task.get("owner"):
1652
+ problems.append(f"{tid}: status=open 却有 owner")
1653
+ if status in ("doing", "blocked") and not task.get("owner"):
1654
+ problems.append(f"{tid}: status={status} 却没有 owner")
1655
+ if status == "done":
1656
+ problems.append(f"{tid}: status=done 不该留在状态表(该用 close 结案)")
1657
+ cat = task.get("category")
1658
+ if cat is not None:
1659
+ try:
1660
+ normalize_label(cat, tid=tid)
1661
+ except Fail as exc:
1662
+ problems.append(str(exc))
1663
+ raw_tags = task.get("tags")
1664
+ if raw_tags is not None:
1665
+ if not isinstance(raw_tags, list) or any(
1666
+ not isinstance(t, str) for t in raw_tags
1667
+ ):
1668
+ problems.append(f"{tid}: tags 必须是字符串数组")
1669
+ else:
1670
+ for t in raw_tags:
1671
+ try:
1672
+ normalize_label(t, field="标签", tid=tid)
1673
+ except Fail as exc:
1674
+ problems.append(str(exc))
1675
+ if len(raw_tags) > TAGS_MAX_COUNT:
1676
+ problems.append(f"{tid}: tags 超过 {TAGS_MAX_COUNT} 个")
1677
+ if find_worklog(repo, tid, task) is None:
1678
+ problems.append(
1679
+ f"{tid}: 缺 worklog 文件(状态表记的路径:{task.get('worklog') or '未记'})"
1680
+ )
1681
+
1682
+ if problems:
1683
+ for p in problems:
1684
+ print(f"✗ {p}", file=sys.stderr)
1685
+ raise Fail(f"校验失败:{len(problems)} 项问题。")
1686
+
1687
+ print(f"✓ 校验通过(在办 {len(data['tasks'])} 条)")
1688
+
1689
+ # 历史层只读体检:发现非 v6 只提示、不写盘 —— 「要不要升级」交给下一次真实写入,
1690
+ # validate 保持只读契约(原设计就不碰历史层,这里只补一条可见的迁移信号)。
1691
+ # 存量平铺文件是只读来源,不会被写入,故其中的旧格式行不会被自动升级。
1692
+ for hp in history_sources(repo):
1693
+ rel = hp.relative_to(repo).as_posix()
1694
+ try:
1695
+ htext = hp.read_text(encoding="utf-8-sig")
1696
+ except (OSError, UnicodeDecodeError) as exc:
1697
+ print(f"提示:读不了 {rel}:{exc}", file=sys.stderr)
1698
+ continue
1699
+ hrecords, hwarnings = parse_history(htext)
1700
+ old_n = sum(1 for r in hrecords if r["fmt"] != "v8")
1701
+ if old_n:
1702
+ print(
1703
+ f"提示:{rel} 含 {old_n} 行旧格式(4 / 5 / 6 列);"
1704
+ "只读文件不自动升级,新记录一律按 8 列写入 ledger/history/。",
1705
+ file=sys.stderr,
1706
+ )
1707
+ for w in hwarnings:
1708
+ print(f"提示:{rel} {w}", file=sys.stderr)
1709
+
1710
+
1711
+ def cmd_history(args: argparse.Namespace) -> None:
1712
+ """查已结案任务(只读)。不加锁、不写任何字节、不依赖格式升级是否完成。
1713
+
1714
+ --owner / --mine 一律**精确相等**:身份标识没有「按前缀找人」的语义,
1715
+ 子串匹配正是 a 误命中 a-11 的根因。
1716
+ --since / --until 按**创建时间**过滤(含边界),可只给其一;旧格式行没有创建时间,
1717
+ 带时间过滤时会被排除(另行提示)。--limit 在过滤 + 倒序之后取前 N 条。
1718
+ 默认输出紧凑单行、不含「完成了什么」摘要(与 list 不吐正文同一原则);要全量走 --json。
1719
+ """
1720
+ if args.owner is not None and args.mine:
1721
+ raise Fail("--owner 和 --mine 只能给一个:要么查某人,要么查自己。")
1722
+ if args.owner is not None and not args.owner.strip():
1723
+ raise Fail("--owner 不能为空。")
1724
+ if args.limit is not None and args.limit < 1:
1725
+ raise Fail("--limit 必须是正整数。")
1726
+ since = normalize_time(args.since, field="--since") if args.since else None
1727
+ until = (
1728
+ normalize_time(args.until, field="--until", date_only_ref="23:59:59")
1729
+ if args.until
1730
+ else None
1731
+ )
1732
+
1733
+ repo = find_repo(args.repo)
1734
+ sources = history_sources(repo)
1735
+ if not sources:
1736
+ print(
1737
+ f"没有已结案记录:ledger/history/ 还没有文件(存量 {HISTORY_REL} 也没有)。"
1738
+ )
1739
+ return
1740
+ if args.months is not None:
1741
+ if args.months < 1:
1742
+ raise Fail("--months 必须是正整数")
1743
+ # 只取按月归档的文件(存量平铺文件不进了——它是改造前的历史,窗口模式用不到)
1744
+ monthly = [p for p in sources if p.parent.name == HISTORY_DIR.name]
1745
+ sources = monthly[-args.months :]
1746
+ if args.count_only:
1747
+ print(json.dumps(history_row_count(sources, since, until), ensure_ascii=False))
1748
+ return
1749
+ records: list[dict[str, Any]] = []
1750
+ warnings: list[str] = []
1751
+ for hp in sources:
1752
+ rel = hp.relative_to(repo).as_posix()
1753
+ try:
1754
+ text = hp.read_text(encoding="utf-8-sig")
1755
+ except (OSError, UnicodeDecodeError) as exc:
1756
+ raise Fail(f"读不了 {hp}:{exc}")
1757
+ recs, warns = parse_history(text)
1758
+ for r in recs:
1759
+ r["src"] = rel # 行所在的历史文件:链接相对它所在目录,带上才可定位
1760
+ # 8 列行自带分类/标签;旧 4/5/6 列行没有,回链读 worklog 头部兜底
1761
+ if not r.get("category") and not r.get("tags"):
1762
+ r["category"], r["tags"] = labels_for_log(r["log"], hp)
1763
+ records.extend(recs)
1764
+ warnings.extend(f"{rel} {w}" for w in warns)
1765
+
1766
+ me = git_user() if args.mine else None
1767
+ want_owner = (
1768
+ args.owner.strip().lstrip("@") if args.owner else None
1769
+ ) # 容忍「@名字」写法
1770
+ rows = []
1771
+ undated = 0
1772
+ for r in records:
1773
+ if want_owner is not None and r["owner"] != want_owner:
1774
+ continue
1775
+ if me is not None and r["owner"] != me:
1776
+ continue
1777
+ # 分类精确、标签包含(与 list 同口径;值优先读列,列空回链 worklog 头部)
1778
+ if args.category and r.get("category") != args.category.strip():
1779
+ continue
1780
+ if args.tag:
1781
+ q = args.tag.strip()
1782
+ if not any(q in t for t in r.get("tags") or []):
1783
+ continue
1784
+ if since or until:
1785
+ c = r.get("created")
1786
+ if c is None: # 旧格式行没有创建时间,无法参与时间过滤
1787
+ undated += 1
1788
+ continue
1789
+ if since and c < since:
1790
+ continue
1791
+ if until and c > until:
1792
+ continue
1793
+ rows.append(r)
1794
+ if undated:
1795
+ print(
1796
+ f"提示:{undated} 行没有创建时间(旧格式),未参与时间过滤;"
1797
+ "下次 close 写入后会自动补齐。",
1798
+ file=sys.stderr,
1799
+ )
1800
+ # 倒序:最新创建在前(旧行无创建时间时退回按结案时间);同一时刻稳定排序保持文件物理先后。
1801
+ rows.sort(key=lambda r: r.get("created") or r["time"], reverse=True)
1802
+ if args.limit is not None:
1803
+ rows = rows[: args.limit]
1804
+
1805
+ if args.json:
1806
+ print(
1807
+ json.dumps(
1808
+ {
1809
+ "count": len(rows),
1810
+ "filters": {
1811
+ "owner": want_owner,
1812
+ "mine": args.mine,
1813
+ "since": since,
1814
+ "until": until,
1815
+ "limit": args.limit,
1816
+ "category": args.category.strip() if args.category else None,
1817
+ "tag": args.tag.strip() if args.tag else None,
1818
+ },
1819
+ "records": rows,
1820
+ },
1821
+ ensure_ascii=False,
1822
+ indent=2,
1823
+ )
1824
+ )
1825
+ return
1826
+
1827
+ if not rows:
1828
+ print("没有符合条件的已结案记录。")
1829
+ return
1830
+ who_label = want_owner or me
1831
+ label = f"(owner=@{who_label})" if who_label else ""
1832
+ span = f",创建 {since or '…'} ~ {until or '…'}" if (since or until) else ""
1833
+ print(f"已结案 {len(rows)} 条{label}{span}:\n")
1834
+
1835
+ def _who(r: dict[str, Any]) -> str:
1836
+ return f"@{r['owner']}" if r["owner"] else "(无 owner)"
1837
+
1838
+ width = max(len(_who(r)) for r in rows)
1839
+ for r in rows:
1840
+ who = _who(r).ljust(width)
1841
+ print(
1842
+ f" {r['time']} 建 {r.get('created') or '—'} {who} "
1843
+ f"[{r.get('category') or '—'}] "
1844
+ f"{r['workpackage']} → {r['log'] or '(无过程文档)'}({r.get('src', '')})"
1845
+ )
1846
+ if warnings:
1847
+ print(f"\n({len(warnings)} 行无法解析,已跳过;详见 stderr)")
1848
+ print(
1849
+ "\n时间列=结案时间;过滤按创建时间。默认不输出「完成了什么」;要摘要跑 history --json。"
1850
+ "\n方括号=分类(回链自 worklog 头部;历史行本身不存这列)。"
1851
+ "\n行尾括号=该行所在的历史文件(文档链接相对它所在目录解析)。"
1852
+ )
1853
+
1854
+
1855
+ # ---------- 入口 ----------
1856
+
1857
+
1858
+ def build_parser() -> argparse.ArgumentParser:
1859
+ parser = argparse.ArgumentParser(
1860
+ prog="progress.py",
1861
+ description="管理项目任务进度(ledger/progress.json + worklog + 历史)",
1862
+ )
1863
+ sub = parser.add_subparsers(dest="command", required=True)
1864
+
1865
+ def add_repo(p: argparse.ArgumentParser) -> None:
1866
+ p.add_argument("--repo", help="当前工作区(项目根)。agent 调用时应显式传入")
1867
+
1868
+ p = sub.add_parser("init", help="建 ledger/progress.json")
1869
+ add_repo(p)
1870
+ p.set_defaults(func=cmd_init)
1871
+
1872
+ p = sub.add_parser("new", help="新建任务")
1873
+ p.add_argument("--title", required=True, help="一句话标题")
1874
+ p.add_argument("--id", help="指定任务号,默认取标题生成的语义名 + 4 位随机")
1875
+ p.add_argument(
1876
+ "--created", help="补录创建时间(默认当前时刻);YYYY-MM-DD[ HH:MM[:SS]]"
1877
+ )
1878
+ p.add_argument(
1879
+ "--self",
1880
+ action="store_true",
1881
+ help="创建即由自己执行(doing),否则留 open 待认领",
1882
+ )
1883
+ p.add_argument(
1884
+ "--category",
1885
+ help="一句话分类:这事是什么类型(≤4 字;纯英文 ≤3 词)。由 Agent 按口径总结,可省",
1886
+ )
1887
+ p.add_argument(
1888
+ "--tags",
1889
+ help="标签:这事涉及什么(对象/领域/模块),逗号分隔(至多 5 个,每个 ≤4 字;空格归英文多词标签自身,不当分隔符)。由 Agent 按口径总结,可省",
1890
+ )
1891
+ add_repo(p)
1892
+ p.set_defaults(func=cmd_new)
1893
+
1894
+ p = sub.add_parser("claim", help="认领一个 open 任务(认领人只能是当前 git 身份)")
1895
+ p.add_argument("--id", required=True)
1896
+ add_repo(p)
1897
+ p.set_defaults(func=cmd_claim)
1898
+
1899
+ p = sub.add_parser("set", help="改状态 / 交接(结案请用 close)")
1900
+ p.add_argument("--id", required=True)
1901
+ p.add_argument("--status", help=f"{' / '.join(STATUSES)}(done 请走 close)")
1902
+ p.add_argument("--owner", help="交接给谁")
1903
+ add_repo(p)
1904
+ p.set_defaults(func=cmd_set)
1905
+
1906
+ p = sub.add_parser("close", help="结案:移出状态 + 追加历史 + 封卷 worklog")
1907
+ p.add_argument("--id", required=True)
1908
+ p.add_argument("--summary", required=True, help="做成了什么,一句话")
1909
+ p.add_argument(
1910
+ "--finished", help="结案时间(默认当前时刻);YYYY-MM-DD[ HH:MM[:SS]]"
1911
+ )
1912
+ p.add_argument("--category", help="结案时修正分类(不传则沿用 new 时的值)")
1913
+ p.add_argument("--tags", help="结案时修正标签(不传则沿用 new 时的值)")
1914
+ add_repo(p)
1915
+ p.set_defaults(func=cmd_close)
1916
+
1917
+ p = sub.add_parser("list", help="查询在办任务")
1918
+ p.add_argument("--mine", action="store_true", help="只看自己的")
1919
+ p.add_argument("--owner", help="只看某个人的")
1920
+ p.add_argument("--status", help="逗号分隔,如 doing,blocked")
1921
+ p.add_argument("--category", help="按分类过滤(精确匹配)")
1922
+ p.add_argument("--tag", help="按标签过滤(包含匹配,命中任一面即算)")
1923
+ p.add_argument(
1924
+ "--since", help="只看该时间(含)之后创建的任务;YYYY-MM-DD[ HH:MM[:SS]]"
1925
+ )
1926
+ p.add_argument(
1927
+ "--until", help="只看该时间(含)之前创建的任务;YYYY-MM-DD 含当天整天"
1928
+ )
1929
+ p.add_argument("--json", action="store_true", help="输出原始 JSON")
1930
+ add_repo(p)
1931
+ p.set_defaults(func=cmd_list)
1932
+
1933
+ p = sub.add_parser("show", help="单任务详情")
1934
+ p.add_argument("--id", required=True)
1935
+ add_repo(p)
1936
+ p.set_defaults(func=cmd_show)
1937
+
1938
+ p = sub.add_parser(
1939
+ "tags", help="分类/标签巡检(--all)与归并(--rename 旧 新,只改在办任务)"
1940
+ )
1941
+ p.add_argument("--all", action="store_true", help="输出全量分类/标签频次表")
1942
+ p.add_argument(
1943
+ "--rename",
1944
+ nargs=2,
1945
+ metavar=("旧", "新"),
1946
+ help="把在办任务里等于「旧」的分类/标签改为「新」,同步改在办 worklog 头部",
1947
+ )
1948
+ add_repo(p)
1949
+ p.set_defaults(func=cmd_tags)
1950
+
1951
+ p = sub.add_parser(
1952
+ "history",
1953
+ help="查已结案任务(只读):--owner / --mine / --since / --until / --limit / --json / --category / --tag",
1954
+ )
1955
+ p.add_argument("--owner", help="只看某个人的(精确匹配,不做前缀/子串)")
1956
+ p.add_argument("--mine", action="store_true", help="只看自己的(当前 git 身份)")
1957
+ p.add_argument("--category", help="按分类过滤(精确匹配;回链读 worklog 头部)")
1958
+ p.add_argument("--tag", help="按标签过滤(包含匹配;回链读 worklog 头部)")
1959
+ p.add_argument(
1960
+ "--since", help="只看该时间(含)之后创建的任务;YYYY-MM-DD[ HH:MM[:SS]]"
1961
+ )
1962
+ p.add_argument(
1963
+ "--until", help="只看该时间(含)之前创建的任务;YYYY-MM-DD 含当天整天"
1964
+ )
1965
+ p.add_argument("--limit", type=int, help="过滤 + 倒序后取前 N 条(最近 N 条)")
1966
+ p.add_argument("--json", action="store_true", help="输出结构化 JSON(含摘要)")
1967
+ p.add_argument(
1968
+ "--count-only",
1969
+ action="store_true",
1970
+ help="只数行数(封顶 1 万,输出 {count, capped});显示级统计,精确数以 --json 为准",
1971
+ )
1972
+ p.add_argument(
1973
+ "--months",
1974
+ type=int,
1975
+ help="只读最近 N 个月的历史文件(ledger/history/*.md 按文件名取末 N 个;不含存量平铺文件)",
1976
+ )
1977
+ add_repo(p)
1978
+ p.set_defaults(func=cmd_history)
1979
+
1980
+ p = sub.add_parser("validate", help="校验状态表与 worklog")
1981
+ add_repo(p)
1982
+ p.set_defaults(func=cmd_validate)
1983
+
1984
+ return parser
1985
+
1986
+
1987
+ def main(argv: list[str] | None = None) -> int:
1988
+ args = build_parser().parse_args(argv)
1989
+ try:
1990
+ args.func(args)
1991
+ except Fail as exc:
1992
+ print(f"错误:{exc}", file=sys.stderr)
1993
+ return 1
1994
+ except KeyboardInterrupt:
1995
+ print("已中断。", file=sys.stderr)
1996
+ return 130
1997
+ except BrokenPipeError:
1998
+ # 输出被下游截断(如 | head):静默退出,不打 traceback
1999
+ devnull = os.open(os.devnull, os.O_WRONLY)
2000
+ os.dup2(devnull, sys.stdout.fileno())
2001
+ return 0
2002
+ except OSError as exc:
2003
+ print(f"错误:{exc}", file=sys.stderr)
2004
+ return 1
2005
+ except Exception as exc: # noqa: BLE001 —— 兜底:未预期的失败用独立退出码,与业务错误区分
2006
+ print(f"错误:未预期的失败 {type(exc).__name__}: {exc}", file=sys.stderr)
2007
+ return 3
2008
+ return 0
2009
+
2010
+
2011
+ if __name__ == "__main__":
2012
+ sys.exit(main())