task-ledger 1.3.4 → 1.3.5

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 (33) 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 +9 -7
  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 +13 -12
  10. package/scripts/ledgerkit/actions/guard.py +38 -16
  11. package/scripts/ledgerkit/actions/handoff.py +19 -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 +48 -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 +20 -245
  23. package/scripts/ledgerkit/core/identity.py +20 -7
  24. package/scripts/ledgerkit/core/labels.py +0 -1
  25. package/scripts/ledgerkit/core/paths.py +0 -1
  26. package/scripts/ledgerkit/env.py +70 -71
  27. package/scripts/ledgerkit/shell/cli.py +60 -3
  28. package/scripts/ledgerkit/shell/gitops.py +14 -1
  29. package/scripts/ledgerkit/shell/render.py +3 -2
  30. package/scripts/ledgerkit/shell/runner.py +1 -1
  31. package/scripts/ledgerkit/store/history_store.py +23 -80
  32. package/scripts/ledgerkit/store/owners_index.py +16 -3
  33. package/scripts/ledgerkit/store/task_store.py +2 -2
@@ -1,14 +1,12 @@
1
- """历史表格的文本操作:拼行、解析、旧列数升级。
1
+ """历史表格的文本操作:拼九列一行、解析九列一行。
2
2
 
3
- **自己不开文件**:升级要回链 worklog 读头部,那件事由调用方传 `read_worklog`
4
- 回调进来(`str -> str | None`),core 只做文本换算。
3
+ **只认九列这一种格式**(开发期没发布,不做任何旧列数 / 旧列语义的迁移)。
5
4
  """
6
5
 
7
6
  from __future__ import annotations
8
7
 
9
8
  import re
10
- from collections.abc import Callable, Iterable
11
- from pathlib import Path
9
+ from collections.abc import Iterable
12
10
  from typing import Any
13
11
 
14
12
  from .paths import to_history_rel
@@ -18,8 +16,6 @@ HISTORY_TITLE = "# 进度历史(已结案)"
18
16
  HISTORY_HEADER = "| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 | owner | creator | 完成了什么 | 过程文档 |"
19
17
  HISTORY_SEP = "|---|---|---|---|---|---|---|---|---|"
20
18
 
21
- # 最旧格式把 owner 缝在工作包末尾:最右一个「空格 + @」之后整段是 owner。
22
- OLD_OWNER_RE = re.compile(r"^(?P<title>.*\S)\s+@(?P<owner>\S.*)$")
23
19
  _LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]*)\)")
24
20
  _TAG_SPLIT_RE = re.compile(r"[,,、]")
25
21
 
@@ -32,7 +28,7 @@ def esc_cell(text: str) -> str:
32
28
 
33
29
 
34
30
  def owner_cell(owner: str | None) -> str:
35
- """owner 列的展示值:人名前加 @(`@` 只是展示约定,匹配一律用裸标识)。"""
31
+ """owner 列的展示值:人名前加 @(`@` 只是展示约定,匹配一律用裸 uuid)。"""
36
32
  return f"@{owner}" if owner else ""
37
33
 
38
34
 
@@ -56,7 +52,6 @@ def is_sep_row(cells: list[str]) -> bool:
56
52
 
57
53
 
58
54
  def _looks_like_header(cells: list[str]) -> bool:
59
- # 按内容判、不按位置判:无表头的老文件首行就是数据,按位置判会把第一条记录当表头删掉。
60
55
  return bool(cells) and cells[0] in ("时间", "结案时间")
61
56
 
62
57
 
@@ -73,7 +68,7 @@ def history_line(
73
68
  ) -> str:
74
69
  """九列的一行。`worklog` 收**相对 `ledger/`** 的路径,这里负责换成相对 `history/` 的链接。
75
70
 
76
- owner / creator 传**裸标识**(`@` 由 owner_cell / creator_cell 加),外面别先加。
71
+ owner / creator 传**裸 uuid**(`@` 由 owner_cell / creator_cell 加),外面别先加。
77
72
  """
78
73
  log_rel = to_history_rel(worklog) if worklog else ""
79
74
  tag_cell = esc_cell("、".join(tags) if tags else "")
@@ -86,66 +81,11 @@ def history_line(
86
81
  )
87
82
 
88
83
 
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
84
  def parse_history(text: str) -> tuple[list[Row], list[str]]:
144
- """解析历史表 → (records, warnings)。**逐行按自己的列数判别**,表头只用来跳过。
85
+ """解析九列历史表 → (records, warnings)。坏行(列数不对 / 时间列不合法)跳过并警告。
145
86
 
146
- 兼容 9 / 8 / 6 / 5(两种)/ 4 列;坏行(列数不在列、时间列不合法)跳过并警告,不中断。
147
87
  返回的键:finished / created / title / category / tags / owner / creator / summary /
148
- worklog / fmt / line。owner 与 creator 一律**剥掉 `@`**(匹配用裸标识)。
88
+ worklog / fmt / line。owner 与 creator 一律**剥掉 `@`**(存储值是裸 uuid)。
149
89
  """
150
90
  records: list[Row] = []
151
91
  warnings: list[str] = []
@@ -155,196 +95,31 @@ def parse_history(text: str) -> tuple[list[Row], list[str]]:
155
95
  cells = split_row(raw)
156
96
  if not cells or is_sep_row(cells) or _looks_like_header(cells):
157
97
  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,已跳过")
98
+ if len(cells) != 9:
99
+ warnings.append(f"第 {lineno} 行不是九列(历史表只认九列),已跳过")
203
100
  continue
101
+ finished, created, title, category, tag_cell, owner, creator, summary, doc = (
102
+ cells
103
+ )
204
104
  if not TIME_RE.fullmatch(finished):
205
105
  warnings.append(f"第 {lineno} 行时间列 {finished!r} 不合法,已跳过")
206
106
  continue
207
107
  m = _LINK_RE.fullmatch(doc.strip())
208
- log = m.group(1) if m else doc.strip()
209
108
  records.append(
210
109
  {
211
110
  "finished": finished,
212
111
  "created": time_key(created) if created else None,
213
112
  "title": title,
214
- "category": category,
215
- "tags": tuple(tags),
216
- "owner": (owner or "").strip().removeprefix("@") or None,
217
- "creator": creator,
113
+ "category": category.strip() or None,
114
+ "tags": tuple(
115
+ t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()
116
+ ),
117
+ "owner": owner.strip().removeprefix("@") or None,
118
+ "creator": creator.strip().removeprefix("@") or None,
218
119
  "summary": summary,
219
- "worklog": log or None,
220
- "fmt": fmt,
120
+ "worklog": (m.group(1) if m else doc.strip()) or None,
121
+ "fmt": "v9",
221
122
  "line": lineno,
222
123
  }
223
124
  )
224
125
  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,28 @@
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]
18
26
 
19
27
 
20
28
  def normalize(who: str) -> str:
@@ -22,6 +30,11 @@ def normalize(who: str) -> str:
22
30
  return (who or "").strip().lower()
23
31
 
24
32
 
33
+ def looks_like_uuid(who: str | None) -> bool:
34
+ """是不是一个 UUID 形态的身份键(名字、邮箱一律 False)。"""
35
+ return bool(UUID_RE.match(normalize(who or "")))
36
+
37
+
25
38
  def is_me(identity: Identity, who: str | None) -> bool:
26
- """`who`(邮箱)是不是这个人。全站只认邮箱这一个身份键。"""
27
- return normalize(who or "") == normalize(identity.email)
39
+ """`who`(uuid)是不是这个人。全站只认 uuid 这一个身份键。"""
40
+ 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
 
@@ -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()