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.
Files changed (36) 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 +34 -13
  16. package/skill/assets/worklog.md +1 -0
  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 +31 -4
  30. package/skill/scripts/ledgerkit/actions/validate.py +6 -49
  31. package/skill/scripts/ledgerkit/core/history_md.py +18 -13
  32. package/skill/scripts/ledgerkit/core/time.py +2 -1
  33. package/skill/scripts/ledgerkit/core/worklog_md.py +137 -8
  34. package/skill/scripts/ledgerkit/shell/cli.py +24 -2
  35. package/skill/scripts/ledgerkit/shell/render.py +48 -4
  36. 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(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,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, 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
+ },
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=logs.read(path), error=None)
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 列的展示值:人名前加 @(`@` 只是展示约定,匹配一律用裸 uuid)。"""
36
- return f"@{owner}" if owner else ""
35
+ """owner 列的值:裸 uuid,**不加 `@`**(`@` 只加在名字列;匹配一律用裸 uuid)。"""
36
+ return owner or ""
37
37
 
38
38
 
39
39
  def creator_cell(creator: str | None) -> str:
40
- """creator 列展示值(与 owner_cell 同一 `@` 约定)。"""
41
- return f"@{creator}" if creator else ""
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**(`@` 由 owner_cell / creator_cell 加),外面别先加。
78
- `owner_name` / `creator_name` 是**落盘的显示名快照**(结案那一刻的名字,之后改名不改旧行),
79
- 原值写入、**不加 `@`** —— `@` 只属于 uuid 那两列。
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 or '')} "
88
- f"| {esc_cell(creator_cell(creator))} | {esc_cell(creator_name or '')} "
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
- 所有**人话显示**都过 `fmt_local`;`--json` 原样吐 UTC。
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 fmt_local, normalize_time
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
- 时刻是**给人读的日志行**,所以在这里转成查看者本地时间(调用方一律传 UTC,
162
- 别在外面先转一遍)。类型取动作名(new / claim / set / release / log / close)。
288
+ 时刻与台账其它存盘时间同口径:**UTC**(调用方一律传 UTC,这里不再转本地)。
289
+ 正文整篇由 `readWorklog` / `show` 原样读出,脚本无法逐行回转 ⇒ 存什么就显示什么;
290
+ 转本地是消费方(终端 / 看板)的事。
291
+ 类型取动作名(new / claim / set / release / log / close)。
163
292
  """
164
- head = f"- {fmt_local(when)} · {kind}"
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}:{fmt_local(finished)}"
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", 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
 
@@ -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": res.filters,
132
- "records": list(res.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(self, path: Path, line: str) -> bool:
159
- """追加一条执行记录;写不进返回 False,**不抛**(调用方自己决定报不报)。"""
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
- write_text_atomic(path, worklog_md.append_exec_log(text, line))
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