task-ledger 1.1.0 → 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.1.0",
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",
@@ -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")
@@ -65,6 +68,14 @@ LABEL_MAX_WORDS_EN = 3
65
68
  TAGS_MAX_COUNT = 5
66
69
  CJK_RE = re.compile(r"[\u4e00-\u9fff]")
67
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
+
68
79
  SKILL_DIR = Path(__file__).resolve().parent.parent
69
80
  WORKLOG_TEMPLATE = SKILL_DIR / "assets" / "worklog.md"
70
81
 
@@ -81,6 +92,8 @@ FALLBACK_TEMPLATE = """# <id>:<title>
81
92
  ## 过程
82
93
 
83
94
  ## 结论
95
+
96
+ ## 执行记录
84
97
  """
85
98
 
86
99
 
@@ -1134,6 +1147,53 @@ def find_worklog(
1134
1147
  return None
1135
1148
 
1136
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
+
1137
1197
  def write_worklog(repo: Path, tid: str, task: dict[str, Any]) -> Path:
1138
1198
  log = repo / worklog_dir_for(task) / worklog_name(tid)
1139
1199
  write_text_atomic(log, render_worklog(tid, task))
@@ -1218,6 +1278,7 @@ def cmd_new(args: argparse.Namespace) -> None:
1218
1278
  "status": "doing" if args.self else "open",
1219
1279
  "created": stamp,
1220
1280
  "started": stamp if args.self else None,
1281
+ "updated": stamp,
1221
1282
  "finished": None,
1222
1283
  "category": category,
1223
1284
  "tags": tags,
@@ -1236,6 +1297,7 @@ def cmd_new(args: argparse.Namespace) -> None:
1236
1297
  except Fail:
1237
1298
  pass
1238
1299
  raise Fail(f"创建失败,已从状态表撤销该任务:{exc}")
1300
+ record_exec(repo, tid, task, creator, "new", f"创建任务({task['status']})")
1239
1301
  print(f"已创建任务 {tid}")
1240
1302
  print(f" 标题 {task['title']}")
1241
1303
  print(f" 状态 {task['status']}")
@@ -1271,10 +1333,13 @@ def cmd_claim(args: argparse.Namespace) -> None:
1271
1333
  f"任务 {tid} 当前状态是 {task.get('status')},只有 open 才能认领。"
1272
1334
  )
1273
1335
 
1336
+ stamp = now()
1274
1337
  task["owner"] = owner
1275
1338
  task["status"] = "doing"
1276
- task["started"] = task.get("started") or now()
1339
+ task["started"] = task.get("started") or stamp
1340
+ task["updated"] = stamp
1277
1341
  commit_guarded(path, before, data)
1342
+ record_exec(repo, tid, task, owner, "claim", "认领 → doing")
1278
1343
  print(f"已认领 {tid} → doing,owner = @{owner}")
1279
1344
  print_worklog(repo, tid, task)
1280
1345
 
@@ -1292,12 +1357,17 @@ def cmd_set(args: argparse.Namespace) -> None:
1292
1357
  raise Fail(
1293
1358
  "--status open(退回未认领)不能同时给 --owner;要交给别人就别改回 open。"
1294
1359
  )
1295
- if status is not None and status not in STATUSES:
1296
- raise Fail(f"--status 只能是 {' / '.join(STATUSES)};结案请用 close。")
1360
+ if status == "blocked":
1361
+ raise Fail(
1362
+ "没有 blocked 状态:阻塞由时间自动判定(接任务满 7 天仍未结案),"
1363
+ "状态只有 open / doing;卡在哪里请写进 worklog。"
1364
+ )
1297
1365
  if status == "done":
1298
1366
  raise Fail(
1299
1367
  "不要把状态直接改成 done —— 用 close 结案(它会同时移出状态、追加历史、封卷 worklog)。"
1300
1368
  )
1369
+ if status is not None and status not in STATUSES:
1370
+ raise Fail(f"--status 只能是 {' / '.join(STATUSES)};结案请用 close。")
1301
1371
 
1302
1372
  repo = find_repo(args.repo)
1303
1373
  tid = require_task_id(args.id)
@@ -1328,11 +1398,35 @@ def cmd_set(args: argparse.Namespace) -> None:
1328
1398
  task["started"] = task.get("started") or now()
1329
1399
  changed.append(f"owner {old or '(无)'} → {task['owner']}")
1330
1400
 
1401
+ task["updated"] = now()
1331
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))
1332
1406
  print(f"已更新 {tid}:" + ",".join(changed))
1333
1407
  print_worklog(repo, tid, task)
1334
1408
 
1335
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
+
1336
1430
  def cmd_close(args: argparse.Namespace) -> None:
1337
1431
  repo = find_repo(args.repo)
1338
1432
  tid = require_task_id(args.id)
@@ -1414,29 +1508,33 @@ def cmd_close(args: argparse.Namespace) -> None:
1414
1508
  if close_tags is not None
1415
1509
  else list(task.get("tags") or []),
1416
1510
  )
1417
- new_log = stamp_frozen(log_text, finished, owner)
1418
1511
  else:
1419
- new_log = stamp_frozen(
1420
- render_worklog(
1421
- tid,
1422
- dict(
1423
- task,
1424
- status="done",
1425
- category=(
1426
- close_category
1427
- if close_category is not None
1428
- else task.get("category")
1429
- ),
1430
- tags=(
1431
- close_tags
1432
- if close_tags is not None
1433
- else list(task.get("tags") or [])
1434
- ),
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 [])
1435
1526
  ),
1436
1527
  ),
1437
- finished,
1438
- owner,
1439
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
+ )
1440
1538
 
1441
1539
  # 落盘前复查:三个文件都没被别人动过
1442
1540
  for p, mark in (
@@ -1539,18 +1637,13 @@ def cmd_list(args: argparse.Namespace) -> None:
1539
1637
  if args.status is not None:
1540
1638
  wanted = [s.strip() for s in args.status.split(",") if s.strip()]
1541
1639
  if not wanted:
1542
- raise Fail("--status 不能为空;逗号分隔,如 doing,blocked")
1543
- 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]
1544
1643
  if bad:
1545
1644
  raise Fail(
1546
1645
  f"--status 含非法值 {', '.join(bad)};可选 {' / '.join(STATUSES)}"
1547
1646
  )
1548
- if "done" in wanted:
1549
- print(
1550
- "提示:done 的任务已结案并移出状态表,用 history 子命令查"
1551
- "(已结案在 ledger/history/<年月>.md,存量在 ledger/PROGRESS-HISTORY.md)。",
1552
- file=sys.stderr,
1553
- )
1554
1647
 
1555
1648
  since = normalize_time(args.since, field="--since") if args.since else None
1556
1649
  until = (
@@ -1734,7 +1827,7 @@ def cmd_validate(args: argparse.Namespace) -> None:
1734
1827
  if not task.get("title"):
1735
1828
  problems.append(f"{tid}: 缺 title")
1736
1829
  status = task.get("status")
1737
- if status not in STATUSES:
1830
+ if status not in STATUSES and status not in LEGACY_STATUSES:
1738
1831
  problems.append(f"{tid}: status={status!r} 非法")
1739
1832
  if status == "open" and task.get("owner"):
1740
1833
  problems.append(f"{tid}: status=open 却有 owner")
@@ -2010,6 +2103,12 @@ def build_parser() -> argparse.ArgumentParser:
2010
2103
  add_repo(p)
2011
2104
  p.set_defaults(func=cmd_set)
2012
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
+
2013
2112
  p = sub.add_parser("close", help="结案:移出状态 + 追加历史 + 封卷 worklog")
2014
2113
  p.add_argument("--id", required=True)
2015
2114
  p.add_argument("--summary", required=True, help="做成了什么,一句话")
@@ -2024,7 +2123,7 @@ def build_parser() -> argparse.ArgumentParser:
2024
2123
  p = sub.add_parser("list", help="查询在办任务")
2025
2124
  p.add_argument("--mine", action="store_true", help="只看自己的")
2026
2125
  p.add_argument("--owner", help="只看某个人的")
2027
- p.add_argument("--status", help="逗号分隔,如 doing,blocked")
2126
+ p.add_argument("--status", help="逗号分隔,如 doing,open")
2028
2127
  p.add_argument("--category", help="按分类过滤(精确匹配)")
2029
2128
  p.add_argument("--tag", help="按标签过滤(包含匹配,命中任一面即算)")
2030
2129
  p.add_argument(