dsh-task-ledger 0.0.101 → 0.0.103

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 (71) hide show
  1. package/dist/client.js +3 -3
  2. package/dist/client.js.map +1 -1
  3. package/dist/host/errors.d.ts +2 -2
  4. package/dist/host/errors.js +2 -2
  5. package/dist/host/repositories/runner.d.ts +2 -2
  6. package/dist/host/repositories/runner.js +13 -10
  7. package/dist/host/services/session.service.js +1 -1
  8. package/dist/host/skill/registration.d.ts +2 -1
  9. package/dist/host/skill/registration.js +4 -3
  10. package/package.json +2 -2
  11. package/skill/README.md +4 -2
  12. package/skill/SKILL.md +5 -3
  13. package/skill/assets/worklog.md +1 -1
  14. package/skill/package.json +1 -1
  15. package/skill/references/init-identity.md +3 -3
  16. package/skill/references/log-note.md +1 -1
  17. package/skill/references/troubleshooting.md +1 -1
  18. package/skill/scripts/bin/ledger-darwin-arm64 +0 -0
  19. package/skill/scripts/bin/ledger-linux-amd64 +0 -0
  20. package/skill/scripts/bin/ledger-windows-amd64.exe +0 -0
  21. package/skill/scripts/progress +44 -0
  22. package/skill/scripts/ledgerkit/__init__.py +0 -0
  23. package/skill/scripts/ledgerkit/actions/__init__.py +0 -0
  24. package/skill/scripts/ledgerkit/actions/atom.py +0 -154
  25. package/skill/scripts/ledgerkit/actions/bootstrap.py +0 -37
  26. package/skill/scripts/ledgerkit/actions/claim.py +0 -88
  27. package/skill/scripts/ledgerkit/actions/close_task.py +0 -191
  28. package/skill/scripts/ledgerkit/actions/guard.py +0 -158
  29. package/skill/scripts/ledgerkit/actions/handoff.py +0 -93
  30. package/skill/scripts/ledgerkit/actions/init_identity.py +0 -90
  31. package/skill/scripts/ledgerkit/actions/list_tasks.py +0 -74
  32. package/skill/scripts/ledgerkit/actions/log_progress.py +0 -67
  33. package/skill/scripts/ledgerkit/actions/new_task.py +0 -129
  34. package/skill/scripts/ledgerkit/actions/query.py +0 -35
  35. package/skill/scripts/ledgerkit/actions/query_history.py +0 -220
  36. package/skill/scripts/ledgerkit/actions/registry.py +0 -82
  37. package/skill/scripts/ledgerkit/actions/relabel.py +0 -89
  38. package/skill/scripts/ledgerkit/actions/release.py +0 -76
  39. package/skill/scripts/ledgerkit/actions/result.py +0 -90
  40. package/skill/scripts/ledgerkit/actions/set_task.py +0 -71
  41. package/skill/scripts/ledgerkit/actions/show_task.py +0 -55
  42. package/skill/scripts/ledgerkit/actions/validate.py +0 -189
  43. package/skill/scripts/ledgerkit/core/__init__.py +0 -0
  44. package/skill/scripts/ledgerkit/core/history_md.py +0 -152
  45. package/skill/scripts/ledgerkit/core/identity.py +0 -54
  46. package/skill/scripts/ledgerkit/core/ids.py +0 -58
  47. package/skill/scripts/ledgerkit/core/labels.py +0 -89
  48. package/skill/scripts/ledgerkit/core/paths.py +0 -92
  49. package/skill/scripts/ledgerkit/core/roots.py +0 -74
  50. package/skill/scripts/ledgerkit/core/time.py +0 -103
  51. package/skill/scripts/ledgerkit/core/worklog_md.py +0 -435
  52. package/skill/scripts/ledgerkit/env.py +0 -94
  53. package/skill/scripts/ledgerkit/errors.py +0 -15
  54. package/skill/scripts/ledgerkit/notices.py +0 -28
  55. package/skill/scripts/ledgerkit/shell/__init__.py +0 -0
  56. package/skill/scripts/ledgerkit/shell/cli.py +0 -532
  57. package/skill/scripts/ledgerkit/shell/gitops.py +0 -668
  58. package/skill/scripts/ledgerkit/shell/merge.py +0 -266
  59. package/skill/scripts/ledgerkit/shell/presync.py +0 -23
  60. package/skill/scripts/ledgerkit/shell/render.py +0 -216
  61. package/skill/scripts/ledgerkit/shell/runner.py +0 -232
  62. package/skill/scripts/ledgerkit/store/__init__.py +0 -0
  63. package/skill/scripts/ledgerkit/store/atomic.py +0 -130
  64. package/skill/scripts/ledgerkit/store/dirty.py +0 -108
  65. package/skill/scripts/ledgerkit/store/history_store.py +0 -134
  66. package/skill/scripts/ledgerkit/store/json_store.py +0 -166
  67. package/skill/scripts/ledgerkit/store/locks.py +0 -64
  68. package/skill/scripts/ledgerkit/store/owners_index.py +0 -127
  69. package/skill/scripts/ledgerkit/store/task_store.py +0 -28
  70. package/skill/scripts/ledgerkit/store/worklog_store.py +0 -178
  71. package/skill/scripts/progress.py +0 -20
@@ -1,232 +0,0 @@
1
- """七步的执行顺序:唯一创建记账本、唯一决定退出码、唯一统一回滚的地方。
2
-
3
- 七步:绑记账本 → 取最新 → 自举 → 调动作 → 打印 → 提交 → 推送。
4
- (仓库定位在更前面,见 `run` 开头。)
5
-
6
- - `already_done` **不**跳过提交与推送(写没写只看记账本)。
7
- - 记账本为空 ⇒ 提交与推送都跳过(没脏文件就没得提交)。
8
- - **唯一不回滚的失败分支是 `BrokenPipeError`**:文件已经写完,照常提交推送。
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import argparse
14
- import sys
15
- from pathlib import Path
16
- from typing import Any
17
-
18
- from .. import notices
19
- from ..actions import registry
20
- from ..actions.atom import atom
21
- from ..actions.guard import current_identity
22
- from ..core.ids import make_id
23
- from ..core.paths import to_repo_rel
24
- from ..core.time import now, to_utc
25
- from ..errors import (
26
- BUSINESS,
27
- INTERRUPTED,
28
- OK,
29
- UNEXPECTED,
30
- Fail,
31
- )
32
- from ..store import dirty
33
- from ..store.atomic import snapshot
34
- from . import render
35
- from .gitops import GitRepo
36
- from .presync import presync_scope
37
-
38
-
39
- def run(command: str, args: argparse.Namespace) -> int:
40
- """跑一条命令,返回退出码。"""
41
- log = dirty.WriteLog()
42
- try:
43
- with dirty.bound(log):
44
- return _run(command, args, log)
45
- except Fail as exc:
46
- if log.commit_started:
47
- # 提交阶段才失败:数据已落盘,回滚 = 吃掉刚写的台账(2026-10-24 修)
48
- render.fail_message(exc)
49
- return BUSINESS
50
- log.rollback_presync()
51
- log.discard_created()
52
- render.fail_message(exc)
53
- return BUSINESS
54
- except BrokenPipeError:
55
- # 唯一不回滚的分支:文件已写,按记账本照常提交推送
56
- _finalize(command, args, log, broken=True)
57
- return OK
58
- except KeyboardInterrupt:
59
- if log.commit_started:
60
- return INTERRUPTED
61
- log.rollback_presync()
62
- log.discard_created()
63
- return INTERRUPTED
64
- except Exception as exc: # noqa: BLE001 —— 兜底:任何未预期异常都要留下可诊断信息
65
- if log.commit_started:
66
- render.fail_message(exc, title="未预期错误")
67
- return UNEXPECTED
68
- log.rollback_presync()
69
- log.discard_created()
70
- render.fail_message(exc, title="未预期错误")
71
- return UNEXPECTED
72
-
73
-
74
- def _run(command: str, args: argparse.Namespace, log: dirty.WriteLog) -> int:
75
- spec = registry.spec_of(command)
76
- repo = _repo_of(args)
77
- me = current_identity(repo)
78
- no_sync = bool(getattr(args, "no_sync", False))
79
- as_json = bool(getattr(args, "json", False))
80
-
81
- git = GitRepo(repo)
82
- syncing = not no_sync
83
- if syncing:
84
- # 中间态是**全局前提**,必须早于下面任何读台账的动作(`_params` 的 `new` 快照、
85
- # `presync_scope` 的锁集都要读台账):冲突标记下的台账不是合法 JSON,读它就抛
86
- # 「未预期错误」(退出码 3),而文档要的是「仓库处于中间态」这句人话。
87
- git.check_repo_state()
88
-
89
- params = _params(command, args, repo, me, spec)
90
-
91
- # 只读:不建 GitRepo、不进事务、不提交
92
- if spec.readonly:
93
- value = spec.func(repo, me, **params)
94
- # 先自报读的是哪个台账:`--repo` 指错目录时输出会变成一片「没有任务」,不带这一行
95
- # 就完全看不出读错了地方。`--count-only` 也出 JSON,同样不打(2026-10-25 改)
96
- render.repo_banner(
97
- repo, machine=as_json or bool(getattr(args, "count_only", False))
98
- )
99
- _render(spec, value, args, as_json=as_json)
100
- if getattr(value, "problems", ()):
101
- return BUSINESS # validate 有 problems ⇒ 退出码 1(§六)
102
- return OK
103
-
104
- boot_scope, act_scope = presync_scope(repo, me, spec.lock_action or command, params)
105
-
106
- if syncing:
107
- git.guard_before_write(boot_scope | act_scope)
108
- for path, before in git.sync_down(boot_scope | act_scope):
109
- log.record_presync(path, before)
110
-
111
- # 自举会登记自己(改 owners.json、缺文件时建池与分片)。它碰过的文件按「命令前的字节」
112
- # 记进记账本:成功时照常提交(登记必须进仓),失败时由 rollback_presync 一起还回去 ——
113
- # 否则一条失败的命令会把台账留在「已写未提交」,下一条写命令被写前 guard 拦死
114
- # (架构文档 §三.5 正是要避免这个)。登记是幂等的,还回去下次再登记,没有损失。
115
- for rel in boot_scope.files:
116
- log.record_presync(repo / to_repo_rel(rel), snapshot(repo / to_repo_rel(rel)))
117
- with atom(repo, *boot_scope.files):
118
- me = registry.bootstrap_func()(repo, me)
119
-
120
- with atom(repo, *act_scope.files):
121
- value = spec.func(repo, me, **params)
122
-
123
- _render(spec, value, args, as_json=as_json)
124
- _finalize(command, args, log, message=getattr(value, "message", None))
125
- return OK
126
-
127
-
128
- def _finalize(
129
- command: str,
130
- args: argparse.Namespace,
131
- log: dirty.WriteLog,
132
- *,
133
- message: str | None = None,
134
- broken: bool = False,
135
- ) -> None:
136
- """提交 + 推送:要不要做只看记账本(与 `already_done` 无关,只有 `--no-sync` 才整段跳过)。"""
137
- if broken:
138
- try:
139
- sys.stdout.close()
140
- except Exception: # noqa: BLE001, S110 # 关 stdout 本就无信息可记
141
- pass
142
- if getattr(args, "no_sync", False):
143
- if getattr(args, "no_sync", False) and log.has_writes():
144
- # 以前这条路径一个字都不打,「已写未提交」就此静默腐烂
145
- notices.note(
146
- "提示:--no-sync 本次只写了本地文件、未签入 git(也未推送)。\n"
147
- " 处理:确认后自行 git add ledger && git commit && git push;"
148
- "留着不提交的话,下一条写命令会被「本地有未提交的台账改动」拦住。"
149
- )
150
- log.clear()
151
- return
152
- if not log.has_writes():
153
- log.clear()
154
- return
155
- repo = _repo_of(args)
156
- git = GitRepo(repo)
157
- if not git.is_git_repo():
158
- # 没有 git 也能用:台账照写,只不做同步(2026-10-24 用户拍板)。
159
- # 以前这里直接 git add,非 git 目录下必失败 ⇒ 整条命令退 1,台账像没写。
160
- notices.note(
161
- "提示:当前目录不是 git 仓库,台账只写了本地文件(未提交、未推送)。\n"
162
- " 处理:想纳入版本管理就 git init;否则继续本地写也行 —— "
163
- "多人各写各的,后写覆盖先写。"
164
- )
165
- log.clear()
166
- return
167
- log.commit_started = True
168
- message = message or f"chore(ledger): {command}"
169
- git.commit(log.commit_set(), message)
170
- git.push()
171
- log.clear()
172
-
173
-
174
- def _render(spec: registry.ActionSpec, value, args, *, as_json: bool) -> None:
175
- func = getattr(render, spec.render)
176
- if spec.render in ("list_view", "history_view"):
177
- func(value, getattr(args, "q", None), as_json=as_json)
178
- else:
179
- func(value, as_json=as_json)
180
-
181
-
182
- def _params(
183
- command: str, args: argparse.Namespace, repo: Path, me, spec: registry.ActionSpec
184
- ) -> dict[str, Any]:
185
- """按 `spec.params` 从 args 里挑;**不许把整个 args 透传进业务层**。"""
186
- out: dict[str, Any] = {}
187
- for key in spec.params:
188
- out[key] = getattr(args, key, None)
189
- if command == "new":
190
- created = out.get("created") or now()
191
- if getattr(args, "created", None):
192
- created = to_utc(args.created, field="--created")
193
- out["created"] = created
194
- if not out.get("tid"):
195
- out["tid"] = make_id(
196
- out.get("title") or "", _task_snapshot(repo, me), created
197
- )
198
- out["tags"] = tuple(out.get("tags") or ())
199
- if command == "close":
200
- out["finished"] = (
201
- to_utc(args.finished, field="--finished")
202
- if getattr(args, "finished", None)
203
- else now()
204
- )
205
- out["tags"] = tuple(out.get("tags") or ())
206
- return out
207
-
208
-
209
- def _task_snapshot(repo: Path, me) -> dict[str, Any]:
210
- """建任务查重要用的在办任务快照(**锁外**只读,只用于生成不撞的号)。"""
211
- from ..core.paths import POOL_REL, shard_rel
212
- from ..store.task_store import TaskStore
213
-
214
- tasks: dict[str, Any] = {}
215
- tasks.update(TaskStore(repo, POOL_REL).all())
216
- tasks.update(TaskStore(repo, shard_rel(me.uuid)).all())
217
- return tasks
218
-
219
-
220
- def _repo_of(args: argparse.Namespace) -> Path:
221
- """工作区根:`cli.main` 已经解析好挂在 `args.repo` 上(定位只做一次)。
222
-
223
- 直接调 runner(测试 / 内嵌调用)时没挂过 ⇒ 现解一次,规则与 cli 一致。
224
- """
225
- repo = getattr(args, "repo", None)
226
- if repo:
227
- from ..core.roots import as_root # 门 2 兜底:绕过 cli 直调 runner 也不收错位根
228
-
229
- return as_root(repo, source="--repo")
230
- from .cli import resolve_repo # 延迟导入:cli 在模块层 import runner
231
-
232
- return resolve_repo(None)
File without changes
@@ -1,130 +0,0 @@
1
- """怎么安全地写一个文件:唯一写盘出口。
2
-
3
- 进门规则:不打印、不跑 git、不判断 owner/status、不自己加锁。
4
- 写完顺手通知 `dirty` 记一笔(那是记账本唯一被写的地方之一)。
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import hashlib
10
- import os
11
- import stat
12
- import tempfile
13
- from pathlib import Path
14
-
15
- from ..errors import Fail
16
- from . import dirty
17
-
18
-
19
- def ensure_dir(path: Path) -> None:
20
- """建目录(`mkdir -p` 语义)。**全仓唯一建目录的地方**,写文件不替人建父目录。"""
21
- try:
22
- path.mkdir(parents=True, exist_ok=True)
23
- except OSError as exc:
24
- raise Fail(f"建目录 {path} 失败:{exc}") from exc
25
-
26
-
27
- def write_text_atomic(path: Path, text: str, *, track: bool = True) -> None:
28
- """原子写文本:同目录临时文件 → 写 → fsync → 继承权限 → replace → fsync 父目录。
29
-
30
- `track=False` 只给 `restore`(复原世界)与外壳两条受控通道用 —— 那三处都不是业务改动。
31
- 两个属性必须保住:mkstemp 建的是 0600,直接 replace 会把 644 降成 600;
32
- 符号链接一律拒绝,否则 replace 会把链接替换成普通文件。
33
- """
34
- fd = None
35
- tmp: Path | None = None
36
- existed = False
37
- try:
38
- # 不替调用方建父目录:建目录只有 ensure_dir 一处,业务层要自己先 ensure_dir
39
- if not path.parent.exists():
40
- raise Fail(
41
- f"写 {path} 失败:父目录 {path.parent} 不存在(建目录要显式调 ensure_dir)。"
42
- )
43
- if path.is_symlink():
44
- raise Fail(f"拒绝写入 {path}:它是符号链接,写入会把它替换成普通文件")
45
- try:
46
- mode = stat.S_IMODE(path.stat().st_mode)
47
- existed = True
48
- except OSError:
49
- mode = 0o644 # 新文件:与 git 默认对齐
50
- fd, tmp_name = tempfile.mkstemp(
51
- dir=str(path.parent), prefix=path.name + ".", suffix=".tmp"
52
- )
53
- tmp = Path(tmp_name)
54
- with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
55
- fd = None # 所有权已交给文件对象
56
- fh.write(text)
57
- fh.flush()
58
- os.fsync(fh.fileno())
59
- os.chmod(tmp, mode)
60
- os.replace(tmp, path)
61
- _fsync_dir(path.parent)
62
- tmp = None
63
- except OSError as exc:
64
- raise Fail(
65
- f"写 {path} 失败:{exc}\n"
66
- " 处理:确认该路径可写、磁盘没满,然后重跑这条命令(文件是整份替换,不会写坏)。"
67
- ) from exc
68
- finally:
69
- if fd is not None:
70
- try:
71
- os.close(fd)
72
- except OSError:
73
- pass
74
- if tmp is not None and tmp.exists():
75
- try:
76
- tmp.unlink()
77
- except OSError:
78
- pass
79
- dirty.current().record(path, track=track, created=not existed)
80
-
81
-
82
- def _fsync_dir(dirpath: Path) -> None:
83
- """刷父目录:掉电时 rename 的目录项可能丢,只刷文件是不够的。"""
84
- try:
85
- fd = os.open(str(dirpath), os.O_RDONLY)
86
- except OSError:
87
- return
88
- try:
89
- os.fsync(fd)
90
- except OSError:
91
- pass
92
- finally:
93
- os.close(fd)
94
-
95
-
96
- def fingerprint(path: Path) -> str | None:
97
- """文件内容 sha256,用来判「我读完以后别人动过没有」。
98
-
99
- 三种返回值互不相等:正常 → sha256 串;不存在 → None;读不了 → `"unreadable"`。
100
- """
101
- try:
102
- data = path.read_bytes()
103
- except FileNotFoundError:
104
- return None
105
- except OSError:
106
- return "unreadable"
107
- return hashlib.sha256(data).hexdigest()
108
-
109
-
110
- def snapshot(path: Path) -> bytes | None:
111
- """写之前把原文留一份字节;原本不存在返回 None。"""
112
- try:
113
- return path.read_bytes()
114
- except FileNotFoundError:
115
- return None
116
- except OSError as exc:
117
- raise Fail(f"读 {path} 失败:{exc}") from exc
118
-
119
-
120
- def restore(path: Path, before: bytes | None) -> None:
121
- """按字节还原:`None` 表示原本不存在 ⇒ 删掉。恢复本身不进记账本(`track=False`)。"""
122
- if before is None:
123
- try:
124
- path.unlink()
125
- except FileNotFoundError:
126
- return
127
- except OSError as exc:
128
- raise Fail(f"撤销 {path} 失败:{exc}") from exc
129
- return
130
- write_text_atomic(path, before.decode("utf-8"), track=False)
@@ -1,108 +0,0 @@
1
- """记账本:这一轮到底动了哪些文件。**提交集合只看它**,不看 `Result.changed`。
2
-
3
- 三类记录各有各的用途:
4
- - `changed`:业务改动,最后要提交;
5
- - `created`:本次新建的文件,失败时要删掉(不能留下孤儿);
6
- - `presync`:取最新时刷过的文件(连"刷之前"的字节一起)——它们**反而必须**一起提交,
7
- 否则拉下来的新内容留在工作树里,下一条写命令会被写前 guard 拦死。
8
-
9
- 只返回清单、不打印(打印归外壳,否则 dirty 要 import shell,依赖反向)。
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import contextlib
15
- from collections.abc import Iterator
16
- from dataclasses import dataclass, field
17
- from pathlib import Path
18
-
19
-
20
- @dataclass
21
- class WriteLog:
22
- changed: list[Path] = field(default_factory=list)
23
- created: list[Path] = field(default_factory=list)
24
- presync: list[tuple[Path, bytes | None]] = field(default_factory=list)
25
- # 进入提交阶段(_finalize 开始 git add/commit)后置位:此时台账数据已落盘,
26
- # 失败 / 中断都不许回滚或删除 —— 那会吃掉刚写的台账(2026-10-24 修)。
27
- commit_started: bool = False
28
-
29
- def record(self, path: Path, *, track: bool = True, created: bool = False) -> None:
30
- """记一次写。`track=False`(复原 / 外壳刷盘)不进改动集合。"""
31
- if created and path not in self.created:
32
- self.created.append(path)
33
- if track and path not in self.changed:
34
- self.changed.append(path)
35
-
36
- def record_presync(self, path: Path, before: bytes | None) -> None:
37
- """记「取最新刷过这个文件」+ 刷之前的字节(失败时按它恢复)。"""
38
- for i, (known, _) in enumerate(self.presync):
39
- if known == path:
40
- self.presync[i] = (path, before)
41
- return
42
- self.presync.append((path, before))
43
-
44
- def commit_set(self) -> list[Path]:
45
- """最后要提交的文件:本次改动 + 取最新刷过的(去重保序)。"""
46
- out: list[Path] = []
47
- for path in [*self.changed, *(p for p, _ in self.presync)]:
48
- if path not in out:
49
- out.append(path)
50
- return out
51
-
52
- def has_writes(self) -> bool:
53
- return bool(self.changed or self.presync)
54
-
55
- def rollback_presync(self) -> list[Path]:
56
- """按备份把「被取最新覆盖掉」的内容恢复回去(刷之前不存在 ⇒ 删掉)。"""
57
- from .atomic import write_text_atomic
58
-
59
- done: list[Path] = []
60
- for path, before in reversed(self.presync):
61
- if before is None:
62
- try:
63
- path.unlink()
64
- except FileNotFoundError:
65
- pass
66
- else:
67
- write_text_atomic(path, before.decode("utf-8"), track=False)
68
- done.append(path)
69
- self.presync = []
70
- return done
71
-
72
- def discard_created(self) -> list[Path]:
73
- """删掉本次新建的文件(失败收尾用)。"""
74
- done: list[Path] = []
75
- for path in self.created:
76
- try:
77
- path.unlink()
78
- except FileNotFoundError:
79
- pass
80
- done.append(path)
81
- self.created = []
82
- return done
83
-
84
- def clear(self) -> None:
85
- """成功路径收尾:全部清零。"""
86
- self.changed = []
87
- self.created = []
88
- self.presync = []
89
-
90
-
91
- _CURRENT: WriteLog = WriteLog()
92
-
93
-
94
- def current() -> WriteLog:
95
- """当前这一轮的记账本(没绑定时给一个空账本)。"""
96
- return _CURRENT
97
-
98
-
99
- @contextlib.contextmanager
100
- def bound(log: WriteLog) -> Iterator[None]:
101
- """绑定这一轮的记账本(生产只在 runner 里绑一次;测试用)。"""
102
- global _CURRENT
103
- saved = _CURRENT
104
- _CURRENT = log
105
- try:
106
- yield
107
- finally:
108
- _CURRENT = saved
@@ -1,134 +0,0 @@
1
- """已结案 `history/<年月>.md`:列全部文件、读一篇、算「追加一行后的新全文」、判重、落盘。
2
-
3
- 「算」和「落」是两个方法 —— 结案要控顺序(历史 → 封卷 → 删分片),落盘时机由业务层定。
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- from pathlib import Path
9
- from typing import Any
10
-
11
- from ..core import history_md
12
- from ..core.paths import HISTORY_DIR, to_repo_rel
13
- from .atomic import write_text_atomic
14
-
15
- HIT = "hit"
16
- MISS = "miss"
17
- UNDECIDABLE = "undecidable"
18
-
19
-
20
- class HistoryStore:
21
- def __init__(self, repo: Path):
22
- self.repo = Path(repo)
23
-
24
- # ---- 读 ----
25
-
26
- def sources(self) -> list[Path]:
27
- """按月的 `history/*.md`(按文件名排序)。"""
28
- base = self.repo / to_repo_rel(HISTORY_DIR)
29
- return sorted(base.glob("*.md")) if base.is_dir() else []
30
-
31
- def month_key(self, path: Path) -> str:
32
- """按月文件的 `YYYYMM`。"""
33
- stem = Path(path).stem
34
- return stem if len(stem) == 6 and stem.isdigit() else ""
35
-
36
- def read(self, path: Path) -> str:
37
- try:
38
- return path.read_text(encoding="utf-8-sig")
39
- except FileNotFoundError:
40
- return ""
41
- except OSError as exc:
42
- raise ValueError(f"读 {path} 失败:{exc}") from exc
43
-
44
- # ---- 判重 ----
45
-
46
- def has_log(
47
- self,
48
- path: Path,
49
- *,
50
- log_name: str | None,
51
- created: str | None,
52
- title: str | None,
53
- ) -> str:
54
- """这个月的历史里已经有这条了吗?三态:`hit` / `miss` / `undecidable`。
55
-
56
- 有末列就比**文件名**(不比整条路径)。末列为空的行改按「创建时间 + 标题」两列比
57
- —— 那种行在调用方给不出 `created` / `title` 时判不了,明说 `undecidable`,不拿 miss 冒充。
58
- """
59
- records, _ = history_md.parse_history(self.read(path))
60
- undecidable = False
61
- for row in records:
62
- worklog = (row.get("worklog") or "").strip()
63
- if log_name and worklog:
64
- if Path(worklog).name == Path(log_name).name:
65
- return HIT
66
- continue
67
- if log_name is None and worklog:
68
- # 历史行里有链接,但这次判不出对应的是哪篇文档(任务的过程文档丢了 / 被移走):
69
- # 判不了就明说。拿 miss 冒充会再追加一行 ⇒ 同一条任务两条结案记录。
70
- undecidable = True
71
- continue
72
- if not worklog:
73
- if created and title:
74
- if row.get("created") == created and row.get("title") == title:
75
- return HIT
76
- else:
77
- undecidable = True
78
- return UNDECIDABLE if undecidable else MISS
79
-
80
- # ---- 算 ----
81
-
82
- def append_row(self, path: Path, row: dict[str, Any]) -> dict[str, Any]:
83
- """算出「追加这一行后的新全文」,**不落盘**。返回 `text` / `duplicate`。"""
84
- original = self.read(path)
85
- duplicate = (
86
- self.has_log(
87
- path,
88
- log_name=_name_of(row.get("worklog")),
89
- created=row.get("created"),
90
- title=row.get("title"),
91
- )
92
- == HIT
93
- )
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"),
107
- )
108
- return {"text": _append_line(text, line), "duplicate": duplicate}
109
-
110
- # ---- 写 ----
111
-
112
- def write(self, path: Path, text: str) -> None:
113
- """历史表唯一的写回口:只用于「结案追加」的结果落盘。"""
114
- write_text_atomic(path, text)
115
-
116
-
117
- def _name_of(worklog: str | None) -> str | None:
118
- return Path(worklog).name if worklog else None
119
-
120
-
121
- def _fresh_table() -> str:
122
- """当月第一次结案:按十一列表头起表(表头的真源只有 `history_md` 一处)。"""
123
- return (
124
- f"{history_md.HISTORY_TITLE}\n\n"
125
- f"{history_md.HISTORY_HEADER}\n{history_md.HISTORY_SEP}\n"
126
- )
127
-
128
-
129
- def _append_line(text: str, line: str) -> str:
130
- body = text.rstrip("\n")
131
- if body and not body.splitlines()[-1].strip().startswith("|"):
132
- # 表前有散文:补一个空行,别把表头贴上去
133
- return body + "\n\n" + line
134
- return body + "\n" + line