task-ledger 1.3.0 → 1.3.1

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 (55) hide show
  1. package/README.md +29 -247
  2. package/SKILL.md +173 -88
  3. package/package.json +22 -22
  4. package/references/log-note.md +3 -1
  5. package/references/state-model.md +29 -27
  6. package/references/troubleshooting.md +97 -32
  7. package/scripts/ledgerkit/__init__.py +0 -0
  8. package/scripts/ledgerkit/actions/__init__.py +0 -0
  9. package/scripts/ledgerkit/actions/atom.py +154 -0
  10. package/scripts/ledgerkit/actions/bootstrap.py +27 -0
  11. package/scripts/ledgerkit/actions/claim.py +82 -0
  12. package/scripts/ledgerkit/actions/close_task.py +180 -0
  13. package/scripts/ledgerkit/actions/guard.py +140 -0
  14. package/scripts/ledgerkit/actions/handoff.py +74 -0
  15. package/scripts/ledgerkit/actions/list_tasks.py +53 -0
  16. package/scripts/ledgerkit/actions/log_progress.py +60 -0
  17. package/scripts/ledgerkit/actions/new_task.py +120 -0
  18. package/scripts/ledgerkit/actions/query.py +33 -0
  19. package/scripts/ledgerkit/actions/query_history.py +173 -0
  20. package/scripts/ledgerkit/actions/registry.py +82 -0
  21. package/scripts/ledgerkit/actions/release.py +51 -0
  22. package/scripts/ledgerkit/actions/result.py +88 -0
  23. package/scripts/ledgerkit/actions/set_task.py +43 -0
  24. package/scripts/ledgerkit/actions/show_task.py +27 -0
  25. package/scripts/ledgerkit/actions/validate.py +224 -0
  26. package/scripts/ledgerkit/core/__init__.py +0 -0
  27. package/scripts/ledgerkit/core/history_md.py +350 -0
  28. package/scripts/ledgerkit/core/identity.py +43 -0
  29. package/scripts/ledgerkit/core/ids.py +58 -0
  30. package/scripts/ledgerkit/core/labels.py +79 -0
  31. package/scripts/ledgerkit/core/paths.py +93 -0
  32. package/scripts/ledgerkit/core/time.py +102 -0
  33. package/scripts/ledgerkit/core/worklog_md.py +232 -0
  34. package/scripts/ledgerkit/env.py +93 -0
  35. package/scripts/ledgerkit/errors.py +15 -0
  36. package/scripts/ledgerkit/notices.py +28 -0
  37. package/scripts/ledgerkit/shell/__init__.py +0 -0
  38. package/scripts/ledgerkit/shell/cli.py +259 -0
  39. package/scripts/ledgerkit/shell/gitops.py +655 -0
  40. package/scripts/ledgerkit/shell/merge.py +266 -0
  41. package/scripts/ledgerkit/shell/presync.py +23 -0
  42. package/scripts/ledgerkit/shell/render.py +165 -0
  43. package/scripts/ledgerkit/shell/runner.py +225 -0
  44. package/scripts/ledgerkit/store/__init__.py +0 -0
  45. package/scripts/ledgerkit/store/atomic.py +130 -0
  46. package/scripts/ledgerkit/store/dirty.py +108 -0
  47. package/scripts/ledgerkit/store/history_store.py +189 -0
  48. package/scripts/ledgerkit/store/json_store.py +166 -0
  49. package/scripts/ledgerkit/store/locks.py +64 -0
  50. package/scripts/ledgerkit/store/owners_index.py +135 -0
  51. package/scripts/ledgerkit/store/task_store.py +31 -0
  52. package/scripts/ledgerkit/store/worklog_store.py +168 -0
  53. package/scripts/progress.py +8 -3109
  54. package/references/taxonomy.md +0 -26
  55. package/references/worklog.md +0 -39
@@ -0,0 +1,266 @@
1
+ """合并语义:push 被拒后 rebase 撞上的**台账冲突**怎么解。
2
+
3
+ 只认台账自己的文件:`pool.json` / 分片 / `owners.json` 按**任务号**三路合并,
4
+ `worklog/` 与 `history/` 是追加式正文,两边各加的内容并在一起。业务代码一个字都不碰。
5
+
6
+ rebase 的 stage 语义与字面直觉相反(实测确认):`:1` 共同祖先、`:2` 上游(别人的)、
7
+ `:3` 本机正在重放的那个提交。本文档的 `ours` 一律指 `:2`、`theirs` 指 `:3`,与 git
8
+ 自己在 rebase 里的叫法一致 —— 标反了会让人照着冲突标记把别人的版本留下来。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from ..core.paths import (
18
+ HISTORY_DIR,
19
+ OWNERS_DIR,
20
+ OWNERS_REL,
21
+ POOL_REL,
22
+ WORKLOG_DIR,
23
+ to_ledger_rel,
24
+ )
25
+ from ..store.atomic import write_text_atomic
26
+ from ..store.json_store import dump_ledger_json
27
+
28
+ EXPAND_KEYS = ("tasks", "workers")
29
+
30
+
31
+ def _load(text: str) -> dict[str, Any] | None:
32
+ if not text.strip():
33
+ return {}
34
+ try:
35
+ data = json.loads(text)
36
+ except json.JSONDecodeError:
37
+ return None
38
+ return data if isinstance(data, dict) else None
39
+
40
+
41
+ def _coerce(value: str | dict | None) -> dict[str, Any] | None:
42
+ """三方内容:git 给的是文本(`git show :N:rel`),测试与调用方也可以直接给 dict。"""
43
+ if value is None:
44
+ return None
45
+ if isinstance(value, dict):
46
+ return value
47
+ return _load(str(value))
48
+
49
+
50
+ def _rows_of(data: dict[str, Any] | None, key: str) -> dict[str, Any]:
51
+ rows = (data or {}).get(key)
52
+ return rows if isinstance(rows, dict) else {}
53
+
54
+
55
+ def is_ledger_json(rel: str) -> bool:
56
+ """是不是按条目合并的 JSON 台账(`pool.json` / 分片 / `owners.json`)。"""
57
+ r = to_ledger_rel(rel)
58
+ return (
59
+ r == POOL_REL.as_posix()
60
+ or r == OWNERS_REL.as_posix()
61
+ or (r.startswith(OWNERS_DIR.as_posix() + "/") and r.endswith(".json"))
62
+ )
63
+
64
+
65
+ def is_append_only(rel: str) -> bool:
66
+ """是不是追加式正文(过程文档 / 历史):只加行,不改写别人的行。"""
67
+ r = to_ledger_rel(rel)
68
+ return r.startswith((WORKLOG_DIR.as_posix() + "/", HISTORY_DIR.as_posix() + "/"))
69
+
70
+
71
+ def owns(rel: str) -> bool:
72
+ """这份冲突文件归不归脚本管(业务文件一个字都不碰)。"""
73
+ return is_ledger_json(rel) or is_append_only(rel)
74
+
75
+
76
+ def expand_key_of(rel: str) -> str:
77
+ """这份 JSON 台账按哪个字段做条目级合并。"""
78
+ return "workers" if to_ledger_rel(rel) == OWNERS_REL.as_posix() else "tasks"
79
+
80
+
81
+ def merge_task_dicts(
82
+ base: str | dict | None,
83
+ ours: str | dict | None,
84
+ theirs: str | dict | None,
85
+ ) -> tuple[dict[str, Any], list[str]]:
86
+ """按任务号做三方合并;返回 (合并结果, 两边都改过的任务号)。
87
+
88
+ 合并单位是「任务」不是文本行 —— 行级合并只认相邻上下文:不同任务各自新增 /
89
+ 结案挤在文件末尾就会撞,而同一任务两边都改时它也判不出该留谁。按 id 对账后,
90
+ 只有一边动过的直接采纳,两边都动过的列进冲突,一个都不静默丢。
91
+ """
92
+ b, o, t = (_coerce(x) for x in (base, ours, theirs))
93
+ if b is None or o is None or t is None:
94
+ return {}, ["台账冲突无法自动合并:有一方不是合法 JSON,需要人工处理。"]
95
+ merged: dict[str, Any] = dict(o)
96
+ for key, value in t.items():
97
+ if key not in o:
98
+ merged[key] = value
99
+ elif o[key] != value and key not in EXPAND_KEYS:
100
+ merged[key] = value # 非任务区(version / identity)以本机那份为准
101
+ conflicts: list[str] = []
102
+ for expand in EXPAND_KEYS:
103
+ if not any(expand in x for x in (b, o, t)):
104
+ continue # 这份文件没有这个区(pool.json 没有 workers、分片没有 tasks)
105
+ base_rows, our_rows, their_rows = (
106
+ _rows_of(b, expand),
107
+ _rows_of(o, expand),
108
+ _rows_of(t, expand),
109
+ )
110
+ rows: dict[str, Any] = {}
111
+ for tid in sorted(set(base_rows) | set(our_rows) | set(their_rows)):
112
+ was, up, loc = base_rows.get(tid), our_rows.get(tid), their_rows.get(tid)
113
+ if up == loc:
114
+ if up is not None:
115
+ rows[tid] = up
116
+ elif up == was:
117
+ if loc is not None:
118
+ rows[tid] = loc
119
+ elif loc == was:
120
+ # 只有上游动过:上游删了就真删掉。写成 rows[tid] = up 会在文件里
121
+ # 留一行 "<tid>": null 永久累积,读侧还会因 null 崩掉或整区降级。
122
+ if up is not None:
123
+ rows[tid] = up
124
+ else:
125
+ conflicts.append(tid)
126
+ rows[tid] = up if up is not None else loc
127
+ merged[expand] = rows
128
+ return merged, conflicts
129
+
130
+
131
+ def union_merge(base: str, ours: str, theirs: str) -> str:
132
+ """追加式正文的三路合并:两边各自新增的行都要在,不凭行号猜。
133
+
134
+ 过程文档 / 历史只追加不改写,所以"合并"= 本机那份 + 上游相对共同祖先新增的行
135
+ (本机已有同样的行就不再重复)。任一侧为空也照常合。
136
+ """
137
+ base_lines = set(base.splitlines())
138
+ have = set(theirs.splitlines())
139
+ added = [
140
+ line
141
+ for line in ours.splitlines()
142
+ if line not in base_lines and line not in have
143
+ ]
144
+ if not added:
145
+ return theirs
146
+ head = theirs if theirs.endswith("\n") or not theirs else theirs + "\n"
147
+ return head + "\n".join(added) + "\n"
148
+
149
+
150
+ def conflict_marked(
151
+ rel: str, base: str, ours: str, theirs: str
152
+ ) -> tuple[str, list[str]] | None:
153
+ """接管一份 JSON 台账冲突:算出「一条一行 + 冲突标记」的新全文。
154
+
155
+ 返回 (文件新文本, 两边都改过的任务号);不是台账 JSON 或有一方解析不了 ⇒ None。
156
+ """
157
+ if not is_ledger_json(rel):
158
+ return None
159
+ data, conflicts = merge_task_dicts(base, ours, theirs)
160
+ if not data:
161
+ return None
162
+ key = expand_key_of(rel)
163
+ if not conflicts:
164
+ return dump_ledger_json(data, expand=key), []
165
+ return (
166
+ _marked_text(
167
+ _load(theirs) or {},
168
+ key,
169
+ data,
170
+ conflicts,
171
+ _load(ours) or {},
172
+ _load(theirs) or {},
173
+ ),
174
+ conflicts,
175
+ )
176
+
177
+
178
+ def _marked_text(
179
+ full: dict[str, Any],
180
+ key: str,
181
+ merged: dict[str, Any],
182
+ conflicts: list[str],
183
+ upstream: dict[str, Any],
184
+ local: dict[str, Any],
185
+ ) -> str:
186
+ """按「一条一行」渲染整份台账;两边都改过的条目用冲突标记包起来。
187
+
188
+ `full` 是本机那一侧的整份结构(version / identity / tasks),除 `key` 之外
189
+ 原样保留 —— 分片里的 identity 丢了就没法自证。
190
+ """
191
+ rows: list[str] = []
192
+ for field in sorted(full):
193
+ if field != key:
194
+ rows.append(
195
+ f" {json.dumps(field, ensure_ascii=False)}: "
196
+ f"{json.dumps(full[field], ensure_ascii=False, sort_keys=True)}"
197
+ )
198
+ continue
199
+ inner: list[str] = []
200
+ merged_rows = merged.get(key) or {}
201
+ for ident in sorted(merged_rows):
202
+ label = json.dumps(ident, ensure_ascii=False)
203
+ if ident not in conflicts:
204
+ inner.append(
205
+ f" {label}: "
206
+ f"{json.dumps(merged_rows[ident], ensure_ascii=False, sort_keys=True)}"
207
+ )
208
+ continue
209
+ left = (
210
+ "(远端已删除)"
211
+ if _rows_of(upstream, key).get(ident) is None
212
+ else json.dumps(
213
+ _rows_of(upstream, key)[ident], ensure_ascii=False, sort_keys=True
214
+ )
215
+ )
216
+ right = (
217
+ "(本地已删除)"
218
+ if _rows_of(local, key).get(ident) is None
219
+ else json.dumps(
220
+ _rows_of(local, key)[ident], ensure_ascii=False, sort_keys=True
221
+ )
222
+ )
223
+ inner.append(
224
+ f"<<<<<<< 远端\n {label}: {left}\n=======\n"
225
+ f" {label}: {right}\n>>>>>>> 本地"
226
+ )
227
+ rows.append(
228
+ f' "{key}": {{\n' + (",\n".join(inner) + "\n" if inner else "") + " }"
229
+ )
230
+ return "{\n" + ",\n".join(rows) + "\n}\n"
231
+
232
+
233
+ def resolve_ledger_conflict(path: Path, text: str) -> str | None:
234
+ """把合并结果写回冲突文件(`track=False`、不进记账本),写完由 `git rebase --continue` 提交。
235
+
236
+ 返回写回的文本;`text` 为空(说明没算出合并结果)返回 None。
237
+ """
238
+ if not text:
239
+ return None
240
+ write_text_atomic(Path(path), text, track=False)
241
+ return text
242
+
243
+
244
+ def render_conflict_text(rel: str, base: str, ours: str, theirs: str) -> str:
245
+ """人话冲突说明:**返回文本,不打印**(打印归 render.py)。"""
246
+ if not owns(rel):
247
+ return f"{rel}:不是台账文件,脚本不代劳,请人工处理。"
248
+ if is_append_only(rel):
249
+ return f"{rel}:两边都追加过,已并成一份(追加式正文,一行没丢)。"
250
+ data, conflicts = merge_task_dicts(base, ours, theirs)
251
+ if not data:
252
+ return f"{rel}:有一方不是合法 JSON,无法自动合并,请人工处理。"
253
+ if not conflicts:
254
+ return f"{rel}:冲突已按任务号自动合并,无需人工处理。"
255
+ lines = [
256
+ (
257
+ f"{rel}:这些任务被两边同时改过,已按任务逐个标出冲突"
258
+ "(标记里左边是远端、右边是本机),没有替你选任何一个:"
259
+ ),
260
+ ]
261
+ lines.extend(f" - {tid}" for tid in conflicts)
262
+ lines.append(" 处理:编辑该文件保留你要的版本 → git add → git rebase --continue;")
263
+ lines.append(
264
+ " 不想合就 git rebase --abort 回到写之前。你的台账改动已提交在本地,不会丢。"
265
+ )
266
+ return "\n".join(lines)
@@ -0,0 +1,23 @@
1
+ """取最新要刷哪些文件:**全仓唯一算锁集的地方**。
2
+
3
+ 返回两份 `Scope`(自举那份、动作那份)。runner 拿它做三件事:
4
+ `sync_down(boot | act)` / `atom(*boot.files)` 包自举 / `atom(*act.files)` 包动作。
5
+ 它本来就不碰 git —— `--no-sync` 也照调。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ..actions.atom import Scope, lock_scope
14
+ from ..core.identity import Identity
15
+
16
+
17
+ def presync_scope(
18
+ repo: Path, me: Identity, action: str, params: dict[str, Any]
19
+ ) -> tuple[Scope, Scope]:
20
+ """(自举那份, 动作那份)。只读动作由 runner 直接拿空的两份,不进这里。"""
21
+ boot = lock_scope(repo, me, "bootstrap", params)
22
+ act = Scope() if action == "bootstrap" else lock_scope(repo, me, action, params)
23
+ return boot, act
@@ -0,0 +1,165 @@
1
+ """唯一打印处。一个返回类型一个函数,**不做统一分发**;也**不 flush**。
2
+
3
+ `flush_notices()` 只在 `cli.main` 的 `finally` 里调一次 —— 提交 / 推送阶段冒出来的提示也才打得到。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ from typing import Any
11
+
12
+ from .. import notices
13
+ from ..actions.query import HistoryQuery, ListQuery
14
+ from ..actions.result import (
15
+ HistoryResult,
16
+ ListResult,
17
+ Result,
18
+ ShowResult,
19
+ ValidationReport,
20
+ )
21
+ from ..core.time import fmt_local
22
+
23
+
24
+ def _out(text: str = "") -> None:
25
+ sys.stdout.write(text + "\n")
26
+
27
+
28
+ def _err(text: str) -> None:
29
+ sys.stderr.write(text + "\n")
30
+
31
+
32
+ def flush_notices() -> None:
33
+ """把 `notices` 一次性打到 stderr(退出前调一次,同一句只打一次)。"""
34
+ for line in notices.warnings():
35
+ _err(line)
36
+ notices.clear()
37
+
38
+
39
+ def fail_message(exc: BaseException, *, title: str = "错误") -> None:
40
+ """业务错误 / 未预期错误的 stderr 输出。"""
41
+ _err(f"{title}:{exc}")
42
+
43
+
44
+ def _json(data: Any) -> None:
45
+ sys.stdout.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
46
+
47
+
48
+ def result(res: Result, *, as_json: bool) -> None:
49
+ """写动作:一律人话(`--json` 不挂写动作,传进来也按人话打)。"""
50
+ del as_json
51
+ if res.already_done:
52
+ _out("无需重复:这次没有新的改动。")
53
+ else:
54
+ _out(f"已改:{'、'.join(res.changed) if res.changed else '(无字段改动)'}")
55
+ if res.where:
56
+ _out(f"落点:{res.where}")
57
+ for line in res.notices:
58
+ _err(line)
59
+ if res.worklog is not None:
60
+ if res.worklog.path:
61
+ _out(f"过程文档:{res.worklog.path}")
62
+ elif res.worklog.error:
63
+ _err(f"过程文档:{res.worklog.error}")
64
+
65
+
66
+ def list_view(res: ListResult, q: ListQuery | None, *, as_json: bool) -> None:
67
+ if as_json:
68
+ _json({tid: task for tid, task in res.rows.items()})
69
+ return
70
+ if not res.table:
71
+ _out("没有符合条件的在办任务。")
72
+ return
73
+ _out(f"在办 {len(res.table)} 条:")
74
+ for row in res.table:
75
+ owner = row.owner or "(未认领)"
76
+ _out(f" {row.tid} {row.status:<6} {owner:<12} {row.title}")
77
+ for line in res.notices:
78
+ _err(line)
79
+
80
+
81
+ def show_view(res: ShowResult, *, as_json: bool) -> None:
82
+ if as_json:
83
+ _json(
84
+ {
85
+ "task": res.task,
86
+ "worklog": {
87
+ "path": res.worklog.path if res.worklog else None,
88
+ "text": res.worklog.text if res.worklog else None,
89
+ "error": res.worklog.error if res.worklog else None,
90
+ },
91
+ "others": list(res.others),
92
+ }
93
+ )
94
+ return
95
+ task = res.task
96
+ _out(f"{task.get('tid', '')}:{task.get('title', '')}")
97
+ _out(
98
+ f" 状态:{task.get('status', '')} 负责人:{task.get('owner') or '(未认领)'}"
99
+ )
100
+ _out(
101
+ f" 创建:{fmt_local(task.get('created'))} "
102
+ f"最后更新:{fmt_local(task.get('updated'))}"
103
+ )
104
+ if res.others:
105
+ _out(f" 还出现在:{'、'.join(res.others)}")
106
+ if res.worklog is None:
107
+ return
108
+ if res.worklog.error:
109
+ _err(f" 过程文档:{res.worklog.error}")
110
+ elif res.worklog.path:
111
+ _out(f" 过程文档:{res.worklog.path}")
112
+
113
+
114
+ def history_view(res: HistoryResult, q: HistoryQuery | None, *, as_json: bool) -> None:
115
+ # `--count-only` 是**计数口径**:固定两个键的 JSON(§六 / ledger-data.md 第七节)。
116
+ # 它与 `--json` 互斥(同给即用法错),所以这里不看 as_json —— 否则那个分支永远是死代码。
117
+ if q is not None and q.count_only:
118
+ _json({"count": res.count, "capped": res.capped})
119
+ return
120
+ if as_json:
121
+ _json(
122
+ {
123
+ "count": res.count,
124
+ "filters": res.filters,
125
+ "records": list(res.records),
126
+ "warnings": list(res.warnings),
127
+ "capped": res.capped,
128
+ }
129
+ )
130
+ return
131
+ for line in res.warnings:
132
+ _err(f"警告:{line}")
133
+ if not res.records:
134
+ _out("没有符合条件的结案记录。")
135
+ return
136
+ _out(f"已结案 {res.count} 条:")
137
+ for row in res.records:
138
+ _out(
139
+ f" {fmt_local(row.get('finished'))} "
140
+ f"{row.get('owner') or '(无人)':<12} {row.get('title', '')}"
141
+ )
142
+
143
+
144
+ def validate_view(rep: ValidationReport, *, as_json: bool) -> None:
145
+ if as_json:
146
+ _json(
147
+ {
148
+ "problems": list(rep.problems),
149
+ "notices": list(rep.notices),
150
+ "checked_files": rep.checked_files,
151
+ "total_tasks": rep.total_tasks,
152
+ "deep": rep.deep,
153
+ }
154
+ )
155
+ return
156
+ mode = "深度" if rep.deep else "浅度"
157
+ _out(f"体检({mode}):查了 {rep.checked_files} 个文件、{rep.total_tasks} 条任务。")
158
+ if rep.problems:
159
+ _out(f"问题 {len(rep.problems)} 条:")
160
+ for line in rep.problems:
161
+ _out(f" - {line}")
162
+ else:
163
+ _out("没有问题。")
164
+ for line in rep.notices:
165
+ _err(f"提示:{line}")
@@ -0,0 +1,225 @@
1
+ """七步的执行顺序:唯一创建记账本、唯一决定退出码、唯一统一回滚的地方。
2
+
3
+ 七步:绑记账本 → 取最新 → 自举 → 调动作 → 打印 → 提交 → 推送。
4
+ (仓库定位在更前面,见 `run` 开头。)
5
+
6
+ - `already_done` **不**跳过提交与推送(写没写只看记账本)。
7
+ - 记账本为空 ⇒ 提交与推送都跳过(没脏文件就没得提交)。
8
+ - **唯一不回滚的失败分支是 `BrokenPipeError`**:文件已经写完,照常提交推送。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .. import notices
19
+ from ..actions import registry
20
+ from ..actions.atom import atom
21
+ from ..actions.guard import current_identity
22
+ from ..core.ids import make_id
23
+ from ..core.paths import to_repo_rel
24
+ from ..core.time import now, to_utc
25
+ from ..errors import (
26
+ BUSINESS,
27
+ INTERRUPTED,
28
+ OK,
29
+ UNEXPECTED,
30
+ Fail,
31
+ )
32
+ from ..store import dirty
33
+ from ..store.atomic import snapshot
34
+ from . import render
35
+ from .gitops import GitRepo
36
+ from .presync import presync_scope
37
+
38
+
39
+ def run(command: str, args: argparse.Namespace) -> int:
40
+ """跑一条命令,返回退出码。"""
41
+ log = dirty.WriteLog()
42
+ try:
43
+ with dirty.bound(log):
44
+ return _run(command, args, log)
45
+ except Fail as exc:
46
+ if log.commit_started:
47
+ # 提交阶段才失败:数据已落盘,回滚 = 吃掉刚写的台账(2026-10-24 修)
48
+ render.fail_message(exc)
49
+ return BUSINESS
50
+ log.rollback_presync()
51
+ log.discard_created()
52
+ render.fail_message(exc)
53
+ return BUSINESS
54
+ except BrokenPipeError:
55
+ # 唯一不回滚的分支:文件已写,按记账本照常提交推送
56
+ _finalize(command, args, log, broken=True)
57
+ return OK
58
+ except KeyboardInterrupt:
59
+ if log.commit_started:
60
+ return INTERRUPTED
61
+ log.rollback_presync()
62
+ log.discard_created()
63
+ return INTERRUPTED
64
+ except Exception as exc: # noqa: BLE001 —— 兜底:任何未预期异常都要留下可诊断信息
65
+ if log.commit_started:
66
+ render.fail_message(exc, title="未预期错误")
67
+ return UNEXPECTED
68
+ log.rollback_presync()
69
+ log.discard_created()
70
+ render.fail_message(exc, title="未预期错误")
71
+ return UNEXPECTED
72
+
73
+
74
+ def _run(command: str, args: argparse.Namespace, log: dirty.WriteLog) -> int:
75
+ spec = registry.spec_of(command)
76
+ repo = _repo_of(args)
77
+ me = current_identity(repo)
78
+ no_sync = bool(getattr(args, "no_sync", False))
79
+ as_json = bool(getattr(args, "json", False))
80
+
81
+ git = GitRepo(repo)
82
+ syncing = not no_sync
83
+ if syncing:
84
+ # 中间态是**全局前提**,必须早于下面任何读台账的动作(`_params` 的 `new` 快照、
85
+ # `presync_scope` 的锁集都要读台账):冲突标记下的台账不是合法 JSON,读它就抛
86
+ # 「未预期错误」(退出码 3),而文档要的是「仓库处于中间态」这句人话。
87
+ git.check_repo_state()
88
+
89
+ params = _params(command, args, repo, me, spec)
90
+
91
+ # 只读:不建 GitRepo、不进事务、不提交
92
+ if spec.readonly:
93
+ value = spec.func(repo, me, **params)
94
+ _render(spec, value, args, as_json=as_json)
95
+ if getattr(value, "problems", ()):
96
+ return BUSINESS # validate 有 problems ⇒ 退出码 1(§六)
97
+ return OK
98
+
99
+ boot_scope, act_scope = presync_scope(repo, me, spec.lock_action or command, params)
100
+
101
+ if syncing:
102
+ git.guard_before_write(boot_scope | act_scope)
103
+ for path, before in git.sync_down(boot_scope | act_scope):
104
+ log.record_presync(path, before)
105
+
106
+ # 自举会登记自己(改 owners.json、缺文件时建池与分片)。它碰过的文件按「命令前的字节」
107
+ # 记进记账本:成功时照常提交(登记必须进仓),失败时由 rollback_presync 一起还回去 ——
108
+ # 否则一条失败的命令会把台账留在「已写未提交」,下一条写命令被写前 guard 拦死
109
+ # (架构文档 §三.5 正是要避免这个)。登记是幂等的,还回去下次再登记,没有损失。
110
+ for rel in boot_scope.files:
111
+ log.record_presync(repo / to_repo_rel(rel), snapshot(repo / to_repo_rel(rel)))
112
+ with atom(repo, *boot_scope.files):
113
+ me = registry.bootstrap_func()(repo, me)
114
+
115
+ with atom(repo, *act_scope.files):
116
+ value = spec.func(repo, me, **params)
117
+
118
+ _render(spec, value, args, as_json=as_json)
119
+ _finalize(command, args, log, message=getattr(value, "message", None))
120
+ return OK
121
+
122
+
123
+ def _finalize(
124
+ command: str,
125
+ args: argparse.Namespace,
126
+ log: dirty.WriteLog,
127
+ *,
128
+ message: str | None = None,
129
+ broken: bool = False,
130
+ ) -> None:
131
+ """提交 + 推送:要不要做只看记账本(与 `already_done` 无关,只有 `--no-sync` 才整段跳过)。"""
132
+ if broken:
133
+ try:
134
+ sys.stdout.close()
135
+ except Exception: # noqa: BLE001, S110 # 关 stdout 本就无信息可记
136
+ pass
137
+ if getattr(args, "no_sync", False):
138
+ if getattr(args, "no_sync", False) and log.has_writes():
139
+ # 以前这条路径一个字都不打,「已写未提交」就此静默腐烂
140
+ notices.note(
141
+ "提示:--no-sync 本次只写了本地文件、未签入 git(也未推送)。\n"
142
+ " 处理:确认后自行 git add ledger && git commit && git push;"
143
+ "留着不提交的话,下一条写命令会被「本地有未提交的台账改动」拦住。"
144
+ )
145
+ log.clear()
146
+ return
147
+ if not log.has_writes():
148
+ log.clear()
149
+ return
150
+ repo = _repo_of(args)
151
+ git = GitRepo(repo)
152
+ if not git.is_git_repo():
153
+ # 没有 git 也能用:台账照写,只不做同步(2026-10-24 用户拍板)。
154
+ # 以前这里直接 git add,非 git 目录下必失败 ⇒ 整条命令退 1,台账像没写。
155
+ notices.note(
156
+ "提示:当前目录不是 git 仓库,台账只写了本地文件(未提交、未推送)。\n"
157
+ " 处理:想纳入版本管理就 git init;否则继续本地写也行 —— "
158
+ "多人各写各的,后写覆盖先写。"
159
+ )
160
+ log.clear()
161
+ return
162
+ log.commit_started = True
163
+ message = message or f"chore(ledger): {command}"
164
+ git.commit(log.commit_set(), message)
165
+ git.push()
166
+ log.clear()
167
+
168
+
169
+ def _render(spec: registry.ActionSpec, value, args, *, as_json: bool) -> None:
170
+ func = getattr(render, spec.render)
171
+ if spec.render in ("list_view", "history_view"):
172
+ func(value, getattr(args, "q", None), as_json=as_json)
173
+ else:
174
+ func(value, as_json=as_json)
175
+
176
+
177
+ def _params(
178
+ command: str, args: argparse.Namespace, repo: Path, me, spec: registry.ActionSpec
179
+ ) -> dict[str, Any]:
180
+ """按 `spec.params` 从 args 里挑;**不许把整个 args 透传进业务层**。"""
181
+ out: dict[str, Any] = {}
182
+ for key in spec.params:
183
+ out[key] = getattr(args, key, None)
184
+ if command == "new":
185
+ created = out.get("created") or now()
186
+ if getattr(args, "created", None):
187
+ created = to_utc(args.created, field="--created")
188
+ out["created"] = created
189
+ if not out.get("tid"):
190
+ out["tid"] = make_id(
191
+ out.get("title") or "", _task_snapshot(repo, me), created
192
+ )
193
+ out["tags"] = tuple(out.get("tags") or ())
194
+ if command == "close":
195
+ out["finished"] = (
196
+ to_utc(args.finished, field="--finished")
197
+ if getattr(args, "finished", None)
198
+ else now()
199
+ )
200
+ out["tags"] = tuple(out.get("tags") or ())
201
+ return out
202
+
203
+
204
+ def _task_snapshot(repo: Path, me) -> dict[str, Any]:
205
+ """建任务查重要用的在办任务快照(**锁外**只读,只用于生成不撞的号)。"""
206
+ from ..core.paths import POOL_REL, shard_rel
207
+ from ..store.task_store import TaskStore
208
+
209
+ tasks: dict[str, Any] = {}
210
+ tasks.update(TaskStore(repo, POOL_REL).all())
211
+ tasks.update(TaskStore(repo, shard_rel(me.email)).all())
212
+ return tasks
213
+
214
+
215
+ def _repo_of(args: argparse.Namespace) -> Path:
216
+ """工作区根:`cli.main` 已经解析好挂在 `args.repo` 上(定位只做一次)。
217
+
218
+ 直接调 runner(测试 / 内嵌调用)时没挂过 ⇒ 现解一次,规则与 cli 一致。
219
+ """
220
+ repo = getattr(args, "repo", None)
221
+ if repo:
222
+ return Path(repo)
223
+ from .cli import resolve_repo # 延迟导入:cli 在模块层 import runner
224
+
225
+ return resolve_repo(None)
File without changes