task-ledger 1.3.0 → 1.3.2

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 -247
  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 +97 -32
  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 +26 -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 +136 -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 +172 -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 +27 -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 +114 -0
  51. package/scripts/ledgerkit/store/task_store.py +28 -0
  52. package/scripts/ledgerkit/store/worklog_store.py +168 -0
  53. package/scripts/progress.py +8 -3109
  54. package/references/taxonomy.md +0 -26
  55. package/references/worklog.md +0 -39
@@ -0,0 +1,130 @@
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)
@@ -0,0 +1,108 @@
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
@@ -0,0 +1,189 @@
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, LEGACY_HISTORY_REL, to_repo_rel
13
+ from .atomic import write_text_atomic
14
+ from .worklog_store import WorklogStore
15
+
16
+ HIT = "hit"
17
+ MISS = "miss"
18
+ UNDECIDABLE = "undecidable"
19
+
20
+
21
+ class HistoryStore:
22
+ def __init__(self, repo: Path):
23
+ self.repo = Path(repo)
24
+
25
+ # ---- 读 ----
26
+
27
+ def sources(self) -> list[Path]:
28
+ """按月的 `history/*.md`(按文件名排序)+ 存量平铺文件(只读,永不重写)。"""
29
+ base = self.repo / to_repo_rel(HISTORY_DIR)
30
+ files = sorted(base.glob("*.md")) if base.is_dir() else []
31
+ legacy = self.repo / to_repo_rel(LEGACY_HISTORY_REL)
32
+ if legacy.is_file():
33
+ files.append(legacy)
34
+ return files
35
+
36
+ def month_key(self, path: Path) -> str:
37
+ """按月文件的 `YYYYMM`;存量平铺文件没有月份键,返回空串。"""
38
+ stem = Path(path).stem
39
+ return stem if len(stem) == 6 and stem.isdigit() else ""
40
+
41
+ def read(self, path: Path) -> str:
42
+ try:
43
+ return path.read_text(encoding="utf-8-sig")
44
+ except FileNotFoundError:
45
+ return ""
46
+ except OSError as exc:
47
+ raise ValueError(f"读 {path} 失败:{exc}") from exc
48
+
49
+ def _read_worklog(self, path: Path):
50
+ """升级要回链的 worklog 头部:读文件这件事留在 store,core 只消费返回的字典。"""
51
+ logs = WorklogStore(self.repo)
52
+
53
+ def read(log_name: str) -> dict[str, Any] | None:
54
+ found = logs.by_name(log_name)
55
+ return logs.head_meta(found) if found else None
56
+
57
+ return read
58
+
59
+ # ---- 判重 ----
60
+
61
+ def has_log(
62
+ self,
63
+ path: Path,
64
+ *,
65
+ log_name: str | None,
66
+ created: str | None,
67
+ title: str | None,
68
+ ) -> str:
69
+ """这个月的历史里已经有这条了吗?三态:`hit` / `miss` / `undecidable`。
70
+
71
+ 有末列就比**文件名**(不比整条路径)。末列为空的行改按「创建时间 + 工作包」两列比
72
+ —— 那种行在调用方给不出 `created` / `title` 时判不了,明说 `undecidable`,不拿 miss 冒充。
73
+ """
74
+ records, _ = history_md.parse_history(self.read(path))
75
+ undecidable = False
76
+ for row in records:
77
+ worklog = (row.get("worklog") or "").strip()
78
+ if log_name and worklog:
79
+ if Path(worklog).name == Path(log_name).name:
80
+ return HIT
81
+ continue
82
+ if not worklog:
83
+ if created and title:
84
+ if row.get("created") == created and row.get("title") == title:
85
+ return HIT
86
+ else:
87
+ undecidable = True
88
+ return UNDECIDABLE if undecidable else MISS
89
+
90
+ # ---- 算 ----
91
+
92
+ def append_row(self, path: Path, row: dict[str, Any]) -> dict[str, Any]:
93
+ """算出「追加这一行后的新全文」,**不落盘**。
94
+
95
+ 返回四个键:`text` / `upgraded` / `duplicate` / `fallback_cols`(`None` = 正常 9 列)。
96
+ 老表列少又不敢整体升级时,按现有列数追加并让业务层提示,绝不硬写 9 列把表格挤歪。
97
+ """
98
+ original = self.read(path)
99
+ duplicate = (
100
+ self.has_log(
101
+ path,
102
+ log_name=_name_of(row.get("worklog")),
103
+ created=row.get("created"),
104
+ title=row.get("title"),
105
+ )
106
+ == HIT
107
+ )
108
+ if not original.strip():
109
+ text = _fresh_table()
110
+ upgraded = False
111
+ fallback_cols = None
112
+ else:
113
+ cols = history_md.history_cols(original)
114
+ if cols == 9:
115
+ text = original
116
+ upgraded = False
117
+ fallback_cols = None
118
+ else:
119
+ upgraded_text = history_md.upgrade_history(
120
+ original,
121
+ read_worklog=self._read_worklog(path),
122
+ fallback_created=row.get("created") or "",
123
+ )
124
+ if upgraded_text is None:
125
+ text = original
126
+ upgraded = False
127
+ fallback_cols = cols
128
+ else:
129
+ text, _ = upgraded_text
130
+ upgraded = True
131
+ fallback_cols = None
132
+ line = (
133
+ history_md.history_line(
134
+ row.get("finished") or "",
135
+ row.get("created"),
136
+ row.get("title") or "",
137
+ row.get("owner"),
138
+ row.get("summary") or "",
139
+ row.get("worklog"),
140
+ row.get("category"),
141
+ row.get("tags"),
142
+ row.get("creator"),
143
+ )
144
+ if fallback_cols is None
145
+ else history_md.history_line_for_cols(
146
+ fallback_cols,
147
+ row.get("finished") or "",
148
+ row.get("created"),
149
+ row.get("title") or "",
150
+ row.get("owner"),
151
+ row.get("summary") or "",
152
+ row.get("worklog"),
153
+ row.get("category"),
154
+ row.get("tags"),
155
+ row.get("creator"),
156
+ )
157
+ )
158
+ return {
159
+ "text": _append_line(text, line),
160
+ "upgraded": upgraded,
161
+ "duplicate": duplicate,
162
+ "fallback_cols": fallback_cols,
163
+ }
164
+
165
+ # ---- 写 ----
166
+
167
+ def write(self, path: Path, text: str) -> None:
168
+ """历史表唯一的写回口:只用于「结案追加」与「8→9 列升级」的结果落盘。"""
169
+ write_text_atomic(path, text)
170
+
171
+
172
+ def _name_of(worklog: str | None) -> str | None:
173
+ return Path(worklog).name if worklog else None
174
+
175
+
176
+ def _fresh_table() -> str:
177
+ """当月第一次结案:按九列表头起表(表头的真源只有 `history_md` 一处)。"""
178
+ return (
179
+ f"{history_md.HISTORY_TITLE}\n\n"
180
+ f"{history_md.HISTORY_HEADER}\n{history_md.HISTORY_SEP}\n"
181
+ )
182
+
183
+
184
+ def _append_line(text: str, line: str) -> str:
185
+ body = text.rstrip("\n")
186
+ if body and not body.splitlines()[-1].strip().startswith("|"):
187
+ # 表前有散文:补一个空行,别把表头贴上去
188
+ return body + "\n\n" + line
189
+ return body + "\n" + line
@@ -0,0 +1,166 @@
1
+ """所有 JSON 台账的共用底座:读、按条取、按条写、按条删、指纹、一条一行的写盘格式。
2
+
3
+ **版本字段的落点就在这里**:读盘发现与 `FORMAT_VERSION` 不符 ⇒ `notices.note` 一次(不拦);
4
+ 写盘一律带上当前 `FORMAT_VERSION`(缺就补)。业务层各自判断就是散落。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .. import notices
14
+ from ..core.labels import FORMAT_VERSION
15
+ from ..errors import Fail
16
+ from .atomic import fingerprint, write_text_atomic
17
+
18
+
19
+ def dump_ledger_json(data: dict[str, Any], *, expand: str = "tasks") -> str:
20
+ """「一条一行」的台账文本(`expand` 指定的那个字典一条一行,其余字段各占一行)。
21
+
22
+ 键排序、值也 `sort_keys`:**同一份逻辑内容永远产出同一串字节** ——
23
+ 多人各写各的才能按行合并,重写也不会产出无意义 diff。这是**格式契约**,
24
+ 别改成缩进美化格式,也别按插入序输出(老文件会整篇翻面)。
25
+ """
26
+ body: dict[str, Any] = dict(data)
27
+ body["version"] = FORMAT_VERSION # 写盘一律带当前版本(缺就补、旧就更新)
28
+ rows: list[str] = []
29
+ for key in sorted(body):
30
+ value = body[key]
31
+ if key == expand and isinstance(value, dict):
32
+ inner = [
33
+ f" {json.dumps(k, ensure_ascii=False)}: "
34
+ f"{json.dumps(v, ensure_ascii=False, sort_keys=True)}"
35
+ for k, v in sorted(value.items())
36
+ ]
37
+ block = (",\n".join(inner) + "\n") if inner else ""
38
+ rows.append(f' "{key}": {{\n{block} }}')
39
+ continue
40
+ rows.append(
41
+ f' "{key}": {json.dumps(value, ensure_ascii=False, sort_keys=True)}'
42
+ )
43
+ return "{\n" + ",\n".join(rows) + "\n}\n"
44
+
45
+
46
+ def write_ledger_json(
47
+ path: Path, data: dict[str, Any], *, expand: str = "tasks"
48
+ ) -> None:
49
+ """按「一条一行」写台账(格式文本由 `dump_ledger_json` 出)。"""
50
+ write_text_atomic(path, dump_ledger_json(data, expand=expand))
51
+
52
+
53
+ class JSONStore:
54
+ """一份 JSON 台账的读改写。首次读盘后缓存在实例里。"""
55
+
56
+ def __init__(self, path: Path, *, default: dict[str, Any], expand: str = "tasks"):
57
+ self.path = Path(path)
58
+ self.expand = expand
59
+ self._default = dict(default)
60
+ self._data: dict[str, Any] | None = None
61
+
62
+ # ---- 读 ----
63
+
64
+ def _load(self) -> dict[str, Any]:
65
+ if self._data is None:
66
+ data = self._read_raw()
67
+ if data is None:
68
+ # 文件还没有:按默认起一份,**不算版本不符**(没文件就没版本可比)
69
+ data = dict(self._default)
70
+ data["version"] = FORMAT_VERSION
71
+ else:
72
+ self._check_version(data)
73
+ rows = data.get(self.expand)
74
+ if rows is not None and not isinstance(rows, dict):
75
+ # 版本不符只告警,但「tasks 不是对象」是读不了的真错(2026-10-22 数据契约)
76
+ raise Fail(
77
+ f"{self._rel()} 的 {self.expand} 不是对象"
78
+ f"(是{type(rows).__name__}),这份台账读不了。\n"
79
+ " 处理:用 git 恢复它(git checkout -- <文件>),或手工改成对象。"
80
+ )
81
+ self._data = data
82
+ return self._data
83
+
84
+ def _read_raw(self) -> dict[str, Any] | None:
85
+ try:
86
+ text = self.path.read_text(encoding="utf-8-sig")
87
+ except FileNotFoundError:
88
+ return None
89
+ except OSError as exc:
90
+ raise ValueError(f"读 {self.path} 失败:{exc}") from exc
91
+ if not text.strip():
92
+ return None
93
+ try:
94
+ data = json.loads(text)
95
+ except json.JSONDecodeError as exc:
96
+ raise ValueError(
97
+ f"{self.path} 不是合法 JSON(第 {exc.lineno} 行):{exc.msg}"
98
+ ) from exc
99
+ if not isinstance(data, dict):
100
+ raise TypeError(f"{self.path} 顶层不是对象。")
101
+ return data
102
+
103
+ def _check_version(self, data: dict[str, Any]) -> None:
104
+ version = data.get("version")
105
+ if isinstance(version, str) and version == FORMAT_VERSION:
106
+ return
107
+ notices.note(
108
+ f"提示:{self._rel()} 的格式版本是 {version or '未知'},"
109
+ f"当前是 {FORMAT_VERSION};不拦,但字段可能有出入。"
110
+ )
111
+
112
+ def _rel(self) -> str:
113
+ try:
114
+ return self.path.name
115
+ except Exception: # noqa: BLE001
116
+ return str(self.path)
117
+
118
+ def data(self) -> dict[str, Any]:
119
+ """整份只读视图(升级 / 体检用)。"""
120
+ return dict(self._load())
121
+
122
+ def get(self, key: str) -> dict[str, Any] | None:
123
+ value = self._load().get(self.expand, {}).get(key)
124
+ return dict(value) if isinstance(value, dict) else None
125
+
126
+ def all(self) -> dict[str, dict[str, Any]]:
127
+ rows = self._load().get(self.expand, {}) or {}
128
+ return {k: dict[str, Any](v) for k, v in rows.items() if isinstance(v, dict)}
129
+
130
+ def fingerprint(self) -> str | None:
131
+ return fingerprint(self.path)
132
+
133
+ # ---- 写 ----
134
+
135
+ def put(self, key: str, value: dict[str, Any]) -> None:
136
+ """改一条,其余原样保留。"""
137
+ data = self._load()
138
+ rows = data.setdefault(self.expand, {})
139
+ if not isinstance(rows, dict):
140
+ rows = {}
141
+ data[self.expand] = rows
142
+ rows[key] = dict(value)
143
+ self._flush(data)
144
+
145
+ def drop(self, key: str) -> None:
146
+ """删一条;本来就没有就什么都不做(不改变文件)。"""
147
+ data = self._load()
148
+ rows = data.get(self.expand, {})
149
+ if not isinstance(rows, dict) or key not in rows:
150
+ return
151
+ del rows[key]
152
+ self._flush(data)
153
+
154
+ def _flush(self, data: dict[str, Any]) -> None:
155
+ write_ledger_json(self.path, data, expand=self.expand)
156
+ self._data = data
157
+
158
+ def ensure_file(self) -> bool:
159
+ """文件不存在就按 `default` 写一份;存在就一个字节都不动。返回「这次建了没有」。"""
160
+ if self.path.exists():
161
+ return False
162
+ data = dict(self._default)
163
+ data.setdefault("version", FORMAT_VERSION)
164
+ write_ledger_json(self.path, data, expand=self.expand)
165
+ self._data = data
166
+ return True
@@ -0,0 +1,64 @@
1
+ """文件锁:只负责定义,**调用权归 actions/atom.py**(数据层禁止调用)。
2
+
3
+ 锁的不是目标文件本身:按绝对路径 hash 在系统临时目录开 `<hash>.lock` 再 flock。
4
+ 这样目标文件还不存在也能锁(锁集里本来就允许"还不存在"的路径),仓库里也不多出锁文件。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import hashlib
11
+ import os
12
+ import tempfile
13
+ from collections.abc import Iterator
14
+ from pathlib import Path
15
+
16
+ from ..core.paths import to_repo_rel
17
+
18
+ try: # pragma: no cover —— 平台差异
19
+ import fcntl
20
+ except ImportError: # pragma: no cover
21
+ fcntl = None # type: ignore[assignment]
22
+ try: # pragma: no cover
23
+ import msvcrt
24
+ except ImportError: # pragma: no cover
25
+ msvcrt = None # type: ignore[assignment]
26
+
27
+
28
+ @contextlib.contextmanager
29
+ def progress_lock(path: Path) -> Iterator[None]:
30
+ """给一个台账文件加互斥锁:阻塞等待、没有超时、**不可重入**。"""
31
+ if fcntl is None and msvcrt is None:
32
+ yield
33
+ return
34
+ key = hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:16]
35
+ lock_path = Path(tempfile.gettempdir()) / f"koc-progress-{key}.lock"
36
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR)
37
+ try:
38
+ if fcntl is not None:
39
+ fcntl.flock(fd, fcntl.LOCK_EX)
40
+ elif msvcrt is not None:
41
+ msvcrt.locking(fd, msvcrt.LK_LOCK, 1) # type: ignore[attr-defined]
42
+ yield
43
+ finally:
44
+ try:
45
+ if fcntl is not None:
46
+ fcntl.flock(fd, fcntl.LOCK_UN)
47
+ elif msvcrt is not None:
48
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) # type: ignore[attr-defined]
49
+ finally:
50
+ os.close(fd)
51
+
52
+
53
+ @contextlib.contextmanager
54
+ def ledger_lock(repo: Path, *rels: Path) -> Iterator[None]:
55
+ """多个文件一起锁:去重 → 按路径排序 → 依次加(避免两人互相等死)。"""
56
+ # rels 相对 `ledger/`(与磁盘 / JSON 同一基准),换算只走 to_repo_rel
57
+ paths = sorted({(repo / to_repo_rel(rel)) for rel in rels}, key=lambda p: str(p))
58
+ if not paths:
59
+ yield
60
+ return
61
+ with contextlib.ExitStack() as stack:
62
+ for path in paths:
63
+ stack.enter_context(progress_lock(path))
64
+ yield