task-ledger 1.3.5 → 1.3.7
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/SKILL.md +16 -2
- package/package.json +1 -1
- package/references/state-model.md +4 -4
- package/scripts/ledgerkit/actions/claim.py +1 -1
- package/scripts/ledgerkit/actions/close_task.py +10 -6
- package/scripts/ledgerkit/actions/handoff.py +8 -3
- package/scripts/ledgerkit/actions/log_progress.py +1 -1
- package/scripts/ledgerkit/actions/new_task.py +2 -2
- package/scripts/ledgerkit/actions/query_history.py +12 -5
- package/scripts/ledgerkit/actions/release.py +1 -1
- package/scripts/ledgerkit/core/history_md.py +37 -15
- package/scripts/ledgerkit/core/identity.py +15 -1
- package/scripts/ledgerkit/core/worklog_md.py +23 -3
- package/scripts/ledgerkit/shell/cli.py +17 -6
- package/scripts/ledgerkit/store/history_store.py +6 -4
package/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.7
|
|
5
5
|
allowed-tools: Read,Edit,Bash,Grep
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -13,6 +13,20 @@ allowed-tools: Read,Edit,Bash,Grep
|
|
|
13
13
|
- **任务状态**:`待认领(open)` = 建了还没人认领;`进行中(doing)` = 已认领、在做;`已结案` = 用户明确说结束后由 `close` 产生,此后只读。
|
|
14
14
|
- **操作方式**:台账的状态与归属只经命令改;唯一能手写的是过程文档前五节(见「手写边界」)。台账只记「建 / 做 / 结」和指向过程文档的链接;产出物本身按项目自己的约定落盘。
|
|
15
15
|
|
|
16
|
+
## 身份闸门(未初始化时怎么走)
|
|
17
|
+
|
|
18
|
+
**任何命令返回「这台机器还没有初始化身份」⇒ 一律停下,不许绕。** 身份是用户自己的,不是你的决定。
|
|
19
|
+
|
|
20
|
+
- **查询类**(`list` / `show` / `history` / `validate`):什么都不做。把 init 命令交给用户,等 ta 跑完再重试原命令。
|
|
21
|
+
- **写入类**(`new` / `claim` / `set` / `log` / `close`):不建、不改、不推送。先裸跑一次 `init`(只读,只把名册里已有的身份报出来):
|
|
22
|
+
- **有名单** ⇒ 「名册里已有这些身份:<uuid 名字>。确定哪个是你的,就自己在终端跑 `init --claim <uuid>` 选它;都不是就 `init --new --name <名字>` 新建。」**报给用户,让 ta 选。**
|
|
23
|
+
- **没名单** ⇒ 「名册为空,请在终端 `init --new --name <名字>` 新建。」
|
|
24
|
+
- **禁止(硬规则)**:
|
|
25
|
+
- **不替用户执行** `init --claim` / `init --new` / `init --name` —— 挑哪个身份是用户的事;
|
|
26
|
+
- **不读** `ledger/*.json`、worklog 去"自己看";
|
|
27
|
+
- 未初始化时**不做任何写**。
|
|
28
|
+
- `init` 的写操作只能由用户在终端交互执行(脚本对无终端调用直接报错);agent **唯一**可跑的 `init` 是不带参数、只报名单的只读形式。
|
|
29
|
+
|
|
16
30
|
## 手写边界
|
|
17
31
|
|
|
18
32
|
- **唯一能手写的是任务的过程文档(worklog)前五节**(怎么写见「过程文档」节);末节「执行记录」由脚本维护,**禁止手写、禁止手工添加**。
|
|
@@ -31,7 +45,7 @@ allowed-tools: Read,Edit,Bash,Grep
|
|
|
31
45
|
| `--claim <uuid>` | 认领名册里已有的 uuid(名字一起复制) |
|
|
32
46
|
| `--new --name "…"` | 新建身份:生成 uuid + 名字 |
|
|
33
47
|
|
|
34
|
-
干什么:写本机身份文件(`~/.taskledger/identity.json
|
|
48
|
+
干什么:写本机身份文件(`~/.taskledger/identity.json`),**一台机器只一次**,与台账无关。**写操作只能由用户在终端里执行**(无终端调用会被脚本拒绝);agent 只许裸跑 `init`(只读、把候选报给用户),不得代跑 `--claim` / `--new` / `--name`。名册为空时裸跑 `init` 会报「新建身份必须输入一个名字」,此时让用户自己 `init --new --name "<名字>"` 新建。
|
|
35
49
|
|
|
36
50
|
### new — 建任务
|
|
37
51
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "task-ledger",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.7",
|
|
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",
|
|
@@ -25,8 +25,8 @@ close → 历史层 + 封卷 + 移出自己名下
|
|
|
25
25
|
- 注意:任务号前缀与 worklog 月份目录按 **UTC** 推 —— UTC+8 下裸日期 `--created 2026-01-05` 存成 `2026-01-04 16:00:00`,任务号前缀变成 `20260104-1600`;临午夜补录要连时间一起传。
|
|
26
26
|
- **筛选语义**:`list` 的 `--since` / `--until` 按**创建时间**;`history` 的按**结案时间**(历史文件按结案月归档,按创建时间比较会漏「上月建、本月结」的记录)。只给日期时起 = 当天 `00:00:00`、止 = 当天 `23:59:59`(含当天)。
|
|
27
27
|
- **时区**:存盘为 UTC 的只有 —— 任务号、worklog 目录 / 文件名、台账 JSON 的 `created`/`started`/`updated`/`finished`、history 行、worklog 头部「创建」行。**执行记录行、封卷行、`list` / `history` 的人话输出都是本机本地时间**(给人看的日志行);`--json` 原样给 UTC,由消费方自己转。存量老数据(2026-10-22 前)写的是写入者本地时间、无标记,无法与 UTC 区分。
|
|
28
|
-
- **history 文件
|
|
29
|
-
- **owner / creator 两列存的是 uuid**(与任务里一致,身份只认 uuid
|
|
28
|
+
- **history 文件 11 列**:结案时间 / 创建时间 / 工作包(= `new --title` 的任务标题)/ 分类 / 标签 / owner / owner_name / creator / creator_name / 摘要 / 过程文档;排序与 `--since` / `--until` 按**结案时间**。(`history` 命令的人话输出只展示其中几列:结案时间、负责人、标题。)
|
|
29
|
+
- **owner / creator 两列存的是 uuid**(与任务里一致,身份只认 uuid);紧随其后的 `owner_name` / `creator_name` 两列**落盘**结案那一刻的显示名 —— 改名不回写旧行,看历史看到的就是当时那个名字。老行(升级前落的)没有名字时按名册兜底补一次展示值,**不回写文件**。
|
|
30
30
|
|
|
31
31
|
## 任务号
|
|
32
32
|
|
|
@@ -42,7 +42,7 @@ close → 历史层 + 封卷 + 移出自己名下
|
|
|
42
42
|
|
|
43
43
|
## 历史层
|
|
44
44
|
|
|
45
|
-
- 按**结案时间**分月存放,
|
|
45
|
+
- 按**结案时间**分月存放,11 列(列序:owner 之后是 `owner_name`,再是 creator、`creator_name`);`close` 从任务直写分类 / 标签终值。
|
|
46
46
|
- 存量平铺历史文件只读兼容、永不重写;`history` 连同它在内、按下面的月份区间合并。
|
|
47
47
|
- **查询区间**:`history` 先由时间参数算出要读的月份文件,再逐个「存在才读、缺月跳过」,不扫磁盘。无时间参数默认最近 3 个自然月;`--months N`(近 N 月)与 `--since/--until`(结案时间闭区间)二选一;一次最多读 13 个历史文件(按月分文件,不区分连续/分散,只数总数),超出直接报错、不读任何文件。末列文档链接只按**文件名**消费(取 basename),链接里的 `../worklog/<年月>/` 前缀只为阅读器跳转服务,解析时忽略。
|
|
48
|
-
- `history --json`
|
|
48
|
+
- `history --json` 直接按文件现状解析,旧行没有的列不编造(名字列为空就按名册补展示值,不回写);存量九列的历史文件按 `docs/history-creator-upgrade.md` 一次性升级,脚本不认旧列数。
|
|
@@ -72,7 +72,7 @@ def claim(repo: Path, me: Identity, tid: str) -> Result:
|
|
|
72
72
|
logs = WorklogStore(repo)
|
|
73
73
|
path = logs.find(tid, moved.get("worklog"))
|
|
74
74
|
if path is not None:
|
|
75
|
-
logs.append_exec(path, exec_line(now(), me.
|
|
75
|
+
logs.append_exec(path, exec_line(now(), me.stamp(), "claim", "认领 → doing"))
|
|
76
76
|
note = None
|
|
77
77
|
return Result(
|
|
78
78
|
changed=("owner", "started", "updated"),
|
|
@@ -95,9 +95,9 @@ def close_task(
|
|
|
95
95
|
if doc_path is not None:
|
|
96
96
|
text = logs.read(doc_path)
|
|
97
97
|
if not has_frozen_line(text):
|
|
98
|
-
frozen = stamp_frozen(text, finished, me.
|
|
98
|
+
frozen = stamp_frozen(text, finished, me.stamp())
|
|
99
99
|
frozen = append_exec_log(
|
|
100
|
-
frozen, exec_line(finished, me.
|
|
100
|
+
frozen, exec_line(finished, me.stamp(), "close", "结案封卷")
|
|
101
101
|
)
|
|
102
102
|
logs.write(doc_path, frozen)
|
|
103
103
|
notes.append("补齐上次未完成的封卷。")
|
|
@@ -138,10 +138,14 @@ def close_task(
|
|
|
138
138
|
"title": task.get("title") or "",
|
|
139
139
|
"category": final_category,
|
|
140
140
|
"tags": final_tags,
|
|
141
|
-
#
|
|
142
|
-
#
|
|
141
|
+
# 历史里 uuid 与名字**都落盘**:uuid 是判定键(`@` + 裸 uuid,
|
|
142
|
+
# `--owner` / `--mine` 比它),名字是**结案那一刻的快照** —— 之后改名
|
|
143
|
+
# 不回写旧行,看历史看到的就是当时那个名字。
|
|
144
|
+
# 取值优先用任务行里认领 / 建任务时写下的那份;老任务没有就留空,不编造。
|
|
143
145
|
"owner": me.uuid,
|
|
146
|
+
"owner_name": task.get("owner_name") or me.name,
|
|
144
147
|
"creator": task.get("creator") or me.uuid,
|
|
148
|
+
"creator_name": task.get("creator_name"),
|
|
145
149
|
"summary": text_summary,
|
|
146
150
|
"worklog": task.get("worklog") if doc_path else None,
|
|
147
151
|
},
|
|
@@ -152,9 +156,9 @@ def close_task(
|
|
|
152
156
|
if doc_path is not None:
|
|
153
157
|
text = logs.read(doc_path)
|
|
154
158
|
if not has_frozen_line(text):
|
|
155
|
-
frozen = stamp_frozen(text, finished, me.
|
|
159
|
+
frozen = stamp_frozen(text, finished, me.stamp())
|
|
156
160
|
frozen = append_exec_log(
|
|
157
|
-
frozen, exec_line(finished, me.
|
|
161
|
+
frozen, exec_line(finished, me.stamp(), "close", "结案封卷")
|
|
158
162
|
)
|
|
159
163
|
logs.write(doc_path, frozen)
|
|
160
164
|
|
|
@@ -39,7 +39,9 @@ def handoff(repo: Path, me: Identity, tid: str, *, to: str) -> Result:
|
|
|
39
39
|
" 处理:跑 validate 看台账是不是脏了。"
|
|
40
40
|
)
|
|
41
41
|
to_name = str(record.get("name") or "") or None # 只用于显示
|
|
42
|
-
who = to_name or to_key
|
|
42
|
+
who = to_name or to_key # 给人看:命令输出、提示语
|
|
43
|
+
# 落盘用「名字 · uuid」:接收人拿什么键去分片里找,比执行者更需要 uuid
|
|
44
|
+
who_stamp = Identity(uuid=to_key, name=to_name or "").stamp()
|
|
43
45
|
to_store = TaskStore(repo, shard_rel(to_key))
|
|
44
46
|
moved = dict(task)
|
|
45
47
|
moved["owner"] = to_key
|
|
@@ -58,7 +60,8 @@ def handoff(repo: Path, me: Identity, tid: str, *, to: str) -> Result:
|
|
|
58
60
|
path = logs.find(tid, moved.get("worklog"))
|
|
59
61
|
if path is not None:
|
|
60
62
|
logs.append_exec(
|
|
61
|
-
path,
|
|
63
|
+
path,
|
|
64
|
+
exec_line(now(), me.stamp(), "set", f"交接给 @{who_stamp}(补齐)"),
|
|
62
65
|
)
|
|
63
66
|
return Result(
|
|
64
67
|
already_done=True,
|
|
@@ -76,7 +79,9 @@ def handoff(repo: Path, me: Identity, tid: str, *, to: str) -> Result:
|
|
|
76
79
|
if path is None:
|
|
77
80
|
note = f"任务 {tid} 已交给 {who},但过程文档没找到,跳过执行记录。"
|
|
78
81
|
else:
|
|
79
|
-
logs.append_exec(
|
|
82
|
+
logs.append_exec(
|
|
83
|
+
path, exec_line(now(), me.stamp(), "set", f"交接给 @{who_stamp}")
|
|
84
|
+
)
|
|
80
85
|
return Result(
|
|
81
86
|
changed=("owner", "owner_name", "updated"),
|
|
82
87
|
where=rel(repo, to_store.path),
|
|
@@ -46,7 +46,7 @@ def log_progress(repo: Path, me: Identity, tid: str, note: str) -> Result:
|
|
|
46
46
|
"提示:这条任务没有过程文档,已跳过执行记录(分片的最后更新已刷)。",
|
|
47
47
|
),
|
|
48
48
|
)
|
|
49
|
-
ok = logs.append_exec(path, exec_line(now(), me.
|
|
49
|
+
ok = logs.append_exec(path, exec_line(now(), me.stamp(), "log", text))
|
|
50
50
|
if not ok:
|
|
51
51
|
return Result(
|
|
52
52
|
changed=("updated",),
|
|
@@ -99,11 +99,11 @@ def new_task(
|
|
|
99
99
|
tid=tid,
|
|
100
100
|
title=clean_title,
|
|
101
101
|
created=created,
|
|
102
|
-
creator=me.
|
|
102
|
+
creator=me.stamp(),
|
|
103
103
|
category=category,
|
|
104
104
|
tags=tag_list,
|
|
105
105
|
first_exec=exec_line(
|
|
106
|
-
created, me.
|
|
106
|
+
created, me.stamp(), "new", "创建任务并认领" if mine else "创建任务"
|
|
107
107
|
),
|
|
108
108
|
)
|
|
109
109
|
target.put(tid, task)
|
|
@@ -143,16 +143,23 @@ def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
|
|
|
143
143
|
key = normalize(q.owner)
|
|
144
144
|
records = [r for r in records if normalize(r.get("owner") or "") == key]
|
|
145
145
|
if q.owner_name:
|
|
146
|
-
#
|
|
147
|
-
|
|
146
|
+
# 名字已经落在历史行里 ⇒ 直接比它(子串、不分大小写)。落的是**当时**那个名字,
|
|
147
|
+
# 改名后的旧行仍留旧名 ⇒ 按新名筛不到旧行,这是快照语义,不是漏。
|
|
148
|
+
# 老行(升级前落的)没有名字列,退回按 uuid 片段比,不至于一条都筛不出来。
|
|
149
|
+
needle = q.owner_name.strip().lower()
|
|
148
150
|
records = [
|
|
149
|
-
r
|
|
151
|
+
r
|
|
152
|
+
for r in records
|
|
153
|
+
if needle in str(r.get("owner_name") or "").lower()
|
|
154
|
+
or needle in normalize(r.get("owner") or "")
|
|
150
155
|
]
|
|
151
156
|
|
|
152
|
-
#
|
|
153
|
-
#
|
|
157
|
+
# 名字**已经落盘**(`owner_name` / `creator_name` 两列)。这里只给没名字的**老行**
|
|
158
|
+
# 按名册兜底补一次 —— 补的是读时展示值,不回写文件。
|
|
154
159
|
for row in records:
|
|
155
160
|
for field, target in (("owner", "owner_name"), ("creator", "creator_name")):
|
|
161
|
+
if row.get(target):
|
|
162
|
+
continue
|
|
156
163
|
key = normalize(row.get(field) or "")
|
|
157
164
|
name = str((roster.get(key) or {}).get("name") or "")
|
|
158
165
|
if name:
|
|
@@ -63,7 +63,7 @@ def release(repo: Path, me: Identity, tid: str) -> Result:
|
|
|
63
63
|
if path is None:
|
|
64
64
|
note = f"任务 {tid} 已退回公共池,但过程文档没找到,跳过执行记录。"
|
|
65
65
|
else:
|
|
66
|
-
logs.append_exec(path, exec_line(now(), me.
|
|
66
|
+
logs.append_exec(path, exec_line(now(), me.stamp(), "release", "释放回公共池"))
|
|
67
67
|
return Result(
|
|
68
68
|
changed=("owner", "owner_name", "status", "updated"),
|
|
69
69
|
where=rel(repo, pool.path),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""历史表格的文本操作:拼十一列一行、解析十一列一行。
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**只认十一列这一种格式**:存量九列的文件按 `docs/history-creator-upgrade.md`
|
|
4
|
+
那份手册一次性升级,脚本不做运行时迁移。
|
|
4
5
|
"""
|
|
5
6
|
|
|
6
7
|
from __future__ import annotations
|
|
@@ -13,8 +14,11 @@ from .paths import to_history_rel
|
|
|
13
14
|
from .time import TIME_RE, time_key
|
|
14
15
|
|
|
15
16
|
HISTORY_TITLE = "# 进度历史(已结案)"
|
|
16
|
-
HISTORY_HEADER =
|
|
17
|
-
|
|
17
|
+
HISTORY_HEADER = (
|
|
18
|
+
"| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 "
|
|
19
|
+
"| owner | owner_name | creator | creator_name | 完成了什么 | 过程文档 |"
|
|
20
|
+
)
|
|
21
|
+
HISTORY_SEP = "|---|---|---|---|---|---|---|---|---|---|---|"
|
|
18
22
|
|
|
19
23
|
_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]*)\)")
|
|
20
24
|
_TAG_SPLIT_RE = re.compile(r"[,,、]")
|
|
@@ -65,10 +69,14 @@ def history_line(
|
|
|
65
69
|
category: str | None = None,
|
|
66
70
|
tags: Iterable[str] | None = None,
|
|
67
71
|
creator: str | None = None,
|
|
72
|
+
owner_name: str | None = None,
|
|
73
|
+
creator_name: str | None = None,
|
|
68
74
|
) -> str:
|
|
69
|
-
"""
|
|
75
|
+
"""十一列的一行。`worklog` 收**相对 `ledger/`** 的路径,这里负责换成相对 `history/` 的链接。
|
|
70
76
|
|
|
71
77
|
owner / creator 传**裸 uuid**(`@` 由 owner_cell / creator_cell 加),外面别先加。
|
|
78
|
+
`owner_name` / `creator_name` 是**落盘的显示名快照**(结案那一刻的名字,之后改名不改旧行),
|
|
79
|
+
原值写入、**不加 `@`** —— `@` 只属于 uuid 那两列。
|
|
72
80
|
"""
|
|
73
81
|
log_rel = to_history_rel(worklog) if worklog else ""
|
|
74
82
|
tag_cell = esc_cell("、".join(tags) if tags else "")
|
|
@@ -76,16 +84,18 @@ def history_line(
|
|
|
76
84
|
return (
|
|
77
85
|
f"| {finished} | {created or ''} | {esc_cell(title)} "
|
|
78
86
|
f"| {esc_cell(category or '')} | {tag_cell} "
|
|
79
|
-
f"| {esc_cell(owner_cell(owner))} | {esc_cell(
|
|
87
|
+
f"| {esc_cell(owner_cell(owner))} | {esc_cell(owner_name or '')} "
|
|
88
|
+
f"| {esc_cell(creator_cell(creator))} | {esc_cell(creator_name or '')} "
|
|
80
89
|
f"| {esc_cell(summary)} | {link} |\n"
|
|
81
90
|
)
|
|
82
91
|
|
|
83
92
|
|
|
84
93
|
def parse_history(text: str) -> tuple[list[Row], list[str]]:
|
|
85
|
-
"""
|
|
94
|
+
"""解析十一列历史表 → (records, warnings)。坏行(列数不对 / 时间列不合法)跳过并警告。
|
|
86
95
|
|
|
87
|
-
返回的键:finished / created / title / category / tags / owner /
|
|
88
|
-
worklog / fmt / line。owner 与 creator
|
|
96
|
+
返回的键:finished / created / title / category / tags / owner / owner_name /
|
|
97
|
+
creator / creator_name / summary / worklog / fmt / line。owner 与 creator 一律
|
|
98
|
+
**剥掉 `@`**(存储值是裸 uuid);两个名字列是**原值**(落盘快照,可能为空)。
|
|
89
99
|
"""
|
|
90
100
|
records: list[Row] = []
|
|
91
101
|
warnings: list[str] = []
|
|
@@ -95,12 +105,22 @@ def parse_history(text: str) -> tuple[list[Row], list[str]]:
|
|
|
95
105
|
cells = split_row(raw)
|
|
96
106
|
if not cells or is_sep_row(cells) or _looks_like_header(cells):
|
|
97
107
|
continue
|
|
98
|
-
if len(cells) !=
|
|
99
|
-
warnings.append(f"第 {lineno}
|
|
108
|
+
if len(cells) != 11:
|
|
109
|
+
warnings.append(f"第 {lineno} 行不是十一列(历史表只认十一列),已跳过")
|
|
100
110
|
continue
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
111
|
+
(
|
|
112
|
+
finished,
|
|
113
|
+
created,
|
|
114
|
+
title,
|
|
115
|
+
category,
|
|
116
|
+
tag_cell,
|
|
117
|
+
owner,
|
|
118
|
+
owner_name,
|
|
119
|
+
creator,
|
|
120
|
+
creator_name,
|
|
121
|
+
summary,
|
|
122
|
+
doc,
|
|
123
|
+
) = cells
|
|
104
124
|
if not TIME_RE.fullmatch(finished):
|
|
105
125
|
warnings.append(f"第 {lineno} 行时间列 {finished!r} 不合法,已跳过")
|
|
106
126
|
continue
|
|
@@ -115,10 +135,12 @@ def parse_history(text: str) -> tuple[list[Row], list[str]]:
|
|
|
115
135
|
t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()
|
|
116
136
|
),
|
|
117
137
|
"owner": owner.strip().removeprefix("@") or None,
|
|
138
|
+
"owner_name": owner_name.strip() or None,
|
|
118
139
|
"creator": creator.strip().removeprefix("@") or None,
|
|
140
|
+
"creator_name": creator_name.strip() or None,
|
|
119
141
|
"summary": summary,
|
|
120
142
|
"worklog": (m.group(1) if m else doc.strip()) or None,
|
|
121
|
-
"fmt": "
|
|
143
|
+
"fmt": "v11",
|
|
122
144
|
"line": lineno,
|
|
123
145
|
}
|
|
124
146
|
)
|
|
@@ -21,9 +21,23 @@ class Identity:
|
|
|
21
21
|
name: str = ""
|
|
22
22
|
|
|
23
23
|
def label(self) -> str:
|
|
24
|
-
"""显示名:没名字就用 uuid 前 8
|
|
24
|
+
"""显示名:没名字就用 uuid 前 8 位。只给人看(报错文案、界面)。"""
|
|
25
25
|
return self.name or self.uuid[:8]
|
|
26
26
|
|
|
27
|
+
def stamp(self) -> str:
|
|
28
|
+
"""**落盘**用的身份串:`名字 · uuid` —— 名字在前给人读,uuid 在后给机器对。
|
|
29
|
+
|
|
30
|
+
与 `label()` 一样**不带 `@`**(`@` 由各落点自己加:模板创建行是 `(@<creator>)`、
|
|
31
|
+
`exec_line` 拼 ` · @{who} · `、`stamp_frozen` 拼 `(@{who})`)。
|
|
32
|
+
|
|
33
|
+
过程文档(创建行 / 封卷行 / 执行记录)是这个串唯一的落点:文件是最终产物,
|
|
34
|
+
创建人和封卷人可能不是同一个人、也可能中途改过名,所以**两个都要留**,不靠
|
|
35
|
+
历史表回链。没名字就只写 uuid。
|
|
36
|
+
"""
|
|
37
|
+
if not self.name:
|
|
38
|
+
return self.uuid
|
|
39
|
+
return f"{self.name} · {self.uuid}"
|
|
40
|
+
|
|
27
41
|
|
|
28
42
|
def normalize(who: str) -> str:
|
|
29
43
|
"""归一化:去首尾空白 + 转小写。全仓只此一处。"""
|
|
@@ -10,6 +10,7 @@ from collections.abc import Iterable
|
|
|
10
10
|
from itertools import takewhile
|
|
11
11
|
|
|
12
12
|
from ..errors import Fail
|
|
13
|
+
from .identity import UUID_RE
|
|
13
14
|
from .labels import label_line, parse_label_line
|
|
14
15
|
from .time import fmt_local, normalize_time
|
|
15
16
|
|
|
@@ -21,7 +22,9 @@ EXEC_HINT = (
|
|
|
21
22
|
FROZEN_MARK = "✅ 完成封卷"
|
|
22
23
|
FROZEN_RE = re.compile(r"^>\s*" + FROZEN_MARK, re.MULTILINE)
|
|
23
24
|
CREATED_RE = re.compile(r"^\s*>\s*\*\*创建\*\*")
|
|
24
|
-
# 头部「创建」行:`> **创建**:2026-10-06 00:00
|
|
25
|
+
# 头部「创建」行:`> **创建**:2026-10-06 00:00(@某人 · uuid)` —— 创建时间与发起人两个来源。
|
|
26
|
+
# 括号里落 `Identity.stamp()`(`@名字 · uuid`):名字在前给人读,uuid 在后给机器对。
|
|
27
|
+
# 老文档只有名字(没有 uuid)时,下面两个函数各自返回它能读到的那一段,不报错。
|
|
25
28
|
CREATED_AT_RE = re.compile(
|
|
26
29
|
r"^\s*>\s*\*\*创建\*\*\s*[::]\s*"
|
|
27
30
|
r"(\d{4}-\d{2}-\d{2}(?: +\d{1,2}:\d{1,2}(?::\d{1,2})?)?)"
|
|
@@ -224,9 +227,26 @@ def head_created(text: str) -> str | None:
|
|
|
224
227
|
|
|
225
228
|
|
|
226
229
|
def head_creator(text: str) -> str | None:
|
|
227
|
-
"""
|
|
230
|
+
"""头部「创建」行括号里的发起人**名字**(不带 @、不带 uuid);读不到返回 None。"""
|
|
228
231
|
for line in text.splitlines():
|
|
229
232
|
m = CREATOR_RE.match(line)
|
|
230
233
|
if m and m.group(1).strip():
|
|
231
|
-
return m.group(1).strip()
|
|
234
|
+
return m.group(1).split(" · ")[0].strip()
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def head_creator_uuid(text: str) -> str | None:
|
|
239
|
+
"""头部「创建」行括号里的发起人 **uuid**;老文档没落 uuid 时返回 None。
|
|
240
|
+
|
|
241
|
+
`UUID_RE` 带 `^$` 锚点,只能整串匹配 —— 所以先按 ` · ` 切出尾段再判,不能 search。
|
|
242
|
+
"""
|
|
243
|
+
for line in text.splitlines():
|
|
244
|
+
m = CREATOR_RE.match(line)
|
|
245
|
+
if not m:
|
|
246
|
+
continue
|
|
247
|
+
parts = m.group(1).split(" · ")
|
|
248
|
+
if len(parts) > 1:
|
|
249
|
+
uuid = parts[-1].strip()
|
|
250
|
+
if UUID_RE.fullmatch(uuid):
|
|
251
|
+
return uuid
|
|
232
252
|
return None
|
|
@@ -271,6 +271,7 @@ def _lock_dir(repo: Path) -> Path:
|
|
|
271
271
|
|
|
272
272
|
def _run_init(args: argparse.Namespace) -> int:
|
|
273
273
|
"""init 不进 runner、不解析台账、不碰 git:只写机器身份文件。"""
|
|
274
|
+
from .. import env
|
|
274
275
|
from ..actions.init_identity import init_identity
|
|
275
276
|
|
|
276
277
|
repo: Path | None = None
|
|
@@ -279,13 +280,23 @@ def _run_init(args: argparse.Namespace) -> int:
|
|
|
279
280
|
repo = as_root(raw, source="--repo") if raw else root_of(Path.cwd().resolve())
|
|
280
281
|
except Fail:
|
|
281
282
|
repo = None
|
|
283
|
+
claim = getattr(args, "claim", None)
|
|
284
|
+
new = bool(getattr(args, "new_id", False))
|
|
285
|
+
name = getattr(args, "name", "") or ""
|
|
282
286
|
try:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
287
|
+
# 首次写身份只能由用户在真终端交互完成:agent 的 subprocess 没有 TTY,
|
|
288
|
+
# 放行就等于让它替用户挑一个身份写进本机(2026-10-26 事故)。
|
|
289
|
+
if (
|
|
290
|
+
env.read_identity() is None
|
|
291
|
+
and (claim or new or name.strip())
|
|
292
|
+
and not sys.stdin.isatty()
|
|
293
|
+
):
|
|
294
|
+
raise Fail(
|
|
295
|
+
"写入身份必须由用户在终端里交互执行(当前没有终端)。\n"
|
|
296
|
+
" 处理:请用户在终端运行 `python3 progress.py init --repo <工作区根>`,\n"
|
|
297
|
+
" 它会列出已有身份供选择,或让 ta 输入一个新名字。"
|
|
298
|
+
)
|
|
299
|
+
res = init_identity(repo, claim=claim, new=new, name=name)
|
|
289
300
|
except Fail as exc:
|
|
290
301
|
render.fail_message(exc)
|
|
291
302
|
return BUSINESS
|
|
@@ -99,9 +99,11 @@ class HistoryStore:
|
|
|
99
99
|
row.get("owner"),
|
|
100
100
|
row.get("summary") or "",
|
|
101
101
|
row.get("worklog"),
|
|
102
|
-
row.get("category"),
|
|
103
|
-
row.get("tags"),
|
|
104
|
-
row.get("creator"),
|
|
102
|
+
category=row.get("category"),
|
|
103
|
+
tags=row.get("tags"),
|
|
104
|
+
creator=row.get("creator"),
|
|
105
|
+
owner_name=row.get("owner_name"),
|
|
106
|
+
creator_name=row.get("creator_name"),
|
|
105
107
|
)
|
|
106
108
|
return {"text": _append_line(text, line), "duplicate": duplicate}
|
|
107
109
|
|
|
@@ -117,7 +119,7 @@ def _name_of(worklog: str | None) -> str | None:
|
|
|
117
119
|
|
|
118
120
|
|
|
119
121
|
def _fresh_table() -> str:
|
|
120
|
-
"""
|
|
122
|
+
"""当月第一次结案:按十一列表头起表(表头的真源只有 `history_md` 一处)。"""
|
|
121
123
|
return (
|
|
122
124
|
f"{history_md.HISTORY_TITLE}\n\n"
|
|
123
125
|
f"{history_md.HISTORY_HEADER}\n{history_md.HISTORY_SEP}\n"
|