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.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/SKILL.md +30 -17
  3. package/package.json +1 -1
  4. package/references/state-model.md +12 -10
  5. package/references/troubleshooting.md +19 -7
  6. package/scripts/ledgerkit/actions/atom.py +2 -2
  7. package/scripts/ledgerkit/actions/bootstrap.py +9 -2
  8. package/scripts/ledgerkit/actions/claim.py +7 -6
  9. package/scripts/ledgerkit/actions/close_task.py +17 -12
  10. package/scripts/ledgerkit/actions/guard.py +38 -16
  11. package/scripts/ledgerkit/actions/handoff.py +24 -8
  12. package/scripts/ledgerkit/actions/init_identity.py +90 -0
  13. package/scripts/ledgerkit/actions/list_tasks.py +21 -3
  14. package/scripts/ledgerkit/actions/log_progress.py +5 -2
  15. package/scripts/ledgerkit/actions/new_task.py +11 -7
  16. package/scripts/ledgerkit/actions/query.py +4 -2
  17. package/scripts/ledgerkit/actions/query_history.py +55 -9
  18. package/scripts/ledgerkit/actions/registry.py +1 -1
  19. package/scripts/ledgerkit/actions/release.py +25 -4
  20. package/scripts/ledgerkit/actions/set_task.py +18 -7
  21. package/scripts/ledgerkit/actions/validate.py +0 -4
  22. package/scripts/ledgerkit/core/history_md.py +47 -250
  23. package/scripts/ledgerkit/core/identity.py +34 -7
  24. package/scripts/ledgerkit/core/labels.py +0 -1
  25. package/scripts/ledgerkit/core/paths.py +0 -1
  26. package/scripts/ledgerkit/core/worklog_md.py +23 -3
  27. package/scripts/ledgerkit/env.py +70 -71
  28. package/scripts/ledgerkit/shell/cli.py +60 -3
  29. package/scripts/ledgerkit/shell/gitops.py +14 -1
  30. package/scripts/ledgerkit/shell/render.py +3 -2
  31. package/scripts/ledgerkit/shell/runner.py +1 -1
  32. package/scripts/ledgerkit/store/history_store.py +26 -81
  33. package/scripts/ledgerkit/store/owners_index.py +16 -3
  34. package/scripts/ledgerkit/store/task_store.py +2 -2
@@ -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
@@ -1,93 +1,92 @@
1
- """唯一允许去问 git「我现在是谁」的地方。
1
+ """确定「我是谁」的唯一地方:只读机器级身份文件,不读 git、不读环境变量。
2
2
 
3
- 只**读**配置(`GIT_COMMITTER_IDENT` / `config user.email` / `config user.name`),
4
- 不 pull、不 commit、不 push —— 那不算 git 操作,算环境。
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 getpass
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 notices
18
- from .core.identity import Identity, normalize
13
+ from .core.identity import Identity, looks_like_uuid, normalize
14
+ from .errors import Fail
19
15
 
20
- _IDENT_RE = re.compile(r"<([^>]*)>")
16
+ IDENTITY_HOME_ENV = "TASK_LEDGER_HOME"
21
17
 
22
18
 
23
- def _git(*args: str) -> subprocess.CompletedProcess[str]:
24
- return subprocess.run(["git", *args], capture_output=True, text=True, check=False)
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 git_value(repo: Path | None, *args: str) -> str | None:
28
- """读一个 git 配置值;cwd 给定时按那个仓库判(--repo 未必是当前目录)。
34
+ def read_identity() -> Identity | None:
35
+ """读机器身份;文件不存在、空文件、坏 JSON、没 uuid 一律返回 None。
29
36
 
30
- 没装 git 二进制不算故障:当「读不到」处理,走兜底身份(2026-10-24:
31
- 以前直接 FileNotFoundError 崩,没 git 的机器连台账都建不起来)。
37
+ uuid 先归一化(去空白 + 小写,与 `--owner` 入参同一口径)。**形态不对直接报错**:
38
+ 身份键只能是 uuid,手改进去一个邮箱 / 名字会让每条命令都写错 owner,
39
+ 与其静默用下去,不如当场停下让人改文件。
32
40
  """
33
- base = ("git", "-C", str(repo)) if repo is not None else ("git",)
41
+ path = identity_file()
34
42
  try:
35
- proc = subprocess.run(
36
- [*base, *args], capture_output=True, text=True, check=False
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
- host = socket.gethostname().strip()
78
- except OSError:
79
- host = ""
80
- return host or "default"
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
- email = git_email(repo)
86
- if not email:
87
- email = fallback_identity()
88
- notices.note(
89
- f"提示:读不到 git 身份(user.email),本次按兜底身份 {email!r} 记账;"
90
- "配好 git config user.email 再跑,任务才会记到正确的名下。"
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 = git_name(repo) or email
93
- return Identity(email=normalize(email), name=name.strip())
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()