task-ledger 1.3.4 → 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.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/SKILL.md +30 -17
  3. package/package.json +1 -1
  4. package/references/state-model.md +12 -10
  5. package/references/troubleshooting.md +19 -7
  6. package/scripts/ledgerkit/actions/atom.py +2 -2
  7. package/scripts/ledgerkit/actions/bootstrap.py +9 -2
  8. package/scripts/ledgerkit/actions/claim.py +7 -6
  9. package/scripts/ledgerkit/actions/close_task.py +17 -12
  10. package/scripts/ledgerkit/actions/guard.py +38 -16
  11. package/scripts/ledgerkit/actions/handoff.py +24 -8
  12. package/scripts/ledgerkit/actions/init_identity.py +90 -0
  13. package/scripts/ledgerkit/actions/list_tasks.py +21 -3
  14. package/scripts/ledgerkit/actions/log_progress.py +5 -2
  15. package/scripts/ledgerkit/actions/new_task.py +11 -7
  16. package/scripts/ledgerkit/actions/query.py +4 -2
  17. package/scripts/ledgerkit/actions/query_history.py +55 -9
  18. package/scripts/ledgerkit/actions/registry.py +1 -1
  19. package/scripts/ledgerkit/actions/release.py +25 -4
  20. package/scripts/ledgerkit/actions/set_task.py +18 -7
  21. package/scripts/ledgerkit/actions/validate.py +0 -4
  22. package/scripts/ledgerkit/core/history_md.py +47 -250
  23. package/scripts/ledgerkit/core/identity.py +34 -7
  24. package/scripts/ledgerkit/core/labels.py +0 -1
  25. package/scripts/ledgerkit/core/paths.py +0 -1
  26. package/scripts/ledgerkit/core/worklog_md.py +23 -3
  27. package/scripts/ledgerkit/env.py +70 -71
  28. package/scripts/ledgerkit/shell/cli.py +60 -3
  29. package/scripts/ledgerkit/shell/gitops.py +14 -1
  30. package/scripts/ledgerkit/shell/render.py +3 -2
  31. package/scripts/ledgerkit/shell/runner.py +1 -1
  32. package/scripts/ledgerkit/store/history_store.py +26 -81
  33. package/scripts/ledgerkit/store/owners_index.py +16 -3
  34. package/scripts/ledgerkit/store/task_store.py +2 -2
@@ -47,7 +47,8 @@ def build_parser() -> argparse.ArgumentParser:
47
47
 
48
48
  setp = sub.add_parser("set", help="释放 / 交接任务")
49
49
  setp.add_argument("--id", required=True, dest="tid")
50
- setp.add_argument("--owner")
50
+ setp.add_argument("--owner", help="交给别人的 uuid(交接)")
51
+ setp.add_argument("--release", action="store_true", help="释放回公共池")
51
52
 
52
53
  logp = sub.add_parser("log", help="记一条进展")
53
54
  logp.add_argument("--id", required=True, dest="tid")
@@ -63,7 +64,12 @@ def build_parser() -> argparse.ArgumentParser:
63
64
  listp = sub.add_parser("list", help="查在办任务")
64
65
  listp.add_argument("--pool", action="store_true")
65
66
  listp.add_argument("--mine", action="store_true")
66
- listp.add_argument("--owner")
67
+ listp.add_argument(
68
+ "--owner", help="某个人的 uuid(精确匹配;名字请用 --owner-name)"
69
+ )
70
+ listp.add_argument(
71
+ "--owner-name", dest="owner_name", help="按显示名模糊筛(子串、不分大小写)"
72
+ )
67
73
  listp.add_argument("--status")
68
74
  listp.add_argument("--category")
69
75
  listp.add_argument("--tag")
@@ -76,7 +82,12 @@ def build_parser() -> argparse.ArgumentParser:
76
82
  showp.add_argument("--json", action="store_true")
77
83
 
78
84
  hist = sub.add_parser("history", help="查已结案")
79
- hist.add_argument("--owner")
85
+ hist.add_argument(
86
+ "--owner", help="某个人的 uuid(精确匹配;名字请用 --owner-name)"
87
+ )
88
+ hist.add_argument(
89
+ "--owner-name", dest="owner_name", help="按显示名模糊筛(子串、不分大小写)"
90
+ )
80
91
  hist.add_argument("--mine", action="store_true")
81
92
  hist.add_argument("--category")
82
93
  hist.add_argument("--tag")
@@ -93,6 +104,11 @@ def build_parser() -> argparse.ArgumentParser:
93
104
  valp.add_argument("--deep", action="store_true")
94
105
  valp.add_argument("--json", action="store_true")
95
106
 
107
+ initp = sub.add_parser("init", help="初始化本机身份(一台机器只一次)")
108
+ initp.add_argument("--claim", help="认领名册里已有的 uuid")
109
+ initp.add_argument("--new", action="store_true", dest="new_id", help="新建身份")
110
+ initp.add_argument("--name", help="新建身份时的显示名字(必填)")
111
+
96
112
  # 宿主(插件 / SKILL.md)的调用写法是 `progress.py list --repo <仓库>`:
97
113
  # `--repo` 挂在**子命令后面**,所以每个子命令都要认;子命令给的值覆盖顶层那个。
98
114
  for parser_ in sub.choices.values():
@@ -119,6 +135,7 @@ def to_query(args: argparse.Namespace) -> None:
119
135
  args.q = ListQuery(
120
136
  scope=scope,
121
137
  owner=getattr(args, "owner", None),
138
+ owner_name=getattr(args, "owner_name", None),
122
139
  statuses=tuple(
123
140
  s.strip()
124
141
  for s in (getattr(args, "status", "") or "").split(",")
@@ -134,6 +151,7 @@ def to_query(args: argparse.Namespace) -> None:
134
151
  elif command == "history":
135
152
  args.q = HistoryQuery(
136
153
  owner=getattr(args, "owner", None),
154
+ owner_name=getattr(args, "owner_name", None),
137
155
  mine=bool(getattr(args, "mine", False)),
138
156
  category=getattr(args, "category", None),
139
157
  tag=getattr(args, "tag", None),
@@ -206,6 +224,8 @@ def main(argv: list[str] | None = None) -> int:
206
224
  to_query(args)
207
225
 
208
226
  command = command_of(args)
227
+ if command == "init":
228
+ return _run_init(args)
209
229
  try:
210
230
  # 定位只做一次:解析好的工作区根挂回 args,runner 与整命令锁都拿它
211
231
  args.repo = str(resolve_repo(getattr(args, "repo", None)))
@@ -249,6 +269,43 @@ def _lock_dir(repo: Path) -> Path:
249
269
  return lock_dir
250
270
 
251
271
 
272
+ def _run_init(args: argparse.Namespace) -> int:
273
+ """init 不进 runner、不解析台账、不碰 git:只写机器身份文件。"""
274
+ from ..actions.init_identity import init_identity
275
+
276
+ repo: Path | None = None
277
+ raw = getattr(args, "repo", None)
278
+ try:
279
+ repo = as_root(raw, source="--repo") if raw else root_of(Path.cwd().resolve())
280
+ except Fail:
281
+ repo = None
282
+ try:
283
+ res = init_identity(
284
+ repo,
285
+ claim=getattr(args, "claim", None),
286
+ new=bool(getattr(args, "new_id", False)),
287
+ name=getattr(args, "name", "") or "",
288
+ )
289
+ except Fail as exc:
290
+ render.fail_message(exc)
291
+ return BUSINESS
292
+ if res.get("need_choose"):
293
+ print("这台机器还没初始化。当前仓库里已有的用户:")
294
+ for opt in res.get("options", []):
295
+ print(f" {opt['uuid']} {opt['name']}")
296
+ print("请重跑:init --claim=<uuid> 认领,或 init --new --name=<名字> 新建")
297
+ return 0
298
+ if res.get("already"):
299
+ print(
300
+ f"已经初始化过,未做改动:{res['name'] or res['uuid'][:8]}({res['uuid']})"
301
+ )
302
+ print(f"文件:{res['path']}")
303
+ return 0
304
+ print(f"已初始化:{res['name'] or res['uuid'][:8]}({res['uuid']})")
305
+ print(f"文件:{res['path']}")
306
+ return 0
307
+
308
+
252
309
  def _readonly(command: str) -> bool:
253
310
  from ..actions.registry import spec_of
254
311
 
@@ -30,7 +30,13 @@ GIT_TIMEOUT = 30
30
30
 
31
31
 
32
32
  def _git(*args: str, timeout: int | None = GIT_TIMEOUT) -> subprocess.CompletedProcess:
33
- """跑一条 git。没装 git 不当崩溃:返回 127 的空结果,由调用方按返回码处理。"""
33
+ """跑一条 git。没装 git 不当崩溃:返回 127 的空结果,由调用方按返回码处理。
34
+
35
+ **超时也折成返回码 124**,不许抛出去:`sync_down` 的 fetch 分支、`push` 的重试、
36
+ `_rebase_retry` 全按返回码判「远端不通」,抛异常会被 runner 兜底成「未预期错误」
37
+ (退出码 3),把「拉取失败、按本地最新写」这条既定降级路径整条跳过。触发场景是
38
+ 弱网 / 网络黑洞 / 代理挂住 —— 远端不立刻拒绝,fetch 一直挂到超时(2026-10-25 修)。
39
+ """
34
40
  try:
35
41
  return subprocess.run(
36
42
  list(args), capture_output=True, text=True, timeout=timeout, check=False
@@ -39,6 +45,13 @@ def _git(*args: str, timeout: int | None = GIT_TIMEOUT) -> subprocess.CompletedP
39
45
  return subprocess.CompletedProcess(
40
46
  list(args), 127, stdout="", stderr="git: command not found"
41
47
  )
48
+ except subprocess.TimeoutExpired:
49
+ return subprocess.CompletedProcess(
50
+ list(args),
51
+ 124,
52
+ stdout="",
53
+ stderr=f"git: timed out after {timeout}s",
54
+ )
42
55
 
43
56
 
44
57
  @contextlib.contextmanager
@@ -101,7 +101,8 @@ def show_view(res: ShowResult, *, as_json: bool) -> None:
101
101
  task = res.task
102
102
  _out(f"{task.get('tid', '')}:{task.get('title', '')}")
103
103
  _out(
104
- f" 状态:{task.get('status', '')} 负责人:{task.get('owner') or '(未认领)'}"
104
+ f" 状态:{task.get('status', '')} "
105
+ f"负责人:{task.get('owner_name') or task.get('owner') or '(未认领)'}"
105
106
  )
106
107
  _out(
107
108
  f" 创建:{fmt_local(task.get('created'))} "
@@ -143,7 +144,7 @@ def history_view(res: HistoryResult, q: HistoryQuery | None, *, as_json: bool) -
143
144
  for row in res.records:
144
145
  _out(
145
146
  f" {fmt_local(row.get('finished'))} "
146
- f"{row.get('owner') or '(无人)':<12} {row.get('title', '')}"
147
+ f"{row.get('owner_name') or row.get('owner') or '(无人)':<12} {row.get('title', '')}"
147
148
  )
148
149
 
149
150
 
@@ -213,7 +213,7 @@ def _task_snapshot(repo: Path, me) -> dict[str, Any]:
213
213
 
214
214
  tasks: dict[str, Any] = {}
215
215
  tasks.update(TaskStore(repo, POOL_REL).all())
216
- tasks.update(TaskStore(repo, shard_rel(me.email)).all())
216
+ tasks.update(TaskStore(repo, shard_rel(me.uuid)).all())
217
217
  return tasks
218
218
 
219
219
 
@@ -9,9 +9,8 @@ from pathlib import Path
9
9
  from typing import Any
10
10
 
11
11
  from ..core import history_md
12
- from ..core.paths import HISTORY_DIR, LEGACY_HISTORY_REL, to_repo_rel
12
+ from ..core.paths import HISTORY_DIR, to_repo_rel
13
13
  from .atomic import write_text_atomic
14
- from .worklog_store import WorklogStore
15
14
 
16
15
  HIT = "hit"
17
16
  MISS = "miss"
@@ -25,16 +24,12 @@ class HistoryStore:
25
24
  # ---- 读 ----
26
25
 
27
26
  def sources(self) -> list[Path]:
28
- """按月的 `history/*.md`(按文件名排序)+ 存量平铺文件(只读,永不重写)。"""
27
+ """按月的 `history/*.md`(按文件名排序)。"""
29
28
  base = self.repo / to_repo_rel(HISTORY_DIR)
30
- files = sorted(base.glob("*.md")) if base.is_dir() else []
31
- legacy = self.repo / to_repo_rel(LEGACY_HISTORY_REL)
32
- if legacy.is_file():
33
- files.append(legacy)
34
- return files
29
+ return sorted(base.glob("*.md")) if base.is_dir() else []
35
30
 
36
31
  def month_key(self, path: Path) -> str:
37
- """按月文件的 `YYYYMM`;存量平铺文件没有月份键,返回空串。"""
32
+ """按月文件的 `YYYYMM`。"""
38
33
  stem = Path(path).stem
39
34
  return stem if len(stem) == 6 and stem.isdigit() else ""
40
35
 
@@ -46,16 +41,6 @@ class HistoryStore:
46
41
  except OSError as exc:
47
42
  raise ValueError(f"读 {path} 失败:{exc}") from exc
48
43
 
49
- def _read_worklog(self, path: Path):
50
- """升级要回链的 worklog 头部:读文件这件事留在 store,core 只消费返回的字典。"""
51
- logs = WorklogStore(self.repo)
52
-
53
- def read(log_name: str) -> dict[str, Any] | None:
54
- found = logs.by_name(log_name)
55
- return logs.head_meta(found) if found else None
56
-
57
- return read
58
-
59
44
  # ---- 判重 ----
60
45
 
61
46
  def has_log(
@@ -79,6 +64,11 @@ class HistoryStore:
79
64
  if Path(worklog).name == Path(log_name).name:
80
65
  return HIT
81
66
  continue
67
+ if log_name is None and worklog:
68
+ # 历史行里有链接,但这次判不出对应的是哪篇文档(任务的过程文档丢了 / 被移走):
69
+ # 判不了就明说。拿 miss 冒充会再追加一行 ⇒ 同一条任务两条结案记录。
70
+ undecidable = True
71
+ continue
82
72
  if not worklog:
83
73
  if created and title:
84
74
  if row.get("created") == created and row.get("title") == title:
@@ -90,11 +80,7 @@ class HistoryStore:
90
80
  # ---- 算 ----
91
81
 
92
82
  def append_row(self, path: Path, row: dict[str, Any]) -> dict[str, Any]:
93
- """算出「追加这一行后的新全文」,**不落盘**。
94
-
95
- 返回四个键:`text` / `upgraded` / `duplicate` / `fallback_cols`(`None` = 正常 9 列)。
96
- 老表列少又不敢整体升级时,按现有列数追加并让业务层提示,绝不硬写 9 列把表格挤歪。
97
- """
83
+ """算出「追加这一行后的新全文」,**不落盘**。返回 `text` / `duplicate`。"""
98
84
  original = self.read(path)
99
85
  duplicate = (
100
86
  self.has_log(
@@ -105,67 +91,26 @@ class HistoryStore:
105
91
  )
106
92
  == HIT
107
93
  )
108
- if not original.strip():
109
- text = _fresh_table()
110
- upgraded = False
111
- fallback_cols = None
112
- else:
113
- cols = history_md.history_cols(original)
114
- if cols == 9:
115
- text = original
116
- upgraded = False
117
- fallback_cols = None
118
- else:
119
- upgraded_text = history_md.upgrade_history(
120
- original,
121
- read_worklog=self._read_worklog(path),
122
- fallback_created=row.get("created") or "",
123
- )
124
- if upgraded_text is None:
125
- text = original
126
- upgraded = False
127
- fallback_cols = cols
128
- else:
129
- text, _ = upgraded_text
130
- upgraded = True
131
- fallback_cols = None
132
- line = (
133
- history_md.history_line(
134
- row.get("finished") or "",
135
- row.get("created"),
136
- row.get("title") or "",
137
- row.get("owner"),
138
- row.get("summary") or "",
139
- row.get("worklog"),
140
- row.get("category"),
141
- row.get("tags"),
142
- row.get("creator"),
143
- )
144
- if fallback_cols is None
145
- else history_md.history_line_for_cols(
146
- fallback_cols,
147
- row.get("finished") or "",
148
- row.get("created"),
149
- row.get("title") or "",
150
- row.get("owner"),
151
- row.get("summary") or "",
152
- row.get("worklog"),
153
- row.get("category"),
154
- row.get("tags"),
155
- row.get("creator"),
156
- )
94
+ text = original if original.strip() else _fresh_table()
95
+ line = history_md.history_line(
96
+ row.get("finished") or "",
97
+ row.get("created"),
98
+ row.get("title") or "",
99
+ row.get("owner"),
100
+ row.get("summary") or "",
101
+ row.get("worklog"),
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"),
157
107
  )
158
- return {
159
- "text": _append_line(text, line),
160
- "upgraded": upgraded,
161
- "duplicate": duplicate,
162
- "fallback_cols": fallback_cols,
163
- }
108
+ return {"text": _append_line(text, line), "duplicate": duplicate}
164
109
 
165
110
  # ---- 写 ----
166
111
 
167
112
  def write(self, path: Path, text: str) -> None:
168
- """历史表唯一的写回口:只用于「结案追加」与「8→9 列升级」的结果落盘。"""
113
+ """历史表唯一的写回口:只用于「结案追加」的结果落盘。"""
169
114
  write_text_atomic(path, text)
170
115
 
171
116
 
@@ -174,7 +119,7 @@ def _name_of(worklog: str | None) -> str | None:
174
119
 
175
120
 
176
121
  def _fresh_table() -> str:
177
- """当月第一次结案:按九列表头起表(表头的真源只有 `history_md` 一处)。"""
122
+ """当月第一次结案:按十一列表头起表(表头的真源只有 `history_md` 一处)。"""
178
123
  return (
179
124
  f"{history_md.HISTORY_TITLE}\n\n"
180
125
  f"{history_md.HISTORY_HEADER}\n{history_md.HISTORY_SEP}\n"
@@ -32,7 +32,7 @@ class OwnersIndex:
32
32
 
33
33
  def ensure(self, me: Identity) -> tuple[bool, dict[str, Any]]:
34
34
  """登记 / 补 `joined`。`name` 只在首次登记时写入,之后不回写(名字是给你做对照的被动字段,不该被任何逻辑读)。返回 (这次写了没有, 记录)。"""
35
- key = normalize(me.email)
35
+ key = normalize(me.uuid)
36
36
  with self._open() as session:
37
37
  data = session.data
38
38
  workers = data.setdefault("workers", {})
@@ -48,6 +48,21 @@ class OwnersIndex:
48
48
  return True, dict(record)
49
49
  return False, dict(record)
50
50
 
51
+ def keys_of_name(self, fragment: str) -> set[str]:
52
+ """显示名模糊匹配(不分大小写的子串)⇒ uuid 集合。**只给查询筛选**,不参与归属判定。
53
+
54
+ 比对的是名册里的名字(`workers[<uuid>].name`),不是每条任务上那份冗余的
55
+ `owner_name`:改名后名册先变,筛选结果跟着变,任务里的旧名字不会干扰。
56
+ """
57
+ needle = normalize(fragment)
58
+ if not needle:
59
+ return set()
60
+ return {
61
+ key
62
+ for key, record in self.all().items()
63
+ if needle in normalize(str((record or {}).get("name") or ""))
64
+ }
65
+
51
66
  def shards(self) -> dict[str, Path]:
52
67
  """身份 → 分片绝对路径(只信索引)。"""
53
68
  out: dict[str, Path] = {}
@@ -110,5 +125,3 @@ class _IndexSession:
110
125
  def __exit__(self, *exc) -> None:
111
126
  if self.changed:
112
127
  self.store._flush(self.data) # 同一个包内的受控写回口
113
-
114
-
@@ -22,7 +22,7 @@ class TaskStore(JSONStore):
22
22
  self.identity = identity
23
23
 
24
24
  def _flush(self, data: dict[str, Any]) -> None:
25
- """写盘时带上分片顶部的 `identity`(只存邮箱;显示名在索引里)。"""
25
+ """写盘时带上分片顶部的 `identity`(只存 uuid;显示名在索引里)。"""
26
26
  if self.identity is not None:
27
- data["identity"] = {"email": self.identity.email}
27
+ data["identity"] = {"uuid": self.identity.uuid}
28
28
  super()._flush(data)