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
@@ -0,0 +1,90 @@
1
+ """init:初始化本机身份(写 `~/.taskledger/identity.json`,一台机器只一次)。
2
+
3
+ 只做逻辑,不打印、不读 stdin;交互/机器读在 shell 层。
4
+ - `claim`:认领名册里已有的 uuid(连 name 一起复制,不输入)。
5
+ - `new` :新建 uuid(必须给 name)。
6
+ - 都不给且名册有人:返回候选列表,交给调用方去问用户。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import uuid as uuidlib
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .. import env
17
+ from ..core.identity import Identity, looks_like_uuid, normalize
18
+ from ..core.paths import OWNERS_REL, to_repo_rel
19
+ from ..errors import Fail
20
+
21
+
22
+ def _read_workers(repo: Path | None) -> dict[str, Any]:
23
+ if repo is None:
24
+ return {}
25
+ try:
26
+ raw = (repo / to_repo_rel(OWNERS_REL)).read_text(encoding="utf-8")
27
+ data = json.loads(raw)
28
+ except (OSError, ValueError):
29
+ return {}
30
+ return (data or {}).get("workers", {}) or {}
31
+
32
+
33
+ def init_identity(
34
+ repo: Path | None, *, claim: str | None, new: bool, name: str
35
+ ) -> dict[str, Any]:
36
+ path = env.identity_file()
37
+ existing = env.read_identity()
38
+ if existing is not None:
39
+ # 已初始化 = 幂等空转(与 release/claim/close 的重跑口径一致):退 0、零写。
40
+ # 只有「认领另一个 uuid」才是真冲突——换身份不归 init 管。
41
+ if claim is None or normalize(claim) == existing.uuid:
42
+ return {
43
+ "already": True,
44
+ "uuid": existing.uuid,
45
+ "name": existing.name,
46
+ "path": str(path),
47
+ }
48
+ raise Fail(
49
+ f"这台机器已经是 {existing.uuid}({path}),不能再认领 {claim.strip()}。\n"
50
+ " 处理:要换身份请手动改该文件的 uuid;一台机器一个身份。"
51
+ )
52
+
53
+ workers = _read_workers(repo)
54
+ if claim:
55
+ key = normalize(claim) # 名册键一律小写;--claim 抄来的大小写不影响
56
+ # 认领目标必须是 uuid:老台账的键还可能是个邮箱 / 名字,认来会让整台机器
57
+ # 拿非 uuid 当身份写进每一处(--owner 反倒拒收自己)
58
+ if not looks_like_uuid(key):
59
+ raise Fail(
60
+ f"认领目标不是 uuid:{claim.strip()}\n"
61
+ " 处理:老台账(身份键是邮箱 / 名字)请改走 `init --new --name=<名字>`;"
62
+ "先把那本台账迁到 uuid 身份,再谈认领。"
63
+ )
64
+ record = workers.get(key)
65
+ if record is None:
66
+ raise Fail(
67
+ f"名册里没有 uuid={claim}。\n"
68
+ " 处理:不带参数跑一次 init 看可选用户,再 --claim 选一个。"
69
+ )
70
+ identity = Identity(uuid=key, name=str(record.get("name") or ""))
71
+ elif workers and not new:
72
+ return {
73
+ "need_choose": True,
74
+ "path": str(path),
75
+ "options": [
76
+ {"uuid": key, "name": str((rec or {}).get("name") or "")}
77
+ for key, rec in workers.items()
78
+ ],
79
+ }
80
+ else:
81
+ if not name.strip():
82
+ raise Fail("新建身份必须输入一个名字(--name=<名字>)。")
83
+ identity = Identity(uuid=str(uuidlib.uuid4()), name=name.strip())
84
+
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ payload = {"uuid": identity.uuid, "name": identity.name}
87
+ path.write_text(
88
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
89
+ )
90
+ return {"uuid": identity.uuid, "name": identity.name, "path": str(path)}
@@ -2,20 +2,35 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from dataclasses import replace
5
6
  from pathlib import Path
6
7
  from typing import Any
7
8
 
8
9
  from ..core.identity import Identity
10
+ from ..core.labels import STATUSES
9
11
  from ..core.paths import POOL_REL
12
+ from ..errors import Fail
10
13
  from ..store.owners_index import OwnersIndex
11
14
  from ..store.task_store import TaskStore
12
- from .guard import in_scope, owner_of, status_of
15
+ from .guard import in_scope, owner_of, require_uuid, status_of
13
16
  from .query import ListQuery
14
17
  from .result import ListResult, TaskRow, rel
15
18
 
16
19
 
17
20
  def list_tasks(repo: Path, me: Identity, q: ListQuery) -> ListResult:
18
21
  """合并顺序定死:先池、再按索引键排序的各分片,同一任务号由分片那份胜出。"""
22
+ # `--owner` 只认 uuid,且**给了就必须生效**:和 --mine / --pool 同传直接报错,
23
+ # 不许出现「传了 uuid 却被范围参数静默吃掉」(--owner "" 也算给了,同样报错)。
24
+ if q.owner is not None:
25
+ if q.scope in ("mine", "pool"):
26
+ raise Fail("--owner 不能和 --mine / --pool 一起用:一次只按一种范围筛。")
27
+ q = replace(q, owner=require_uuid(q.owner, field="--owner"))
28
+ # `--status` 只认白名单:`--status blocked` 这种早该消失的值不许静默返回空表
29
+ unknown = [s for s in q.statuses if s not in STATUSES]
30
+ if unknown:
31
+ raise Fail(
32
+ f"--status 只认 {' / '.join(STATUSES)}(收到 {', '.join(unknown)})。"
33
+ )
19
34
  rows: dict[str, dict[str, Any]] = {}
20
35
  where: dict[str, Path] = {}
21
36
 
@@ -33,13 +48,16 @@ def list_tasks(repo: Path, me: Identity, q: ListQuery) -> ListResult:
33
48
  rows[tid] = task
34
49
  where[tid] = store.path
35
50
 
36
- kept = {tid: t for tid, t in rows.items() if in_scope(t, q, me=me)}
51
+ name_keys = index.keys_of_name(q.owner_name) if q.owner_name else set()
52
+ kept = {
53
+ tid: t for tid, t in rows.items() if in_scope(t, q, me=me, name_keys=name_keys)
54
+ }
37
55
  table = tuple(
38
56
  TaskRow(
39
57
  tid=tid,
40
58
  title=str(task.get("title") or ""),
41
59
  status=status_of(task),
42
- owner=owner_of(task),
60
+ owner=task.get("owner_name") or owner_of(task),
43
61
  created=task.get("created"),
44
62
  started=task.get("started"),
45
63
  category=task.get("category"),
@@ -11,6 +11,7 @@ from ..core.worklog_md import exec_line
11
11
  from ..errors import Fail
12
12
  from ..store.task_store import TaskStore
13
13
  from ..store.worklog_store import WorklogStore
14
+ from .guard import require_owner
14
15
  from .result import Result, rel
15
16
 
16
17
 
@@ -23,10 +24,12 @@ def log_progress(repo: Path, me: Identity, tid: str, note: str) -> Result:
23
24
  text = " ".join((note or "").split())
24
25
  if not text:
25
26
  raise Fail("--note 不能为空。")
26
- mine = TaskStore(repo, shard_rel(me.email), identity=me)
27
+ mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
27
28
  task = mine.get(tid) # 锁内重读
28
29
  if task is None:
29
30
  raise Fail(f"任务 {tid} 不在我的分片里(只能给自己的任务记进展)。")
31
+ # 分片里有、行内 owner 却不是我 ⇒ 台账脏了(validate ⑤),先拦下来
32
+ require_owner(task, tid, me)
30
33
 
31
34
  updated = dict(task)
32
35
  updated["updated"] = now()
@@ -43,7 +46,7 @@ def log_progress(repo: Path, me: Identity, tid: str, note: str) -> Result:
43
46
  "提示:这条任务没有过程文档,已跳过执行记录(分片的最后更新已刷)。",
44
47
  ),
45
48
  )
46
- ok = logs.append_exec(path, exec_line(now(), me.email, "log", text))
49
+ ok = logs.append_exec(path, exec_line(now(), me.label(), "log", text))
47
50
  if not ok:
48
51
  return Result(
49
52
  changed=("updated",),
@@ -50,7 +50,7 @@ def new_task(
50
50
  category = normalize_label(category, field="分类") if category else None
51
51
  tag_list = parse_tags(tags)
52
52
 
53
- mine_store = TaskStore(repo, shard_rel(me.email), identity=me)
53
+ mine_store = TaskStore(repo, shard_rel(me.uuid), identity=me)
54
54
  pool = TaskStore(repo, POOL_REL)
55
55
  target = mine_store if mine else pool
56
56
 
@@ -66,8 +66,10 @@ def new_task(
66
66
  # O(1),不必扫历史文件。上面分片 / 池是并发安全闸门,这里补文件级的精确判重。
67
67
  if (repo / to_repo_rel(worklog_rel(tid, created))).exists():
68
68
  raise Fail(
69
- f"任务号 {tid} 的过程文档已经存在(同名号的在办 / 已结案任务占着同一篇文档)"
70
- "⇒ 换一个 --id,别复用历史号。"
69
+ f"任务号 {tid} 的过程文档已经存在(同名号的在办 / 已结案任务占着同一篇文档)。\n"
70
+ " 处理:换一个 --id,别复用历史号。\n"
71
+ " 若上次 new 崩在中间(文档已写、台账没落),这篇就是它留下的:确认内容要保留就换号重建;"
72
+ "非要复用这一号,先把那篇文档挪走再重跑。"
71
73
  )
72
74
 
73
75
  doc_rel = worklog_rel(tid, created) # 别叫 rel:那是 result.rel 的函数名
@@ -80,8 +82,10 @@ def new_task(
80
82
  "created": created,
81
83
  # --self = 建并认领:直接归自己名下就必须是在做,不留 open 半成品
82
84
  "status": "doing" if mine else "open",
83
- "owner": me.email if mine else None,
84
- "creator": me.email,
85
+ "owner": me.uuid if mine else None,
86
+ "owner_name": me.name if mine else None,
87
+ "creator": me.uuid,
88
+ "creator_name": me.name,
85
89
  "category": category,
86
90
  "tags": list(tag_list),
87
91
  "worklog": doc_rel,
@@ -95,11 +99,11 @@ def new_task(
95
99
  tid=tid,
96
100
  title=clean_title,
97
101
  created=created,
98
- creator=me.email,
102
+ creator=me.label(),
99
103
  category=category,
100
104
  tags=tag_list,
101
105
  first_exec=exec_line(
102
- created, me.email, "new", "创建任务并认领" if mine else "创建任务"
106
+ created, me.label(), "new", "创建任务并认领" if mine else "创建任务"
103
107
  ),
104
108
  )
105
109
  target.put(tid, task)
@@ -8,7 +8,8 @@ from dataclasses import dataclass
8
8
  @dataclass(frozen=True)
9
9
  class ListQuery:
10
10
  scope: str = "all" # "all" / "pool" / "mine" / "owner"
11
- owner: str | None = None # scope="owner" 时生效
11
+ owner: str | None = None # scope="owner" 时生效;**只收 uuid**
12
+ owner_name: str | None = None # 显示名模糊筛(子串、不分大小写);只筛不判定
12
13
  statuses: tuple[str, ...] = ()
13
14
  category: str | None = None
14
15
  tag: str | None = None
@@ -19,7 +20,8 @@ class ListQuery:
19
20
 
20
21
  @dataclass(frozen=True)
21
22
  class HistoryQuery:
22
- owner: str | None = None
23
+ owner: str | None = None # **只收 uuid**
24
+ owner_name: str | None = None # 显示名模糊筛(子串、不分大小写)
23
25
  mine: bool = False
24
26
  category: str | None = None
25
27
  tag: str | None = None
@@ -6,16 +6,19 @@
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ from dataclasses import replace
9
10
  from datetime import datetime, timedelta
10
11
  from pathlib import Path
11
12
  from typing import Any
12
13
 
13
14
  from ..core import history_md
14
15
  from ..core.identity import Identity, normalize
15
- from ..core.paths import HISTORY_DIR, LEGACY_HISTORY_REL, month_of, to_repo_rel
16
+ from ..core.paths import HISTORY_DIR, month_of, to_repo_rel
16
17
  from ..core.time import now, time_key
17
18
  from ..errors import Fail
18
19
  from ..store.history_store import HistoryStore
20
+ from ..store.owners_index import OwnersIndex
21
+ from .guard import require_uuid
19
22
  from .query import COUNT_CAP, HistoryQuery
20
23
  from .result import HistoryResult, rel
21
24
 
@@ -102,14 +105,19 @@ def _target_month_keys(q: HistoryQuery) -> list[str]:
102
105
 
103
106
 
104
107
  def _candidate_paths(repo: Path, keys: list[str]) -> list[Path]:
105
- """按月键【直接拼】`history/<年月>.md`(不扫磁盘);存量平铺文件按固定路径顺带一看。"""
106
- paths = [repo / to_repo_rel(HISTORY_DIR / f"{ym}.md") for ym in keys]
107
- paths.append(repo / to_repo_rel(LEGACY_HISTORY_REL))
108
- return paths
108
+ """按月键【直接拼】`history/<年月>.md`(不扫磁盘)。"""
109
+ return [repo / to_repo_rel(HISTORY_DIR / f"{ym}.md") for ym in keys]
109
110
 
110
111
 
111
112
  def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
112
- """`--owner` / `--mine` 一律**精确相等**(不做前缀 / 子串);`--since` / `--until` 比 `finished`(结案时间)。"""
113
+ """`--owner` / `--mine` 一律按 **uuid 精确相等**;名字只能走 `--owner-name` 模糊筛。
114
+
115
+ `--since` / `--until` 比 `finished`(结案时间)。
116
+ """
117
+ if q.owner is not None:
118
+ if q.mine:
119
+ raise Fail("--owner 不能和 --mine 一起用:一次只按一种范围筛。")
120
+ q = replace(q, owner=require_uuid(q.owner, field="--owner"))
113
121
  keys = _target_month_keys(q)
114
122
  store = HistoryStore(repo)
115
123
  records: list[dict[str, Any]] = []
@@ -126,12 +134,29 @@ def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
126
134
  row["finished"] = time_key(row.get("finished") or "") or row.get("finished")
127
135
  records.append(row)
128
136
 
137
+ index = OwnersIndex(repo)
138
+ roster = index.all()
129
139
  if q.mine:
130
- wanted = {normalize(me.email)}
140
+ wanted = {normalize(me.uuid)}
131
141
  records = [r for r in records if normalize(r.get("owner") or "") in wanted]
132
142
  elif q.owner:
133
143
  key = normalize(q.owner)
134
144
  records = [r for r in records if normalize(r.get("owner") or "") == key]
145
+ if q.owner_name:
146
+ # 名字只在名册里模糊匹配出 uuid 集合,命中判定仍比 uuid
147
+ keys_of_name = index.keys_of_name(q.owner_name)
148
+ records = [
149
+ r for r in records if normalize(r.get("owner") or "") in keys_of_name
150
+ ]
151
+
152
+ # 展示名(`owner_name` / `creator_name`)只在这层补上,不落盘:
153
+ # 历史文件里 owner / creator 两列存的一律是 uuid,读出来给人看时才换成名字。
154
+ for row in records:
155
+ for field, target in (("owner", "owner_name"), ("creator", "creator_name")):
156
+ key = normalize(row.get(field) or "")
157
+ name = str((roster.get(key) or {}).get("name") or "")
158
+ if name:
159
+ row[target] = name
135
160
  if q.category:
136
161
  records = [r for r in records if (r.get("category") or "") == q.category]
137
162
  if q.tag:
@@ -142,19 +167,33 @@ def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
142
167
  lo_key, hi_key = min(keys), max(keys)
143
168
  eff_since = q.since or f"{lo_key[0:4]}-{lo_key[4:6]}-01 00:00:00"
144
169
  eff_until = q.until or _month_end(hi_key)
170
+ if eff_since > eff_until:
171
+ raise Fail(
172
+ f"时间区间反了:--since({eff_since})晚于 --until({eff_until})。\n"
173
+ " 处理:把两端换过来;只给一端就只给一端。"
174
+ )
145
175
  records = [r for r in records if (r.get("finished") or "") >= eff_since]
146
176
  records = [r for r in records if (r.get("finished") or "") <= eff_until]
147
177
 
148
178
  records.sort(key=lambda r: str(r.get("finished") or ""), reverse=True)
179
+ if q.count_only and q.limit is not None:
180
+ raise Fail("--count-only 与 --limit 不能同时用:计数不截断,列表才截断。")
181
+ limit: int | None = None
182
+ if q.limit is not None:
183
+ limit = int(q.limit)
184
+ if limit < 1:
185
+ raise Fail(f"--limit 必须 ≥ 1(收到 {limit})。")
149
186
  capped = False
150
187
  if q.count_only and len(records) > COUNT_CAP:
151
188
  capped = True
152
189
  records = records[:COUNT_CAP]
153
- elif q.limit:
154
- records = records[: int(q.limit)]
190
+ elif limit is not None:
191
+ capped = len(records) > limit # 截断了要让调用方知道「还有更多」
192
+ records = records[:limit]
155
193
 
156
194
  filters = {
157
195
  "owner": q.owner,
196
+ "owner_name": q.owner_name,
158
197
  "mine": q.mine,
159
198
  "category": q.category,
160
199
  "tag": q.tag,
@@ -43,7 +43,7 @@ ACTIONS: dict[str, ActionSpec] = {
43
43
  "set": ActionSpec(
44
44
  set_task.set_task,
45
45
  "result",
46
- params=("tid", "owner"),
46
+ params=("tid", "owner", "release"),
47
47
  lock_action="set",
48
48
  ),
49
49
  "log": ActionSpec(log_progress.log_progress, "result", params=("tid", "note")),
@@ -11,16 +11,25 @@ from ..core.worklog_md import exec_line
11
11
  from ..errors import Fail
12
12
  from ..store.task_store import TaskStore
13
13
  from ..store.worklog_store import WorklogStore
14
+ from .guard import owner_of, require_owner, status_of
14
15
  from .result import Result, rel
15
16
 
16
17
 
17
18
  def release(repo: Path, me: Identity, tid: str) -> Result:
18
19
  """写序:写回池(已有同号就不覆盖)→ 删我分片那行 → 过程文档记一笔。"""
19
20
  pool = TaskStore(repo, POOL_REL)
20
- mine = TaskStore(repo, shard_rel(me.email), identity=me)
21
+ mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
21
22
  task = mine.get(tid) # 锁内重读
22
23
  if task is None:
23
- if pool.get(tid) is not None:
24
+ in_pool = pool.get(tid)
25
+ if in_pool is not None:
26
+ # 幂等分支也要验「池里那条真的已经是没人要的开放任务」,
27
+ # 否则池里混进带 owner 的脏行时,release 会报「无需重复释放」而任务实际仍被占
28
+ if owner_of(in_pool) or status_of(in_pool) != "open":
29
+ raise Fail(
30
+ f"任务 {tid} 在公共池里,但身上还挂着 owner / 不是 open —— 台账脏了。\n"
31
+ " 处理:跑 validate 看清单,别用 release 强行覆盖。"
32
+ )
24
33
  return Result(
25
34
  already_done=True,
26
35
  where=rel(repo, pool.path),
@@ -28,12 +37,24 @@ def release(repo: Path, me: Identity, tid: str) -> Result:
28
37
  )
29
38
  raise Fail(f"任务 {tid} 不在我的分片里,也没在公共池里。")
30
39
 
40
+ # 分片里在、但行内 owner 不是我 ⇒ 台账脏了(validate ⑤):不许把别人的行推进池
41
+ require_owner(task, tid, me)
42
+
31
43
  back = dict(task)
32
44
  back["owner"] = None
45
+ back["owner_name"] = None # 冗余显示名跟着 owner 一起清,否则池里那条仍显示原负责人
33
46
  back["status"] = "open"
34
47
  back["updated"] = now()
35
48
  if pool.get(tid) is None:
36
49
  pool.put(tid, back)
50
+ else:
51
+ # 池里已有同号:那份必须是干净的空位,否则不写回(它的 owner/owner_name 会被旧值带脏)
52
+ existing = pool.get(tid) or {}
53
+ if owner_of(existing) or status_of(existing) != "open":
54
+ raise Fail(
55
+ f"公共池里已经有一条 {tid},且它带着 owner / 不是 open —— 台账脏了。\n"
56
+ " 处理:跑 validate 看清单,先确认哪条是真的。"
57
+ )
37
58
  mine.drop(tid)
38
59
 
39
60
  logs = WorklogStore(repo)
@@ -42,9 +63,9 @@ def release(repo: Path, me: Identity, tid: str) -> Result:
42
63
  if path is None:
43
64
  note = f"任务 {tid} 已退回公共池,但过程文档没找到,跳过执行记录。"
44
65
  else:
45
- logs.append_exec(path, exec_line(now(), me.email, "release", "释放回公共池"))
66
+ logs.append_exec(path, exec_line(now(), me.label(), "release", "释放回公共池"))
46
67
  return Result(
47
- changed=("owner", "status", "updated"),
68
+ changed=("owner", "owner_name", "status", "updated"),
48
69
  where=rel(repo, pool.path),
49
70
  message=f"释放 {tid}:{back.get('title') or ''}".strip(),
50
71
  notices=(note,) if note else (),
@@ -19,21 +19,32 @@ from ..store.task_store import TaskStore
19
19
  from . import claim as claim_mod
20
20
  from . import handoff as handoff_mod
21
21
  from . import release as release_mod
22
+ from .guard import require_uuid
22
23
  from .result import Result, rel
23
24
 
24
25
 
25
- def set_task(repo: Path, me: Identity, tid: str, *, owner: str | None) -> Result:
26
- """路由:空串 = 释放;别人 = 交接;自己 = 已认领则空转、没认领先认领。"""
26
+ def set_task(
27
+ repo: Path,
28
+ me: Identity,
29
+ tid: str,
30
+ *,
31
+ owner: str | None = None,
32
+ release: bool = False,
33
+ ) -> Result:
34
+ """路由:`--release` = 释放;别人的 uuid = 交接;自己的 uuid = 已认领则空转、没认领先认领。"""
35
+ if release:
36
+ if owner is not None:
37
+ raise Fail("--release 与 --owner 不能同时给:释放不带人,交接不带释放。")
38
+ return release_mod.release(repo, me, tid)
27
39
  if owner is None:
28
40
  raise Fail(
29
- "set 必须带 --owner:空串 = 释放回公共池;对方身份 = 交接(预留通道,agent 不用)。"
41
+ "set 必须二选一:`--release`(释放回公共池)或 `--owner <对方 uuid>`(交接)。"
30
42
  )
31
- target = owner.strip()
32
- if not target:
33
- return release_mod.release(repo, me, tid)
43
+ # 交接目标只收 uuid:名字查不到就当场报错,绝不去猜「他大概是这个人」
44
+ target = require_uuid(owner, field="--owner")
34
45
  if not is_me(me, target):
35
46
  return handoff_mod.handoff(repo, me, tid, to=target)
36
- mine = TaskStore(repo, shard_rel(me.email), identity=me)
47
+ mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
37
48
  if mine.get(tid) is not None:
38
49
  return Result(
39
50
  already_done=True,
@@ -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