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,166 +0,0 @@
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
@@ -1,64 +0,0 @@
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
@@ -1,127 +0,0 @@
1
- """索引 `owners.json`:谁是登记过的人、各自的分片文件是哪个、新人的登记与改名。
2
-
3
- **分片只认索引,不扫目录** —— 唯一例外是全仓仅此一处的 `disk_shards()`,它只给 `validate` 用。
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- from pathlib import Path
9
- from typing import Any, Self
10
-
11
- from ..core.identity import Identity, normalize
12
- from ..core.paths import OWNERS_DIR, OWNERS_REL, shard_rel, to_repo_rel
13
- from ..core.time import now
14
- from ..errors import Fail
15
- from .json_store import JSONStore
16
-
17
- DEFAULT_DATA = {"version": "", "workers": {}}
18
-
19
-
20
- class OwnersIndex:
21
- def __init__(self, repo: Path):
22
- self.repo = Path(repo)
23
- self._store = JSONStore(
24
- self.repo / to_repo_rel(OWNERS_REL), default=DEFAULT_DATA, expand="workers"
25
- )
26
-
27
- def get(self, who: str) -> dict[str, Any] | None:
28
- return self._store.get(normalize(who))
29
-
30
- def all(self) -> dict[str, dict[str, Any]]:
31
- return self._store.all()
32
-
33
- def ensure(self, me: Identity) -> tuple[bool, dict[str, Any]]:
34
- """登记 / 补 `joined`。`name` 只在首次登记时写入,之后不回写(名字是给你做对照的被动字段,不该被任何逻辑读)。返回 (这次写了没有, 记录)。"""
35
- key = normalize(me.uuid)
36
- with self._open() as session:
37
- data = session.data
38
- workers = data.setdefault("workers", {})
39
- record = workers.get(key)
40
- if record is None:
41
- record = {
42
- "file": shard_rel(key).name,
43
- "name": me.name,
44
- "joined": now(),
45
- }
46
- workers[key] = record
47
- session.mark()
48
- return True, dict(record)
49
- return False, dict(record)
50
-
51
- def keys_of_name(self, fragment: str) -> set[str]:
52
- """显示名模糊匹配(不分大小写的子串)⇒ uuid 集合。**只给查询筛选**,不参与归属判定。
53
-
54
- 比对的是名册里的名字(`workers[<uuid>].name`),不是每条任务上那份冗余的
55
- `owner_name`:改名后名册先变,筛选结果跟着变,任务里的旧名字不会干扰。
56
- """
57
- needle = normalize(fragment)
58
- if not needle:
59
- return set()
60
- return {
61
- key
62
- for key, record in self.all().items()
63
- if needle in normalize(str((record or {}).get("name") or ""))
64
- }
65
-
66
- def shards(self) -> dict[str, Path]:
67
- """身份 → 分片绝对路径(只信索引)。"""
68
- out: dict[str, Path] = {}
69
- for key, record in self.all().items():
70
- name = (record or {}).get("file")
71
- if not name:
72
- continue
73
- out[key] = self.repo / to_repo_rel(OWNERS_DIR) / str(name)
74
- return out
75
-
76
- def disk_shards(self) -> list[Path]:
77
- """扫 `owners/` 下的全部 `*.json`。**全仓唯一扫目录的地方**,只给 `validate`。"""
78
- base = self.repo / to_repo_rel(OWNERS_DIR)
79
- if not base.is_dir():
80
- return []
81
- return sorted(p for p in base.glob("*.json") if p.is_file())
82
-
83
- def assert_present(self) -> None:
84
- """登记过的分片(含池)文件必须都在;被人删了/改名了就报清楚,绝不悄悄当空、绝不修复。
85
-
86
- 只校验「该在的文件在不在」,不碰内容、不重建。本方法只在校读路径
87
- (list / show / validate)调用;会新建分片的写动作(claim/set/…)里不调,
88
- 否则全新仓库第一次写会被误报「分片文件缺失」。
89
- """
90
- missing = [str(p) for p in self.shards().values() if not p.exists()]
91
- if missing:
92
- raise Fail(
93
- "台账里登记的任务文件找不到了(可能被人手动删除或改名):\n "
94
- + "\n ".join(missing)
95
- + "\n程序不自动修复——请用 git 恢复原文件(git checkout -- <文件>),"
96
- "或把原文件放回原处,再重试。"
97
- )
98
- # 索引本身没了、但盘上还有分片 ⇒ 分片变成无主孤儿,等同于被删
99
- if not (self.repo / to_repo_rel(OWNERS_REL)).exists() and self.disk_shards():
100
- raise Fail(
101
- "台账索引 owners.json 找不到了,但 owners/ 下还有任务文件"
102
- "(可能被人手动删除或改名)。\n程序不自动修复——请用 git 恢复 owners.json,再重试。"
103
- )
104
-
105
- def _open(self):
106
- """把 `ensure` 的读写收在一处:任何改动最终都按一条一行写回。"""
107
- return _IndexSession(self._store)
108
-
109
-
110
- class _IndexSession:
111
- """索引的一次读写:**没改动就不写回**(自举要能零写,否则每次都撞「nothing to commit」)。"""
112
-
113
- def __init__(self, store: JSONStore):
114
- self.store = store
115
- self.data: dict[str, Any] = {}
116
- self.changed = False
117
-
118
- def mark(self) -> None:
119
- self.changed = True
120
-
121
- def __enter__(self) -> Self:
122
- self.data = self.store.data()
123
- return self
124
-
125
- def __exit__(self, *exc) -> None:
126
- if self.changed:
127
- self.store._flush(self.data) # 同一个包内的受控写回口
@@ -1,28 +0,0 @@
1
- """任务表:公共池 `pool.json` 与某人的分片 `owners/<hash>.json` —— **同一个类,只是路径不同**。
2
-
3
- `identity` 只用于**写盘格式**(分片顶部的可选 `identity` 字段),它不参与任何判断:
4
- 不判归属、不改 `updated`、不判状态 —— 那些归 `actions/guard.py`。构造**不写盘**。
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from pathlib import Path
10
- from typing import Any
11
-
12
- from ..core.identity import Identity
13
- from ..core.paths import to_repo_rel
14
- from .json_store import JSONStore
15
-
16
- DEFAULT_DATA = {"version": "", "tasks": {}}
17
-
18
-
19
- class TaskStore(JSONStore):
20
- def __init__(self, repo: Path, rel: Path, *, identity: Identity | None = None):
21
- super().__init__(Path(repo) / to_repo_rel(rel), default=DEFAULT_DATA)
22
- self.identity = identity
23
-
24
- def _flush(self, data: dict[str, Any]) -> None:
25
- """写盘时带上分片顶部的 `identity`(只存 uuid;显示名在索引里)。"""
26
- if self.identity is not None:
27
- data["identity"] = {"uuid": self.identity.uuid}
28
- super()._flush(data)
@@ -1,178 +0,0 @@
1
- """过程文档 `worklog/<年月>/*.md`:找一篇、读、列全部、建新篇、写回、追加执行记录。
2
-
3
- 模板的**读取**在这里(`template()` 是全仓唯一读 `assets/worklog.md` 的地方),
4
- 套模板的字符串替换归 `core/worklog_md.render_worklog`。
5
- 写回只走「锚点编辑后的新全文」,不许用它替换前五节正文。
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from pathlib import Path
11
- from typing import Any
12
-
13
- from .. import notices
14
- from ..core import worklog_md
15
- from ..core.paths import (
16
- WORKLOG_DIR,
17
- WORKLOG_TEMPLATE,
18
- to_repo_rel,
19
- worklog_name,
20
- worklog_rel,
21
- )
22
- from .atomic import ensure_dir, write_text_atomic
23
-
24
- FALLBACK_TEMPLATE = """# <id>:<title>
25
-
26
- - **状态**:<status>
27
- - **创建**:<created> · @<creator>
28
-
29
- ## 背景
30
-
31
- ## 待办
32
-
33
- ## 下一步
34
-
35
- ## 过程
36
-
37
- ## 结论
38
-
39
- ## 执行记录
40
-
41
- <!-- 本节由脚本维护:new / claim / set / release / log / close 自动追加,请勿手改。 -->
42
- """
43
-
44
-
45
- class WorklogStore:
46
- def __init__(self, repo: Path):
47
- self.repo = Path(repo)
48
-
49
- # ---- 模板 ----
50
-
51
- def template(self) -> str:
52
- """六节模板:读不到 `assets/worklog.md` → 内置骨架 + 一条提示。"""
53
- try:
54
- if WORKLOG_TEMPLATE.exists():
55
- return WORKLOG_TEMPLATE.read_text(encoding="utf-8")
56
- except (OSError, UnicodeDecodeError):
57
- pass
58
- notices.note(
59
- "提示:读不到模板 assets/worklog.md,本次用内置骨架建篇(六节齐全,正文仍是空的)。"
60
- )
61
- return FALLBACK_TEMPLATE
62
-
63
- # ---- 找 ----
64
-
65
- def find(self, tid: str, worklog_rel_value: str | None = None) -> Path | None:
66
- """定位一篇过程文档:先信任务对象里记的路径,再按命名推算,最后扫目录。"""
67
- if worklog_rel_value:
68
- cand = self.repo / to_repo_rel(worklog_rel_value)
69
- if cand.is_file():
70
- return cand
71
- base = self.repo / to_repo_rel(WORKLOG_DIR)
72
- names = (worklog_name(tid), f"{tid}.md")
73
- dirs = [base]
74
- if base.is_dir():
75
- dirs += sorted(d for d in base.iterdir() if d.is_dir())
76
- for directory in dirs:
77
- for name in names:
78
- cand = directory / name
79
- if cand.is_file():
80
- return cand
81
- return None
82
-
83
- def by_name(self, log_name: str) -> Path | None:
84
- """按文件名找(历史末列回链用)。"""
85
- base = self.repo / to_repo_rel(WORKLOG_DIR)
86
- if not base.is_dir():
87
- return None
88
- direct = base / log_name
89
- if direct.is_file():
90
- return direct
91
- for path in sorted(base.rglob(log_name)):
92
- if path.is_file():
93
- return path
94
- return None
95
-
96
- def all(self) -> list[Path]:
97
- """递归列出全部过程文档。"""
98
- base = self.repo / to_repo_rel(WORKLOG_DIR)
99
- if not base.is_dir():
100
- return []
101
- return sorted(p for p in base.rglob("*.md") if p.is_file())
102
-
103
- # ---- 读 ----
104
-
105
- def read(self, path: Path) -> str:
106
- try:
107
- return path.read_text(encoding="utf-8-sig")
108
- except FileNotFoundError:
109
- return ""
110
- except OSError as exc:
111
- raise ValueError(f"读 {path} 失败:{exc}") from exc
112
-
113
- def head_meta(self, path: Path) -> dict[str, Any]:
114
- """头部四样:`created` / `creator` / `category` / `tags`;读不出给空值。"""
115
- text = self.read(path)
116
- category, tags = worklog_md.head_meta(text)
117
- return {
118
- "created": worklog_md.head_created(text),
119
- "creator": worklog_md.head_creator(text),
120
- "category": category,
121
- "tags": list(tags),
122
- }
123
-
124
- # ---- 写 ----
125
-
126
- def create(
127
- self,
128
- *,
129
- tid: str,
130
- title: str,
131
- created: str,
132
- creator: str,
133
- tags: tuple[str, ...] = (),
134
- status: worklog_md.StatusLine | None = None,
135
- first_exec: str | None = None,
136
- ) -> Path:
137
- """一次写完整篇(含状态行与首条执行记录)。返回绝对路径。"""
138
- rel = worklog_rel(tid, created)
139
- path = self.repo / to_repo_rel(rel)
140
- ensure_dir(path.parent)
141
- text = worklog_md.render_worklog(
142
- tid=tid,
143
- title=title,
144
- created=created,
145
- creator=creator,
146
- tags=tags,
147
- status=status,
148
- template=self.template(),
149
- )
150
- if first_exec:
151
- text = worklog_md.append_exec_log(text, first_exec)
152
- write_text_atomic(path, text)
153
- return path
154
-
155
- def write(self, path: Path, text: str) -> None:
156
- """写回「锚点编辑后的新全文」;不许用它替换前五节正文。"""
157
- write_text_atomic(path, text)
158
-
159
- def append_exec(
160
- self, path: Path, line: str, *, status: worklog_md.StatusLine | None = None
161
- ) -> bool:
162
- """追加一条执行记录,并在**同一次写盘**里顺带刷新头部状态行(`status` 给了才刷)。
163
-
164
- 写不进返回 False,**不抛**(调用方自己决定报不报)。状态行与执行记录必须一起落地:
165
- 分两写就会留下「记录写了、状态没刷」这种不报错的不一致。
166
- """
167
- try:
168
- text = path.read_text(encoding="utf-8-sig")
169
- except (OSError, UnicodeDecodeError):
170
- return False
171
- try:
172
- new = worklog_md.append_exec_log(text, line)
173
- if status is not None:
174
- new = worklog_md.rewrite_status_line(new, status)
175
- write_text_atomic(path, new)
176
- except Exception: # noqa: BLE001 —— 写不进是这条记录的失败,不该掀翻整条命令
177
- return False
178
- return True
@@ -1,20 +0,0 @@
1
- #!/usr/bin/env python3
2
- """程序入口:只做四件事,业务逻辑一行都不在这里。
3
-
4
- 1. 关掉字节码(本脚本会被 cp -r 拷进插件产物,不能带 __pycache__);
5
- 2. 把自己所在目录塞进 sys.path(宿主与 SKILL.md 都按这个路径调,不许改名挪位);
6
- 3. 调 ledgerkit.shell.cli.main;
7
- 4. 把返回码交给系统。
8
- """
9
-
10
- import os
11
- import sys
12
-
13
- sys.dont_write_bytecode = True
14
-
15
- sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
16
-
17
- from ledgerkit.shell.cli import main
18
-
19
- if __name__ == "__main__":
20
- sys.exit(main())