dsh-task-ledger 0.0.100 → 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.
- package/dist/client.js +2 -2
- package/dist/host/repositories/runner.d.ts +1 -1
- package/dist/host/repositories/runner.js +10 -7
- package/dist/host/skill/registration.d.ts +2 -1
- package/dist/host/skill/registration.js +4 -3
- package/package.json +1 -1
- package/skill/README.md +4 -2
- package/skill/SKILL.md +17 -14
- package/skill/assets/worklog.md +3 -3
- package/skill/package.json +1 -1
- package/skill/references/init-identity.md +3 -3
- package/skill/references/log-note.md +1 -1
- package/skill/references/state-model.md +1 -1
- package/skill/references/troubleshooting.md +1 -1
- package/skill/scripts/bin/ledger-darwin-arm64 +0 -0
- package/skill/scripts/bin/ledger-linux-amd64 +0 -0
- package/skill/scripts/bin/ledger-windows-amd64.exe +0 -0
- package/skill/scripts/progress +44 -0
- package/skill/scripts/ledgerkit/__init__.py +0 -0
- package/skill/scripts/ledgerkit/actions/__init__.py +0 -0
- package/skill/scripts/ledgerkit/actions/atom.py +0 -154
- package/skill/scripts/ledgerkit/actions/bootstrap.py +0 -37
- package/skill/scripts/ledgerkit/actions/claim.py +0 -88
- package/skill/scripts/ledgerkit/actions/close_task.py +0 -193
- package/skill/scripts/ledgerkit/actions/guard.py +0 -158
- package/skill/scripts/ledgerkit/actions/handoff.py +0 -93
- package/skill/scripts/ledgerkit/actions/init_identity.py +0 -90
- package/skill/scripts/ledgerkit/actions/list_tasks.py +0 -74
- package/skill/scripts/ledgerkit/actions/log_progress.py +0 -67
- package/skill/scripts/ledgerkit/actions/new_task.py +0 -130
- package/skill/scripts/ledgerkit/actions/query.py +0 -35
- package/skill/scripts/ledgerkit/actions/query_history.py +0 -220
- package/skill/scripts/ledgerkit/actions/registry.py +0 -82
- package/skill/scripts/ledgerkit/actions/relabel.py +0 -105
- package/skill/scripts/ledgerkit/actions/release.py +0 -76
- package/skill/scripts/ledgerkit/actions/result.py +0 -90
- package/skill/scripts/ledgerkit/actions/set_task.py +0 -73
- package/skill/scripts/ledgerkit/actions/show_task.py +0 -55
- package/skill/scripts/ledgerkit/actions/validate.py +0 -189
- package/skill/scripts/ledgerkit/core/__init__.py +0 -0
- package/skill/scripts/ledgerkit/core/history_md.py +0 -152
- package/skill/scripts/ledgerkit/core/identity.py +0 -54
- package/skill/scripts/ledgerkit/core/ids.py +0 -58
- package/skill/scripts/ledgerkit/core/labels.py +0 -90
- package/skill/scripts/ledgerkit/core/paths.py +0 -92
- package/skill/scripts/ledgerkit/core/roots.py +0 -74
- package/skill/scripts/ledgerkit/core/time.py +0 -103
- package/skill/scripts/ledgerkit/core/worklog_md.py +0 -437
- package/skill/scripts/ledgerkit/env.py +0 -94
- package/skill/scripts/ledgerkit/errors.py +0 -15
- package/skill/scripts/ledgerkit/notices.py +0 -28
- package/skill/scripts/ledgerkit/shell/__init__.py +0 -0
- package/skill/scripts/ledgerkit/shell/cli.py +0 -536
- package/skill/scripts/ledgerkit/shell/gitops.py +0 -668
- package/skill/scripts/ledgerkit/shell/merge.py +0 -266
- package/skill/scripts/ledgerkit/shell/presync.py +0 -23
- package/skill/scripts/ledgerkit/shell/render.py +0 -216
- package/skill/scripts/ledgerkit/shell/runner.py +0 -232
- package/skill/scripts/ledgerkit/store/__init__.py +0 -0
- package/skill/scripts/ledgerkit/store/atomic.py +0 -130
- package/skill/scripts/ledgerkit/store/dirty.py +0 -108
- package/skill/scripts/ledgerkit/store/history_store.py +0 -134
- package/skill/scripts/ledgerkit/store/json_store.py +0 -166
- package/skill/scripts/ledgerkit/store/locks.py +0 -64
- package/skill/scripts/ledgerkit/store/owners_index.py +0 -127
- package/skill/scripts/ledgerkit/store/task_store.py +0 -28
- package/skill/scripts/ledgerkit/store/worklog_store.py +0 -180
- 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,180 +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
|
-
category: str | None = None,
|
|
134
|
-
tags: tuple[str, ...] = (),
|
|
135
|
-
status: worklog_md.StatusLine | None = None,
|
|
136
|
-
first_exec: str | None = None,
|
|
137
|
-
) -> Path:
|
|
138
|
-
"""一次写完整篇(含状态行与首条执行记录)。返回绝对路径。"""
|
|
139
|
-
rel = worklog_rel(tid, created)
|
|
140
|
-
path = self.repo / to_repo_rel(rel)
|
|
141
|
-
ensure_dir(path.parent)
|
|
142
|
-
text = worklog_md.render_worklog(
|
|
143
|
-
tid=tid,
|
|
144
|
-
title=title,
|
|
145
|
-
created=created,
|
|
146
|
-
creator=creator,
|
|
147
|
-
category=category,
|
|
148
|
-
tags=tags,
|
|
149
|
-
status=status,
|
|
150
|
-
template=self.template(),
|
|
151
|
-
)
|
|
152
|
-
if first_exec:
|
|
153
|
-
text = worklog_md.append_exec_log(text, first_exec)
|
|
154
|
-
write_text_atomic(path, text)
|
|
155
|
-
return path
|
|
156
|
-
|
|
157
|
-
def write(self, path: Path, text: str) -> None:
|
|
158
|
-
"""写回「锚点编辑后的新全文」;不许用它替换前五节正文。"""
|
|
159
|
-
write_text_atomic(path, text)
|
|
160
|
-
|
|
161
|
-
def append_exec(
|
|
162
|
-
self, path: Path, line: str, *, status: worklog_md.StatusLine | None = None
|
|
163
|
-
) -> bool:
|
|
164
|
-
"""追加一条执行记录,并在**同一次写盘**里顺带刷新头部状态行(`status` 给了才刷)。
|
|
165
|
-
|
|
166
|
-
写不进返回 False,**不抛**(调用方自己决定报不报)。状态行与执行记录必须一起落地:
|
|
167
|
-
分两写就会留下「记录写了、状态没刷」这种不报错的不一致。
|
|
168
|
-
"""
|
|
169
|
-
try:
|
|
170
|
-
text = path.read_text(encoding="utf-8-sig")
|
|
171
|
-
except (OSError, UnicodeDecodeError):
|
|
172
|
-
return False
|
|
173
|
-
try:
|
|
174
|
-
new = worklog_md.append_exec_log(text, line)
|
|
175
|
-
if status is not None:
|
|
176
|
-
new = worklog_md.rewrite_status_line(new, status)
|
|
177
|
-
write_text_atomic(path, new)
|
|
178
|
-
except Exception: # noqa: BLE001 —— 写不进是这条记录的失败,不该掀翻整条命令
|
|
179
|
-
return False
|
|
180
|
-
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())
|