task-ledger 1.2.9 → 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 -245
- 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 +98 -25
- 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 -3021
- package/references/taxonomy.md +0 -26
- package/references/worklog.md +0 -39
|
File without changes
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""历史表格的文本操作:拼行、解析、旧列数升级。
|
|
2
|
+
|
|
3
|
+
**自己不开文件**:升级要回链 worklog 读头部,那件事由调用方传 `read_worklog`
|
|
4
|
+
回调进来(`str -> str | None`),core 只做文本换算。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Callable, Iterable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .paths import to_history_rel
|
|
15
|
+
from .time import TIME_RE, time_key
|
|
16
|
+
|
|
17
|
+
HISTORY_TITLE = "# 进度历史(已结案)"
|
|
18
|
+
HISTORY_HEADER = "| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 | owner | creator | 完成了什么 | 过程文档 |"
|
|
19
|
+
HISTORY_SEP = "|---|---|---|---|---|---|---|---|---|"
|
|
20
|
+
|
|
21
|
+
# 最旧格式把 owner 缝在工作包末尾:最右一个「空格 + @」之后整段是 owner。
|
|
22
|
+
OLD_OWNER_RE = re.compile(r"^(?P<title>.*\S)\s+@(?P<owner>\S.*)$")
|
|
23
|
+
_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]*)\)")
|
|
24
|
+
_TAG_SPLIT_RE = re.compile(r"[,,、]")
|
|
25
|
+
|
|
26
|
+
Row = dict[str, Any] # 一条历史行:见 parse_history 的返回
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def esc_cell(text: str) -> str:
|
|
30
|
+
"""表格单元格:折叠空白并转义竖线,避免把历史表格写坏。"""
|
|
31
|
+
return " ".join((text or "").split()).replace("|", "\\|")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def owner_cell(owner: str | None) -> str:
|
|
35
|
+
"""owner 列的展示值:人名前加 @(`@` 只是展示约定,匹配一律用裸标识)。"""
|
|
36
|
+
return f"@{owner}" if owner else ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def creator_cell(creator: str | None) -> str:
|
|
40
|
+
"""creator 列展示值(与 owner_cell 同一 `@` 约定)。"""
|
|
41
|
+
return f"@{creator}" if creator else ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def split_row(line: str) -> list[str]:
|
|
45
|
+
"""按 `|` 切表格行:转义竖线 `\\|` 不当分隔符,切完反转义、去首尾空单元。"""
|
|
46
|
+
cells = re.split(r"(?<!\\)\|", line.strip())
|
|
47
|
+
if cells and cells[0] == "":
|
|
48
|
+
cells = cells[1:]
|
|
49
|
+
if cells and cells[-1] == "":
|
|
50
|
+
cells = cells[:-1]
|
|
51
|
+
return [c.strip().replace("\\|", "|") for c in cells]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_sep_row(cells: list[str]) -> bool:
|
|
55
|
+
return bool(cells) and all(re.fullmatch(r":?-+:?", c) for c in cells)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _looks_like_header(cells: list[str]) -> bool:
|
|
59
|
+
# 按内容判、不按位置判:无表头的老文件首行就是数据,按位置判会把第一条记录当表头删掉。
|
|
60
|
+
return bool(cells) and cells[0] in ("时间", "结案时间")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def history_line(
|
|
64
|
+
finished: str,
|
|
65
|
+
created: str | None,
|
|
66
|
+
title: str,
|
|
67
|
+
owner: str | None,
|
|
68
|
+
summary: str,
|
|
69
|
+
worklog: str | None,
|
|
70
|
+
category: str | None = None,
|
|
71
|
+
tags: Iterable[str] | None = None,
|
|
72
|
+
creator: str | None = None,
|
|
73
|
+
) -> str:
|
|
74
|
+
"""九列的一行。`worklog` 收**相对 `ledger/`** 的路径,这里负责换成相对 `history/` 的链接。
|
|
75
|
+
|
|
76
|
+
owner / creator 传**裸标识**(`@` 由 owner_cell / creator_cell 加),外面别先加。
|
|
77
|
+
"""
|
|
78
|
+
log_rel = to_history_rel(worklog) if worklog else ""
|
|
79
|
+
tag_cell = esc_cell("、".join(tags) if tags else "")
|
|
80
|
+
link = f"[{log_rel}]({log_rel})" if log_rel else ""
|
|
81
|
+
return (
|
|
82
|
+
f"| {finished} | {created or ''} | {esc_cell(title)} "
|
|
83
|
+
f"| {esc_cell(category or '')} | {tag_cell} "
|
|
84
|
+
f"| {esc_cell(owner_cell(owner))} | {esc_cell(creator_cell(creator))} "
|
|
85
|
+
f"| {esc_cell(summary)} | {link} |\n"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def history_line_for_cols(
|
|
90
|
+
cols: int,
|
|
91
|
+
finished: str,
|
|
92
|
+
created: str | None,
|
|
93
|
+
title: str,
|
|
94
|
+
owner: str | None,
|
|
95
|
+
summary: str,
|
|
96
|
+
worklog: str | None,
|
|
97
|
+
category: str | None = None,
|
|
98
|
+
tags: Iterable[str] | None = None,
|
|
99
|
+
creator: str | None = None,
|
|
100
|
+
) -> str:
|
|
101
|
+
"""「历史层有无法解析的行、不敢整体升级」时按现有列数追加一行,避免列错位。
|
|
102
|
+
|
|
103
|
+
GFM 按首行列数渲染,列数写错会被静默丢弃 / 错位。少列的文件硬塞 9 列会把整行挤歪。
|
|
104
|
+
"""
|
|
105
|
+
log_rel = to_history_rel(worklog) if worklog else ""
|
|
106
|
+
link = f"[{log_rel}]({log_rel})" if log_rel else ""
|
|
107
|
+
if cols <= 4:
|
|
108
|
+
who = f" @{owner}" if owner else ""
|
|
109
|
+
return (
|
|
110
|
+
f"| {finished} | {esc_cell(title + who)} | {esc_cell(summary)} | {link} |\n"
|
|
111
|
+
)
|
|
112
|
+
if cols == 5:
|
|
113
|
+
return (
|
|
114
|
+
f"| {finished} | {esc_cell(title)} | {esc_cell(owner_cell(owner))} "
|
|
115
|
+
f"| {esc_cell(summary)} | {link} |\n"
|
|
116
|
+
)
|
|
117
|
+
if cols == 6:
|
|
118
|
+
return (
|
|
119
|
+
f"| {finished} | {created or ''} | {esc_cell(title)} "
|
|
120
|
+
f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | {link} |\n"
|
|
121
|
+
)
|
|
122
|
+
return history_line(
|
|
123
|
+
finished, created, title, owner, summary, worklog, category, tags, creator
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def history_cols(text: str) -> int:
|
|
128
|
+
"""现有历史表格数据行的列数(取众数、平局取较大);无数据行时按 6。"""
|
|
129
|
+
counts: dict[int, int] = {}
|
|
130
|
+
for raw in text.splitlines():
|
|
131
|
+
if not raw.strip().startswith("|"):
|
|
132
|
+
continue
|
|
133
|
+
cells = split_row(raw)
|
|
134
|
+
if not cells or is_sep_row(cells) or _looks_like_header(cells):
|
|
135
|
+
continue
|
|
136
|
+
if len(cells) >= 4:
|
|
137
|
+
counts[len(cells)] = counts.get(len(cells), 0) + 1
|
|
138
|
+
if not counts:
|
|
139
|
+
return 6
|
|
140
|
+
return max(counts, key=lambda k: (counts[k], k))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_history(text: str) -> tuple[list[Row], list[str]]:
|
|
144
|
+
"""解析历史表 → (records, warnings)。**逐行按自己的列数判别**,表头只用来跳过。
|
|
145
|
+
|
|
146
|
+
兼容 9 / 8 / 6 / 5(两种)/ 4 列;坏行(列数不在列、时间列不合法)跳过并警告,不中断。
|
|
147
|
+
返回的键:finished / created / title / category / tags / owner / creator / summary /
|
|
148
|
+
worklog / fmt / line。owner 与 creator 一律**剥掉 `@`**(匹配用裸标识)。
|
|
149
|
+
"""
|
|
150
|
+
records: list[Row] = []
|
|
151
|
+
warnings: list[str] = []
|
|
152
|
+
for lineno, raw in enumerate(text.splitlines(), 1):
|
|
153
|
+
if not raw.strip().startswith("|"):
|
|
154
|
+
continue
|
|
155
|
+
cells = split_row(raw)
|
|
156
|
+
if not cells or is_sep_row(cells) or _looks_like_header(cells):
|
|
157
|
+
continue
|
|
158
|
+
created = ""
|
|
159
|
+
category: str | None = None
|
|
160
|
+
tags: list[str] = []
|
|
161
|
+
creator: str | None = None
|
|
162
|
+
if len(cells) == 9:
|
|
163
|
+
(
|
|
164
|
+
finished,
|
|
165
|
+
created,
|
|
166
|
+
title,
|
|
167
|
+
category,
|
|
168
|
+
tag_cell,
|
|
169
|
+
owner,
|
|
170
|
+
creator,
|
|
171
|
+
summary,
|
|
172
|
+
doc,
|
|
173
|
+
) = cells
|
|
174
|
+
fmt = "v9"
|
|
175
|
+
category = category.strip() or None
|
|
176
|
+
tags = [t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()]
|
|
177
|
+
creator = creator.strip().removeprefix("@") or None
|
|
178
|
+
elif len(cells) == 8:
|
|
179
|
+
finished, created, title, category, tag_cell, owner, summary, doc = cells
|
|
180
|
+
fmt = "v8"
|
|
181
|
+
category = category.strip() or None
|
|
182
|
+
tags = [t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()]
|
|
183
|
+
elif len(cells) == 6:
|
|
184
|
+
finished, created, title, owner, summary, doc = cells
|
|
185
|
+
fmt = "v6"
|
|
186
|
+
elif len(cells) == 5 and TIME_RE.fullmatch(cells[1]):
|
|
187
|
+
# 旧 ledger 格式(结案 / 创建 / 工作包 / 摘要 / 文档):行内自带创建时间。
|
|
188
|
+
finished, created, title, summary, doc = cells
|
|
189
|
+
owner = ""
|
|
190
|
+
fmt = "v5ledger"
|
|
191
|
+
elif len(cells) == 5:
|
|
192
|
+
finished, title, owner, summary, doc = cells
|
|
193
|
+
fmt = "v5"
|
|
194
|
+
elif len(cells) == 4:
|
|
195
|
+
finished, title, summary, doc = cells
|
|
196
|
+
m = OLD_OWNER_RE.match(title)
|
|
197
|
+
owner = m.group("owner") if m else ""
|
|
198
|
+
if m:
|
|
199
|
+
title = m.group("title")
|
|
200
|
+
fmt = "v4"
|
|
201
|
+
else:
|
|
202
|
+
warnings.append(f"第 {lineno} 行列数不是 4 / 5 / 6 / 8 / 9,已跳过")
|
|
203
|
+
continue
|
|
204
|
+
if not TIME_RE.fullmatch(finished):
|
|
205
|
+
warnings.append(f"第 {lineno} 行时间列 {finished!r} 不合法,已跳过")
|
|
206
|
+
continue
|
|
207
|
+
m = _LINK_RE.fullmatch(doc.strip())
|
|
208
|
+
log = m.group(1) if m else doc.strip()
|
|
209
|
+
records.append(
|
|
210
|
+
{
|
|
211
|
+
"finished": finished,
|
|
212
|
+
"created": time_key(created) if created else None,
|
|
213
|
+
"title": title,
|
|
214
|
+
"category": category,
|
|
215
|
+
"tags": tuple(tags),
|
|
216
|
+
"owner": (owner or "").strip().removeprefix("@") or None,
|
|
217
|
+
"creator": creator,
|
|
218
|
+
"summary": summary,
|
|
219
|
+
"worklog": log or None,
|
|
220
|
+
"fmt": fmt,
|
|
221
|
+
"line": lineno,
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
return records, warnings
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _drop_trailing_blank(out: list[str]) -> None:
|
|
228
|
+
"""去掉已在结果里的尾部空行(丢弃重复表头时用):中间的空行会把 GFM 表格截断。"""
|
|
229
|
+
while out and not out[-1].strip():
|
|
230
|
+
out.pop()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def upgrade_history(
|
|
234
|
+
text: str,
|
|
235
|
+
*,
|
|
236
|
+
read_worklog: Callable[[str], dict[str, Any] | None],
|
|
237
|
+
fallback_created: str,
|
|
238
|
+
) -> tuple[str, int] | None:
|
|
239
|
+
"""旧格式(4 / 5 / 6 / 8 列)→ v9。**全部数据行可解析才返回 (新文本, 转换行数)**,否则 None。
|
|
240
|
+
|
|
241
|
+
`None` 的语义是「不敢动」:调用方必须退回 `history_line_for_cols` 按原列数追加并明示,
|
|
242
|
+
绝不半升级、绝不猜。分类 / 标签 / creator / 缺的创建时间都靠 `read_worklog` 回链补,
|
|
243
|
+
读不到就留空(创建时间除外:留空会让排序失效,所以退化到 `fallback_created`)。
|
|
244
|
+
|
|
245
|
+
`read_worklog` 的契约:`(log_name: str) -> dict | None` —— 收**文件名**(历史末列的
|
|
246
|
+
basename,不是路径),返回 `{"created", "creator", "category", "tags"}`。core 只消费
|
|
247
|
+
返回值,自己不开文件。
|
|
248
|
+
"""
|
|
249
|
+
out: list[str] = []
|
|
250
|
+
converted = 0
|
|
251
|
+
seen_header = False
|
|
252
|
+
seen_sep = False
|
|
253
|
+
for raw in text.splitlines():
|
|
254
|
+
if not raw.strip().startswith("|"):
|
|
255
|
+
out.append(raw)
|
|
256
|
+
continue
|
|
257
|
+
cells = split_row(raw)
|
|
258
|
+
if not cells:
|
|
259
|
+
out.append(raw)
|
|
260
|
+
continue
|
|
261
|
+
if is_sep_row(cells):
|
|
262
|
+
if seen_sep:
|
|
263
|
+
_drop_trailing_blank(out)
|
|
264
|
+
continue
|
|
265
|
+
seen_sep = True
|
|
266
|
+
out.append(HISTORY_SEP)
|
|
267
|
+
continue
|
|
268
|
+
if _looks_like_header(cells):
|
|
269
|
+
if seen_header:
|
|
270
|
+
_drop_trailing_blank(out)
|
|
271
|
+
continue
|
|
272
|
+
seen_header = True
|
|
273
|
+
out.append(HISTORY_HEADER)
|
|
274
|
+
continue
|
|
275
|
+
if len(cells) == 9:
|
|
276
|
+
if time_key(cells[0]) is None:
|
|
277
|
+
return None
|
|
278
|
+
out.append("| " + " | ".join(cells) + " |")
|
|
279
|
+
continue
|
|
280
|
+
explicit_created: str | None = None
|
|
281
|
+
creator: str | None = None
|
|
282
|
+
if len(cells) == 8:
|
|
283
|
+
finished, created, title, category, tag_cell, owner, summary, doc = cells
|
|
284
|
+
if time_key(finished) is None:
|
|
285
|
+
return None
|
|
286
|
+
created_key = time_key(created.strip())
|
|
287
|
+
tags = [t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()]
|
|
288
|
+
creator = (_meta_of(doc, read_worklog) or {}).get("creator")
|
|
289
|
+
out.append(
|
|
290
|
+
f"| {finished} | {created_key or ''} | {esc_cell(title)} "
|
|
291
|
+
f"| {esc_cell(category.strip())} "
|
|
292
|
+
f"| {esc_cell('、'.join(tags) if tags else '')} "
|
|
293
|
+
f"| {esc_cell(owner_cell(owner.lstrip('@')))} "
|
|
294
|
+
f"| {esc_cell(creator_cell(creator))} "
|
|
295
|
+
f"| {esc_cell(summary)} | {doc} |"
|
|
296
|
+
)
|
|
297
|
+
converted += 1
|
|
298
|
+
continue
|
|
299
|
+
if len(cells) == 6:
|
|
300
|
+
finished, created, title, owner, summary, doc = cells
|
|
301
|
+
owner = owner.lstrip("@")
|
|
302
|
+
explicit_created = time_key(created.strip())
|
|
303
|
+
elif len(cells) == 5 and TIME_RE.fullmatch(cells[1]):
|
|
304
|
+
finished, created_cell, title, summary, doc = cells
|
|
305
|
+
owner = ""
|
|
306
|
+
explicit_created = time_key(created_cell.strip())
|
|
307
|
+
elif len(cells) == 5:
|
|
308
|
+
finished, title, owner, summary, doc = cells
|
|
309
|
+
owner = owner.lstrip("@")
|
|
310
|
+
elif len(cells) == 4:
|
|
311
|
+
finished, title, summary, doc = cells
|
|
312
|
+
m = OLD_OWNER_RE.match(title)
|
|
313
|
+
owner = m.group("owner") if m else ""
|
|
314
|
+
if m:
|
|
315
|
+
title = m.group("title")
|
|
316
|
+
else:
|
|
317
|
+
return None
|
|
318
|
+
finished_key = time_key(finished)
|
|
319
|
+
if finished_key is None:
|
|
320
|
+
return None
|
|
321
|
+
meta = _meta_of(doc, read_worklog)
|
|
322
|
+
created = explicit_created or (meta or {}).get("created") or fallback_created
|
|
323
|
+
category = (meta or {}).get("category")
|
|
324
|
+
tags = tuple((meta or {}).get("tags") or ())
|
|
325
|
+
creator = (meta or {}).get("creator")
|
|
326
|
+
out.append(
|
|
327
|
+
f"| {finished_key} | {created} | {esc_cell(title)} "
|
|
328
|
+
f"| {esc_cell(category or '')} "
|
|
329
|
+
f"| {esc_cell('、'.join(tags) if tags else '')} "
|
|
330
|
+
f"| {esc_cell(owner_cell(owner))} "
|
|
331
|
+
f"| {esc_cell(creator_cell(creator))} "
|
|
332
|
+
f"| {esc_cell(summary)} | {doc} |"
|
|
333
|
+
)
|
|
334
|
+
converted += 1
|
|
335
|
+
return "\n".join(out) + "\n", converted
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _link_target(cell: str) -> str:
|
|
339
|
+
m = _LINK_RE.fullmatch((cell or "").strip())
|
|
340
|
+
return (m.group(1) if m else (cell or "")).strip()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _meta_of(
|
|
344
|
+
doc: str, read_worklog: Callable[[str], dict[str, Any] | None]
|
|
345
|
+
) -> dict[str, Any] | None:
|
|
346
|
+
"""顺着历史行的文档链接取回 worklog 头部四样(`read_worklog` 收文件名)。"""
|
|
347
|
+
target = _link_target(doc)
|
|
348
|
+
if not target:
|
|
349
|
+
return None
|
|
350
|
+
return read_worklog(Path(target).name)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""身份这个值对象:归一化与比较规则。**只做字符串换算,不读 git**(读 git 归 env.py)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Identity:
|
|
10
|
+
"""一个人的身份。`email` 是分区键(归一化后的裸标识),`name` 只用于显示。"""
|
|
11
|
+
|
|
12
|
+
email: str
|
|
13
|
+
name: str = ""
|
|
14
|
+
aliases: tuple[str, ...] = ()
|
|
15
|
+
|
|
16
|
+
def label(self) -> str:
|
|
17
|
+
"""显示名:没名字就用邮箱。"""
|
|
18
|
+
return self.name or self.email
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize(who: str) -> str:
|
|
22
|
+
"""归一化:去首尾空白 + 转小写。全仓只此一处。"""
|
|
23
|
+
return (who or "").strip().lower()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def keys(identity: Identity) -> tuple[str, ...]:
|
|
27
|
+
"""这个身份的全部可比标识:自己 + aliases(去重、保持顺序)。"""
|
|
28
|
+
out: list[str] = []
|
|
29
|
+
for value in (identity.email, *identity.aliases):
|
|
30
|
+
key = normalize(value)
|
|
31
|
+
if key and key not in out:
|
|
32
|
+
out.append(key)
|
|
33
|
+
return tuple(out)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_me(identity: Identity, who: str | None) -> bool:
|
|
37
|
+
"""`who`(可能是邮箱、名字或别名)是不是这个人。"""
|
|
38
|
+
return normalize(who or "") in keys(identity)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def with_aliases(identity: Identity, aliases: tuple[str, ...]) -> Identity:
|
|
42
|
+
"""换一个带 aliases 的同身份对象(索引里读到别名后由外壳装配)。"""
|
|
43
|
+
return replace(identity, aliases=tuple(aliases))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""任务号:生成、形态校验。只做字符串换算。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
|
|
9
|
+
from ..errors import Fail
|
|
10
|
+
from .time import now
|
|
11
|
+
|
|
12
|
+
ID_RE = re.compile(r"[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*\Z")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def slugify(title: str) -> str:
|
|
16
|
+
"""任务号用:保留 ASCII 字母数字与汉字,其余转连字符,截到 40 字。"""
|
|
17
|
+
s = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", title.strip().lower())
|
|
18
|
+
return s.strip("-")[:40].strip("-")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def id_stamp(created: str) -> str:
|
|
22
|
+
"""`created`(YYYY-MM-DD HH:MM:SS)→ 任务号前缀 `YYYYMMDD-HHMM`(任务号只到分)。"""
|
|
23
|
+
return (
|
|
24
|
+
f"{created[0:4]}{created[5:7]}{created[8:10]}-{created[11:13]}{created[14:16]}"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_id(title: str, tasks: Mapping[str, object], created: str | None = None) -> str:
|
|
29
|
+
"""按标题生成一个不撞号的任务号。
|
|
30
|
+
|
|
31
|
+
随机码不能省:`make_id` 只在**在办**任务里去重,而结案的过程文档长期留在磁盘上,
|
|
32
|
+
去掉随机码会让新任务撞上已结案文档的名字。
|
|
33
|
+
"""
|
|
34
|
+
slug = slugify(title)
|
|
35
|
+
if not slug:
|
|
36
|
+
raise Fail(
|
|
37
|
+
"标题里没有可用字符(字母、数字或汉字),无法自动生成任务号。"
|
|
38
|
+
f'用 --id 指定一个,例如 --id "task-{secrets.token_hex(2)}"。'
|
|
39
|
+
)
|
|
40
|
+
stamp = id_stamp(created or now())
|
|
41
|
+
for _ in range(20):
|
|
42
|
+
tid = f"{stamp}-{slug}-{secrets.token_hex(2)}"
|
|
43
|
+
if tid not in tasks:
|
|
44
|
+
return tid
|
|
45
|
+
raise Fail("任务号连续冲突 20 次,请检查台账是否异常。")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def require_task_id(tid: str) -> str:
|
|
49
|
+
"""任务号形态校验(全仓唯一入口)。不合法直接抛 Fail。"""
|
|
50
|
+
value = (tid or "").strip()
|
|
51
|
+
if not value:
|
|
52
|
+
raise Fail("任务号不能为空。")
|
|
53
|
+
if not ID_RE.fullmatch(value.lower()):
|
|
54
|
+
raise Fail(
|
|
55
|
+
f"任务号 {tid!r} 不合法:只能用小写字母、数字、汉字和连字符,"
|
|
56
|
+
"且不能以连字符开头或结尾。"
|
|
57
|
+
)
|
|
58
|
+
return value
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""分类与标签:校验、拼行、拆行。只做文本换算。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from ..errors import Fail
|
|
9
|
+
|
|
10
|
+
STATUSES = ("open", "doing")
|
|
11
|
+
LEGACY_STATUSES = ("blocked",) # 旧数据:读侧当进行中,不写回
|
|
12
|
+
FORMAT_VERSION = "20261022000000"
|
|
13
|
+
|
|
14
|
+
LABEL_MAX_CHARS = 4 # 含 CJK 时按「字」算
|
|
15
|
+
LABEL_MAX_WORDS_EN = 3 # 纯 ASCII 时按「词」算
|
|
16
|
+
TAGS_MAX_COUNT = 5
|
|
17
|
+
|
|
18
|
+
CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
|
19
|
+
LABEL_CAT_RE = re.compile(r"分类\*\*\s*[::]\s*([^||]+)")
|
|
20
|
+
LABEL_TAG_RE = re.compile(r"标签\*\*\s*[::]\s*(.+)$")
|
|
21
|
+
_SPLIT_RE = re.compile(r"[,,、]")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize_label(value: str, *, field: str) -> str:
|
|
25
|
+
"""分类 / 单个标签的校验:去空白、限长。空串直接 Fail(给了就要有意义)。"""
|
|
26
|
+
text = (value or "").strip()
|
|
27
|
+
if not text:
|
|
28
|
+
raise Fail(f"{field} 不能为空。")
|
|
29
|
+
if CJK_RE.search(text):
|
|
30
|
+
if len(text) > LABEL_MAX_CHARS:
|
|
31
|
+
raise Fail(f"{field} {text!r} 太长:含中文时最多 {LABEL_MAX_CHARS} 个字。")
|
|
32
|
+
elif len(text.split()) > LABEL_MAX_WORDS_EN:
|
|
33
|
+
raise Fail(f"{field} {text!r} 太长:英文最多 {LABEL_MAX_WORDS_EN} 个词。")
|
|
34
|
+
return text
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_tags(raw: str | Iterable[str] | None) -> tuple[str, ...]:
|
|
38
|
+
"""标签串(「、」「,」「,」分隔)→ 去重保序的元组;超上限报错。"""
|
|
39
|
+
if raw is None:
|
|
40
|
+
return ()
|
|
41
|
+
items = list(raw) if not isinstance(raw, str) else _SPLIT_RE.split(raw)
|
|
42
|
+
out: list[str] = []
|
|
43
|
+
for item in items:
|
|
44
|
+
text = normalize_label(str(item).strip(), field="标签")
|
|
45
|
+
if text not in out:
|
|
46
|
+
out.append(text)
|
|
47
|
+
if len(out) > TAGS_MAX_COUNT:
|
|
48
|
+
raise Fail(f"标签最多 {TAGS_MAX_COUNT} 个,收到 {len(out)} 个。")
|
|
49
|
+
return tuple(out)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def label_line(category: str | None, tags: Iterable[str] | None) -> str:
|
|
53
|
+
"""过程文档头部的「分类/标签」行;两者皆空返回空串(此行不写)。"""
|
|
54
|
+
segs: list[str] = []
|
|
55
|
+
if category:
|
|
56
|
+
segs.append(f"**分类**:{category}")
|
|
57
|
+
tag_list = [t for t in (tags or ()) if t]
|
|
58
|
+
if tag_list:
|
|
59
|
+
segs.append(f"**标签**:{'、'.join(tag_list)}")
|
|
60
|
+
return "> " + " | ".join(segs) if segs else ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_label_line(text: str) -> tuple[str | None, tuple[str, ...]]:
|
|
64
|
+
"""从过程文档头部(第一个 `## ` 小节之前)解析分类与标签。"""
|
|
65
|
+
for line in text.splitlines():
|
|
66
|
+
if line.startswith("## "):
|
|
67
|
+
break
|
|
68
|
+
mc = LABEL_CAT_RE.search(line)
|
|
69
|
+
mt = LABEL_TAG_RE.search(line)
|
|
70
|
+
if not (mc or mt):
|
|
71
|
+
continue
|
|
72
|
+
category = mc.group(1).strip() if mc else None
|
|
73
|
+
tags = (
|
|
74
|
+
tuple(t.strip() for t in _SPLIT_RE.split(mt.group(1).strip()) if t.strip())
|
|
75
|
+
if mt
|
|
76
|
+
else ()
|
|
77
|
+
)
|
|
78
|
+
return (category or None, tags)
|
|
79
|
+
return (None, ())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""路径与文件名怎么拼:只做换算,一个文件都不碰。
|
|
2
|
+
|
|
3
|
+
**基准只有两个**:
|
|
4
|
+
- 磁盘与 JSON 里存的(`task["worklog"]` 等)一律**相对 `ledger/`**;
|
|
5
|
+
- 输出给人或给界面的(`Result.where`、`payload` 里的 `*_path`)一律**相对 repo**。
|
|
6
|
+
|
|
7
|
+
换算只在这两个函数里做,任何层都不许自己拼 `..`。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
LEDGER_DIR = Path("ledger")
|
|
17
|
+
|
|
18
|
+
POOL_REL = Path("pool.json")
|
|
19
|
+
OWNERS_REL = Path("owners.json")
|
|
20
|
+
OWNERS_DIR = Path("owners")
|
|
21
|
+
HISTORY_DIR = Path("history")
|
|
22
|
+
WORKLOG_DIR = Path("worklog")
|
|
23
|
+
LEGACY_HISTORY_REL = Path("PROGRESS-HISTORY.md") # 存量平铺文件:只读不写
|
|
24
|
+
|
|
25
|
+
SHA_LEN = 12
|
|
26
|
+
|
|
27
|
+
# 模板与资产的唯一定位基准:按自身位置算绝对路径,不依赖调用方 cwd。
|
|
28
|
+
# ledgerkit/core/paths.py → parents: [core, ledgerkit, scripts, skill]
|
|
29
|
+
SKILL_DIR = Path(__file__).resolve().parents[3]
|
|
30
|
+
WORKLOG_TEMPLATE = SKILL_DIR / "assets" / "worklog.md"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def to_repo_rel(ledger_rel: str | Path) -> Path:
|
|
34
|
+
"""相对 `ledger/` 的路径 → 相对 repo 的路径。"""
|
|
35
|
+
return LEDGER_DIR / Path(ledger_rel)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def to_ledger_rel(repo_rel: str | Path) -> str:
|
|
39
|
+
"""相对 repo 的路径 → 相对 `ledger/` 的路径(已经是 `ledger/` 内的原样返回)。"""
|
|
40
|
+
p = Path(repo_rel)
|
|
41
|
+
if p.parts and p.parts[0] == LEDGER_DIR.name:
|
|
42
|
+
return Path(*p.parts[1:]).as_posix()
|
|
43
|
+
return p.as_posix()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def to_history_rel(ledger_rel: str | Path) -> str:
|
|
47
|
+
"""`ledger/` 内的路径 → 从 `ledger/history/<ym>.md` 出发的链接串。
|
|
48
|
+
|
|
49
|
+
深度固定(`history/` 与 `worklog/` 都在 `ledger/` 下),拼 `..` 只许在这一处。
|
|
50
|
+
"""
|
|
51
|
+
return ("../" / Path(ledger_rel)).as_posix()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def shard_rel(identity: str) -> Path:
|
|
55
|
+
"""某人分片相对 `ledger/` 的路径:sha256(归一化身份)[:12] + .json。"""
|
|
56
|
+
digest = hashlib.sha256(identity.strip().lower().encode("utf-8")).hexdigest()
|
|
57
|
+
return OWNERS_DIR / f"{digest[:SHA_LEN]}.json"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def month_of(stamp: str, *, fallback_now: str | None = None) -> str:
|
|
61
|
+
"""存盘时间 → `YYYYMM`;取不到时用 `fallback_now`,再没有就抛错由调用方定夺。"""
|
|
62
|
+
month = re.sub(r"[^0-9]", "", (stamp or "")[:7])[:6]
|
|
63
|
+
if len(month) == 6:
|
|
64
|
+
return month
|
|
65
|
+
if fallback_now:
|
|
66
|
+
return re.sub(r"[^0-9]", "", fallback_now[:7])[:6]
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def worklog_dir_for(created: str) -> Path:
|
|
71
|
+
"""过程文档的按月目录(相对 `ledger/`):`worklog/<YYYYMM>/`。"""
|
|
72
|
+
return WORKLOG_DIR / month_of(created)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def worklog_name(tid: str) -> str:
|
|
76
|
+
"""过程文档的文件名:任务号剥掉前缀年月日,随机码保留。
|
|
77
|
+
|
|
78
|
+
父目录 `worklog/<YYYYMM>/` 已带年月,文件名里再写一遍是冗余;
|
|
79
|
+
自定义 `--id` 不以「8 位数字-」开头时,文件名就是任务号本身。
|
|
80
|
+
"""
|
|
81
|
+
if len(tid) > 9 and tid[:8].isdigit() and tid[8] == "-":
|
|
82
|
+
return f"{tid[6:]}.md"
|
|
83
|
+
return f"{tid}.md"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def worklog_rel(tid: str, created: str) -> str:
|
|
87
|
+
"""过程文档相对 `ledger/` 的路径:任务对象与历史末列共用同一口径。"""
|
|
88
|
+
return (worklog_dir_for(created) / worklog_name(tid)).as_posix()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def history_path_for(finished: str) -> Path:
|
|
92
|
+
"""结案时间 → 历史文件相对 `ledger/` 的路径:`history/<YYYYMM>.md`。"""
|
|
93
|
+
return HISTORY_DIR / f"{month_of(finished)}.md"
|