task-ledger 1.3.0 → 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.
- package/README.md +29 -247
- package/SKILL.md +173 -88
- package/package.json +22 -22
- package/references/log-note.md +3 -1
- package/references/state-model.md +29 -27
- package/references/troubleshooting.md +97 -32
- package/scripts/ledgerkit/__init__.py +0 -0
- package/scripts/ledgerkit/actions/__init__.py +0 -0
- package/scripts/ledgerkit/actions/atom.py +154 -0
- package/scripts/ledgerkit/actions/bootstrap.py +27 -0
- package/scripts/ledgerkit/actions/claim.py +82 -0
- package/scripts/ledgerkit/actions/close_task.py +180 -0
- package/scripts/ledgerkit/actions/guard.py +140 -0
- package/scripts/ledgerkit/actions/handoff.py +74 -0
- package/scripts/ledgerkit/actions/list_tasks.py +53 -0
- package/scripts/ledgerkit/actions/log_progress.py +60 -0
- package/scripts/ledgerkit/actions/new_task.py +120 -0
- package/scripts/ledgerkit/actions/query.py +33 -0
- package/scripts/ledgerkit/actions/query_history.py +173 -0
- package/scripts/ledgerkit/actions/registry.py +82 -0
- package/scripts/ledgerkit/actions/release.py +51 -0
- package/scripts/ledgerkit/actions/result.py +88 -0
- package/scripts/ledgerkit/actions/set_task.py +43 -0
- package/scripts/ledgerkit/actions/show_task.py +27 -0
- package/scripts/ledgerkit/actions/validate.py +224 -0
- package/scripts/ledgerkit/core/__init__.py +0 -0
- package/scripts/ledgerkit/core/history_md.py +350 -0
- package/scripts/ledgerkit/core/identity.py +43 -0
- package/scripts/ledgerkit/core/ids.py +58 -0
- package/scripts/ledgerkit/core/labels.py +79 -0
- package/scripts/ledgerkit/core/paths.py +93 -0
- package/scripts/ledgerkit/core/time.py +102 -0
- package/scripts/ledgerkit/core/worklog_md.py +232 -0
- package/scripts/ledgerkit/env.py +93 -0
- package/scripts/ledgerkit/errors.py +15 -0
- package/scripts/ledgerkit/notices.py +28 -0
- package/scripts/ledgerkit/shell/__init__.py +0 -0
- package/scripts/ledgerkit/shell/cli.py +259 -0
- package/scripts/ledgerkit/shell/gitops.py +655 -0
- package/scripts/ledgerkit/shell/merge.py +266 -0
- package/scripts/ledgerkit/shell/presync.py +23 -0
- package/scripts/ledgerkit/shell/render.py +165 -0
- package/scripts/ledgerkit/shell/runner.py +225 -0
- package/scripts/ledgerkit/store/__init__.py +0 -0
- package/scripts/ledgerkit/store/atomic.py +130 -0
- package/scripts/ledgerkit/store/dirty.py +108 -0
- package/scripts/ledgerkit/store/history_store.py +189 -0
- package/scripts/ledgerkit/store/json_store.py +166 -0
- package/scripts/ledgerkit/store/locks.py +64 -0
- package/scripts/ledgerkit/store/owners_index.py +135 -0
- package/scripts/ledgerkit/store/task_store.py +31 -0
- package/scripts/ledgerkit/store/worklog_store.py +168 -0
- package/scripts/progress.py +8 -3109
- package/references/taxonomy.md +0 -26
- package/references/worklog.md +0 -39
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""索引 `owners.json`:谁是登记过的人、各自的分片文件是哪个、新人的登记与改名。
|
|
2
|
+
|
|
3
|
+
**分片只认索引,不扫目录** —— 唯一例外是全仓仅此一处的 `disk_shards()`,它只给 `validate` 用。
|
|
4
|
+
`ensure` 是 `aliases` 的唯一写入点。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Self
|
|
11
|
+
|
|
12
|
+
from ..core.identity import Identity, normalize
|
|
13
|
+
from ..core.paths import OWNERS_DIR, OWNERS_REL, shard_rel, to_repo_rel
|
|
14
|
+
from ..core.time import now
|
|
15
|
+
from ..errors import Fail
|
|
16
|
+
from .json_store import JSONStore
|
|
17
|
+
|
|
18
|
+
DEFAULT_DATA = {"version": "", "workers": {}}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OwnersIndex:
|
|
22
|
+
def __init__(self, repo: Path):
|
|
23
|
+
self.repo = Path(repo)
|
|
24
|
+
self._store = JSONStore(
|
|
25
|
+
self.repo / to_repo_rel(OWNERS_REL), default=DEFAULT_DATA, expand="workers"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def get(self, who: str) -> dict[str, Any] | None:
|
|
29
|
+
return self._store.get(normalize(who))
|
|
30
|
+
|
|
31
|
+
def all(self) -> dict[str, dict[str, Any]]:
|
|
32
|
+
return self._store.all()
|
|
33
|
+
|
|
34
|
+
def ensure(self, me: Identity) -> tuple[bool, dict[str, Any]]:
|
|
35
|
+
"""登记 / 补 `joined` / 名字或 aliases 变了才写。返回 (这次写了没有, 记录)。"""
|
|
36
|
+
key = normalize(me.email)
|
|
37
|
+
with self._open() as session:
|
|
38
|
+
data = session.data
|
|
39
|
+
workers = data.setdefault("workers", {})
|
|
40
|
+
record = workers.get(key)
|
|
41
|
+
if record is None:
|
|
42
|
+
record = {
|
|
43
|
+
"file": shard_rel(key).name,
|
|
44
|
+
"name": me.name,
|
|
45
|
+
"joined": now(),
|
|
46
|
+
"aliases": _merge_aliases([], me.aliases),
|
|
47
|
+
}
|
|
48
|
+
workers[key] = record
|
|
49
|
+
session.mark()
|
|
50
|
+
return True, dict(record)
|
|
51
|
+
changed = False
|
|
52
|
+
if me.name and record.get("name") != me.name:
|
|
53
|
+
record["name"] = me.name
|
|
54
|
+
changed = True
|
|
55
|
+
known = list(record.get("aliases") or [])
|
|
56
|
+
aliases = _merge_aliases(known, me.aliases) # 只增不删
|
|
57
|
+
if aliases != known:
|
|
58
|
+
record["aliases"] = aliases
|
|
59
|
+
changed = True
|
|
60
|
+
if changed:
|
|
61
|
+
session.mark()
|
|
62
|
+
return changed, dict(record)
|
|
63
|
+
|
|
64
|
+
def shards(self) -> dict[str, Path]:
|
|
65
|
+
"""身份 → 分片绝对路径(只信索引)。"""
|
|
66
|
+
out: dict[str, Path] = {}
|
|
67
|
+
for key, record in self.all().items():
|
|
68
|
+
name = (record or {}).get("file")
|
|
69
|
+
if not name:
|
|
70
|
+
continue
|
|
71
|
+
out[key] = self.repo / to_repo_rel(OWNERS_DIR) / str(name)
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
def disk_shards(self) -> list[Path]:
|
|
75
|
+
"""扫 `owners/` 下的全部 `*.json`。**全仓唯一扫目录的地方**,只给 `validate`。"""
|
|
76
|
+
base = self.repo / to_repo_rel(OWNERS_DIR)
|
|
77
|
+
if not base.is_dir():
|
|
78
|
+
return []
|
|
79
|
+
return sorted(p for p in base.glob("*.json") if p.is_file())
|
|
80
|
+
|
|
81
|
+
def assert_present(self) -> None:
|
|
82
|
+
"""登记过的分片(含池)文件必须都在;被人删了/改名了就报清楚,绝不悄悄当空、绝不修复。
|
|
83
|
+
|
|
84
|
+
只校验「该在的文件在不在」,不碰内容、不重建。本方法只在校读路径
|
|
85
|
+
(list / show / validate)调用;会新建分片的写动作(claim/set/…)里不调,
|
|
86
|
+
否则全新仓库第一次写会被误报「分片文件缺失」。
|
|
87
|
+
"""
|
|
88
|
+
missing = [str(p) for p in self.shards().values() if not p.exists()]
|
|
89
|
+
if missing:
|
|
90
|
+
raise Fail(
|
|
91
|
+
"台账里登记的任务文件找不到了(可能被人手动删除或改名):\n "
|
|
92
|
+
+ "\n ".join(missing)
|
|
93
|
+
+ "\n程序不自动修复——请用 git 恢复原文件(git checkout -- <文件>),"
|
|
94
|
+
"或把原文件放回原处,再重试。"
|
|
95
|
+
)
|
|
96
|
+
# 索引本身没了、但盘上还有分片 ⇒ 分片变成无主孤儿,等同于被删
|
|
97
|
+
if not (self.repo / to_repo_rel(OWNERS_REL)).exists() and self.disk_shards():
|
|
98
|
+
raise Fail(
|
|
99
|
+
"台账索引 owners.json 找不到了,但 owners/ 下还有任务文件"
|
|
100
|
+
"(可能被人手动删除或改名)。\n程序不自动修复——请用 git 恢复 owners.json,再重试。"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _open(self):
|
|
104
|
+
"""把 `ensure` 的读写收在一处:任何改动最终都按一条一行写回。"""
|
|
105
|
+
return _IndexSession(self._store)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _IndexSession:
|
|
109
|
+
"""索引的一次读写:**没改动就不写回**(自举要能零写,否则每次都撞「nothing to commit」)。"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, store: JSONStore):
|
|
112
|
+
self.store = store
|
|
113
|
+
self.data: dict[str, Any] = {}
|
|
114
|
+
self.changed = False
|
|
115
|
+
|
|
116
|
+
def mark(self) -> None:
|
|
117
|
+
self.changed = True
|
|
118
|
+
|
|
119
|
+
def __enter__(self) -> Self:
|
|
120
|
+
self.data = self.store.data()
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def __exit__(self, *exc) -> None:
|
|
124
|
+
if self.changed:
|
|
125
|
+
self.store._flush(self.data) # 同一个包内的受控写回口
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _merge_aliases(known: list, extra: tuple[str, ...] | list[str]) -> list[str]:
|
|
129
|
+
"""把新别名并进去,只增不删、去重保序。"""
|
|
130
|
+
out = list(known)
|
|
131
|
+
for alias in extra or ():
|
|
132
|
+
key = normalize(alias)
|
|
133
|
+
if key and key not in out:
|
|
134
|
+
out.append(key)
|
|
135
|
+
return out
|
|
@@ -0,0 +1,31 @@
|
|
|
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`(只改格式,不动任务内容)。"""
|
|
26
|
+
if self.identity is not None:
|
|
27
|
+
data["identity"] = {
|
|
28
|
+
"email": self.identity.email,
|
|
29
|
+
"name": self.identity.name,
|
|
30
|
+
}
|
|
31
|
+
super()._flush(data)
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
> **创建**:<created>(@<creator>)
|
|
27
|
+
|
|
28
|
+
## 背景
|
|
29
|
+
|
|
30
|
+
## 待办
|
|
31
|
+
|
|
32
|
+
## 下一步
|
|
33
|
+
|
|
34
|
+
## 过程
|
|
35
|
+
|
|
36
|
+
## 结论
|
|
37
|
+
|
|
38
|
+
## 执行记录
|
|
39
|
+
|
|
40
|
+
<!-- 本节由脚本维护:new / claim / set / release / log / close 自动追加,请勿手改。 -->
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WorklogStore:
|
|
45
|
+
def __init__(self, repo: Path):
|
|
46
|
+
self.repo = Path(repo)
|
|
47
|
+
|
|
48
|
+
# ---- 模板 ----
|
|
49
|
+
|
|
50
|
+
def template(self) -> str:
|
|
51
|
+
"""六节模板:读不到 `assets/worklog.md` → 内置骨架 + 一条提示。"""
|
|
52
|
+
try:
|
|
53
|
+
if WORKLOG_TEMPLATE.exists():
|
|
54
|
+
return WORKLOG_TEMPLATE.read_text(encoding="utf-8")
|
|
55
|
+
except (OSError, UnicodeDecodeError):
|
|
56
|
+
pass
|
|
57
|
+
notices.note(
|
|
58
|
+
"提示:读不到模板 assets/worklog.md,本次用内置骨架建篇(六节齐全,正文仍是空的)。"
|
|
59
|
+
)
|
|
60
|
+
return FALLBACK_TEMPLATE
|
|
61
|
+
|
|
62
|
+
# ---- 找 ----
|
|
63
|
+
|
|
64
|
+
def find(self, tid: str, worklog_rel_value: str | None = None) -> Path | None:
|
|
65
|
+
"""定位一篇过程文档:先信任务对象里记的路径,再按命名推算,最后扫目录。"""
|
|
66
|
+
if worklog_rel_value:
|
|
67
|
+
cand = self.repo / to_repo_rel(worklog_rel_value)
|
|
68
|
+
if cand.is_file():
|
|
69
|
+
return cand
|
|
70
|
+
base = self.repo / to_repo_rel(WORKLOG_DIR)
|
|
71
|
+
names = (worklog_name(tid), f"{tid}.md")
|
|
72
|
+
dirs = [base]
|
|
73
|
+
if base.is_dir():
|
|
74
|
+
dirs += sorted(d for d in base.iterdir() if d.is_dir())
|
|
75
|
+
for directory in dirs:
|
|
76
|
+
for name in names:
|
|
77
|
+
cand = directory / name
|
|
78
|
+
if cand.is_file():
|
|
79
|
+
return cand
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def by_name(self, log_name: str) -> Path | None:
|
|
83
|
+
"""按文件名找(历史末列回链用)。"""
|
|
84
|
+
base = self.repo / to_repo_rel(WORKLOG_DIR)
|
|
85
|
+
if not base.is_dir():
|
|
86
|
+
return None
|
|
87
|
+
direct = base / log_name
|
|
88
|
+
if direct.is_file():
|
|
89
|
+
return direct
|
|
90
|
+
for path in sorted(base.rglob(log_name)):
|
|
91
|
+
if path.is_file():
|
|
92
|
+
return path
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def all(self) -> list[Path]:
|
|
96
|
+
"""递归列出全部过程文档。"""
|
|
97
|
+
base = self.repo / to_repo_rel(WORKLOG_DIR)
|
|
98
|
+
if not base.is_dir():
|
|
99
|
+
return []
|
|
100
|
+
return sorted(p for p in base.rglob("*.md") if p.is_file())
|
|
101
|
+
|
|
102
|
+
# ---- 读 ----
|
|
103
|
+
|
|
104
|
+
def read(self, path: Path) -> str:
|
|
105
|
+
try:
|
|
106
|
+
return path.read_text(encoding="utf-8-sig")
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
return ""
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
raise ValueError(f"读 {path} 失败:{exc}") from exc
|
|
111
|
+
|
|
112
|
+
def head_meta(self, path: Path) -> dict[str, Any]:
|
|
113
|
+
"""头部四样:`created` / `creator` / `category` / `tags`;读不出给空值。"""
|
|
114
|
+
text = self.read(path)
|
|
115
|
+
category, tags = worklog_md.head_meta(text)
|
|
116
|
+
return {
|
|
117
|
+
"created": worklog_md.head_created(text),
|
|
118
|
+
"creator": worklog_md.head_creator(text),
|
|
119
|
+
"category": category,
|
|
120
|
+
"tags": list(tags),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# ---- 写 ----
|
|
124
|
+
|
|
125
|
+
def create(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
tid: str,
|
|
129
|
+
title: str,
|
|
130
|
+
created: str,
|
|
131
|
+
creator: str,
|
|
132
|
+
category: str | None = None,
|
|
133
|
+
tags: tuple[str, ...] = (),
|
|
134
|
+
first_exec: str | None = None,
|
|
135
|
+
) -> Path:
|
|
136
|
+
"""一次写完整篇(含首条执行记录)。返回绝对路径。"""
|
|
137
|
+
rel = worklog_rel(tid, created)
|
|
138
|
+
path = self.repo / to_repo_rel(rel)
|
|
139
|
+
ensure_dir(path.parent)
|
|
140
|
+
text = worklog_md.render_worklog(
|
|
141
|
+
tid=tid,
|
|
142
|
+
title=title,
|
|
143
|
+
created=created,
|
|
144
|
+
creator=creator,
|
|
145
|
+
category=category,
|
|
146
|
+
tags=tags,
|
|
147
|
+
template=self.template(),
|
|
148
|
+
)
|
|
149
|
+
if first_exec:
|
|
150
|
+
text = worklog_md.append_exec_log(text, first_exec)
|
|
151
|
+
write_text_atomic(path, text)
|
|
152
|
+
return path
|
|
153
|
+
|
|
154
|
+
def write(self, path: Path, text: str) -> None:
|
|
155
|
+
"""写回「锚点编辑后的新全文」;不许用它替换前五节正文。"""
|
|
156
|
+
write_text_atomic(path, text)
|
|
157
|
+
|
|
158
|
+
def append_exec(self, path: Path, line: str) -> bool:
|
|
159
|
+
"""追加一条执行记录;写不进返回 False,**不抛**(调用方自己决定报不报)。"""
|
|
160
|
+
try:
|
|
161
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
162
|
+
except (OSError, UnicodeDecodeError):
|
|
163
|
+
return False
|
|
164
|
+
try:
|
|
165
|
+
write_text_atomic(path, worklog_md.append_exec_log(text, line))
|
|
166
|
+
except Exception: # noqa: BLE001 —— 写不进是这条记录的失败,不该掀翻整条命令
|
|
167
|
+
return False
|
|
168
|
+
return True
|