dsh-task-ledger 0.0.78 → 0.0.80

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.
Files changed (57) hide show
  1. package/dist/client.js +29 -14
  2. package/dist/client.js.map +1 -1
  3. package/dist/host/board.d.ts +2 -2
  4. package/dist/host/board.js +2 -2
  5. package/dist/host/repositories/cache.js +18 -2
  6. package/dist/host/repositories/identity.repository.d.ts +3 -8
  7. package/dist/host/repositories/identity.repository.js +32 -23
  8. package/dist/host/repositories/ledger.repository.d.ts +7 -3
  9. package/dist/host/repositories/ledger.repository.js +23 -14
  10. package/dist/host/repositories/records.js +20 -1
  11. package/dist/host/repositories/runner.d.ts +2 -2
  12. package/dist/host/repositories/runner.js +1 -1
  13. package/dist/host/routes.js +40 -11
  14. package/dist/host/services/home.service.d.ts +2 -2
  15. package/dist/host/services/home.service.js +7 -4
  16. package/dist/host/services/ledger-write.service.d.ts +4 -4
  17. package/dist/host/services/ledger-write.service.js +7 -7
  18. package/dist/host/services/trend.service.d.ts +2 -2
  19. package/dist/host/services/trend.service.js +9 -10
  20. package/dist/host/services/uuid.d.ts +11 -0
  21. package/dist/host/services/uuid.js +15 -0
  22. package/dist/host/types.d.ts +21 -5
  23. package/package.json +1 -1
  24. package/skill/README.md +1 -1
  25. package/skill/SKILL.md +30 -17
  26. package/skill/package.json +1 -1
  27. package/skill/references/state-model.md +12 -10
  28. package/skill/references/troubleshooting.md +19 -7
  29. package/skill/scripts/ledgerkit/actions/atom.py +2 -2
  30. package/skill/scripts/ledgerkit/actions/bootstrap.py +9 -2
  31. package/skill/scripts/ledgerkit/actions/claim.py +7 -6
  32. package/skill/scripts/ledgerkit/actions/close_task.py +17 -12
  33. package/skill/scripts/ledgerkit/actions/guard.py +38 -16
  34. package/skill/scripts/ledgerkit/actions/handoff.py +24 -8
  35. package/skill/scripts/ledgerkit/actions/init_identity.py +90 -0
  36. package/skill/scripts/ledgerkit/actions/list_tasks.py +21 -3
  37. package/skill/scripts/ledgerkit/actions/log_progress.py +5 -2
  38. package/skill/scripts/ledgerkit/actions/new_task.py +11 -7
  39. package/skill/scripts/ledgerkit/actions/query.py +4 -2
  40. package/skill/scripts/ledgerkit/actions/query_history.py +55 -9
  41. package/skill/scripts/ledgerkit/actions/registry.py +1 -1
  42. package/skill/scripts/ledgerkit/actions/release.py +25 -4
  43. package/skill/scripts/ledgerkit/actions/set_task.py +18 -7
  44. package/skill/scripts/ledgerkit/actions/validate.py +0 -4
  45. package/skill/scripts/ledgerkit/core/history_md.py +47 -250
  46. package/skill/scripts/ledgerkit/core/identity.py +34 -7
  47. package/skill/scripts/ledgerkit/core/labels.py +0 -1
  48. package/skill/scripts/ledgerkit/core/paths.py +0 -1
  49. package/skill/scripts/ledgerkit/core/worklog_md.py +23 -3
  50. package/skill/scripts/ledgerkit/env.py +70 -71
  51. package/skill/scripts/ledgerkit/shell/cli.py +60 -3
  52. package/skill/scripts/ledgerkit/shell/gitops.py +14 -1
  53. package/skill/scripts/ledgerkit/shell/render.py +3 -2
  54. package/skill/scripts/ledgerkit/shell/runner.py +1 -1
  55. package/skill/scripts/ledgerkit/store/history_store.py +26 -81
  56. package/skill/scripts/ledgerkit/store/owners_index.py +16 -3
  57. package/skill/scripts/ledgerkit/store/task_store.py +2 -2
@@ -16,7 +16,6 @@ from ..core.identity import Identity
16
16
  from ..core.labels import STATUSES
17
17
  from ..core.paths import (
18
18
  LEDGER_DIR,
19
- LEGACY_HISTORY_REL,
20
19
  OWNERS_REL,
21
20
  POOL_REL,
22
21
  to_repo_rel,
@@ -200,9 +199,6 @@ def validate(repo: Path, me: Identity, deep: bool = False) -> ValidationReport:
200
199
  )
201
200
  checked += _check_worklog(repo, logs, problems, notices, deep=deep)
202
201
 
203
- legacy = repo / to_repo_rel(LEGACY_HISTORY_REL)
204
- if legacy.exists():
205
- notices.append(f"存量平铺文件 {rel(repo, legacy)} 只读兼容,不会重写。")
206
202
  if not (repo / to_repo_rel(OWNERS_REL)).exists():
207
203
  notices.append("还没有 owners.json(索引):跑一条写命令会自动建。")
208
204
  stray = repo / LEDGER_DIR / LEDGER_DIR
@@ -1,25 +1,25 @@
1
- """历史表格的文本操作:拼行、解析、旧列数升级。
1
+ """历史表格的文本操作:拼十一列一行、解析十一列一行。
2
2
 
3
- **自己不开文件**:升级要回链 worklog 读头部,那件事由调用方传 `read_worklog`
4
- 回调进来(`str -> str | None`),core 只做文本换算。
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 Callable, Iterable
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 = "| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 | owner | creator | 完成了什么 | 过程文档 |"
19
- HISTORY_SEP = "|---|---|---|---|---|---|---|---|---|"
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
- """九列的一行。`worklog` 收**相对 `ledger/`** 的路径,这里负责换成相对 `history/` 的链接。
75
+ """十一列的一行。`worklog` 收**相对 `ledger/`** 的路径,这里负责换成相对 `history/` 的链接。
75
76
 
76
- owner / creator 传**裸标识**(`@` 由 owner_cell / creator_cell 加),外面别先加。
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(creator_cell(creator))} "
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
- """解析历史表 → (records, warnings)。**逐行按自己的列数判别**,表头只用来跳过。
94
+ """解析十一列历史表 → (records, warnings)。坏行(列数不对 / 时间列不合法)跳过并警告。
145
95
 
146
- 兼容 9 / 8 / 6 / 5(两种)/ 4 列;坏行(列数不在列、时间列不合法)跳过并警告,不中断。
147
- 返回的键:finished / created / title / category / tags / owner / creator / summary /
148
- 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);两个名字列是**原值**(落盘快照,可能为空)。
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
- created = ""
159
- category: str | None = None
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(tags),
216
- "owner": (owner or "").strip().removeprefix("@") or None,
217
- "creator": creator,
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": log or None,
220
- "fmt": 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
- """身份这个值对象:归一化与比较规则。**只做字符串换算,不读 git**(读 git 归 env.py)。"""
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
- """一个人的身份。`email` 是分区键(归一化后的裸标识),`name` 只用于显示。"""
18
+ """一个人的身份。`uuid` 是唯一键,`name` 只用于显示。"""
11
19
 
12
- email: str
20
+ uuid: str
13
21
  name: str = ""
14
22
 
15
23
  def label(self) -> str:
16
- """显示名:没名字就用邮箱。"""
17
- return self.name or self.email
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.email)
53
+ """`who`(uuid)是不是这个人。全站只认 uuid 这一个身份键。"""
54
+ return normalize(who or "") == normalize(identity.uuid)
@@ -8,7 +8,6 @@ from collections.abc import Iterable
8
8
  from ..errors import Fail
9
9
 
10
10
  STATUSES = ("open", "doing")
11
- LEGACY_STATUSES = ("blocked",) # 旧数据:读侧当进行中,不写回
12
11
  FORMAT_VERSION = "20261022000000"
13
12
 
14
13
  LABEL_MAX_CHARS = 4 # 含 CJK 时按「字」算
@@ -20,7 +20,6 @@ OWNERS_REL = Path("owners.json")
20
20
  OWNERS_DIR = Path("owners")
21
21
  HISTORY_DIR = Path("history")
22
22
  WORKLOG_DIR = Path("worklog")
23
- LEGACY_HISTORY_REL = Path("PROGRESS-HISTORY.md") # 存量平铺文件:只读不写
24
23
 
25
24
  SHA_LEN = 12
26
25
 
@@ -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
- """头部「创建」行括号里的发起人(裸标识,不带 @);读不到返回 None。"""
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