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,259 @@
|
|
|
1
|
+
"""命令行:只做解析与装配,不碰文件。
|
|
2
|
+
|
|
3
|
+
- `--json` 与 `--count-only` 互斥(退出码 2)。
|
|
4
|
+
- `TASK_LEDGER_NO_SYNC` **只在这里读一次**,折成 `args.no_sync` 往下传。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..actions.query import HistoryQuery, ListQuery
|
|
15
|
+
from ..core.paths import LEDGER_DIR
|
|
16
|
+
from ..core.time import to_utc
|
|
17
|
+
from ..errors import BUSINESS, USAGE, Fail
|
|
18
|
+
from . import render, runner
|
|
19
|
+
from .gitops import repo_sync_lock
|
|
20
|
+
|
|
21
|
+
NO_SYNC_ENV = "TASK_LEDGER_NO_SYNC"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
"""动作名与只读名单从 `actions.registry` 取,不在 cli 里另写一份。"""
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="progress.py", description="任务台账(本地文件 + git 同步)"
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--repo", help="台账所在仓库(默认按当前目录向上找含 ledger/ 的目录)"
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--no-sync", action="store_true", help="只写本地,不取最新 / 不提交 / 不推送"
|
|
34
|
+
)
|
|
35
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
36
|
+
|
|
37
|
+
new = sub.add_parser("new", help="建任务")
|
|
38
|
+
new.add_argument("--title", required=True)
|
|
39
|
+
new.add_argument("--id", dest="tid")
|
|
40
|
+
new.add_argument("--created")
|
|
41
|
+
new.add_argument("--category")
|
|
42
|
+
new.add_argument("--tags")
|
|
43
|
+
new.add_argument("--self", action="store_true", dest="mine")
|
|
44
|
+
|
|
45
|
+
claim = sub.add_parser("claim", help="认领公共池里的任务")
|
|
46
|
+
claim.add_argument("--id", required=True, dest="tid")
|
|
47
|
+
|
|
48
|
+
setp = sub.add_parser("set", help="释放 / 交接任务")
|
|
49
|
+
setp.add_argument("--id", required=True, dest="tid")
|
|
50
|
+
setp.add_argument("--owner")
|
|
51
|
+
|
|
52
|
+
logp = sub.add_parser("log", help="记一条进展")
|
|
53
|
+
logp.add_argument("--id", required=True, dest="tid")
|
|
54
|
+
logp.add_argument("--note", required=True)
|
|
55
|
+
|
|
56
|
+
close = sub.add_parser("close", help="结案")
|
|
57
|
+
close.add_argument("--id", required=True, dest="tid")
|
|
58
|
+
close.add_argument("--summary", required=True)
|
|
59
|
+
close.add_argument("--finished")
|
|
60
|
+
close.add_argument("--category")
|
|
61
|
+
close.add_argument("--tags")
|
|
62
|
+
|
|
63
|
+
listp = sub.add_parser("list", help="查在办任务")
|
|
64
|
+
listp.add_argument("--pool", action="store_true")
|
|
65
|
+
listp.add_argument("--mine", action="store_true")
|
|
66
|
+
listp.add_argument("--owner")
|
|
67
|
+
listp.add_argument("--status")
|
|
68
|
+
listp.add_argument("--category")
|
|
69
|
+
listp.add_argument("--tag")
|
|
70
|
+
listp.add_argument("--since")
|
|
71
|
+
listp.add_argument("--until")
|
|
72
|
+
listp.add_argument("--json", action="store_true")
|
|
73
|
+
|
|
74
|
+
showp = sub.add_parser("show", help="看一条任务")
|
|
75
|
+
showp.add_argument("--id", required=True, dest="tid")
|
|
76
|
+
showp.add_argument("--json", action="store_true")
|
|
77
|
+
|
|
78
|
+
hist = sub.add_parser("history", help="查已结案")
|
|
79
|
+
hist.add_argument("--owner")
|
|
80
|
+
hist.add_argument("--mine", action="store_true")
|
|
81
|
+
hist.add_argument("--category")
|
|
82
|
+
hist.add_argument("--tag")
|
|
83
|
+
hist.add_argument("--since")
|
|
84
|
+
hist.add_argument("--until")
|
|
85
|
+
hist.add_argument("--limit", type=int)
|
|
86
|
+
hist.add_argument("--months", type=int)
|
|
87
|
+
hist.add_argument("--count-only", action="store_true")
|
|
88
|
+
hist.add_argument("--json", action="store_true")
|
|
89
|
+
|
|
90
|
+
valp = sub.add_parser(
|
|
91
|
+
"validate", help="台账体检(默认浅度;--deep 读全部历史与过程文档)"
|
|
92
|
+
)
|
|
93
|
+
valp.add_argument("--deep", action="store_true")
|
|
94
|
+
valp.add_argument("--json", action="store_true")
|
|
95
|
+
|
|
96
|
+
# 宿主(插件 / SKILL.md)的调用写法是 `progress.py list --repo <仓库>`:
|
|
97
|
+
# `--repo` 挂在**子命令后面**,所以每个子命令都要认;子命令给的值覆盖顶层那个。
|
|
98
|
+
for parser_ in sub.choices.values():
|
|
99
|
+
# default=SUPPRESS:子命令没给就不写回 namespace,否则会把顶层的 --repo 覆盖成 None
|
|
100
|
+
parser_.add_argument(
|
|
101
|
+
"--repo", default=argparse.SUPPRESS, help=argparse.SUPPRESS
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return parser
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def to_query(args: argparse.Namespace) -> None:
|
|
108
|
+
"""装配查询条件并挂到 `args.q`(参数 → 查询对象的装配只此一处)。"""
|
|
109
|
+
command = getattr(args, "command", "")
|
|
110
|
+
if command == "list":
|
|
111
|
+
if getattr(args, "mine", False):
|
|
112
|
+
scope = "mine"
|
|
113
|
+
elif getattr(args, "pool", False):
|
|
114
|
+
scope = "pool"
|
|
115
|
+
elif getattr(args, "owner", None):
|
|
116
|
+
scope = "owner"
|
|
117
|
+
else:
|
|
118
|
+
scope = "all"
|
|
119
|
+
args.q = ListQuery(
|
|
120
|
+
scope=scope,
|
|
121
|
+
owner=getattr(args, "owner", None),
|
|
122
|
+
statuses=tuple(
|
|
123
|
+
s.strip()
|
|
124
|
+
for s in (getattr(args, "status", "") or "").split(",")
|
|
125
|
+
if s.strip()
|
|
126
|
+
),
|
|
127
|
+
category=getattr(args, "category", None),
|
|
128
|
+
tag=getattr(args, "tag", None),
|
|
129
|
+
since=_utc_of(getattr(args, "since", None), "--since"),
|
|
130
|
+
until=_utc_of(
|
|
131
|
+
getattr(args, "until", None), "--until", date_only_ref="23:59:59"
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
elif command == "history":
|
|
135
|
+
args.q = HistoryQuery(
|
|
136
|
+
owner=getattr(args, "owner", None),
|
|
137
|
+
mine=bool(getattr(args, "mine", False)),
|
|
138
|
+
category=getattr(args, "category", None),
|
|
139
|
+
tag=getattr(args, "tag", None),
|
|
140
|
+
since=_utc_of(getattr(args, "since", None), "--since"),
|
|
141
|
+
until=_utc_of(
|
|
142
|
+
getattr(args, "until", None), "--until", date_only_ref="23:59:59"
|
|
143
|
+
),
|
|
144
|
+
limit=getattr(args, "limit", None),
|
|
145
|
+
months=getattr(args, "months", None),
|
|
146
|
+
count_only=bool(getattr(args, "count_only", False)),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _utc_of(
|
|
151
|
+
value: str | None, field: str, *, date_only_ref: str | None = None
|
|
152
|
+
) -> str | None:
|
|
153
|
+
return to_utc(value, field=field, date_only_ref=date_only_ref) if value else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def command_of(args: argparse.Namespace) -> str:
|
|
157
|
+
"""命令行 → 名册的键(命令写法)。"""
|
|
158
|
+
return str(getattr(args, "command", ""))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _split_tags(value: str | None) -> tuple[str, ...]:
|
|
162
|
+
if not value:
|
|
163
|
+
return ()
|
|
164
|
+
# 分隔符与 core/labels._SPLIT_RE 保持一致:少一个「、」就会把
|
|
165
|
+
# 「甲、乙」静默合成一个标签(2026-10-24 修)
|
|
166
|
+
for sep in (",", "、"):
|
|
167
|
+
value = value.replace(sep, ",")
|
|
168
|
+
return tuple(t.strip() for t in value.split(",") if t.strip())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_repo(arg: str | None, start: Path | None = None) -> Path:
|
|
172
|
+
"""`--repo` 给了就用;没给就从 `start`(默认 cwd)往上找含 `ledger/` 的目录。
|
|
173
|
+
|
|
174
|
+
找不到 ⇒ Fail:绝不在当前目录自作主张建台账(2026-10-22 设计定稿)。
|
|
175
|
+
建台账是第一条写命令的事,不需要、也没有 `init` 子命令。
|
|
176
|
+
"""
|
|
177
|
+
if arg:
|
|
178
|
+
return Path(arg)
|
|
179
|
+
here = (start or Path.cwd()).resolve()
|
|
180
|
+
for candidate in (*here.parents, here):
|
|
181
|
+
if (candidate / LEDGER_DIR).is_dir():
|
|
182
|
+
return candidate
|
|
183
|
+
raise Fail(
|
|
184
|
+
"找不到台账:从当前目录往上都没有 ledger/ 目录。\n"
|
|
185
|
+
" 处理:回到项目根再跑;或用 --repo <项目根> 指定。\n"
|
|
186
|
+
" 第一条写命令会自动把 ledger/ 建出来,不用先初始化。"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _repo_of(args: argparse.Namespace) -> Path:
|
|
191
|
+
"""本次命令的工作区根(runner 与锁都用它;`main` 里已解析过一次)。"""
|
|
192
|
+
return resolve_repo(getattr(args, "repo", None))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main(argv: list[str] | None = None) -> int:
|
|
196
|
+
parser = build_parser()
|
|
197
|
+
args = parser.parse_args(argv)
|
|
198
|
+
if os.environ.get(NO_SYNC_ENV):
|
|
199
|
+
args.no_sync = True
|
|
200
|
+
if getattr(args, "json", False) and getattr(args, "count_only", False):
|
|
201
|
+
parser.error("--json 与 --count-only 不能同时用")
|
|
202
|
+
return USAGE
|
|
203
|
+
for field in ("tags",):
|
|
204
|
+
if hasattr(args, field):
|
|
205
|
+
setattr(args, field, _split_tags(getattr(args, field)))
|
|
206
|
+
to_query(args)
|
|
207
|
+
|
|
208
|
+
command = command_of(args)
|
|
209
|
+
try:
|
|
210
|
+
# 定位只做一次:解析好的工作区根挂回 args,runner 与整命令锁都拿它
|
|
211
|
+
args.repo = str(resolve_repo(getattr(args, "repo", None)))
|
|
212
|
+
if _readonly(command):
|
|
213
|
+
return runner.run(command, args) # 只读不碰 git ⇒ 不拿同步锁
|
|
214
|
+
with repo_sync_lock(_lock_dir(Path(args.repo))):
|
|
215
|
+
return runner.run(command, args)
|
|
216
|
+
except Fail as exc:
|
|
217
|
+
render.fail_message(exc)
|
|
218
|
+
return BUSINESS
|
|
219
|
+
finally:
|
|
220
|
+
render.flush_notices()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _lock_dir(repo: Path) -> Path:
|
|
224
|
+
"""整命令锁放 **git 公共目录**里(不进仓库、不被 git status 看见;worktree 之间共享)。
|
|
225
|
+
|
|
226
|
+
`repo/.git` 是目录 ⇒ 就是它;是文件 ⇒ 链接 worktree,按 `gitdir:` 指针反推公共目录
|
|
227
|
+
(`<common>/worktrees/<name>` 的上级的上级);两者都不成 ⇒ 不是 git 仓库,
|
|
228
|
+
锁文件放系统临时目录(按仓库绝对路径哈希)—— 落用户工作区会被日后
|
|
229
|
+
`git init && git add -A` 误提交(2026-10-24 修)。
|
|
230
|
+
"""
|
|
231
|
+
dot = Path(repo) / ".git"
|
|
232
|
+
if dot.is_dir():
|
|
233
|
+
return dot
|
|
234
|
+
if dot.is_file():
|
|
235
|
+
try:
|
|
236
|
+
text = dot.read_text(encoding="utf-8").strip()
|
|
237
|
+
except OSError:
|
|
238
|
+
text = ""
|
|
239
|
+
if text.startswith("gitdir:"):
|
|
240
|
+
common = Path(text.split(":", 1)[1].strip()).parent.parent
|
|
241
|
+
if common.is_dir():
|
|
242
|
+
return common
|
|
243
|
+
import hashlib
|
|
244
|
+
import tempfile
|
|
245
|
+
|
|
246
|
+
key = hashlib.sha256(str(Path(repo).resolve()).encode("utf-8")).hexdigest()[:16]
|
|
247
|
+
lock_dir = Path(tempfile.gettempdir()) / f"dsh-task-ledger-{key}"
|
|
248
|
+
lock_dir.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
return lock_dir
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _readonly(command: str) -> bool:
|
|
253
|
+
from ..actions.registry import spec_of
|
|
254
|
+
|
|
255
|
+
return spec_of(command).readonly
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
if __name__ == "__main__": # pragma: no cover
|
|
259
|
+
sys.exit(main())
|