dsh-task-ledger 0.0.101 → 0.0.103
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 +3 -3
- package/dist/client.js.map +1 -1
- package/dist/host/errors.d.ts +2 -2
- package/dist/host/errors.js +2 -2
- package/dist/host/repositories/runner.d.ts +2 -2
- package/dist/host/repositories/runner.js +13 -10
- package/dist/host/services/session.service.js +1 -1
- package/dist/host/skill/registration.d.ts +2 -1
- package/dist/host/skill/registration.js +4 -3
- package/package.json +2 -2
- package/skill/README.md +4 -2
- package/skill/SKILL.md +5 -3
- package/skill/assets/worklog.md +1 -1
- 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/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 -191
- 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 -129
- 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 -89
- 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 -71
- 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 -89
- 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 -435
- 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 -532
- 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 -178
- package/skill/scripts/progress.py +0 -20
|
@@ -1,435 +0,0 @@
|
|
|
1
|
-
"""过程文档的文本操作:给一段文字,返回新文字。不读文件、不写文件。
|
|
2
|
-
|
|
3
|
-
模板的**读取**归 `store/WorklogStore.template()`,这里只做字符串替换。
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
import re
|
|
9
|
-
from collections.abc import Iterable
|
|
10
|
-
from itertools import takewhile
|
|
11
|
-
from typing import NamedTuple
|
|
12
|
-
|
|
13
|
-
from ..errors import Fail
|
|
14
|
-
from .identity import UUID_RE
|
|
15
|
-
from .labels import label_line, parse_label_line
|
|
16
|
-
from .time import normalize_time
|
|
17
|
-
|
|
18
|
-
EXEC_HEAD = "## 执行记录"
|
|
19
|
-
EXEC_HINT = (
|
|
20
|
-
"<!-- 本节由脚本维护:new / claim / set / release / log / close 自动追加,请勿手改。\n"
|
|
21
|
-
' 补记用 progress.py log --id <任务号> --note "一句话"。 -->'
|
|
22
|
-
)
|
|
23
|
-
FROZEN_MARK = "封卷"
|
|
24
|
-
#: 头部四行的规范顺序:标签 → 状态 → 创建 → 封卷。
|
|
25
|
-
#: 标签行只在有标签时写(分类不进文档),「封卷」只在结案时写,另两行必有。
|
|
26
|
-
HEAD_KEY_ORDER = ("meta", "status", "created", "frozen")
|
|
27
|
-
#: 头部行前缀:新格式是无序列表 `- `,老文档是引用块 `> ` —— 读的时候两种都认。
|
|
28
|
-
_HEAD_BULLET = r"^(?:\s*>\s?|\s*[-*]\s+)"
|
|
29
|
-
FROZEN_RE = re.compile(
|
|
30
|
-
_HEAD_BULLET + r"(?:\*\*" + FROZEN_MARK + r"\*\*|✅ 完成封卷)", re.MULTILINE
|
|
31
|
-
)
|
|
32
|
-
#: 封卷行的时间(老文档是 `> ✅ 完成封卷:<时间>(@…)`,新格式是 `- **封卷**:<时间> · @…`)。
|
|
33
|
-
_FROZEN_AT_RE = re.compile(
|
|
34
|
-
_HEAD_BULLET + r"(?:\*\*" + FROZEN_MARK + r"\*\*|✅ 完成封卷)[^::]*[::]\s*"
|
|
35
|
-
r"(\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?)"
|
|
36
|
-
)
|
|
37
|
-
# 头部「创建」行:`- **创建**:2026-10-06 00:00 · @某人 · uuid`。
|
|
38
|
-
# 值一律 `时间 · @名字 · uuid`,与执行记录行同一种分隔符;老文档是 `(@名字 · uuid)`,读得到就认。
|
|
39
|
-
CREATED_RE = re.compile(_HEAD_BULLET + r"\*\*创建\*\*")
|
|
40
|
-
CREATED_AT_RE = re.compile(
|
|
41
|
-
_HEAD_BULLET + r"\*\*创建\*\*\s*[::]\s*"
|
|
42
|
-
r"(\d{4}-\d{2}-\d{2}(?: +\d{1,2}:\d{1,2}(?::\d{1,2})?)?)"
|
|
43
|
-
)
|
|
44
|
-
CREATOR_RE = re.compile(_HEAD_BULLET + r"\*\*创建\*\*\s*[::].*?@(.+?)\s*[))]?\s*$")
|
|
45
|
-
|
|
46
|
-
# 头部「状态」行:`- **状态**:doing · 2026-10-27 08:28:22 · @某人 · uuid`。
|
|
47
|
-
# 它是**镜像**:分片 / 池里的 `status` / `owner` 才是权威,这行只是让「看一条详情」
|
|
48
|
-
# 不必再翻索引。老文档没有这行 ⇒ 合法,读取方回退查分片。
|
|
49
|
-
STATUS_MARK = "状态"
|
|
50
|
-
STATUS_LINE_RE = re.compile(_HEAD_BULLET + r"\*\*" + STATUS_MARK + r"\*\*")
|
|
51
|
-
STATUS_RE = re.compile(
|
|
52
|
-
_HEAD_BULLET + r"\*\*" + STATUS_MARK + r"\*\*\s*[::]\s*([A-Za-z_]+)"
|
|
53
|
-
)
|
|
54
|
-
#: 状态取值(与 `core/labels.STATUSES` + 结案后的 `closed` 同口径,全链路英文)。
|
|
55
|
-
STATUS_VALUES = ("open", "doing", "closed")
|
|
56
|
-
_STATUS_UUID_RE = re.compile(
|
|
57
|
-
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
|
58
|
-
)
|
|
59
|
-
_STATUS_NAME_RE = re.compile(r"@([^((·]+)")
|
|
60
|
-
_STATUS_WHEN_RE = re.compile(r"(\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?)")
|
|
61
|
-
_TITLE_RE = re.compile(r"^#\s*(.+?)\s*[::]\s*(.*)$")
|
|
62
|
-
|
|
63
|
-
_HEAD_RE = re.compile(r"\A(.*?)(?=^## |\Z)", re.DOTALL | re.MULTILINE)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
#: 过程文档应有的六节,顺序即模板顺序(真源是 `assets/worklog.md`)。
|
|
67
|
-
WORKLOG_SECTIONS = ("背景", "待办", "下一步", "过程", "结论", "执行记录")
|
|
68
|
-
#: 判「格式坏」只要求前五节;「执行记录」由脚本维护,老文档没有、写入时会补,缺了只提示。
|
|
69
|
-
REQUIRED_SECTIONS = WORKLOG_SECTIONS[:5]
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def section_heads(text: str) -> list[str]:
|
|
73
|
-
"""文档里的二级标题(`## 背景` → `背景`),按出现顺序。"""
|
|
74
|
-
return [
|
|
75
|
-
line[3:].strip() for line in (text or "").splitlines() if line.startswith("## ")
|
|
76
|
-
]
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _head_lines(lines: list[str]) -> list[str]:
|
|
80
|
-
"""第一个二级标题之前的行(头部引用块)。"""
|
|
81
|
-
return list(takewhile(lambda line: not line.startswith("## "), lines))
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def format_problems(text: str) -> list[str]:
|
|
85
|
-
"""过程文档的**格式**问题:只看骨架结构,不看内容、不判业务。空列表 = 没问题。
|
|
86
|
-
|
|
87
|
-
五项:第一行是标题 / 头部「创建」行有时间 / 创建行有发起人 / 前五节齐全 / 前五节顺序对。
|
|
88
|
-
结案封卷、分类标签、正文写得怎么样都不在这里判(那是内容与业务)。
|
|
89
|
-
"""
|
|
90
|
-
problems: list[str] = []
|
|
91
|
-
body = (text or "").lstrip("\ufeff")
|
|
92
|
-
lines = body.splitlines()
|
|
93
|
-
if not lines or not lines[0].startswith("# "):
|
|
94
|
-
problems.append("第一行不是标题(应为 `# <任务号>:<标题>`)")
|
|
95
|
-
head = _head_lines(lines)
|
|
96
|
-
if not any(CREATED_AT_RE.match(line) for line in head):
|
|
97
|
-
problems.append(
|
|
98
|
-
"头部缺「创建」时间行(应为 `- **创建**:YYYY-MM-DD HH:MM:SS · @某人 · uuid`)"
|
|
99
|
-
)
|
|
100
|
-
elif not any(CREATOR_RE.match(line) for line in head):
|
|
101
|
-
problems.append("头部「创建」行缺发起人(应为 `… · @某人 · uuid`)")
|
|
102
|
-
# 状态行:**有就必须合法**;缺了不报——老文档没有这行是合法的(读取方回退查分片)
|
|
103
|
-
for line in head:
|
|
104
|
-
if not STATUS_LINE_RE.match(line):
|
|
105
|
-
continue
|
|
106
|
-
m = STATUS_RE.match(line)
|
|
107
|
-
if m is None or m.group(1) not in STATUS_VALUES:
|
|
108
|
-
problems.append(
|
|
109
|
-
"头部「状态」行的值不合法(应为 " + " / ".join(STATUS_VALUES) + ")"
|
|
110
|
-
)
|
|
111
|
-
break
|
|
112
|
-
heads = section_heads(body)
|
|
113
|
-
missing = [s for s in REQUIRED_SECTIONS if s not in heads]
|
|
114
|
-
if missing:
|
|
115
|
-
problems.append("缺节:" + "、".join(f"`## {s}`" for s in missing))
|
|
116
|
-
else:
|
|
117
|
-
# 按「首次出现」去重再比顺序:老文档里同一节重复出现很常见(编辑残留),
|
|
118
|
-
# 那不影响读写(脚本按锚点定位),只作提示,不判错。
|
|
119
|
-
order: list[str] = []
|
|
120
|
-
for head in heads:
|
|
121
|
-
if head in REQUIRED_SECTIONS and head not in order:
|
|
122
|
-
order.append(head)
|
|
123
|
-
if order != list(REQUIRED_SECTIONS):
|
|
124
|
-
problems.append(
|
|
125
|
-
"节的顺序不对:应为 "
|
|
126
|
-
+ " → ".join(f"`## {s}`" for s in REQUIRED_SECTIONS)
|
|
127
|
-
)
|
|
128
|
-
return problems
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def duplicated_sections(text: str) -> list[str]:
|
|
132
|
-
"""重复出现的节标题(老文档编辑残留;不影响读写,只作提示)。"""
|
|
133
|
-
heads = section_heads(text)
|
|
134
|
-
return [s for s in REQUIRED_SECTIONS if heads.count(s) > 1]
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def lacks_exec_section(text: str) -> bool:
|
|
138
|
-
"""缺「执行记录」节(老文档没有;只提示,脚本写入时会自动补上这一节)。"""
|
|
139
|
-
return WORKLOG_SECTIONS[-1] not in section_heads(text)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def is_skeleton(text: str) -> bool:
|
|
143
|
-
"""是不是骨架(正文还没填):前五节里没有任何实质内容。"""
|
|
144
|
-
body = _sections_body(text)
|
|
145
|
-
if not body.strip():
|
|
146
|
-
return True
|
|
147
|
-
for line in body.splitlines():
|
|
148
|
-
stripped = line.strip()
|
|
149
|
-
if not stripped or stripped.startswith(("<!--", "-->", ">")):
|
|
150
|
-
continue
|
|
151
|
-
if stripped.startswith("#"):
|
|
152
|
-
continue
|
|
153
|
-
if stripped in {"-", "—"}:
|
|
154
|
-
continue
|
|
155
|
-
return False
|
|
156
|
-
return True
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def _sections_body(text: str) -> str:
|
|
160
|
-
"""去掉「执行记录」节之后剩下的正文。"""
|
|
161
|
-
idx = text.find(f"\n{EXEC_HEAD}")
|
|
162
|
-
return text[:idx] if idx >= 0 else text
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def render_worklog(
|
|
166
|
-
*,
|
|
167
|
-
tid: str,
|
|
168
|
-
title: str,
|
|
169
|
-
created: str,
|
|
170
|
-
creator: str,
|
|
171
|
-
tags: Iterable[str] | None = None,
|
|
172
|
-
status: StatusLine | None = None,
|
|
173
|
-
template: str,
|
|
174
|
-
) -> str:
|
|
175
|
-
"""把模板套成一篇过程文档(`<id>` / `<title>` / `<created>` / `<creator>` / `<status>`)。
|
|
176
|
-
|
|
177
|
-
`<status>` 只占状态行的**内容**(`doing · 时间 · @某人 · uuid`);模板里的
|
|
178
|
-
`- **状态**:` 前缀是字面量。不传 status ⇒ 整行删掉(不留空壳)。
|
|
179
|
-
"""
|
|
180
|
-
text = (
|
|
181
|
-
template.replace("<id>", tid)
|
|
182
|
-
.replace("<title>", title or "")
|
|
183
|
-
.replace("<created>", created or "")
|
|
184
|
-
.replace("<creator>", creator or "")
|
|
185
|
-
)
|
|
186
|
-
if status is None:
|
|
187
|
-
text = (
|
|
188
|
-
"\n".join(
|
|
189
|
-
ln
|
|
190
|
-
for ln in text.splitlines()
|
|
191
|
-
if not (STATUS_LINE_RE.match(ln) and "<status>" in ln)
|
|
192
|
-
)
|
|
193
|
-
+ "\n"
|
|
194
|
-
)
|
|
195
|
-
else:
|
|
196
|
-
text = text.replace(
|
|
197
|
-
"<status>",
|
|
198
|
-
render_status_body(status.status, status.name, status.uuid, status.when),
|
|
199
|
-
)
|
|
200
|
-
meta = label_line(tags)
|
|
201
|
-
if not meta:
|
|
202
|
-
return text
|
|
203
|
-
return "\n".join(_insert_head_line(text.splitlines(), meta, "meta")) + "\n"
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
#: 头部「标签」行;老格式的「分类」段也一并匹配 —— 改写时整行替换,分类自然消失。
|
|
207
|
-
_META_LINE_RE = re.compile(_HEAD_BULLET + r"\*\*(?:分类|标签)\*\*")
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _head_key(line: str) -> str | None:
|
|
211
|
-
"""这一行是头部哪个字段(`meta` / `status` / `created` / `frozen`);都不是给 None。"""
|
|
212
|
-
if _META_LINE_RE.search(line):
|
|
213
|
-
return "meta"
|
|
214
|
-
if STATUS_LINE_RE.match(line):
|
|
215
|
-
return "status"
|
|
216
|
-
if CREATED_RE.match(line):
|
|
217
|
-
return "created"
|
|
218
|
-
if FROZEN_RE.match(line):
|
|
219
|
-
return "frozen"
|
|
220
|
-
return None
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def _insert_head_line(lines: list[str], line: str, key: str) -> list[str]:
|
|
224
|
-
"""按 `HEAD_KEY_ORDER` 把一行插进头部(第一个 `## ` 之前)。
|
|
225
|
-
|
|
226
|
-
排在它后面的字段行先出现就插在那行前面;没有就接在最后一个字段行之后;
|
|
227
|
-
一个字段行都没有就插在标题(及其后的空行)下面。
|
|
228
|
-
"""
|
|
229
|
-
rank = HEAD_KEY_ORDER.index(key)
|
|
230
|
-
last: int | None = None
|
|
231
|
-
for i, ln in enumerate(lines):
|
|
232
|
-
if ln.startswith("## "):
|
|
233
|
-
break
|
|
234
|
-
found = _head_key(ln)
|
|
235
|
-
if found is None:
|
|
236
|
-
continue
|
|
237
|
-
if HEAD_KEY_ORDER.index(found) > rank:
|
|
238
|
-
lines.insert(i, line)
|
|
239
|
-
return lines
|
|
240
|
-
last = i
|
|
241
|
-
if last is None:
|
|
242
|
-
at = 1 if lines and lines[0].startswith("# ") else 0
|
|
243
|
-
while at + 1 < len(lines) and not lines[at + 1].strip():
|
|
244
|
-
at += 1
|
|
245
|
-
lines.insert(at + 1, line)
|
|
246
|
-
else:
|
|
247
|
-
lines.insert(last + 1, line)
|
|
248
|
-
return lines
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
class StatusLine(NamedTuple):
|
|
252
|
-
"""头部状态行的四样内容(`when` 传 UTC,与执行记录同口径)。"""
|
|
253
|
-
|
|
254
|
-
status: str
|
|
255
|
-
name: str | None = None
|
|
256
|
-
uuid: str | None = None
|
|
257
|
-
when: str | None = None
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def render_status_body(
|
|
261
|
-
status: str,
|
|
262
|
-
name: str | None = None,
|
|
263
|
-
uuid: str | None = None,
|
|
264
|
-
when: str | None = None,
|
|
265
|
-
) -> str:
|
|
266
|
-
"""状态行的**内容**部分(`- **状态**:` 之后那段):`状态 · 时间 · @名字 · uuid`。"""
|
|
267
|
-
parts = [status]
|
|
268
|
-
if when:
|
|
269
|
-
parts.append(when)
|
|
270
|
-
if name:
|
|
271
|
-
parts.append(f"@{name}")
|
|
272
|
-
if uuid:
|
|
273
|
-
parts.append(uuid)
|
|
274
|
-
return " · ".join(parts)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def render_status_line(
|
|
278
|
-
status: str,
|
|
279
|
-
name: str | None = None,
|
|
280
|
-
uuid: str | None = None,
|
|
281
|
-
when: str | None = None,
|
|
282
|
-
) -> str:
|
|
283
|
-
return f"- **{STATUS_MARK}**:{render_status_body(status, name, uuid, when)}"
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def rewrite_status_line(text: str, status: StatusLine) -> str:
|
|
287
|
-
"""把头部状态行改成 `status`:有就就地替换,没有就按头部顺序插进去。"""
|
|
288
|
-
line = render_status_line(status.status, status.name, status.uuid, status.when)
|
|
289
|
-
lines = (text or "").splitlines()
|
|
290
|
-
for i, ln in enumerate(lines):
|
|
291
|
-
if ln.startswith("## "):
|
|
292
|
-
break
|
|
293
|
-
if STATUS_LINE_RE.match(ln):
|
|
294
|
-
lines[i] = line
|
|
295
|
-
return "\n".join(lines) + "\n"
|
|
296
|
-
return "\n".join(_insert_head_line(lines, line, "status")) + "\n"
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def read_status(text: str) -> StatusLine | None:
|
|
300
|
-
"""头部状态行 → `StatusLine`;没有这行、或值不合法 ⇒ None(调用方回退查分片)。"""
|
|
301
|
-
for line in _head_lines((text or "").lstrip("\ufeff").splitlines()):
|
|
302
|
-
m = STATUS_RE.match(line)
|
|
303
|
-
if not (m and m.group(1) in STATUS_VALUES):
|
|
304
|
-
continue
|
|
305
|
-
u = _STATUS_UUID_RE.search(line)
|
|
306
|
-
name = _STATUS_NAME_RE.search(line)
|
|
307
|
-
when = _STATUS_WHEN_RE.search(line)
|
|
308
|
-
return StatusLine(
|
|
309
|
-
m.group(1),
|
|
310
|
-
(name.group(1).strip() or None) if name else None,
|
|
311
|
-
(u.group(0).lower() if u else None),
|
|
312
|
-
(when.group(1) if when else None),
|
|
313
|
-
)
|
|
314
|
-
return None
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def head_title(text: str) -> tuple[str | None, str | None]:
|
|
318
|
-
"""第一行 `# <任务号>:<标题>` → (任务号, 标题);读不出给 (None, None)。"""
|
|
319
|
-
for line in (text or "").lstrip("\ufeff").splitlines():
|
|
320
|
-
if not line.startswith("# "):
|
|
321
|
-
continue
|
|
322
|
-
m = _TITLE_RE.match(line)
|
|
323
|
-
if m:
|
|
324
|
-
return m.group(1).strip() or None, m.group(2).strip() or None
|
|
325
|
-
return None, None
|
|
326
|
-
return None, None
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def exec_line(when: str, who: str | None, kind: str, note: str) -> str:
|
|
330
|
-
"""一条执行记录:`- 时刻 · 类型 · @某人 · 说明`。
|
|
331
|
-
|
|
332
|
-
时刻与台账其它存盘时间同口径:**UTC**(调用方一律传 UTC,这里不再转本地)。
|
|
333
|
-
正文整篇由 `readWorklog` / `show` 原样读出,脚本无法逐行回转 ⇒ 存什么就显示什么;
|
|
334
|
-
转本地是消费方(终端 / 看板)的事。
|
|
335
|
-
类型取动作名(new / claim / set / release / log / close)。
|
|
336
|
-
"""
|
|
337
|
-
head = f"- {when} · {kind}"
|
|
338
|
-
tail = f" · @{who} · {note}" if who else f" · {note}"
|
|
339
|
-
return head + tail
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def append_exec_log(text: str, line: str) -> str:
|
|
343
|
-
"""把一条记录追加到「执行记录」节末尾;老文档没有这一节就先补一节。"""
|
|
344
|
-
if f"\n{EXEC_HEAD}" in text or text.startswith(EXEC_HEAD):
|
|
345
|
-
return text.rstrip("\n") + "\n" + line + "\n"
|
|
346
|
-
block = f"\n{EXEC_HEAD}\n\n{EXEC_HINT}\n{line}\n"
|
|
347
|
-
return text.rstrip("\n") + "\n" + block
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def has_frozen_line(text: str) -> bool:
|
|
351
|
-
"""这篇文档封卷了没有(认标记行)。"""
|
|
352
|
-
return bool(FROZEN_RE.search(text or ""))
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
def stamp_frozen(text: str, finished: str, who: str | None = "") -> str:
|
|
356
|
-
"""封卷:在头部打「封卷」行(`finished` 传 UTC,原样写盘——见 exec_line 的口径说明)。"""
|
|
357
|
-
mark = f"- **{FROZEN_MARK}**:{finished}"
|
|
358
|
-
if who:
|
|
359
|
-
mark += f" · @{who}"
|
|
360
|
-
if FROZEN_RE.search(text or ""):
|
|
361
|
-
return text
|
|
362
|
-
return "\n".join(_insert_head_line(text.splitlines(), mark, "frozen")) + "\n"
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
def rewrite_label_line(text: str, tags: Iterable[str] | None) -> str:
|
|
366
|
-
"""改写头部的标签行:有就地替换(老文档的分类段一并清掉),没有就按头部顺序插进去。"""
|
|
367
|
-
line = label_line(tags)
|
|
368
|
-
lines = text.splitlines()
|
|
369
|
-
for i, ln in enumerate(lines):
|
|
370
|
-
if ln.startswith("## "):
|
|
371
|
-
break
|
|
372
|
-
if _META_LINE_RE.match(ln):
|
|
373
|
-
if line:
|
|
374
|
-
lines[i] = line
|
|
375
|
-
else:
|
|
376
|
-
del lines[i]
|
|
377
|
-
return "\n".join(lines) + "\n"
|
|
378
|
-
if not line:
|
|
379
|
-
return text
|
|
380
|
-
return "\n".join(_insert_head_line(lines, line, "meta")) + "\n"
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
def head_meta(text: str) -> tuple[str | None, tuple[str, ...]]:
|
|
384
|
-
"""过程文档头部的分类与标签(供历史回链与标签统计共用)。"""
|
|
385
|
-
return parse_label_line(text)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
def head_created(text: str) -> str | None:
|
|
389
|
-
"""头部「创建」行的时间(原样返回,不转时区);读不到返回 None。"""
|
|
390
|
-
for line in text.splitlines():
|
|
391
|
-
m = CREATED_AT_RE.match(line)
|
|
392
|
-
if m:
|
|
393
|
-
try:
|
|
394
|
-
return normalize_time(m.group(1), field="创建")
|
|
395
|
-
except Fail:
|
|
396
|
-
return None
|
|
397
|
-
return None
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
def head_frozen(text: str) -> str | None:
|
|
401
|
-
"""头部「封卷」行的时间(原样返回,不转时区);没封卷 / 读不到返回 None。"""
|
|
402
|
-
for line in text.splitlines():
|
|
403
|
-
m = _FROZEN_AT_RE.match(line)
|
|
404
|
-
if m:
|
|
405
|
-
try:
|
|
406
|
-
return normalize_time(m.group(1), field="封卷")
|
|
407
|
-
except Fail:
|
|
408
|
-
return None
|
|
409
|
-
return None
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
def head_creator(text: str) -> str | None:
|
|
413
|
-
"""头部「创建」行的发起人**名字**(不带 @、不带 uuid);读不到返回 None。"""
|
|
414
|
-
for line in text.splitlines():
|
|
415
|
-
m = CREATOR_RE.match(line)
|
|
416
|
-
if m and m.group(1).strip():
|
|
417
|
-
return m.group(1).split(" · ")[0].strip()
|
|
418
|
-
return None
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
def head_creator_uuid(text: str) -> str | None:
|
|
422
|
-
"""头部「创建」行的发起人 **uuid**;老文档没落 uuid 时返回 None。
|
|
423
|
-
|
|
424
|
-
`UUID_RE` 带 `^$` 锚点,只能整串匹配 —— 所以先按 ` · ` 切出尾段再判,不能 search。
|
|
425
|
-
"""
|
|
426
|
-
for line in text.splitlines():
|
|
427
|
-
m = CREATOR_RE.match(line)
|
|
428
|
-
if not m:
|
|
429
|
-
continue
|
|
430
|
-
parts = m.group(1).split(" · ")
|
|
431
|
-
if len(parts) > 1:
|
|
432
|
-
uuid = parts[-1].strip()
|
|
433
|
-
if UUID_RE.fullmatch(uuid):
|
|
434
|
-
return uuid
|
|
435
|
-
return None
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
"""确定「我是谁」的唯一地方:只读机器级身份文件,不读 git、不读环境变量。
|
|
2
|
-
|
|
3
|
-
身份文件:`$TASK_LEDGER_HOME/.taskledger/identity.json`(默认 `~/.taskledger/identity.json`),
|
|
4
|
-
内容是 `{"uuid": "...", "name": "..."}`。没有就报错,由 `init` 命令创建。
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import json
|
|
10
|
-
import os
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
from .core.identity import Identity, looks_like_uuid, normalize
|
|
14
|
-
from .errors import Fail
|
|
15
|
-
|
|
16
|
-
IDENTITY_HOME_ENV = "TASK_LEDGER_HOME"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def identity_file() -> Path:
|
|
20
|
-
"""机器级身份文件路径;`TASK_LEDGER_HOME` 可覆写主目录(容器/测试用)。"""
|
|
21
|
-
base = os.environ.get(IDENTITY_HOME_ENV)
|
|
22
|
-
if base:
|
|
23
|
-
return Path(base) / ".taskledger" / "identity.json"
|
|
24
|
-
try:
|
|
25
|
-
home = Path.home()
|
|
26
|
-
except RuntimeError as exc:
|
|
27
|
-
raise Fail(
|
|
28
|
-
"找不到用户主目录(HOME)。\n"
|
|
29
|
-
" 处理:设置环境变量 TASK_LEDGER_HOME 指向一个可写目录,再重试。"
|
|
30
|
-
) from exc
|
|
31
|
-
return home / ".taskledger" / "identity.json"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def read_identity() -> Identity | None:
|
|
35
|
-
"""读机器身份;文件不存在、空文件、坏 JSON、没 uuid 一律返回 None。
|
|
36
|
-
|
|
37
|
-
uuid 先归一化(去空白 + 小写,与 `--owner` 入参同一口径)。**形态不对直接报错**:
|
|
38
|
-
身份键只能是 uuid,手改进去一个邮箱 / 名字会让每条命令都写错 owner,
|
|
39
|
-
与其静默用下去,不如当场停下让人改文件。
|
|
40
|
-
"""
|
|
41
|
-
path = identity_file()
|
|
42
|
-
try:
|
|
43
|
-
raw = path.read_text(encoding="utf-8")
|
|
44
|
-
except (OSError, ValueError):
|
|
45
|
-
return None
|
|
46
|
-
try:
|
|
47
|
-
data = json.loads(raw)
|
|
48
|
-
except ValueError:
|
|
49
|
-
return None
|
|
50
|
-
uuid = normalize(str((data or {}).get("uuid") or ""))
|
|
51
|
-
if not uuid:
|
|
52
|
-
return None
|
|
53
|
-
if not looks_like_uuid(uuid):
|
|
54
|
-
raise Fail(
|
|
55
|
-
f"身份文件里的 uuid 不合法:{path}\n"
|
|
56
|
-
f" 读到:{uuid}(身份键只能是 uuid,不能是邮箱 / 名字)\n"
|
|
57
|
-
" 处理:把该文件的 uuid 改成 uuid(或删掉这个文件重跑 init)。"
|
|
58
|
-
)
|
|
59
|
-
name = str((data or {}).get("name") or "").strip()
|
|
60
|
-
return Identity(uuid=uuid, name=name)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def current(repo: Path | None = None) -> Identity:
|
|
64
|
-
"""当前操作者;没初始化就报错,提示去 init。
|
|
65
|
-
|
|
66
|
-
name 的取值规则:工作区 owners.json 里同 uuid 的 name 优先,没有就用总体的 name。
|
|
67
|
-
"""
|
|
68
|
-
identity = read_identity()
|
|
69
|
-
if identity is None:
|
|
70
|
-
raise Fail(
|
|
71
|
-
"这台机器还没有初始化身份。\n"
|
|
72
|
-
" 处理:这是未初始化,不是故障——不要 ls / cat identity.json / 读源码或台账去排查。\n"
|
|
73
|
-
" 请让用户在自己的终端运行 `progress.py init --repo <工作区根>`,由 ta 亲自选择/新建并确认;\n"
|
|
74
|
-
" Agent 只能裸跑 init 只读把名册报给 ta。细则见 references/init-identity.md。"
|
|
75
|
-
)
|
|
76
|
-
name = identity.name
|
|
77
|
-
if repo is not None:
|
|
78
|
-
name = _workspace_name(repo, identity.uuid) or name
|
|
79
|
-
return Identity(uuid=identity.uuid, name=name)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _workspace_name(repo: Path, uuid: str) -> str:
|
|
83
|
-
"""当前工作区 owners.json 里这个 uuid 的 name;没有/读不到返回空串。"""
|
|
84
|
-
from .core.paths import OWNERS_REL, to_repo_rel
|
|
85
|
-
|
|
86
|
-
try:
|
|
87
|
-
raw = (repo / to_repo_rel(OWNERS_REL)).read_text(encoding="utf-8")
|
|
88
|
-
workers = (json.loads(raw) or {}).get("workers", {}) or {}
|
|
89
|
-
except (OSError, ValueError):
|
|
90
|
-
return ""
|
|
91
|
-
record = workers.get(uuid) or workers.get(uuid.lower())
|
|
92
|
-
if not isinstance(record, dict):
|
|
93
|
-
return ""
|
|
94
|
-
return str(record.get("name") or "").strip()
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
"""只放两样东西:业务错误 `Fail`,以及退出码数字。
|
|
2
|
-
|
|
3
|
-
任何层都可以 import 它;它自己谁也不 import。
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class Fail(Exception):
|
|
8
|
-
"""业务错误:抛出即中止,由外壳接住转成「错误:…」并返回对应退出码。"""
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
OK = 0
|
|
12
|
-
BUSINESS = 1 # 业务错:任务不存在、不是你的、参数不合法
|
|
13
|
-
USAGE = 2 # 用法错:argparse 自己会退出,这里留给外壳显式用
|
|
14
|
-
UNEXPECTED = 3 # 未预期异常
|
|
15
|
-
INTERRUPTED = 130 # Ctrl-C / SIGINT
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
"""进程级提示收集器:只收文本,永不打印。
|
|
2
|
-
|
|
3
|
-
各层都能往里放(数据层读盘时的版本告警、env 的兜底身份、业务层的兜底提示),
|
|
4
|
-
最后由 shell/render.py 一次性打到 stderr。同一句只收一次。
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
_seen: set[str] = set()
|
|
8
|
-
_items: list[str] = []
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def note(text: str) -> None:
|
|
12
|
-
"""收一条提示;重复的同一句自动忽略。"""
|
|
13
|
-
line = (text or "").strip()
|
|
14
|
-
if not line or line in _seen:
|
|
15
|
-
return
|
|
16
|
-
_seen.add(line)
|
|
17
|
-
_items.append(line)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def warnings() -> tuple[str, ...]:
|
|
21
|
-
"""取当前全部提示(只有 render.py 与 runner.py 能调)。"""
|
|
22
|
-
return tuple(_items)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def clear() -> None:
|
|
26
|
-
"""清空(只有 render.py 与 runner.py 能调)。"""
|
|
27
|
-
_seen.clear()
|
|
28
|
-
_items.clear()
|
|
File without changes
|