dsh-task-ledger 0.0.96 → 0.0.98
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 +3618 -116
- package/dist/client.js.map +1 -1
- package/dist/host/repositories/ledger.repository.d.ts +8 -2
- package/dist/host/repositories/ledger.repository.js +4 -1
- package/dist/host/repositories/records.d.ts +10 -2
- package/dist/host/repositories/records.js +11 -2
- package/dist/host/routes.js +1 -0
- package/dist/host/services/dates.d.ts +7 -2
- package/dist/host/services/dates.js +29 -6
- package/dist/host/services/history.service.js +1 -1
- package/dist/host/services/trend.service.d.ts +4 -1
- package/dist/host/services/trend.service.js +9 -1
- package/package.json +4 -1
- package/skill/README.md +2 -2
- package/skill/SKILL.md +43 -13
- package/skill/assets/worklog.md +5 -2
- package/skill/package.json +1 -1
- package/skill/references/state-model.md +6 -1
- package/skill/references/troubleshooting.md +5 -5
- package/skill/scripts/ledgerkit/actions/claim.py +7 -2
- package/skill/scripts/ledgerkit/actions/close_task.py +8 -0
- package/skill/scripts/ledgerkit/actions/handoff.py +5 -2
- package/skill/scripts/ledgerkit/actions/list_tasks.py +4 -1
- package/skill/scripts/ledgerkit/actions/log_progress.py +6 -2
- package/skill/scripts/ledgerkit/actions/new_task.py +7 -1
- package/skill/scripts/ledgerkit/actions/query_history.py +3 -1
- package/skill/scripts/ledgerkit/actions/release.py +6 -2
- package/skill/scripts/ledgerkit/actions/result.py +2 -0
- package/skill/scripts/ledgerkit/actions/show_task.py +32 -4
- package/skill/scripts/ledgerkit/actions/validate.py +6 -49
- package/skill/scripts/ledgerkit/core/identity.py +3 -3
- package/skill/scripts/ledgerkit/core/labels.py +16 -4
- package/skill/scripts/ledgerkit/core/time.py +2 -1
- package/skill/scripts/ledgerkit/core/worklog_md.py +216 -31
- package/skill/scripts/ledgerkit/shell/cli.py +24 -2
- package/skill/scripts/ledgerkit/shell/render.py +48 -4
- package/skill/scripts/ledgerkit/store/worklog_store.py +17 -5
|
@@ -7,7 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
from ..core.identity import Identity
|
|
8
8
|
from ..core.paths import POOL_REL, shard_rel
|
|
9
9
|
from ..core.time import now
|
|
10
|
-
from ..core.worklog_md import exec_line
|
|
10
|
+
from ..core.worklog_md import StatusLine, exec_line
|
|
11
11
|
from ..errors import Fail
|
|
12
12
|
from ..store.task_store import TaskStore
|
|
13
13
|
from ..store.worklog_store import WorklogStore
|
|
@@ -63,7 +63,11 @@ def release(repo: Path, me: Identity, tid: str) -> Result:
|
|
|
63
63
|
if path is None:
|
|
64
64
|
note = f"任务 {tid} 已退回公共池,但过程文档没找到,跳过执行记录。"
|
|
65
65
|
else:
|
|
66
|
-
logs.append_exec(
|
|
66
|
+
logs.append_exec(
|
|
67
|
+
path,
|
|
68
|
+
exec_line(now(), me.stamp(), "release", "释放回公共池"),
|
|
69
|
+
status=StatusLine("open", None, None, now()),
|
|
70
|
+
)
|
|
67
71
|
return Result(
|
|
68
72
|
changed=("owner", "owner_name", "status", "updated"),
|
|
69
73
|
where=rel(repo, pool.path),
|
|
@@ -67,6 +67,8 @@ class ShowResult:
|
|
|
67
67
|
task: dict[str, Any] = field(default_factory=dict)
|
|
68
68
|
worklog: WorklogView | None = None
|
|
69
69
|
others: tuple[str, ...] = () # 同一个任务号还出现在哪些文件里
|
|
70
|
+
#: 字段是从**在办索引**读来的(老文档没有状态行 / 文档丢了),不是文档自述
|
|
71
|
+
legacy: bool = False
|
|
70
72
|
notices: tuple[str, ...] = ()
|
|
71
73
|
|
|
72
74
|
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""只读:看一条任务。**文档优先**——读到文档就知道状态,不再翻索引(2026-10-27)。
|
|
2
|
+
|
|
3
|
+
两条路径:
|
|
4
|
+
- 文档存在且头部有状态行 ⇒ 完全按文档自述回答(`status` / 负责人 / 更新时间 / 标题),
|
|
5
|
+
pool、分片、历史一个都不读,已结案的任务同样看得到;
|
|
6
|
+
- 文档没有状态行(老文档)或根本没有文档 ⇒ 回退 `load_task` 查在办索引,
|
|
7
|
+
返回里标 `legacy=True`,让调用方知道这些字段不是文档自述的。
|
|
8
|
+
"""
|
|
2
9
|
|
|
3
10
|
from __future__ import annotations
|
|
4
11
|
|
|
5
12
|
from pathlib import Path
|
|
6
13
|
|
|
14
|
+
from ..core import worklog_md
|
|
7
15
|
from ..core.identity import Identity
|
|
8
16
|
from ..store.worklog_store import WorklogStore
|
|
9
17
|
from .guard import load_task
|
|
@@ -11,17 +19,37 @@ from .result import ShowResult, WorklogView, rel
|
|
|
11
19
|
|
|
12
20
|
|
|
13
21
|
def show_task(repo: Path, me: Identity, tid: str) -> ShowResult:
|
|
14
|
-
task, where = load_task(repo, tid)
|
|
15
22
|
logs = WorklogStore(repo)
|
|
16
|
-
path = logs.find(tid
|
|
23
|
+
path = logs.find(tid) # 只按任务号找文件名:不碰 pool / 分片 / 历史
|
|
24
|
+
text = logs.read(path) if path is not None else ""
|
|
25
|
+
status = worklog_md.read_status(text) if text else None
|
|
26
|
+
if status is not None and path is not None:
|
|
27
|
+
head_tid, title = worklog_md.head_title(text)
|
|
28
|
+
return ShowResult(
|
|
29
|
+
task={
|
|
30
|
+
"tid": head_tid or tid,
|
|
31
|
+
"title": title,
|
|
32
|
+
"status": status.status,
|
|
33
|
+
"owner": status.uuid,
|
|
34
|
+
"owner_name": status.name,
|
|
35
|
+
"created": worklog_md.head_created(text),
|
|
36
|
+
"updated": status.when,
|
|
37
|
+
"finished": worklog_md.head_frozen(text),
|
|
38
|
+
},
|
|
39
|
+
worklog=WorklogView(path=rel(repo, path), text=text, error=None),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# 老文档 / 文档缺失:回退查在办(找不到会抛,文案与既往一致)
|
|
43
|
+
task, where = load_task(repo, tid)
|
|
17
44
|
if path is None:
|
|
18
45
|
view = WorklogView(
|
|
19
46
|
path=None, text=None, error="这条任务没有过程文档(或文档已不在原处)。"
|
|
20
47
|
)
|
|
21
48
|
else:
|
|
22
|
-
view = WorklogView(path=rel(repo, path), text=
|
|
49
|
+
view = WorklogView(path=rel(repo, path), text=text or None, error=None)
|
|
23
50
|
return ShowResult(
|
|
24
51
|
task=task,
|
|
25
52
|
worklog=view,
|
|
26
53
|
others=tuple(rel(repo, p) for p in where),
|
|
54
|
+
legacy=True,
|
|
27
55
|
)
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
"""只读:台账体检。两档:默认浅度只查格式,`--deep`
|
|
1
|
+
"""只读:台账体检。两档:默认浅度只查格式,`--deep` 才读全部历史。
|
|
2
2
|
|
|
3
|
-
浅度(默认):池 +
|
|
4
|
-
深度(`--deep
|
|
3
|
+
浅度(默认):池 + 各分片的字段格式、索引与文件对不对得上、**当月**历史。
|
|
4
|
+
深度(`--deep`):历史不止当月(全部月份)。
|
|
5
|
+
|
|
6
|
+
**两档都不读过程文档正文**(2026-10-27 用户拍板):过程文档是附件、存量可以上万篇,
|
|
7
|
+
逐篇读全文换不来体检结论,只白花时间;文档自身的格式与内容不在体检范围。
|
|
5
8
|
|
|
6
9
|
只查格式,不查业务(该谁结案、逾期与否都不管);也不查「同一任务号落在多个文件」——
|
|
7
10
|
读侧只显示一次(`show` 会点名)、写侧已有防护,撞上了不关体检的事(2026-10-24 用户拍板)。
|
|
@@ -21,16 +24,9 @@ from ..core.paths import (
|
|
|
21
24
|
to_repo_rel,
|
|
22
25
|
)
|
|
23
26
|
from ..core.time import now
|
|
24
|
-
from ..core.worklog_md import (
|
|
25
|
-
duplicated_sections,
|
|
26
|
-
format_problems,
|
|
27
|
-
is_skeleton,
|
|
28
|
-
lacks_exec_section,
|
|
29
|
-
)
|
|
30
27
|
from ..store.history_store import HistoryStore
|
|
31
28
|
from ..store.owners_index import OwnersIndex
|
|
32
29
|
from ..store.task_store import TaskStore
|
|
33
|
-
from ..store.worklog_store import WorklogStore
|
|
34
30
|
from .guard import owner_of, status_of
|
|
35
31
|
from .result import ValidationReport, rel
|
|
36
32
|
|
|
@@ -138,43 +134,6 @@ def _check_history(
|
|
|
138
134
|
return checked
|
|
139
135
|
|
|
140
136
|
|
|
141
|
-
def _check_worklog(
|
|
142
|
-
repo: Path,
|
|
143
|
-
logs: WorklogStore,
|
|
144
|
-
problems: list[str],
|
|
145
|
-
notices: list[str],
|
|
146
|
-
*,
|
|
147
|
-
deep: bool,
|
|
148
|
-
) -> int:
|
|
149
|
-
"""每篇过程文档:模板格式(浅度就查)+ 空骨架判定(只有 `--deep` 才判)。
|
|
150
|
-
|
|
151
|
-
一趟读完两件事都做 —— 格式要看全文找节标题,骨架吃同一份文本,别为此读第二遍。
|
|
152
|
-
"""
|
|
153
|
-
checked = 0
|
|
154
|
-
for path in logs.all():
|
|
155
|
-
checked += 1
|
|
156
|
-
try:
|
|
157
|
-
text = logs.read(path)
|
|
158
|
-
except ValueError:
|
|
159
|
-
continue
|
|
160
|
-
if not text:
|
|
161
|
-
continue
|
|
162
|
-
where = rel(repo, path)
|
|
163
|
-
problems.extend(f"{where} 格式:{line}" for line in format_problems(text))
|
|
164
|
-
if lacks_exec_section(text):
|
|
165
|
-
notices.append(
|
|
166
|
-
f"{where} 没有「执行记录」节(老文档;写一条记录会自动补上)。"
|
|
167
|
-
)
|
|
168
|
-
dup = duplicated_sections(text)
|
|
169
|
-
if dup:
|
|
170
|
-
notices.append(
|
|
171
|
-
f"{where} 有重复的节:{'、'.join(f'`## {s}`' for s in dup)}。"
|
|
172
|
-
)
|
|
173
|
-
if deep and is_skeleton(text):
|
|
174
|
-
notices.append(f"{where} 还是骨架(正文没填)。")
|
|
175
|
-
return checked
|
|
176
|
-
|
|
177
|
-
|
|
178
137
|
def validate(repo: Path, me: Identity, deep: bool = False) -> ValidationReport:
|
|
179
138
|
problems: list[str] = []
|
|
180
139
|
notices: list[str] = []
|
|
@@ -183,7 +142,6 @@ def validate(repo: Path, me: Identity, deep: bool = False) -> ValidationReport:
|
|
|
183
142
|
|
|
184
143
|
index = OwnersIndex(repo)
|
|
185
144
|
history = HistoryStore(repo)
|
|
186
|
-
logs = WorklogStore(repo)
|
|
187
145
|
|
|
188
146
|
files, tasks = _check_task_files(repo, index, problems)
|
|
189
147
|
checked += files
|
|
@@ -197,7 +155,6 @@ def validate(repo: Path, me: Identity, deep: bool = False) -> ValidationReport:
|
|
|
197
155
|
history,
|
|
198
156
|
notices,
|
|
199
157
|
)
|
|
200
|
-
checked += _check_worklog(repo, logs, problems, notices, deep=deep)
|
|
201
158
|
|
|
202
159
|
if not (repo / to_repo_rel(OWNERS_REL)).exists():
|
|
203
160
|
notices.append("还没有 owners.json(索引):跑一条写命令会自动建。")
|
|
@@ -27,10 +27,10 @@ class Identity:
|
|
|
27
27
|
def stamp(self) -> str:
|
|
28
28
|
"""**落盘**用的身份串:`名字 · uuid` —— 名字在前给人读,uuid 在后给机器对。
|
|
29
29
|
|
|
30
|
-
与 `label()` 一样**不带 `@`**(`@`
|
|
31
|
-
`exec_line` 拼 ` · @{who} ·
|
|
30
|
+
与 `label()` 一样**不带 `@`**(`@` 由各落点自己加:头部各行拼 ` · @{stamp}`、
|
|
31
|
+
`exec_line` 拼 ` · @{who} · `)。
|
|
32
32
|
|
|
33
|
-
过程文档(创建行 / 封卷行 / 执行记录)是这个串唯一的落点:文件是最终产物,
|
|
33
|
+
过程文档(创建行 / 状态行 / 封卷行 / 执行记录)是这个串唯一的落点:文件是最终产物,
|
|
34
34
|
创建人和封卷人可能不是同一个人、也可能中途改过名,所以**两个都要留**,不靠
|
|
35
35
|
历史表回链。没名字就只写 uuid。
|
|
36
36
|
"""
|
|
@@ -15,7 +15,7 @@ LABEL_MAX_WORDS_EN = 3 # 纯 ASCII 时按「词」算
|
|
|
15
15
|
TAGS_MAX_COUNT = 5
|
|
16
16
|
|
|
17
17
|
CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
|
18
|
-
LABEL_CAT_RE = re.compile(r"分类\*\*\s*[::]\s*([
|
|
18
|
+
LABEL_CAT_RE = re.compile(r"分类\*\*\s*[::]\s*([^||·]+)")
|
|
19
19
|
LABEL_TAG_RE = re.compile(r"标签\*\*\s*[::]\s*(.+)$")
|
|
20
20
|
_SPLIT_RE = re.compile(r"[,,、]")
|
|
21
21
|
|
|
@@ -49,21 +49,33 @@ def parse_tags(raw: str | Iterable[str] | None) -> tuple[str, ...]:
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def label_line(category: str | None, tags: Iterable[str] | None) -> str:
|
|
52
|
-
"""
|
|
52
|
+
"""过程文档头部的「分类 / 标签」行;两者皆空返回空串(此行不写)。"""
|
|
53
53
|
segs: list[str] = []
|
|
54
54
|
if category:
|
|
55
55
|
segs.append(f"**分类**:{category}")
|
|
56
56
|
tag_list = [t for t in (tags or ()) if t]
|
|
57
57
|
if tag_list:
|
|
58
58
|
segs.append(f"**标签**:{'、'.join(tag_list)}")
|
|
59
|
-
return "
|
|
59
|
+
return "- " + " · ".join(segs) if segs else ""
|
|
60
60
|
|
|
61
61
|
|
|
62
62
|
def parse_label_line(text: str) -> tuple[str | None, tuple[str, ...]]:
|
|
63
|
-
"""从过程文档头部(第一个 `## ` 小节之前)解析分类与标签。
|
|
63
|
+
"""从过程文档头部(第一个 `## ` 小节之前)解析分类与标签。
|
|
64
|
+
|
|
65
|
+
跳过 HTML 注释块:模板注释里举例写着「**分类**:…」,那不是数据。
|
|
66
|
+
"""
|
|
67
|
+
in_comment = False
|
|
64
68
|
for line in text.splitlines():
|
|
65
69
|
if line.startswith("## "):
|
|
66
70
|
break
|
|
71
|
+
stripped = line.lstrip()
|
|
72
|
+
if in_comment:
|
|
73
|
+
if "-->" in stripped:
|
|
74
|
+
in_comment = False
|
|
75
|
+
continue
|
|
76
|
+
if stripped.startswith("<!--"):
|
|
77
|
+
in_comment = "-->" not in stripped
|
|
78
|
+
continue
|
|
67
79
|
mc = LABEL_CAT_RE.search(line)
|
|
68
80
|
mt = LABEL_TAG_RE.search(line)
|
|
69
81
|
if not (mc or mt):
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
进门规则:不读文件、不写文件、不打印;只依赖 errors。
|
|
4
4
|
所有**存盘**时间都是 UTC(`YYYY-MM-DD HH:MM:SS`,不带偏移量);
|
|
5
|
-
|
|
5
|
+
所有**对外输出**(人话表格与 `--json`)的时间字段都过 `fmt_local` 转本机时区,
|
|
6
|
+
内部筛选 / 排序一律用存盘的 UTC —— 调用方拿到的时间不用再转。
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
from __future__ import annotations
|
|
@@ -8,28 +8,57 @@ from __future__ import annotations
|
|
|
8
8
|
import re
|
|
9
9
|
from collections.abc import Iterable
|
|
10
10
|
from itertools import takewhile
|
|
11
|
+
from typing import NamedTuple
|
|
11
12
|
|
|
12
13
|
from ..errors import Fail
|
|
13
14
|
from .identity import UUID_RE
|
|
14
15
|
from .labels import label_line, parse_label_line
|
|
15
|
-
from .time import
|
|
16
|
+
from .time import normalize_time
|
|
16
17
|
|
|
17
18
|
EXEC_HEAD = "## 执行记录"
|
|
18
19
|
EXEC_HINT = (
|
|
19
20
|
"<!-- 本节由脚本维护:new / claim / set / release / log / close 自动追加,请勿手改。\n"
|
|
20
21
|
' 补记用 progress.py log --id <任务号> --note "一句话"。 -->'
|
|
21
22
|
)
|
|
22
|
-
FROZEN_MARK = "
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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"\*\*创建\*\*")
|
|
28
40
|
CREATED_AT_RE = re.compile(
|
|
29
|
-
r"
|
|
41
|
+
_HEAD_BULLET + r"\*\*创建\*\*\s*[::]\s*"
|
|
30
42
|
r"(\d{4}-\d{2}-\d{2}(?: +\d{1,2}:\d{1,2}(?::\d{1,2})?)?)"
|
|
31
43
|
)
|
|
32
|
-
CREATOR_RE = re.compile(r"
|
|
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*(.*)$")
|
|
33
62
|
|
|
34
63
|
_HEAD_RE = re.compile(r"\A(.*?)(?=^## |\Z)", re.DOTALL | re.MULTILINE)
|
|
35
64
|
|
|
@@ -66,10 +95,20 @@ def format_problems(text: str) -> list[str]:
|
|
|
66
95
|
head = _head_lines(lines)
|
|
67
96
|
if not any(CREATED_AT_RE.match(line) for line in head):
|
|
68
97
|
problems.append(
|
|
69
|
-
"头部缺「创建」时间行(应为
|
|
98
|
+
"头部缺「创建」时间行(应为 `- **创建**:YYYY-MM-DD HH:MM:SS · @某人 · uuid`)"
|
|
70
99
|
)
|
|
71
100
|
elif not any(CREATOR_RE.match(line) for line in head):
|
|
72
|
-
problems.append("头部「创建」行缺发起人(应为
|
|
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
|
|
73
112
|
heads = section_heads(body)
|
|
74
113
|
missing = [s for s in REQUIRED_SECTIONS if s not in heads]
|
|
75
114
|
if missing:
|
|
@@ -131,37 +170,171 @@ def render_worklog(
|
|
|
131
170
|
creator: str,
|
|
132
171
|
category: str | None = None,
|
|
133
172
|
tags: Iterable[str] | None = None,
|
|
173
|
+
status: StatusLine | None = None,
|
|
134
174
|
template: str,
|
|
135
175
|
) -> str:
|
|
136
|
-
"""把模板套成一篇过程文档(`<id>` / `<title>` / `<created>` / `<creator>`
|
|
176
|
+
"""把模板套成一篇过程文档(`<id>` / `<title>` / `<created>` / `<creator>` / `<status>`)。
|
|
177
|
+
|
|
178
|
+
`<status>` 只占状态行的**内容**(`doing · 时间 · @某人 · uuid`);模板里的
|
|
179
|
+
`- **状态**:` 前缀是字面量。不传 status ⇒ 整行删掉(不留空壳)。
|
|
180
|
+
"""
|
|
137
181
|
text = (
|
|
138
182
|
template.replace("<id>", tid)
|
|
139
183
|
.replace("<title>", title or "")
|
|
140
184
|
.replace("<created>", created or "")
|
|
141
185
|
.replace("<creator>", creator or "")
|
|
142
186
|
)
|
|
187
|
+
if status is None:
|
|
188
|
+
text = (
|
|
189
|
+
"\n".join(
|
|
190
|
+
ln
|
|
191
|
+
for ln in text.splitlines()
|
|
192
|
+
if not (STATUS_LINE_RE.match(ln) and "<status>" in ln)
|
|
193
|
+
)
|
|
194
|
+
+ "\n"
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
text = text.replace(
|
|
198
|
+
"<status>",
|
|
199
|
+
render_status_body(status.status, status.name, status.uuid, status.when),
|
|
200
|
+
)
|
|
143
201
|
meta = label_line(category, tags)
|
|
144
202
|
if not meta:
|
|
145
203
|
return text
|
|
146
|
-
return "\n".join(
|
|
204
|
+
return "\n".join(_insert_head_line(text.splitlines(), meta, "meta")) + "\n"
|
|
205
|
+
|
|
147
206
|
|
|
207
|
+
_META_LINE_RE = re.compile(_HEAD_BULLET + r"\*\*(?:分类|标签)\*\*")
|
|
148
208
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if
|
|
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:
|
|
153
242
|
at = 1 if lines and lines[0].startswith("# ") else 0
|
|
154
|
-
|
|
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)
|
|
155
248
|
return lines
|
|
156
249
|
|
|
157
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
|
+
|
|
158
329
|
def exec_line(when: str, who: str | None, kind: str, note: str) -> str:
|
|
159
|
-
"""一条执行记录:`-
|
|
330
|
+
"""一条执行记录:`- 时刻 · 类型 · @某人 · 说明`。
|
|
160
331
|
|
|
161
|
-
|
|
162
|
-
|
|
332
|
+
时刻与台账其它存盘时间同口径:**UTC**(调用方一律传 UTC,这里不再转本地)。
|
|
333
|
+
正文整篇由 `readWorklog` / `show` 原样读出,脚本无法逐行回转 ⇒ 存什么就显示什么;
|
|
334
|
+
转本地是消费方(终端 / 看板)的事。
|
|
335
|
+
类型取动作名(new / claim / set / release / log / close)。
|
|
163
336
|
"""
|
|
164
|
-
head = f"- {
|
|
337
|
+
head = f"- {when} · {kind}"
|
|
165
338
|
tail = f" · @{who} · {note}" if who else f" · {note}"
|
|
166
339
|
return head + tail
|
|
167
340
|
|
|
@@ -180,25 +353,25 @@ def has_frozen_line(text: str) -> bool:
|
|
|
180
353
|
|
|
181
354
|
|
|
182
355
|
def stamp_frozen(text: str, finished: str, who: str | None = "") -> str:
|
|
183
|
-
"""
|
|
184
|
-
mark = f"
|
|
356
|
+
"""封卷:在头部打「封卷」行(`finished` 传 UTC,原样写盘——见 exec_line 的口径说明)。"""
|
|
357
|
+
mark = f"- **{FROZEN_MARK}**:{finished}"
|
|
185
358
|
if who:
|
|
186
|
-
mark += f"
|
|
359
|
+
mark += f" · @{who}"
|
|
187
360
|
if FROZEN_RE.search(text or ""):
|
|
188
361
|
return text
|
|
189
|
-
return "\n".join(
|
|
362
|
+
return "\n".join(_insert_head_line(text.splitlines(), mark, "frozen")) + "\n"
|
|
190
363
|
|
|
191
364
|
|
|
192
365
|
def rewrite_label_line(
|
|
193
366
|
text: str, category: str | None, tags: Iterable[str] | None
|
|
194
367
|
) -> str:
|
|
195
|
-
"""改写头部的分类 /
|
|
368
|
+
"""改写头部的分类 / 标签行:有就地替换,没有就按头部顺序插进去。"""
|
|
196
369
|
line = label_line(category, tags)
|
|
197
370
|
lines = text.splitlines()
|
|
198
371
|
for i, ln in enumerate(lines):
|
|
199
372
|
if ln.startswith("## "):
|
|
200
373
|
break
|
|
201
|
-
if
|
|
374
|
+
if _META_LINE_RE.match(ln):
|
|
202
375
|
if line:
|
|
203
376
|
lines[i] = line
|
|
204
377
|
else:
|
|
@@ -206,7 +379,7 @@ def rewrite_label_line(
|
|
|
206
379
|
return "\n".join(lines) + "\n"
|
|
207
380
|
if not line:
|
|
208
381
|
return text
|
|
209
|
-
return "\n".join(
|
|
382
|
+
return "\n".join(_insert_head_line(lines, line, "meta")) + "\n"
|
|
210
383
|
|
|
211
384
|
|
|
212
385
|
def head_meta(text: str) -> tuple[str | None, tuple[str, ...]]:
|
|
@@ -226,8 +399,20 @@ def head_created(text: str) -> str | None:
|
|
|
226
399
|
return None
|
|
227
400
|
|
|
228
401
|
|
|
402
|
+
def head_frozen(text: str) -> str | None:
|
|
403
|
+
"""头部「封卷」行的时间(原样返回,不转时区);没封卷 / 读不到返回 None。"""
|
|
404
|
+
for line in text.splitlines():
|
|
405
|
+
m = _FROZEN_AT_RE.match(line)
|
|
406
|
+
if m:
|
|
407
|
+
try:
|
|
408
|
+
return normalize_time(m.group(1), field="封卷")
|
|
409
|
+
except Fail:
|
|
410
|
+
return None
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
229
414
|
def head_creator(text: str) -> str | None:
|
|
230
|
-
"""
|
|
415
|
+
"""头部「创建」行的发起人**名字**(不带 @、不带 uuid);读不到返回 None。"""
|
|
231
416
|
for line in text.splitlines():
|
|
232
417
|
m = CREATOR_RE.match(line)
|
|
233
418
|
if m and m.group(1).strip():
|
|
@@ -236,7 +421,7 @@ def head_creator(text: str) -> str | None:
|
|
|
236
421
|
|
|
237
422
|
|
|
238
423
|
def head_creator_uuid(text: str) -> str | None:
|
|
239
|
-
"""
|
|
424
|
+
"""头部「创建」行的发起人 **uuid**;老文档没落 uuid 时返回 None。
|
|
240
425
|
|
|
241
426
|
`UUID_RE` 带 `^$` 锚点,只能整串匹配 —— 所以先按 ` · ` 切出尾段再判,不能 search。
|
|
242
427
|
"""
|
|
@@ -14,7 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
from ..actions.query import HistoryQuery, ListQuery
|
|
15
15
|
from ..core.roots import as_root, root_of
|
|
16
16
|
from ..core.time import to_utc
|
|
17
|
-
from ..errors import BUSINESS, USAGE, Fail
|
|
17
|
+
from ..errors import BUSINESS, OK, USAGE, Fail
|
|
18
18
|
from . import render, runner
|
|
19
19
|
from .gitops import repo_sync_lock
|
|
20
20
|
|
|
@@ -99,11 +99,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
99
99
|
hist.add_argument("--json", action="store_true")
|
|
100
100
|
|
|
101
101
|
valp = sub.add_parser(
|
|
102
|
-
"validate",
|
|
102
|
+
"validate",
|
|
103
|
+
help="台账体检(默认浅度;--deep 历史读全部月份;两档都不读过程文档)",
|
|
103
104
|
)
|
|
104
105
|
valp.add_argument("--deep", action="store_true")
|
|
105
106
|
valp.add_argument("--json", action="store_true")
|
|
106
107
|
|
|
108
|
+
who = sub.add_parser("whoami", help="我是谁(本机身份;未初始化也照常回答)")
|
|
109
|
+
who.add_argument("--json", action="store_true")
|
|
110
|
+
|
|
107
111
|
initp = sub.add_parser("init", help="初始化本机身份(一台机器只一次)")
|
|
108
112
|
initp.add_argument("--claim", help="认领名册里已有的 uuid")
|
|
109
113
|
initp.add_argument("--new", action="store_true", dest="new_id", help="新建身份")
|
|
@@ -229,6 +233,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
229
233
|
to_query(args)
|
|
230
234
|
|
|
231
235
|
command = command_of(args)
|
|
236
|
+
if command == "whoami":
|
|
237
|
+
return _run_whoami(args)
|
|
232
238
|
if command == "init":
|
|
233
239
|
return _run_init(args)
|
|
234
240
|
try:
|
|
@@ -405,6 +411,22 @@ def _confirm(question: str) -> bool:
|
|
|
405
411
|
print("请输入 Y 或 N。")
|
|
406
412
|
|
|
407
413
|
|
|
414
|
+
def _run_whoami(args: argparse.Namespace) -> int:
|
|
415
|
+
"""只读机器身份:不解析台账、不碰 git、不看 `--repo`。
|
|
416
|
+
|
|
417
|
+
未初始化**不是错误**:照常回答(`uuid` / `name` 双 null),由调用方据此引导 init。
|
|
418
|
+
"""
|
|
419
|
+
from .. import env
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
identity = env.read_identity()
|
|
423
|
+
except Fail as exc:
|
|
424
|
+
render.fail_message(exc)
|
|
425
|
+
return BUSINESS
|
|
426
|
+
render.whoami_view(identity, as_json=bool(getattr(args, "json", False)))
|
|
427
|
+
return OK
|
|
428
|
+
|
|
429
|
+
|
|
408
430
|
def _run_init(args: argparse.Namespace) -> int:
|
|
409
431
|
"""init 不进 runner、不解析台账、不碰 git:只写机器身份文件。
|
|
410
432
|
|