task-ledger 1.3.4 → 1.3.6
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 +1 -1
- package/SKILL.md +30 -17
- package/package.json +1 -1
- package/references/state-model.md +12 -10
- package/references/troubleshooting.md +19 -7
- package/scripts/ledgerkit/actions/atom.py +2 -2
- package/scripts/ledgerkit/actions/bootstrap.py +9 -2
- package/scripts/ledgerkit/actions/claim.py +7 -6
- package/scripts/ledgerkit/actions/close_task.py +17 -12
- package/scripts/ledgerkit/actions/guard.py +38 -16
- package/scripts/ledgerkit/actions/handoff.py +24 -8
- package/scripts/ledgerkit/actions/init_identity.py +90 -0
- package/scripts/ledgerkit/actions/list_tasks.py +21 -3
- package/scripts/ledgerkit/actions/log_progress.py +5 -2
- package/scripts/ledgerkit/actions/new_task.py +11 -7
- package/scripts/ledgerkit/actions/query.py +4 -2
- package/scripts/ledgerkit/actions/query_history.py +55 -9
- package/scripts/ledgerkit/actions/registry.py +1 -1
- package/scripts/ledgerkit/actions/release.py +25 -4
- package/scripts/ledgerkit/actions/set_task.py +18 -7
- package/scripts/ledgerkit/actions/validate.py +0 -4
- package/scripts/ledgerkit/core/history_md.py +47 -250
- package/scripts/ledgerkit/core/identity.py +34 -7
- package/scripts/ledgerkit/core/labels.py +0 -1
- package/scripts/ledgerkit/core/paths.py +0 -1
- package/scripts/ledgerkit/core/worklog_md.py +23 -3
- package/scripts/ledgerkit/env.py +70 -71
- package/scripts/ledgerkit/shell/cli.py +60 -3
- package/scripts/ledgerkit/shell/gitops.py +14 -1
- package/scripts/ledgerkit/shell/render.py +3 -2
- package/scripts/ledgerkit/shell/runner.py +1 -1
- package/scripts/ledgerkit/store/history_store.py +26 -81
- package/scripts/ledgerkit/store/owners_index.py +16 -3
- package/scripts/ledgerkit/store/task_store.py +2 -2
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""历史表格的文本操作:拼十一列一行、解析十一列一行。
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
**只认十一列这一种格式**:存量九列的文件按 `docs/history-creator-upgrade.md`
|
|
4
|
+
那份手册一次性升级,脚本不做运行时迁移。
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import re
|
|
10
|
-
from collections.abc import
|
|
11
|
-
from pathlib import Path
|
|
10
|
+
from collections.abc import Iterable
|
|
12
11
|
from typing import Any
|
|
13
12
|
|
|
14
13
|
from .paths import to_history_rel
|
|
15
14
|
from .time import TIME_RE, time_key
|
|
16
15
|
|
|
17
16
|
HISTORY_TITLE = "# 进度历史(已结案)"
|
|
18
|
-
HISTORY_HEADER =
|
|
19
|
-
|
|
17
|
+
HISTORY_HEADER = (
|
|
18
|
+
"| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 "
|
|
19
|
+
"| owner | owner_name | creator | creator_name | 完成了什么 | 过程文档 |"
|
|
20
|
+
)
|
|
21
|
+
HISTORY_SEP = "|---|---|---|---|---|---|---|---|---|---|---|"
|
|
20
22
|
|
|
21
|
-
# 最旧格式把 owner 缝在工作包末尾:最右一个「空格 + @」之后整段是 owner。
|
|
22
|
-
OLD_OWNER_RE = re.compile(r"^(?P<title>.*\S)\s+@(?P<owner>\S.*)$")
|
|
23
23
|
_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]*)\)")
|
|
24
24
|
_TAG_SPLIT_RE = re.compile(r"[,,、]")
|
|
25
25
|
|
|
@@ -32,7 +32,7 @@ def esc_cell(text: str) -> str:
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def owner_cell(owner: str | None) -> str:
|
|
35
|
-
"""owner 列的展示值:人名前加 @(`@`
|
|
35
|
+
"""owner 列的展示值:人名前加 @(`@` 只是展示约定,匹配一律用裸 uuid)。"""
|
|
36
36
|
return f"@{owner}" if owner else ""
|
|
37
37
|
|
|
38
38
|
|
|
@@ -56,7 +56,6 @@ def is_sep_row(cells: list[str]) -> bool:
|
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
def _looks_like_header(cells: list[str]) -> bool:
|
|
59
|
-
# 按内容判、不按位置判:无表头的老文件首行就是数据,按位置判会把第一条记录当表头删掉。
|
|
60
59
|
return bool(cells) and cells[0] in ("时间", "结案时间")
|
|
61
60
|
|
|
62
61
|
|
|
@@ -70,10 +69,14 @@ def history_line(
|
|
|
70
69
|
category: str | None = None,
|
|
71
70
|
tags: Iterable[str] | None = None,
|
|
72
71
|
creator: str | None = None,
|
|
72
|
+
owner_name: str | None = None,
|
|
73
|
+
creator_name: str | None = None,
|
|
73
74
|
) -> str:
|
|
74
|
-
"""
|
|
75
|
+
"""十一列的一行。`worklog` 收**相对 `ledger/`** 的路径,这里负责换成相对 `history/` 的链接。
|
|
75
76
|
|
|
76
|
-
owner / creator
|
|
77
|
+
owner / creator 传**裸 uuid**(`@` 由 owner_cell / creator_cell 加),外面别先加。
|
|
78
|
+
`owner_name` / `creator_name` 是**落盘的显示名快照**(结案那一刻的名字,之后改名不改旧行),
|
|
79
|
+
原值写入、**不加 `@`** —— `@` 只属于 uuid 那两列。
|
|
77
80
|
"""
|
|
78
81
|
log_rel = to_history_rel(worklog) if worklog else ""
|
|
79
82
|
tag_cell = esc_cell("、".join(tags) if tags else "")
|
|
@@ -81,71 +84,18 @@ def history_line(
|
|
|
81
84
|
return (
|
|
82
85
|
f"| {finished} | {created or ''} | {esc_cell(title)} "
|
|
83
86
|
f"| {esc_cell(category or '')} | {tag_cell} "
|
|
84
|
-
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 '')} "
|
|
85
89
|
f"| {esc_cell(summary)} | {link} |\n"
|
|
86
90
|
)
|
|
87
91
|
|
|
88
92
|
|
|
89
|
-
def history_line_for_cols(
|
|
90
|
-
cols: int,
|
|
91
|
-
finished: str,
|
|
92
|
-
created: str | None,
|
|
93
|
-
title: str,
|
|
94
|
-
owner: str | None,
|
|
95
|
-
summary: str,
|
|
96
|
-
worklog: str | None,
|
|
97
|
-
category: str | None = None,
|
|
98
|
-
tags: Iterable[str] | None = None,
|
|
99
|
-
creator: str | None = None,
|
|
100
|
-
) -> str:
|
|
101
|
-
"""「历史层有无法解析的行、不敢整体升级」时按现有列数追加一行,避免列错位。
|
|
102
|
-
|
|
103
|
-
GFM 按首行列数渲染,列数写错会被静默丢弃 / 错位。少列的文件硬塞 9 列会把整行挤歪。
|
|
104
|
-
"""
|
|
105
|
-
log_rel = to_history_rel(worklog) if worklog else ""
|
|
106
|
-
link = f"[{log_rel}]({log_rel})" if log_rel else ""
|
|
107
|
-
if cols <= 4:
|
|
108
|
-
who = f" @{owner}" if owner else ""
|
|
109
|
-
return (
|
|
110
|
-
f"| {finished} | {esc_cell(title + who)} | {esc_cell(summary)} | {link} |\n"
|
|
111
|
-
)
|
|
112
|
-
if cols == 5:
|
|
113
|
-
return (
|
|
114
|
-
f"| {finished} | {esc_cell(title)} | {esc_cell(owner_cell(owner))} "
|
|
115
|
-
f"| {esc_cell(summary)} | {link} |\n"
|
|
116
|
-
)
|
|
117
|
-
if cols == 6:
|
|
118
|
-
return (
|
|
119
|
-
f"| {finished} | {created or ''} | {esc_cell(title)} "
|
|
120
|
-
f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | {link} |\n"
|
|
121
|
-
)
|
|
122
|
-
return history_line(
|
|
123
|
-
finished, created, title, owner, summary, worklog, category, tags, creator
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def history_cols(text: str) -> int:
|
|
128
|
-
"""现有历史表格数据行的列数(取众数、平局取较大);无数据行时按 6。"""
|
|
129
|
-
counts: dict[int, int] = {}
|
|
130
|
-
for raw in text.splitlines():
|
|
131
|
-
if not raw.strip().startswith("|"):
|
|
132
|
-
continue
|
|
133
|
-
cells = split_row(raw)
|
|
134
|
-
if not cells or is_sep_row(cells) or _looks_like_header(cells):
|
|
135
|
-
continue
|
|
136
|
-
if len(cells) >= 4:
|
|
137
|
-
counts[len(cells)] = counts.get(len(cells), 0) + 1
|
|
138
|
-
if not counts:
|
|
139
|
-
return 6
|
|
140
|
-
return max(counts, key=lambda k: (counts[k], k))
|
|
141
|
-
|
|
142
|
-
|
|
143
93
|
def parse_history(text: str) -> tuple[list[Row], list[str]]:
|
|
144
|
-
"""
|
|
94
|
+
"""解析十一列历史表 → (records, warnings)。坏行(列数不对 / 时间列不合法)跳过并警告。
|
|
145
95
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
96
|
+
返回的键:finished / created / title / category / tags / owner / owner_name /
|
|
97
|
+
creator / creator_name / summary / worklog / fmt / line。owner 与 creator 一律
|
|
98
|
+
**剥掉 `@`**(存储值是裸 uuid);两个名字列是**原值**(落盘快照,可能为空)。
|
|
149
99
|
"""
|
|
150
100
|
records: list[Row] = []
|
|
151
101
|
warnings: list[str] = []
|
|
@@ -155,196 +105,43 @@ def parse_history(text: str) -> tuple[list[Row], list[str]]:
|
|
|
155
105
|
cells = split_row(raw)
|
|
156
106
|
if not cells or is_sep_row(cells) or _looks_like_header(cells):
|
|
157
107
|
continue
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
tags: list[str] = []
|
|
161
|
-
creator: str | None = None
|
|
162
|
-
if len(cells) == 9:
|
|
163
|
-
(
|
|
164
|
-
finished,
|
|
165
|
-
created,
|
|
166
|
-
title,
|
|
167
|
-
category,
|
|
168
|
-
tag_cell,
|
|
169
|
-
owner,
|
|
170
|
-
creator,
|
|
171
|
-
summary,
|
|
172
|
-
doc,
|
|
173
|
-
) = cells
|
|
174
|
-
fmt = "v9"
|
|
175
|
-
category = category.strip() or None
|
|
176
|
-
tags = [t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()]
|
|
177
|
-
creator = creator.strip().removeprefix("@") or None
|
|
178
|
-
elif len(cells) == 8:
|
|
179
|
-
finished, created, title, category, tag_cell, owner, summary, doc = cells
|
|
180
|
-
fmt = "v8"
|
|
181
|
-
category = category.strip() or None
|
|
182
|
-
tags = [t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()]
|
|
183
|
-
elif len(cells) == 6:
|
|
184
|
-
finished, created, title, owner, summary, doc = cells
|
|
185
|
-
fmt = "v6"
|
|
186
|
-
elif len(cells) == 5 and TIME_RE.fullmatch(cells[1]):
|
|
187
|
-
# 旧 ledger 格式(结案 / 创建 / 工作包 / 摘要 / 文档):行内自带创建时间。
|
|
188
|
-
finished, created, title, summary, doc = cells
|
|
189
|
-
owner = ""
|
|
190
|
-
fmt = "v5ledger"
|
|
191
|
-
elif len(cells) == 5:
|
|
192
|
-
finished, title, owner, summary, doc = cells
|
|
193
|
-
fmt = "v5"
|
|
194
|
-
elif len(cells) == 4:
|
|
195
|
-
finished, title, summary, doc = cells
|
|
196
|
-
m = OLD_OWNER_RE.match(title)
|
|
197
|
-
owner = m.group("owner") if m else ""
|
|
198
|
-
if m:
|
|
199
|
-
title = m.group("title")
|
|
200
|
-
fmt = "v4"
|
|
201
|
-
else:
|
|
202
|
-
warnings.append(f"第 {lineno} 行列数不是 4 / 5 / 6 / 8 / 9,已跳过")
|
|
108
|
+
if len(cells) != 11:
|
|
109
|
+
warnings.append(f"第 {lineno} 行不是十一列(历史表只认十一列),已跳过")
|
|
203
110
|
continue
|
|
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
|
|
204
124
|
if not TIME_RE.fullmatch(finished):
|
|
205
125
|
warnings.append(f"第 {lineno} 行时间列 {finished!r} 不合法,已跳过")
|
|
206
126
|
continue
|
|
207
127
|
m = _LINK_RE.fullmatch(doc.strip())
|
|
208
|
-
log = m.group(1) if m else doc.strip()
|
|
209
128
|
records.append(
|
|
210
129
|
{
|
|
211
130
|
"finished": finished,
|
|
212
131
|
"created": time_key(created) if created else None,
|
|
213
132
|
"title": title,
|
|
214
|
-
"category": category,
|
|
215
|
-
"tags": tuple(
|
|
216
|
-
|
|
217
|
-
|
|
133
|
+
"category": category.strip() or None,
|
|
134
|
+
"tags": tuple(
|
|
135
|
+
t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()
|
|
136
|
+
),
|
|
137
|
+
"owner": owner.strip().removeprefix("@") or None,
|
|
138
|
+
"owner_name": owner_name.strip() or None,
|
|
139
|
+
"creator": creator.strip().removeprefix("@") or None,
|
|
140
|
+
"creator_name": creator_name.strip() or None,
|
|
218
141
|
"summary": summary,
|
|
219
|
-
"worklog":
|
|
220
|
-
"fmt":
|
|
142
|
+
"worklog": (m.group(1) if m else doc.strip()) or None,
|
|
143
|
+
"fmt": "v11",
|
|
221
144
|
"line": lineno,
|
|
222
145
|
}
|
|
223
146
|
)
|
|
224
147
|
return records, warnings
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def _drop_trailing_blank(out: list[str]) -> None:
|
|
228
|
-
"""去掉已在结果里的尾部空行(丢弃重复表头时用):中间的空行会把 GFM 表格截断。"""
|
|
229
|
-
while out and not out[-1].strip():
|
|
230
|
-
out.pop()
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def upgrade_history(
|
|
234
|
-
text: str,
|
|
235
|
-
*,
|
|
236
|
-
read_worklog: Callable[[str], dict[str, Any] | None],
|
|
237
|
-
fallback_created: str,
|
|
238
|
-
) -> tuple[str, int] | None:
|
|
239
|
-
"""旧格式(4 / 5 / 6 / 8 列)→ v9。**全部数据行可解析才返回 (新文本, 转换行数)**,否则 None。
|
|
240
|
-
|
|
241
|
-
`None` 的语义是「不敢动」:调用方必须退回 `history_line_for_cols` 按原列数追加并明示,
|
|
242
|
-
绝不半升级、绝不猜。分类 / 标签 / creator / 缺的创建时间都靠 `read_worklog` 回链补,
|
|
243
|
-
读不到就留空(创建时间除外:留空会让排序失效,所以退化到 `fallback_created`)。
|
|
244
|
-
|
|
245
|
-
`read_worklog` 的契约:`(log_name: str) -> dict | None` —— 收**文件名**(历史末列的
|
|
246
|
-
basename,不是路径),返回 `{"created", "creator", "category", "tags"}`。core 只消费
|
|
247
|
-
返回值,自己不开文件。
|
|
248
|
-
"""
|
|
249
|
-
out: list[str] = []
|
|
250
|
-
converted = 0
|
|
251
|
-
seen_header = False
|
|
252
|
-
seen_sep = False
|
|
253
|
-
for raw in text.splitlines():
|
|
254
|
-
if not raw.strip().startswith("|"):
|
|
255
|
-
out.append(raw)
|
|
256
|
-
continue
|
|
257
|
-
cells = split_row(raw)
|
|
258
|
-
if not cells:
|
|
259
|
-
out.append(raw)
|
|
260
|
-
continue
|
|
261
|
-
if is_sep_row(cells):
|
|
262
|
-
if seen_sep:
|
|
263
|
-
_drop_trailing_blank(out)
|
|
264
|
-
continue
|
|
265
|
-
seen_sep = True
|
|
266
|
-
out.append(HISTORY_SEP)
|
|
267
|
-
continue
|
|
268
|
-
if _looks_like_header(cells):
|
|
269
|
-
if seen_header:
|
|
270
|
-
_drop_trailing_blank(out)
|
|
271
|
-
continue
|
|
272
|
-
seen_header = True
|
|
273
|
-
out.append(HISTORY_HEADER)
|
|
274
|
-
continue
|
|
275
|
-
if len(cells) == 9:
|
|
276
|
-
if time_key(cells[0]) is None:
|
|
277
|
-
return None
|
|
278
|
-
out.append("| " + " | ".join(cells) + " |")
|
|
279
|
-
continue
|
|
280
|
-
explicit_created: str | None = None
|
|
281
|
-
creator: str | None = None
|
|
282
|
-
if len(cells) == 8:
|
|
283
|
-
finished, created, title, category, tag_cell, owner, summary, doc = cells
|
|
284
|
-
if time_key(finished) is None:
|
|
285
|
-
return None
|
|
286
|
-
created_key = time_key(created.strip())
|
|
287
|
-
tags = [t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()]
|
|
288
|
-
creator = (_meta_of(doc, read_worklog) or {}).get("creator")
|
|
289
|
-
out.append(
|
|
290
|
-
f"| {finished} | {created_key or ''} | {esc_cell(title)} "
|
|
291
|
-
f"| {esc_cell(category.strip())} "
|
|
292
|
-
f"| {esc_cell('、'.join(tags) if tags else '')} "
|
|
293
|
-
f"| {esc_cell(owner_cell(owner.lstrip('@')))} "
|
|
294
|
-
f"| {esc_cell(creator_cell(creator))} "
|
|
295
|
-
f"| {esc_cell(summary)} | {doc} |"
|
|
296
|
-
)
|
|
297
|
-
converted += 1
|
|
298
|
-
continue
|
|
299
|
-
if len(cells) == 6:
|
|
300
|
-
finished, created, title, owner, summary, doc = cells
|
|
301
|
-
owner = owner.lstrip("@")
|
|
302
|
-
explicit_created = time_key(created.strip())
|
|
303
|
-
elif len(cells) == 5 and TIME_RE.fullmatch(cells[1]):
|
|
304
|
-
finished, created_cell, title, summary, doc = cells
|
|
305
|
-
owner = ""
|
|
306
|
-
explicit_created = time_key(created_cell.strip())
|
|
307
|
-
elif len(cells) == 5:
|
|
308
|
-
finished, title, owner, summary, doc = cells
|
|
309
|
-
owner = owner.lstrip("@")
|
|
310
|
-
elif len(cells) == 4:
|
|
311
|
-
finished, title, summary, doc = cells
|
|
312
|
-
m = OLD_OWNER_RE.match(title)
|
|
313
|
-
owner = m.group("owner") if m else ""
|
|
314
|
-
if m:
|
|
315
|
-
title = m.group("title")
|
|
316
|
-
else:
|
|
317
|
-
return None
|
|
318
|
-
finished_key = time_key(finished)
|
|
319
|
-
if finished_key is None:
|
|
320
|
-
return None
|
|
321
|
-
meta = _meta_of(doc, read_worklog)
|
|
322
|
-
created = explicit_created or (meta or {}).get("created") or fallback_created
|
|
323
|
-
category = (meta or {}).get("category")
|
|
324
|
-
tags = tuple((meta or {}).get("tags") or ())
|
|
325
|
-
creator = (meta or {}).get("creator")
|
|
326
|
-
out.append(
|
|
327
|
-
f"| {finished_key} | {created} | {esc_cell(title)} "
|
|
328
|
-
f"| {esc_cell(category or '')} "
|
|
329
|
-
f"| {esc_cell('、'.join(tags) if tags else '')} "
|
|
330
|
-
f"| {esc_cell(owner_cell(owner))} "
|
|
331
|
-
f"| {esc_cell(creator_cell(creator))} "
|
|
332
|
-
f"| {esc_cell(summary)} | {doc} |"
|
|
333
|
-
)
|
|
334
|
-
converted += 1
|
|
335
|
-
return "\n".join(out) + "\n", converted
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def _link_target(cell: str) -> str:
|
|
339
|
-
m = _LINK_RE.fullmatch((cell or "").strip())
|
|
340
|
-
return (m.group(1) if m else (cell or "")).strip()
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
def _meta_of(
|
|
344
|
-
doc: str, read_worklog: Callable[[str], dict[str, Any] | None]
|
|
345
|
-
) -> dict[str, Any] | None:
|
|
346
|
-
"""顺着历史行的文档链接取回 worklog 头部四样(`read_worklog` 收文件名)。"""
|
|
347
|
-
target = _link_target(doc)
|
|
348
|
-
if not target:
|
|
349
|
-
return None
|
|
350
|
-
return read_worklog(Path(target).name)
|
|
@@ -1,20 +1,42 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""身份这个值对象:uuid 是唯一键,name 只用于显示与查询筛选。
|
|
2
|
+
|
|
3
|
+
**只做字符串换算,不读文件**(读身份归 env.py)。名字永远不参与归属判定:
|
|
4
|
+
认领 / 释放 / 交接 / 查「我的」,一律只比 uuid。
|
|
5
|
+
"""
|
|
2
6
|
|
|
3
7
|
from __future__ import annotations
|
|
4
8
|
|
|
9
|
+
import re
|
|
5
10
|
from dataclasses import dataclass
|
|
6
11
|
|
|
12
|
+
#: 合法身份键的形态(8-4-4-4-12 的十六进制 UUID)。`--owner` 只收这种值。
|
|
13
|
+
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
14
|
+
|
|
7
15
|
|
|
8
16
|
@dataclass(frozen=True)
|
|
9
17
|
class Identity:
|
|
10
|
-
"""一个人的身份。`
|
|
18
|
+
"""一个人的身份。`uuid` 是唯一键,`name` 只用于显示。"""
|
|
11
19
|
|
|
12
|
-
|
|
20
|
+
uuid: str
|
|
13
21
|
name: str = ""
|
|
14
22
|
|
|
15
23
|
def label(self) -> str:
|
|
16
|
-
"""
|
|
17
|
-
return self.name or self.
|
|
24
|
+
"""显示名:没名字就用 uuid 前 8 位。只给人看(报错文案、界面)。"""
|
|
25
|
+
return self.name or self.uuid[:8]
|
|
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}"
|
|
18
40
|
|
|
19
41
|
|
|
20
42
|
def normalize(who: str) -> str:
|
|
@@ -22,6 +44,11 @@ def normalize(who: str) -> str:
|
|
|
22
44
|
return (who or "").strip().lower()
|
|
23
45
|
|
|
24
46
|
|
|
47
|
+
def looks_like_uuid(who: str | None) -> bool:
|
|
48
|
+
"""是不是一个 UUID 形态的身份键(名字、邮箱一律 False)。"""
|
|
49
|
+
return bool(UUID_RE.match(normalize(who or "")))
|
|
50
|
+
|
|
51
|
+
|
|
25
52
|
def is_me(identity: Identity, who: str | None) -> bool:
|
|
26
|
-
"""`who
|
|
27
|
-
return normalize(who or "") == normalize(identity.
|
|
53
|
+
"""`who`(uuid)是不是这个人。全站只认 uuid 这一个身份键。"""
|
|
54
|
+
return normalize(who or "") == normalize(identity.uuid)
|
|
@@ -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
|
package/scripts/ledgerkit/env.py
CHANGED
|
@@ -1,93 +1,92 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""确定「我是谁」的唯一地方:只读机器级身份文件,不读 git、不读环境变量。
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
兜底身份这件事本身只 `notices.note("提示:…")`,不打印。
|
|
3
|
+
身份文件:`$TASK_LEDGER_HOME/.taskledger/identity.json`(默认 `~/.taskledger/identity.json`),
|
|
4
|
+
内容是 `{"uuid": "...", "name": "..."}`。没有就报错,由 `init` 命令创建。
|
|
6
5
|
"""
|
|
7
6
|
|
|
8
7
|
from __future__ import annotations
|
|
9
8
|
|
|
10
|
-
import
|
|
9
|
+
import json
|
|
11
10
|
import os
|
|
12
|
-
import re
|
|
13
|
-
import socket
|
|
14
|
-
import subprocess
|
|
15
11
|
from pathlib import Path
|
|
16
12
|
|
|
17
|
-
from . import
|
|
18
|
-
from .
|
|
13
|
+
from .core.identity import Identity, looks_like_uuid, normalize
|
|
14
|
+
from .errors import Fail
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
IDENTITY_HOME_ENV = "TASK_LEDGER_HOME"
|
|
21
17
|
|
|
22
18
|
|
|
23
|
-
def
|
|
24
|
-
|
|
19
|
+
def identity_file() -> Path:
|
|
20
|
+
"""机器级身份文件路径;`TASK_LEDGER_HOME` 可覆写主目录(容器/测试用)。"""
|
|
21
|
+
base = os.environ.get(IDENTITY_HOME_ENV)
|
|
22
|
+
if base:
|
|
23
|
+
return Path(base) / ".taskledger" / "identity.json"
|
|
24
|
+
try:
|
|
25
|
+
home = Path.home()
|
|
26
|
+
except RuntimeError as exc:
|
|
27
|
+
raise Fail(
|
|
28
|
+
"找不到用户主目录(HOME)。\n"
|
|
29
|
+
" 处理:设置环境变量 TASK_LEDGER_HOME 指向一个可写目录,再重试。"
|
|
30
|
+
) from exc
|
|
31
|
+
return home / ".taskledger" / "identity.json"
|
|
25
32
|
|
|
26
33
|
|
|
27
|
-
def
|
|
28
|
-
"""
|
|
34
|
+
def read_identity() -> Identity | None:
|
|
35
|
+
"""读机器身份;文件不存在、空文件、坏 JSON、没 uuid 一律返回 None。
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
uuid 先归一化(去空白 + 小写,与 `--owner` 入参同一口径)。**形态不对直接报错**:
|
|
38
|
+
身份键只能是 uuid,手改进去一个邮箱 / 名字会让每条命令都写错 owner,
|
|
39
|
+
与其静默用下去,不如当场停下让人改文件。
|
|
32
40
|
"""
|
|
33
|
-
|
|
41
|
+
path = identity_file()
|
|
34
42
|
try:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
except FileNotFoundError:
|
|
43
|
+
raw = path.read_text(encoding="utf-8")
|
|
44
|
+
except (OSError, ValueError):
|
|
39
45
|
return None
|
|
40
|
-
if proc.returncode == 0 and proc.stdout.strip():
|
|
41
|
-
return proc.stdout.strip()
|
|
42
|
-
return None
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def git_email(repo: Path | None) -> str | None:
|
|
46
|
-
"""GIT_COMMITTER_IDENT 优先,退回 user.email。"""
|
|
47
|
-
ident = git_value(repo, "var", "GIT_COMMITTER_IDENT")
|
|
48
|
-
if ident:
|
|
49
|
-
m = _IDENT_RE.search(ident)
|
|
50
|
-
if m and m.group(1).strip():
|
|
51
|
-
return m.group(1).strip()
|
|
52
|
-
return git_value(repo, "config", "user.email")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def git_name(repo: Path | None) -> str | None:
|
|
56
|
-
ident = git_value(repo, "var", "GIT_COMMITTER_IDENT")
|
|
57
|
-
if ident:
|
|
58
|
-
head = ident.split("<", 1)[0].strip()
|
|
59
|
-
if head:
|
|
60
|
-
return head
|
|
61
|
-
return git_value(repo, "config", "user.name")
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def fallback_identity() -> str:
|
|
65
|
-
"""不在 git 仓库里时的兜底链:系统用户名 → getpass → 主机名 → 字面 default。"""
|
|
66
|
-
for key in ("USER", "LOGNAME"):
|
|
67
|
-
value = os.environ.get(key, "").strip()
|
|
68
|
-
if value:
|
|
69
|
-
return value
|
|
70
|
-
try:
|
|
71
|
-
name = getpass.getuser().strip()
|
|
72
|
-
except Exception: # noqa: BLE001 —— 拿不到用户名是正常分支,不该炸
|
|
73
|
-
name = ""
|
|
74
|
-
if name:
|
|
75
|
-
return name
|
|
76
46
|
try:
|
|
77
|
-
|
|
78
|
-
except
|
|
79
|
-
|
|
80
|
-
|
|
47
|
+
data = json.loads(raw)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
uuid = normalize(str((data or {}).get("uuid") or ""))
|
|
51
|
+
if not uuid:
|
|
52
|
+
return None
|
|
53
|
+
if not looks_like_uuid(uuid):
|
|
54
|
+
raise Fail(
|
|
55
|
+
f"身份文件里的 uuid 不合法:{path}\n"
|
|
56
|
+
f" 读到:{uuid}(身份键只能是 uuid,不能是邮箱 / 名字)\n"
|
|
57
|
+
" 处理:把该文件的 uuid 改成 uuid(或删掉这个文件重跑 init)。"
|
|
58
|
+
)
|
|
59
|
+
name = str((data or {}).get("name") or "").strip()
|
|
60
|
+
return Identity(uuid=uuid, name=name)
|
|
81
61
|
|
|
82
62
|
|
|
83
63
|
def current(repo: Path | None = None) -> Identity:
|
|
84
|
-
"""
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
64
|
+
"""当前操作者;没初始化就报错,提示去 init。
|
|
65
|
+
|
|
66
|
+
name 的取值规则:工作区 owners.json 里同 uuid 的 name 优先,没有就用总体的 name。
|
|
67
|
+
"""
|
|
68
|
+
identity = read_identity()
|
|
69
|
+
if identity is None:
|
|
70
|
+
raise Fail(
|
|
71
|
+
"这台机器还没有初始化身份。\n"
|
|
72
|
+
" 处理:先运行 `progress.py init --repo <工作区根>`,输入一个名字,再执行本命令。"
|
|
91
73
|
)
|
|
92
|
-
name =
|
|
93
|
-
|
|
74
|
+
name = identity.name
|
|
75
|
+
if repo is not None:
|
|
76
|
+
name = _workspace_name(repo, identity.uuid) or name
|
|
77
|
+
return Identity(uuid=identity.uuid, name=name)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _workspace_name(repo: Path, uuid: str) -> str:
|
|
81
|
+
"""当前工作区 owners.json 里这个 uuid 的 name;没有/读不到返回空串。"""
|
|
82
|
+
from .core.paths import OWNERS_REL, to_repo_rel
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
raw = (repo / to_repo_rel(OWNERS_REL)).read_text(encoding="utf-8")
|
|
86
|
+
workers = (json.loads(raw) or {}).get("workers", {}) or {}
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
return ""
|
|
89
|
+
record = workers.get(uuid) or workers.get(uuid.lower())
|
|
90
|
+
if not isinstance(record, dict):
|
|
91
|
+
return ""
|
|
92
|
+
return str(record.get("name") or "").strip()
|