dsh-task-ledger 0.0.97 → 0.0.99
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/dist/client.js +2 -2
- package/package.json +1 -1
- package/skill/SKILL.md +23 -7
- package/skill/assets/worklog.md +5 -3
- package/skill/package.json +1 -1
- package/skill/scripts/ledgerkit/actions/registry.py +1 -1
- package/skill/scripts/ledgerkit/actions/relabel.py +105 -0
- package/skill/scripts/ledgerkit/actions/set_task.py +22 -3
- package/skill/scripts/ledgerkit/actions/show_task.py +1 -0
- package/skill/scripts/ledgerkit/core/identity.py +3 -3
- package/skill/scripts/ledgerkit/core/labels.py +16 -4
- package/skill/scripts/ledgerkit/core/worklog_md.py +100 -44
- package/skill/scripts/ledgerkit/shell/cli.py +5 -1
- package/skill/scripts/ledgerkit/store/worklog_store.py +2 -1
package/dist/client.js
CHANGED
|
@@ -4883,8 +4883,8 @@ body[data-ds-dark-theme]{
|
|
|
4883
4883
|
onClick: onBack
|
|
4884
4884
|
}, `← ${t("backToConversation")}`), (0, react.createElement)("div", { style: titleStyle }, t("panelTitle"), (0, react.createElement)("span", {
|
|
4885
4885
|
style: buildTagStyle,
|
|
4886
|
-
title: `v0.0.
|
|
4887
|
-
}, `v0.0.
|
|
4886
|
+
title: `v0.0.99 · 261027-123653-555`
|
|
4887
|
+
}, `v0.0.99 261027-123653-555`))), (0, react.createElement)("div", { style: {
|
|
4888
4888
|
display: "flex",
|
|
4889
4889
|
alignItems: "center",
|
|
4890
4890
|
gap: "var(--dtl-space-2)"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-task-ledger",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.99",
|
|
4
4
|
"description": "Task ledger board for dsh (DeepSeek Harness): read-only board over task-ledger skill workspaces. The task-ledger skill is bundled — install the plugin, get the skill globally.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek",
|
package/skill/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: task-ledger
|
|
3
3
|
description: 本地任务台账,跨会话记录在办任务的归属、进度与起止。当用户要登记一件新的事(新建任务 / 建个坑 / 这活我领了)、记录已有任务的进展(接着做 / 记一条进展 / 这轮先记下)、查看任务状态或历史(现在该做什么 / 这活谁在做 / 上周做过什么)、给任务结案或退回(做完了 / 验收通过 / 不做了 / 退回待认领)、整理标签或检查台账一致性时加载;只是改代码、讨论方案时不要用。
|
|
4
|
-
version: 1.3.
|
|
4
|
+
version: 1.3.25
|
|
5
5
|
allowed-tools: Read,Edit,Bash,Grep
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -49,7 +49,7 @@ allowed-tools: Read,Edit,Bash,Grep
|
|
|
49
49
|
|
|
50
50
|
干什么:建任务、按模板建好过程文档。不加 `--self` = 只建,落公共池等认领;加 `--self` = 建并认领。
|
|
51
51
|
|
|
52
|
-
分类 / 标签口径(`new`
|
|
52
|
+
分类 / 标签口径(`new` / `set` / `close` 共用这组参数):`--category` 答「是什么类型」,单值,中文 ≤4 字 / 纯英文 ≤3 词,吃不准写「其他」;`--tags` 答「涉及什么」,0~5 个、逗号分隔,写不出就不传(传空串等于没传)。tags 不许写 category 的同义词;查询用 tags(包含匹配,见 `list --tag`)。名词性短语、中文优先。**建任务时没写、后来想补就用 `set --category` / `--tags`**(在办任务随时可改);结案时 `close` 写的值会覆盖成终值并进历史行。正例:`--category 缺陷修复 --tags "镜像拉取,超时重试"`;反例:`--category 已完成登录修复`、`--tags 修 bug`。
|
|
53
53
|
|
|
54
54
|
举例:用户说「新建个任务:把发布流程走通」→ `new --title "把发布流程走通"`(只建,不认领);用户明确说「新建个任务并开始做」→ `new --title "把发布流程走通" --self`。
|
|
55
55
|
|
|
@@ -63,17 +63,24 @@ allowed-tools: Read,Edit,Bash,Grep
|
|
|
63
63
|
|
|
64
64
|
举例:用户说「这活我领了」→ `claim --id 20261023-1117-把发布流程走通-7f3a`。
|
|
65
65
|
|
|
66
|
-
### set —
|
|
66
|
+
### set — 释放任务 / 改分类标签
|
|
67
67
|
|
|
68
68
|
| 参数 | 含义 |
|
|
69
69
|
|---|---|
|
|
70
|
-
| `--id <任务号>` |
|
|
70
|
+
| `--id <任务号>` | 哪条任务(必填) |
|
|
71
71
|
| `--release` | 释放回公共池(不带人) |
|
|
72
72
|
| `--owner <别人的uuid>` | 把任务转交给对方(预留通道,**agent 不用**,见纪律;只收 uuid,传名字当场报错) |
|
|
73
|
+
| `--category <分类>` | 改分类(改过程文档头部「分类/标签」行 + 分片字段) |
|
|
74
|
+
| `--tags <a、b>` | 改标签(顿号 / 逗号分隔,最多 5 个) |
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
干什么:两组用法,**不能混在一次调用里** ——
|
|
75
77
|
|
|
76
|
-
|
|
78
|
+
- `--release` / `--owner`:改归属。释放回公共池、状态回「待认领」,别人即可认领;没有单独的「改状态」入口。**这两个必须二选一、不能同给**。
|
|
79
|
+
- `--category` / `--tags`:改分类标签。只改自己名下**在办**任务;给了哪个就替换哪个,没给的沿用原值;头部那行没有就插到最上面,有就原地改。已结案(在历史里)的任务改不了。
|
|
80
|
+
|
|
81
|
+
只给 `--id` 会报错,要求你指明要做哪件事。
|
|
82
|
+
|
|
83
|
+
举例:用户明确说「把这个任务退回去」→ `set --id <任务号> --release`;「这任务归到建设类、打上台账标签」→ `set --id <任务号> --category 建设 --tags 台账、看板`。
|
|
77
84
|
|
|
78
85
|
### log — 记一条进展
|
|
79
86
|
|
|
@@ -207,7 +214,16 @@ python3 <本 SKILL.md 所在目录>/scripts/progress.py <子命令> --repo <工
|
|
|
207
214
|
|
|
208
215
|
- 「执行记录」节名一个字都别动;老文档缺这一节也不用补。
|
|
209
216
|
- 过程文档找不到时 `log` 只提示不失败;用 `show` 查路径,不要手写。
|
|
210
|
-
-
|
|
217
|
+
- 头部是四行,由脚本维护、**都不要手改**,顺序固定 `分类/标签 → 状态 → 创建 → 封卷`,值一律 `· ` 分隔:
|
|
218
|
+
|
|
219
|
+
```markdown
|
|
220
|
+
- **分类**:建设 · **标签**:DSH、台账
|
|
221
|
+
- **状态**:doing · 2026-10-27 09:12:33 · @某人 · 8d557c6d-…
|
|
222
|
+
- **创建**:2026-10-26 23:38:02 · @某人 · 8d557c6d-…
|
|
223
|
+
- **封卷**:2026-10-27 09:20:10 · @某人 · 8d557c6d-…
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
分类 / 标签行与封卷行不一定有(没给分类、没结案就没有)。老文档没有状态行是合法的(`show` 回退查在办,`--json` 标 `legacy: true`),不用补。
|
|
211
227
|
- 执行记录行格式 `- 时间(UTC) · 类型 · @谁 · 说明`,`@谁` 是这次动手的人、不是任务现归属;只追加、不改旧行。行首「类型」是脚本内部动作名(会见到 `release`,它就是 `set --release`);认不出就别当成新命令,按 `--help` 查。
|
|
212
228
|
- 只改某条任务的正文:`show --id <任务号>` 拿路径直接改,不另建 md;已结案的用 `history --months 12 --json` 找回链接(**人话输出里没有链接,必须加 `--json`**)——默认只查近 3 个月,结案久了的要显式放大窗口;只读不改。
|
|
213
229
|
|
package/skill/assets/worklog.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# <id>:<title>
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
>
|
|
3
|
+
- **状态**:<status>
|
|
4
|
+
- **创建**:<created> · @<creator>
|
|
5
5
|
|
|
6
|
-
<!--
|
|
6
|
+
<!-- 头部四行由脚本维护,顺序固定:分类/标签 → 状态 → 创建 → 封卷。
|
|
7
|
+
「- **分类**:… · **标签**:…」按 new --category / --tags 插在最上面,两者皆空则不写;
|
|
8
|
+
「- **封卷**:…」在结案时加在最后。 -->
|
|
7
9
|
|
|
8
10
|
## 背景
|
|
9
11
|
|
package/skill/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "task-ledger",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.25",
|
|
4
4
|
"description": "Task-ledger skill for coding agents: per-workspace progress tracking (ledger/pool.json + owners/<hash>.json shards + owners.json index, 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",
|
|
@@ -43,7 +43,7 @@ ACTIONS: dict[str, ActionSpec] = {
|
|
|
43
43
|
"set": ActionSpec(
|
|
44
44
|
set_task.set_task,
|
|
45
45
|
"result",
|
|
46
|
-
params=("tid", "owner", "release"),
|
|
46
|
+
params=("tid", "owner", "release", "category", "tags"),
|
|
47
47
|
lock_action="set",
|
|
48
48
|
),
|
|
49
49
|
"log": ActionSpec(log_progress.log_progress, "result", params=("tid", "note")),
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""改分类 / 标签:分片那两个字段 + 过程文档头部那行,一次改动两处同步。
|
|
2
|
+
|
|
3
|
+
只动「分类 / 标签」这一件事,不碰归属与状态 —— 归属走 `--owner` / `--release`,
|
|
4
|
+
状态只随命令语义变。已结案的任务不在任何分片里,改不了(历史行是快照)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..core.identity import Identity
|
|
12
|
+
from ..core.labels import normalize_label, parse_tags
|
|
13
|
+
from ..core.paths import shard_rel
|
|
14
|
+
from ..core.time import now
|
|
15
|
+
from ..core.worklog_md import (
|
|
16
|
+
StatusLine,
|
|
17
|
+
append_exec_log,
|
|
18
|
+
exec_line,
|
|
19
|
+
rewrite_label_line,
|
|
20
|
+
rewrite_status_line,
|
|
21
|
+
)
|
|
22
|
+
from ..errors import Fail
|
|
23
|
+
from ..store.task_store import TaskStore
|
|
24
|
+
from ..store.worklog_store import WorklogStore
|
|
25
|
+
from .guard import require_owner
|
|
26
|
+
from .result import Result, rel
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _describe(category: str | None, tags: list[str]) -> str:
|
|
30
|
+
"""执行记录里那一句说明:`建设 · DSH、台账`。"""
|
|
31
|
+
parts = [category] if category else []
|
|
32
|
+
if tags:
|
|
33
|
+
parts.append("、".join(tags))
|
|
34
|
+
return " · ".join(parts)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def relabel(
|
|
38
|
+
repo: Path,
|
|
39
|
+
me: Identity,
|
|
40
|
+
tid: str,
|
|
41
|
+
*,
|
|
42
|
+
category: str | None = None,
|
|
43
|
+
tags: str | tuple[str, ...] | None = None,
|
|
44
|
+
) -> Result:
|
|
45
|
+
"""写序:算最终值 → 过程文档(标签行 + 执行记录 + 状态行,一次写)→ 分片。
|
|
46
|
+
|
|
47
|
+
`tags` 收字符串(顿号 / 逗号分隔)或已切好的元组 —— 外壳可能已经切过一遍。
|
|
48
|
+
"""
|
|
49
|
+
new_category = normalize_label(category, field="分类") if category else None
|
|
50
|
+
new_tags = parse_tags(tags) if tags else None
|
|
51
|
+
if new_category is None and new_tags is None:
|
|
52
|
+
raise Fail(
|
|
53
|
+
"改分类 / 标签至少要给一个:`--category <分类>` 或 `--tags <a、b>`。"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
|
|
57
|
+
task = mine.get(tid) # 锁内重读
|
|
58
|
+
if task is None:
|
|
59
|
+
raise Fail(
|
|
60
|
+
f"任务 {tid} 不在我的分片里(只能改自己名下在办的任务;已结案的任务改不了)。"
|
|
61
|
+
)
|
|
62
|
+
require_owner(task, tid, me)
|
|
63
|
+
|
|
64
|
+
# 没给的沿用原值:分类与标签是一行里的两段,写一次就得给全
|
|
65
|
+
final_category = new_category if new_category else task.get("category")
|
|
66
|
+
final_tags: list[str] = (
|
|
67
|
+
[str(tag) for tag in new_tags]
|
|
68
|
+
if new_tags is not None
|
|
69
|
+
else [str(tag) for tag in (task.get("tags") or ())]
|
|
70
|
+
)
|
|
71
|
+
if not final_category and not final_tags:
|
|
72
|
+
raise Fail("分类与标签都是空的,没有可写的值。")
|
|
73
|
+
|
|
74
|
+
when = now()
|
|
75
|
+
updated = dict(task)
|
|
76
|
+
updated["category"] = final_category
|
|
77
|
+
updated["tags"] = final_tags
|
|
78
|
+
updated["updated"] = when
|
|
79
|
+
|
|
80
|
+
logs = WorklogStore(repo)
|
|
81
|
+
path = logs.find(tid, task.get("worklog"))
|
|
82
|
+
if path is None:
|
|
83
|
+
mine.put(tid, updated)
|
|
84
|
+
return Result(
|
|
85
|
+
changed=("category", "tags", "updated"),
|
|
86
|
+
where=rel(repo, mine.path),
|
|
87
|
+
message=f"{tid}:改分类 / 标签",
|
|
88
|
+
notices=("提示:这条任务没有过程文档,只改了分片,文档与历史里看不到。",),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
text = logs.read(path)
|
|
92
|
+
note = "改分类 / 标签:" + _describe(final_category, final_tags)
|
|
93
|
+
text = rewrite_label_line(text, final_category, final_tags)
|
|
94
|
+
text = append_exec_log(text, exec_line(when, me.stamp(), "set", note))
|
|
95
|
+
text = rewrite_status_line(
|
|
96
|
+
text, StatusLine(task.get("status") or "doing", me.name, me.uuid, when)
|
|
97
|
+
)
|
|
98
|
+
logs.write(path, text)
|
|
99
|
+
mine.put(tid, updated)
|
|
100
|
+
|
|
101
|
+
return Result(
|
|
102
|
+
changed=("category", "tags", "updated"),
|
|
103
|
+
where=rel(repo, mine.path),
|
|
104
|
+
message=f"{tid}:改分类 / 标签",
|
|
105
|
+
)
|
|
@@ -18,10 +18,16 @@ from ..errors import Fail
|
|
|
18
18
|
from ..store.task_store import TaskStore
|
|
19
19
|
from . import claim as claim_mod
|
|
20
20
|
from . import handoff as handoff_mod
|
|
21
|
+
from . import relabel as relabel_mod
|
|
21
22
|
from . import release as release_mod
|
|
22
23
|
from .guard import require_uuid
|
|
23
24
|
from .result import Result, rel
|
|
24
25
|
|
|
26
|
+
_MIXED = (
|
|
27
|
+
"--category / --tags 不能与 --release / --owner 同时给:"
|
|
28
|
+
"一个是改分类标签,一个是改归属,分两次跑。"
|
|
29
|
+
)
|
|
30
|
+
|
|
25
31
|
|
|
26
32
|
def set_task(
|
|
27
33
|
repo: Path,
|
|
@@ -30,15 +36,28 @@ def set_task(
|
|
|
30
36
|
*,
|
|
31
37
|
owner: str | None = None,
|
|
32
38
|
release: bool = False,
|
|
39
|
+
category: str | None = None,
|
|
40
|
+
tags: str | tuple[str, ...] | None = None,
|
|
33
41
|
) -> Result:
|
|
34
|
-
"""路由:`--release` = 释放;别人的 uuid = 交接;自己的 uuid =
|
|
42
|
+
"""路由:`--release` = 释放;别人的 uuid = 交接;自己的 uuid = 已认领则空转、没认领先认领;
|
|
43
|
+
`--category` / `--tags` = 改分类与标签(只动文档头部那行与分片字段)。
|
|
44
|
+
"""
|
|
45
|
+
relabel_wanted = bool(category or tags)
|
|
35
46
|
if release:
|
|
36
47
|
if owner is not None:
|
|
37
48
|
raise Fail("--release 与 --owner 不能同时给:释放不带人,交接不带释放。")
|
|
49
|
+
if relabel_wanted:
|
|
50
|
+
raise Fail(_MIXED)
|
|
38
51
|
return release_mod.release(repo, me, tid)
|
|
39
|
-
if owner is None:
|
|
52
|
+
if owner is not None:
|
|
53
|
+
if relabel_wanted:
|
|
54
|
+
raise Fail(_MIXED)
|
|
55
|
+
else:
|
|
56
|
+
if relabel_wanted:
|
|
57
|
+
return relabel_mod.relabel(repo, me, tid, category=category, tags=tags)
|
|
40
58
|
raise Fail(
|
|
41
|
-
"set
|
|
59
|
+
"set 必须给一样:`--release`(释放回公共池)/ `--owner <对方 uuid>`(交接)/ "
|
|
60
|
+
"`--category <分类>` 或 `--tags <a、b>`(改分类标签)。"
|
|
42
61
|
)
|
|
43
62
|
# 交接目标只收 uuid:名字查不到就当场报错,绝不去猜「他大概是这个人」
|
|
44
63
|
target = require_uuid(owner, field="--owner")
|
|
@@ -34,6 +34,7 @@ def show_task(repo: Path, me: Identity, tid: str) -> ShowResult:
|
|
|
34
34
|
"owner_name": status.name,
|
|
35
35
|
"created": worklog_md.head_created(text),
|
|
36
36
|
"updated": status.when,
|
|
37
|
+
"finished": worklog_md.head_frozen(text),
|
|
37
38
|
},
|
|
38
39
|
worklog=WorklogView(path=rel(repo, path), text=text, error=None),
|
|
39
40
|
)
|
|
@@ -27,10 +27,10 @@ class Identity:
|
|
|
27
27
|
def stamp(self) -> str:
|
|
28
28
|
"""**落盘**用的身份串:`名字 · uuid` —— 名字在前给人读,uuid 在后给机器对。
|
|
29
29
|
|
|
30
|
-
与 `label()` 一样**不带 `@`**(`@`
|
|
31
|
-
`exec_line` 拼 ` · @{who} ·
|
|
30
|
+
与 `label()` 一样**不带 `@`**(`@` 由各落点自己加:头部各行拼 ` · @{stamp}`、
|
|
31
|
+
`exec_line` 拼 ` · @{who} · `)。
|
|
32
32
|
|
|
33
|
-
过程文档(创建行 / 封卷行 / 执行记录)是这个串唯一的落点:文件是最终产物,
|
|
33
|
+
过程文档(创建行 / 状态行 / 封卷行 / 执行记录)是这个串唯一的落点:文件是最终产物,
|
|
34
34
|
创建人和封卷人可能不是同一个人、也可能中途改过名,所以**两个都要留**,不靠
|
|
35
35
|
历史表回链。没名字就只写 uuid。
|
|
36
36
|
"""
|
|
@@ -15,7 +15,7 @@ LABEL_MAX_WORDS_EN = 3 # 纯 ASCII 时按「词」算
|
|
|
15
15
|
TAGS_MAX_COUNT = 5
|
|
16
16
|
|
|
17
17
|
CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
|
18
|
-
LABEL_CAT_RE = re.compile(r"分类\*\*\s*[::]\s*([
|
|
18
|
+
LABEL_CAT_RE = re.compile(r"分类\*\*\s*[::]\s*([^||·]+)")
|
|
19
19
|
LABEL_TAG_RE = re.compile(r"标签\*\*\s*[::]\s*(.+)$")
|
|
20
20
|
_SPLIT_RE = re.compile(r"[,,、]")
|
|
21
21
|
|
|
@@ -49,21 +49,33 @@ def parse_tags(raw: str | Iterable[str] | None) -> tuple[str, ...]:
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def label_line(category: str | None, tags: Iterable[str] | None) -> str:
|
|
52
|
-
"""
|
|
52
|
+
"""过程文档头部的「分类 / 标签」行;两者皆空返回空串(此行不写)。"""
|
|
53
53
|
segs: list[str] = []
|
|
54
54
|
if category:
|
|
55
55
|
segs.append(f"**分类**:{category}")
|
|
56
56
|
tag_list = [t for t in (tags or ()) if t]
|
|
57
57
|
if tag_list:
|
|
58
58
|
segs.append(f"**标签**:{'、'.join(tag_list)}")
|
|
59
|
-
return "
|
|
59
|
+
return "- " + " · ".join(segs) if segs else ""
|
|
60
60
|
|
|
61
61
|
|
|
62
62
|
def parse_label_line(text: str) -> tuple[str | None, tuple[str, ...]]:
|
|
63
|
-
"""从过程文档头部(第一个 `## ` 小节之前)解析分类与标签。
|
|
63
|
+
"""从过程文档头部(第一个 `## ` 小节之前)解析分类与标签。
|
|
64
|
+
|
|
65
|
+
跳过 HTML 注释块:模板注释里举例写着「**分类**:…」,那不是数据。
|
|
66
|
+
"""
|
|
67
|
+
in_comment = False
|
|
64
68
|
for line in text.splitlines():
|
|
65
69
|
if line.startswith("## "):
|
|
66
70
|
break
|
|
71
|
+
stripped = line.lstrip()
|
|
72
|
+
if in_comment:
|
|
73
|
+
if "-->" in stripped:
|
|
74
|
+
in_comment = False
|
|
75
|
+
continue
|
|
76
|
+
if stripped.startswith("<!--"):
|
|
77
|
+
in_comment = "-->" not in stripped
|
|
78
|
+
continue
|
|
67
79
|
mc = LABEL_CAT_RE.search(line)
|
|
68
80
|
mt = LABEL_TAG_RE.search(line)
|
|
69
81
|
if not (mc or mt):
|
|
@@ -20,33 +20,44 @@ EXEC_HINT = (
|
|
|
20
20
|
"<!-- 本节由脚本维护:new / claim / set / release / log / close 自动追加,请勿手改。\n"
|
|
21
21
|
' 补记用 progress.py log --id <任务号> --note "一句话"。 -->'
|
|
22
22
|
)
|
|
23
|
-
FROZEN_MARK = "
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
FROZEN_MARK = "封卷"
|
|
24
|
+
#: 头部四行的规范顺序:分类/标签 → 状态 → 创建 → 封卷。
|
|
25
|
+
#: 「分类」只在有分类 / 标签时写,「封卷」只在结案时写,另两行必有。
|
|
26
|
+
HEAD_KEY_ORDER = ("meta", "status", "created", "frozen")
|
|
27
|
+
#: 头部行前缀:新格式是无序列表 `- `,老文档是引用块 `> ` —— 读的时候两种都认。
|
|
28
|
+
_HEAD_BULLET = r"^(?:\s*>\s?|\s*[-*]\s+)"
|
|
29
|
+
FROZEN_RE = re.compile(
|
|
30
|
+
_HEAD_BULLET + r"(?:\*\*" + FROZEN_MARK + r"\*\*|✅ 完成封卷)", re.MULTILINE
|
|
31
|
+
)
|
|
32
|
+
#: 封卷行的时间(老文档是 `> ✅ 完成封卷:<时间>(@…)`,新格式是 `- **封卷**:<时间> · @…`)。
|
|
33
|
+
_FROZEN_AT_RE = re.compile(
|
|
34
|
+
_HEAD_BULLET + r"(?:\*\*" + FROZEN_MARK + r"\*\*|✅ 完成封卷)[^::]*[::]\s*"
|
|
35
|
+
r"(\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?)"
|
|
36
|
+
)
|
|
37
|
+
# 头部「创建」行:`- **创建**:2026-10-06 00:00 · @某人 · uuid`。
|
|
38
|
+
# 值一律 `时间 · @名字 · uuid`,与执行记录行同一种分隔符;老文档是 `(@名字 · uuid)`,读得到就认。
|
|
39
|
+
CREATED_RE = re.compile(_HEAD_BULLET + r"\*\*创建\*\*")
|
|
29
40
|
CREATED_AT_RE = re.compile(
|
|
30
|
-
r"
|
|
41
|
+
_HEAD_BULLET + r"\*\*创建\*\*\s*[::]\s*"
|
|
31
42
|
r"(\d{4}-\d{2}-\d{2}(?: +\d{1,2}:\d{1,2}(?::\d{1,2})?)?)"
|
|
32
43
|
)
|
|
33
|
-
CREATOR_RE = re.compile(r"
|
|
44
|
+
CREATOR_RE = re.compile(_HEAD_BULLET + r"\*\*创建\*\*\s*[::].*?@(.+?)\s*[))]?\s*$")
|
|
34
45
|
|
|
35
|
-
#
|
|
46
|
+
# 头部「状态」行:`- **状态**:doing · 2026-10-27 08:28:22 · @某人 · uuid`。
|
|
36
47
|
# 它是**镜像**:分片 / 池里的 `status` / `owner` 才是权威,这行只是让「看一条详情」
|
|
37
|
-
#
|
|
48
|
+
# 不必再翻索引。老文档没有这行 ⇒ 合法,读取方回退查分片。
|
|
38
49
|
STATUS_MARK = "状态"
|
|
39
|
-
STATUS_LINE_RE = re.compile(r"
|
|
40
|
-
STATUS_RE = re.compile(
|
|
50
|
+
STATUS_LINE_RE = re.compile(_HEAD_BULLET + r"\*\*" + STATUS_MARK + r"\*\*")
|
|
51
|
+
STATUS_RE = re.compile(
|
|
52
|
+
_HEAD_BULLET + r"\*\*" + STATUS_MARK + r"\*\*\s*[::]\s*([A-Za-z_]+)"
|
|
53
|
+
)
|
|
41
54
|
#: 状态取值(与 `core/labels.STATUSES` + 结案后的 `closed` 同口径,全链路英文)。
|
|
42
55
|
STATUS_VALUES = ("open", "doing", "closed")
|
|
43
56
|
_STATUS_UUID_RE = re.compile(
|
|
44
57
|
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
|
45
58
|
)
|
|
46
59
|
_STATUS_NAME_RE = re.compile(r"@([^((·]+)")
|
|
47
|
-
_STATUS_WHEN_RE = re.compile(
|
|
48
|
-
r"更新\s+(\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?)"
|
|
49
|
-
)
|
|
60
|
+
_STATUS_WHEN_RE = re.compile(r"(\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?)")
|
|
50
61
|
_TITLE_RE = re.compile(r"^#\s*(.+?)\s*[::]\s*(.*)$")
|
|
51
62
|
|
|
52
63
|
_HEAD_RE = re.compile(r"\A(.*?)(?=^## |\Z)", re.DOTALL | re.MULTILINE)
|
|
@@ -84,10 +95,10 @@ def format_problems(text: str) -> list[str]:
|
|
|
84
95
|
head = _head_lines(lines)
|
|
85
96
|
if not any(CREATED_AT_RE.match(line) for line in head):
|
|
86
97
|
problems.append(
|
|
87
|
-
"头部缺「创建」时间行(应为
|
|
98
|
+
"头部缺「创建」时间行(应为 `- **创建**:YYYY-MM-DD HH:MM:SS · @某人 · uuid`)"
|
|
88
99
|
)
|
|
89
100
|
elif not any(CREATOR_RE.match(line) for line in head):
|
|
90
|
-
problems.append("头部「创建」行缺发起人(应为
|
|
101
|
+
problems.append("头部「创建」行缺发起人(应为 `… · @某人 · uuid`)")
|
|
91
102
|
# 状态行:**有就必须合法**;缺了不报——老文档没有这行是合法的(读取方回退查分片)
|
|
92
103
|
for line in head:
|
|
93
104
|
if not STATUS_LINE_RE.match(line):
|
|
@@ -164,8 +175,8 @@ def render_worklog(
|
|
|
164
175
|
) -> str:
|
|
165
176
|
"""把模板套成一篇过程文档(`<id>` / `<title>` / `<created>` / `<creator>` / `<status>`)。
|
|
166
177
|
|
|
167
|
-
`<status>` 只占状态行的**内容**(`doing ·
|
|
168
|
-
|
|
178
|
+
`<status>` 只占状态行的**内容**(`doing · 时间 · @某人 · uuid`);模板里的
|
|
179
|
+
`- **状态**:` 前缀是字面量。不传 status ⇒ 整行删掉(不留空壳)。
|
|
169
180
|
"""
|
|
170
181
|
text = (
|
|
171
182
|
template.replace("<id>", tid)
|
|
@@ -190,15 +201,50 @@ def render_worklog(
|
|
|
190
201
|
meta = label_line(category, tags)
|
|
191
202
|
if not meta:
|
|
192
203
|
return text
|
|
193
|
-
return "\n".join(
|
|
204
|
+
return "\n".join(_insert_head_line(text.splitlines(), meta, "meta")) + "\n"
|
|
194
205
|
|
|
195
206
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
207
|
+
_META_LINE_RE = re.compile(_HEAD_BULLET + r"\*\*(?:分类|标签)\*\*")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _head_key(line: str) -> str | None:
|
|
211
|
+
"""这一行是头部哪个字段(`meta` / `status` / `created` / `frozen`);都不是给 None。"""
|
|
212
|
+
if _META_LINE_RE.search(line):
|
|
213
|
+
return "meta"
|
|
214
|
+
if STATUS_LINE_RE.match(line):
|
|
215
|
+
return "status"
|
|
216
|
+
if CREATED_RE.match(line):
|
|
217
|
+
return "created"
|
|
218
|
+
if FROZEN_RE.match(line):
|
|
219
|
+
return "frozen"
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _insert_head_line(lines: list[str], line: str, key: str) -> list[str]:
|
|
224
|
+
"""按 `HEAD_KEY_ORDER` 把一行插进头部(第一个 `## ` 之前)。
|
|
225
|
+
|
|
226
|
+
排在它后面的字段行先出现就插在那行前面;没有就接在最后一个字段行之后;
|
|
227
|
+
一个字段行都没有就插在标题(及其后的空行)下面。
|
|
228
|
+
"""
|
|
229
|
+
rank = HEAD_KEY_ORDER.index(key)
|
|
230
|
+
last: int | None = None
|
|
231
|
+
for i, ln in enumerate(lines):
|
|
232
|
+
if ln.startswith("## "):
|
|
233
|
+
break
|
|
234
|
+
found = _head_key(ln)
|
|
235
|
+
if found is None:
|
|
236
|
+
continue
|
|
237
|
+
if HEAD_KEY_ORDER.index(found) > rank:
|
|
238
|
+
lines.insert(i, line)
|
|
239
|
+
return lines
|
|
240
|
+
last = i
|
|
241
|
+
if last is None:
|
|
200
242
|
at = 1 if lines and lines[0].startswith("# ") else 0
|
|
201
|
-
|
|
243
|
+
while at + 1 < len(lines) and not lines[at + 1].strip():
|
|
244
|
+
at += 1
|
|
245
|
+
lines.insert(at + 1, line)
|
|
246
|
+
else:
|
|
247
|
+
lines.insert(last + 1, line)
|
|
202
248
|
return lines
|
|
203
249
|
|
|
204
250
|
|
|
@@ -217,16 +263,14 @@ def render_status_body(
|
|
|
217
263
|
uuid: str | None = None,
|
|
218
264
|
when: str | None = None,
|
|
219
265
|
) -> str:
|
|
220
|
-
"""
|
|
266
|
+
"""状态行的**内容**部分(`- **状态**:` 之后那段):`状态 · 时间 · @名字 · uuid`。"""
|
|
221
267
|
parts = [status]
|
|
222
|
-
if
|
|
223
|
-
parts.append(
|
|
224
|
-
|
|
268
|
+
if when:
|
|
269
|
+
parts.append(when)
|
|
270
|
+
if name:
|
|
225
271
|
parts.append(f"@{name}")
|
|
226
|
-
|
|
272
|
+
if uuid:
|
|
227
273
|
parts.append(uuid)
|
|
228
|
-
if when:
|
|
229
|
-
parts.append(f"更新 {when}")
|
|
230
274
|
return " · ".join(parts)
|
|
231
275
|
|
|
232
276
|
|
|
@@ -236,11 +280,11 @@ def render_status_line(
|
|
|
236
280
|
uuid: str | None = None,
|
|
237
281
|
when: str | None = None,
|
|
238
282
|
) -> str:
|
|
239
|
-
return f"
|
|
283
|
+
return f"- **{STATUS_MARK}**:{render_status_body(status, name, uuid, when)}"
|
|
240
284
|
|
|
241
285
|
|
|
242
286
|
def rewrite_status_line(text: str, status: StatusLine) -> str:
|
|
243
|
-
"""把头部状态行改成 `status
|
|
287
|
+
"""把头部状态行改成 `status`:有就就地替换,没有就按头部顺序插进去。"""
|
|
244
288
|
line = render_status_line(status.status, status.name, status.uuid, status.when)
|
|
245
289
|
lines = (text or "").splitlines()
|
|
246
290
|
for i, ln in enumerate(lines):
|
|
@@ -249,7 +293,7 @@ def rewrite_status_line(text: str, status: StatusLine) -> str:
|
|
|
249
293
|
if STATUS_LINE_RE.match(ln):
|
|
250
294
|
lines[i] = line
|
|
251
295
|
return "\n".join(lines) + "\n"
|
|
252
|
-
return "\n".join(
|
|
296
|
+
return "\n".join(_insert_head_line(lines, line, "status")) + "\n"
|
|
253
297
|
|
|
254
298
|
|
|
255
299
|
def read_status(text: str) -> StatusLine | None:
|
|
@@ -309,25 +353,25 @@ def has_frozen_line(text: str) -> bool:
|
|
|
309
353
|
|
|
310
354
|
|
|
311
355
|
def stamp_frozen(text: str, finished: str, who: str | None = "") -> str:
|
|
312
|
-
"""
|
|
313
|
-
mark = f"
|
|
356
|
+
"""封卷:在头部打「封卷」行(`finished` 传 UTC,原样写盘——见 exec_line 的口径说明)。"""
|
|
357
|
+
mark = f"- **{FROZEN_MARK}**:{finished}"
|
|
314
358
|
if who:
|
|
315
|
-
mark += f"
|
|
359
|
+
mark += f" · @{who}"
|
|
316
360
|
if FROZEN_RE.search(text or ""):
|
|
317
361
|
return text
|
|
318
|
-
return "\n".join(
|
|
362
|
+
return "\n".join(_insert_head_line(text.splitlines(), mark, "frozen")) + "\n"
|
|
319
363
|
|
|
320
364
|
|
|
321
365
|
def rewrite_label_line(
|
|
322
366
|
text: str, category: str | None, tags: Iterable[str] | None
|
|
323
367
|
) -> str:
|
|
324
|
-
"""改写头部的分类 /
|
|
368
|
+
"""改写头部的分类 / 标签行:有就地替换,没有就按头部顺序插进去。"""
|
|
325
369
|
line = label_line(category, tags)
|
|
326
370
|
lines = text.splitlines()
|
|
327
371
|
for i, ln in enumerate(lines):
|
|
328
372
|
if ln.startswith("## "):
|
|
329
373
|
break
|
|
330
|
-
if
|
|
374
|
+
if _META_LINE_RE.match(ln):
|
|
331
375
|
if line:
|
|
332
376
|
lines[i] = line
|
|
333
377
|
else:
|
|
@@ -335,7 +379,7 @@ def rewrite_label_line(
|
|
|
335
379
|
return "\n".join(lines) + "\n"
|
|
336
380
|
if not line:
|
|
337
381
|
return text
|
|
338
|
-
return "\n".join(
|
|
382
|
+
return "\n".join(_insert_head_line(lines, line, "meta")) + "\n"
|
|
339
383
|
|
|
340
384
|
|
|
341
385
|
def head_meta(text: str) -> tuple[str | None, tuple[str, ...]]:
|
|
@@ -355,8 +399,20 @@ def head_created(text: str) -> str | None:
|
|
|
355
399
|
return None
|
|
356
400
|
|
|
357
401
|
|
|
402
|
+
def head_frozen(text: str) -> str | None:
|
|
403
|
+
"""头部「封卷」行的时间(原样返回,不转时区);没封卷 / 读不到返回 None。"""
|
|
404
|
+
for line in text.splitlines():
|
|
405
|
+
m = _FROZEN_AT_RE.match(line)
|
|
406
|
+
if m:
|
|
407
|
+
try:
|
|
408
|
+
return normalize_time(m.group(1), field="封卷")
|
|
409
|
+
except Fail:
|
|
410
|
+
return None
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
358
414
|
def head_creator(text: str) -> str | None:
|
|
359
|
-
"""
|
|
415
|
+
"""头部「创建」行的发起人**名字**(不带 @、不带 uuid);读不到返回 None。"""
|
|
360
416
|
for line in text.splitlines():
|
|
361
417
|
m = CREATOR_RE.match(line)
|
|
362
418
|
if m and m.group(1).strip():
|
|
@@ -365,7 +421,7 @@ def head_creator(text: str) -> str | None:
|
|
|
365
421
|
|
|
366
422
|
|
|
367
423
|
def head_creator_uuid(text: str) -> str | None:
|
|
368
|
-
"""
|
|
424
|
+
"""头部「创建」行的发起人 **uuid**;老文档没落 uuid 时返回 None。
|
|
369
425
|
|
|
370
426
|
`UUID_RE` 带 `^$` 锚点,只能整串匹配 —— 所以先按 ` · ` 切出尾段再判,不能 search。
|
|
371
427
|
"""
|
|
@@ -45,10 +45,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
45
45
|
claim = sub.add_parser("claim", help="认领公共池里的任务")
|
|
46
46
|
claim.add_argument("--id", required=True, dest="tid")
|
|
47
47
|
|
|
48
|
-
setp = sub.add_parser("set", help="释放 /
|
|
48
|
+
setp = sub.add_parser("set", help="释放 / 交接 / 改分类标签")
|
|
49
49
|
setp.add_argument("--id", required=True, dest="tid")
|
|
50
50
|
setp.add_argument("--owner", help="交给别人的 uuid(交接)")
|
|
51
51
|
setp.add_argument("--release", action="store_true", help="释放回公共池")
|
|
52
|
+
setp.add_argument(
|
|
53
|
+
"--category", help="改分类(与 --tags 一起改过程文档头部分类/标签行)"
|
|
54
|
+
)
|
|
55
|
+
setp.add_argument("--tags", help="改标签,顿号 / 逗号分隔")
|
|
52
56
|
|
|
53
57
|
logp = sub.add_parser("log", help="记一条进展")
|
|
54
58
|
logp.add_argument("--id", required=True, dest="tid")
|