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,114 @@
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.email)
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 shards(self) -> dict[str, Path]:
52
+ """身份 → 分片绝对路径(只信索引)。"""
53
+ out: dict[str, Path] = {}
54
+ for key, record in self.all().items():
55
+ name = (record or {}).get("file")
56
+ if not name:
57
+ continue
58
+ out[key] = self.repo / to_repo_rel(OWNERS_DIR) / str(name)
59
+ return out
60
+
61
+ def disk_shards(self) -> list[Path]:
62
+ """扫 `owners/` 下的全部 `*.json`。**全仓唯一扫目录的地方**,只给 `validate`。"""
63
+ base = self.repo / to_repo_rel(OWNERS_DIR)
64
+ if not base.is_dir():
65
+ return []
66
+ return sorted(p for p in base.glob("*.json") if p.is_file())
67
+
68
+ def assert_present(self) -> None:
69
+ """登记过的分片(含池)文件必须都在;被人删了/改名了就报清楚,绝不悄悄当空、绝不修复。
70
+
71
+ 只校验「该在的文件在不在」,不碰内容、不重建。本方法只在校读路径
72
+ (list / show / validate)调用;会新建分片的写动作(claim/set/…)里不调,
73
+ 否则全新仓库第一次写会被误报「分片文件缺失」。
74
+ """
75
+ missing = [str(p) for p in self.shards().values() if not p.exists()]
76
+ if missing:
77
+ raise Fail(
78
+ "台账里登记的任务文件找不到了(可能被人手动删除或改名):\n "
79
+ + "\n ".join(missing)
80
+ + "\n程序不自动修复——请用 git 恢复原文件(git checkout -- <文件>),"
81
+ "或把原文件放回原处,再重试。"
82
+ )
83
+ # 索引本身没了、但盘上还有分片 ⇒ 分片变成无主孤儿,等同于被删
84
+ if not (self.repo / to_repo_rel(OWNERS_REL)).exists() and self.disk_shards():
85
+ raise Fail(
86
+ "台账索引 owners.json 找不到了,但 owners/ 下还有任务文件"
87
+ "(可能被人手动删除或改名)。\n程序不自动修复——请用 git 恢复 owners.json,再重试。"
88
+ )
89
+
90
+ def _open(self):
91
+ """把 `ensure` 的读写收在一处:任何改动最终都按一条一行写回。"""
92
+ return _IndexSession(self._store)
93
+
94
+
95
+ class _IndexSession:
96
+ """索引的一次读写:**没改动就不写回**(自举要能零写,否则每次都撞「nothing to commit」)。"""
97
+
98
+ def __init__(self, store: JSONStore):
99
+ self.store = store
100
+ self.data: dict[str, Any] = {}
101
+ self.changed = False
102
+
103
+ def mark(self) -> None:
104
+ self.changed = True
105
+
106
+ def __enter__(self) -> Self:
107
+ self.data = self.store.data()
108
+ return self
109
+
110
+ def __exit__(self, *exc) -> None:
111
+ if self.changed:
112
+ self.store._flush(self.data) # 同一个包内的受控写回口
113
+
114
+
@@ -0,0 +1,28 @@
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"] = {"email": self.identity.email}
28
+ 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