dsh-task-ledger 0.0.101 → 0.0.102

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 (67) hide show
  1. package/dist/client.js +2 -2
  2. package/dist/host/repositories/runner.d.ts +1 -1
  3. package/dist/host/repositories/runner.js +10 -7
  4. package/dist/host/skill/registration.d.ts +2 -1
  5. package/dist/host/skill/registration.js +4 -3
  6. package/package.json +1 -1
  7. package/skill/README.md +4 -2
  8. package/skill/SKILL.md +5 -3
  9. package/skill/assets/worklog.md +1 -1
  10. package/skill/package.json +1 -1
  11. package/skill/references/init-identity.md +3 -3
  12. package/skill/references/log-note.md +1 -1
  13. package/skill/references/troubleshooting.md +1 -1
  14. package/skill/scripts/bin/ledger-darwin-arm64 +0 -0
  15. package/skill/scripts/bin/ledger-linux-amd64 +0 -0
  16. package/skill/scripts/bin/ledger-windows-amd64.exe +0 -0
  17. package/skill/scripts/progress +44 -0
  18. package/skill/scripts/ledgerkit/__init__.py +0 -0
  19. package/skill/scripts/ledgerkit/actions/__init__.py +0 -0
  20. package/skill/scripts/ledgerkit/actions/atom.py +0 -154
  21. package/skill/scripts/ledgerkit/actions/bootstrap.py +0 -37
  22. package/skill/scripts/ledgerkit/actions/claim.py +0 -88
  23. package/skill/scripts/ledgerkit/actions/close_task.py +0 -191
  24. package/skill/scripts/ledgerkit/actions/guard.py +0 -158
  25. package/skill/scripts/ledgerkit/actions/handoff.py +0 -93
  26. package/skill/scripts/ledgerkit/actions/init_identity.py +0 -90
  27. package/skill/scripts/ledgerkit/actions/list_tasks.py +0 -74
  28. package/skill/scripts/ledgerkit/actions/log_progress.py +0 -67
  29. package/skill/scripts/ledgerkit/actions/new_task.py +0 -129
  30. package/skill/scripts/ledgerkit/actions/query.py +0 -35
  31. package/skill/scripts/ledgerkit/actions/query_history.py +0 -220
  32. package/skill/scripts/ledgerkit/actions/registry.py +0 -82
  33. package/skill/scripts/ledgerkit/actions/relabel.py +0 -89
  34. package/skill/scripts/ledgerkit/actions/release.py +0 -76
  35. package/skill/scripts/ledgerkit/actions/result.py +0 -90
  36. package/skill/scripts/ledgerkit/actions/set_task.py +0 -71
  37. package/skill/scripts/ledgerkit/actions/show_task.py +0 -55
  38. package/skill/scripts/ledgerkit/actions/validate.py +0 -189
  39. package/skill/scripts/ledgerkit/core/__init__.py +0 -0
  40. package/skill/scripts/ledgerkit/core/history_md.py +0 -152
  41. package/skill/scripts/ledgerkit/core/identity.py +0 -54
  42. package/skill/scripts/ledgerkit/core/ids.py +0 -58
  43. package/skill/scripts/ledgerkit/core/labels.py +0 -89
  44. package/skill/scripts/ledgerkit/core/paths.py +0 -92
  45. package/skill/scripts/ledgerkit/core/roots.py +0 -74
  46. package/skill/scripts/ledgerkit/core/time.py +0 -103
  47. package/skill/scripts/ledgerkit/core/worklog_md.py +0 -435
  48. package/skill/scripts/ledgerkit/env.py +0 -94
  49. package/skill/scripts/ledgerkit/errors.py +0 -15
  50. package/skill/scripts/ledgerkit/notices.py +0 -28
  51. package/skill/scripts/ledgerkit/shell/__init__.py +0 -0
  52. package/skill/scripts/ledgerkit/shell/cli.py +0 -532
  53. package/skill/scripts/ledgerkit/shell/gitops.py +0 -668
  54. package/skill/scripts/ledgerkit/shell/merge.py +0 -266
  55. package/skill/scripts/ledgerkit/shell/presync.py +0 -23
  56. package/skill/scripts/ledgerkit/shell/render.py +0 -216
  57. package/skill/scripts/ledgerkit/shell/runner.py +0 -232
  58. package/skill/scripts/ledgerkit/store/__init__.py +0 -0
  59. package/skill/scripts/ledgerkit/store/atomic.py +0 -130
  60. package/skill/scripts/ledgerkit/store/dirty.py +0 -108
  61. package/skill/scripts/ledgerkit/store/history_store.py +0 -134
  62. package/skill/scripts/ledgerkit/store/json_store.py +0 -166
  63. package/skill/scripts/ledgerkit/store/locks.py +0 -64
  64. package/skill/scripts/ledgerkit/store/owners_index.py +0 -127
  65. package/skill/scripts/ledgerkit/store/task_store.py +0 -28
  66. package/skill/scripts/ledgerkit/store/worklog_store.py +0 -178
  67. package/skill/scripts/progress.py +0 -20
@@ -1,129 +0,0 @@
1
- """建任务:拿外壳算好的任务号写过程文档、写进池(或直接写进自己的分片)。
2
-
3
- `tid` / `created` 由外壳算好传进来(锁层要靠它们算出那篇文档的路径),
4
- 业务层不许现算现用;缺了就是外壳的 bug,直接 Fail。
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from pathlib import Path
10
-
11
- from ..core.identity import Identity
12
- from ..core.ids import require_task_id
13
- from ..core.labels import normalize_label, parse_tags
14
- from ..core.paths import (
15
- POOL_REL,
16
- shard_rel,
17
- to_repo_rel,
18
- worklog_rel,
19
- )
20
- from ..core.worklog_md import StatusLine, exec_line
21
- from ..errors import Fail
22
- from ..store.atomic import ensure_dir, restore, snapshot
23
- from ..store.task_store import TaskStore
24
- from ..store.worklog_store import WorklogStore
25
- from .result import Result, WorklogView, rel
26
-
27
- FIELDS = ("created", "title", "status", "owner", "creator", "worklog")
28
-
29
-
30
- def new_task(
31
- repo: Path,
32
- me: Identity,
33
- *,
34
- title: str,
35
- tid: str,
36
- created: str,
37
- category: str | None = None,
38
- tags: tuple[str, ...] = (),
39
- mine: bool = False,
40
- ) -> Result:
41
- """写序:`ensure_dir(当月文档目录)` → 过程文档(含首条记录)→ 台账。"""
42
- if not (title or "").strip():
43
- raise Fail("标题不能为空。")
44
- if not tid:
45
- raise Fail("建任务必须由外壳先算好任务号(内部错误:tid 为空)。")
46
- if not created:
47
- raise Fail("建任务必须由外壳先算好创建时间(内部错误:created 为空)。")
48
- tid = require_task_id(tid)
49
- clean_title = " ".join(title.strip().split())
50
- category = normalize_label(category, field="分类") if category else None
51
- tag_list = parse_tags(tags)
52
-
53
- mine_store = TaskStore(repo, shard_rel(me.uuid), identity=me)
54
- pool = TaskStore(repo, POOL_REL)
55
- target = mine_store if mine else pool
56
-
57
- # 查重:池 + 我自己的分片,**并且在锁内重读**(锁外的快照只配决定锁多宽)
58
- if target.get(tid) is not None:
59
- raise Fail(f"任务号 {tid} 已经存在了(这不是重跑,是重号)。")
60
- other = pool if mine else mine_store
61
- if other.get(tid) is not None:
62
- raise Fail(f"任务号 {tid} 已经存在了(这不是重跑,是重号)。")
63
- # 已结案的号也不能复用,且跨月同号互不干扰:直接看过程文档路径在不在。
64
- # 在办任务的文档在磁盘上、已结案任务的文档也留在磁盘上 ⇒ 文档存在即这个号被占过
65
- # (撞的是同一篇 worklog 文件,会覆盖 / 日后让 close 误判 already_done 变僵尸)。
66
- # O(1),不必扫历史文件。上面分片 / 池是并发安全闸门,这里补文件级的精确判重。
67
- if (repo / to_repo_rel(worklog_rel(tid, created))).exists():
68
- raise Fail(
69
- f"任务号 {tid} 的过程文档已经存在(同名号的在办 / 已结案任务占着同一篇文档)。\n"
70
- " 处理:换一个 --id,别复用历史号。\n"
71
- " 若上次 new 崩在中间(文档已写、台账没落),这篇就是它留下的:确认内容要保留就换号重建;"
72
- "非要复用这一号,先把那篇文档挪走再重跑。"
73
- )
74
-
75
- doc_rel = worklog_rel(tid, created) # 别叫 rel:那是 result.rel 的函数名
76
- path = repo / to_repo_rel(doc_rel)
77
- logs = WorklogStore(repo)
78
- before_doc = snapshot(path)
79
-
80
- task = {
81
- "title": clean_title,
82
- "created": created,
83
- # --self = 建并认领:直接归自己名下就必须是在做,不留 open 半成品
84
- "status": "doing" if mine else "open",
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,
89
- "category": category,
90
- "tags": list(tag_list),
91
- "worklog": doc_rel,
92
- "started": created if mine else None,
93
- "updated": created,
94
- "finished": None,
95
- }
96
- try:
97
- ensure_dir(path.parent)
98
- _ = logs.create(
99
- tid=tid,
100
- title=clean_title,
101
- created=created,
102
- creator=me.stamp(),
103
- tags=tag_list,
104
- status=StatusLine(
105
- "doing" if mine else "open",
106
- me.name if mine else None,
107
- me.uuid if mine else None,
108
- created,
109
- ),
110
- first_exec=exec_line(
111
- created, me.stamp(), "new", "创建任务并认领" if mine else "创建任务"
112
- ),
113
- )
114
- target.put(tid, task)
115
- except Exception:
116
- # 同一个 atom 内收尾:台账那条 drop 掉,文档原本不存在 ⇒ restore(None) 删掉
117
- target.drop(tid)
118
- restore(path, before_doc)
119
- raise
120
-
121
- text = logs.read(path)
122
- return Result(
123
- changed=FIELDS,
124
- where=rel(repo, target.path),
125
- message=f"新建任务 {tid}:{clean_title}",
126
- worklog=WorklogView(path=rel(repo, path), text=text, error=None),
127
- payload={"tid": tid},
128
- notices=(f"新建过程文档:{rel(repo, path)}",),
129
- )
@@ -1,35 +0,0 @@
1
- """查询条件长什么样。外壳把命令行装成它,界面直接构造它 —— **动作层不认识 argparse**。"""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
-
7
-
8
- @dataclass(frozen=True)
9
- class ListQuery:
10
- scope: str = "all" # "all" / "pool" / "mine" / "owner"
11
- owner: str | None = None # scope="owner" 时生效;**只收 uuid**
12
- owner_name: str | None = None # 显示名模糊筛(子串、不分大小写);只筛不判定
13
- statuses: tuple[str, ...] = ()
14
- category: str | None = None
15
- tag: str | None = None
16
- since: str | None = None # UTC 串,闭区间
17
- until: str | None = None # 同上
18
- # `since` / `until` **一律比 `created`**(含边界,只给其一也行),不比 started / updated。
19
-
20
-
21
- @dataclass(frozen=True)
22
- class HistoryQuery:
23
- owner: str | None = None # **只收 uuid**
24
- owner_name: str | None = None # 显示名模糊筛(子串、不分大小写)
25
- mine: bool = False
26
- category: str | None = None
27
- tag: str | None = None
28
- since: str | None = None # UTC 串,闭区间;比 finished(结案时间),不比 created
29
- until: str | None = None # 同上
30
- limit: int | None = None
31
- months: int | None = None # 最近 N 个自然月;与 since/until 互斥
32
- count_only: bool = False # 只要计数,不要 records
33
-
34
-
35
- COUNT_CAP = 10_000 # --count-only 的封顶:超过就 capped=True
@@ -1,220 +0,0 @@
1
- """只读:查已结案(按结案时间过滤,倒序取最近)。不写任何字节、不依赖格式升级。
2
-
3
- 时间区间一律落在**结案时间** `finished` 上 —— 历史文件本来就按结案月归档
4
- (`history/<结案年月>.md`),按创建时间过滤会漏掉「上月建、本月结」的记录。
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from dataclasses import replace
10
- from datetime import datetime, timedelta
11
- from pathlib import Path
12
- from typing import Any
13
-
14
- from ..core import history_md
15
- from ..core.identity import Identity, normalize
16
- from ..core.paths import HISTORY_DIR, month_of, to_repo_rel
17
- from ..core.time import now, time_key
18
- from ..errors import Fail
19
- from ..store.history_store import HistoryStore
20
- from ..store.owners_index import OwnersIndex
21
- from .guard import is_mine, require_uuid
22
- from .query import COUNT_CAP, HistoryQuery
23
- from .result import HistoryResult, rel
24
-
25
- #: 一次最多读多少个历史文件(按月分文件;不区分连续 / 分散,只数总数)。
26
- MAX_HISTORY_FILES = 13
27
- #: 没给任何时间参数时,默认只看最近几个自然月。
28
- DEFAULT_HISTORY_MONTHS = 3
29
-
30
-
31
- def _recent_month_keys(count: int) -> list[str]:
32
- """最近 count 个自然月的月份键(含当月),按当前 UTC 推,从新到旧。"""
33
- stamp = now()
34
- year, month = int(stamp[0:4]), int(stamp[5:7])
35
- keys: list[str] = []
36
- for _ in range(max(count, 0)):
37
- keys.append(f"{year:04d}{month:02d}")
38
- month -= 1
39
- if month == 0:
40
- year, month = year - 1, 12
41
- return keys
42
-
43
-
44
- def _shift_month(ym: str, delta: int) -> str:
45
- """`YYYYMM` 偏移 delta 个月;越过年份边界自动进位 / 借位。"""
46
- idx = int(ym[0:4]) * 12 + (int(ym[4:6]) - 1) + delta
47
- return f"{idx // 12:04d}{idx % 12 + 1:02d}"
48
-
49
-
50
- def _month_keys_between(lo_ym: str, hi_ym: str) -> list[str]:
51
- """`[lo_ym, hi_ym]` 闭区间内的月份键(升序);两端给反了也能兜住。"""
52
- if lo_ym > hi_ym:
53
- lo_ym, hi_ym = hi_ym, lo_ym
54
- keys: list[str] = []
55
- cur = lo_ym
56
- while cur <= hi_ym:
57
- keys.append(cur)
58
- cur = _shift_month(cur, 1)
59
- return keys
60
-
61
-
62
- def _month_end(ym: str) -> str:
63
- """`YYYYMM` 所在月的最后一秒(`YYYY-MM-DD HH:MM:SS`),用于把老平铺文件也框进区间。"""
64
- year, month = int(ym[0:4]), int(ym[4:6])
65
- nxt = datetime(year + (1 if month == 12 else 0), (month % 12) + 1, 1)
66
- return (nxt - timedelta(seconds=1)).strftime("%Y-%m-%d %H:%M:%S")
67
-
68
-
69
- def _target_month_keys(q: HistoryQuery) -> list[str]:
70
- """由时间参数算出「要读的月份键」;超过上限直接 Fail(不碰磁盘)。
71
-
72
- - 无任何时间参数 ⇒ 最近 `DEFAULT_HISTORY_MONTHS` 个自然月;
73
- - `--months N` ⇒ 最近 N 个自然月(与 `--since/--until` 互斥);
74
- - `--since/--until` ⇒ 结案月区间(只给一端时,另一端按默认口径补齐)。
75
- """
76
- if q.months is not None and (q.since or q.until):
77
- raise Fail("--months 与 --since/--until 不能同时用(时间口径二选一)。")
78
- if q.months is not None:
79
- if q.months < 1:
80
- raise Fail("--months 至少为 1。")
81
- keys = _recent_month_keys(q.months)
82
- elif q.since or q.until:
83
- if q.since:
84
- lo = month_of(q.since)
85
- elif q.until:
86
- # 只给 --until:往前补到默认窗口(与「默认近 3 月」一致)
87
- lo = _shift_month(month_of(q.until), -(DEFAULT_HISTORY_MONTHS - 1))
88
- else: # pragma: no cover - 上面已保证至少给了一个
89
- raise Fail("--since/--until 至少要给一个。")
90
- hi = month_of(q.until) if q.until else month_of(now())
91
- if not lo or not hi:
92
- raise Fail("--since/--until 时间格式不对,算不出月份。")
93
- keys = _month_keys_between(lo, hi)
94
- else:
95
- keys = _recent_month_keys(DEFAULT_HISTORY_MONTHS)
96
- # 上限卡的是「要读的文件总数」,不是月跨度:按月分文件,一个键一个文件;
97
- # 连不连续、将来把多段时间拼在一起也都只数总数。
98
- files = sorted(set(keys))
99
- if len(files) > MAX_HISTORY_FILES:
100
- raise Fail(
101
- f"历史查询一次最多读 {MAX_HISTORY_FILES} 个历史文件(按月分文件,不区分连续/分散),"
102
- f"本次要读 {len(files)} 个 ⇒ 缩小 --since/--until 或 --months。"
103
- )
104
- return files
105
-
106
-
107
- def _candidate_paths(repo: Path, keys: list[str]) -> list[Path]:
108
- """按月键【直接拼】`history/<年月>.md`(不扫磁盘)。"""
109
- return [repo / to_repo_rel(HISTORY_DIR / f"{ym}.md") for ym in keys]
110
-
111
-
112
- def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
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"))
121
- keys = _target_month_keys(q)
122
- store = HistoryStore(repo)
123
- records: list[dict[str, Any]] = []
124
- warnings: list[str] = []
125
- for path in _candidate_paths(repo, keys):
126
- if not path.is_file():
127
- continue # 该月无记录 / 老平铺文件不存在 ⇒ 跳过,不报错
128
- rows, warns = history_md.parse_history(store.read(path))
129
- warnings.extend(f"{rel(repo, path)}:{w}" for w in warns)
130
- for row in rows:
131
- row = dict(row)
132
- row["src"] = rel(repo, path)
133
- row["created"] = time_key(row.get("created") or "") or row.get("created")
134
- row["finished"] = time_key(row.get("finished") or "") or row.get("finished")
135
- # 每行自带 `mine`(只比 uuid):调用方不必自己拿 uuid 再比一次
136
- row["mine"] = is_mine(row, me)
137
- records.append(row)
138
-
139
- index = OwnersIndex(repo)
140
- roster = index.all()
141
- if q.mine:
142
- wanted = {normalize(me.uuid)}
143
- records = [r for r in records if normalize(r.get("owner") or "") in wanted]
144
- elif q.owner:
145
- key = normalize(q.owner)
146
- records = [r for r in records if normalize(r.get("owner") or "") == key]
147
- if q.owner_name:
148
- # 名字已经落在历史行里 ⇒ 直接比它(子串、不分大小写)。落的是**当时**那个名字,
149
- # 改名后的旧行仍留旧名 ⇒ 按新名筛不到旧行,这是快照语义,不是漏。
150
- # 老行(升级前落的)没有名字列,退回按 uuid 片段比,不至于一条都筛不出来。
151
- needle = q.owner_name.strip().lower()
152
- records = [
153
- r
154
- for r in records
155
- if needle in str(r.get("owner_name") or "").lower()
156
- or needle in normalize(r.get("owner") or "")
157
- ]
158
-
159
- # 名字**已经落盘**(`owner_name` / `creator_name` 两列)。这里只给没名字的**老行**
160
- # 按名册兜底补一次 —— 补的是读时展示值,不回写文件。
161
- for row in records:
162
- for field, target in (("owner", "owner_name"), ("creator", "creator_name")):
163
- if row.get(target):
164
- continue
165
- key = normalize(row.get(field) or "")
166
- name = str((roster.get(key) or {}).get("name") or "")
167
- if name:
168
- row[target] = name
169
- if q.category:
170
- records = [r for r in records if (r.get("category") or "") == q.category]
171
- if q.tag:
172
- records = [r for r in records if q.tag in (r.get("tags") or ())]
173
-
174
- # 有效结案区间:显式 --since/--until 优先(精确到秒),缺的一端用月份窗口补,
175
- # 这样老平铺文件里的行也会被同一条规则框住,不会在月份查询里「漏网」。
176
- lo_key, hi_key = min(keys), max(keys)
177
- eff_since = q.since or f"{lo_key[0:4]}-{lo_key[4:6]}-01 00:00:00"
178
- eff_until = q.until or _month_end(hi_key)
179
- if eff_since > eff_until:
180
- raise Fail(
181
- f"时间区间反了:--since({eff_since})晚于 --until({eff_until})。\n"
182
- " 处理:把两端换过来;只给一端就只给一端。"
183
- )
184
- records = [r for r in records if (r.get("finished") or "") >= eff_since]
185
- records = [r for r in records if (r.get("finished") or "") <= eff_until]
186
-
187
- records.sort(key=lambda r: str(r.get("finished") or ""), reverse=True)
188
- if q.count_only and q.limit is not None:
189
- raise Fail("--count-only 与 --limit 不能同时用:计数不截断,列表才截断。")
190
- limit: int | None = None
191
- if q.limit is not None:
192
- limit = int(q.limit)
193
- if limit < 1:
194
- raise Fail(f"--limit 必须 ≥ 1(收到 {limit})。")
195
- capped = False
196
- if q.count_only and len(records) > COUNT_CAP:
197
- capped = True
198
- records = records[:COUNT_CAP]
199
- elif limit is not None:
200
- capped = len(records) > limit # 截断了要让调用方知道「还有更多」
201
- records = records[:limit]
202
-
203
- filters = {
204
- "owner": q.owner,
205
- "owner_name": q.owner_name,
206
- "mine": q.mine,
207
- "category": q.category,
208
- "tag": q.tag,
209
- "since": q.since,
210
- "until": q.until,
211
- "limit": q.limit,
212
- "months": q.months,
213
- }
214
- return HistoryResult(
215
- count=len(records),
216
- filters=filters,
217
- records=tuple(records),
218
- warnings=tuple(warnings),
219
- capped=capped,
220
- )
@@ -1,82 +0,0 @@
1
- """动作名册:命令写法 → `ActionSpec`。只有 `shell/cli.py` 与 `shell/runner.py` 会 import 它。
2
-
3
- **渲染函数只存名字**,不 import `shell/render.py` —— 否则 actions → shell 的依赖就反了;
4
- 由 runner 用 `getattr(render, spec.render)` 取到函数。
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from collections.abc import Callable
10
- from dataclasses import dataclass, field
11
-
12
- from ..errors import Fail
13
- from . import (
14
- bootstrap,
15
- claim,
16
- close_task,
17
- list_tasks,
18
- log_progress,
19
- new_task,
20
- query_history,
21
- set_task,
22
- show_task,
23
- validate,
24
- )
25
-
26
-
27
- @dataclass(frozen=True)
28
- class ActionSpec:
29
- func: Callable # (repo, me, **params) -> Result | 视图对象
30
- render: str # 渲染函数**名**;runner 用 getattr 取
31
- readonly: bool = False
32
- lock_action: str = "" # 交给 lock_scope 的动作名;默认同命令名
33
- params: tuple[str, ...] = field(default=()) # 从参数里挑哪些键传给 func
34
-
35
-
36
- ACTIONS: dict[str, ActionSpec] = {
37
- "new": ActionSpec(
38
- new_task.new_task,
39
- "result",
40
- params=("title", "tid", "created", "category", "tags", "mine"),
41
- ),
42
- "claim": ActionSpec(claim.claim, "result", params=("tid",)),
43
- "set": ActionSpec(
44
- set_task.set_task,
45
- "result",
46
- params=("tid", "owner", "release", "tags"),
47
- lock_action="set",
48
- ),
49
- "log": ActionSpec(log_progress.log_progress, "result", params=("tid", "note")),
50
- "close": ActionSpec(
51
- close_task.close_task,
52
- "result",
53
- params=("tid", "summary", "finished", "tags"),
54
- ),
55
- "list": ActionSpec(
56
- list_tasks.list_tasks, "list_view", readonly=True, params=("q",)
57
- ),
58
- "show": ActionSpec(
59
- show_task.show_task, "show_view", readonly=True, params=("tid",)
60
- ),
61
- "history": ActionSpec(
62
- query_history.query_history, "history_view", readonly=True, params=("q",)
63
- ),
64
- "validate": ActionSpec(
65
- validate.validate, "validate_view", readonly=True, params=("deep",)
66
- ),
67
- }
68
-
69
- READONLY = tuple(name for name, spec in ACTIONS.items() if spec.readonly)
70
-
71
-
72
- def spec_of(command: str) -> ActionSpec:
73
- """按命令写法取名册项;不在名册里就是用法错(业务错,退 1 人话)。"""
74
- spec = ACTIONS.get(command)
75
- if spec is None:
76
- raise Fail(f"未知命令:{command}")
77
- return spec
78
-
79
-
80
- def bootstrap_func():
81
- """自举不是名册里的命令(runner 单独调),这里只给它的入口,避免 shell 直接 import 动作。"""
82
- return bootstrap.bootstrap
@@ -1,89 +0,0 @@
1
- """改标签:分片 `tags` + 过程文档头部标签行,一次写两处。
2
-
3
- **分类不可改**(2026-10-27 用户拍板):它在 `new` 时定死,之后没有任何入口能改 —
4
- 不进过程文档、不进 `set` / `close` 参数;历史行里那个值就是建任务时那个值。
5
-
6
- 只动「标签」这一件事,不碰归属与状态 —— 归属走 `--owner` / `--release`,
7
- 状态只随命令语义变。已结案的任务不在任何分片里,改不了(历史行是快照)。
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from pathlib import Path
13
-
14
- from ..core.identity import Identity
15
- from ..core.labels import parse_tags
16
- from ..core.paths import shard_rel
17
- from ..core.time import now
18
- from ..core.worklog_md import (
19
- StatusLine,
20
- append_exec_log,
21
- exec_line,
22
- rewrite_label_line,
23
- rewrite_status_line,
24
- )
25
- from ..errors import Fail
26
- from ..store.task_store import TaskStore
27
- from ..store.worklog_store import WorklogStore
28
- from .guard import require_owner
29
- from .result import Result, rel
30
-
31
-
32
- def relabel(
33
- repo: Path,
34
- me: Identity,
35
- tid: str,
36
- *,
37
- tags: str | tuple[str, ...] | None = None,
38
- ) -> Result:
39
- """写序:算最终值 → 过程文档(标签行 + 执行记录 + 状态行,一次写)→ 分片。
40
-
41
- `tags` 收字符串(顿号 / 逗号分隔)或已切好的元组 —— 外壳可能已经切过一遍。
42
- """
43
- new_tags = parse_tags(tags) if tags else None
44
- if new_tags is None:
45
- raise Fail("改标签要给 `--tags <a、b>`。")
46
-
47
- mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
48
- task = mine.get(tid) # 锁内重读
49
- if task is None:
50
- raise Fail(
51
- f"任务 {tid} 不在我的分片里(只能改自己名下在办的任务;已结案的任务改不了)。"
52
- )
53
- require_owner(task, tid, me)
54
-
55
- final_tags: list[str] = [str(tag) for tag in new_tags]
56
- if not final_tags:
57
- raise Fail("标签是空的,没有可写的值。")
58
-
59
- when = now()
60
- updated = dict(task)
61
- updated["tags"] = final_tags
62
- updated["updated"] = when
63
-
64
- logs = WorklogStore(repo)
65
- path = logs.find(tid, task.get("worklog"))
66
- if path is None:
67
- mine.put(tid, updated)
68
- return Result(
69
- changed=("tags", "updated"),
70
- where=rel(repo, mine.path),
71
- message=f"{tid}:改标签",
72
- notices=("提示:这条任务没有过程文档,只改了分片,文档与历史里看不到。",),
73
- )
74
-
75
- text = logs.read(path)
76
- note = "改标签:" + "、".join(final_tags)
77
- text = rewrite_label_line(text, final_tags)
78
- text = append_exec_log(text, exec_line(when, me.stamp(), "set", note))
79
- text = rewrite_status_line(
80
- text, StatusLine(task.get("status") or "doing", me.name, me.uuid, when)
81
- )
82
- logs.write(path, text)
83
- mine.put(tid, updated)
84
-
85
- return Result(
86
- changed=("tags", "updated"),
87
- where=rel(repo, mine.path),
88
- message=f"{tid}:改标签",
89
- )
@@ -1,76 +0,0 @@
1
- """释放:写回公共池,再删我分片那行,再记一笔。"""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
-
7
- from ..core.identity import Identity
8
- from ..core.paths import POOL_REL, shard_rel
9
- from ..core.time import now
10
- from ..core.worklog_md import StatusLine, exec_line
11
- from ..errors import Fail
12
- from ..store.task_store import TaskStore
13
- from ..store.worklog_store import WorklogStore
14
- from .guard import owner_of, require_owner, status_of
15
- from .result import Result, rel
16
-
17
-
18
- def release(repo: Path, me: Identity, tid: str) -> Result:
19
- """写序:写回池(已有同号就不覆盖)→ 删我分片那行 → 过程文档记一笔。"""
20
- pool = TaskStore(repo, POOL_REL)
21
- mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
22
- task = mine.get(tid) # 锁内重读
23
- if task is 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
- )
33
- return Result(
34
- already_done=True,
35
- where=rel(repo, pool.path),
36
- notices=(f"任务 {tid} 已经在公共池里,无需重复释放。",),
37
- )
38
- raise Fail(f"任务 {tid} 不在我的分片里,也没在公共池里。")
39
-
40
- # 分片里在、但行内 owner 不是我 ⇒ 台账脏了(validate ⑤):不许把别人的行推进池
41
- require_owner(task, tid, me)
42
-
43
- back = dict(task)
44
- back["owner"] = None
45
- back["owner_name"] = None # 冗余显示名跟着 owner 一起清,否则池里那条仍显示原负责人
46
- back["status"] = "open"
47
- back["updated"] = now()
48
- if pool.get(tid) is None:
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
- )
58
- mine.drop(tid)
59
-
60
- logs = WorklogStore(repo)
61
- path = logs.find(tid, back.get("worklog"))
62
- note = None
63
- if path is None:
64
- note = f"任务 {tid} 已退回公共池,但过程文档没找到,跳过执行记录。"
65
- else:
66
- logs.append_exec(
67
- path,
68
- exec_line(now(), me.stamp(), "release", "释放回公共池"),
69
- status=StatusLine("open", None, None, now()),
70
- )
71
- return Result(
72
- changed=("owner", "owner_name", "status", "updated"),
73
- where=rel(repo, pool.path),
74
- message=f"释放 {tid}:{back.get('title') or ''}".strip(),
75
- notices=(note,) if note else (),
76
- )