task-ledger 1.3.5 → 1.3.6

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/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: task-ledger
3
3
  description: 本地任务台账,跨会话记录在办任务的归属、进度与起止。当用户要登记一件新的事(新建任务 / 建个坑 / 这活我领了)、记录已有任务的进展(接着做 / 记一条进展 / 这轮先记下)、查看任务状态或历史(现在该做什么 / 这活谁在做 / 上周做过什么)、给任务结案或退回(做完了 / 验收通过 / 不做了 / 退回待认领)、整理标签或检查台账一致性时加载;只是改代码、讨论方案时不要用。
4
- version: 1.3.5
4
+ version: 1.3.6
5
5
  allowed-tools: Read,Edit,Bash,Grep
6
6
  ---
7
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-ledger",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
4
4
  "description": "Task-ledger skill for coding agents: per-workspace progress tracking (ledger/pool.json + owners/<hash>.json shards + owners.json index, history, worklog) driven by a CLI script. Standalone install for CodeBuddy and other agents; also bundled inside the dsh-task-ledger DSH plugin.",
5
5
  "keywords": [
6
6
  "skill",
@@ -25,8 +25,8 @@ close → 历史层 + 封卷 + 移出自己名下
25
25
  - 注意:任务号前缀与 worklog 月份目录按 **UTC** 推 —— UTC+8 下裸日期 `--created 2026-01-05` 存成 `2026-01-04 16:00:00`,任务号前缀变成 `20260104-1600`;临午夜补录要连时间一起传。
26
26
  - **筛选语义**:`list` 的 `--since` / `--until` 按**创建时间**;`history` 的按**结案时间**(历史文件按结案月归档,按创建时间比较会漏「上月建、本月结」的记录)。只给日期时起 = 当天 `00:00:00`、止 = 当天 `23:59:59`(含当天)。
27
27
  - **时区**:存盘为 UTC 的只有 —— 任务号、worklog 目录 / 文件名、台账 JSON 的 `created`/`started`/`updated`/`finished`、history 行、worklog 头部「创建」行。**执行记录行、封卷行、`list` / `history` 的人话输出都是本机本地时间**(给人看的日志行);`--json` 原样给 UTC,由消费方自己转。存量老数据(2026-10-22 前)写的是写入者本地时间、无标记,无法与 UTC 区分。
28
- - **history 文件 9 列**:结案时间 / 创建时间 / 工作包(= `new --title` 的任务标题)/ 分类 / 标签 / owner / creator / 摘要 / 过程文档;排序与 `--since` / `--until` 按**结案时间**。(`history` 命令的人话输出只展示其中几列:结案时间、负责人、标题。)
29
- - **owner / creator 两列存的是 uuid**(与任务里一致,身份只认 uuid);人话输出与 `--json` 里的 `owner_name` / `creator_name` 是读的时候按 `owners.json` 名册现查补上的显示名,不落盘。名册里查不到就退回裸 uuid。
28
+ - **history 文件 11 列**:结案时间 / 创建时间 / 工作包(= `new --title` 的任务标题)/ 分类 / 标签 / owner / owner_name / creator / creator_name / 摘要 / 过程文档;排序与 `--since` / `--until` 按**结案时间**。(`history` 命令的人话输出只展示其中几列:结案时间、负责人、标题。)
29
+ - **owner / creator 两列存的是 uuid**(与任务里一致,身份只认 uuid);紧随其后的 `owner_name` / `creator_name` 两列**落盘**结案那一刻的显示名 —— 改名不回写旧行,看历史看到的就是当时那个名字。老行(升级前落的)没有名字时按名册兜底补一次展示值,**不回写文件**。
30
30
 
31
31
  ## 任务号
32
32
 
@@ -42,7 +42,7 @@ close → 历史层 + 封卷 + 移出自己名下
42
42
 
43
43
  ## 历史层
44
44
 
45
- - 按**结案时间**分月存放,9 列(creator 在 owner 之后);`close` 从任务直写分类 / 标签终值。
45
+ - 按**结案时间**分月存放,11 列(列序:owner 之后是 `owner_name`,再是 creator、`creator_name`);`close` 从任务直写分类 / 标签终值。
46
46
  - 存量平铺历史文件只读兼容、永不重写;`history` 连同它在内、按下面的月份区间合并。
47
47
  - **查询区间**:`history` 先由时间参数算出要读的月份文件,再逐个「存在才读、缺月跳过」,不扫磁盘。无时间参数默认最近 3 个自然月;`--months N`(近 N 月)与 `--since/--until`(结案时间闭区间)二选一;一次最多读 13 个历史文件(按月分文件,不区分连续/分散,只数总数),超出直接报错、不读任何文件。末列文档链接只按**文件名**消费(取 basename),链接里的 `../worklog/<年月>/` 前缀只为阅读器跳转服务,解析时忽略。
48
- - `history --json` 直接按文件现状解析,旧行没有的列不编造;`close` 追加历史行时才会把旧行升级补上 creator。
48
+ - `history --json` 直接按文件现状解析,旧行没有的列不编造(名字列为空就按名册补展示值,不回写);存量九列的历史文件按 `docs/history-creator-upgrade.md` 一次性升级,脚本不认旧列数。
@@ -72,7 +72,7 @@ def claim(repo: Path, me: Identity, tid: str) -> Result:
72
72
  logs = WorklogStore(repo)
73
73
  path = logs.find(tid, moved.get("worklog"))
74
74
  if path is not None:
75
- logs.append_exec(path, exec_line(now(), me.label(), "claim", "认领 → doing"))
75
+ logs.append_exec(path, exec_line(now(), me.stamp(), "claim", "认领 → doing"))
76
76
  note = None
77
77
  return Result(
78
78
  changed=("owner", "started", "updated"),
@@ -95,9 +95,9 @@ def close_task(
95
95
  if doc_path is not None:
96
96
  text = logs.read(doc_path)
97
97
  if not has_frozen_line(text):
98
- frozen = stamp_frozen(text, finished, me.label())
98
+ frozen = stamp_frozen(text, finished, me.stamp())
99
99
  frozen = append_exec_log(
100
- frozen, exec_line(finished, me.label(), "close", "结案封卷")
100
+ frozen, exec_line(finished, me.stamp(), "close", "结案封卷")
101
101
  )
102
102
  logs.write(doc_path, frozen)
103
103
  notes.append("补齐上次未完成的封卷。")
@@ -138,10 +138,14 @@ def close_task(
138
138
  "title": task.get("title") or "",
139
139
  "category": final_category,
140
140
  "tags": final_tags,
141
- # 历史两列只存 **uuid**(身份唯一键);显示名在读的时候按名册换,
142
- # 不落盘 —— 否则 `history --owner <uuid>` / `--mine` 永远比不到东西。
141
+ # 历史里 uuid 与名字**都落盘**:uuid 是判定键(`@` + 裸 uuid,
142
+ # `--owner` / `--mine` 比它),名字是**结案那一刻的快照** —— 之后改名
143
+ # 不回写旧行,看历史看到的就是当时那个名字。
144
+ # 取值优先用任务行里认领 / 建任务时写下的那份;老任务没有就留空,不编造。
143
145
  "owner": me.uuid,
146
+ "owner_name": task.get("owner_name") or me.name,
144
147
  "creator": task.get("creator") or me.uuid,
148
+ "creator_name": task.get("creator_name"),
145
149
  "summary": text_summary,
146
150
  "worklog": task.get("worklog") if doc_path else None,
147
151
  },
@@ -152,9 +156,9 @@ def close_task(
152
156
  if doc_path is not None:
153
157
  text = logs.read(doc_path)
154
158
  if not has_frozen_line(text):
155
- frozen = stamp_frozen(text, finished, me.label())
159
+ frozen = stamp_frozen(text, finished, me.stamp())
156
160
  frozen = append_exec_log(
157
- frozen, exec_line(finished, me.label(), "close", "结案封卷")
161
+ frozen, exec_line(finished, me.stamp(), "close", "结案封卷")
158
162
  )
159
163
  logs.write(doc_path, frozen)
160
164
 
@@ -39,7 +39,9 @@ def handoff(repo: Path, me: Identity, tid: str, *, to: str) -> Result:
39
39
  " 处理:跑 validate 看台账是不是脏了。"
40
40
  )
41
41
  to_name = str(record.get("name") or "") or None # 只用于显示
42
- who = to_name or to_key
42
+ who = to_name or to_key # 给人看:命令输出、提示语
43
+ # 落盘用「名字 · uuid」:接收人拿什么键去分片里找,比执行者更需要 uuid
44
+ who_stamp = Identity(uuid=to_key, name=to_name or "").stamp()
43
45
  to_store = TaskStore(repo, shard_rel(to_key))
44
46
  moved = dict(task)
45
47
  moved["owner"] = to_key
@@ -58,7 +60,8 @@ def handoff(repo: Path, me: Identity, tid: str, *, to: str) -> Result:
58
60
  path = logs.find(tid, moved.get("worklog"))
59
61
  if path is not None:
60
62
  logs.append_exec(
61
- path, exec_line(now(), me.label(), "set", f"交接给 {who}(补齐)")
63
+ path,
64
+ exec_line(now(), me.stamp(), "set", f"交接给 @{who_stamp}(补齐)"),
62
65
  )
63
66
  return Result(
64
67
  already_done=True,
@@ -76,7 +79,9 @@ def handoff(repo: Path, me: Identity, tid: str, *, to: str) -> Result:
76
79
  if path is None:
77
80
  note = f"任务 {tid} 已交给 {who},但过程文档没找到,跳过执行记录。"
78
81
  else:
79
- logs.append_exec(path, exec_line(now(), me.label(), "set", f"交接给 {who}"))
82
+ logs.append_exec(
83
+ path, exec_line(now(), me.stamp(), "set", f"交接给 @{who_stamp}")
84
+ )
80
85
  return Result(
81
86
  changed=("owner", "owner_name", "updated"),
82
87
  where=rel(repo, to_store.path),
@@ -46,7 +46,7 @@ def log_progress(repo: Path, me: Identity, tid: str, note: str) -> Result:
46
46
  "提示:这条任务没有过程文档,已跳过执行记录(分片的最后更新已刷)。",
47
47
  ),
48
48
  )
49
- ok = logs.append_exec(path, exec_line(now(), me.label(), "log", text))
49
+ ok = logs.append_exec(path, exec_line(now(), me.stamp(), "log", text))
50
50
  if not ok:
51
51
  return Result(
52
52
  changed=("updated",),
@@ -99,11 +99,11 @@ def new_task(
99
99
  tid=tid,
100
100
  title=clean_title,
101
101
  created=created,
102
- creator=me.label(),
102
+ creator=me.stamp(),
103
103
  category=category,
104
104
  tags=tag_list,
105
105
  first_exec=exec_line(
106
- created, me.label(), "new", "创建任务并认领" if mine else "创建任务"
106
+ created, me.stamp(), "new", "创建任务并认领" if mine else "创建任务"
107
107
  ),
108
108
  )
109
109
  target.put(tid, task)
@@ -143,16 +143,23 @@ def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
143
143
  key = normalize(q.owner)
144
144
  records = [r for r in records if normalize(r.get("owner") or "") == key]
145
145
  if q.owner_name:
146
- # 名字只在名册里模糊匹配出 uuid 集合,命中判定仍比 uuid
147
- keys_of_name = index.keys_of_name(q.owner_name)
146
+ # 名字已经落在历史行里 ⇒ 直接比它(子串、不分大小写)。落的是**当时**那个名字,
147
+ # 改名后的旧行仍留旧名 ⇒ 按新名筛不到旧行,这是快照语义,不是漏。
148
+ # 老行(升级前落的)没有名字列,退回按 uuid 片段比,不至于一条都筛不出来。
149
+ needle = q.owner_name.strip().lower()
148
150
  records = [
149
- r for r in records if normalize(r.get("owner") or "") in keys_of_name
151
+ r
152
+ for r in records
153
+ if needle in str(r.get("owner_name") or "").lower()
154
+ or needle in normalize(r.get("owner") or "")
150
155
  ]
151
156
 
152
- # 展示名(`owner_name` / `creator_name`)只在这层补上,不落盘:
153
- # 历史文件里 owner / creator 两列存的一律是 uuid,读出来给人看时才换成名字。
157
+ # 名字**已经落盘**(`owner_name` / `creator_name` 两列)。这里只给没名字的**老行**
158
+ # 按名册兜底补一次 —— 补的是读时展示值,不回写文件。
154
159
  for row in records:
155
160
  for field, target in (("owner", "owner_name"), ("creator", "creator_name")):
161
+ if row.get(target):
162
+ continue
156
163
  key = normalize(row.get(field) or "")
157
164
  name = str((roster.get(key) or {}).get("name") or "")
158
165
  if name:
@@ -63,7 +63,7 @@ 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.label(), "release", "释放回公共池"))
66
+ logs.append_exec(path, exec_line(now(), me.stamp(), "release", "释放回公共池"))
67
67
  return Result(
68
68
  changed=("owner", "owner_name", "status", "updated"),
69
69
  where=rel(repo, pool.path),
@@ -1,6 +1,7 @@
1
- """历史表格的文本操作:拼九列一行、解析九列一行。
1
+ """历史表格的文本操作:拼十一列一行、解析十一列一行。
2
2
 
3
- **只认九列这一种格式**(开发期没发布,不做任何旧列数 / 旧列语义的迁移)。
3
+ **只认十一列这一种格式**:存量九列的文件按 `docs/history-creator-upgrade.md`
4
+ 那份手册一次性升级,脚本不做运行时迁移。
4
5
  """
5
6
 
6
7
  from __future__ import annotations
@@ -13,8 +14,11 @@ from .paths import to_history_rel
13
14
  from .time import TIME_RE, time_key
14
15
 
15
16
  HISTORY_TITLE = "# 进度历史(已结案)"
16
- HISTORY_HEADER = "| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 | owner | creator | 完成了什么 | 过程文档 |"
17
- HISTORY_SEP = "|---|---|---|---|---|---|---|---|---|"
17
+ HISTORY_HEADER = (
18
+ "| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 "
19
+ "| owner | owner_name | creator | creator_name | 完成了什么 | 过程文档 |"
20
+ )
21
+ HISTORY_SEP = "|---|---|---|---|---|---|---|---|---|---|---|"
18
22
 
19
23
  _LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]*)\)")
20
24
  _TAG_SPLIT_RE = re.compile(r"[,,、]")
@@ -65,10 +69,14 @@ def history_line(
65
69
  category: str | None = None,
66
70
  tags: Iterable[str] | None = None,
67
71
  creator: str | None = None,
72
+ owner_name: str | None = None,
73
+ creator_name: str | None = None,
68
74
  ) -> str:
69
- """九列的一行。`worklog` 收**相对 `ledger/`** 的路径,这里负责换成相对 `history/` 的链接。
75
+ """十一列的一行。`worklog` 收**相对 `ledger/`** 的路径,这里负责换成相对 `history/` 的链接。
70
76
 
71
77
  owner / creator 传**裸 uuid**(`@` 由 owner_cell / creator_cell 加),外面别先加。
78
+ `owner_name` / `creator_name` 是**落盘的显示名快照**(结案那一刻的名字,之后改名不改旧行),
79
+ 原值写入、**不加 `@`** —— `@` 只属于 uuid 那两列。
72
80
  """
73
81
  log_rel = to_history_rel(worklog) if worklog else ""
74
82
  tag_cell = esc_cell("、".join(tags) if tags else "")
@@ -76,16 +84,18 @@ def history_line(
76
84
  return (
77
85
  f"| {finished} | {created or ''} | {esc_cell(title)} "
78
86
  f"| {esc_cell(category or '')} | {tag_cell} "
79
- f"| {esc_cell(owner_cell(owner))} | {esc_cell(creator_cell(creator))} "
87
+ f"| {esc_cell(owner_cell(owner))} | {esc_cell(owner_name or '')} "
88
+ f"| {esc_cell(creator_cell(creator))} | {esc_cell(creator_name or '')} "
80
89
  f"| {esc_cell(summary)} | {link} |\n"
81
90
  )
82
91
 
83
92
 
84
93
  def parse_history(text: str) -> tuple[list[Row], list[str]]:
85
- """解析九列历史表 → (records, warnings)。坏行(列数不对 / 时间列不合法)跳过并警告。
94
+ """解析十一列历史表 → (records, warnings)。坏行(列数不对 / 时间列不合法)跳过并警告。
86
95
 
87
- 返回的键:finished / created / title / category / tags / owner / creator / summary /
88
- worklog / fmt / line。owner 与 creator 一律**剥掉 `@`**(存储值是裸 uuid)。
96
+ 返回的键:finished / created / title / category / tags / owner / owner_name /
97
+ creator / creator_name / summary / worklog / fmt / line。owner 与 creator 一律
98
+ **剥掉 `@`**(存储值是裸 uuid);两个名字列是**原值**(落盘快照,可能为空)。
89
99
  """
90
100
  records: list[Row] = []
91
101
  warnings: list[str] = []
@@ -95,12 +105,22 @@ def parse_history(text: str) -> tuple[list[Row], list[str]]:
95
105
  cells = split_row(raw)
96
106
  if not cells or is_sep_row(cells) or _looks_like_header(cells):
97
107
  continue
98
- if len(cells) != 9:
99
- warnings.append(f"第 {lineno} 行不是九列(历史表只认九列),已跳过")
108
+ if len(cells) != 11:
109
+ warnings.append(f"第 {lineno} 行不是十一列(历史表只认十一列),已跳过")
100
110
  continue
101
- finished, created, title, category, tag_cell, owner, creator, summary, doc = (
102
- cells
103
- )
111
+ (
112
+ finished,
113
+ created,
114
+ title,
115
+ category,
116
+ tag_cell,
117
+ owner,
118
+ owner_name,
119
+ creator,
120
+ creator_name,
121
+ summary,
122
+ doc,
123
+ ) = cells
104
124
  if not TIME_RE.fullmatch(finished):
105
125
  warnings.append(f"第 {lineno} 行时间列 {finished!r} 不合法,已跳过")
106
126
  continue
@@ -115,10 +135,12 @@ def parse_history(text: str) -> tuple[list[Row], list[str]]:
115
135
  t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()
116
136
  ),
117
137
  "owner": owner.strip().removeprefix("@") or None,
138
+ "owner_name": owner_name.strip() or None,
118
139
  "creator": creator.strip().removeprefix("@") or None,
140
+ "creator_name": creator_name.strip() or None,
119
141
  "summary": summary,
120
142
  "worklog": (m.group(1) if m else doc.strip()) or None,
121
- "fmt": "v9",
143
+ "fmt": "v11",
122
144
  "line": lineno,
123
145
  }
124
146
  )
@@ -21,9 +21,23 @@ class Identity:
21
21
  name: str = ""
22
22
 
23
23
  def label(self) -> str:
24
- """显示名:没名字就用 uuid 前 8 位。"""
24
+ """显示名:没名字就用 uuid 前 8 位。只给人看(报错文案、界面)。"""
25
25
  return self.name or self.uuid[:8]
26
26
 
27
+ def stamp(self) -> str:
28
+ """**落盘**用的身份串:`名字 · uuid` —— 名字在前给人读,uuid 在后给机器对。
29
+
30
+ 与 `label()` 一样**不带 `@`**(`@` 由各落点自己加:模板创建行是 `(@<creator>)`、
31
+ `exec_line` 拼 ` · @{who} · `、`stamp_frozen` 拼 `(@{who})`)。
32
+
33
+ 过程文档(创建行 / 封卷行 / 执行记录)是这个串唯一的落点:文件是最终产物,
34
+ 创建人和封卷人可能不是同一个人、也可能中途改过名,所以**两个都要留**,不靠
35
+ 历史表回链。没名字就只写 uuid。
36
+ """
37
+ if not self.name:
38
+ return self.uuid
39
+ return f"{self.name} · {self.uuid}"
40
+
27
41
 
28
42
  def normalize(who: str) -> str:
29
43
  """归一化:去首尾空白 + 转小写。全仓只此一处。"""
@@ -10,6 +10,7 @@ from collections.abc import Iterable
10
10
  from itertools import takewhile
11
11
 
12
12
  from ..errors import Fail
13
+ from .identity import UUID_RE
13
14
  from .labels import label_line, parse_label_line
14
15
  from .time import fmt_local, normalize_time
15
16
 
@@ -21,7 +22,9 @@ EXEC_HINT = (
21
22
  FROZEN_MARK = "✅ 完成封卷"
22
23
  FROZEN_RE = re.compile(r"^>\s*" + FROZEN_MARK, re.MULTILINE)
23
24
  CREATED_RE = re.compile(r"^\s*>\s*\*\*创建\*\*")
24
- # 头部「创建」行:`> **创建**:2026-10-06 00:00(@某人)` —— 创建时间与发起人两个来源。
25
+ # 头部「创建」行:`> **创建**:2026-10-06 00:00(@某人 · uuid)` —— 创建时间与发起人两个来源。
26
+ # 括号里落 `Identity.stamp()`(`@名字 · uuid`):名字在前给人读,uuid 在后给机器对。
27
+ # 老文档只有名字(没有 uuid)时,下面两个函数各自返回它能读到的那一段,不报错。
25
28
  CREATED_AT_RE = re.compile(
26
29
  r"^\s*>\s*\*\*创建\*\*\s*[::]\s*"
27
30
  r"(\d{4}-\d{2}-\d{2}(?: +\d{1,2}:\d{1,2}(?::\d{1,2})?)?)"
@@ -224,9 +227,26 @@ def head_created(text: str) -> str | None:
224
227
 
225
228
 
226
229
  def head_creator(text: str) -> str | None:
227
- """头部「创建」行括号里的发起人(裸标识,不带 @);读不到返回 None。"""
230
+ """头部「创建」行括号里的发起人**名字**(不带 @、不带 uuid);读不到返回 None。"""
228
231
  for line in text.splitlines():
229
232
  m = CREATOR_RE.match(line)
230
233
  if m and m.group(1).strip():
231
- return m.group(1).strip()
234
+ return m.group(1).split(" · ")[0].strip()
235
+ return None
236
+
237
+
238
+ def head_creator_uuid(text: str) -> str | None:
239
+ """头部「创建」行括号里的发起人 **uuid**;老文档没落 uuid 时返回 None。
240
+
241
+ `UUID_RE` 带 `^$` 锚点,只能整串匹配 —— 所以先按 ` · ` 切出尾段再判,不能 search。
242
+ """
243
+ for line in text.splitlines():
244
+ m = CREATOR_RE.match(line)
245
+ if not m:
246
+ continue
247
+ parts = m.group(1).split(" · ")
248
+ if len(parts) > 1:
249
+ uuid = parts[-1].strip()
250
+ if UUID_RE.fullmatch(uuid):
251
+ return uuid
232
252
  return None
@@ -99,9 +99,11 @@ class HistoryStore:
99
99
  row.get("owner"),
100
100
  row.get("summary") or "",
101
101
  row.get("worklog"),
102
- row.get("category"),
103
- row.get("tags"),
104
- row.get("creator"),
102
+ category=row.get("category"),
103
+ tags=row.get("tags"),
104
+ creator=row.get("creator"),
105
+ owner_name=row.get("owner_name"),
106
+ creator_name=row.get("creator_name"),
105
107
  )
106
108
  return {"text": _append_line(text, line), "duplicate": duplicate}
107
109
 
@@ -117,7 +119,7 @@ def _name_of(worklog: str | None) -> str | None:
117
119
 
118
120
 
119
121
  def _fresh_table() -> str:
120
- """当月第一次结案:按九列表头起表(表头的真源只有 `history_md` 一处)。"""
122
+ """当月第一次结案:按十一列表头起表(表头的真源只有 `history_md` 一处)。"""
121
123
  return (
122
124
  f"{history_md.HISTORY_TITLE}\n\n"
123
125
  f"{history_md.HISTORY_HEADER}\n{history_md.HISTORY_SEP}\n"