task-ledger 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,7 @@
35
35
 
36
36
  ### 状态用 JSON,不用 Markdown
37
37
 
38
- 状态要频繁改(`doing` → `blocked` → `done`),Markdown 里改一个词很容易顺手改到别处。JSON 结构化、字段固定、一次只动一个值,改动面最小。
38
+ 状态要频繁改(`open` → `doing` → 结案),Markdown 里改一个词很容易顺手改到别处。JSON 结构化、字段固定、一次只动一个值,改动面最小。
39
39
 
40
40
  ### 状态与过程正文分离
41
41
 
@@ -114,7 +114,7 @@
114
114
 
115
115
  ### 并发策略
116
116
 
117
- - 所有写命令(`init` / `new` / `claim` / `set` / `close`)走同一套:**读 → 改内存 → 复查指纹 → 原子写**
117
+ - 所有写命令(`init` / `new` / `claim` / `set` / `log` / `close`)走同一套:**读 → 改内存 → 复查指纹 → 原子写**
118
118
  - 临时文件名唯一(`mkstemp`),写完 `fsync` 再 `os.replace`,无论成败都清理临时文件
119
119
  - key 按字母序落盘,减少两人同时新增时的文本冲突
120
120
  - 冲突时**报错要求重跑**,不静默覆盖
@@ -171,7 +171,7 @@
171
171
  graph LR
172
172
  U[用户说一句话] --> A[agent 读 SKILL.md 判断意图]
173
173
  A -->|只读直接跑| R[list / show / validate / history]
174
- A -->|先复述参数再确认| W[init / new / claim / set / close]
174
+ A -->|写操作| W[init / new / claim / set / log / close]
175
175
  W --> P[ledger/progress.json]
176
176
  W --> H[ledger/history/年月.md]
177
177
  W --> M[ledger/worklog/年月/日-时分-说明-码.md]
@@ -187,15 +187,13 @@ stateDiagram-v2
187
187
  [*] --> open: new(不带 --self)
188
188
  [*] --> doing: new --self
189
189
  open --> doing: claim(认领,写 owner)
190
- doing --> blocked: set --status blocked
191
- blocked --> doing: set --status doing
192
- doing --> open: set --status open(退回未认领,清空 owner)
193
- blocked --> open: set --status open
190
+ doing --> open: set --status open(释放认领,清空 owner)
194
191
  doing --> [*]: close(进历史)
195
- blocked --> [*]: close(进历史)
196
192
  note right of doing
197
193
  set --owner 交接:owner 换成别人
198
194
  (只有当前 owner 能执行)
195
+ 阻塞 = 接任务满 7 天;停滞 = 最后更新满 3 天
196
+ (都由看板按时间算,不是状态)
199
197
  end note
200
198
  ```
201
199
 
package/SKILL.md CHANGED
@@ -14,11 +14,11 @@ allowed-tools: Read,Write,Bash,Grep
14
14
  | 文件 | 装什么 | 谁写 |
15
15
  |---|---|---|
16
16
  | `ledger/progress.json` | 在办任务的状态:任务号 / 标题 / 创建人 / owner / 状态 / 时间 / **分类(`category`)与标签(`tags`)** / 过程文档路径(`worklog` 字段) | **只由脚本写** |
17
- | `ledger/worklog/<年月>/<日>-<时分>-<说明>-<随机码>.md` | 该任务的过程正文,固定五节:背景 / 待办 / 下一步 / 过程 / 结论 | **建档只由脚本按模板做**,之后正文由 agent 直接编辑 |
17
+ | `ledger/worklog/<年月>/<日>-<时分>-<说明>-<随机码>.md` | 该任务的过程正文,固定六节:背景 / 待办 / 下一步 / 过程 / 结论 + **执行记录**(末节,脚本维护、禁手改) | **建档只由脚本按模板做**,之后正文(前五节)由 agent 直接编辑 |
18
18
  | `ledger/history/<年月>.md` | 已结案任务,一行一条(8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / **owner** / 摘要 / 过程文档);按**结案时间**分月。存量 `ledger/PROGRESS-HISTORY.md` 只读兼容、永不重写 | **只由脚本写**;查询走 `history` 子命令 |
19
19
 
20
20
  这三个文件之外不要另建副本。**不要直接编辑状态表和历史索引 —— 一律走脚本。**
21
- 过程文档的**建档也一律走脚本**(`new`):脚本按模板生成五节骨架、把路径写进状态表;
21
+ 过程文档的**建档也一律走脚本**(`new`):脚本按模板生成六节骨架、把路径写进状态表;
22
22
  你只负责往里填正文,**不要自己拼文件名、也不要另建一个 md 顶替它**。
23
23
 
24
24
  **台账 ≠ 产出物**:台账(`ledger/`)只装「什么时候建 / 谁在做 / 做到哪 / 什么时候结案」
@@ -49,14 +49,14 @@ allowed-tools: Read,Write,Bash,Grep
49
49
  |---|---|---|
50
50
  | 「现在该做什么」「我还有啥没做完」 | 查自己的在办 | `list --mine` |
51
51
  | 「谁在做 X」「有哪些在办任务」 | 查全部在办 | `list` |
52
- | 「有没有卡住的」 | 按状态查 | `list --status blocked` |
52
+ | 「有哪些在等认领」 | 按状态查 | `list --status open` |
53
53
  | 「某件老活是什么时候做的」 | 查已结案 | `history`(只读,可直接跑) |
54
54
  | 「谁改过什么」「拉某人的工作日志」 | 按人查已结案 | `history --owner "<名字>"`(**精确匹配**) |
55
55
  | 「这周 / 这个月建了啥活」 | 按创建时间查已结案 | `history --since 2026-10-01` |
56
56
  | 「新建一个任务」「我要做 X」 | 建任务 | `new` |
57
57
  | 「这活我领了」「我来做这个」 | 认领 | `claim` |
58
- | 「卡住了」「先放一放」 | 改状态 | `set --status blocked` |
59
- | 「继续做」「解卡了」 | 改状态 | `set --status doing` |
58
+ | 「卡住了」「先放一放」 | 写清卡在哪(不改状态) | 编辑 worklog 的「待办 / 过程」,再 `log --note "卡在…"` |
59
+ | 「继续做」 | 记一条推进 | `log --note "继续推进…"` |
60
60
  | 「转给某人」「交接给某人」 | 换负责人 | `set --owner "<新负责人>"` |
61
61
  | 「这活做完了」「验收通过」「结案」 | 结案归档 | `close`(**这句必须由用户说**) |
62
62
  | 项目里还没有状态表 | 初始化 | `init` |
@@ -80,6 +80,7 @@ python3 <本 SKILL.md 所在目录>/scripts/progress.py <子命令> --repo <当
80
80
  | `new` | 新建任务,并自动建过程文档 | `--title`(必填)、`--id`(可选)、`--self`、`--created`(可选,补录创建时间)、`--category` / `--tags`(可选,分类 / 标签,见下节口径) |
81
81
  | `claim` | 认领一个待认领任务 | `--id`(必填) |
82
82
  | `set` | 改状态 / 换负责人 | `--id`(必填)、`--status`、`--owner` |
83
+ | `log` | 往 worklog 的「执行记录」节补一条 | `--id`(必填)、`--note`(必填,一句话) |
83
84
  | `close` | 结案:移出状态 + 追加历史 + 封卷过程文档 | `--id`、`--summary`(都必填)、`--finished`(可选,结案时间)、`--category` / `--tags`(可选,结案时覆盖修正一次) |
84
85
  | `list` | 查询在办任务 | `--mine`、`--owner`、`--status`、`--since`、`--until`、`--category`(精确)、`--tag`(包含)、`--json` |
85
86
  | `history` | 查已结案任务(只读) | `--owner`、`--mine`、`--since`、`--until`、`--limit`、`--category`(精确)、`--tag`(包含)、`--json`;另:`--count-only`(只数行数、封顶 1 万输出 `{count, capped}`,显示级统计)、`--months N`(只读最近 N 个月文件,不含存量平铺文件) |
@@ -92,13 +93,15 @@ python3 <本 SKILL.md 所在目录>/scripts/progress.py <子命令> --repo <当
92
93
  | 命令 | 返回 |
93
94
  |---|---|
94
95
  | `new` / `claim` / `set` / `show` | 状态行 + **该任务过程文档的路径与全文** —— 拿到就能直接编辑,不用再去找文件、拼路径(`history` 是例外:只给链接不给全文) |
96
+ | `log` | 一行确认(记了什么) |
95
97
  | `close` | 结案结果与摘要 |
96
98
  | `list` | 在办任务列表(**不含**文档正文,避免撑爆上下文) |
97
99
  | `history` | 已结案紧凑列表(结案时间 / 创建时间 / owner / 工作包 / 过程文档链接,行尾附所在历史文件),**默认不含摘要**;要摘要走 `--json` |
98
100
 
99
101
  ### 几个必须知道的点
100
102
 
101
- - **状态取值**:`open`(已建待认领)/ `doing` / `blocked` / `done`。**`done` 只能由 `close` 产生**,不要用 `set --status done`。
103
+ - **状态取值**:只有 `open`(已建待认领)/ `doing`(已认领在做)两个;`done` 只能由 `close` 产生,不要用 `set --status done`。
104
+ **没有 `blocked` 状态**:阻塞由时间判定(接任务满 7 天仍未结案),看板自己算,不用也不能手动改;卡在哪里写进 worklog。旧数据里残留的 `blocked` 读侧当进行中处理(`list --status blocked` 可捞出来,用 `set --status doing` 归位)。
102
105
  - **`new --self`** = 建完即认领(直接 `doing`);**不带 `--self`** = 空开,等别人 `claim`。
103
106
  - **任务号自动生成**:创建时间(`年月日-时分`)+ 标题里的字母、数字、汉字 + 4 位随机码(如 `20261015-1430-登录接入-sso-7f3a`)。想指定就传 `--id`。
104
107
  - **owner 取 `git config user.name`**:认领人**只能是当前 git 身份,不能由外部传入**;读不到就直接报错退出(提示「需要用户名才能执行」),不做任何兜底 —— 先让用户配好 git 身份。(`history` 只有 `--mine` 会取这个身份;`--owner <别人>` 是只读查询,不校验身份)
@@ -117,7 +120,7 @@ python3 <本 SKILL.md 所在目录>/scripts/progress.py <子命令> --repo <当
117
120
  ## 过程文档的生命周期
118
121
 
119
122
  1. **建档** —— 执行 `new` 时,脚本**必须**按 `assets/worklog.md` 模板创建 `ledger/worklog/<年月>/<日>-<时分>-<说明>-<随机码>.md`(`<年月>` 取任务创建月份,如 `202610`;年月已经在目录名里,文件名不再重复)。路径同时写进状态表的 `worklog` 字段,查的时候不必靠推算。
120
- 2. **推进** —— 这个 md 由 **agent 直接编辑**(它是正文,不是状态数据,不走脚本)。五节(背景 / 待办 / 下一步 / 过程 / 结论)**标题结构不能动**。**各写什么、写成什么样算对,模板注释里给了正例**,照它填即可。
123
+ 2. **推进** —— 这个 md 由 **agent 直接编辑**(它是正文,不是状态数据,不走脚本)。六节(背景 / 待办 / 下一步 / 过程 / 结论 / 执行记录)**标题结构不能动**,且**末节「执行记录」由脚本维护、不许手改**:改完前五节内容后跑一次 `log --id <任务号> --note "这轮做了什么"`,脚本会按时间追加一行(`new` / `claim` / `set` / `close` 也会各自自动记一条)。记录格式固定 `- 时间 · 类型 · @谁 · 说明`,类型取 `new` / `claim` / `set` / `release`(`set --status open` 释放认领,单列)/ `log` / `close`;**只追加不改旧行**,移交后旧记录仍记在原负责人名下。**各写什么、写成什么样算对,模板注释里给了正例**,照它填即可。
121
124
  3. **交付(待验收)** —— 活干完了但**用户还没验收**时,任务仍留在 `doing`:worklog 的「待办」写「待用户验收」,「下一步」写清验收该看什么(哪条命令、哪个文件)。**这一段不产出状态变化,只写正文。**
122
125
  4. **封卷** —— 执行 `close` 时,脚本自动打封卷标记并把头部状态改为完成,此后不再修改。历史行按**结案时间**落 `ledger/history/<年月>.md`(存量 `ledger/PROGRESS-HISTORY.md` 只读、不再写入);封卷后要找回这篇文档:`history` 会给出它的链接与所在历史文件(只读、无需确认)。
123
126
 
@@ -149,7 +152,7 @@ python3 <本 SKILL.md 所在目录>/scripts/progress.py <子命令> --repo <当
149
152
 
150
153
  `init` / `new` / `claim` / `set` / `close` 都有副作用,但**分两档**:
151
154
 
152
- **一、动手类(`init` / `new` / `claim` / `set`)**:执行前先把完整参数复述给用户并取得确认(任务号、标题、负责人、目标状态)。不得先执行再补报告,也不得一次执行多条用户没逐条确认的写操作。
155
+ **一、动手类(`init` / `new` / `claim` / `set` / `log`)**:执行前先把完整参数复述给用户并取得确认(任务号、标题、负责人、目标状态)。不得先执行再补报告,也不得一次执行多条用户没逐条确认的写操作。
153
156
 
154
157
  **二、`close` 是独立授权,不是做任务流程的最后一步**:
155
158
 
package/assets/worklog.md CHANGED
@@ -29,3 +29,10 @@
29
29
 
30
30
  <!-- 收尾时填:做成了什么、留了什么坑。
31
31
  例:已修复,但只覆盖 UTF-8;GBK 另开任务 -->
32
+
33
+ ## 执行记录
34
+
35
+ <!-- 本节由脚本维护:new / claim / set / close / log 自动追加,请勿手改。
36
+ 要补一条就跑 progress.py log --id <任务号> --note "做了什么"。
37
+ 每条一行:时间 · 类型 · @谁 · 说明(类型取 new / claim / set / release / log / close);
38
+ 只追加、不改旧行 —— 移交后旧记录仍留在原负责人名下。 -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-ledger",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Task-ledger skill for coding agents: per-workspace progress tracking (ledger/progress.json, history, worklog) driven by a CLI script. Standalone install for CodeBuddy and other agents; also bundled inside the dsh-task-ledger DSH plugin.",
5
5
  "keywords": [
6
6
  "skill",
@@ -5,7 +5,7 @@
5
5
  ledger/progress.json 在办任务的状态
6
6
  ledger/worklog/<年月>/<日>-<时分>-<说明>-<随机码>.md
7
7
  单个任务的过程(路径另记在状态表 worklog 字段)
8
- ledger/history/<年月>.md 已结案任务,一行一条(8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / 完成了什么 / 过程文档);
8
+ ledger/history/<年月>.md 已结案任务,一行一条(9 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / 完成了什么 / 过程文档);
9
9
  按结案时间分月归档。存量 ledger/PROGRESS-HISTORY.md 只读兼容、永不重写
10
10
 
11
11
  只用标准库,无第三方依赖。
@@ -37,7 +37,10 @@ try:
37
37
  except ImportError:
38
38
  msvcrt = None
39
39
 
40
- STATUSES = ("open", "doing", "blocked", "done")
40
+ STATUSES = ("open", "doing")
41
+ # 旧数据只读兼容:早期版本有 blocked 状态(手动标「卡住」)。现在阻塞按时间判定
42
+ # (接任务满 7 天),不再允许新写入;list / validate / 看板仍能吃老文件。
43
+ LEGACY_STATUSES = ("blocked",)
41
44
  PROGRESS_REL = Path("ledger/progress.json")
42
45
  # 存量:改造前的单文件历史层,只读兼容、永不重写
43
46
  HISTORY_REL = Path("ledger/PROGRESS-HISTORY.md")
@@ -46,13 +49,12 @@ HISTORY_DIR = Path(
46
49
  ) # 新历史层:按结案时间分月,ledger/history/<YYYYMM>.md
47
50
  WORKLOG_DIR = Path("ledger/worklog")
48
51
  HISTORY_TITLE = "# 进度历史(已结案)"
49
- # 8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / 完成了什么 / 过程文档。
52
+ # 9 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / 完成了什么 / 过程文档。
50
53
  # owner 独立成列 —— 按人检索是精确列比较,不靠文本切分。
51
- # 分类/标签列(2026-10-15 存量改造时加入):close 从 task 直写;旧 4/5/6 列由
52
- # upgrade_history 升级为 8 列(回链 worklog 头部补值),parse_history 三时代兼容读取。
53
- HISTORY_HEADER = (
54
- "| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 | owner | 完成了什么 | 过程文档 |"
55
- )
54
+ # creator(2026-10-17 加):任务的「发起人」——与 owner 区分(owner 可被 claim 改走,
55
+ # 但「谁建的」不变)。看板「新增(我的)= 我创建的」要它;旧 8 列无此列,
56
+ # upgrade_history / cmd_history 回链 worklog 头部「创建」行补(与 created 同一来源,不丢)。
57
+ HISTORY_HEADER = "| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 | owner | creator | 完成了什么 | 过程文档 |"
56
58
  HISTORY_SEP = "|---|---|---|---|---|---|---|---|"
57
59
  FROZEN_MARK = "✅ 完成封卷"
58
60
  ID_RE = re.compile(r"[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*\Z")
@@ -66,6 +68,14 @@ LABEL_MAX_WORDS_EN = 3
66
68
  TAGS_MAX_COUNT = 5
67
69
  CJK_RE = re.compile(r"[\u4e00-\u9fff]")
68
70
 
71
+ # 执行记录:worklog 末尾由脚本维护的一节。老文档没有这一节就不动(不迁移);
72
+ # 有这一节时脚本按节点自动追加,agent 改完正文跑 log 补一条。
73
+ EXEC_HEAD = "## 执行记录"
74
+ EXEC_HINT = (
75
+ "<!-- 本节由脚本维护:new / claim / set / close / log 自动追加,请勿手改。\n"
76
+ ' 要补一条就跑 progress.py log --id <任务号> --note "做了什么"。 -->'
77
+ )
78
+
69
79
  SKILL_DIR = Path(__file__).resolve().parent.parent
70
80
  WORKLOG_TEMPLATE = SKILL_DIR / "assets" / "worklog.md"
71
81
 
@@ -82,6 +92,8 @@ FALLBACK_TEMPLATE = """# <id>:<title>
82
92
  ## 过程
83
93
 
84
94
  ## 结论
95
+
96
+ ## 执行记录
85
97
  """
86
98
 
87
99
 
@@ -403,6 +415,11 @@ def owner_cell(owner: str | None) -> str:
403
415
  return f"@{owner}" if owner else ""
404
416
 
405
417
 
418
+ def creator_cell(creator: str | None) -> str:
419
+ """creator 列展示值(与 owner_cell 同一 @ 约定)。"""
420
+ return f"@{creator}" if creator else ""
421
+
422
+
406
423
  def history_line(
407
424
  finished: str,
408
425
  created: str | None,
@@ -412,17 +429,19 @@ def history_line(
412
429
  log_rel: str,
413
430
  category: str | None = None,
414
431
  tags: list[str] | None = None,
432
+ creator: str | None = None,
415
433
  ) -> str:
416
- """历史层的一行(8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / 完成了什么 / 过程文档)。
434
+ """历史层的一行(9 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / 完成了什么 / 过程文档)。
417
435
 
418
- close 专用,避免两处格式漂移。owner 是独立列、单独过 esc_cell ——
419
- 它来自 git config user.name,含 `|` 也不稀奇。分类/标签由 close 传 task 终值。
436
+ close 专用,避免两处格式漂移。owner / creator 是独立列、单独过 esc_cell ——
437
+ 它们来自 git config user.name,含 `|` 也不稀奇。分类/标签由 close 传 task 终值。
420
438
  """
421
439
  cat_cell = esc_cell(category or "")
422
440
  tag_cell = esc_cell("、".join(tags) if tags else "")
423
441
  return (
424
442
  f"| {finished} | {created or ''} | {esc_cell(title)} | {cat_cell} | {tag_cell} "
425
- f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
443
+ f"| {esc_cell(owner_cell(owner))} | {esc_cell(creator_cell(creator))} "
444
+ f"| {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
426
445
  )
427
446
 
428
447
 
@@ -436,12 +455,13 @@ def history_line_for_cols(
436
455
  log_rel: str,
437
456
  category: str | None = None,
438
457
  tags: list[str] | None = None,
458
+ creator: str | None = None,
439
459
  ) -> str:
440
460
  """「历史层有无法解析的行、不敢整体升级」时,按现有列数追加一行,避免列错位。
441
461
 
442
462
  GFM 按首行列数渲染,列数写错会被静默丢弃 / 错位。cols<=4 ⇒ 旧 4 列(owner 缝在
443
463
  工作包末尾);5 ⇒ 无创建时间列;6 ⇒ 无分类/标签列(存量改造前的最后形态);
444
- 否则 8 列。分类/标签只在 8 列里出现 —— 少列的文件硬塞会把整行挤歪。
464
+ 否则 9 列。分类/标签/creator 只在 9 列里出现 —— 少列的文件硬塞会把整行挤歪。
445
465
  """
446
466
  if cols <= 4:
447
467
  who = f" @{owner}" if owner else ""
@@ -460,7 +480,7 @@ def history_line_for_cols(
460
480
  f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
461
481
  )
462
482
  return history_line(
463
- finished, created, title, owner, summary, log_rel, category, tags
483
+ finished, created, title, owner, summary, log_rel, category, tags, creator
464
484
  )
465
485
 
466
486
 
@@ -509,11 +529,13 @@ def _looks_like_header(cells: list[str]) -> bool:
509
529
  def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
510
530
  """解析历史层,返回 (records, warnings)。
511
531
 
512
- 多格式兼容:8 列 = v8(结案时间 / 创建时间 / 工作包 / 分类 / 标签 / …);6 列 = v6
532
+ 多格式兼容:9 列 = v9(结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / …);
533
+ 8 列 = v8(存量改造后、2026-10-17 前,无 creator);6 列 = v6
513
534
  (存量改造前最后形态,无分类/标签);5 列且第 2 列是时间 = 旧 ledger 格式
514
535
  (有创建时间列、无 owner 列);5 列 = 无创建时间列;4 列 = 最旧(owner 缝在工作包末尾)。
515
536
  **逐行按自己的列数判别**,表头只用来跳过 —— 旧文件追加新列
516
- 后是混排态,按表头判列数会切错。坏行(列数不在 4/5/6/8、时间列不合法)不中断:跳过 + 警告。
537
+ 后是混排态,按表头判列数会切错。坏行(列数不在 4/5/6/8/9、时间列不合法)不中断:跳过 + 警告。
538
+ 8 列行无 creator 列 ⇒ creator 留 None,cmd_history 回链 worklog 头部「创建」行补(与 created 同源)。
517
539
  """
518
540
  records: list[dict[str, Any]] = []
519
541
  warnings: list[str] = []
@@ -526,13 +548,31 @@ def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
526
548
  created = ""
527
549
  category: str | None = None
528
550
  tags: list[str] = []
529
- if len(cells) == 8:
551
+ creator: str | None = None
552
+ if len(cells) == 9:
553
+ (
554
+ finished,
555
+ created,
556
+ workpackage,
557
+ category,
558
+ tag_cell,
559
+ owner,
560
+ creator,
561
+ summary,
562
+ doc,
563
+ ) = cells
564
+ fmt = "v9"
565
+ category = category.strip() or None
566
+ tags = [t.strip() for t in re.split(r"[,,、]", tag_cell) if t.strip()]
567
+ creator = creator.strip().removeprefix("@") or None
568
+ elif len(cells) == 8:
530
569
  finished, created, workpackage, category, tag_cell, owner, summary, doc = (
531
570
  cells
532
571
  )
533
572
  fmt = "v8"
534
573
  category = category.strip() or None
535
574
  tags = [t.strip() for t in re.split(r"[,,、]", tag_cell) if t.strip()]
575
+ # 旧 8 列无 creator 列:留 None,cmd_history 回链 worklog 头部补(不丢)
536
576
  elif len(cells) == 6:
537
577
  finished, created, workpackage, owner, summary, doc = cells
538
578
  fmt = "v6"
@@ -555,7 +595,7 @@ def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
555
595
  owner = ""
556
596
  fmt = "v4"
557
597
  else:
558
- warnings.append(f"第 {lineno} 行列数不是 4 / 5 / 6 / 8,已跳过")
598
+ warnings.append(f"第 {lineno} 行列数不是 4 / 5 / 6 / 8 / 9,已跳过")
559
599
  continue
560
600
  if not TIME_RE.fullmatch(finished):
561
601
  warnings.append(f"第 {lineno} 行时间列 {finished!r} 不合法,已跳过")
@@ -572,6 +612,7 @@ def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
572
612
  "category": category,
573
613
  "tags": tags,
574
614
  "owner": own or None,
615
+ "creator": creator,
575
616
  "summary": summary,
576
617
  "log": log or None,
577
618
  "fmt": fmt,
@@ -581,11 +622,14 @@ def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
581
622
  return records, warnings
582
623
 
583
624
 
584
- # worklog 头部「创建」行:`> **创建**:2026-10-06 00:00(@某人)`。用于给旧历史行回填创建时间。
625
+ # worklog 头部「创建」行:`> **创建**:2026-10-06 00:00(@某人)`。
626
+ # 时间给旧历史行回填创建时间;括号内 @某人 即 creator(发起人)。
585
627
  WORKLOG_CREATED_RE = re.compile(
586
628
  r"^\s*>\s*\*\*创建\*\*\s*[::]\s*"
587
629
  r"(\d{4}-\d{2}-\d{2}(?: +\d{1,2}:\d{1,2}(?::\d{1,2})?)?)"
588
630
  )
631
+ # 同一行的发起人(@某人)——creator 列来源。
632
+ WORKLOG_CREATOR_RE = re.compile(r"^\s*>\s*\*\*创建\*\*\s*[::].*?(@(.+?))")
589
633
 
590
634
 
591
635
  def created_for_history(log_cell: str, history_path: Path) -> str | None:
@@ -611,6 +655,28 @@ def created_for_history(log_cell: str, history_path: Path) -> str | None:
611
655
  return None
612
656
 
613
657
 
658
+ def creator_for_history(log_cell: str | None, history_path: Path) -> str | None:
659
+ """顺着历史行的文档链接找到 worklog,读它头部「创建」行的发起人(@某人);找不到返回 None。
660
+
661
+ 旧 8 列历史行没有 creator 列,迁移 / 只读回填都靠它 —— 与 created 同源,不丢数据。
662
+ """
663
+ if not log_cell:
664
+ return None
665
+ m = re.fullmatch(r"\[[^\]]*\]\(([^)]*)\)", log_cell.strip())
666
+ rel = (m.group(1) if m else log_cell).strip()
667
+ if not rel:
668
+ return None
669
+ try:
670
+ text = (history_path.parent / rel).read_text(encoding="utf-8")
671
+ except (OSError, UnicodeDecodeError):
672
+ return None
673
+ for ln in text.splitlines():
674
+ mm = WORKLOG_CREATOR_RE.match(ln)
675
+ if mm:
676
+ return mm.group(1).strip()
677
+ return None
678
+
679
+
614
680
  def labels_for_log(
615
681
  log_cell: str | None, history_path: Path
616
682
  ) -> tuple[str | None, list[str]]:
@@ -633,11 +699,13 @@ def labels_for_log(
633
699
 
634
700
 
635
701
  def upgrade_history(text: str, history_path: Path) -> tuple[str, int] | None:
636
- """旧格式(4 / 5 / 6 列)→ v8(8 列)。全部数据行可解析才返回 (新文本, 转换行数),否则 None。
702
+ """旧格式(4 / 5 / 6 / 8 列)→ v9(9 列)。全部数据行可解析才返回 (新文本, 转换行数),否则 None。
637
703
 
638
704
  None 的语义是「不敢动」:调用方必须退回按原列数追加并明示,绝不半升级、绝不猜。
639
705
  分类/标签列:回链每行的 worklog 头部读「分类/标签」行(读不到留空,不编造)——
640
706
  与 created 的回填同一模式,依赖 Step 2 已给存量 worklog 补行。
707
+ creator 列(2026-10-17 加):旧 8 列无此列,同样回链 worklog 头部「创建」行的
708
+ (@发起人)补 —— **与 created 同源,迁移不丢发起人**。读不到留空。
641
709
  缺创建时间的旧行:先读它链接的 worklog 头部「创建」行,找不到用升级时刻 —— 不允许留空。
642
710
  5 列有两种(判别与 parse_history 一致,靠「第 2 列是不是时间」):旧 ledger 格式
643
711
  (结案 / 创建 / 工作包 / 摘要 / 文档,无 owner)行内自带创建时间,优先用它;
@@ -662,13 +730,33 @@ def upgrade_history(text: str, history_path: Path) -> tuple[str, int] | None:
662
730
  if _looks_like_header(cells):
663
731
  out.append(HISTORY_HEADER)
664
732
  continue
665
- if len(cells) == 8:
666
- # v8 行原样回写:内容已是最终格式(含分类/标签)
733
+ if len(cells) == 9:
734
+ # v9 行原样回写:已含 creator
667
735
  if time_key(cells[0]) is None:
668
736
  return None
669
737
  out.append("| " + " | ".join(cells) + " |")
670
738
  continue
671
739
  explicit_created: str | None = None
740
+ creator: str | None = None
741
+ if len(cells) == 8:
742
+ # v8 → v9:补 creator 列(回链 worklog 头部「创建」行的 @发起人;读不到留空不编造)
743
+ finished, created, workpackage, category, tag_cell, owner, summary, doc = (
744
+ cells
745
+ )
746
+ if time_key(finished) is None:
747
+ return None
748
+ created_key = time_key(created.strip())
749
+ cat = category.strip() or None
750
+ tags = [t.strip() for t in re.split(r"[,,、]", tag_cell) if t.strip()]
751
+ creator = creator_for_history(doc, history_path) or ""
752
+ out.append(
753
+ f"| {finished} | {created_key or ''} | {esc_cell(workpackage)} "
754
+ f"| {esc_cell(cat or '')} | {esc_cell('、'.join(tags) if tags else '')} "
755
+ f"| {esc_cell(owner_cell(owner.lstrip('@')))} | {esc_cell(creator_cell(creator))} "
756
+ f"| {esc_cell(summary)} | {doc} |"
757
+ )
758
+ converted += 1
759
+ continue
672
760
  if len(cells) == 6:
673
761
  finished, created, workpackage, owner, summary, doc = cells
674
762
  # v6 升级:owner 单元格已是展示格式(带 @),剥掉再走 owner_cell,否则升级出 @@
@@ -698,10 +786,12 @@ def upgrade_history(text: str, history_path: Path) -> tuple[str, int] | None:
698
786
  return None
699
787
  created = explicit_created or created_for_history(doc, history_path) or fallback
700
788
  cat, tags = labels_for_log(doc, history_path)
789
+ creator = creator_for_history(doc, history_path) or ""
701
790
  out.append(
702
791
  f"| {finished_key} | {created} | {esc_cell(workpackage)} "
703
792
  f"| {esc_cell(cat or '')} | {esc_cell('、'.join(tags) if tags else '')} "
704
- f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | {doc} |"
793
+ f"| {esc_cell(owner_cell(owner))} | {esc_cell(creator_cell(creator))} "
794
+ f"| {esc_cell(summary)} | {doc} |"
705
795
  )
706
796
  converted += 1
707
797
  return "\n".join(out) + "\n", converted
@@ -796,18 +886,27 @@ def append_history_line(
796
886
  log_rel: str,
797
887
  category: str | None = None,
798
888
  tags: list[str] | None = None,
889
+ creator: str | None = None,
799
890
  ) -> tuple[str, int]:
800
891
  """把一行历史追加到指定历史文件,返回 (新全文, 升级行数)。
801
892
 
802
- 读目标 → upgrade_history → 解析不了就按现有列数追加(绝不把 8 列写进少列表头)
803
- → 文件不存在则带 8 列表头新建。close 专用:「行落哪个月」之外,
804
- 历史行的落盘逻辑只此一处。分类/标签由 close 传 task 终值。
893
+ 读目标 → upgrade_history → 解析不了就按现有列数追加(绝不把 9 列写进少列表头)
894
+ → 文件不存在则带 9 列表头新建。close 专用:「行落哪个月」之外,
895
+ 历史行的落盘逻辑只此一处。分类/标签/creator 由 close 传 task 终值。
805
896
  """
806
897
  if not history_path.exists():
807
898
  return (
808
899
  f"{HISTORY_TITLE}\n\n{HISTORY_HEADER}\n{HISTORY_SEP}\n"
809
900
  + history_line(
810
- finished, created, title, owner, summary, log_rel, category, tags
901
+ finished,
902
+ created,
903
+ title,
904
+ owner,
905
+ summary,
906
+ log_rel,
907
+ category,
908
+ tags,
909
+ creator,
811
910
  ),
812
911
  0,
813
912
  )
@@ -836,6 +935,7 @@ def append_history_line(
836
935
  log_rel,
837
936
  category,
838
937
  tags,
938
+ creator,
839
939
  ),
840
940
  0,
841
941
  )
@@ -843,7 +943,7 @@ def append_history_line(
843
943
  return (
844
944
  new_text
845
945
  + history_line(
846
- finished, created, title, owner, summary, log_rel, category, tags
946
+ finished, created, title, owner, summary, log_rel, category, tags, creator
847
947
  ),
848
948
  n,
849
949
  )
@@ -1047,6 +1147,53 @@ def find_worklog(
1047
1147
  return None
1048
1148
 
1049
1149
 
1150
+ def exec_line(when: str, who: str | None, kind: str, note: str) -> str:
1151
+ """一条执行记录:`- 时间 · 类型 · @某人 · 说明`。
1152
+
1153
+ 类型取脚本子命令名(new / claim / set / log / close),纯 ASCII、可 grep 统计;
1154
+ 记录只追加不改写 —— 移交后旧记录仍记在原来那个人名下。
1155
+ """
1156
+ head = f"- {when} · {kind}"
1157
+ return f"{head} · @{who} · {note}" if who else f"{head} · {note}"
1158
+
1159
+
1160
+ def append_exec_log(text: str, line: str) -> str:
1161
+ """把一条记录追加到「执行记录」节末尾;文档没有这一节(老 worklog)原样返回。"""
1162
+ lines = text.splitlines()
1163
+ at = next((i for i, ln in enumerate(lines) if ln.strip() == EXEC_HEAD), None)
1164
+ if at is None:
1165
+ return text
1166
+ end = next(
1167
+ (i for i in range(at + 1, len(lines)) if lines[i].startswith("## ")), len(lines)
1168
+ )
1169
+ insert = end
1170
+ while insert > at + 1 and not lines[insert - 1].strip():
1171
+ insert -= 1
1172
+ lines.insert(insert, line)
1173
+ return "\n".join(lines) + "\n"
1174
+
1175
+
1176
+ def record_exec(
1177
+ repo: Path, tid: str, task: dict[str, Any], who: str | None, kind: str, note: str
1178
+ ) -> None:
1179
+ """给任务的过程文档补一条执行记录(`who` = 这次动手的人,不是任务现 owner)。
1180
+
1181
+ 调用方必须已经持有 progress_lock —— 与 close 的封卷(读全文→改写→落盘)互斥,
1182
+ 否则两条记录并发追加会互相覆盖。
1183
+ """
1184
+ log = find_worklog(repo, tid, task)
1185
+ if log is None:
1186
+ return
1187
+ try:
1188
+ text = log.read_text(encoding="utf-8")
1189
+ except (OSError, UnicodeDecodeError):
1190
+ return
1191
+ line = exec_line(now(), who, kind, note)
1192
+ new_text = append_exec_log(text, line)
1193
+ if new_text != text:
1194
+ write_text_atomic(log, new_text)
1195
+
1196
+
1050
1197
  def write_worklog(repo: Path, tid: str, task: dict[str, Any]) -> Path:
1051
1198
  log = repo / worklog_dir_for(task) / worklog_name(tid)
1052
1199
  write_text_atomic(log, render_worklog(tid, task))
@@ -1131,6 +1278,7 @@ def cmd_new(args: argparse.Namespace) -> None:
1131
1278
  "status": "doing" if args.self else "open",
1132
1279
  "created": stamp,
1133
1280
  "started": stamp if args.self else None,
1281
+ "updated": stamp,
1134
1282
  "finished": None,
1135
1283
  "category": category,
1136
1284
  "tags": tags,
@@ -1149,6 +1297,7 @@ def cmd_new(args: argparse.Namespace) -> None:
1149
1297
  except Fail:
1150
1298
  pass
1151
1299
  raise Fail(f"创建失败,已从状态表撤销该任务:{exc}")
1300
+ record_exec(repo, tid, task, creator, "new", f"创建任务({task['status']})")
1152
1301
  print(f"已创建任务 {tid}")
1153
1302
  print(f" 标题 {task['title']}")
1154
1303
  print(f" 状态 {task['status']}")
@@ -1184,10 +1333,13 @@ def cmd_claim(args: argparse.Namespace) -> None:
1184
1333
  f"任务 {tid} 当前状态是 {task.get('status')},只有 open 才能认领。"
1185
1334
  )
1186
1335
 
1336
+ stamp = now()
1187
1337
  task["owner"] = owner
1188
1338
  task["status"] = "doing"
1189
- task["started"] = task.get("started") or now()
1339
+ task["started"] = task.get("started") or stamp
1340
+ task["updated"] = stamp
1190
1341
  commit_guarded(path, before, data)
1342
+ record_exec(repo, tid, task, owner, "claim", "认领 → doing")
1191
1343
  print(f"已认领 {tid} → doing,owner = @{owner}")
1192
1344
  print_worklog(repo, tid, task)
1193
1345
 
@@ -1205,12 +1357,17 @@ def cmd_set(args: argparse.Namespace) -> None:
1205
1357
  raise Fail(
1206
1358
  "--status open(退回未认领)不能同时给 --owner;要交给别人就别改回 open。"
1207
1359
  )
1208
- if status is not None and status not in STATUSES:
1209
- raise Fail(f"--status 只能是 {' / '.join(STATUSES)};结案请用 close。")
1360
+ if status == "blocked":
1361
+ raise Fail(
1362
+ "没有 blocked 状态:阻塞由时间自动判定(接任务满 7 天仍未结案),"
1363
+ "状态只有 open / doing;卡在哪里请写进 worklog。"
1364
+ )
1210
1365
  if status == "done":
1211
1366
  raise Fail(
1212
1367
  "不要把状态直接改成 done —— 用 close 结案(它会同时移出状态、追加历史、封卷 worklog)。"
1213
1368
  )
1369
+ if status is not None and status not in STATUSES:
1370
+ raise Fail(f"--status 只能是 {' / '.join(STATUSES)};结案请用 close。")
1214
1371
 
1215
1372
  repo = find_repo(args.repo)
1216
1373
  tid = require_task_id(args.id)
@@ -1241,11 +1398,35 @@ def cmd_set(args: argparse.Namespace) -> None:
1241
1398
  task["started"] = task.get("started") or now()
1242
1399
  changed.append(f"owner {old or '(无)'} → {task['owner']}")
1243
1400
 
1401
+ task["updated"] = now()
1244
1402
  commit_guarded(path, before, data)
1403
+ # 释放认领(退回 open)单列一个类型,便于一眼看出「被释放过」
1404
+ kind = "release" if status == "open" else "set"
1405
+ record_exec(repo, tid, task, me, kind, ",".join(changed))
1245
1406
  print(f"已更新 {tid}:" + ",".join(changed))
1246
1407
  print_worklog(repo, tid, task)
1247
1408
 
1248
1409
 
1410
+ def cmd_log(args: argparse.Namespace) -> None:
1411
+ repo = find_repo(args.repo)
1412
+ tid = require_task_id(args.id)
1413
+ note = " ".join(args.note.split())
1414
+ if not note:
1415
+ raise Fail("--note 不能为空:一句话写清这轮做了什么。")
1416
+ me = git_user()
1417
+ path = repo / PROGRESS_REL
1418
+ with progress_lock(path):
1419
+ before, data = read_guarded(path)
1420
+ task = data["tasks"].get(tid)
1421
+ if task is None:
1422
+ raise Fail(f"没有任务 {tid}(可能未建或已结案;封卷后不再记)。")
1423
+ require_owner(task, tid, me)
1424
+ task["updated"] = now()
1425
+ commit_guarded(path, before, data)
1426
+ record_exec(repo, tid, task, me, "log", note)
1427
+ print(f"已记入 {tid} 的执行记录:{note}")
1428
+
1429
+
1249
1430
  def cmd_close(args: argparse.Namespace) -> None:
1250
1431
  repo = find_repo(args.repo)
1251
1432
  tid = require_task_id(args.id)
@@ -1308,6 +1489,7 @@ def cmd_close(args: argparse.Namespace) -> None:
1308
1489
  log_rel,
1309
1490
  final_cat,
1310
1491
  final_tags,
1492
+ task.get("creator"),
1311
1493
  )
1312
1494
 
1313
1495
  if log_path.exists():
@@ -1326,29 +1508,33 @@ def cmd_close(args: argparse.Namespace) -> None:
1326
1508
  if close_tags is not None
1327
1509
  else list(task.get("tags") or []),
1328
1510
  )
1329
- new_log = stamp_frozen(log_text, finished, owner)
1330
1511
  else:
1331
- new_log = stamp_frozen(
1332
- render_worklog(
1333
- tid,
1334
- dict(
1335
- task,
1336
- status="done",
1337
- category=(
1338
- close_category
1339
- if close_category is not None
1340
- else task.get("category")
1341
- ),
1342
- tags=(
1343
- close_tags
1344
- if close_tags is not None
1345
- else list(task.get("tags") or [])
1346
- ),
1512
+ log_text = render_worklog(
1513
+ tid,
1514
+ dict(
1515
+ task,
1516
+ status="done",
1517
+ category=(
1518
+ close_category
1519
+ if close_category is not None
1520
+ else task.get("category")
1521
+ ),
1522
+ tags=(
1523
+ close_tags
1524
+ if close_tags is not None
1525
+ else list(task.get("tags") or [])
1347
1526
  ),
1348
1527
  ),
1349
- finished,
1350
- owner,
1351
1528
  )
1529
+ # 结案本身是最后一个节点:封卷前补上这条记录,封卷后不再追加
1530
+ new_log = stamp_frozen(
1531
+ append_exec_log(
1532
+ log_text,
1533
+ exec_line(finished, me, "close", f"结案:{summary}"),
1534
+ ),
1535
+ finished,
1536
+ owner,
1537
+ )
1352
1538
 
1353
1539
  # 落盘前复查:三个文件都没被别人动过
1354
1540
  for p, mark in (
@@ -1451,18 +1637,13 @@ def cmd_list(args: argparse.Namespace) -> None:
1451
1637
  if args.status is not None:
1452
1638
  wanted = [s.strip() for s in args.status.split(",") if s.strip()]
1453
1639
  if not wanted:
1454
- raise Fail("--status 不能为空;逗号分隔,如 doing,blocked")
1455
- bad = [s for s in wanted if s not in STATUSES]
1640
+ raise Fail("--status 不能为空;逗号分隔,如 doing,open")
1641
+ # blocked 只用于捞旧数据(新任务不可能再有这个值)
1642
+ bad = [s for s in wanted if s not in STATUSES and s not in LEGACY_STATUSES]
1456
1643
  if bad:
1457
1644
  raise Fail(
1458
1645
  f"--status 含非法值 {', '.join(bad)};可选 {' / '.join(STATUSES)}"
1459
1646
  )
1460
- if "done" in wanted:
1461
- print(
1462
- "提示:done 的任务已结案并移出状态表,用 history 子命令查"
1463
- "(已结案在 ledger/history/<年月>.md,存量在 ledger/PROGRESS-HISTORY.md)。",
1464
- file=sys.stderr,
1465
- )
1466
1647
 
1467
1648
  since = normalize_time(args.since, field="--since") if args.since else None
1468
1649
  until = (
@@ -1646,7 +1827,7 @@ def cmd_validate(args: argparse.Namespace) -> None:
1646
1827
  if not task.get("title"):
1647
1828
  problems.append(f"{tid}: 缺 title")
1648
1829
  status = task.get("status")
1649
- if status not in STATUSES:
1830
+ if status not in STATUSES and status not in LEGACY_STATUSES:
1650
1831
  problems.append(f"{tid}: status={status!r} 非法")
1651
1832
  if status == "open" and task.get("owner"):
1652
1833
  problems.append(f"{tid}: status=open 却有 owner")
@@ -1788,6 +1969,9 @@ def cmd_history(args: argparse.Namespace) -> None:
1788
1969
  # 8 列行自带分类/标签;旧 4/5/6 列行没有,回链读 worklog 头部兜底
1789
1970
  if not r.get("category") and not r.get("tags"):
1790
1971
  r["category"], r["tags"] = labels_for_log(r["log"], hp)
1972
+ # v8 行(无 creator 列)回链 worklog 头部「创建」行取发起人,不丢
1973
+ if not r.get("creator"):
1974
+ r["creator"] = creator_for_history(r["log"], hp)
1791
1975
  records.extend(recs)
1792
1976
  warnings.extend(f"{rel} {w}" for w in warns)
1793
1977
 
@@ -1919,6 +2103,12 @@ def build_parser() -> argparse.ArgumentParser:
1919
2103
  add_repo(p)
1920
2104
  p.set_defaults(func=cmd_set)
1921
2105
 
2106
+ p = sub.add_parser("log", help="往 worklog 的「执行记录」节补一条记录")
2107
+ p.add_argument("--id", required=True)
2108
+ p.add_argument("--note", required=True, help="一句话:这轮做了什么")
2109
+ add_repo(p)
2110
+ p.set_defaults(func=cmd_log)
2111
+
1922
2112
  p = sub.add_parser("close", help="结案:移出状态 + 追加历史 + 封卷 worklog")
1923
2113
  p.add_argument("--id", required=True)
1924
2114
  p.add_argument("--summary", required=True, help="做成了什么,一句话")
@@ -1933,7 +2123,7 @@ def build_parser() -> argparse.ArgumentParser:
1933
2123
  p = sub.add_parser("list", help="查询在办任务")
1934
2124
  p.add_argument("--mine", action="store_true", help="只看自己的")
1935
2125
  p.add_argument("--owner", help="只看某个人的")
1936
- p.add_argument("--status", help="逗号分隔,如 doing,blocked")
2126
+ p.add_argument("--status", help="逗号分隔,如 doing,open")
1937
2127
  p.add_argument("--category", help="按分类过滤(精确匹配)")
1938
2128
  p.add_argument("--tag", help="按标签过滤(包含匹配,命中任一面即算)")
1939
2129
  p.add_argument(