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 +6 -8
- package/SKILL.md +11 -8
- package/assets/worklog.md +7 -0
- package/package.json +1 -1
- package/scripts/progress.py +250 -60
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
### 状态用 JSON,不用 Markdown
|
|
37
37
|
|
|
38
|
-
状态要频繁改(`
|
|
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
|
|
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 -->
|
|
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` |
|
|
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
|
-
|
|
|
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
|
-
| 「卡住了」「先放一放」 |
|
|
59
|
-
|
|
|
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
|
-
-
|
|
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
|
|
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",
|
package/scripts/progress.py
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
ledger/progress.json 在办任务的状态
|
|
6
6
|
ledger/worklog/<年月>/<日>-<时分>-<说明>-<随机码>.md
|
|
7
7
|
单个任务的过程(路径另记在状态表 worklog 字段)
|
|
8
|
-
ledger/history/<年月>.md 已结案任务,一行一条(
|
|
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"
|
|
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
|
-
#
|
|
52
|
+
# 9 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / 完成了什么 / 过程文档。
|
|
50
53
|
# owner 独立成列 —— 按人检索是精确列比较,不靠文本切分。
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
"""历史层的一行(
|
|
434
|
+
"""历史层的一行(9 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / 完成了什么 / 过程文档)。
|
|
417
435
|
|
|
418
|
-
close 专用,避免两处格式漂移。owner 是独立列、单独过 esc_cell ——
|
|
419
|
-
|
|
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(
|
|
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
|
-
否则
|
|
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
|
-
多格式兼容:
|
|
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
|
-
|
|
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 列)→
|
|
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) ==
|
|
666
|
-
#
|
|
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(
|
|
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 → 解析不了就按现有列数追加(绝不把
|
|
803
|
-
→ 文件不存在则带
|
|
804
|
-
|
|
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,
|
|
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
|
|
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
|
|
1209
|
-
raise Fail(
|
|
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
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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,
|
|
1455
|
-
|
|
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,
|
|
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(
|