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.
Files changed (37) hide show
  1. package/dist/client.js +3618 -116
  2. package/dist/client.js.map +1 -1
  3. package/dist/host/repositories/ledger.repository.d.ts +8 -2
  4. package/dist/host/repositories/ledger.repository.js +4 -1
  5. package/dist/host/repositories/records.d.ts +10 -2
  6. package/dist/host/repositories/records.js +11 -2
  7. package/dist/host/routes.js +1 -0
  8. package/dist/host/services/dates.d.ts +7 -2
  9. package/dist/host/services/dates.js +29 -6
  10. package/dist/host/services/history.service.js +1 -1
  11. package/dist/host/services/trend.service.d.ts +4 -1
  12. package/dist/host/services/trend.service.js +9 -1
  13. package/package.json +4 -1
  14. package/skill/README.md +2 -2
  15. package/skill/SKILL.md +43 -13
  16. package/skill/assets/worklog.md +5 -2
  17. package/skill/package.json +1 -1
  18. package/skill/references/state-model.md +6 -1
  19. package/skill/references/troubleshooting.md +5 -5
  20. package/skill/scripts/ledgerkit/actions/claim.py +7 -2
  21. package/skill/scripts/ledgerkit/actions/close_task.py +8 -0
  22. package/skill/scripts/ledgerkit/actions/handoff.py +5 -2
  23. package/skill/scripts/ledgerkit/actions/list_tasks.py +4 -1
  24. package/skill/scripts/ledgerkit/actions/log_progress.py +6 -2
  25. package/skill/scripts/ledgerkit/actions/new_task.py +7 -1
  26. package/skill/scripts/ledgerkit/actions/query_history.py +3 -1
  27. package/skill/scripts/ledgerkit/actions/release.py +6 -2
  28. package/skill/scripts/ledgerkit/actions/result.py +2 -0
  29. package/skill/scripts/ledgerkit/actions/show_task.py +32 -4
  30. package/skill/scripts/ledgerkit/actions/validate.py +6 -49
  31. package/skill/scripts/ledgerkit/core/identity.py +3 -3
  32. package/skill/scripts/ledgerkit/core/labels.py +16 -4
  33. package/skill/scripts/ledgerkit/core/time.py +2 -1
  34. package/skill/scripts/ledgerkit/core/worklog_md.py +216 -31
  35. package/skill/scripts/ledgerkit/shell/cli.py +24 -2
  36. package/skill/scripts/ledgerkit/shell/render.py +48 -4
  37. 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(path, exec_line(now(), me.stamp(), "release", "释放回公共池"))
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, task.get("worklog"))
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=logs.read(path), error=None)
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()` 一样**不带 `@`**(`@` 由各落点自己加:模板创建行是 `(@<creator>)`、
31
- `exec_line` 拼 ` · @{who} · `、`stamp_frozen` 拼 `(@{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 "> " + " | ".join(segs) if segs else ""
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
- 所有**人话显示**都过 `fmt_local`;`--json` 原样吐 UTC。
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 fmt_local, normalize_time
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
- FROZEN_RE = re.compile(r"^>\s*" + FROZEN_MARK, re.MULTILINE)
24
- CREATED_RE = re.compile(r"^\s*>\s*\*\*创建\*\*")
25
- # 头部「创建」行:`> **创建**:2026-10-06 00:00(@某人 · uuid)` —— 创建时间与发起人两个来源。
26
- # 括号里落 `Identity.stamp()`(`@名字 · uuid`):名字在前给人读,uuid 在后给机器对。
27
- # 老文档只有名字(没有 uuid)时,下面两个函数各自返回它能读到的那一段,不报错。
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"^\s*>\s*\*\*创建\*\*\s*[::]\s*"
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"^\s*>\s*\*\*创建\*\*\s*[::].*?(@(.+?))")
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
- "头部缺「创建」时间行(应为 `> **创建**:YYYY-MM-DD HH:MM(@某人)`)"
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(_insert_after_head(text.splitlines(), meta)) + "\n"
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
- def _insert_after_head(lines: list[str], line: str) -> list[str]:
150
- """把一行插到「创建」行之后(头部引用块内);找不到创建行就插标题后。"""
151
- at = next((i for i, ln in enumerate(lines) if CREATED_RE.match(ln)), None)
152
- if at is None:
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
- lines.insert(at + 1, line)
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
- 时刻是**给人读的日志行**,所以在这里转成查看者本地时间(调用方一律传 UTC,
162
- 别在外面先转一遍)。类型取动作名(new / claim / set / release / log / close)。
332
+ 时刻与台账其它存盘时间同口径:**UTC**(调用方一律传 UTC,这里不再转本地)。
333
+ 正文整篇由 `readWorklog` / `show` 原样读出,脚本无法逐行回转 ⇒ 存什么就显示什么;
334
+ 转本地是消费方(终端 / 看板)的事。
335
+ 类型取动作名(new / claim / set / release / log / close)。
163
336
  """
164
- head = f"- {fmt_local(when)} · {kind}"
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
- """封卷:在头部打标记行(`finished` 传 UTC,这里转本地给人看)。"""
184
- mark = f"> {FROZEN_MARK}:{fmt_local(finished)}"
356
+ """封卷:在头部打「封卷」行(`finished` 传 UTC,原样写盘——见 exec_line 的口径说明)。"""
357
+ mark = f"- **{FROZEN_MARK}**:{finished}"
185
358
  if who:
186
- mark += f"(@{who})"
359
+ mark += f" · @{who}"
187
360
  if FROZEN_RE.search(text or ""):
188
361
  return text
189
- return "\n".join(_insert_after_head(text.splitlines(), mark)) + "\n"
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 "**分类**" in ln or "**标签**" in ln:
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(_insert_after_head(lines, line)) + "\n"
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
- """头部「创建」行括号里的发起人**名字**(不带 @、不带 uuid);读不到返回 None。"""
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
- """头部「创建」行括号里的发起人 **uuid**;老文档没落 uuid 时返回 None。
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", help="台账体检(默认浅度;--deep 读全部历史与过程文档)"
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