dsh-task-ledger 0.0.95 → 0.0.97
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 +34 -13
- package/skill/assets/worklog.md +1 -0
- 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 +31 -4
- package/skill/scripts/ledgerkit/actions/validate.py +6 -49
- package/skill/scripts/ledgerkit/core/history_md.py +18 -13
- package/skill/scripts/ledgerkit/core/time.py +2 -1
- package/skill/scripts/ledgerkit/core/worklog_md.py +137 -8
- 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 +15 -4
|
@@ -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,36 @@ 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
|
+
},
|
|
38
|
+
worklog=WorklogView(path=rel(repo, path), text=text, error=None),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# 老文档 / 文档缺失:回退查在办(找不到会抛,文案与既往一致)
|
|
42
|
+
task, where = load_task(repo, tid)
|
|
17
43
|
if path is None:
|
|
18
44
|
view = WorklogView(
|
|
19
45
|
path=None, text=None, error="这条任务没有过程文档(或文档已不在原处)。"
|
|
20
46
|
)
|
|
21
47
|
else:
|
|
22
|
-
view = WorklogView(path=rel(repo, path), text=
|
|
48
|
+
view = WorklogView(path=rel(repo, path), text=text or None, error=None)
|
|
23
49
|
return ShowResult(
|
|
24
50
|
task=task,
|
|
25
51
|
worklog=view,
|
|
26
52
|
others=tuple(rel(repo, p) for p in where),
|
|
53
|
+
legacy=True,
|
|
27
54
|
)
|
|
@@ -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(索引):跑一条写命令会自动建。")
|
|
@@ -32,13 +32,18 @@ def esc_cell(text: str) -> str:
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def owner_cell(owner: str | None) -> str:
|
|
35
|
-
"""owner
|
|
36
|
-
return
|
|
35
|
+
"""owner 列的值:裸 uuid,**不加 `@`**(`@` 只加在名字列;匹配一律用裸 uuid)。"""
|
|
36
|
+
return owner or ""
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def creator_cell(creator: str | None) -> str:
|
|
40
|
-
"""creator
|
|
41
|
-
return
|
|
40
|
+
"""creator 列的值(与 owner_cell 同一约定:裸 uuid,不加 `@`)。"""
|
|
41
|
+
return creator or ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def name_cell(name: str | None) -> str:
|
|
45
|
+
"""名字列的展示值:名字前加 `@`(与过程文档 `@名字 · uuid` 同一口径)。"""
|
|
46
|
+
return f"@{name}" if name else ""
|
|
42
47
|
|
|
43
48
|
|
|
44
49
|
def split_row(line: str) -> list[str]:
|
|
@@ -74,9 +79,9 @@ def history_line(
|
|
|
74
79
|
) -> str:
|
|
75
80
|
"""十一列的一行。`worklog` 收**相对 `ledger/`** 的路径,这里负责换成相对 `history/` 的链接。
|
|
76
81
|
|
|
77
|
-
owner / creator 传**裸 uuid
|
|
78
|
-
`
|
|
79
|
-
|
|
82
|
+
owner / creator 传**裸 uuid**、名字传**裸名字**,两处都别先加 `@`:
|
|
83
|
+
`@` 由 `name_cell` 只加在名字列(与过程文档 `@名字 · uuid` 同一口径),uuid 列永远是裸值。
|
|
84
|
+
`owner_name` / `creator_name` 是**落盘的显示名快照**(结案那一刻的名字,之后改名不改旧行)。
|
|
80
85
|
"""
|
|
81
86
|
log_rel = to_history_rel(worklog) if worklog else ""
|
|
82
87
|
tag_cell = esc_cell("、".join(tags) if tags else "")
|
|
@@ -84,8 +89,8 @@ def history_line(
|
|
|
84
89
|
return (
|
|
85
90
|
f"| {finished} | {created or ''} | {esc_cell(title)} "
|
|
86
91
|
f"| {esc_cell(category or '')} | {tag_cell} "
|
|
87
|
-
f"| {esc_cell(owner_cell(owner))} | {esc_cell(owner_name
|
|
88
|
-
f"| {esc_cell(creator_cell(creator))} | {esc_cell(creator_name
|
|
92
|
+
f"| {esc_cell(owner_cell(owner))} | {esc_cell(name_cell(owner_name))} "
|
|
93
|
+
f"| {esc_cell(creator_cell(creator))} | {esc_cell(name_cell(creator_name))} "
|
|
89
94
|
f"| {esc_cell(summary)} | {link} |\n"
|
|
90
95
|
)
|
|
91
96
|
|
|
@@ -94,8 +99,8 @@ def parse_history(text: str) -> tuple[list[Row], list[str]]:
|
|
|
94
99
|
"""解析十一列历史表 → (records, warnings)。坏行(列数不对 / 时间列不合法)跳过并警告。
|
|
95
100
|
|
|
96
101
|
返回的键:finished / created / title / category / tags / owner / owner_name /
|
|
97
|
-
creator / creator_name / summary / worklog / fmt / line。owner 与 creator
|
|
98
|
-
**剥掉 `@`**(存储值是裸 uuid
|
|
102
|
+
creator / creator_name / summary / worklog / fmt / line。owner 与 creator 两列
|
|
103
|
+
**剥掉 `@`**(存储值是裸 uuid);两个名字列剥掉展示用的 `@`(落盘快照,可能为空)。
|
|
99
104
|
"""
|
|
100
105
|
records: list[Row] = []
|
|
101
106
|
warnings: list[str] = []
|
|
@@ -135,9 +140,9 @@ def parse_history(text: str) -> tuple[list[Row], list[str]]:
|
|
|
135
140
|
t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()
|
|
136
141
|
),
|
|
137
142
|
"owner": owner.strip().removeprefix("@") or None,
|
|
138
|
-
"owner_name": owner_name.strip() or None,
|
|
143
|
+
"owner_name": owner_name.strip().removeprefix("@") or None,
|
|
139
144
|
"creator": creator.strip().removeprefix("@") or None,
|
|
140
|
-
"creator_name": creator_name.strip() or None,
|
|
145
|
+
"creator_name": creator_name.strip().removeprefix("@") or None,
|
|
141
146
|
"summary": summary,
|
|
142
147
|
"worklog": (m.group(1) if m else doc.strip()) or None,
|
|
143
148
|
"fmt": "v11",
|
|
@@ -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,11 +8,12 @@ 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 = (
|
|
@@ -31,6 +32,23 @@ CREATED_AT_RE = re.compile(
|
|
|
31
32
|
)
|
|
32
33
|
CREATOR_RE = re.compile(r"^\s*>\s*\*\*创建\*\*\s*[::].*?(@(.+?))")
|
|
33
34
|
|
|
35
|
+
# 头部「状态」行:`> **状态**:doing · @某人(uuid)· 更新 2026-10-27 08:28:22`。
|
|
36
|
+
# 它是**镜像**:分片 / 池里的 `status` / `owner` 才是权威,这行只是让「看一条详情」
|
|
37
|
+
# 不必再翻索引(2026-10-27 用户拍板)。老文档没有这行 ⇒ 合法,读取方回退查分片。
|
|
38
|
+
STATUS_MARK = "状态"
|
|
39
|
+
STATUS_LINE_RE = re.compile(r"^\s*>\s*\*\*" + STATUS_MARK + r"\*\*")
|
|
40
|
+
STATUS_RE = re.compile(r"^\s*>\s*\*\*" + STATUS_MARK + r"\*\*\s*[::]\s*([A-Za-z_]+)")
|
|
41
|
+
#: 状态取值(与 `core/labels.STATUSES` + 结案后的 `closed` 同口径,全链路英文)。
|
|
42
|
+
STATUS_VALUES = ("open", "doing", "closed")
|
|
43
|
+
_STATUS_UUID_RE = re.compile(
|
|
44
|
+
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}"
|
|
45
|
+
)
|
|
46
|
+
_STATUS_NAME_RE = re.compile(r"@([^((·]+)")
|
|
47
|
+
_STATUS_WHEN_RE = re.compile(
|
|
48
|
+
r"更新\s+(\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?)"
|
|
49
|
+
)
|
|
50
|
+
_TITLE_RE = re.compile(r"^#\s*(.+?)\s*[::]\s*(.*)$")
|
|
51
|
+
|
|
34
52
|
_HEAD_RE = re.compile(r"\A(.*?)(?=^## |\Z)", re.DOTALL | re.MULTILINE)
|
|
35
53
|
|
|
36
54
|
|
|
@@ -70,6 +88,16 @@ def format_problems(text: str) -> list[str]:
|
|
|
70
88
|
)
|
|
71
89
|
elif not any(CREATOR_RE.match(line) for line in head):
|
|
72
90
|
problems.append("头部「创建」行缺发起人(应为 `…(@某人)`)")
|
|
91
|
+
# 状态行:**有就必须合法**;缺了不报——老文档没有这行是合法的(读取方回退查分片)
|
|
92
|
+
for line in head:
|
|
93
|
+
if not STATUS_LINE_RE.match(line):
|
|
94
|
+
continue
|
|
95
|
+
m = STATUS_RE.match(line)
|
|
96
|
+
if m is None or m.group(1) not in STATUS_VALUES:
|
|
97
|
+
problems.append(
|
|
98
|
+
"头部「状态」行的值不合法(应为 " + " / ".join(STATUS_VALUES) + ")"
|
|
99
|
+
)
|
|
100
|
+
break
|
|
73
101
|
heads = section_heads(body)
|
|
74
102
|
missing = [s for s in REQUIRED_SECTIONS if s not in heads]
|
|
75
103
|
if missing:
|
|
@@ -131,15 +159,34 @@ def render_worklog(
|
|
|
131
159
|
creator: str,
|
|
132
160
|
category: str | None = None,
|
|
133
161
|
tags: Iterable[str] | None = None,
|
|
162
|
+
status: StatusLine | None = None,
|
|
134
163
|
template: str,
|
|
135
164
|
) -> str:
|
|
136
|
-
"""把模板套成一篇过程文档(`<id>` / `<title>` / `<created>` / `<creator>`
|
|
165
|
+
"""把模板套成一篇过程文档(`<id>` / `<title>` / `<created>` / `<creator>` / `<status>`)。
|
|
166
|
+
|
|
167
|
+
`<status>` 只占状态行的**内容**(`doing · @某人(uuid)· 更新 …`);模板里的
|
|
168
|
+
`> **状态**:` 前缀是字面量。不传 status ⇒ 整行删掉(不留空壳)。
|
|
169
|
+
"""
|
|
137
170
|
text = (
|
|
138
171
|
template.replace("<id>", tid)
|
|
139
172
|
.replace("<title>", title or "")
|
|
140
173
|
.replace("<created>", created or "")
|
|
141
174
|
.replace("<creator>", creator or "")
|
|
142
175
|
)
|
|
176
|
+
if status is None:
|
|
177
|
+
text = (
|
|
178
|
+
"\n".join(
|
|
179
|
+
ln
|
|
180
|
+
for ln in text.splitlines()
|
|
181
|
+
if not (STATUS_LINE_RE.match(ln) and "<status>" in ln)
|
|
182
|
+
)
|
|
183
|
+
+ "\n"
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
text = text.replace(
|
|
187
|
+
"<status>",
|
|
188
|
+
render_status_body(status.status, status.name, status.uuid, status.when),
|
|
189
|
+
)
|
|
143
190
|
meta = label_line(category, tags)
|
|
144
191
|
if not meta:
|
|
145
192
|
return text
|
|
@@ -155,13 +202,95 @@ def _insert_after_head(lines: list[str], line: str) -> list[str]:
|
|
|
155
202
|
return lines
|
|
156
203
|
|
|
157
204
|
|
|
205
|
+
class StatusLine(NamedTuple):
|
|
206
|
+
"""头部状态行的四样内容(`when` 传 UTC,与执行记录同口径)。"""
|
|
207
|
+
|
|
208
|
+
status: str
|
|
209
|
+
name: str | None = None
|
|
210
|
+
uuid: str | None = None
|
|
211
|
+
when: str | None = None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def render_status_body(
|
|
215
|
+
status: str,
|
|
216
|
+
name: str | None = None,
|
|
217
|
+
uuid: str | None = None,
|
|
218
|
+
when: str | None = None,
|
|
219
|
+
) -> str:
|
|
220
|
+
"""状态行的**内容**部分(`> **状态**:` 之后那段)。"""
|
|
221
|
+
parts = [status]
|
|
222
|
+
if name and uuid:
|
|
223
|
+
parts.append(f"@{name}({uuid})")
|
|
224
|
+
elif name:
|
|
225
|
+
parts.append(f"@{name}")
|
|
226
|
+
elif uuid:
|
|
227
|
+
parts.append(uuid)
|
|
228
|
+
if when:
|
|
229
|
+
parts.append(f"更新 {when}")
|
|
230
|
+
return " · ".join(parts)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def render_status_line(
|
|
234
|
+
status: str,
|
|
235
|
+
name: str | None = None,
|
|
236
|
+
uuid: str | None = None,
|
|
237
|
+
when: str | None = None,
|
|
238
|
+
) -> str:
|
|
239
|
+
return f"> **{STATUS_MARK}**:{render_status_body(status, name, uuid, when)}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def rewrite_status_line(text: str, status: StatusLine) -> str:
|
|
243
|
+
"""把头部状态行改成 `status`:有就就地替换,没有就插到「创建」行后。"""
|
|
244
|
+
line = render_status_line(status.status, status.name, status.uuid, status.when)
|
|
245
|
+
lines = (text or "").splitlines()
|
|
246
|
+
for i, ln in enumerate(lines):
|
|
247
|
+
if ln.startswith("## "):
|
|
248
|
+
break
|
|
249
|
+
if STATUS_LINE_RE.match(ln):
|
|
250
|
+
lines[i] = line
|
|
251
|
+
return "\n".join(lines) + "\n"
|
|
252
|
+
return "\n".join(_insert_after_head(lines, line)) + "\n"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def read_status(text: str) -> StatusLine | None:
|
|
256
|
+
"""头部状态行 → `StatusLine`;没有这行、或值不合法 ⇒ None(调用方回退查分片)。"""
|
|
257
|
+
for line in _head_lines((text or "").lstrip("\ufeff").splitlines()):
|
|
258
|
+
m = STATUS_RE.match(line)
|
|
259
|
+
if not (m and m.group(1) in STATUS_VALUES):
|
|
260
|
+
continue
|
|
261
|
+
u = _STATUS_UUID_RE.search(line)
|
|
262
|
+
name = _STATUS_NAME_RE.search(line)
|
|
263
|
+
when = _STATUS_WHEN_RE.search(line)
|
|
264
|
+
return StatusLine(
|
|
265
|
+
m.group(1),
|
|
266
|
+
(name.group(1).strip() or None) if name else None,
|
|
267
|
+
(u.group(0).lower() if u else None),
|
|
268
|
+
(when.group(1) if when else None),
|
|
269
|
+
)
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def head_title(text: str) -> tuple[str | None, str | None]:
|
|
274
|
+
"""第一行 `# <任务号>:<标题>` → (任务号, 标题);读不出给 (None, None)。"""
|
|
275
|
+
for line in (text or "").lstrip("\ufeff").splitlines():
|
|
276
|
+
if not line.startswith("# "):
|
|
277
|
+
continue
|
|
278
|
+
m = _TITLE_RE.match(line)
|
|
279
|
+
if m:
|
|
280
|
+
return m.group(1).strip() or None, m.group(2).strip() or None
|
|
281
|
+
return None, None
|
|
282
|
+
return None, None
|
|
283
|
+
|
|
284
|
+
|
|
158
285
|
def exec_line(when: str, who: str | None, kind: str, note: str) -> str:
|
|
159
|
-
"""一条执行记录:`-
|
|
286
|
+
"""一条执行记录:`- 时刻 · 类型 · @某人 · 说明`。
|
|
160
287
|
|
|
161
|
-
|
|
162
|
-
|
|
288
|
+
时刻与台账其它存盘时间同口径:**UTC**(调用方一律传 UTC,这里不再转本地)。
|
|
289
|
+
正文整篇由 `readWorklog` / `show` 原样读出,脚本无法逐行回转 ⇒ 存什么就显示什么;
|
|
290
|
+
转本地是消费方(终端 / 看板)的事。
|
|
291
|
+
类型取动作名(new / claim / set / release / log / close)。
|
|
163
292
|
"""
|
|
164
|
-
head = f"- {
|
|
293
|
+
head = f"- {when} · {kind}"
|
|
165
294
|
tail = f" · @{who} · {note}" if who else f" · {note}"
|
|
166
295
|
return head + tail
|
|
167
296
|
|
|
@@ -180,8 +309,8 @@ def has_frozen_line(text: str) -> bool:
|
|
|
180
309
|
|
|
181
310
|
|
|
182
311
|
def stamp_frozen(text: str, finished: str, who: str | None = "") -> str:
|
|
183
|
-
"""封卷:在头部打标记行(`finished` 传 UTC
|
|
184
|
-
mark = f"> {FROZEN_MARK}:{
|
|
312
|
+
"""封卷:在头部打标记行(`finished` 传 UTC,原样写盘——见 exec_line 的口径说明)。"""
|
|
313
|
+
mark = f"> {FROZEN_MARK}:{finished}"
|
|
185
314
|
if who:
|
|
186
315
|
mark += f"(@{who})"
|
|
187
316
|
if FROZEN_RE.search(text or ""):
|
|
@@ -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
|
|
|
@@ -18,6 +18,7 @@ from ..actions.result import (
|
|
|
18
18
|
ShowResult,
|
|
19
19
|
ValidationReport,
|
|
20
20
|
)
|
|
21
|
+
from ..core.identity import Identity
|
|
21
22
|
from ..core.time import fmt_local
|
|
22
23
|
|
|
23
24
|
|
|
@@ -51,6 +52,23 @@ def _json(data: Any) -> None:
|
|
|
51
52
|
sys.stdout.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
52
53
|
|
|
53
54
|
|
|
55
|
+
#: 任务 / 历史行里要转本机时区的时间字段(存盘一律 UTC,出门一律本机时区)。
|
|
56
|
+
TIME_FIELDS = ("created", "started", "updated", "finished")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def localize_times(data: dict) -> dict:
|
|
60
|
+
"""复制一份,把上面四个时间字段转成本机时区;其余字段原样。
|
|
61
|
+
|
|
62
|
+
**只动这四个**:任务号里嵌的时间戳、过程文档正文里的时间都不动(原文照旧)。
|
|
63
|
+
"""
|
|
64
|
+
out = dict(data)
|
|
65
|
+
for key in TIME_FIELDS:
|
|
66
|
+
value = out.get(key)
|
|
67
|
+
if value:
|
|
68
|
+
out[key] = fmt_local(value)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
54
72
|
def result(res: Result, *, as_json: bool) -> None:
|
|
55
73
|
"""写动作:一律人话(`--json` 不挂写动作,传进来也按人话打)。"""
|
|
56
74
|
del as_json
|
|
@@ -71,7 +89,7 @@ def result(res: Result, *, as_json: bool) -> None:
|
|
|
71
89
|
|
|
72
90
|
def list_view(res: ListResult, q: ListQuery | None, *, as_json: bool) -> None:
|
|
73
91
|
if as_json:
|
|
74
|
-
_json({tid: task for tid, task in res.rows.items()})
|
|
92
|
+
_json({tid: localize_times(task) for tid, task in res.rows.items()})
|
|
75
93
|
return
|
|
76
94
|
if not res.table:
|
|
77
95
|
_out("没有符合条件的在办任务。")
|
|
@@ -84,11 +102,33 @@ def list_view(res: ListResult, q: ListQuery | None, *, as_json: bool) -> None:
|
|
|
84
102
|
_err(line)
|
|
85
103
|
|
|
86
104
|
|
|
105
|
+
def whoami_view(identity: Identity | None, *, as_json: bool) -> None:
|
|
106
|
+
"""本机身份:未初始化给 `uuid` / `name` 双 null(不是错误,调用方据此引导 init)。"""
|
|
107
|
+
if as_json:
|
|
108
|
+
_json(
|
|
109
|
+
{
|
|
110
|
+
"uuid": identity.uuid if identity else None,
|
|
111
|
+
"name": identity.name if identity else None,
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
if identity is None:
|
|
116
|
+
_out("这台机器还没有初始化身份。")
|
|
117
|
+
_out(
|
|
118
|
+
" 处理:请用户在自己的终端运行 "
|
|
119
|
+
"`progress.py init --repo <工作区根>`,由 ta 亲自选择/新建并确认。"
|
|
120
|
+
)
|
|
121
|
+
return
|
|
122
|
+
_out(f"@{identity.label()} {identity.uuid}")
|
|
123
|
+
|
|
124
|
+
|
|
87
125
|
def show_view(res: ShowResult, *, as_json: bool) -> None:
|
|
88
126
|
if as_json:
|
|
89
127
|
_json(
|
|
90
128
|
{
|
|
91
|
-
"task": res.task,
|
|
129
|
+
"task": localize_times(res.task),
|
|
130
|
+
# 文档自述还是回退查在办:调用方据此知道字段来源(老文档 / 文档缺失)
|
|
131
|
+
"legacy": res.legacy,
|
|
92
132
|
"worklog": {
|
|
93
133
|
"path": res.worklog.path if res.worklog else None,
|
|
94
134
|
"text": res.worklog.text if res.worklog else None,
|
|
@@ -125,11 +165,15 @@ def history_view(res: HistoryResult, q: HistoryQuery | None, *, as_json: bool) -
|
|
|
125
165
|
_json({"count": res.count, "capped": res.capped})
|
|
126
166
|
return
|
|
127
167
|
if as_json:
|
|
168
|
+
filters = dict(res.filters)
|
|
169
|
+
for key in ("since", "until"):
|
|
170
|
+
if filters.get(key):
|
|
171
|
+
filters[key] = fmt_local(filters[key])
|
|
128
172
|
_json(
|
|
129
173
|
{
|
|
130
174
|
"count": res.count,
|
|
131
|
-
"filters":
|
|
132
|
-
"records":
|
|
175
|
+
"filters": filters,
|
|
176
|
+
"records": [localize_times(row) for row in res.records],
|
|
133
177
|
"warnings": list(res.warnings),
|
|
134
178
|
"capped": res.capped,
|
|
135
179
|
}
|
|
@@ -131,9 +131,10 @@ class WorklogStore:
|
|
|
131
131
|
creator: str,
|
|
132
132
|
category: str | None = None,
|
|
133
133
|
tags: tuple[str, ...] = (),
|
|
134
|
+
status: worklog_md.StatusLine | None = None,
|
|
134
135
|
first_exec: str | None = None,
|
|
135
136
|
) -> Path:
|
|
136
|
-
"""
|
|
137
|
+
"""一次写完整篇(含状态行与首条执行记录)。返回绝对路径。"""
|
|
137
138
|
rel = worklog_rel(tid, created)
|
|
138
139
|
path = self.repo / to_repo_rel(rel)
|
|
139
140
|
ensure_dir(path.parent)
|
|
@@ -144,6 +145,7 @@ class WorklogStore:
|
|
|
144
145
|
creator=creator,
|
|
145
146
|
category=category,
|
|
146
147
|
tags=tags,
|
|
148
|
+
status=status,
|
|
147
149
|
template=self.template(),
|
|
148
150
|
)
|
|
149
151
|
if first_exec:
|
|
@@ -155,14 +157,23 @@ class WorklogStore:
|
|
|
155
157
|
"""写回「锚点编辑后的新全文」;不许用它替换前五节正文。"""
|
|
156
158
|
write_text_atomic(path, text)
|
|
157
159
|
|
|
158
|
-
def append_exec(
|
|
159
|
-
|
|
160
|
+
def append_exec(
|
|
161
|
+
self, path: Path, line: str, *, status: worklog_md.StatusLine | None = None
|
|
162
|
+
) -> bool:
|
|
163
|
+
"""追加一条执行记录,并在**同一次写盘**里顺带刷新头部状态行(`status` 给了才刷)。
|
|
164
|
+
|
|
165
|
+
写不进返回 False,**不抛**(调用方自己决定报不报)。状态行与执行记录必须一起落地:
|
|
166
|
+
分两写就会留下「记录写了、状态没刷」这种不报错的不一致。
|
|
167
|
+
"""
|
|
160
168
|
try:
|
|
161
169
|
text = path.read_text(encoding="utf-8-sig")
|
|
162
170
|
except (OSError, UnicodeDecodeError):
|
|
163
171
|
return False
|
|
164
172
|
try:
|
|
165
|
-
|
|
173
|
+
new = worklog_md.append_exec_log(text, line)
|
|
174
|
+
if status is not None:
|
|
175
|
+
new = worklog_md.rewrite_status_line(new, status)
|
|
176
|
+
write_text_atomic(path, new)
|
|
166
177
|
except Exception: # noqa: BLE001 —— 写不进是这条记录的失败,不该掀翻整条命令
|
|
167
178
|
return False
|
|
168
179
|
return True
|