task-ledger 1.2.9 → 1.3.1

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 (55) hide show
  1. package/README.md +29 -245
  2. package/SKILL.md +173 -88
  3. package/package.json +22 -22
  4. package/references/log-note.md +3 -1
  5. package/references/state-model.md +29 -27
  6. package/references/troubleshooting.md +98 -25
  7. package/scripts/ledgerkit/__init__.py +0 -0
  8. package/scripts/ledgerkit/actions/__init__.py +0 -0
  9. package/scripts/ledgerkit/actions/atom.py +154 -0
  10. package/scripts/ledgerkit/actions/bootstrap.py +27 -0
  11. package/scripts/ledgerkit/actions/claim.py +82 -0
  12. package/scripts/ledgerkit/actions/close_task.py +180 -0
  13. package/scripts/ledgerkit/actions/guard.py +140 -0
  14. package/scripts/ledgerkit/actions/handoff.py +74 -0
  15. package/scripts/ledgerkit/actions/list_tasks.py +53 -0
  16. package/scripts/ledgerkit/actions/log_progress.py +60 -0
  17. package/scripts/ledgerkit/actions/new_task.py +120 -0
  18. package/scripts/ledgerkit/actions/query.py +33 -0
  19. package/scripts/ledgerkit/actions/query_history.py +173 -0
  20. package/scripts/ledgerkit/actions/registry.py +82 -0
  21. package/scripts/ledgerkit/actions/release.py +51 -0
  22. package/scripts/ledgerkit/actions/result.py +88 -0
  23. package/scripts/ledgerkit/actions/set_task.py +43 -0
  24. package/scripts/ledgerkit/actions/show_task.py +27 -0
  25. package/scripts/ledgerkit/actions/validate.py +224 -0
  26. package/scripts/ledgerkit/core/__init__.py +0 -0
  27. package/scripts/ledgerkit/core/history_md.py +350 -0
  28. package/scripts/ledgerkit/core/identity.py +43 -0
  29. package/scripts/ledgerkit/core/ids.py +58 -0
  30. package/scripts/ledgerkit/core/labels.py +79 -0
  31. package/scripts/ledgerkit/core/paths.py +93 -0
  32. package/scripts/ledgerkit/core/time.py +102 -0
  33. package/scripts/ledgerkit/core/worklog_md.py +232 -0
  34. package/scripts/ledgerkit/env.py +93 -0
  35. package/scripts/ledgerkit/errors.py +15 -0
  36. package/scripts/ledgerkit/notices.py +28 -0
  37. package/scripts/ledgerkit/shell/__init__.py +0 -0
  38. package/scripts/ledgerkit/shell/cli.py +259 -0
  39. package/scripts/ledgerkit/shell/gitops.py +655 -0
  40. package/scripts/ledgerkit/shell/merge.py +266 -0
  41. package/scripts/ledgerkit/shell/presync.py +23 -0
  42. package/scripts/ledgerkit/shell/render.py +165 -0
  43. package/scripts/ledgerkit/shell/runner.py +225 -0
  44. package/scripts/ledgerkit/store/__init__.py +0 -0
  45. package/scripts/ledgerkit/store/atomic.py +130 -0
  46. package/scripts/ledgerkit/store/dirty.py +108 -0
  47. package/scripts/ledgerkit/store/history_store.py +189 -0
  48. package/scripts/ledgerkit/store/json_store.py +166 -0
  49. package/scripts/ledgerkit/store/locks.py +64 -0
  50. package/scripts/ledgerkit/store/owners_index.py +135 -0
  51. package/scripts/ledgerkit/store/task_store.py +31 -0
  52. package/scripts/ledgerkit/store/worklog_store.py +168 -0
  53. package/scripts/progress.py +8 -3021
  54. package/references/taxonomy.md +0 -26
  55. package/references/worklog.md +0 -39
@@ -0,0 +1,180 @@
1
+ """结案:历史加一行 → 封卷过程文档 → **最后**删分片那行。
2
+
3
+ `finished` 必填、由外壳算好(同一个值也用于算锁集与刷取范围)—— 业务层不许再调时钟,
4
+ 这是跨月刷错月份的唯一根因。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from ..core.identity import Identity
12
+ from ..core.labels import normalize_label, parse_tags
13
+ from ..core.paths import history_path_for, shard_rel, to_repo_rel, worklog_name
14
+ from ..core.worklog_md import (
15
+ append_exec_log,
16
+ exec_line,
17
+ has_frozen_line,
18
+ rewrite_label_line,
19
+ stamp_frozen,
20
+ )
21
+ from ..errors import Fail
22
+ from ..store.atomic import ensure_dir, fingerprint, restore, snapshot
23
+ from ..store.history_store import HIT, UNDECIDABLE, HistoryStore
24
+ from ..store.task_store import TaskStore
25
+ from ..store.worklog_store import WorklogStore
26
+ from .result import Result, rel
27
+
28
+ _COLS_FALLBACK_HINT = "警告:历史表列数不是 9 且无法整体升级,本次按现有列数追加。"
29
+
30
+
31
+ def close_task(
32
+ repo: Path,
33
+ me: Identity,
34
+ tid: str,
35
+ *,
36
+ summary: str,
37
+ finished: str,
38
+ category: str | None = None,
39
+ tags: tuple[str, ...] | None = None,
40
+ ) -> Result:
41
+ """写序四步:改标签行 → 历史加一行 → 封卷 → 删分片(永远是最后一步)。"""
42
+ if not finished:
43
+ raise Fail("结案必须由外壳先算好时间(内部错误:finished 为空)。")
44
+ text_summary = " ".join((summary or "").split())
45
+ if not text_summary:
46
+ raise Fail("结案要写一句「完成了什么」(--summary 不能为空)。")
47
+ new_category = normalize_label(category, field="分类") if category else None
48
+ new_tags = parse_tags(tags) if tags else None
49
+
50
+ logs = WorklogStore(repo)
51
+ history = HistoryStore(repo)
52
+ hist_path = repo / to_repo_rel(history_path_for(finished))
53
+
54
+ mine = TaskStore(repo, shard_rel(me.email), identity=me)
55
+ task = mine.get(tid) # 锁内重读
56
+ if task is None:
57
+ # 分片里已经没有这条:上次崩在「删分片」之后。只按过程文档名判重,命中即已结案
58
+ state = history.has_log(
59
+ hist_path, log_name=worklog_name(tid), created=None, title=None
60
+ )
61
+ if state == HIT:
62
+ return Result(
63
+ already_done=True,
64
+ where=rel(repo, hist_path),
65
+ notices=(f"任务 {tid} 已经结过案,无需重复。",),
66
+ )
67
+ raise Fail(f"任务 {tid} 不在我的分片里(只能结案自己的任务)。")
68
+
69
+ # 历史行那两列取**最终值**:给了参数用新值,没给用任务原值,两处必须一致
70
+ final_category = new_category if new_category else task.get("category")
71
+ final_tags = tuple(new_tags) if new_tags else tuple(task.get("tags") or ())
72
+
73
+ doc_path = logs.find(tid, task.get("worklog"))
74
+
75
+ # 写前核对:指纹任一变了就中止,同时留三份字节备用
76
+ paths = [p for p in (mine.path, hist_path, doc_path) if p is not None]
77
+ stamps = {p: fingerprint(p) for p in paths}
78
+ backups = {p: snapshot(p) for p in paths}
79
+
80
+ try:
81
+ log_name = Path(doc_path).name if doc_path else None
82
+ state = history.has_log(
83
+ hist_path,
84
+ log_name=log_name,
85
+ created=task.get("created"),
86
+ title=task.get("title"),
87
+ )
88
+ if state == HIT:
89
+ # 崩在②(历史行已写)之后、③/④之前:历史里已有,但分片行可能还在、
90
+ # 过程文档可能还没封卷。不补齐就留下「已结案却仍在办」的僵尸 ——
91
+ # 它还能收 log,甚至被释放推回公共池让别人认领(2026-10-24 修)。
92
+ notes = [f"任务 {tid} 已经结过案,无需重复。"]
93
+ if doc_path is not None:
94
+ text = logs.read(doc_path)
95
+ if not has_frozen_line(text):
96
+ frozen = stamp_frozen(text, finished, me.email)
97
+ frozen = append_exec_log(
98
+ frozen, exec_line(finished, me.email, "close", "结案封卷")
99
+ )
100
+ logs.write(doc_path, frozen)
101
+ notes.append("补齐上次未完成的封卷。")
102
+ if mine.get(tid) is not None:
103
+ mine.drop(tid)
104
+ notes.append("补齐上次未完成的移出分片。")
105
+ return Result(
106
+ already_done=True,
107
+ where=rel(repo, hist_path),
108
+ notices=tuple(notes),
109
+ )
110
+ if state == UNDECIDABLE:
111
+ raise Fail(
112
+ f"历史里有一条没有过程文档链接的旧行,判不出是不是 {tid} ⇒ 已中止,"
113
+ f"请人工看一眼 {rel(repo, hist_path)} 再重跑。"
114
+ )
115
+ # 写前核对(2026-10-23 补):三个文件的指纹与读到时不一致 ⇒ 有人在锁外动过,
116
+ # 一个字节都不写(snapshot 那份留着给下面的异常分支按字节退回)。
117
+ for path in paths:
118
+ if fingerprint(path) != stamps[path]:
119
+ raise Fail(
120
+ f"{rel(repo, path)} 在本命令执行期间被改动过"
121
+ "(可能是编辑器或 git 操作),已中止、未写入任何内容;重跑一次即可。"
122
+ )
123
+
124
+ # ① 改标签行(给了 --category / --tags 才做,且必须在封卷之前)
125
+ if doc_path is not None and (new_category or new_tags):
126
+ text = logs.read(doc_path)
127
+ logs.write(doc_path, rewrite_label_line(text, final_category, final_tags))
128
+
129
+ # ② 历史加一行(先 ensure_dir:首次结案时 history/ 可能还不存在)
130
+ ensure_dir(hist_path.parent)
131
+ out = history.append_row(
132
+ hist_path,
133
+ {
134
+ "finished": finished,
135
+ "created": task.get("created"),
136
+ "title": task.get("title") or "",
137
+ "category": final_category,
138
+ "tags": final_tags,
139
+ "owner": me.email,
140
+ "creator": task.get("creator") or me.email,
141
+ "summary": text_summary,
142
+ "worklog": task.get("worklog") if doc_path else None,
143
+ },
144
+ )
145
+ history.write(hist_path, out["text"])
146
+
147
+ # ③ 封卷(没有过程文档就跳过)
148
+ if doc_path is not None:
149
+ text = logs.read(doc_path)
150
+ if not has_frozen_line(text):
151
+ frozen = stamp_frozen(text, finished, me.email)
152
+ frozen = append_exec_log(
153
+ frozen, exec_line(finished, me.email, "close", "结案封卷")
154
+ )
155
+ logs.write(doc_path, frozen)
156
+
157
+ # ④ 删我分片那行(永远最后)
158
+ mine.drop(tid)
159
+ except Exception:
160
+ for path in paths:
161
+ if path in backups:
162
+ restore(path, backups[path])
163
+ raise
164
+
165
+ notices: list[str] = []
166
+ if out.get("fallback_cols"):
167
+ notices.append(_COLS_FALLBACK_HINT)
168
+ if doc_path is None:
169
+ notices.append(f"提示:任务 {tid} 没有过程文档,已跳过封卷,历史末列留空。")
170
+ return Result(
171
+ changed=("status", "finished"),
172
+ where=rel(repo, hist_path),
173
+ message=f"结案 {tid}:{task.get('title') or ''}".strip(),
174
+ payload={
175
+ "history_path": rel(repo, hist_path),
176
+ "worklog_path": rel(repo, doc_path) if doc_path else None,
177
+ "fallback_cols": out.get("fallback_cols"),
178
+ },
179
+ notices=tuple(notices),
180
+ )
@@ -0,0 +1,140 @@
1
+ """业务校验的共用件:任务号形态、任务存不存在、「这条是不是你的」。
2
+
3
+ **`owner` / `status` 的读取只走这里的四个谓词**:`store/` 里不许出现 `get("owner")`,
4
+ `actions/` 里除本文件外也不许裸写字段名比较。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ..core.identity import Identity, is_me, normalize
14
+ from ..core.labels import LEGACY_STATUSES
15
+ from ..core.paths import POOL_REL
16
+ from ..errors import Fail
17
+ from ..store.owners_index import OwnersIndex
18
+ from ..store.task_store import TaskStore
19
+ from .query import ListQuery
20
+
21
+
22
+ def current_identity(repo: Path) -> Identity:
23
+ """env 的本机身份 + `owners.json` 里这个身份的 `aliases`(不写盘、不登记)。"""
24
+ from .. import env
25
+ from ..core.identity import with_aliases
26
+
27
+ me = env.current(repo)
28
+ record = OwnersIndex(repo).get(me.email) or {}
29
+ aliases = tuple(record.get("aliases") or ())
30
+ return with_aliases(me, aliases) if aliases else me
31
+
32
+
33
+ def require_task(tasks: Mapping[str, Any], tid: str, *, where: Path) -> dict[str, Any]:
34
+ """取一条任务;缺失时给可读原因(带所在文件)。"""
35
+ task = tasks.get(tid)
36
+ if task is None:
37
+ raise Fail(f"任务 {tid} 不在 {_show(where)} 里。")
38
+ if not isinstance(task, dict):
39
+ raise Fail(f"任务 {tid} 在 {_show(where)} 里不是一条对象,台账可能坏了。")
40
+ return dict(task)
41
+
42
+
43
+ def require_owner(task: Mapping[str, Any], tid: str, me: Identity) -> None:
44
+ """只有当前 owner 能改。"""
45
+ owner = owner_of(task)
46
+ if not owner:
47
+ raise Fail(f"任务 {tid} 还没有人认领(在公共池里),先 claim 再改。")
48
+ if not is_me(me, owner):
49
+ raise Fail(f"任务 {tid} 的负责人是 {owner},不是你({me.label()})。")
50
+
51
+
52
+ def same_task_ignoring_stamp(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool:
53
+ """两份内容是不是只差 `updated`(交接重跑时以对方那份为准的判据)。"""
54
+ left = {k: v for k, v in a.items() if k not in ("updated", "updated_by")}
55
+ right = {k: v for k, v in b.items() if k not in ("updated", "updated_by")}
56
+ return left == right
57
+
58
+
59
+ def owner_of(task: Mapping[str, Any]) -> str | None:
60
+ """读 `owner` 的唯一入口。"""
61
+ value = task.get("owner")
62
+ return normalize(value) if isinstance(value, str) and value.strip() else None
63
+
64
+
65
+ def status_of(task: Mapping[str, Any]) -> str:
66
+ """读 `status` 的唯一入口;老数据 `blocked` 当进行中。"""
67
+ value = task.get("status")
68
+ if not isinstance(value, str) or not value.strip():
69
+ return "open"
70
+ value = value.strip().lower()
71
+ return "doing" if value in LEGACY_STATUSES else value
72
+
73
+
74
+ def is_mine(task: Mapping[str, Any], me: Identity) -> bool:
75
+ """这条任务是不是我的(含 aliases)。"""
76
+ return is_me(me, owner_of(task) or "")
77
+
78
+
79
+ def in_scope(task: Mapping[str, Any], q: ListQuery, *, me: Identity) -> bool:
80
+ """`list` 的过滤谓词:范围、状态、分类、标签、创建时间闭区间。"""
81
+ if q.scope == "mine" and not is_mine(task, me):
82
+ return False
83
+ if q.scope == "pool" and owner_of(task):
84
+ return False
85
+ if q.scope == "owner" and q.owner and normalize(q.owner) != owner_of(task):
86
+ return False
87
+ if q.statuses:
88
+ status = status_of(task)
89
+ wanted = {s for s in q.statuses}
90
+ if status not in wanted and not (
91
+ "doing" in wanted and status in LEGACY_STATUSES
92
+ ):
93
+ return False
94
+ if q.category and (task.get("category") or "") != q.category:
95
+ return False
96
+ if q.tag and q.tag not in (task.get("tags") or ()):
97
+ return False
98
+ created = task.get("created")
99
+ if q.since and isinstance(created, str) and created[:19] < q.since[:19]:
100
+ return False
101
+ return not (q.until and isinstance(created, str) and created[:19] > q.until[:19])
102
+
103
+
104
+ def _show(path: Path) -> str:
105
+ try:
106
+ return str(Path(path).name)
107
+ except Exception: # noqa: BLE001
108
+ return str(path)
109
+
110
+
111
+ def load_task(repo: Path, tid: str) -> tuple[dict[str, Any], list[Path]]:
112
+ """在池 + 索引登记的全部分片里找这条任务(只读),返回 (任务, 出现过的文件)。"""
113
+ index = OwnersIndex(repo)
114
+ index.assert_present()
115
+ found: dict[str, Any] | None = None
116
+ where: list[Path] = []
117
+ pool = TaskStore(repo, POOL_REL)
118
+ if pool.get(tid):
119
+ found = pool.get(tid)
120
+ where.append(pool.path)
121
+ for rel in _shard_rels(repo, index):
122
+ store = TaskStore(repo, rel)
123
+ task = store.get(tid)
124
+ if task:
125
+ where.append(store.path)
126
+ if found is None:
127
+ found = task
128
+ if found is None:
129
+ raise Fail(f"找不到任务 {tid}(池与已登记的分片里都没有)。")
130
+ return found, where
131
+
132
+
133
+ def _shard_rels(repo: Path, index: OwnersIndex) -> list[Path]:
134
+ out: list[Path] = []
135
+ for path in index.shards().values():
136
+ try:
137
+ out.append(path.relative_to(repo / "ledger"))
138
+ except ValueError: # pragma: no cover
139
+ continue
140
+ return out
@@ -0,0 +1,74 @@
1
+ """交接:先写进对方分片,再删我分片那行,再记一笔。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..core.identity import Identity, normalize
8
+ from ..core.paths import shard_rel
9
+ from ..core.time import now
10
+ from ..core.worklog_md import exec_line
11
+ from ..errors import Fail
12
+ from ..store.owners_index import OwnersIndex
13
+ from ..store.task_store import TaskStore
14
+ from ..store.worklog_store import WorklogStore
15
+ from .guard import same_task_ignoring_stamp
16
+ from .result import Result, rel
17
+
18
+
19
+ def handoff(repo: Path, me: Identity, tid: str, *, to: str) -> Result:
20
+ """写序:对方分片 → 删我分片那行 → 过程文档记一笔。"""
21
+ index = OwnersIndex(repo)
22
+ record = index.get(to)
23
+ if record is None:
24
+ raise Fail(
25
+ f"{to} 还没在索引里登记过 ⇒ 不能交接给他(写出一份对别人不存在的分片,他自己也看不到)。"
26
+ )
27
+ mine = TaskStore(repo, shard_rel(me.email), identity=me)
28
+ task = mine.get(tid) # 锁内重读
29
+ if task is None:
30
+ raise Fail(f"任务 {tid} 不在我的分片里,交接不了。")
31
+
32
+ to_key = normalize(to)
33
+ to_store = TaskStore(repo, shard_rel(to_key))
34
+ moved = dict(task)
35
+ moved["owner"] = to_key
36
+ moved["updated"] = now()
37
+
38
+ existing = to_store.get(tid)
39
+ if existing is not None:
40
+ if not same_task_ignoring_stamp(existing, moved):
41
+ raise Fail(
42
+ f"任务 {tid} 在 {to_key} 的分片里已经存在,且内容与本次不一致 ⇒ 拒绝交接。"
43
+ )
44
+ # 重跑:以对方那份为准,只补删我这边
45
+ mine.drop(tid)
46
+ logs = WorklogStore(repo)
47
+ path = logs.find(tid, moved.get("worklog"))
48
+ if path is not None:
49
+ logs.append_exec(
50
+ path, exec_line(now(), me.email, "set", f"交接给 {to_key}(补齐)")
51
+ )
52
+ return Result(
53
+ already_done=True,
54
+ where=rel(repo, to_store.path),
55
+ message=f"交接 {tid} → {to_key}:补齐上次未完成的清理",
56
+ notices=(f"任务 {tid} 已在对方分片里(内容一致),本次只补删我分片那行。",),
57
+ )
58
+
59
+ to_store.put(tid, moved)
60
+ mine.drop(tid)
61
+
62
+ logs = WorklogStore(repo)
63
+ path = logs.find(tid, moved.get("worklog"))
64
+ note = None
65
+ if path is None:
66
+ note = f"任务 {tid} 已交给 {to_key},但过程文档没找到,跳过执行记录。"
67
+ else:
68
+ logs.append_exec(path, exec_line(now(), me.email, "set", f"交接给 {to_key}"))
69
+ return Result(
70
+ changed=("owner", "updated"),
71
+ where=rel(repo, to_store.path),
72
+ message=f"交接 {tid} → {to_key}",
73
+ notices=(note,) if note else (),
74
+ )
@@ -0,0 +1,53 @@
1
+ """只读:查在办任务(池 + 分片,后读到的为准)。不拿锁、不跑自举、不做取最新。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..core.identity import Identity
9
+ from ..core.paths import POOL_REL
10
+ from ..store.owners_index import OwnersIndex
11
+ from ..store.task_store import TaskStore
12
+ from .guard import in_scope, owner_of, status_of
13
+ from .query import ListQuery
14
+ from .result import ListResult, TaskRow, rel
15
+
16
+
17
+ def list_tasks(repo: Path, me: Identity, q: ListQuery) -> ListResult:
18
+ """合并顺序定死:先池、再按索引键排序的各分片,同一任务号由分片那份胜出。"""
19
+ rows: dict[str, dict[str, Any]] = {}
20
+ where: dict[str, Path] = {}
21
+
22
+ pool = TaskStore(repo, POOL_REL)
23
+ for tid, task in pool.all().items():
24
+ rows[tid] = task
25
+ where[tid] = pool.path
26
+
27
+ index = OwnersIndex(repo)
28
+ index.assert_present()
29
+ for key in sorted(index.shards()):
30
+ path = index.shards()[key]
31
+ store = TaskStore(repo, Path(path).relative_to(repo / "ledger"))
32
+ for tid, task in store.all().items():
33
+ rows[tid] = task
34
+ where[tid] = store.path
35
+
36
+ kept = {tid: t for tid, t in rows.items() if in_scope(t, q, me=me)}
37
+ table = tuple(
38
+ TaskRow(
39
+ tid=tid,
40
+ title=str(task.get("title") or ""),
41
+ status=status_of(task),
42
+ owner=owner_of(task),
43
+ created=task.get("created"),
44
+ started=task.get("started"),
45
+ category=task.get("category"),
46
+ tags=tuple(task.get("tags") or ()),
47
+ where=rel(repo, where[tid]),
48
+ )
49
+ for tid, task in sorted(
50
+ kept.items(), key=lambda kv: str(kv[1].get("created") or "")
51
+ )
52
+ )
53
+ return ListResult(rows=kept, table=table)
@@ -0,0 +1,60 @@
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 shard_rel
9
+ from ..core.time import now
10
+ from ..core.worklog_md import exec_line
11
+ from ..errors import Fail
12
+ from ..store.task_store import TaskStore
13
+ from ..store.worklog_store import WorklogStore
14
+ from .result import Result, rel
15
+
16
+
17
+ def log_progress(repo: Path, me: Identity, tid: str, note: str) -> Result:
18
+ """写序:刷 `updated` → 过程文档追加一行。
19
+
20
+ 文档写不进(`append_exec` 回 False)⇒ **不算失败**:分片已经刷了就照常提交,
21
+ 只留一条警告 —— 抛错让外壳回滚会变成「改了又不提交」,下一条命令被写前 guard 拦死。
22
+ """
23
+ text = " ".join((note or "").split())
24
+ if not text:
25
+ raise Fail("--note 不能为空。")
26
+ mine = TaskStore(repo, shard_rel(me.email), identity=me)
27
+ task = mine.get(tid) # 锁内重读
28
+ if task is None:
29
+ raise Fail(f"任务 {tid} 不在我的分片里(只能给自己的任务记进展)。")
30
+
31
+ updated = dict(task)
32
+ updated["updated"] = now()
33
+ mine.put(tid, updated)
34
+
35
+ logs = WorklogStore(repo)
36
+ path = logs.find(tid, updated.get("worklog"))
37
+ if path is None:
38
+ return Result(
39
+ changed=("updated",),
40
+ where=rel(repo, mine.path),
41
+ message=f"{tid}:记进展",
42
+ notices=(
43
+ "提示:这条任务没有过程文档,已跳过执行记录(分片的最后更新已刷)。",
44
+ ),
45
+ )
46
+ ok = logs.append_exec(path, exec_line(now(), me.email, "log", text))
47
+ if not ok:
48
+ return Result(
49
+ changed=("updated",),
50
+ where=rel(repo, mine.path),
51
+ message=f"{tid}:记进展",
52
+ notices=(
53
+ "警告:分片的最后更新已刷,过程文档补不上(文件不可写);已写的分片照常提交,文档那行要手动补。",
54
+ ),
55
+ )
56
+ return Result(
57
+ changed=("updated",),
58
+ where=rel(repo, mine.path),
59
+ message=f"{tid}:记进展",
60
+ )
@@ -0,0 +1,120 @@
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 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.email), 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} 的过程文档已经存在(同名号的在办 / 已结案任务占着同一篇文档)"
70
+ "⇒ 换一个 --id,别复用历史号。"
71
+ )
72
+
73
+ doc_rel = worklog_rel(tid, created) # 别叫 rel:那是 result.rel 的函数名
74
+ path = repo / to_repo_rel(doc_rel)
75
+ logs = WorklogStore(repo)
76
+ before_doc = snapshot(path)
77
+
78
+ task = {
79
+ "title": clean_title,
80
+ "created": created,
81
+ # --self = 建并认领:直接归自己名下就必须是在做,不留 open 半成品
82
+ "status": "doing" if mine else "open",
83
+ "owner": me.email if mine else None,
84
+ "creator": me.email,
85
+ "category": category,
86
+ "tags": list(tag_list),
87
+ "worklog": doc_rel,
88
+ "started": created if mine else None,
89
+ "updated": created,
90
+ "finished": None,
91
+ }
92
+ try:
93
+ ensure_dir(path.parent)
94
+ _ = logs.create(
95
+ tid=tid,
96
+ title=clean_title,
97
+ created=created,
98
+ creator=me.email,
99
+ category=category,
100
+ tags=tag_list,
101
+ first_exec=exec_line(
102
+ created, me.email, "new", "创建任务并认领" if mine else "创建任务"
103
+ ),
104
+ )
105
+ target.put(tid, task)
106
+ except Exception:
107
+ # 同一个 atom 内收尾:台账那条 drop 掉,文档原本不存在 ⇒ restore(None) 删掉
108
+ target.drop(tid)
109
+ restore(path, before_doc)
110
+ raise
111
+
112
+ text = logs.read(path)
113
+ return Result(
114
+ changed=FIELDS,
115
+ where=rel(repo, target.path),
116
+ message=f"新建任务 {tid}:{clean_title}",
117
+ worklog=WorklogView(path=rel(repo, path), text=text, error=None),
118
+ payload={"tid": tid},
119
+ notices=(f"新建过程文档:{rel(repo, path)}",),
120
+ )
@@ -0,0 +1,33 @@
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" 时生效
12
+ statuses: tuple[str, ...] = ()
13
+ category: str | None = None
14
+ tag: str | None = None
15
+ since: str | None = None # UTC 串,闭区间
16
+ until: str | None = None # 同上
17
+ # `since` / `until` **一律比 `created`**(含边界,只给其一也行),不比 started / updated。
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class HistoryQuery:
22
+ owner: str | None = None
23
+ mine: bool = False
24
+ category: str | None = None
25
+ tag: str | None = None
26
+ since: str | None = None # UTC 串,闭区间;比 finished(结案时间),不比 created
27
+ until: str | None = None # 同上
28
+ limit: int | None = None
29
+ months: int | None = None # 最近 N 个自然月;与 since/until 互斥
30
+ count_only: bool = False # 只要计数,不要 records
31
+
32
+
33
+ COUNT_CAP = 10_000 # --count-only 的封顶:超过就 capped=True