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
@@ -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()
@@ -47,7 +47,8 @@ def build_parser() -> argparse.ArgumentParser:
47
47
 
48
48
  setp = sub.add_parser("set", help="释放 / 交接任务")
49
49
  setp.add_argument("--id", required=True, dest="tid")
50
- setp.add_argument("--owner")
50
+ setp.add_argument("--owner", help="交给别人的 uuid(交接)")
51
+ setp.add_argument("--release", action="store_true", help="释放回公共池")
51
52
 
52
53
  logp = sub.add_parser("log", help="记一条进展")
53
54
  logp.add_argument("--id", required=True, dest="tid")
@@ -63,7 +64,12 @@ def build_parser() -> argparse.ArgumentParser:
63
64
  listp = sub.add_parser("list", help="查在办任务")
64
65
  listp.add_argument("--pool", action="store_true")
65
66
  listp.add_argument("--mine", action="store_true")
66
- listp.add_argument("--owner")
67
+ listp.add_argument(
68
+ "--owner", help="某个人的 uuid(精确匹配;名字请用 --owner-name)"
69
+ )
70
+ listp.add_argument(
71
+ "--owner-name", dest="owner_name", help="按显示名模糊筛(子串、不分大小写)"
72
+ )
67
73
  listp.add_argument("--status")
68
74
  listp.add_argument("--category")
69
75
  listp.add_argument("--tag")
@@ -76,7 +82,12 @@ def build_parser() -> argparse.ArgumentParser:
76
82
  showp.add_argument("--json", action="store_true")
77
83
 
78
84
  hist = sub.add_parser("history", help="查已结案")
79
- hist.add_argument("--owner")
85
+ hist.add_argument(
86
+ "--owner", help="某个人的 uuid(精确匹配;名字请用 --owner-name)"
87
+ )
88
+ hist.add_argument(
89
+ "--owner-name", dest="owner_name", help="按显示名模糊筛(子串、不分大小写)"
90
+ )
80
91
  hist.add_argument("--mine", action="store_true")
81
92
  hist.add_argument("--category")
82
93
  hist.add_argument("--tag")
@@ -93,6 +104,11 @@ def build_parser() -> argparse.ArgumentParser:
93
104
  valp.add_argument("--deep", action="store_true")
94
105
  valp.add_argument("--json", action="store_true")
95
106
 
107
+ initp = sub.add_parser("init", help="初始化本机身份(一台机器只一次)")
108
+ initp.add_argument("--claim", help="认领名册里已有的 uuid")
109
+ initp.add_argument("--new", action="store_true", dest="new_id", help="新建身份")
110
+ initp.add_argument("--name", help="新建身份时的显示名字(必填)")
111
+
96
112
  # 宿主(插件 / SKILL.md)的调用写法是 `progress.py list --repo <仓库>`:
97
113
  # `--repo` 挂在**子命令后面**,所以每个子命令都要认;子命令给的值覆盖顶层那个。
98
114
  for parser_ in sub.choices.values():
@@ -119,6 +135,7 @@ def to_query(args: argparse.Namespace) -> None:
119
135
  args.q = ListQuery(
120
136
  scope=scope,
121
137
  owner=getattr(args, "owner", None),
138
+ owner_name=getattr(args, "owner_name", None),
122
139
  statuses=tuple(
123
140
  s.strip()
124
141
  for s in (getattr(args, "status", "") or "").split(",")
@@ -134,6 +151,7 @@ def to_query(args: argparse.Namespace) -> None:
134
151
  elif command == "history":
135
152
  args.q = HistoryQuery(
136
153
  owner=getattr(args, "owner", None),
154
+ owner_name=getattr(args, "owner_name", None),
137
155
  mine=bool(getattr(args, "mine", False)),
138
156
  category=getattr(args, "category", None),
139
157
  tag=getattr(args, "tag", None),
@@ -206,6 +224,8 @@ def main(argv: list[str] | None = None) -> int:
206
224
  to_query(args)
207
225
 
208
226
  command = command_of(args)
227
+ if command == "init":
228
+ return _run_init(args)
209
229
  try:
210
230
  # 定位只做一次:解析好的工作区根挂回 args,runner 与整命令锁都拿它
211
231
  args.repo = str(resolve_repo(getattr(args, "repo", None)))
@@ -249,6 +269,43 @@ def _lock_dir(repo: Path) -> Path:
249
269
  return lock_dir
250
270
 
251
271
 
272
+ def _run_init(args: argparse.Namespace) -> int:
273
+ """init 不进 runner、不解析台账、不碰 git:只写机器身份文件。"""
274
+ from ..actions.init_identity import init_identity
275
+
276
+ repo: Path | None = None
277
+ raw = getattr(args, "repo", None)
278
+ try:
279
+ repo = as_root(raw, source="--repo") if raw else root_of(Path.cwd().resolve())
280
+ except Fail:
281
+ repo = None
282
+ try:
283
+ res = init_identity(
284
+ repo,
285
+ claim=getattr(args, "claim", None),
286
+ new=bool(getattr(args, "new_id", False)),
287
+ name=getattr(args, "name", "") or "",
288
+ )
289
+ except Fail as exc:
290
+ render.fail_message(exc)
291
+ return BUSINESS
292
+ if res.get("need_choose"):
293
+ print("这台机器还没初始化。当前仓库里已有的用户:")
294
+ for opt in res.get("options", []):
295
+ print(f" {opt['uuid']} {opt['name']}")
296
+ print("请重跑:init --claim=<uuid> 认领,或 init --new --name=<名字> 新建")
297
+ return 0
298
+ if res.get("already"):
299
+ print(
300
+ f"已经初始化过,未做改动:{res['name'] or res['uuid'][:8]}({res['uuid']})"
301
+ )
302
+ print(f"文件:{res['path']}")
303
+ return 0
304
+ print(f"已初始化:{res['name'] or res['uuid'][:8]}({res['uuid']})")
305
+ print(f"文件:{res['path']}")
306
+ return 0
307
+
308
+
252
309
  def _readonly(command: str) -> bool:
253
310
  from ..actions.registry import spec_of
254
311
 
@@ -30,7 +30,13 @@ GIT_TIMEOUT = 30
30
30
 
31
31
 
32
32
  def _git(*args: str, timeout: int | None = GIT_TIMEOUT) -> subprocess.CompletedProcess:
33
- """跑一条 git。没装 git 不当崩溃:返回 127 的空结果,由调用方按返回码处理。"""
33
+ """跑一条 git。没装 git 不当崩溃:返回 127 的空结果,由调用方按返回码处理。
34
+
35
+ **超时也折成返回码 124**,不许抛出去:`sync_down` 的 fetch 分支、`push` 的重试、
36
+ `_rebase_retry` 全按返回码判「远端不通」,抛异常会被 runner 兜底成「未预期错误」
37
+ (退出码 3),把「拉取失败、按本地最新写」这条既定降级路径整条跳过。触发场景是
38
+ 弱网 / 网络黑洞 / 代理挂住 —— 远端不立刻拒绝,fetch 一直挂到超时(2026-10-25 修)。
39
+ """
34
40
  try:
35
41
  return subprocess.run(
36
42
  list(args), capture_output=True, text=True, timeout=timeout, check=False
@@ -39,6 +45,13 @@ def _git(*args: str, timeout: int | None = GIT_TIMEOUT) -> subprocess.CompletedP
39
45
  return subprocess.CompletedProcess(
40
46
  list(args), 127, stdout="", stderr="git: command not found"
41
47
  )
48
+ except subprocess.TimeoutExpired:
49
+ return subprocess.CompletedProcess(
50
+ list(args),
51
+ 124,
52
+ stdout="",
53
+ stderr=f"git: timed out after {timeout}s",
54
+ )
42
55
 
43
56
 
44
57
  @contextlib.contextmanager
@@ -101,7 +101,8 @@ def show_view(res: ShowResult, *, as_json: bool) -> None:
101
101
  task = res.task
102
102
  _out(f"{task.get('tid', '')}:{task.get('title', '')}")
103
103
  _out(
104
- f" 状态:{task.get('status', '')} 负责人:{task.get('owner') or '(未认领)'}"
104
+ f" 状态:{task.get('status', '')} "
105
+ f"负责人:{task.get('owner_name') or task.get('owner') or '(未认领)'}"
105
106
  )
106
107
  _out(
107
108
  f" 创建:{fmt_local(task.get('created'))} "
@@ -143,7 +144,7 @@ def history_view(res: HistoryResult, q: HistoryQuery | None, *, as_json: bool) -
143
144
  for row in res.records:
144
145
  _out(
145
146
  f" {fmt_local(row.get('finished'))} "
146
- f"{row.get('owner') or '(无人)':<12} {row.get('title', '')}"
147
+ f"{row.get('owner_name') or row.get('owner') or '(无人)':<12} {row.get('title', '')}"
147
148
  )
148
149
 
149
150
 
@@ -213,7 +213,7 @@ def _task_snapshot(repo: Path, me) -> dict[str, Any]:
213
213
 
214
214
  tasks: dict[str, Any] = {}
215
215
  tasks.update(TaskStore(repo, POOL_REL).all())
216
- tasks.update(TaskStore(repo, shard_rel(me.email)).all())
216
+ tasks.update(TaskStore(repo, shard_rel(me.uuid)).all())
217
217
  return tasks
218
218
 
219
219
 
@@ -9,9 +9,8 @@ from pathlib import Path
9
9
  from typing import Any
10
10
 
11
11
  from ..core import history_md
12
- from ..core.paths import HISTORY_DIR, LEGACY_HISTORY_REL, to_repo_rel
12
+ from ..core.paths import HISTORY_DIR, to_repo_rel
13
13
  from .atomic import write_text_atomic
14
- from .worklog_store import WorklogStore
15
14
 
16
15
  HIT = "hit"
17
16
  MISS = "miss"
@@ -25,16 +24,12 @@ class HistoryStore:
25
24
  # ---- 读 ----
26
25
 
27
26
  def sources(self) -> list[Path]:
28
- """按月的 `history/*.md`(按文件名排序)+ 存量平铺文件(只读,永不重写)。"""
27
+ """按月的 `history/*.md`(按文件名排序)。"""
29
28
  base = self.repo / to_repo_rel(HISTORY_DIR)
30
- files = sorted(base.glob("*.md")) if base.is_dir() else []
31
- legacy = self.repo / to_repo_rel(LEGACY_HISTORY_REL)
32
- if legacy.is_file():
33
- files.append(legacy)
34
- return files
29
+ return sorted(base.glob("*.md")) if base.is_dir() else []
35
30
 
36
31
  def month_key(self, path: Path) -> str:
37
- """按月文件的 `YYYYMM`;存量平铺文件没有月份键,返回空串。"""
32
+ """按月文件的 `YYYYMM`。"""
38
33
  stem = Path(path).stem
39
34
  return stem if len(stem) == 6 and stem.isdigit() else ""
40
35
 
@@ -46,16 +41,6 @@ class HistoryStore:
46
41
  except OSError as exc:
47
42
  raise ValueError(f"读 {path} 失败:{exc}") from exc
48
43
 
49
- def _read_worklog(self, path: Path):
50
- """升级要回链的 worklog 头部:读文件这件事留在 store,core 只消费返回的字典。"""
51
- logs = WorklogStore(self.repo)
52
-
53
- def read(log_name: str) -> dict[str, Any] | None:
54
- found = logs.by_name(log_name)
55
- return logs.head_meta(found) if found else None
56
-
57
- return read
58
-
59
44
  # ---- 判重 ----
60
45
 
61
46
  def has_log(
@@ -79,6 +64,11 @@ class HistoryStore:
79
64
  if Path(worklog).name == Path(log_name).name:
80
65
  return HIT
81
66
  continue
67
+ if log_name is None and worklog:
68
+ # 历史行里有链接,但这次判不出对应的是哪篇文档(任务的过程文档丢了 / 被移走):
69
+ # 判不了就明说。拿 miss 冒充会再追加一行 ⇒ 同一条任务两条结案记录。
70
+ undecidable = True
71
+ continue
82
72
  if not worklog:
83
73
  if created and title:
84
74
  if row.get("created") == created and row.get("title") == title:
@@ -90,11 +80,7 @@ class HistoryStore:
90
80
  # ---- 算 ----
91
81
 
92
82
  def append_row(self, path: Path, row: dict[str, Any]) -> dict[str, Any]:
93
- """算出「追加这一行后的新全文」,**不落盘**。
94
-
95
- 返回四个键:`text` / `upgraded` / `duplicate` / `fallback_cols`(`None` = 正常 9 列)。
96
- 老表列少又不敢整体升级时,按现有列数追加并让业务层提示,绝不硬写 9 列把表格挤歪。
97
- """
83
+ """算出「追加这一行后的新全文」,**不落盘**。返回 `text` / `duplicate`。"""
98
84
  original = self.read(path)
99
85
  duplicate = (
100
86
  self.has_log(
@@ -105,67 +91,26 @@ class HistoryStore:
105
91
  )
106
92
  == HIT
107
93
  )
108
- if not original.strip():
109
- text = _fresh_table()
110
- upgraded = False
111
- fallback_cols = None
112
- else:
113
- cols = history_md.history_cols(original)
114
- if cols == 9:
115
- text = original
116
- upgraded = False
117
- fallback_cols = None
118
- else:
119
- upgraded_text = history_md.upgrade_history(
120
- original,
121
- read_worklog=self._read_worklog(path),
122
- fallback_created=row.get("created") or "",
123
- )
124
- if upgraded_text is None:
125
- text = original
126
- upgraded = False
127
- fallback_cols = cols
128
- else:
129
- text, _ = upgraded_text
130
- upgraded = True
131
- fallback_cols = None
132
- line = (
133
- history_md.history_line(
134
- row.get("finished") or "",
135
- row.get("created"),
136
- row.get("title") or "",
137
- row.get("owner"),
138
- row.get("summary") or "",
139
- row.get("worklog"),
140
- row.get("category"),
141
- row.get("tags"),
142
- row.get("creator"),
143
- )
144
- if fallback_cols is None
145
- else history_md.history_line_for_cols(
146
- fallback_cols,
147
- row.get("finished") or "",
148
- row.get("created"),
149
- row.get("title") or "",
150
- row.get("owner"),
151
- row.get("summary") or "",
152
- row.get("worklog"),
153
- row.get("category"),
154
- row.get("tags"),
155
- row.get("creator"),
156
- )
94
+ text = original if original.strip() else _fresh_table()
95
+ line = history_md.history_line(
96
+ row.get("finished") or "",
97
+ row.get("created"),
98
+ row.get("title") or "",
99
+ row.get("owner"),
100
+ row.get("summary") or "",
101
+ row.get("worklog"),
102
+ category=row.get("category"),
103
+ tags=row.get("tags"),
104
+ creator=row.get("creator"),
105
+ owner_name=row.get("owner_name"),
106
+ creator_name=row.get("creator_name"),
157
107
  )
158
- return {
159
- "text": _append_line(text, line),
160
- "upgraded": upgraded,
161
- "duplicate": duplicate,
162
- "fallback_cols": fallback_cols,
163
- }
108
+ return {"text": _append_line(text, line), "duplicate": duplicate}
164
109
 
165
110
  # ---- 写 ----
166
111
 
167
112
  def write(self, path: Path, text: str) -> None:
168
- """历史表唯一的写回口:只用于「结案追加」与「8→9 列升级」的结果落盘。"""
113
+ """历史表唯一的写回口:只用于「结案追加」的结果落盘。"""
169
114
  write_text_atomic(path, text)
170
115
 
171
116
 
@@ -174,7 +119,7 @@ def _name_of(worklog: str | None) -> str | None:
174
119
 
175
120
 
176
121
  def _fresh_table() -> str:
177
- """当月第一次结案:按九列表头起表(表头的真源只有 `history_md` 一处)。"""
122
+ """当月第一次结案:按十一列表头起表(表头的真源只有 `history_md` 一处)。"""
178
123
  return (
179
124
  f"{history_md.HISTORY_TITLE}\n\n"
180
125
  f"{history_md.HISTORY_HEADER}\n{history_md.HISTORY_SEP}\n"
@@ -32,7 +32,7 @@ class OwnersIndex:
32
32
 
33
33
  def ensure(self, me: Identity) -> tuple[bool, dict[str, Any]]:
34
34
  """登记 / 补 `joined`。`name` 只在首次登记时写入,之后不回写(名字是给你做对照的被动字段,不该被任何逻辑读)。返回 (这次写了没有, 记录)。"""
35
- key = normalize(me.email)
35
+ key = normalize(me.uuid)
36
36
  with self._open() as session:
37
37
  data = session.data
38
38
  workers = data.setdefault("workers", {})
@@ -48,6 +48,21 @@ class OwnersIndex:
48
48
  return True, dict(record)
49
49
  return False, dict(record)
50
50
 
51
+ def keys_of_name(self, fragment: str) -> set[str]:
52
+ """显示名模糊匹配(不分大小写的子串)⇒ uuid 集合。**只给查询筛选**,不参与归属判定。
53
+
54
+ 比对的是名册里的名字(`workers[<uuid>].name`),不是每条任务上那份冗余的
55
+ `owner_name`:改名后名册先变,筛选结果跟着变,任务里的旧名字不会干扰。
56
+ """
57
+ needle = normalize(fragment)
58
+ if not needle:
59
+ return set()
60
+ return {
61
+ key
62
+ for key, record in self.all().items()
63
+ if needle in normalize(str((record or {}).get("name") or ""))
64
+ }
65
+
51
66
  def shards(self) -> dict[str, Path]:
52
67
  """身份 → 分片绝对路径(只信索引)。"""
53
68
  out: dict[str, Path] = {}
@@ -110,5 +125,3 @@ class _IndexSession:
110
125
  def __exit__(self, *exc) -> None:
111
126
  if self.changed:
112
127
  self.store._flush(self.data) # 同一个包内的受控写回口
113
-
114
-
@@ -22,7 +22,7 @@ class TaskStore(JSONStore):
22
22
  self.identity = identity
23
23
 
24
24
  def _flush(self, data: dict[str, Any]) -> None:
25
- """写盘时带上分片顶部的 `identity`(只存邮箱;显示名在索引里)。"""
25
+ """写盘时带上分片顶部的 `identity`(只存 uuid;显示名在索引里)。"""
26
26
  if self.identity is not None:
27
- data["identity"] = {"email": self.identity.email}
27
+ data["identity"] = {"uuid": self.identity.uuid}
28
28
  super()._flush(data)