task-ledger 1.2.9 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +29 -245
  2. package/SKILL.md +173 -88
  3. package/package.json +22 -22
  4. package/references/log-note.md +3 -1
  5. package/references/state-model.md +29 -27
  6. package/references/troubleshooting.md +98 -25
  7. package/scripts/ledgerkit/__init__.py +0 -0
  8. package/scripts/ledgerkit/actions/__init__.py +0 -0
  9. package/scripts/ledgerkit/actions/atom.py +154 -0
  10. package/scripts/ledgerkit/actions/bootstrap.py +27 -0
  11. package/scripts/ledgerkit/actions/claim.py +82 -0
  12. package/scripts/ledgerkit/actions/close_task.py +180 -0
  13. package/scripts/ledgerkit/actions/guard.py +140 -0
  14. package/scripts/ledgerkit/actions/handoff.py +74 -0
  15. package/scripts/ledgerkit/actions/list_tasks.py +53 -0
  16. package/scripts/ledgerkit/actions/log_progress.py +60 -0
  17. package/scripts/ledgerkit/actions/new_task.py +120 -0
  18. package/scripts/ledgerkit/actions/query.py +33 -0
  19. package/scripts/ledgerkit/actions/query_history.py +173 -0
  20. package/scripts/ledgerkit/actions/registry.py +82 -0
  21. package/scripts/ledgerkit/actions/release.py +51 -0
  22. package/scripts/ledgerkit/actions/result.py +88 -0
  23. package/scripts/ledgerkit/actions/set_task.py +43 -0
  24. package/scripts/ledgerkit/actions/show_task.py +27 -0
  25. package/scripts/ledgerkit/actions/validate.py +224 -0
  26. package/scripts/ledgerkit/core/__init__.py +0 -0
  27. package/scripts/ledgerkit/core/history_md.py +350 -0
  28. package/scripts/ledgerkit/core/identity.py +43 -0
  29. package/scripts/ledgerkit/core/ids.py +58 -0
  30. package/scripts/ledgerkit/core/labels.py +79 -0
  31. package/scripts/ledgerkit/core/paths.py +93 -0
  32. package/scripts/ledgerkit/core/time.py +102 -0
  33. package/scripts/ledgerkit/core/worklog_md.py +232 -0
  34. package/scripts/ledgerkit/env.py +93 -0
  35. package/scripts/ledgerkit/errors.py +15 -0
  36. package/scripts/ledgerkit/notices.py +28 -0
  37. package/scripts/ledgerkit/shell/__init__.py +0 -0
  38. package/scripts/ledgerkit/shell/cli.py +259 -0
  39. package/scripts/ledgerkit/shell/gitops.py +655 -0
  40. package/scripts/ledgerkit/shell/merge.py +266 -0
  41. package/scripts/ledgerkit/shell/presync.py +23 -0
  42. package/scripts/ledgerkit/shell/render.py +165 -0
  43. package/scripts/ledgerkit/shell/runner.py +225 -0
  44. package/scripts/ledgerkit/store/__init__.py +0 -0
  45. package/scripts/ledgerkit/store/atomic.py +130 -0
  46. package/scripts/ledgerkit/store/dirty.py +108 -0
  47. package/scripts/ledgerkit/store/history_store.py +189 -0
  48. package/scripts/ledgerkit/store/json_store.py +166 -0
  49. package/scripts/ledgerkit/store/locks.py +64 -0
  50. package/scripts/ledgerkit/store/owners_index.py +135 -0
  51. package/scripts/ledgerkit/store/task_store.py +31 -0
  52. package/scripts/ledgerkit/store/worklog_store.py +168 -0
  53. package/scripts/progress.py +8 -3021
  54. package/references/taxonomy.md +0 -26
  55. package/references/worklog.md +0 -39
@@ -1,3033 +1,20 @@
1
1
  #!/usr/bin/env python3
2
- # pyright: standard
3
- """task-ledger — 管理项目任务进度。
2
+ """程序入口:只做四件事,业务逻辑一行都不在这里。
4
3
 
5
- ledger/progress.json 在办任务的状态
6
- ledger/worklog/<年月>/<日>-<时分>-<说明>-<随机码>.md
7
- 单个任务的过程(路径另记在状态表 worklog 字段)
8
- ledger/history/<年月>.md 已结案任务,一行一条(9 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / 完成了什么 / 过程文档);
9
- 按结案时间分月归档。存量 ledger/PROGRESS-HISTORY.md 只读兼容、永不重写
10
-
11
- 只用标准库,无第三方依赖。
4
+ 1. 关掉字节码(本脚本会被 cp -r 拷进插件产物,不能带 __pycache__);
5
+ 2. 把自己所在目录塞进 sys.path(宿主与 SKILL.md 都按这个路径调,不许改名挪位);
6
+ 3. 调 ledgerkit.shell.cli.main;
7
+ 4. 把返回码交给系统。
12
8
  """
13
9
 
14
- from __future__ import annotations
15
-
16
- import argparse
17
- import contextlib
18
- import hashlib
19
- import json
20
10
  import os
21
- import re
22
- import secrets
23
- import stat
24
- import subprocess
25
11
  import sys
26
- import tempfile
27
- import time
28
- from collections.abc import Iterator
29
- from datetime import datetime
30
- from pathlib import Path
31
- from typing import Any
32
-
33
- try:
34
- import fcntl
35
- except ImportError: # Windows
36
- fcntl = None
37
- try:
38
- import msvcrt
39
- except ImportError:
40
- msvcrt = None
41
-
42
- STATUSES = ("open", "doing")
43
- # 旧数据只读兼容:早期版本有 blocked 状态(手动标「卡住」)。现在超期按时间判定
44
- # (接任务满 7 天),不再允许新写入;list / validate / 看板仍能吃老文件。
45
- LEGACY_STATUSES = ("blocked",)
46
- PROGRESS_REL = Path("ledger/progress.json")
47
- # 存量:改造前的单文件历史层,只读兼容、永不重写
48
- HISTORY_REL = Path("ledger/PROGRESS-HISTORY.md")
49
- HISTORY_DIR = Path(
50
- "ledger/history"
51
- ) # 新历史层:按结案时间分月,ledger/history/<YYYYMM>.md
52
- WORKLOG_DIR = Path("ledger/worklog")
53
- HISTORY_TITLE = "# 进度历史(已结案)"
54
- # 9 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / 完成了什么 / 过程文档。
55
- # owner 独立成列 —— 按人检索是精确列比较,不靠文本切分。
56
- # creator(2026-10-17 加):任务的「发起人」——与 owner 区分(owner 可被 claim 改走,
57
- # 但「谁建的」不变)。看板「新增(我的)= 我创建的」要它;旧 8 列无此列,
58
- # upgrade_history / cmd_history 回链 worklog 头部「创建」行补(与 created 同一来源,不丢)。
59
- HISTORY_HEADER = "| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 | owner | creator | 完成了什么 | 过程文档 |"
60
- HISTORY_SEP = "|---|---|---|---|---|---|---|---|---|"
61
- FROZEN_MARK = "✅ 完成封卷"
62
- ID_RE = re.compile(r"[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*\Z")
63
- # 读侧兼容:秒可有可无(旧数据只到分)。写侧一律到秒,见 normalize_time。
64
- TIME_RE = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?\Z")
65
- # 分类 / 标签口径:值由 Agent 在 new 时按 SKILL.md 规则自行总结(不设枚举),
66
- # 脚本只硬校验形态 —— 含 CJK 按字符数 ≤4,纯 ASCII 按单词数 ≤3;标签数量只设上限,
67
- # 设下限会逼 Agent 凑数,凑出来的正是噪声。
68
- LABEL_MAX_CHARS = 4
69
- LABEL_MAX_WORDS_EN = 3
70
- TAGS_MAX_COUNT = 5
71
- CJK_RE = re.compile(r"[\u4e00-\u9fff]")
72
-
73
- # 执行记录:worklog 末尾由脚本维护的一节。老文档没有这一节就不动(不迁移);
74
- # 有这一节时脚本按节点自动追加,agent 改完正文跑 log 补一条。
75
- EXEC_HEAD = "## 执行记录"
76
- EXEC_HINT = (
77
- "<!-- 本节由脚本维护:new / claim / set / close / log 自动追加,请勿手改。\n"
78
- ' 要补一条就跑 progress.py log --id <任务号> --note "做了什么"。 -->'
79
- )
80
-
81
- SKILL_DIR = Path(__file__).resolve().parent.parent
82
- WORKLOG_TEMPLATE = SKILL_DIR / "assets" / "worklog.md"
83
-
84
- FALLBACK_TEMPLATE = """# <id>:<title>
85
-
86
- > **创建**:<created>(@<creator>)
87
-
88
- ## 背景
89
-
90
- ## 待办
91
-
92
- ## 下一步
93
-
94
- ## 过程
95
-
96
- ## 结论
97
-
98
- ## 执行记录
99
- """
100
-
101
-
102
- class Fail(Exception):
103
- """业务错误:退出码非 0,只打印消息。"""
104
-
105
-
106
- # ---------- 基础设施 ----------
107
-
108
-
109
- def now() -> str:
110
- """当前时刻,精确到秒:YYYY-MM-DD HH:MM:SS。"""
111
- return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
112
-
113
-
114
- # 时间字面量:日期 + 可选「时:分[:秒]」。容忍 `/` 与 `T` 分隔(归一化后再匹配)。
115
- _TIME_IN_RE = re.compile(
116
- r"(\d{4})-(\d{1,2})-(\d{1,2})(?: +(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?\Z"
117
- )
118
-
119
-
120
- def normalize_time(
121
- value: str | datetime,
122
- *,
123
- field: str = "时间",
124
- tid: str | None = None,
125
- date_only_ref: str | None = None,
126
- ) -> str:
127
- """把「正确但不完整」的时间写法归一化到 YYYY-MM-DD HH:MM:SS。
128
-
129
- 接受 `YYYY-MM-DD`、`YYYY-MM-DD HH:MM`、`YYYY-MM-DD HH:MM:SS`,容忍 `/` 与 `T`。
130
- 缺秒补 `:00`,缺分秒补 `00:00`(即 `00:00:00`)。非法值报错。
131
- date_only_ref:只给了日期(没给时分)时,用这个时刻顶替(如 `--until` 想「含当天整天」传 `23:59:59`)。
132
- """
133
- where = f"{tid}: " if tid else ""
134
- if isinstance(value, datetime):
135
- return value.strftime("%Y-%m-%d %H:%M:%S")
136
- if not isinstance(value, str) or not value.strip():
137
- raise Fail(f"{where}{field} 要是一个时间字符串,收到 {value!r}")
138
- s = " ".join(
139
- value.strip().replace("/", "-").replace("T", " ").replace("t", " ").split()
140
- )
141
- m = _TIME_IN_RE.fullmatch(s)
142
- if not m:
143
- raise Fail(
144
- f"{where}{field} 时间格式不对:{value!r}"
145
- "(可用 YYYY-MM-DD、YYYY-MM-DD HH:MM、YYYY-MM-DD HH:MM:SS)"
146
- )
147
- y, mo, d, hh, mm, ss = m.groups()
148
- if date_only_ref is not None and hh is None:
149
- out = f"{int(y):04d}-{int(mo):02d}-{int(d):02d} {date_only_ref}"
150
- else:
151
- out = (
152
- f"{int(y):04d}-{int(mo):02d}-{int(d):02d} "
153
- f"{int(hh or 0):02d}:{int(mm or 0):02d}:{int(ss or 0):02d}"
154
- )
155
- try:
156
- datetime.strptime(out, "%Y-%m-%d %H:%M:%S")
157
- except ValueError as exc:
158
- raise Fail(f"{where}{field} 不是真实时间:{value!r}({exc})")
159
- return out
160
-
161
-
162
- def time_key(value: object) -> str | None:
163
- """已存时间(可能只到分)→ 秒精度字符串,用于比较/排序;不合法返回 None。"""
164
- if not isinstance(value, str) or not TIME_RE.fullmatch(value):
165
- return None
166
- return value if len(value) == 19 else value + ":00"
167
-
168
-
169
- def find_repo(explicit: str | None) -> Path:
170
- """项目根:--repo > 向上找含 ledger/progress.json 的目录 > 向上找含 ledger/ 的目录。
171
-
172
- git 根只作为一个候选,不无条件优先 —— 否则 monorepo 子目录会定位到整个仓库根。
173
- """
174
- if explicit:
175
- p = Path(explicit).expanduser().resolve()
176
- if not p.is_dir():
177
- raise Fail(f"--repo 不是目录:{p}")
178
- # 传进来的常是工作区子目录;向上找真正的台账根,否则写前同步会对着
179
- # 不存在的 <子目录>/ledger 空转。都找不到就仍用传入目录(首次 init 需要它)。
180
- for cand in (p, *p.parents):
181
- if (cand / PROGRESS_REL).exists():
182
- return cand
183
- return p
184
-
185
- bases: list[Path] = []
186
- try:
187
- out = subprocess.run(
188
- ["git", "rev-parse", "--show-toplevel"],
189
- capture_output=True,
190
- text=True,
191
- check=True,
192
- ).stdout.strip()
193
- if out:
194
- bases.append(Path(out).resolve())
195
- except (subprocess.CalledProcessError, FileNotFoundError, OSError):
196
- pass
197
-
198
- try:
199
- here = Path.cwd().resolve()
200
- except OSError as exc:
201
- raise Fail(f"拿不到当前目录:{exc}")
202
- if here not in bases:
203
- bases.append(here)
204
-
205
- ordered: list[Path] = []
206
- for base in bases:
207
- for p in (base, *base.parents):
208
- if p not in ordered:
209
- ordered.append(p)
210
-
211
- for p in ordered:
212
- if (p / PROGRESS_REL).exists():
213
- return p
214
- for p in ordered:
215
- if (p / "docs").is_dir():
216
- return p
217
- raise Fail(
218
- "定位不到项目根:向上找不到 ledger/progress.json 或 ledger/。用 --repo 指定。"
219
- )
220
-
221
-
222
- def git_user() -> str:
223
- """owner 身份:只认 git config user.name。取不到就报错,不做任何兜底。"""
224
- try:
225
- proc = subprocess.run( # noqa: PLW1510 —— 要看 stdout,不抛异常
226
- ["git", "config", "user.name"], capture_output=True, text=True
227
- )
228
- except FileNotFoundError:
229
- raise Fail("需要用户名才能执行:本机没有 git,读不到 git config user.name。")
230
- name = proc.stdout.strip()
231
- if not name:
232
- raise Fail(
233
- "需要用户名才能执行:读不到本机 git 身份(git config user.name 为空)。\n"
234
- '先配置:git config --global user.name "你的名字"'
235
- )
236
- return name
237
-
238
-
239
- def read_json(path: Path) -> dict[str, Any]:
240
- if not path.exists():
241
- return {"tasks": {}}
242
- try:
243
- text = path.read_text(encoding="utf-8-sig")
244
- except (OSError, UnicodeDecodeError) as exc:
245
- raise Fail(f"读不了 {path}:{exc}")
246
- try:
247
- data = json.loads(text)
248
- except json.JSONDecodeError as exc:
249
- raise Fail(f"{path} 不是合法 JSON:{exc}")
250
- if not isinstance(data, dict) or not isinstance(data.get("tasks"), dict):
251
- raise Fail(f'{path} 结构不对,顶层应为 {{"tasks": {{...}}}}')
252
- return data
253
-
254
-
255
- # 本次进程内被台账写入的真实文件路径,供写成功后一次性自动同步(git commit/push)。
256
- CHANGED_FILES: list[Path] = []
257
-
258
- # 写前预同步从远端落盘的文件:不是本次命令的写入目标,但必须随本次提交入库
259
- # (否则会一直以未跟踪状态留在工作树里,把自动 rebase 挡在门口)。
260
- PRESYNC_FILES: list[Path] = []
261
-
262
- # 写前预同步覆盖前的内容(None = 覆盖前不存在)。命令失败时整体回滚,
263
- # 免得把「刚才从远端刷下来的版本」留在工作树里,下次又被 guard 当成用户未提交改动。
264
- PRESYNC_BACKUPS: dict[Path, bytes | None] = {}
265
-
266
-
267
- def presync_rollback() -> None:
268
- """把写前预同步覆盖过的文件恢复原样。只在命令失败的路径上调用。"""
269
- while PRESYNC_BACKUPS:
270
- path, original = PRESYNC_BACKUPS.popitem()
271
- try:
272
- if original is None:
273
- path.unlink(missing_ok=True)
274
- else:
275
- path.write_bytes(original)
276
- except OSError as exc:
277
- print(
278
- f"提示:写前刷新 {path} 的回滚未完成({exc}),请手动检查该文件。",
279
- file=sys.stderr,
280
- )
281
- PRESYNC_FILES.clear()
282
-
283
-
284
- def write_text_atomic(path: Path, text: str, *, track: bool = True) -> None:
285
- """原子写文本:同目录唯一临时名 + fsync + os.replace,写完清理临时文件。
286
-
287
- 整段都在 try 内 —— mkdir / mkstemp 的 OSError 同样要转成 Fail,
288
- 否则会漏成裸异常,上层的「失败就撤销」也就收不到。
289
-
290
- track=False 用于「从远端刷下来」的内容:那不是本次命令的改动,
291
- 不能进 CHANGED_FILES,否则会被写后自动提交当成本次台账变更。
292
-
293
- 两个属性都得保住:mkstemp 建的是 0600,直接 replace 会把文件权限从 644 降成
294
- 600(组内用户与 CI 读不到),所以写前继承原文件的 mode;符号链接一律拒绝,
295
- 否则 replace 会把链接本身换成普通文件。
296
- """
297
- fd = None
298
- tmp = None
299
- try:
300
- path.parent.mkdir(parents=True, exist_ok=True)
301
- if path.is_symlink():
302
- raise Fail(f"拒绝写入 {path}:它是符号链接,写入会把它替换成普通文件")
303
- try:
304
- mode = stat.S_IMODE(path.stat().st_mode)
305
- except OSError:
306
- mode = 0o644 # 新文件:与 git 默认 644 对齐
307
- fd, tmp_name = tempfile.mkstemp(
308
- dir=str(path.parent), prefix=path.name + ".", suffix=".tmp"
309
- )
310
- tmp = Path(tmp_name)
311
- with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
312
- fd = None # 所有权已交给文件对象
313
- fh.write(text)
314
- fh.flush()
315
- os.fsync(fh.fileno())
316
- os.chmod(tmp, mode)
317
- os.replace(tmp, path)
318
- if track:
319
- CHANGED_FILES.append(path) # 记下本次改动,供写后自动同步
320
- tmp = None
321
- except OSError as exc:
322
- raise Fail(f"写 {path} 失败:{exc}")
323
- finally:
324
- if fd is not None:
325
- try:
326
- os.close(fd)
327
- except OSError:
328
- pass
329
- if tmp is not None and tmp.exists():
330
- try:
331
- tmp.unlink()
332
- except OSError:
333
- pass
334
-
335
-
336
- def write_json(path: Path, data: dict[str, Any]) -> None:
337
- """原子写状态表:一个任务一行,任务内 key 按字母序。
338
-
339
- 为什么压成一行一条:progress.json 是多 agent 共享的并发写点。任务块各占十几行时,
340
- A 结案删掉一个任务、B 同时刷新相邻任务的 updated,两处改动间距不足 git 的 3 行
341
- patch 上下文,rebase 必冲突(实测撞到过)。一行一条后,删任务只删一行、改任务只改
342
- 一行,只有相邻两个任务同时被改才可能撞上。
343
-
344
- 顶层不是 `{"tasks": {...}}` 形状时退回缩进格式:本函数只管状态表,不顺手改坏别的结构。
345
- """
346
- tasks = data.get("tasks")
347
- if not isinstance(tasks, dict) or set(data) != {"tasks"}:
348
- write_text_atomic(
349
- path, json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
350
- )
351
- return
352
- rows = ",\n".join(
353
- f" {json.dumps(k, ensure_ascii=False)}: {json.dumps(v, ensure_ascii=False, sort_keys=True)}"
354
- for k, v in sorted(tasks.items())
355
- )
356
- body = '{\n "tasks": {\n' + (rows + "\n" if rows else "") + " }\n}\n"
357
- write_text_atomic(path, body)
358
-
359
-
360
- def stat_key(path: Path) -> str | tuple[str, ...] | None:
361
- """文件内容的指纹;不存在返回 None。
362
-
363
- 刻意不用 (mtime, size):并发写入可能落在同一时间精度内、且字节数恰好相同,
364
- 那样指纹不变、写前复查就形同虚设(实测踩过)。用内容哈希,代价是多读一次小文件。
365
- """
366
- try:
367
- raw = path.read_bytes()
368
- except FileNotFoundError:
369
- return None
370
- except OSError:
371
- return ("unreadable",)
372
- return hashlib.sha256(raw).hexdigest()
373
-
374
-
375
- @contextlib.contextmanager
376
- def progress_lock(path: Path) -> Iterator[None]:
377
- """把状态表的「读-改-写」串行化,避免并发丢更新。
378
-
379
- 锁文件放系统临时目录(按项目绝对路径取名),不污染项目;进程退出时内核自动释放。
380
- 无 fcntl / msvcrt 的平台上退化为不加锁。
381
- """
382
- if fcntl is None and msvcrt is None:
383
- yield
384
- return
385
- key = hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:16]
386
- lock_path = Path(tempfile.gettempdir()) / f"koc-progress-{key}.lock"
387
- fd = os.open(lock_path, os.O_CREAT | os.O_RDWR)
388
- try:
389
- if fcntl is not None:
390
- fcntl.flock(fd, fcntl.LOCK_EX)
391
- elif msvcrt is not None:
392
- # typeshed 把 msvcrt.locking / LK_* 门控在 win32,非 Windows 平台跑 pyright
393
- # 会报「未知属性」;这段只有 Windows 真能执行到,故局部忽略(import 处是 try 容错)
394
- msvcrt.locking(fd, msvcrt.LK_LOCK, 1) # pyright: ignore[reportAttributeAccessIssue]
395
- yield
396
- finally:
397
- try:
398
- if fcntl is not None:
399
- fcntl.flock(fd, fcntl.LOCK_UN)
400
- elif msvcrt is not None:
401
- msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) # pyright: ignore[reportAttributeAccessIssue]
402
- finally:
403
- os.close(fd)
404
-
405
-
406
- @contextlib.contextmanager
407
- def repo_sync_lock(git_dir: Path, timeout: float = 60.0) -> Iterator[None]:
408
- """写命令的整段临界区:写前同步 → 命令写入 → 提交推送,同一 clone 只允许一个。
409
-
410
- 为什么必须整段:写前同步是「检查本地干净 → 覆盖 progress.json」两步,中间是敞开的。
411
- 另一个 agent 恰好在这几毫秒里写完,覆盖就会把它的改动静默抹掉,随后的提交还会把
412
- 错误内容推走 —— 事后只能靠人翻历史才发现。progress_lock 只管单文件的读改写,盖不住
413
- 这两步之间的缝。
414
-
415
- 锁文件放 git 目录里(不进仓库、不被 git 状态看见);拿锁最多等 timeout 秒。
416
- """
417
- if fcntl is None and msvcrt is None:
418
- yield
419
- return
420
- lock_path = git_dir / "dsh-task-ledger.sync.lock"
421
- fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
422
- acquired = False
423
- try:
424
- deadline = time.monotonic() + timeout
425
- while True:
426
- try:
427
- if fcntl is not None:
428
- fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
429
- elif msvcrt is not None:
430
- os.lseek(fd, 0, os.SEEK_SET)
431
- msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) # pyright: ignore[reportAttributeAccessIssue]
432
- acquired = True
433
- break
434
- except OSError:
435
- if time.monotonic() >= deadline:
436
- raise Fail(
437
- f"另一个会话正在同步同一份台账(已等 {int(timeout)} 秒),请稍后重试。"
438
- )
439
- time.sleep(0.1)
440
- yield
441
- finally:
442
- if acquired:
443
- try:
444
- if fcntl is not None:
445
- fcntl.flock(fd, fcntl.LOCK_UN)
446
- elif msvcrt is not None:
447
- os.lseek(fd, 0, os.SEEK_SET)
448
- msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) # pyright: ignore[reportAttributeAccessIssue]
449
- except OSError:
450
- pass
451
- os.close(fd)
452
-
453
-
454
- def read_guarded(path: Path):
455
- """读状态表并记下指纹;配合 commit_guarded 做写前复查。"""
456
- return stat_key(path), read_json(path)
457
-
458
-
459
- def commit_guarded(
460
- path: Path, before: str | tuple[str, ...] | None, data: dict[str, Any]
461
- ) -> None:
462
- """写前复查指纹:被并发改动过就报错,不覆盖别人的改动。"""
463
- if stat_key(path) != before:
464
- raise Fail(f"{path.name} 在操作期间被改动过,已中止。重新执行一次。")
465
- write_json(path, data)
466
-
467
-
468
- def _git(
469
- *args: str, timeout: int | None = None, check: bool = False
470
- ) -> subprocess.CompletedProcess:
471
- """跑一条 git 命令,吞掉输出(同步用,不污染 stdout)。
472
-
473
- check=True 让失败抛异常(推送要能被下面的兜底捕获);提交则靠返回码分辨,
474
- 因为不同仓库的提交检查(commitlint 等)会拒掉消息,需要看 stderr 原文。
475
- """
476
- return subprocess.run(
477
- list(args), capture_output=True, text=True, timeout=timeout, check=check
478
- )
479
-
480
-
481
- def _git_actor(repo_root: str) -> str:
482
- """提交者:复用 git 身份;取不到用占位,不阻断提交。"""
483
- out = _git("git", "-C", repo_root, "config", "user.name")
484
- return out.stdout.strip() or "ledger-script"
485
-
486
-
487
- def _why(raw: str | None, fallback: str) -> str:
488
- """把 git 的 stderr / stdout 压成一行:取最后几行非空内容,够定位原因就行。"""
489
- lines = [ln.strip() for ln in (raw or "").splitlines() if ln.strip()]
490
- return " / ".join(lines[-3:]) if lines else fallback
491
-
492
-
493
- def _foreign_commits(g: tuple[str, ...], upstream: str) -> tuple[int, list[str]]:
494
- """本地领先 upstream、但不在 `ledger/` 里的提交(用户自己的代码):返回 (数量, 前几条摘要)。
495
-
496
- git 按路径过滤,不看提交消息 —— 用户把台账提交改写成别的 message 也不会被误判成「业务提交」。
497
- """
498
- spec = f"{upstream}..HEAD"
499
- raw = _git_bytes(*g, "rev-list", "--count", spec, "--", ".", ":(exclude)ledger")
500
- try:
501
- count = int((raw or b"-1").decode("ascii", "replace").strip())
502
- except ValueError:
503
- return 0, [] # 数不出来就当作没有:宁可少提示,不阻断台账写入
504
- if count <= 0:
505
- return 0, []
506
- listed = _git_bytes(
507
- *g, "log", "--format=%h %s", spec, "--", ".", ":(exclude)ledger"
508
- )
509
- return count, (listed or b"").decode("utf-8", "replace").strip().splitlines()[:3]
510
-
511
-
512
- def _rebase_retry(g: tuple[str, ...]) -> bool:
513
- """推送被拒后自动 rebase 一次;只在工作树干净时做,失败立刻 abort。
514
-
515
- 台账按任务分条存(progress.json 是按任务号 key 的对象、history 一行一条),
516
- 各自改各自的任务时 git 能按行自动合并。真冲突(同一条任务被两边改)就干净失败,
517
- 绝不把仓库留在 rebase 中间态,也绝不 --force。
518
- """
519
- fetched = _git(*g, "fetch", timeout=30)
520
- if fetched.returncode != 0:
521
- # 不静默:凭据过期 / 离线时这里就是唯一能说明「为什么没自动合并」的地方
522
- print(
523
- f"提示:推送被拒后重新取最新失败"
524
- f"({_why(fetched.stderr or fetched.stdout, f'退出码 {fetched.returncode}')}),未自动合并。",
525
- file=sys.stderr,
526
- )
527
- return False
528
- # 用户自己 commit 的代码不动:rebase 会重放整支分支,把他们的提交号全改写。
529
- # 只在自己那几条台账提交上做,才是「只碰台账」这条边界的完整实现。
530
- foreign, titles = _foreign_commits(g, "@{upstream}")
531
- if foreign > 0:
532
- detail = f":{';'.join(titles)}" if titles else ""
533
- print(
534
- f"提示:本地有 {foreign} 个你自己的提交还没推送{detail}。"
535
- "脚本不替你 rebase(那会改写它们的提交号),本次没有自动合并。\n"
536
- " 处理:手动 `git pull --rebase` 或 `git merge` 后再 `git push`;"
537
- "台账改动已安全留在本地。",
538
- file=sys.stderr,
539
- )
540
- return False
541
- # --autostash:把工作树里未提交的改动(用户正在写的代码)临时收走、rebase 完自动放回。
542
- # 不这样做的话,只要仓库里有一处未提交改动,自动合并就永久失效 —— 台账提交越堆越多,
543
- # 直到下一次写被「有未推送提交」拦住,而提示跟用户的业务代码毫无关系。
544
- rebase = _git(*g, "rebase", "--autostash", "@{upstream}", timeout=30)
545
- if rebase.returncode == 0:
546
- combined = f"{rebase.stdout or ''}{rebase.stderr or ''}"
547
- if "autostash" in combined:
548
- print(
549
- "提示:自动合并前暂存了你的未提交改动,rebase 后已放回;"
550
- "若 git 提示 autostash 冲突,跑 `git stash list` 手动处理。",
551
- file=sys.stderr,
552
- )
553
- return True
554
- conflict = (rebase.stderr or rebase.stdout or "").strip()
555
- abort = _git(*g, "rebase", "--abort", timeout=30)
556
- if abort.returncode != 0:
557
- # abort 也失败:仓库现在真的停在 rebase 中间态,必须把真实状态原样交出去。
558
- # 谎称「已回滚」最坏 —— 那时 progress.json 可能带冲突标记,继续写会全盘读坏。
559
- status = _git(*g, "status", "--short", "--branch")
560
- print(
561
- "错误:自动 rebase 冲突后回滚也失败了,仓库可能停在 rebase 中间态。"
562
- "在收拾干净之前不要再跑台账命令。\n"
563
- f" 冲突:{conflict}\n"
564
- f" 当前状态:{(status.stdout or '').strip()}\n"
565
- " 处理:在仓库根执行 `git rebase --abort`;仍失败就先 `git status` 看清残留文件"
566
- "(必要时删掉 .git/rebase-merge 或 .git/rebase-apply)再重试。",
567
- file=sys.stderr,
568
- )
569
- return False
570
- print(
571
- "提示:自动 rebase 未成功(已回滚到 rebase 前),多半是同一条任务被两边同时改了,"
572
- f"请手动合并:{conflict}",
573
- file=sys.stderr,
574
- )
575
- return False
576
-
577
-
578
- def git_sync(action: str) -> None:
579
- """写成功后调用一次:把本次改动的台账文件提交;有上游跟踪分支则推送。
580
-
581
- 纯脚本、确定性,不依赖大模型。无 git / 无上游 / 全被 ignore / 无实际变更 都静默跳过。
582
- 同步失败(提交被仓库钩子拒后连 --no-verify 也失败、推送被拒、意外异常)一律打到
583
- stderr 提示,绝不静默;但退出码不变 —— 台账本身已经写成功,别让调用方误判去重做。
584
- """
585
- if os.environ.get("TASK_LEDGER_NO_SYNC"):
586
- return
587
- try:
588
- # 本次写入目标 + 写前补下的 worklog:后者不是本次改动,但也得随这次提交入库
589
- files = [
590
- p for p in dict.fromkeys([*CHANGED_FILES, *PRESYNC_FILES]) if p.exists()
591
- ]
592
- if not files:
593
- return
594
- # 从任一文件反查仓库根,不依赖调用方透传 --repo
595
- top = _git("git", "-C", str(files[0].parent), "rev-parse", "--show-toplevel")
596
- if top.returncode != 0:
597
- return # 不是 git 项目 → 跳过(规则1:有才提交)
598
- root = top.stdout.strip()
599
- g = ("git", "-C", root)
600
- # 只 add 本次真实改动、且未被 ignore 的文件
601
- to_add = [
602
- str(f)
603
- for f in files
604
- if _git(*g, "check-ignore", "-q", str(f)).returncode != 0
605
- ]
606
- if not to_add:
607
- return # 全被 ignore → 尊重用户选择,不强行提交
608
- staged = _git(*g, "add", *to_add)
609
- if staged.returncode != 0:
610
- print(
611
- "提示:台账已写入本地,但 git add 失败,请手动提交 ledger/:"
612
- f"{(staged.stderr or staged.stdout or '').strip()}",
613
- file=sys.stderr,
614
- )
615
- return
616
- # 只看本次要提交的路径:别人暂存的文件不该让这里误判成「有变更」
617
- if _git(*g, "diff", "--cached", "--quiet", "--", *to_add).returncode == 0:
618
- return # 无实际变更
619
- actor = _git_actor(root)
620
- # 消息按 conventional commits 写:多数仓库装了 commitlint,只认白名单里的 type
621
- msg = f"chore(ledger): {action} {actor}"
622
- # 必须 --only + pathspec:git commit 默认提交整个 index,会把用户或别的 agent
623
- # 已 git add 的业务文件一起卷进这条 chore(ledger) 提交并推走。
624
- done = _git(*g, "commit", "--only", "-m", msg, "--", *to_add)
625
- if done.returncode != 0:
626
- # 各仓库提交规则各不相同(type 白名单、大小写、scope 枚举……),台账是数据文件,
627
- # 过不了就让这一次跳过钩子提交,别把台账永久锁在本地暂存区。
628
- done = _git(*g, "commit", "--only", "--no-verify", "-m", msg, "--", *to_add)
629
- if done.returncode == 0:
630
- print(
631
- f"提示:仓库提交检查拒绝了消息「{msg}」,已用 --no-verify 提交本次台账改动。",
632
- file=sys.stderr,
633
- )
634
- if done.returncode != 0:
635
- print(
636
- "提示:台账已写入本地,但自动提交失败,请手动提交 ledger/:"
637
- f"{(done.stderr or done.stdout or '').strip()}",
638
- file=sys.stderr,
639
- )
640
- return
641
- # 规则2:有上游跟踪分支才推
642
- upstream = _git(*g, "rev-parse", "--abbrev-ref", "@{upstream}")
643
- if upstream.returncode != 0:
644
- return
645
- # push 是分支级的:本地有用户自己 commit 但没推的代码,会一并送到远端。
646
- # 脚本不替用户下这个决定,但必须说出来,别让它无声发生。
647
- foreign, titles = _foreign_commits(g, upstream.stdout.strip())
648
- if foreign > 0:
649
- detail = f":{';'.join(titles)}" if titles else ""
650
- print(
651
- f"提示:本次推送会把本地 {foreign} 个你自己的提交一并推上去{detail}。",
652
- file=sys.stderr,
653
- )
654
- try:
655
- _git(*g, "push", timeout=30, check=True)
656
- except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as push_exc:
657
- # 非快进:只在台账工作树干净时自动 rebase 一次再推,冲突立刻 abort。
658
- # 各自改各自的任务时 git 按行自动合并;同一条任务冲突就干净失败,绝不 --force。
659
- if _rebase_retry(g):
660
- try:
661
- _git(*g, "push", timeout=30, check=True)
662
- return
663
- except (
664
- subprocess.CalledProcessError,
665
- subprocess.TimeoutExpired,
666
- ) as retry_exc:
667
- print(
668
- "提示:自动合并后再次推送仍被拒"
669
- f"({_why(getattr(retry_exc, 'stderr', '') or str(retry_exc), '推送失败')})。",
670
- file=sys.stderr,
671
- )
672
- # 必须带 git 原话:认证过期 / 分支保护 / 非快进只能从原文分辨,只给一句猜测等于没说
673
- print(
674
- "提示:本地已提交,但推送被拒,本次改动已安全留在本地。\n"
675
- f" git:{_why(getattr(push_exc, 'stderr', '') or str(push_exc), '推送失败')}\n"
676
- " 处理:`git push` 看原话;认证 / 权限问题找用户,不要反复重试。",
677
- file=sys.stderr,
678
- )
679
- except Exception as exc: # noqa: BLE001 —— 同步失败不应污染台账主流程
680
- print(
681
- f"提示:写后自动同步未执行({type(exc).__name__}: {exc})。数据已写入本地。",
682
- file=sys.stderr,
683
- )
684
-
685
-
686
- # 只读命令不碰 git;其余(含写类与 tags)写前刷新 ledger/。
687
- READ_ONLY_COMMANDS = {"list", "show", "history", "validate"}
688
-
689
-
690
- def _git_bytes(*args: str, timeout: int | None = None) -> bytes | None:
691
- """跑 git 拿原始字节;失败返回 None。
692
-
693
- 不走 _git():那里固定 text=True + 默认编码,会按 locale 解码并做换行归一,
694
- 中文路径与非 UTF-8 locale 都会出事。取台账内容必须字节保真。
695
- """
696
- try:
697
- proc = subprocess.run(
698
- list(args), capture_output=True, timeout=timeout, check=False
699
- )
700
- except (OSError, subprocess.TimeoutExpired):
701
- return None
702
- return proc.stdout if proc.returncode == 0 else None
703
-
704
-
705
- def _split_z(out: bytes | None) -> list[str]:
706
- """按 NUL 切 -z 输出(路径原样,不被 core.quotepath 转义)。"""
707
- if not out:
708
- return []
709
- return [s.decode("utf-8", "surrogateescape") for s in out.split(b"\0") if s]
710
-
711
-
712
- def _remote_exists(root: str, upstream: str, rel: str) -> bool:
713
- """远端是否有这个文件。"""
714
- return (
715
- subprocess.run(
716
- ["git", "-C", root, "cat-file", "-e", f"{upstream}:{rel}"],
717
- capture_output=True,
718
- check=False,
719
- ).returncode
720
- == 0
721
- )
722
-
723
-
724
- def _remote_dir_files(root: str, upstream: str, rel_dir: str) -> list[str]:
725
- """列远端目录下的文件(相对仓库根,原样路径)。远端没有该目录时返回空。"""
726
- return _split_z(
727
- _git_bytes(
728
- "git",
729
- "-C",
730
- root,
731
- "ls-tree",
732
- "-r",
733
- "-z",
734
- "--name-only",
735
- upstream,
736
- "--",
737
- rel_dir + "/",
738
- )
739
- )
740
-
741
-
742
- def _git_midstate(git_root: str) -> str | None:
743
- """仓库是否停在 merge / rebase 等冲突中间态;是就返回人话名字。
744
-
745
- 中间态下工作树里的台账可能是冲突版本(带 `<<<<<<<` 标记),
746
- 写前同步与写后提交都会往这个现场里写,必须先让用户收尾。
747
- """
748
- out = _git("git", "-C", git_root, "rev-parse", "--absolute-git-dir")
749
- if out.returncode != 0:
750
- return None
751
- git_dir = Path(out.stdout.strip())
752
- for marker, label in (
753
- ("MERGE_HEAD", "merge"),
754
- ("rebase-merge", "rebase"),
755
- ("rebase-apply", "rebase/am"),
756
- ("CHERRY_PICK_HEAD", "cherry-pick"),
757
- ("REVERT_HEAD", "revert"),
758
- ):
759
- if (git_dir / marker).exists():
760
- return label
761
- return None
762
-
763
-
764
- def _presync_guard(
765
- git_root: str, upstream: str, managed: list[str], remote_files: set[str]
766
- ) -> None:
767
- """取最新之前确认本地没有会被覆盖掉的东西;命中就中止,绝不静默覆盖。
768
-
769
- 三种命中:未推送的台账提交(取最新会把它们的内容回退掉)、已跟踪文件的
770
- 未提交改动、本地还没入库但远端已有同名文件。
771
- """
772
- ahead = _git_bytes(
773
- "git",
774
- "-C",
775
- git_root,
776
- "rev-list",
777
- "--count",
778
- f"{upstream}..HEAD",
779
- "--",
780
- *managed,
781
- )
782
- count = (ahead or b"").decode("ascii", "replace").strip()
783
- if count and count != "0":
784
- listing = _git_bytes(
785
- "git",
786
- "-C",
787
- git_root,
788
- "log",
789
- "--oneline",
790
- f"{upstream}..HEAD",
791
- "--",
792
- *managed,
793
- )
794
- recent = (listing or b"").decode("utf-8", "replace").strip().splitlines()[:3]
795
- detail = ("\n 未推送:" + ";".join(recent)) if recent else ""
796
- raise Fail(
797
- f"本地有 {count} 个台账提交还没推到 {upstream},写前取最新会把它们的内容"
798
- f"回退掉,已中止本次操作。{detail}\n"
799
- " 处理:有网络时先 `git push`;暂时离线可加 --no-sync 继续在本地写,"
800
- "联网后再提交推送。"
801
- )
802
- dirty = _split_z(
803
- _git_bytes(
804
- "git", "-C", git_root, "diff", "--name-only", "-z", "HEAD", "--", *managed
805
- )
806
- )
807
- if dirty:
808
- raise Fail(
809
- "本地台账有未提交改动,写前取最新会覆盖它,已中止本次操作:\n "
810
- + "\n ".join(dirty)
811
- + '\n 处理:先提交这份改动(`git add ledger/ && git commit -m "chore(ledger): ..."`)'
812
- "再重跑;确认不要它可加 --no-sync。"
813
- )
814
- untracked = set(
815
- _split_z(
816
- _git_bytes(
817
- "git",
818
- "-C",
819
- git_root,
820
- "ls-files",
821
- "--others",
822
- "--exclude-standard",
823
- "-z",
824
- "--",
825
- *managed,
826
- )
827
- )
828
- )
829
- clash = sorted(untracked & remote_files)
830
- if clash:
831
- raise Fail(
832
- "这些台账文件本地还没入库、远端已有同名文件,写前取最新会覆盖它们,"
833
- "已中止本次操作:\n "
834
- + "\n ".join(clash)
835
- + "\n 处理:先提交(或删掉)这些文件再重跑;确认不要远端版本可加 --no-sync。"
836
- )
837
-
838
-
839
- def _presync_fetch_file(
840
- git_root: str, upstream: str, rel: str, *, track: bool = False
841
- ) -> bool:
842
- """把远端某个文件的当前内容落到工作树;内容相同就不写。返回是否真的写了。
843
-
844
- 用 blob 内容自己落盘,而不是 checkout / restore:只碰工作树、不动 index,
845
- 不需要 git ≥2.23,也不会因为一个路径失败而整批失败。
846
-
847
- track=False(默认)用于随后还要改写的脚本独占文件;track=True 用于补下来的
848
- worklog 新文件——它不是本次改动,但必须随本次提交入库(记进 PRESYNC_FILES),
849
- 否则会一直以未跟踪状态留在工作树里,把「有没有未提交改动」的判断弄脏。
850
- """
851
- data = _git_bytes("git", "-C", git_root, "cat-file", "blob", f"{upstream}:{rel}")
852
- if data is None:
853
- return False
854
- dest = Path(git_root) / rel
855
- try:
856
- if dest.is_file() and dest.read_bytes() == data:
857
- return False
858
- except OSError:
859
- pass
860
- try:
861
- text = data.decode("utf-8")
862
- except UnicodeDecodeError:
863
- print(f"提示:{rel} 不是 UTF-8,已跳过写前刷新。", file=sys.stderr)
864
- return False
865
- # 覆盖前留底:命令随后失败时回滚,别让远端版本留在工作树里冒充用户的未提交改动
866
- if dest not in PRESYNC_BACKUPS:
867
- try:
868
- PRESYNC_BACKUPS[dest] = dest.read_bytes() if dest.is_file() else None
869
- except OSError:
870
- PRESYNC_BACKUPS[dest] = None
871
- try:
872
- write_text_atomic(dest, text, track=False)
873
- except Fail as exc:
874
- print(f"提示:写前刷新 {rel} 失败({exc}),继续用本地版本。", file=sys.stderr)
875
- return False
876
- if track:
877
- PRESYNC_FILES.append(dest)
878
- return True
879
-
880
-
881
- def git_presync(action: str, args: argparse.Namespace) -> None:
882
- """写命令执行前把「这条命令真的要写的那几个文件」刷成远端最新。
883
-
884
- 范围只到本次写入目标:`ledger/progress.json`(每条写命令都写),以及 `close`
885
- 时结案时间所在的那一个月历史文件(`--finished` 补录旧月份就刷那个月)——不是
886
- 整个 history 目录。`ledger/worklog/` 的正文由人写,这里只补「远端有、本地缺」
887
- 的新文件,已有文件一个字都不碰。本地有会被覆盖掉的东西(未推送提交 / 未提交
888
- 改动 / 未入库且远端同名)就中止,不静默覆盖。
889
- 无 git / 无上游 / 远端尚无该文件 静默跳过;拉取失败只提示不中止。
890
- """
891
- if os.environ.get("TASK_LEDGER_NO_SYNC"):
892
- return
893
- if action in READ_ONLY_COMMANDS:
894
- return
895
- # tags --all 是纯读,不拉取
896
- if action == "tags" and not getattr(args, "rename", None):
897
- return
898
- try:
899
- repo = find_repo(getattr(args, "repo", None))
900
- except Fail:
901
- return
902
- ledger_dir = repo / "ledger"
903
- top = _git("git", "-C", str(repo), "rev-parse", "--show-toplevel")
904
- if top.returncode != 0:
905
- return
906
- git_root = Path(top.stdout.strip())
907
- try:
908
- ledger_rel = ledger_dir.resolve().relative_to(git_root.resolve()).as_posix()
909
- except ValueError:
910
- return # ledger 不在 git 仓内
911
- midstate = _git_midstate(str(git_root))
912
- if midstate:
913
- raise Fail(
914
- f"仓库正处于 {midstate} 中间态(有没收尾的冲突),此刻做写前同步和写后提交"
915
- "都会把台账写进冲突现场,已中止本次操作。\n"
916
- " 处理:先把这次 merge / rebase 收尾 —— `git merge --continue`、"
917
- "`git rebase --continue`,要放弃就 `--abort`;然后重跑台账命令。"
918
- )
919
- ups = _git("git", "-C", str(git_root), "rev-parse", "--abbrev-ref", "@{upstream}")
920
- if ups.returncode != 0:
921
- head = _git("git", "-C", str(git_root), "rev-parse", "--abbrev-ref", "HEAD")
922
- branch = (head.stdout or "").strip()
923
- if branch == "HEAD":
924
- print(
925
- "提示:当前是 detached HEAD,台账只写本地文件并提交到当前 HEAD,不会推送。",
926
- file=sys.stderr,
927
- )
928
- else:
929
- print(
930
- f"提示:分支 {branch or '(未知)'} 没有上游跟踪分支,"
931
- "台账只提交到本地、不会推送。",
932
- file=sys.stderr,
933
- )
934
- return
935
- upstream = ups.stdout.strip()
936
- if "/" not in upstream:
937
- print(
938
- f"提示:上游 {upstream} 是本地分支而不是远端分支,无法取最新,已跳过写前同步。",
939
- file=sys.stderr,
940
- )
941
- return
942
- remote = upstream.split("/", 1)[0]
943
- try:
944
- _git("git", "-C", str(git_root), "fetch", remote, timeout=30, check=True)
945
- except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
946
- print(
947
- "提示:写前拉取失败(离线 / 无权限 / 超时),将基于本地当前内容写入;"
948
- "若他人已改动,推送时可能冲突。",
949
- file=sys.stderr,
950
- )
951
- return
952
-
953
- # 本地没有未推送提交时先快进到远端(等价 `pull --ff-only`)。
954
- # 只覆盖工作树会留个后患:本次写入提交会把「远端新增的内容」当成自己的改动一起提交,
955
- # 推送被拒后 rebase 与远端那次提交撞车(同一段内容被重放两次)—— 实测必冲突。
956
- # 有未推送提交就跳过:那是用户自己的时间线,脚本不碰。
957
- def _rev_count(spec: str) -> int:
958
- raw = _git_bytes("git", "-C", str(git_root), "rev-list", "--count", spec)
959
- try:
960
- return int((raw or b"-1").decode("ascii", "replace").strip())
961
- except ValueError:
962
- return -1
963
-
964
- ahead_n = _rev_count(f"{upstream}..HEAD")
965
- behind_n = _rev_count(f"HEAD..{upstream}")
966
- if ahead_n == 0 and behind_n > 0:
967
- ff = _git(
968
- "git", "-C", str(git_root), "merge", "--ff-only", "-q", upstream, timeout=30
969
- )
970
- if ff.returncode == 0:
971
- print(
972
- f"提示:写前把本地快进到 {upstream}({behind_n} 个提交,仅 fast-forward)。",
973
- file=sys.stderr,
974
- )
975
- else:
976
- # 不中止:工作区有未提交改动时 ff 必然失败,那是常态,后面还有脏检查兜底。
977
- # 但必须说出来,否则「为什么没快进」无从查起。
978
- print(
979
- f"提示:写前快进到 {upstream} 没成功"
980
- f"({_why(ff.stderr or ff.stdout, f'退出码 {ff.returncode}')}),"
981
- "改为只刷新本次要写的台账文件。",
982
- file=sys.stderr,
983
- )
984
-
985
- # 离线攒下的台账提交:联网后第一笔写先把它们推上去,别拿「有 N 个没推」挡住写入。
986
- # 用户自己的业务提交不碰:push 会把它们一并带走,那个决定归用户(见 _rebase_retry)。
987
- if ahead_n > 0:
988
- g_tuple = ("git", "-C", str(git_root))
989
- foreign_n, _ = _foreign_commits(g_tuple, upstream)
990
- if foreign_n == 0:
991
- pushed = _git(*g_tuple, "push", timeout=30)
992
- if pushed.returncode != 0 and _rebase_retry(g_tuple):
993
- pushed = _git(*g_tuple, "push", timeout=30)
994
- if pushed.returncode == 0:
995
- print(
996
- f"提示:写前把本地 {ahead_n} 个未推送的提交推上去了。",
997
- file=sys.stderr,
998
- )
999
- ahead_n = 0
1000
- else:
1001
- print(
1002
- "提示:写前推送失败"
1003
- f"({_why(pushed.stderr or pushed.stdout, f'退出码 {pushed.returncode}')})。",
1004
- file=sys.stderr,
1005
- )
1006
-
1007
- root = str(git_root)
1008
- progress_rel = f"{ledger_rel}/{PROGRESS_REL.name}"
1009
- worklog_rel = f"{ledger_rel}/{WORKLOG_DIR.name}"
1010
- # 只刷「这条命令真的要写」的文件:progress.json 人人都写;
1011
- # 历史层只有 close 写,而且只写结案时间所在的那一个月(--finished 可能补录旧月份)。
1012
- managed = [progress_rel]
1013
- if action == "close":
1014
- finished = ""
1015
- raw_finished = getattr(args, "finished", None)
1016
- if raw_finished:
1017
- try:
1018
- finished = normalize_time(raw_finished, field="--finished")
1019
- except Fail:
1020
- finished = "" # 参数不合法交给 close 自己报错,这里按当前月兜底
1021
- target = (
1022
- history_path_for(repo, finished)
1023
- if finished
1024
- else history_path_for(repo, now())
1025
- )
1026
- try:
1027
- managed.append(target.resolve().relative_to(git_root.resolve()).as_posix())
1028
- except ValueError:
1029
- pass # 历史文件不在仓内:跳过(写的时候自有报错)
1030
-
1031
- remote_files = {rel for rel in managed if _remote_exists(root, upstream, rel)}
1032
-
1033
- _presync_guard(root, upstream, managed, remote_files)
1034
-
1035
- for rel in remote_files:
1036
- _presync_fetch_file(root, upstream, rel)
1037
-
1038
- # worklog:只补本地缺的新文件,已有文件(正文由人写)一个字都不碰
1039
- added = 0
1040
- for rel in _remote_dir_files(root, upstream, worklog_rel):
1041
- if (git_root / rel).exists():
1042
- continue
1043
- # track=True:补下来的新文件要随本次提交入库,否则它会一直以未跟踪状态挡在
1044
- # 工作树里(后续 rebase 直接被它挡住)
1045
- if _presync_fetch_file(root, upstream, rel, track=True):
1046
- added += 1
1047
- if added:
1048
- print(
1049
- f"提示:从 {upstream} 补下 {added} 个本地缺的 worklog 文件(没有动已有正文)。",
1050
- file=sys.stderr,
1051
- )
1052
-
1053
-
1054
- def require_task_id(tid: str) -> str:
1055
- """任务号会参与拼路径,任何命令拿到它都要先验一遍。"""
1056
- if not ID_RE.fullmatch(tid):
1057
- raise Fail(
1058
- f"任务号 {tid!r} 不合规:只用小写字母、数字、连字符和汉字,且以字母、数字或汉字开头。"
1059
- )
1060
- return tid
1061
-
1062
-
1063
- def normalize_label(value: str, *, field: str = "分类", tid: str | None = None) -> str:
1064
- """分类 / 标签单值形态校验:拒控制字符与换行(防注入 worklog 头部行)、折叠内部空白。
1065
-
1066
- 含 CJK 按字符数 ≤4;纯 ASCII 按单词数 ≤3 —— 中英混排按字符数卡,逼 Agent 写短。
1067
- """
1068
- where = f"{tid}: " if tid else ""
1069
- if any(ord(c) < 32 or ord(c) == 127 for c in value):
1070
- raise Fail(f"{where}{field}不能含控制字符或换行:{value!r}")
1071
- s = " ".join(value.split())
1072
- if not s:
1073
- raise Fail(f"{where}{field}不能为空。")
1074
- if CJK_RE.search(s):
1075
- if len(s) > LABEL_MAX_CHARS:
1076
- raise Fail(
1077
- f"{where}{field}「{s}」超长:含中文按字符数最多 {LABEL_MAX_CHARS} 个。"
1078
- )
1079
- elif len(s.split()) > LABEL_MAX_WORDS_EN:
1080
- raise Fail(
1081
- f"{where}{field}「{s}」超长:纯英文按单词数最多 {LABEL_MAX_WORDS_EN} 个。"
1082
- )
1083
- return s
1084
-
1085
-
1086
- def parse_tags(raw: str, *, field: str = "标签", tid: str | None = None) -> list[str]:
1087
- """标签串 → 列表:逗号 / 顿号切分(**不按空格**——空格留给英文多词标签自身),
1088
- 去空、去重保序;数量 ≤5,逐个过 normalize_label。"""
1089
- where = f"{tid}: " if tid else ""
1090
- tags: list[str] = []
1091
- for part in re.split(r"[,,、]", raw):
1092
- if not part.strip():
1093
- continue
1094
- tag = normalize_label(part, field=field, tid=tid)
1095
- if tag not in tags:
1096
- tags.append(tag)
1097
- if not tags:
1098
- raise Fail(f"{where}{field}不能为空。")
1099
- if len(tags) > TAGS_MAX_COUNT:
1100
- raise Fail(
1101
- f"{where}{field}最多 {TAGS_MAX_COUNT} 个,收到 {len(tags)} 个:{'、'.join(tags)}"
1102
- )
1103
- return tags
1104
-
1105
-
1106
- def esc_cell(text: str) -> str:
1107
- """表格单元格:折叠空白并转义竖线,避免把历史表格写坏。"""
1108
- return " ".join(text.split()).replace("|", "\\|")
1109
-
1110
-
1111
- def owner_cell(owner: str | None) -> str:
1112
- """owner 列的展示值:人名前加 @(一眼看出这是个人),空给空串。
1113
-
1114
- @ 只是展示层约定:存储(progress.json)与 --owner 匹配始终用裸名字,
1115
- parse_history 读回来时会把 @ 剥掉。
1116
- """
1117
- return f"@{owner}" if owner else ""
1118
-
1119
-
1120
- def creator_cell(creator: str | None) -> str:
1121
- """creator 列展示值(与 owner_cell 同一 @ 约定)。"""
1122
- return f"@{creator}" if creator else ""
1123
-
1124
-
1125
- def history_line(
1126
- finished: str,
1127
- created: str | None,
1128
- title: str,
1129
- owner: str | None,
1130
- summary: str,
1131
- log_rel: str,
1132
- category: str | None = None,
1133
- tags: list[str] | None = None,
1134
- creator: str | None = None,
1135
- ) -> str:
1136
- """历史层的一行(9 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / 完成了什么 / 过程文档)。
1137
-
1138
- close 专用,避免两处格式漂移。owner / creator 是独立列、单独过 esc_cell ——
1139
- 它们来自 git config user.name,含 `|` 也不稀奇。分类/标签由 close 传 task 终值。
1140
- """
1141
- cat_cell = esc_cell(category or "")
1142
- tag_cell = esc_cell("、".join(tags) if tags else "")
1143
- return (
1144
- f"| {finished} | {created or ''} | {esc_cell(title)} | {cat_cell} | {tag_cell} "
1145
- f"| {esc_cell(owner_cell(owner))} | {esc_cell(creator_cell(creator))} "
1146
- f"| {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
1147
- )
1148
-
1149
-
1150
- def history_line_for_cols(
1151
- cols: int,
1152
- finished: str,
1153
- created: str | None,
1154
- title: str,
1155
- owner: str | None,
1156
- summary: str,
1157
- log_rel: str,
1158
- category: str | None = None,
1159
- tags: list[str] | None = None,
1160
- creator: str | None = None,
1161
- ) -> str:
1162
- """「历史层有无法解析的行、不敢整体升级」时,按现有列数追加一行,避免列错位。
1163
-
1164
- GFM 按首行列数渲染,列数写错会被静默丢弃 / 错位。cols<=4 ⇒ 旧 4 列(owner 缝在
1165
- 工作包末尾);5 ⇒ 无创建时间列;6 ⇒ 无分类/标签列(存量改造前的最后形态);
1166
- 否则 9 列。分类/标签/creator 只在 9 列里出现 —— 少列的文件硬塞会把整行挤歪。
1167
- """
1168
- if cols <= 4:
1169
- who = f" @{owner}" if owner else ""
1170
- return (
1171
- f"| {finished} | {esc_cell(title + who)} | {esc_cell(summary)} "
1172
- f"| [{log_rel}]({log_rel}) |\n"
1173
- )
1174
- if cols == 5:
1175
- return (
1176
- f"| {finished} | {esc_cell(title)} | {esc_cell(owner_cell(owner))} "
1177
- f"| {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
1178
- )
1179
- if cols == 6:
1180
- return (
1181
- f"| {finished} | {created or ''} | {esc_cell(title)} "
1182
- f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
1183
- )
1184
- return history_line(
1185
- finished, created, title, owner, summary, log_rel, category, tags, creator
1186
- )
1187
-
1188
-
1189
- def history_cols(text: str) -> int:
1190
- """现有历史表格数据行的列数(取众数、平局取较大);无数据行时按 6。"""
1191
- counts: dict[int, int] = {}
1192
- for raw in text.splitlines():
1193
- if not raw.strip().startswith("|"):
1194
- continue
1195
- cells = split_row(raw)
1196
- if not cells or _is_sep_row(cells) or _looks_like_header(cells):
1197
- continue
1198
- n = len(cells)
1199
- if n >= 4:
1200
- counts[n] = counts.get(n, 0) + 1
1201
- if not counts:
1202
- return 6
1203
- return max(counts, key=lambda k: (counts[k], k))
1204
-
1205
-
1206
- # 旧行切 owner:最右一个「空格 + @」之后的整段是 owner(owner 可含空格,git user.name 合法)。
1207
- # 要求 @ 前有空白 ⇒ 标题里的 `rulemux@0.1.0` 不会误伤;贪婪 title 保证最后一个「 @」赢。
1208
- HISTORY_OLD_OWNER_RE = re.compile(r"^(?P<title>.*\S)\s+@(?P<owner>\S.*)$")
1209
-
1210
-
1211
- def split_row(line: str) -> list[str]:
1212
- """按 | 切表格行。反顾后视让转义竖线 \\| 不当分隔符;切完再反转义、去首尾空单元。"""
1213
- cells = re.split(r"(?<!\\)\|", line.strip())
1214
- if cells and cells[0] == "":
1215
- cells = cells[1:]
1216
- if cells and cells[-1] == "":
1217
- cells = cells[:-1]
1218
- return [c.strip().replace("\\|", "|") for c in cells]
1219
-
1220
-
1221
- def _is_sep_row(cells: list[str]) -> bool:
1222
- return bool(cells) and all(re.fullmatch(r":?-+:?", c) for c in cells)
1223
-
1224
-
1225
- def _looks_like_header(cells: list[str]) -> bool:
1226
- # 按内容判、不按位置判:无表头的老文件首行就是数据,位置判断会把第一条记录当表头删掉。
1227
- # v6 表头首列是「结案时间」,「工作包」在第 3 列;v4/v5 首列是「时间」、「工作包」在第 2 列。
1228
- return bool(cells) and (cells[0] in ("时间", "结案时间") or "工作包" in cells[:3])
1229
-
1230
-
1231
- def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
1232
- """解析历史层,返回 (records, warnings)。
1233
-
1234
- 多格式兼容:9 列 = v9(结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / …);
1235
- 8 列 = v8(存量改造后、2026-10-17 前,无 creator);6 列 = v6
1236
- (存量改造前最后形态,无分类/标签);5 列且第 2 列是时间 = 旧 ledger 格式
1237
- (有创建时间列、无 owner 列);5 列 = 无创建时间列;4 列 = 最旧(owner 缝在工作包末尾)。
1238
- **逐行按自己的列数判别**,表头只用来跳过 —— 旧文件追加新列
1239
- 后是混排态,按表头判列数会切错。坏行(列数不在 4/5/6/8/9、时间列不合法)不中断:跳过 + 警告。
1240
- 8 列行无 creator 列 ⇒ creator 留 None,cmd_history 回链 worklog 头部「创建」行补(与 created 同源)。
1241
- """
1242
- records: list[dict[str, Any]] = []
1243
- warnings: list[str] = []
1244
- for lineno, raw in enumerate(text.splitlines(), 1):
1245
- if not raw.strip().startswith("|"):
1246
- continue
1247
- cells = split_row(raw)
1248
- if not cells or _is_sep_row(cells) or _looks_like_header(cells):
1249
- continue
1250
- created = ""
1251
- category: str | None = None
1252
- tags: list[str] = []
1253
- creator: str | None = None
1254
- if len(cells) == 9:
1255
- (
1256
- finished,
1257
- created,
1258
- workpackage,
1259
- category,
1260
- tag_cell,
1261
- owner,
1262
- creator,
1263
- summary,
1264
- doc,
1265
- ) = cells
1266
- fmt = "v9"
1267
- category = category.strip() or None
1268
- tags = [t.strip() for t in re.split(r"[,,、]", tag_cell) if t.strip()]
1269
- creator = creator.strip().removeprefix("@") or None
1270
- elif len(cells) == 8:
1271
- finished, created, workpackage, category, tag_cell, owner, summary, doc = (
1272
- cells
1273
- )
1274
- fmt = "v8"
1275
- category = category.strip() or None
1276
- tags = [t.strip() for t in re.split(r"[,,、]", tag_cell) if t.strip()]
1277
- # 旧 8 列无 creator 列:留 None,cmd_history 回链 worklog 头部补(不丢)
1278
- elif len(cells) == 6:
1279
- finished, created, workpackage, owner, summary, doc = cells
1280
- fmt = "v6"
1281
- elif len(cells) == 5 and TIME_RE.fullmatch(cells[1]):
1282
- # 旧 ledger 格式(结案时间 / 创建时间 / 工作包 / 摘要 / 过程文档):无 owner 列。
1283
- # 与下面的 v5(无创建时间列、owner 缝在中间)靠「第 2 列是不是时间」区分 ——
1284
- # v5 的第 2 列是工作包(标题),不会是时间,判别无歧义。
1285
- finished, created, workpackage, summary, doc = cells
1286
- owner = ""
1287
- fmt = "v5ledger"
1288
- elif len(cells) == 5:
1289
- finished, workpackage, owner, summary, doc = cells
1290
- fmt = "v5"
1291
- elif len(cells) == 4:
1292
- finished, workpackage, summary, doc = cells
1293
- m = HISTORY_OLD_OWNER_RE.match(workpackage)
1294
- if m:
1295
- workpackage, owner = m.group("title"), m.group("owner")
1296
- else:
1297
- owner = ""
1298
- fmt = "v4"
1299
- else:
1300
- warnings.append(f"第 {lineno} 行列数不是 4 / 5 / 6 / 8 / 9,已跳过")
1301
- continue
1302
- if not TIME_RE.fullmatch(finished):
1303
- warnings.append(f"第 {lineno} 行时间列 {finished!r} 不合法,已跳过")
1304
- continue
1305
- m = re.fullmatch(r"\[[^\]]*\]\(([^)]*)\)", doc)
1306
- log = m.group(1) if m else doc
1307
- # 展示层写入带 @;存储与匹配一律裸名字
1308
- own = (owner or "").strip().removeprefix("@")
1309
- records.append(
1310
- {
1311
- "time": finished,
1312
- "created": time_key(created) if created else None,
1313
- "workpackage": workpackage,
1314
- "category": category,
1315
- "tags": tags,
1316
- "owner": own or None,
1317
- "creator": creator,
1318
- "summary": summary,
1319
- "log": log or None,
1320
- "fmt": fmt,
1321
- "line": lineno,
1322
- }
1323
- )
1324
- return records, warnings
1325
-
1326
-
1327
- # worklog 头部「创建」行:`> **创建**:2026-10-06 00:00(@某人)`。
1328
- # 时间给旧历史行回填创建时间;括号内 @某人 即 creator(发起人)。
1329
- WORKLOG_CREATED_RE = re.compile(
1330
- r"^\s*>\s*\*\*创建\*\*\s*[::]\s*"
1331
- r"(\d{4}-\d{2}-\d{2}(?: +\d{1,2}:\d{1,2}(?::\d{1,2})?)?)"
1332
- )
1333
- # 同一行的发起人(@某人)——creator 列来源。
1334
- WORKLOG_CREATOR_RE = re.compile(r"^\s*>\s*\*\*创建\*\*\s*[::].*?(@(.+?))")
1335
-
1336
-
1337
- def created_for_history(log_cell: str, history_path: Path) -> str | None:
1338
- """顺着历史行的文档链接找到 worklog,读它头部「创建」行的时间;找不到返回 None。
1339
-
1340
- 链接相对历史文件所在目录解析:存量平铺文件里是 `worklog/…`,按月文件里是 `../worklog/…`。
1341
- """
1342
- m = re.fullmatch(r"\[[^\]]*\]\(([^)]*)\)", log_cell.strip())
1343
- rel = (m.group(1) if m else log_cell).strip()
1344
- if not rel:
1345
- return None
1346
- try:
1347
- text = (history_path.parent / rel).read_text(encoding="utf-8-sig")
1348
- except (OSError, UnicodeDecodeError):
1349
- return None
1350
- for ln in text.splitlines():
1351
- mm = WORKLOG_CREATED_RE.match(ln)
1352
- if mm:
1353
- try:
1354
- return normalize_time(mm.group(1), field="创建")
1355
- except Fail:
1356
- return None
1357
- return None
1358
-
1359
-
1360
- def creator_for_history(log_cell: str | None, history_path: Path) -> str | None:
1361
- """顺着历史行的文档链接找到 worklog,读它头部「创建」行的发起人(@某人);找不到返回 None。
1362
-
1363
- 旧 8 列历史行没有 creator 列,迁移 / 只读回填都靠它 —— 与 created 同源,不丢数据。
1364
- """
1365
- if not log_cell:
1366
- return None
1367
- m = re.fullmatch(r"\[[^\]]*\]\(([^)]*)\)", log_cell.strip())
1368
- rel = (m.group(1) if m else log_cell).strip()
1369
- if not rel:
1370
- return None
1371
- try:
1372
- text = (history_path.parent / rel).read_text(encoding="utf-8-sig")
1373
- except (OSError, UnicodeDecodeError):
1374
- return None
1375
- for ln in text.splitlines():
1376
- mm = WORKLOG_CREATOR_RE.match(ln)
1377
- if mm:
1378
- return mm.group(1).strip()
1379
- return None
1380
-
1381
-
1382
- def labels_for_log(
1383
- log_cell: str | None, history_path: Path
1384
- ) -> tuple[str | None, list[str]]:
1385
- """顺着历史行的文档链接找 worklog,解析头部「分类/标签」行;读不到返回空。
1386
-
1387
- 接受纯路径或 markdown 链接单元格(`[x](y)`)—— cmd_history 传解好的路径,
1388
- upgrade_history 传原始单元格,两处都走这里。
1389
- """
1390
- if not log_cell:
1391
- return (None, [])
1392
- m = re.fullmatch(r"\[[^\]]*\]\(([^)]*)\)", log_cell.strip())
1393
- rel = (m.group(1) if m else log_cell).strip()
1394
- if not rel:
1395
- return (None, [])
1396
- try:
1397
- text = (history_path.parent / rel).read_text(encoding="utf-8-sig")
1398
- except (OSError, UnicodeDecodeError):
1399
- return (None, [])
1400
- return parse_label_line(text)
1401
-
1402
-
1403
- def upgrade_history(text: str, history_path: Path) -> tuple[str, int] | None:
1404
- """旧格式(4 / 5 / 6 / 8 列)→ v9(9 列)。全部数据行可解析才返回 (新文本, 转换行数),否则 None。
1405
-
1406
- None 的语义是「不敢动」:调用方必须退回按原列数追加并明示,绝不半升级、绝不猜。
1407
- 分类/标签列:回链每行的 worklog 头部读「分类/标签」行(读不到留空,不编造)——
1408
- 与 created 的回填同一模式,依赖 Step 2 已给存量 worklog 补行。
1409
- creator 列(2026-10-17 加):旧 8 列无此列,同样回链 worklog 头部「创建」行的
1410
- (@发起人)补 —— **与 created 同源,迁移不丢发起人**。读不到留空。
1411
- 缺创建时间的旧行:先读它链接的 worklog 头部「创建」行,找不到用升级时刻 —— 不允许留空。
1412
- 5 列有两种(判别与 parse_history 一致,靠「第 2 列是不是时间」):旧 ledger 格式
1413
- (结案 / 创建 / 工作包 / 摘要 / 文档,无 owner)行内自带创建时间,优先用它;
1414
- v5(结案 / 工作包 / owner / 摘要 / 文档,无创建时间)才去 worklog 头找。
1415
- 只重排表格行;标题、空行、表头外的散文原样保留。
1416
- history_path 只用于按文件所在目录解析文档链接。
1417
- """
1418
- fallback = now()
1419
- out: list[str] = []
1420
- converted = 0
1421
- for raw in text.splitlines():
1422
- if not raw.strip().startswith("|"):
1423
- out.append(raw)
1424
- continue
1425
- cells = split_row(raw)
1426
- if not cells:
1427
- out.append(raw)
1428
- continue
1429
- if _is_sep_row(cells):
1430
- out.append(HISTORY_SEP)
1431
- continue
1432
- if _looks_like_header(cells):
1433
- out.append(HISTORY_HEADER)
1434
- continue
1435
- if len(cells) == 9:
1436
- # v9 行原样回写:已含 creator
1437
- if time_key(cells[0]) is None:
1438
- return None
1439
- out.append("| " + " | ".join(cells) + " |")
1440
- continue
1441
- explicit_created: str | None = None
1442
- creator: str | None = None
1443
- if len(cells) == 8:
1444
- # v8 → v9:补 creator 列(回链 worklog 头部「创建」行的 @发起人;读不到留空不编造)
1445
- finished, created, workpackage, category, tag_cell, owner, summary, doc = (
1446
- cells
1447
- )
1448
- if time_key(finished) is None:
1449
- return None
1450
- created_key = time_key(created.strip())
1451
- cat = category.strip() or None
1452
- tags = [t.strip() for t in re.split(r"[,,、]", tag_cell) if t.strip()]
1453
- creator = creator_for_history(doc, history_path) or ""
1454
- out.append(
1455
- f"| {finished} | {created_key or ''} | {esc_cell(workpackage)} "
1456
- f"| {esc_cell(cat or '')} | {esc_cell('、'.join(tags) if tags else '')} "
1457
- f"| {esc_cell(owner_cell(owner.lstrip('@')))} | {esc_cell(creator_cell(creator))} "
1458
- f"| {esc_cell(summary)} | {doc} |"
1459
- )
1460
- converted += 1
1461
- continue
1462
- if len(cells) == 6:
1463
- finished, created, workpackage, owner, summary, doc = cells
1464
- # v6 升级:owner 单元格已是展示格式(带 @),剥掉再走 owner_cell,否则升级出 @@
1465
- owner = owner.lstrip("@")
1466
- explicit_created = time_key(created.strip())
1467
- elif len(cells) == 5 and TIME_RE.fullmatch(cells[1]):
1468
- # 旧 ledger 格式(结案时间 / 创建时间 / 工作包 / 摘要 / 过程文档):无 owner 列。
1469
- # 与下面的 v5(无创建时间列、owner 缝在中间)靠「第 2 列是不是时间」区分 ——
1470
- # v5 的第 2 列是工作包(标题),不会是时间,判别无歧义。
1471
- finished, created_cell, workpackage, summary, doc = cells
1472
- owner = ""
1473
- explicit_created = time_key(created_cell.strip())
1474
- elif len(cells) == 5:
1475
- finished, workpackage, owner, summary, doc = cells
1476
- # v5 行 owner 列可能已是展示格式(带 @),剥掉再走 owner_cell,否则升级出 @@
1477
- owner = owner.lstrip("@")
1478
- elif len(cells) == 4:
1479
- finished, workpackage, summary, doc = cells
1480
- m = HISTORY_OLD_OWNER_RE.match(workpackage)
1481
- owner = m.group("owner") if m else ""
1482
- if m:
1483
- workpackage = m.group("title")
1484
- else:
1485
- return None
1486
- finished_key = time_key(finished)
1487
- if finished_key is None:
1488
- return None
1489
- created = explicit_created or created_for_history(doc, history_path) or fallback
1490
- cat, tags = labels_for_log(doc, history_path)
1491
- creator = creator_for_history(doc, history_path) or ""
1492
- out.append(
1493
- f"| {finished_key} | {created} | {esc_cell(workpackage)} "
1494
- f"| {esc_cell(cat or '')} | {esc_cell('、'.join(tags) if tags else '')} "
1495
- f"| {esc_cell(owner_cell(owner))} | {esc_cell(creator_cell(creator))} "
1496
- f"| {esc_cell(summary)} | {doc} |"
1497
- )
1498
- converted += 1
1499
- return "\n".join(out) + "\n", converted
1500
-
1501
-
1502
- # ---------- 历史层:按结案时间分月归档 ----------
1503
-
1504
-
1505
- def history_path_for(repo: Path, finished: str) -> Path:
1506
- """结案时间(YYYY-MM-DD HH:MM:SS)→ ledger/history/<YYYYMM>.md。
1507
-
1508
- 与 worklog 的 ledger/worklog/<年月>/ 同构:一个按创建月、一个按结案月。
1509
- """
1510
- month = re.sub(r"[^0-9]", "", finished[:7])[:6]
1511
- if len(month) != 6:
1512
- raise Fail(f"结案时间 {finished!r} 取不到年月,无法路由历史文件。")
1513
- return repo / HISTORY_DIR / f"{month}.md"
1514
-
1515
-
1516
- def history_sources(repo: Path) -> list[Path]:
1517
- """全部历史来源(读侧用):存量平铺文件 + ledger/history/*.md,按路径排序。
1518
-
1519
- 存量文件只读 —— 写侧永远不碰它,它停在原地、链接基准(相对 ledger/)不变。
1520
- """
1521
- paths: list[Path] = []
1522
- legacy = repo / HISTORY_REL
1523
- if legacy.exists():
1524
- paths.append(legacy)
1525
- hdir = repo / HISTORY_DIR
1526
- if hdir.is_dir():
1527
- paths.extend(sorted(p for p in hdir.glob("*.md") if p.is_file()))
1528
- return paths
1529
-
1530
-
1531
- # 数行计数的封顶值(2026-10-17 为 dsh-task-ledger 看板统计卡新增):显示级统计封顶,
1532
- # 超出即 capped,精确值走 history --json / 列表。
1533
- COUNT_CAP = 10000
1534
-
1535
-
1536
- def history_row_count(
1537
- sources: list[Path], since: str | None, until: str | None, cap: int = COUNT_CAP
1538
- ) -> dict[str, Any]:
1539
- """数行计数(只读;2026-10-17 新增,服务看板「本周 / 近一年完成」统计卡)。
1540
-
1541
- 只数数据行(`| 2` 开头 = 结案时间以年份开头),到 cap 封顶返回 capped=True。
1542
- 给了 since/until 时按创建时间列(第 2 列)粗筛——取日期部分做字符串比较
1543
- (ISO 日期字典序即时间序),不建对象、不判格式,比 parse_history 便宜一个量级。
1544
- ⚠️ 显示级统计:坏行 / 旧格式行可能多数少数,精确数以 history --json 为准。
1545
- """
1546
- since_d = (since or "")[:10]
1547
- until_d = (until or "")[:10]
1548
- total = 0
1549
- for hp in sources:
1550
- try:
1551
- text = hp.read_text(encoding="utf-8-sig")
1552
- except (OSError, UnicodeDecodeError):
1553
- continue
1554
- for line in text.splitlines():
1555
- if not line.startswith("| 2"):
1556
- continue
1557
- if since_d or until_d:
1558
- cells = line.split("|")
1559
- if len(cells) < 4:
1560
- continue
1561
- created = cells[2].strip()[:10]
1562
- if since_d and created < since_d:
1563
- continue
1564
- if until_d and created > until_d:
1565
- continue
1566
- total += 1
1567
- if total >= cap:
1568
- return {"count": total, "capped": True}
1569
- return {"count": total, "capped": False}
1570
-
1571
-
1572
- def worklog_link(log_path: Path, history_path: Path) -> str:
1573
- """worklog 相对历史文件所在目录的 posix 链接。
1574
-
1575
- 存量平铺文件得 `worklog/<月>/<文件>.md`;按月文件得 `../worklog/<月>/<文件>.md`
1576
- —— 链接跟着所在文件走,两边都可点击、可解析。
1577
- """
1578
- return os.path.relpath(log_path, history_path.parent).replace(os.sep, "/")
1579
-
1580
-
1581
- def append_history_line(
1582
- history_path: Path,
1583
- finished: str,
1584
- created: str | None,
1585
- title: str,
1586
- owner: str | None,
1587
- summary: str,
1588
- log_rel: str,
1589
- category: str | None = None,
1590
- tags: list[str] | None = None,
1591
- creator: str | None = None,
1592
- ) -> tuple[str, int]:
1593
- """把一行历史追加到指定历史文件,返回 (新全文, 升级行数)。
1594
-
1595
- 读目标 → upgrade_history → 解析不了就按现有列数追加(绝不把 9 列写进少列表头)
1596
- → 文件不存在则带 9 列表头新建。close 专用:「行落哪个月」之外,
1597
- 历史行的落盘逻辑只此一处。分类/标签/creator 由 close 传 task 终值。
1598
- """
1599
- if not history_path.exists():
1600
- return (
1601
- f"{HISTORY_TITLE}\n\n{HISTORY_HEADER}\n{HISTORY_SEP}\n"
1602
- + history_line(
1603
- finished,
1604
- created,
1605
- title,
1606
- owner,
1607
- summary,
1608
- log_rel,
1609
- category,
1610
- tags,
1611
- creator,
1612
- ),
1613
- 0,
1614
- )
1615
- try:
1616
- text = history_path.read_text(encoding="utf-8")
1617
- except (OSError, UnicodeDecodeError) as exc:
1618
- raise Fail(f"读不了 {history_path}:{exc}")
1619
- if not text.endswith("\n"):
1620
- text += "\n"
1621
- upgraded = upgrade_history(text, history_path)
1622
- if upgraded is None:
1623
- # 有解析不了的行:不升级、不猜,退回「按现有列数」追加
1624
- print(
1625
- f"警告:{history_path.name} 有无法解析的行,未做格式升级,本次按现有列数追加。",
1626
- file=sys.stderr,
1627
- )
1628
- return (
1629
- text
1630
- + history_line_for_cols(
1631
- history_cols(text),
1632
- finished,
1633
- created,
1634
- title,
1635
- owner,
1636
- summary,
1637
- log_rel,
1638
- category,
1639
- tags,
1640
- creator,
1641
- ),
1642
- 0,
1643
- )
1644
- new_text, n = upgraded
1645
- return (
1646
- new_text
1647
- + history_line(
1648
- finished, created, title, owner, summary, log_rel, category, tags, creator
1649
- ),
1650
- n,
1651
- )
1652
-
1653
-
1654
- def slugify(title: str) -> str:
1655
- """任务号用:保留 ASCII 字母数字与中文汉字,其余转连字符。"""
1656
- s = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", title.strip().lower())
1657
- return s.strip("-")[:40].strip("-")
1658
-
1659
-
1660
- def id_stamp(created: str) -> str:
1661
- """从 created(YYYY-MM-DD HH:MM:SS)取任务号前缀 YYYYMMDD-HHMM(任务号只到分)。"""
1662
- return (
1663
- f"{created[0:4]}{created[5:7]}{created[8:10]}-{created[11:13]}{created[14:16]}"
1664
- )
1665
-
1666
-
1667
- def make_id(title: str, tasks: dict[str, Any], created: str | None = None) -> str:
1668
- slug = slugify(title)
1669
- if not slug:
1670
- raise Fail(
1671
- "标题里没有可用字符(字母、数字或汉字),无法自动生成任务号。"
1672
- f'用 --id 指定一个,例如 --id "task-{secrets.token_hex(2)}"。'
1673
- )
1674
- # 前置创建时间:年月日-时分(任务号 / 文件名只到分,与记录时间到秒分开)。随机码不能省
1675
- # —— make_id 只在在办任务里去重,而 worklog 结案后仍留在磁盘上,去掉随机码会让新任务与
1676
- # 已结案的过程文档同名、覆盖历史。
1677
- stamp = id_stamp(created) if created else datetime.now().strftime("%Y%m%d-%H%M")
1678
- for _ in range(20):
1679
- tid = f"{stamp}-{slug}-{secrets.token_hex(2)}"
1680
- if tid not in tasks:
1681
- return tid
1682
- raise Fail("任务号连续冲突 20 次,请检查状态表是否异常。")
1683
-
1684
-
1685
- def load_task(repo: Path, tid: str) -> dict[str, Any]:
1686
- data = read_json(repo / PROGRESS_REL)
1687
- task = data["tasks"].get(tid)
1688
- if task is None:
1689
- raise Fail(f"没有任务 {tid}(可能已被结案或删除)。用 list 看现有任务。")
1690
- return task
1691
-
1692
-
1693
- def require_owner(task: dict, tid: str, me: str) -> None:
1694
- """只有当前 owner 能变更该任务;owner 取变更前的值,不放行就报错。"""
1695
- owner = task.get("owner")
1696
- if not owner:
1697
- status = task.get("status")
1698
- if status == "open":
1699
- raise Fail(f"任务 {tid} 还没人认领(open)。先认领再操作:claim --id {tid}")
1700
- raise Fail(
1701
- f"任务 {tid} 的 status 是 {status} 却没有 owner,状态表不一致。先跑 validate。"
1702
- )
1703
- if owner != me:
1704
- raise Fail(
1705
- f"任务 {tid} 的 owner 是 {owner},不是你({me})。\n"
1706
- f'要接手得让 {owner} 先交接:set --id {tid} --owner "{me}"'
1707
- )
1708
-
1709
-
1710
- # 头部「分类/标签」行(label_line 生成、parse_label_line 解析,两处共用一个格式):
1711
- # `> **分类**:缺陷修复 | **标签**:镜像拉取、Hindsight`
1712
- LABEL_CAT_RE = re.compile(r"分类\*\*\s*[::]\s*([^||]+)")
1713
- LABEL_TAG_RE = re.compile(r"标签\*\*\s*[::]\s*(.+)$")
1714
-
1715
-
1716
- def label_line(category: str | None, tags: list[str] | None) -> str:
1717
- """worklog 头部的「分类/标签」行;两者皆空返回空串(此行不写)。"""
1718
- segs = []
1719
- if category:
1720
- segs.append(f"**分类**:{category}")
1721
- if tags:
1722
- segs.append(f"**标签**:{'、'.join(tags)}")
1723
- return "> " + " | ".join(segs) if segs else ""
1724
-
1725
-
1726
- def _insert_after_head(lines: list[str], line: str) -> list[str]:
1727
- """把一行插到「创建」行之后(头部引用块内);找不到创建行就插标题后。"""
1728
- at = next((i for i, ln in enumerate(lines) if WORKLOG_CREATED_RE.match(ln)), None)
1729
- if at is None:
1730
- at = 1 if lines and lines[0].startswith("# ") else 0
1731
- lines.insert(at + 1, line)
1732
- return lines
1733
-
1734
-
1735
- def parse_label_line(text: str) -> tuple[str | None, list[str]]:
1736
- """从 worklog 头部(第一个 `## ` 小节之前)解析「分类/标签」行;解析不到返回空。"""
1737
- for ln in text.splitlines():
1738
- if ln.startswith("## "):
1739
- break
1740
- mc = LABEL_CAT_RE.search(ln)
1741
- mt = LABEL_TAG_RE.search(ln)
1742
- if not (mc or mt):
1743
- continue
1744
- cat = mc.group(1).strip() if mc else None
1745
- tags = (
1746
- [t.strip() for t in re.split(r"[,,、]", mt.group(1).strip()) if t.strip()]
1747
- if mt
1748
- else []
1749
- )
1750
- return (cat or None, tags)
1751
- return (None, [])
1752
-
1753
-
1754
- def rewrite_label_line(text: str, category: str | None, tags: list[str]) -> str:
1755
- """改写 worklog 头部的「分类/标签」行:有就地替换,没有就插到创建行后(close 覆盖用)。"""
1756
- line = label_line(category, tags)
1757
- lines = text.splitlines()
1758
- for i, ln in enumerate(lines):
1759
- if ln.startswith("## "):
1760
- break
1761
- if LABEL_CAT_RE.search(ln) or LABEL_TAG_RE.search(ln):
1762
- if line:
1763
- lines[i] = line
1764
- else:
1765
- del lines[i]
1766
- return "\n".join(lines) + "\n"
1767
- if not line:
1768
- return text
1769
- return "\n".join(_insert_after_head(lines, line)) + "\n"
1770
-
1771
-
1772
- def render_worklog(tid: str, task: dict[str, Any]) -> str:
1773
- template = FALLBACK_TEMPLATE
1774
- try:
1775
- if WORKLOG_TEMPLATE.exists():
1776
- template = WORKLOG_TEMPLATE.read_text(encoding="utf-8")
1777
- except (OSError, UnicodeDecodeError):
1778
- template = FALLBACK_TEMPLATE
1779
- text = (
1780
- template.replace("<id>", tid)
1781
- .replace("<title>", task.get("title") or "")
1782
- .replace("<created>", task.get("created") or "")
1783
- .replace("<creator>", task.get("creator") or "")
1784
- )
1785
- # 分类/标签行走动态插入(不占模板占位符):两个模板都不用改,空值时自动不写行
1786
- meta = label_line(task.get("category"), task.get("tags") or [])
1787
- if not meta:
1788
- return text
1789
- return "\n".join(_insert_after_head(text.splitlines(), meta)) + "\n"
1790
-
1791
-
1792
- def worklog_dir_for(task: dict[str, Any]) -> Path:
1793
- """按月归档目录:ledger/worklog/<YYYYMM>/。取任务创建月份,取不到就退回当前月。"""
1794
- created = (task.get("created") or "").strip()
1795
- month = re.sub(r"[^0-9]", "", created[:7])[:6]
1796
- if len(month) != 6:
1797
- month = datetime.now().strftime("%Y%m")
1798
- return WORKLOG_DIR / month
1799
-
1800
-
1801
- def worklog_name(tid: str) -> str:
1802
- """过程文档的文件名:任务号剥掉前缀的年月(YYYYMM)。
1803
-
1804
- 父目录 worklog/<YYYYMM>/ 已经带了年月,文件名里再写一遍是冗余,所以只留
1805
- 日-时分-说明-随机码:任务号 20261006-0000-doc-arch-review-7a3c(前 8 位
1806
- 是年月日)得到 06-0000-doc-arch-review-7a3c.md。随机码保留:结案文件长期
1807
- 留在磁盘上,同一分钟内建两个同标题任务仍要能不撞名。
1808
- 自定义 --id 不以「8 位数字-」开头时,文件名就是任务号本身。
1809
- """
1810
- if len(tid) > 9 and tid[:8].isdigit() and tid[8] == "-":
1811
- return f"{tid[6:]}.md"
1812
- return f"{tid}.md"
1813
-
1814
-
1815
- def worklog_rel(tid: str, task: dict[str, Any]) -> str:
1816
- """过程文档相对 ledger/ 的路径。状态表记它,历史层也记它,两处同口径。"""
1817
- return (
1818
- (worklog_dir_for(task) / worklog_name(tid))
1819
- .relative_to(WORKLOG_DIR.parent)
1820
- .as_posix()
1821
- )
1822
-
1823
-
1824
- def find_worklog(
1825
- repo: Path, tid: str, task: dict[str, Any] | None = None
1826
- ) -> Path | None:
1827
- """定位任务的过程文档。
1828
-
1829
- 先信状态表里记的路径(记了就不猜);没记再按新命名推算;最后回退到
1830
- 「文件名就是任务号」的老命名,并兼顾平铺旧位置 —— 规则改动前建的文件
1831
- 留在原地不动,照样能读能封卷。
1832
- """
1833
- if task:
1834
- recorded = task.get("worklog")
1835
- if recorded:
1836
- cand = repo / WORKLOG_DIR.parent / recorded
1837
- if cand.exists():
1838
- return cand
1839
- parent = repo / WORKLOG_DIR
1840
- names = (worklog_name(tid), f"{tid}.md")
1841
- dirs = [parent]
1842
- if parent.is_dir():
1843
- dirs += [d for d in sorted(parent.iterdir()) if d.is_dir()]
1844
- for d in dirs:
1845
- for name in names:
1846
- cand = d / name
1847
- if cand.exists():
1848
- return cand
1849
- return None
1850
-
1851
-
1852
- def exec_line(when: str, who: str | None, kind: str, note: str) -> str:
1853
- """一条执行记录:`- 时间 · 类型 · @某人 · 说明`。
1854
-
1855
- 类型取脚本子命令名(new / claim / set / log / close),纯 ASCII、可 grep 统计;
1856
- 记录只追加不改写 —— 移交后旧记录仍记在原来那个人名下。
1857
- """
1858
- head = f"- {when} · {kind}"
1859
- return f"{head} · @{who} · {note}" if who else f"{head} · {note}"
1860
-
1861
-
1862
- def append_exec_log(text: str, line: str) -> str:
1863
- """把一条记录追加到「执行记录」节末尾。
1864
-
1865
- 升级前建的老 worklog 没有这一节:在文末补一节再写(不迁移正文,也不让记录静默丢掉)。
1866
- """
1867
- lines = text.splitlines()
1868
- at = next((i for i, ln in enumerate(lines) if ln.strip() == EXEC_HEAD), None)
1869
- if at is None:
1870
- # 只去文末多余空行:正文最后一行的行尾空格是人家写的,别顺手抹掉
1871
- return text.rstrip("\n") + f"\n\n{EXEC_HEAD}\n\n{EXEC_HINT}\n\n{line}\n"
1872
- end = next(
1873
- (i for i in range(at + 1, len(lines)) if lines[i].startswith("## ")), len(lines)
1874
- )
1875
- insert = end
1876
- while insert > at + 1 and not lines[insert - 1].strip():
1877
- insert -= 1
1878
- lines.insert(insert, line)
1879
- return "\n".join(lines) + "\n"
1880
-
1881
-
1882
- def record_exec(
1883
- repo: Path, tid: str, task: dict[str, Any], who: str | None, kind: str, note: str
1884
- ) -> bool:
1885
- """给任务的过程文档补一条执行记录(`who` = 这次动手的人,不是任务现 owner)。
1886
-
1887
- 返回是否真的落盘;`log` 用它判断要不要报错。调用方必须已经持有 progress_lock ——
1888
- 与 close 的封卷(读全文→改写→落盘)互斥,否则两条记录并发追加会互相覆盖。
1889
- """
1890
- log = find_worklog(repo, tid, task)
1891
- if log is None:
1892
- return False
1893
- try:
1894
- text = log.read_text(encoding="utf-8")
1895
- except (OSError, UnicodeDecodeError):
1896
- return False
1897
- line = exec_line(now(), who, kind, note)
1898
- try:
1899
- write_text_atomic(log, append_exec_log(text, line))
1900
- except OSError:
1901
- return False
1902
- return True
1903
-
1904
-
1905
- def write_worklog(repo: Path, tid: str, task: dict[str, Any]) -> Path:
1906
- log = repo / worklog_dir_for(task) / worklog_name(tid)
1907
- write_text_atomic(log, render_worklog(tid, task))
1908
- return log
1909
-
1910
-
1911
- def worklog_is_skeleton(repo: Path, tid: str, task: dict[str, Any]) -> bool:
1912
- """正文是否还是模板骨架(「执行记录」之前只剩标题、引用行和注释占位)。
1913
-
1914
- 读不到文件返回 False(那是别处要报的错,不在这里重复)。只做体检、不改盘。
1915
- """
1916
- log = find_worklog(repo, tid, task)
1917
- if log is None:
1918
- return False
1919
- try:
1920
- text = log.read_text(encoding="utf-8")
1921
- except (OSError, UnicodeDecodeError):
1922
- return False
1923
- in_comment = False
1924
- for line in text.splitlines():
1925
- s = line.strip()
1926
- if s == EXEC_HEAD:
1927
- break
1928
- if in_comment:
1929
- if "-->" in s:
1930
- in_comment = False
1931
- continue
1932
- if s.startswith("<!--"):
1933
- in_comment = "-->" not in s
1934
- continue
1935
- if not s or s.startswith(("#", ">")):
1936
- continue
1937
- return False
1938
- return True
1939
-
1940
-
1941
- def print_worklog(repo: Path, tid: str, task: dict[str, Any] | None = None) -> None:
1942
- """把该任务的过程文档全文打出来;读不到只提示,不拖垮主操作。"""
1943
- log = find_worklog(repo, tid, task)
1944
- if log is None:
1945
- print(
1946
- f"\n--- 缺少 {WORKLOG_DIR}/<年月>/{worklog_name(tid)}(跑 validate 看看) ---"
1947
- )
1948
- return
1949
- try:
1950
- body = log.read_text(encoding="utf-8").rstrip()
1951
- except (OSError, UnicodeDecodeError) as exc:
1952
- print(f"\n--- 读不了 {log.relative_to(repo)}:{exc} ---", file=sys.stderr)
1953
- return
1954
- print(f"\n--- {log.relative_to(repo)} ---")
1955
- print(body)
1956
- print("--- 以上是该任务的过程文档,直接编辑这个文件即可 ---")
1957
-
1958
-
1959
- def stamp_frozen(text: str, finished: str, finisher: str | None = "") -> str:
1960
- """把 worklog 标成已封卷:封卷标记并进头部引用块。"""
1961
- lines = text.splitlines()
1962
- who = f"(@{finisher})" if finisher else ""
1963
- marker = f"> **{FROZEN_MARK}**:{finished}{who}"
1964
- at = next((i for i, ln in enumerate(lines) if ln.startswith(">")), None)
1965
- if at is None:
1966
- # 只认一级标题;`## 背景` 这类小节标题不算,否则标记会插进正文里面。
1967
- at = 1 if lines and lines[0].startswith("# ") else 0
1968
- lines.insert(at, marker)
1969
- return "\n".join(lines) + "\n"
1970
-
1971
-
1972
- # ---------- 子命令 ----------
1973
-
1974
-
1975
- def cmd_init(args: argparse.Namespace) -> None:
1976
- repo = find_repo(args.repo)
1977
- progress = repo / PROGRESS_REL
1978
- with progress_lock(progress):
1979
- if progress.exists():
1980
- print(f"已存在,未改动:{progress.relative_to(repo)}")
1981
- return
1982
- before, data = read_guarded(progress)
1983
- data.setdefault("tasks", {})
1984
- commit_guarded(progress, before, data)
1985
- print(f"已创建 {progress.relative_to(repo)}")
1986
- print("按需创建的目录(worklog/、history/ 等)留到实际有内容时再建。")
1987
-
1988
-
1989
- def cmd_new(args: argparse.Namespace) -> None:
1990
- repo = find_repo(args.repo)
1991
- path = repo / PROGRESS_REL
1992
- if not path.exists():
1993
- raise Fail(f"{PROGRESS_REL} 不存在,先跑 init。")
1994
- creator = git_user()
1995
- created = normalize_time(args.created, field="--created") if args.created else now()
1996
- # 参数自检前移:分类/标签形态校验在锁外做,失败的调用不该占着锁
1997
- category = (
1998
- normalize_label(args.category, field="--category") if args.category else None
1999
- )
2000
- tags = parse_tags(args.tags, field="--tags") if args.tags else []
2001
-
2002
- with progress_lock(path):
2003
- before, data = read_guarded(path)
2004
- tasks = data["tasks"]
2005
-
2006
- tid = args.id or make_id(args.title, tasks, created)
2007
- require_task_id(tid)
2008
- if tid in tasks:
2009
- raise Fail(f"任务号 {tid} 已存在。")
2010
-
2011
- stamp = created
2012
- task = {
2013
- "title": args.title,
2014
- "creator": creator,
2015
- "owner": creator if args.self else None,
2016
- "status": "doing" if args.self else "open",
2017
- "created": stamp,
2018
- "started": stamp if args.self else None,
2019
- "updated": stamp,
2020
- "finished": None,
2021
- "category": category,
2022
- "tags": tags,
2023
- }
2024
- # 路径先算好再落盘:文件名不等于任务号,状态表不记就只能靠约定去猜。
2025
- task["worklog"] = worklog_rel(tid, task)
2026
- tasks[tid] = task
2027
- commit_guarded(path, before, data)
2028
- try:
2029
- log = write_worklog(repo, tid, task)
2030
- except Fail as exc:
2031
- # 过程文档建不出来就把刚写进去的任务撤掉,别留下「有任务、没文档」
2032
- tasks.pop(tid, None)
2033
- try:
2034
- write_json(path, data)
2035
- except Fail:
2036
- pass
2037
- # 撤销完成:别让写后自动同步把这个「有任务、没文档」的半成品提交上去
2038
- CHANGED_FILES.clear()
2039
- raise Fail(f"创建失败,已从状态表撤销该任务:{exc}")
2040
- record_exec(repo, tid, task, creator, "new", f"创建任务({task['status']})")
2041
- print(f"已创建任务 {tid}")
2042
- print(f" 标题 {task['title']}")
2043
- print(f" 状态 {task['status']}")
2044
- print(f" creator @{creator}")
2045
- owner_show = f"@{task['owner']}" if task["owner"] else "(待认领)"
2046
- print(f" owner {owner_show}")
2047
- if category or tags:
2048
- print(f" 分类 {category or '—'}")
2049
- if tags:
2050
- print(f" 标签 {'、'.join(tags)}")
2051
- print(f" worklog {log.relative_to(repo)}")
2052
- if not args.self:
2053
- print(f"\n空开状态。谁做谁认领:progress.py claim --id {tid}")
2054
- print_worklog(repo, tid, task)
2055
-
2056
-
2057
- def cmd_claim(args: argparse.Namespace) -> None:
2058
- repo = find_repo(args.repo)
2059
- tid = require_task_id(args.id)
2060
- owner = git_user() # 先取身份:拿不到就直接报错,不进入后续判断
2061
- path = repo / PROGRESS_REL
2062
- with progress_lock(path):
2063
- before, data = read_guarded(path)
2064
- task = data["tasks"].get(tid)
2065
- if task is None:
2066
- raise Fail(f"没有任务 {tid}。")
2067
- if task.get("owner"):
2068
- raise Fail(
2069
- f"任务 {tid} 状态不一致:owner 已是 {task['owner']},但 status 是 {task.get('status')}。先跑 validate。"
2070
- )
2071
- if task.get("status") != "open":
2072
- raise Fail(
2073
- f"任务 {tid} 当前状态是 {task.get('status')},只有 open 才能认领。"
2074
- )
2075
-
2076
- stamp = now()
2077
- task["owner"] = owner
2078
- task["status"] = "doing"
2079
- task["started"] = task.get("started") or stamp
2080
- task["updated"] = stamp
2081
- commit_guarded(path, before, data)
2082
- record_exec(repo, tid, task, owner, "claim", "认领 → doing")
2083
- print(f"已认领 {tid} → doing,owner = @{owner}")
2084
- print_worklog(repo, tid, task)
2085
-
2086
-
2087
- def cmd_set(args: argparse.Namespace) -> None:
2088
- # 参数自检全部前移:必然失败的调用不该先去起 git 子进程、读文件
2089
- if args.status is None and args.owner is None:
2090
- raise Fail("至少要给 --status 或 --owner 之一。结案请用 close。")
2091
- if args.status is not None and not args.status.strip():
2092
- raise Fail(f"--status 不能为空;可选 {' / '.join(STATUSES)}")
2093
- if args.owner is not None and not args.owner.strip():
2094
- raise Fail("--owner 不能为空。")
2095
- status = args.status.strip() if args.status else None
2096
- if status == "open" and args.owner is not None:
2097
- raise Fail(
2098
- "--status open(退回未认领)不能同时给 --owner;要交给别人就别改回 open。"
2099
- )
2100
- if status == "blocked":
2101
- raise Fail(
2102
- "没有 blocked 状态:超期由时间自动判定(接任务满 14 天仍未结案),"
2103
- "状态只有 open / doing;卡在哪里请写进 worklog。"
2104
- )
2105
- if status == "done":
2106
- raise Fail(
2107
- "不要把状态直接改成 done —— 用 close 结案(它会同时移出状态、追加历史、封卷 worklog)。"
2108
- )
2109
- if status is not None and status not in STATUSES:
2110
- raise Fail(f"--status 只能是 {' / '.join(STATUSES)};结案请用 close。")
2111
-
2112
- repo = find_repo(args.repo)
2113
- tid = require_task_id(args.id)
2114
- me = git_user()
2115
- path = repo / PROGRESS_REL
2116
- with progress_lock(path):
2117
- before, data = read_guarded(path)
2118
- task = data["tasks"].get(tid)
2119
- if task is None:
2120
- raise Fail(f"没有任务 {tid}。")
2121
- require_owner(task, tid, me)
2122
-
2123
- changed = []
2124
- if status is not None:
2125
- task["status"] = status
2126
- if status == "open":
2127
- task["owner"] = None
2128
- task["started"] = None
2129
- changed.append(f"status={status}")
2130
-
2131
- if args.owner is not None:
2132
- old = task.get("owner")
2133
- task["owner"] = args.owner.strip().lstrip(
2134
- "@"
2135
- ) # 容忍「@名字」写法;存储与匹配用裸名
2136
- if task.get("status") == "open":
2137
- task["status"] = "doing"
2138
- task["started"] = task.get("started") or now()
2139
- changed.append(f"owner {old or '(无)'} → {task['owner']}")
2140
-
2141
- task["updated"] = now()
2142
- commit_guarded(path, before, data)
2143
- # 释放认领(退回 open)单列一个类型,便于一眼看出「被释放过」
2144
- kind = "release" if status == "open" else "set"
2145
- record_exec(repo, tid, task, me, kind, ",".join(changed))
2146
- print(f"已更新 {tid}:" + ",".join(changed))
2147
- print_worklog(repo, tid, task)
2148
-
2149
-
2150
- def cmd_log(args: argparse.Namespace) -> None:
2151
- repo = find_repo(args.repo)
2152
- tid = require_task_id(args.id)
2153
- note = " ".join(args.note.split())
2154
- if not note:
2155
- raise Fail("--note 不能为空:一句话写清这轮做了什么。")
2156
- me = git_user()
2157
- path = repo / PROGRESS_REL
2158
- with progress_lock(path):
2159
- before, data = read_guarded(path)
2160
- task = data["tasks"].get(tid)
2161
- if task is None:
2162
- raise Fail(f"没有任务 {tid}(可能未建或已结案;封卷后不再记)。")
2163
- require_owner(task, tid, me)
2164
- task["updated"] = now()
2165
- commit_guarded(path, before, data)
2166
- written = record_exec(repo, tid, task, me, "log", note)
2167
- if not written:
2168
- raise Fail(
2169
- f"没写进 {tid} 的过程文档(文件不存在或不可读);progress.json 的 updated 已刷新。"
2170
- "先用 show --id 确认 worklog 路径,不要手写「执行记录」。"
2171
- )
2172
- print(f"已记入 {tid} 的执行记录:{note}")
2173
-
2174
-
2175
- def cmd_close(args: argparse.Namespace) -> None:
2176
- repo = find_repo(args.repo)
2177
- tid = require_task_id(args.id)
2178
- me = git_user()
2179
- progress_path = repo / PROGRESS_REL
2180
- # 参数自检前移:--category / --tags 的形态校验在锁外做(None = 没传,沿用建档值)
2181
- close_category = (
2182
- normalize_label(args.category, field="--category") if args.category else None
2183
- )
2184
- close_tags = parse_tags(args.tags, field="--tags") if args.tags else None
2185
-
2186
- with progress_lock(progress_path):
2187
- before, data = read_guarded(progress_path)
2188
- task = data["tasks"].get(tid)
2189
- if task is None:
2190
- raise Fail(f"没有任务 {tid}(可能已结案)。")
2191
- require_owner(task, tid, me)
2192
- if task.get("finished"):
2193
- raise Fail(f"任务 {tid} 已经结案过了。")
2194
-
2195
- finished = (
2196
- normalize_time(args.finished, field="--finished")
2197
- if args.finished
2198
- else now()
2199
- )
2200
- title = " ".join((task.get("title") or tid).split())
2201
- summary = " ".join(args.summary.split())
2202
- if not summary:
2203
- raise Fail("--summary 不能为空:写清楚「做成了什么」。")
2204
-
2205
- owner = task.get("owner") or me
2206
- # 老文件留在平铺位置,新文件在按月子目录;找不到就按新规则推算目标路径。
2207
- log_path = find_worklog(repo, tid, task)
2208
- if log_path is None and task.get("worklog"):
2209
- # 记过路径却找不到文件:绝不能按模板新建一份空文档封卷 —— 那会把真正的
2210
- # 正文永久顶掉(封卷后不再改)。让调用方先把文件恢复回来。
2211
- raise Fail(
2212
- f"任务 {tid} 的过程文档找不到:ledger/{task['worklog']}。\n"
2213
- "不要新建同名空文档(会把正文顶掉)。先从 git 恢复再结案:\n"
2214
- f" git checkout HEAD -- ledger/{task['worklog']}"
2215
- )
2216
- if log_path is None:
2217
- log_path = repo / worklog_dir_for(task) / worklog_name(tid)
2218
- # 历史行按结案时间落月文件;文档链接相对该文件所在目录算(../worklog/…)。
2219
- history_path = history_path_for(repo, finished)
2220
- log_rel = worklog_link(log_path, history_path)
2221
- created_key = time_key(task.get("created")) or ""
2222
- hist_before = stat_key(history_path)
2223
- log_before = stat_key(log_path)
2224
-
2225
- # 三个文件的新内容先在内存里备好
2226
- new_tasks = dict(data["tasks"])
2227
- new_tasks.pop(tid)
2228
-
2229
- bak_path: Path | None = None
2230
- # 历史行的分类/标签取终值:--category/--tags 覆盖过就用覆盖值,否则用建档值
2231
- final_cat = (
2232
- close_category if close_category is not None else task.get("category")
2233
- )
2234
- final_tags = close_tags if close_tags is not None else (task.get("tags") or [])
2235
- new_history, history_upgrade_n = append_history_line(
2236
- history_path,
2237
- finished,
2238
- created_key,
2239
- title,
2240
- owner,
2241
- summary,
2242
- log_rel,
2243
- final_cat,
2244
- final_tags,
2245
- task.get("creator"),
2246
- )
2247
-
2248
- if log_path.exists():
2249
- try:
2250
- log_text = log_path.read_text(encoding="utf-8")
2251
- except (OSError, UnicodeDecodeError) as exc:
2252
- raise Fail(f"读不了 {log_path}:{exc}")
2253
- if close_category is not None or close_tags is not None:
2254
- # 覆盖走改写头部「分类/标签」行(在封卷之前),真源与展示副本同口径
2255
- log_text = rewrite_label_line(
2256
- log_text,
2257
- close_category
2258
- if close_category is not None
2259
- else task.get("category"),
2260
- close_tags
2261
- if close_tags is not None
2262
- else list(task.get("tags") or []),
2263
- )
2264
- else:
2265
- log_text = render_worklog(
2266
- tid,
2267
- dict(
2268
- task,
2269
- status="done",
2270
- category=(
2271
- close_category
2272
- if close_category is not None
2273
- else task.get("category")
2274
- ),
2275
- tags=(
2276
- close_tags
2277
- if close_tags is not None
2278
- else list(task.get("tags") or [])
2279
- ),
2280
- ),
2281
- )
2282
- # 结案本身是最后一个节点:封卷前补上这条记录,封卷后不再追加
2283
- new_log = stamp_frozen(
2284
- append_exec_log(
2285
- log_text,
2286
- exec_line(finished, me, "close", f"结案:{summary}"),
2287
- ),
2288
- finished,
2289
- owner,
2290
- )
2291
-
2292
- # 落盘前复查:三个文件都没被别人动过
2293
- for p, mark in (
2294
- (progress_path, before),
2295
- (history_path, hist_before),
2296
- (log_path, log_before),
2297
- ):
2298
- if stat_key(p) != mark:
2299
- raise Fail(
2300
- f"{p.relative_to(repo)} 在操作期间被改动过,已中止。重新执行一次。"
2301
- )
2302
-
2303
- if history_upgrade_n:
2304
- # 复查通过才落备份:并发中止时不留孤儿 .bak。成功后删除(内容没丢,只是 owner 换了列)。
2305
- bak_path = history_path.with_name(history_path.name + ".pre-upgrade.bak")
2306
- write_text_atomic(bak_path, history_path.read_text(encoding="utf-8"))
2307
- print(
2308
- f"历史层已升级为 8 列(补分类/标签,{history_upgrade_n} 行转换),原文件备份在 {bak_path.name}",
2309
- file=sys.stderr,
2310
- )
2311
-
2312
- backups: dict[Path, bytes] = {}
2313
- created: list[Path] = []
2314
- for p in (progress_path, history_path, log_path):
2315
- try:
2316
- backups[p] = p.read_bytes()
2317
- except FileNotFoundError:
2318
- created.append(p)
2319
- except OSError as exc:
2320
- raise Fail(f"读不了 {p} 用于备份:{exc}")
2321
-
2322
- # 顺序:先写历史与过程文档,**最后**才从状态表移除。
2323
- # 中途崩溃最坏是「历史多一行、状态还留着」,可重跑识别,不会丢记录。
2324
- try:
2325
- write_text_atomic(history_path, new_history)
2326
- write_text_atomic(log_path, new_log)
2327
- write_json(
2328
- progress_path, {"tasks": {k: v for k, v in sorted(new_tasks.items())}}
2329
- )
2330
- except (OSError, Fail) as exc:
2331
- restored, failed = [], []
2332
- history_restored = False
2333
- for p, content in backups.items():
2334
- try:
2335
- p.write_bytes(content)
2336
- restored.append(str(p.relative_to(repo)))
2337
- if p == history_path:
2338
- history_restored = True
2339
- except OSError as rerr:
2340
- failed.append(f"{p.relative_to(repo)}({rerr})")
2341
- for p in created:
2342
- try:
2343
- if p.exists():
2344
- p.unlink()
2345
- except OSError as rerr:
2346
- failed.append(f"{p.relative_to(repo)} 清理失败({rerr})")
2347
- if bak_path is not None and bak_path.exists():
2348
- if history_restored:
2349
- try:
2350
- bak_path.unlink()
2351
- except OSError:
2352
- pass
2353
- else:
2354
- # 历史层没恢复成功 ⇒ .bak 是最后保障,留着并报告
2355
- failed.append(
2356
- f"{bak_path.name}(历史层未恢复成功,升级备份保留待人工处理)"
2357
- )
2358
- msg = f"结案失败:{exc}"
2359
- if restored:
2360
- msg += f"\n已恢复:{'、'.join(restored)}"
2361
- if failed:
2362
- msg += f"\n回滚未完成,请手工检查:{'、'.join(failed)}"
2363
- raise Fail(msg)
2364
-
2365
- if bak_path is not None and bak_path.exists():
2366
- try:
2367
- bak_path.unlink()
2368
- except OSError:
2369
- pass
2370
-
2371
- print(f"已结案 {tid}")
2372
- print(f" 从 {PROGRESS_REL} 移出")
2373
- print(f" 追加到 {history_path.relative_to(repo)}")
2374
- print(f" 封卷 {log_path.relative_to(repo)}")
2375
- if final_cat or final_tags:
2376
- print(f" 分类 {final_cat or '—'}")
2377
- if final_tags:
2378
- print(f" 标签 {'、'.join(final_tags)}")
2379
- print(f"\n摘要:{summary}")
2380
-
2381
-
2382
- def cmd_list(args: argparse.Namespace) -> None:
2383
- repo = find_repo(args.repo)
2384
- path = repo / PROGRESS_REL
2385
- if not path.exists():
2386
- raise Fail(f"{PROGRESS_REL} 不存在:这个项目还没初始化,先跑 init。")
2387
- data = read_json(path)
2388
-
2389
- wanted = None
2390
- if args.status is not None:
2391
- wanted = [s.strip() for s in args.status.split(",") if s.strip()]
2392
- if not wanted:
2393
- raise Fail("--status 不能为空;逗号分隔,如 doing,open")
2394
- # blocked 只用于捞旧数据(新任务不可能再有这个值)
2395
- bad = [s for s in wanted if s not in STATUSES and s not in LEGACY_STATUSES]
2396
- if bad:
2397
- raise Fail(
2398
- f"--status 含非法值 {', '.join(bad)};可选 {' / '.join(STATUSES)}"
2399
- )
2400
-
2401
- since = normalize_time(args.since, field="--since") if args.since else None
2402
- until = (
2403
- normalize_time(args.until, field="--until", date_only_ref="23:59:59")
2404
- if args.until
2405
- else None
2406
- )
2407
-
2408
- me = git_user() if args.mine else None
2409
-
2410
- rows = []
2411
- for tid, task in sorted(data["tasks"].items()):
2412
- if me is not None and task.get("owner") != me:
2413
- continue
2414
- if args.owner and task.get("owner") != args.owner.strip().lstrip("@"):
2415
- continue
2416
- if wanted is not None and task.get("status") not in wanted:
2417
- continue
2418
- # 分类精确匹配(统计轴,同值才能比较);标签包含匹配(容错下钻,吃掉同义变体)
2419
- if args.category and (task.get("category") or "") != args.category.strip():
2420
- continue
2421
- if args.tag:
2422
- q = args.tag.strip()
2423
- if not any(q in t for t in (task.get("tags") or [])):
2424
- continue
2425
- created = time_key(task.get("created"))
2426
- if since and (created is None or created < since):
2427
- continue
2428
- if until and (created is None or created > until):
2429
- continue
2430
- rows.append((tid, task))
2431
-
2432
- if args.json:
2433
- print(
2434
- json.dumps({tid: task for tid, task in rows}, ensure_ascii=False, indent=2)
2435
- )
2436
- return
2437
-
2438
- if not rows:
2439
- print("没有符合条件的在办任务。")
2440
- return
2441
-
2442
- print(f"在办任务 {len(rows)} 条:\n")
2443
- for tid, task in rows:
2444
- who = f"@{task['owner']}" if task.get("owner") else "(待认领)"
2445
- print(f" [{task.get('status')}] {tid}")
2446
- print(f" {task.get('title')} — owner={who},created={task.get('created')}")
2447
- if task.get("category") or task.get("tags"):
2448
- cat = task.get("category") or "—"
2449
- line = f" 分类:{cat}"
2450
- if task.get("tags"):
2451
- line += f" | 标签:{'、'.join(task['tags'])}"
2452
- print(line)
2453
- print(
2454
- "\n细节在 worklog 里(路径记在状态表 worklog 字段);已结案的用 history 子命令查"
2455
- "(ledger/history/<年月>.md)。"
2456
- )
2457
-
2458
-
2459
- def cmd_show(args: argparse.Namespace) -> None:
2460
- repo = find_repo(args.repo)
2461
- tid = require_task_id(args.id)
2462
- task = load_task(repo, tid)
2463
- print(json.dumps(task, ensure_ascii=False, indent=2))
2464
- print_worklog(repo, tid, task)
2465
-
2466
-
2467
- def cmd_tags(args: argparse.Namespace) -> None:
2468
- """分类/标签巡检与归并。
2469
-
2470
- --all:全量频次表(扫全部 worklog 头部,每任务恰好一次;已结案也含在内)—— 让漂移可见。
2471
- --rename 旧 新:把在办任务里等于「旧」的 category / tag 改成「新」,同步改在办 worklog
2472
- 头部;已封卷文档不动(封卷即不可变,看板侧自行归并)。
2473
- """
2474
- if args.all and args.rename:
2475
- raise Fail("--all 和 --rename 只能给一个。")
2476
- if not args.all and not args.rename:
2477
- raise Fail("至少给一个:--all(巡检)或 --rename 旧 新(归并)。")
2478
- repo = find_repo(args.repo)
2479
-
2480
- if args.rename:
2481
- old, new = args.rename
2482
- old = old.strip()
2483
- new_label = normalize_label(new, field="新标签")
2484
- path = repo / PROGRESS_REL
2485
- with progress_lock(path):
2486
- before, data = read_guarded(path)
2487
- tasks = data["tasks"]
2488
- changed: list[str] = []
2489
- for tid, task in tasks.items():
2490
- cat = task.get("category")
2491
- tags = list(task.get("tags") or [])
2492
- new_cat = new_label if cat == old else cat
2493
- new_tags = [new_label if t == old else t for t in tags]
2494
- if new_cat != cat or new_tags != tags:
2495
- task["category"] = new_cat
2496
- task["tags"] = new_tags
2497
- changed.append(tid)
2498
- if not changed:
2499
- print(f"没有在办任务的分类/标签等于「{old}」。")
2500
- return
2501
- # 先改在办 worklog 头部(展示副本与真源同口径),最后才提交状态表:
2502
- # 中途失败时 json 未动、worklog 改写幂等,重跑一次即可收敛
2503
- backups: dict[Path, bytes] = {}
2504
- try:
2505
- for tid in changed:
2506
- task = tasks[tid]
2507
- log = find_worklog(repo, tid, task)
2508
- if log is None:
2509
- continue
2510
- backups[log] = log.read_bytes()
2511
- write_text_atomic(
2512
- log,
2513
- rewrite_label_line(
2514
- backups[log].decode("utf-8"),
2515
- task.get("category"),
2516
- task.get("tags") or [],
2517
- ),
2518
- )
2519
- except (OSError, Fail, UnicodeDecodeError) as exc:
2520
- for log, raw in backups.items():
2521
- try:
2522
- log.write_bytes(raw)
2523
- except OSError:
2524
- pass
2525
- raise Fail(f"归并失败,已恢复已改文档,重跑一次即可:{exc}")
2526
- commit_guarded(path, before, data)
2527
- print(f"已归并「{old}」→「{new_label}」,涉及 {len(changed)} 个在办任务:")
2528
- for tid in changed:
2529
- print(f" {tid}")
2530
- return
2531
-
2532
- # --all:worklog 目录是唯一来源 —— 在办任务的 worklog 也在里面(new 建档即写),
2533
- # 与状态表合并会重复计数;已结案任务状态表已移除、只剩 worklog,天然只计一次。
2534
- cat_count: dict[str, int] = {}
2535
- tag_count: dict[str, int] = {}
2536
- wdir = repo / WORKLOG_DIR
2537
- n_logs = 0
2538
- if wdir.is_dir():
2539
- for log in sorted(wdir.rglob("*.md")):
2540
- try:
2541
- text = log.read_text(encoding="utf-8")
2542
- except (OSError, UnicodeDecodeError):
2543
- continue
2544
- cat, tags = parse_label_line(text)
2545
- n_logs += 1
2546
- if cat:
2547
- cat_count[cat] = cat_count.get(cat, 0) + 1
2548
- for t in tags:
2549
- tag_count[t] = tag_count.get(t, 0) + 1
2550
- if not cat_count and not tag_count:
2551
- print("还没有任何分类/标签:new 时用 --category / --tags 让 Agent 自行总结。")
2552
- return
2553
- print(f"扫描 {n_logs} 份 worklog:\n")
2554
- print("分类(看板统计轴,精确匹配):")
2555
- for cat, n in sorted(cat_count.items(), key=lambda kv: (-kv[1], kv[0])):
2556
- print(f" {n:>4} {cat}")
2557
- print("\n标签(下钻用,包含匹配):")
2558
- for t, n in sorted(tag_count.items(), key=lambda kv: (-kv[1], kv[0])):
2559
- print(f" {n:>4} {t}")
2560
- low = sorted(t for t, n in tag_count.items() if n <= 2)
2561
- if low:
2562
- print(
2563
- f"\n低频标签(≤2 次,疑似变体):{'、'.join(low)}\n"
2564
- "归并:tags --rename <旧> <新>(只改在办任务;已封卷文档不动)"
2565
- )
2566
-
2567
-
2568
- def cmd_validate(args: argparse.Namespace) -> None:
2569
- repo = find_repo(args.repo)
2570
- progress_path = repo / PROGRESS_REL
2571
- problems: list[str] = []
2572
- blank: list[str] = []
2573
-
2574
- if not progress_path.exists():
2575
- raise Fail(f"{PROGRESS_REL} 不存在,先跑 init。")
2576
-
2577
- data = read_json(progress_path)
2578
- for tid, task in sorted(data["tasks"].items()):
2579
- if not ID_RE.fullmatch(tid):
2580
- problems.append(f"{tid}: 任务号不合规(小写字母、数字、连字符、汉字)")
2581
- if not task.get("title"):
2582
- problems.append(f"{tid}: 缺 title")
2583
- status = task.get("status")
2584
- if status not in STATUSES and status not in LEGACY_STATUSES:
2585
- problems.append(f"{tid}: status={status!r} 非法")
2586
- if status == "open" and task.get("owner"):
2587
- problems.append(f"{tid}: status=open 却有 owner")
2588
- if status in ("doing", "blocked") and not task.get("owner"):
2589
- problems.append(f"{tid}: status={status} 却没有 owner")
2590
- if status == "done":
2591
- problems.append(f"{tid}: status=done 不该留在状态表(该用 close 结案)")
2592
- cat = task.get("category")
2593
- if cat is not None:
2594
- try:
2595
- normalize_label(cat, tid=tid)
2596
- except Fail as exc:
2597
- problems.append(str(exc))
2598
- raw_tags = task.get("tags")
2599
- if raw_tags is not None:
2600
- if not isinstance(raw_tags, list) or any(
2601
- not isinstance(t, str) for t in raw_tags
2602
- ):
2603
- problems.append(f"{tid}: tags 必须是字符串数组")
2604
- else:
2605
- for t in raw_tags:
2606
- try:
2607
- normalize_label(t, field="标签", tid=tid)
2608
- except Fail as exc:
2609
- problems.append(str(exc))
2610
- if len(raw_tags) > TAGS_MAX_COUNT:
2611
- problems.append(f"{tid}: tags 超过 {TAGS_MAX_COUNT} 个")
2612
- if find_worklog(repo, tid, task) is None:
2613
- problems.append(
2614
- f"{tid}: 缺 worklog 文件(状态表记的路径:{task.get('worklog') or '未记'})"
2615
- )
2616
- elif worklog_is_skeleton(repo, tid, task):
2617
- blank.append(tid)
2618
-
2619
- if blank:
2620
- # 只提示、不算问题:正文写没写是人的事,脚本不替他下判断;
2621
- # 但「骨架」通常意味着忘了写、或曾被写前同步覆盖,给一条可见信号。
2622
- print(
2623
- f"提示:{len(blank)} 个在办任务的 worklog 正文还是模板骨架"
2624
- f"(背景/待办/下一步/过程/结论都是注释占位):{'、'.join(blank)}\n"
2625
- ' 补写正文后跑一次 log --id <任务号> --note "…"。',
2626
- file=sys.stderr,
2627
- )
2628
-
2629
- if problems:
2630
- for p in problems:
2631
- print(f"✗ {p}", file=sys.stderr)
2632
- raise Fail(f"校验失败:{len(problems)} 项问题。")
2633
-
2634
- print(f"✓ 校验通过(在办 {len(data['tasks'])} 条)")
2635
-
2636
- # 历史层只读体检:发现非 v6 只提示、不写盘 —— 「要不要升级」交给下一次真实写入,
2637
- # validate 保持只读契约(原设计就不碰历史层,这里只补一条可见的迁移信号)。
2638
- # 存量平铺文件是只读来源,不会被写入,故其中的旧格式行不会被自动升级。
2639
- for hp in history_sources(repo):
2640
- rel = hp.relative_to(repo).as_posix()
2641
- try:
2642
- htext = hp.read_text(encoding="utf-8-sig")
2643
- except (OSError, UnicodeDecodeError) as exc:
2644
- print(f"提示:读不了 {rel}:{exc}", file=sys.stderr)
2645
- continue
2646
- hrecords, hwarnings = parse_history(htext)
2647
- old_n = sum(1 for r in hrecords if r["fmt"] != "v8")
2648
- if old_n:
2649
- print(
2650
- f"提示:{rel} 含 {old_n} 行旧格式(4 / 5 / 6 列);"
2651
- "只读文件不自动升级,新记录一律按 8 列写入 ledger/history/。",
2652
- file=sys.stderr,
2653
- )
2654
- for w in hwarnings:
2655
- print(f"提示:{rel} {w}", file=sys.stderr)
2656
-
2657
-
2658
- def cmd_history(args: argparse.Namespace) -> None:
2659
- """查已结案任务(只读)。不加锁、不写任何字节、不依赖格式升级是否完成。
2660
-
2661
- --owner / --mine 一律**精确相等**:身份标识没有「按前缀找人」的语义,
2662
- 子串匹配正是 a 误命中 a-11 的根因。
2663
- --since / --until 按**创建时间**过滤(含边界),可只给其一;旧格式行没有创建时间,
2664
- 带时间过滤时会被排除(另行提示)。--limit 在过滤 + 倒序之后取前 N 条。
2665
- 默认输出紧凑单行、不含「完成了什么」摘要(与 list 不吐正文同一原则);要全量走 --json。
2666
- """
2667
- if args.owner is not None and args.mine:
2668
- raise Fail("--owner 和 --mine 只能给一个:要么查某人,要么查自己。")
2669
- if args.owner is not None and not args.owner.strip():
2670
- raise Fail("--owner 不能为空。")
2671
- if args.limit is not None and args.limit < 1:
2672
- raise Fail("--limit 必须是正整数。")
2673
- since = normalize_time(args.since, field="--since") if args.since else None
2674
- until = (
2675
- normalize_time(args.until, field="--until", date_only_ref="23:59:59")
2676
- if args.until
2677
- else None
2678
- )
2679
-
2680
- repo = find_repo(args.repo)
2681
- sources = history_sources(repo)
2682
- want_owner = (
2683
- args.owner.strip().lstrip("@") if args.owner else None
2684
- ) # 容忍「@名字」写法
2685
- me = git_user() if args.mine else None
2686
- filter_echo = {
2687
- "owner": want_owner,
2688
- "mine": args.mine,
2689
- "since": since,
2690
- "until": until,
2691
- "limit": args.limit,
2692
- "category": args.category.strip() if args.category else None,
2693
- "tag": args.tag.strip() if args.tag else None,
2694
- }
2695
- if not sources:
2696
- # JSON / 计数模式必须**始终**机器可读:空台账也给 JSON,人话只走 stderr。
2697
- # (2026-10-17 真机教训:这里原先直接 print 人话后 return,--json 调用方
2698
- # 拿到非 JSON 输出 ⇒ dsh-task-ledger 看板 /board 整条 500。)
2699
- if args.count_only:
2700
- print(json.dumps({"count": 0, "capped": False}, ensure_ascii=False))
2701
- return
2702
- if args.json:
2703
- print(
2704
- json.dumps(
2705
- {"count": 0, "filters": filter_echo, "records": []},
2706
- ensure_ascii=False,
2707
- indent=2,
2708
- )
2709
- )
2710
- return
2711
- print(
2712
- f"没有已结案记录:ledger/history/ 还没有文件(存量 {HISTORY_REL} 也没有)。"
2713
- )
2714
- return
2715
- if args.months is not None:
2716
- if args.months < 1:
2717
- raise Fail("--months 必须是正整数")
2718
- # 只取按月归档的文件(存量平铺文件不进了——它是改造前的历史,窗口模式用不到)
2719
- monthly = [p for p in sources if p.parent.name == HISTORY_DIR.name]
2720
- sources = monthly[-args.months :]
2721
- if args.count_only:
2722
- print(json.dumps(history_row_count(sources, since, until), ensure_ascii=False))
2723
- return
2724
- records: list[dict[str, Any]] = []
2725
- warnings: list[str] = []
2726
- for hp in sources:
2727
- rel = hp.relative_to(repo).as_posix()
2728
- try:
2729
- text = hp.read_text(encoding="utf-8-sig")
2730
- except (OSError, UnicodeDecodeError) as exc:
2731
- raise Fail(f"读不了 {hp}:{exc}")
2732
- recs, warns = parse_history(text)
2733
- for r in recs:
2734
- r["src"] = rel # 行所在的历史文件:链接相对它所在目录,带上才可定位
2735
- # 8 列行自带分类/标签;旧 4/5/6 列行没有,回链读 worklog 头部兜底
2736
- if not r.get("category") and not r.get("tags"):
2737
- r["category"], r["tags"] = labels_for_log(r["log"], hp)
2738
- # v8 行(无 creator 列)回链 worklog 头部「创建」行取发起人,不丢
2739
- if not r.get("creator"):
2740
- r["creator"] = creator_for_history(r["log"], hp)
2741
- records.extend(recs)
2742
- warnings.extend(f"{rel} {w}" for w in warns)
2743
-
2744
- rows = []
2745
- undated = 0
2746
- for r in records:
2747
- if want_owner is not None and r["owner"] != want_owner:
2748
- continue
2749
- if me is not None and r["owner"] != me:
2750
- continue
2751
- # 分类精确、标签包含(与 list 同口径;值优先读列,列空回链 worklog 头部)
2752
- if args.category and r.get("category") != args.category.strip():
2753
- continue
2754
- if args.tag:
2755
- q = args.tag.strip()
2756
- if not any(q in t for t in r.get("tags") or []):
2757
- continue
2758
- if since or until:
2759
- c = r.get("created")
2760
- if c is None: # 旧格式行没有创建时间,无法参与时间过滤
2761
- undated += 1
2762
- continue
2763
- if since and c < since:
2764
- continue
2765
- if until and c > until:
2766
- continue
2767
- rows.append(r)
2768
- if undated:
2769
- print(
2770
- f"提示:{undated} 行没有创建时间(旧格式),未参与时间过滤;"
2771
- "下次 close 写入后会自动补齐。",
2772
- file=sys.stderr,
2773
- )
2774
- # 倒序:最新创建在前(旧行无创建时间时退回按结案时间);同一时刻稳定排序保持文件物理先后。
2775
- rows.sort(key=lambda r: r.get("created") or r["time"], reverse=True)
2776
- if args.limit is not None:
2777
- rows = rows[: args.limit]
2778
-
2779
- if args.json:
2780
- print(
2781
- json.dumps(
2782
- {
2783
- "count": len(rows),
2784
- "filters": filter_echo,
2785
- "records": rows,
2786
- },
2787
- ensure_ascii=False,
2788
- indent=2,
2789
- )
2790
- )
2791
- return
2792
-
2793
- if not rows:
2794
- print("没有符合条件的已结案记录。")
2795
- return
2796
- who_label = want_owner or me
2797
- label = f"(owner=@{who_label})" if who_label else ""
2798
- span = f",创建 {since or '…'} ~ {until or '…'}" if (since or until) else ""
2799
- print(f"已结案 {len(rows)} 条{label}{span}:\n")
2800
-
2801
- def _who(r: dict[str, Any]) -> str:
2802
- return f"@{r['owner']}" if r["owner"] else "(无 owner)"
2803
-
2804
- width = max(len(_who(r)) for r in rows)
2805
- for r in rows:
2806
- who = _who(r).ljust(width)
2807
- print(
2808
- f" {r['time']} 建 {r.get('created') or '—'} {who} "
2809
- f"[{r.get('category') or '—'}] "
2810
- f"{r['workpackage']} → {r['log'] or '(无过程文档)'}({r.get('src', '')})"
2811
- )
2812
- if warnings:
2813
- print(f"\n({len(warnings)} 行无法解析,已跳过;详见 stderr)")
2814
- print(
2815
- "\n时间列=结案时间;过滤按创建时间。默认不输出「完成了什么」;要摘要跑 history --json。"
2816
- "\n方括号=分类(回链自 worklog 头部;历史行本身不存这列)。"
2817
- "\n行尾括号=该行所在的历史文件(文档链接相对它所在目录解析)。"
2818
- )
2819
-
2820
-
2821
- # ---------- 入口 ----------
2822
-
2823
-
2824
- def build_parser() -> argparse.ArgumentParser:
2825
- parser = argparse.ArgumentParser(
2826
- prog="progress.py",
2827
- description="管理项目任务进度(ledger/progress.json + worklog + 历史)",
2828
- )
2829
- parser.add_argument(
2830
- "--no-sync",
2831
- action="store_true",
2832
- help="跳过写前取最新与写后自动 git 提交 / 推送(等价于 TASK_LEDGER_NO_SYNC=1)",
2833
- )
2834
- sub = parser.add_subparsers(dest="command", required=True)
2835
-
2836
- def add_repo(p: argparse.ArgumentParser) -> None:
2837
- p.add_argument("--repo", help="当前工作区(项目根)。agent 调用时应显式传入")
2838
-
2839
- p = sub.add_parser("init", help="建 ledger/progress.json")
2840
- add_repo(p)
2841
- p.set_defaults(func=cmd_init)
2842
-
2843
- p = sub.add_parser("new", help="新建任务")
2844
- p.add_argument("--title", required=True, help="一句话标题")
2845
- p.add_argument("--id", help="指定任务号,默认取标题生成的语义名 + 4 位随机")
2846
- p.add_argument(
2847
- "--created", help="补录创建时间(默认当前时刻);YYYY-MM-DD[ HH:MM[:SS]]"
2848
- )
2849
- p.add_argument(
2850
- "--self",
2851
- action="store_true",
2852
- help="创建即由自己执行(doing),否则留 open 待认领",
2853
- )
2854
- p.add_argument(
2855
- "--category",
2856
- help="一句话分类:这事是什么类型(≤4 字;纯英文 ≤3 词)。由 Agent 按口径总结,可省",
2857
- )
2858
- p.add_argument(
2859
- "--tags",
2860
- help="标签:这事涉及什么(对象/领域/模块),逗号分隔(至多 5 个,每个 ≤4 字;空格归英文多词标签自身,不当分隔符)。由 Agent 按口径总结,可省",
2861
- )
2862
- add_repo(p)
2863
- p.set_defaults(func=cmd_new)
2864
-
2865
- p = sub.add_parser("claim", help="认领一个 open 任务(认领人只能是当前 git 身份)")
2866
- p.add_argument("--id", required=True)
2867
- add_repo(p)
2868
- p.set_defaults(func=cmd_claim)
2869
-
2870
- p = sub.add_parser("set", help="改状态 / 交接(结案请用 close)")
2871
- p.add_argument("--id", required=True)
2872
- p.add_argument("--status", help=f"{' / '.join(STATUSES)}(done 请走 close)")
2873
- p.add_argument("--owner", help="交接给谁(仅供 UI / 维护者;agent 提示词不暴露)")
2874
- add_repo(p)
2875
- p.set_defaults(func=cmd_set)
2876
-
2877
- p = sub.add_parser("log", help="往 worklog 的「执行记录」节补一条记录")
2878
- p.add_argument("--id", required=True)
2879
- p.add_argument("--note", required=True, help="一句话:这轮做了什么")
2880
- add_repo(p)
2881
- p.set_defaults(func=cmd_log)
2882
-
2883
- p = sub.add_parser("close", help="结案:移出状态 + 追加历史 + 封卷 worklog")
2884
- p.add_argument("--id", required=True)
2885
- p.add_argument("--summary", required=True, help="做成了什么,一句话")
2886
- p.add_argument(
2887
- "--finished", help="结案时间(默认当前时刻);YYYY-MM-DD[ HH:MM[:SS]]"
2888
- )
2889
- p.add_argument("--category", help="结案时修正分类(不传则沿用 new 时的值)")
2890
- p.add_argument("--tags", help="结案时修正标签(不传则沿用 new 时的值)")
2891
- add_repo(p)
2892
- p.set_defaults(func=cmd_close)
2893
-
2894
- p = sub.add_parser("list", help="查询在办任务")
2895
- p.add_argument("--mine", action="store_true", help="只看自己的")
2896
- p.add_argument("--owner", help="只看某个人的")
2897
- p.add_argument("--status", help="逗号分隔,如 doing,open")
2898
- p.add_argument("--category", help="按分类过滤(精确匹配)")
2899
- p.add_argument("--tag", help="按标签过滤(包含匹配,命中任一面即算)")
2900
- p.add_argument(
2901
- "--since", help="只看该时间(含)之后创建的任务;YYYY-MM-DD[ HH:MM[:SS]]"
2902
- )
2903
- p.add_argument(
2904
- "--until", help="只看该时间(含)之前创建的任务;YYYY-MM-DD 含当天整天"
2905
- )
2906
- p.add_argument("--json", action="store_true", help="输出原始 JSON")
2907
- add_repo(p)
2908
- p.set_defaults(func=cmd_list)
2909
-
2910
- p = sub.add_parser("show", help="单任务详情")
2911
- p.add_argument("--id", required=True)
2912
- add_repo(p)
2913
- p.set_defaults(func=cmd_show)
2914
-
2915
- p = sub.add_parser(
2916
- "tags", help="分类/标签巡检(--all)与归并(--rename 旧 新,只改在办任务)"
2917
- )
2918
- p.add_argument("--all", action="store_true", help="输出全量分类/标签频次表")
2919
- p.add_argument(
2920
- "--rename",
2921
- nargs=2,
2922
- metavar=("旧", "新"),
2923
- help="把在办任务里等于「旧」的分类/标签改为「新」,同步改在办 worklog 头部",
2924
- )
2925
- add_repo(p)
2926
- p.set_defaults(func=cmd_tags)
2927
-
2928
- p = sub.add_parser(
2929
- "history",
2930
- help="查已结案任务(只读):--owner / --mine / --since / --until / --limit / --json / --category / --tag",
2931
- )
2932
- p.add_argument("--owner", help="只看某个人的(精确匹配,不做前缀/子串)")
2933
- p.add_argument("--mine", action="store_true", help="只看自己的(当前 git 身份)")
2934
- p.add_argument("--category", help="按分类过滤(精确匹配;回链读 worklog 头部)")
2935
- p.add_argument("--tag", help="按标签过滤(包含匹配;回链读 worklog 头部)")
2936
- p.add_argument(
2937
- "--since", help="只看该时间(含)之后创建的任务;YYYY-MM-DD[ HH:MM[:SS]]"
2938
- )
2939
- p.add_argument(
2940
- "--until", help="只看该时间(含)之前创建的任务;YYYY-MM-DD 含当天整天"
2941
- )
2942
- p.add_argument("--limit", type=int, help="过滤 + 倒序后取前 N 条(最近 N 条)")
2943
- p.add_argument("--json", action="store_true", help="输出结构化 JSON(含摘要)")
2944
- p.add_argument(
2945
- "--count-only",
2946
- action="store_true",
2947
- help="只数行数(封顶 1 万,输出 {count, capped});显示级统计,精确数以 --json 为准",
2948
- )
2949
- p.add_argument(
2950
- "--months",
2951
- type=int,
2952
- help="只读最近 N 个月的历史文件(ledger/history/*.md 按文件名取末 N 个;不含存量平铺文件)",
2953
- )
2954
- add_repo(p)
2955
- p.set_defaults(func=cmd_history)
2956
-
2957
- p = sub.add_parser("validate", help="校验状态表与 worklog")
2958
- add_repo(p)
2959
- p.set_defaults(func=cmd_validate)
2960
-
2961
- return parser
2962
-
2963
-
2964
- def _repo_lock_dir(args: argparse.Namespace) -> Path | None:
2965
- """写命令要仓级串行时返回 git 目录;只读 / 纯读 tags / --no-sync / 无 git 返回 None。"""
2966
- if os.environ.get("TASK_LEDGER_NO_SYNC"):
2967
- return None
2968
- action = getattr(args, "command", "")
2969
- if action in READ_ONLY_COMMANDS:
2970
- return None
2971
- if action == "tags" and not getattr(args, "rename", None):
2972
- return None
2973
- try:
2974
- repo = find_repo(getattr(args, "repo", None))
2975
- except Fail:
2976
- return None
2977
- out = _git("git", "-C", str(repo), "rev-parse", "--absolute-git-dir")
2978
- if out.returncode != 0:
2979
- return None
2980
- return Path(out.stdout.strip())
2981
-
2982
-
2983
- def main(argv: list[str] | None = None) -> int:
2984
- args = build_parser().parse_args(argv)
2985
- if getattr(args, "no_sync", False):
2986
- os.environ["TASK_LEDGER_NO_SYNC"] = "1"
2987
- lock_dir = _repo_lock_dir(args)
2988
- lock = (
2989
- repo_sync_lock(lock_dir) if lock_dir is not None else contextlib.nullcontext()
2990
- )
2991
- try:
2992
- # 整段持锁:写前同步的「检查 → 覆盖」与随后的写入、提交推送必须是同一段临界区
2993
- with lock:
2994
- return _run_once(args)
2995
- except Fail as exc: # 这里只可能是等锁超时
2996
- print(f"错误:{exc}", file=sys.stderr)
2997
- return 1
2998
12
 
13
+ sys.dont_write_bytecode = True
2999
14
 
3000
- def _run_once(args: argparse.Namespace) -> int:
3001
- """一条命令的完整生命周期:写前同步 → 命令本身 → 写后提交推送。"""
3002
- try:
3003
- git_presync(args.command, args) # 写前刷新 ledger/ 到远端最新
3004
- args.func(args)
3005
- except Fail as exc:
3006
- # 命令没成功:把写前刷新覆盖过的文件恢复原样,别让远端版本冒充用户改动
3007
- presync_rollback()
3008
- print(f"错误:{exc}", file=sys.stderr)
3009
- return 1
3010
- except KeyboardInterrupt:
3011
- presync_rollback()
3012
- print("已中断。", file=sys.stderr)
3013
- return 130
3014
- except BrokenPipeError:
3015
- # 输出被下游截断(如 | head):命令本身已经写成功,不回滚
3016
- devnull = os.open(os.devnull, os.O_WRONLY)
3017
- os.dup2(devnull, sys.stdout.fileno())
3018
- return 0
3019
- except OSError as exc:
3020
- presync_rollback()
3021
- print(f"错误:{exc}", file=sys.stderr)
3022
- return 1
3023
- except Exception as exc: # noqa: BLE001 —— 兜底:未预期的失败用独立退出码,与业务错误区分
3024
- presync_rollback()
3025
- print(f"错误:未预期的失败 {type(exc).__name__}: {exc}", file=sys.stderr)
3026
- return 3
3027
- PRESYNC_BACKUPS.clear() # 写成功:留底作废
3028
- git_sync(args.command) # 写成功后一次性自动同步(commit / push)
3029
- return 0
15
+ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
3030
16
 
17
+ from ledgerkit.shell.cli import main
3031
18
 
3032
19
  if __name__ == "__main__":
3033
20
  sys.exit(main())