task-ledger 1.3.1 → 1.3.3
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/README.md +1 -1
- package/SKILL.md +3 -3
- package/package.json +1 -1
- package/references/state-model.md +1 -1
- package/references/troubleshooting.md +4 -1
- package/scripts/ledgerkit/actions/bootstrap.py +10 -7
- package/scripts/ledgerkit/actions/guard.py +3 -7
- package/scripts/ledgerkit/actions/query_history.py +1 -2
- package/scripts/ledgerkit/actions/validate.py +13 -1
- package/scripts/ledgerkit/core/identity.py +3 -19
- package/scripts/ledgerkit/core/roots.py +74 -0
- package/scripts/ledgerkit/env.py +1 -1
- package/scripts/ledgerkit/shell/cli.py +12 -12
- package/scripts/ledgerkit/shell/render.py +6 -0
- package/scripts/ledgerkit/shell/runner.py +8 -1
- package/scripts/ledgerkit/store/owners_index.py +2 -23
- package/scripts/ledgerkit/store/task_store.py +2 -5
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
直接跑:
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
python3 <SKILL.md 所在目录>/scripts/progress.py <子命令> --repo
|
|
34
|
+
python3 <SKILL.md 所在目录>/scripts/progress.py <子命令> --repo <工作区根>
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
子命令:`new` `claim` `set` `log` `close` `list` `history` `show` `tags` `validate`。命令参考(参数 / 一句话 / 举例)、任务生命周期、纪律(数据完整性 / 人类授权 / 故障处置)全在 `SKILL.md`;参数细节看 `<子命令> --help`(不要凭记忆编)。
|
package/SKILL.md
CHANGED
|
@@ -131,10 +131,10 @@ allowed-tools: Read,Edit,Bash,Grep
|
|
|
131
131
|
## 怎么调用
|
|
132
132
|
|
|
133
133
|
```bash
|
|
134
|
-
python3 <本 SKILL.md 所在目录>/scripts/progress.py <子命令> --repo
|
|
134
|
+
python3 <本 SKILL.md 所在目录>/scripts/progress.py <子命令> --repo <工作区根>
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
- 定位项目:`--repo
|
|
137
|
+
- 定位项目:`--repo <工作区根>`(**直接含有 `ledger/` 的那个目录**)给了就校验后用;不给就从当前目录逐级向上找。`ledger/` 本身、以及它下面的任何子目录都不是根,指过去会被直接拒绝(判据只看位置,不看目录里有哪些文件 —— 项目根自带 `pool.json`、`history/`、`worklog/` 都不影响)。
|
|
138
138
|
- `--no-sync`(放在子命令**前**):用户明确要求「先别同步」时才用;之后自己 `git add ledger && commit && push`。
|
|
139
139
|
- `--json` 只对只读命令(`list` / `show` / `history` / `validate`)有意义;写命令不接收 `--json`,传了是用法错。`--json` 里的时间字段是 UTC,人话输出是本地时间。
|
|
140
140
|
- 命令回执只用来确认生效;任务状态以 `show` / `list` 为准。
|
|
@@ -193,7 +193,7 @@ python3 <本 SKILL.md 所在目录>/scripts/progress.py <子命令> --repo <当
|
|
|
193
193
|
|
|
194
194
|
### 人类授权(跑之前问不问)
|
|
195
195
|
|
|
196
|
-
项目没有 `ledger/` ⇒ 先问「要不要建个台账记这活?」,不许自建。用户同意 ⇒ 直接跑第一条写命令,且**必须带 `--repo
|
|
196
|
+
项目没有 `ledger/` ⇒ 先问「要不要建个台账记这活?」,不许自建。用户同意 ⇒ 直接跑第一条写命令,且**必须带 `--repo <工作区根>`**(根是直接含有 `ledger/` 的那个目录,`ledger/` 本身不是根、指到它会被拒绝):不带时脚本只从当前目录逐级往上找第一个含 `ledger/` 的目录,一个都没有就退 1 报「找不到台账」,绝不就地建;台账由这条写命令顺带建出来,没有 `init` 命令。之后可以不带 `--repo`,但查找是**逐级往上**的 —— 在本项目的子目录里跑可能命中上层项目的台账,拿不准就一直带 `--repo <工作区根>`。
|
|
197
197
|
|
|
198
198
|
| 命令 | 档位 | 要求 |
|
|
199
199
|
|---|---|---|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "task-ledger",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
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",
|
|
@@ -35,7 +35,7 @@ close → 历史层 + 封卷 + 移出自己名下
|
|
|
35
35
|
|
|
36
36
|
- 主键 = **git 邮箱**(`git var GIT_COMMITTER_IDENT` 里的 email,退回 `git config user.email`),归一化(去空白 + 小写)后比对。
|
|
37
37
|
- 读不到邮箱**不报错**:走兜底链(`USER`/`LOGNAME` → `getpass` → 主机名 → 字面 `default`)并出一条提示,命令照常跑完。看到提示要告诉用户配好 git,否则任务记到兜底身份名下。
|
|
38
|
-
-
|
|
38
|
+
- 全站只认 git 邮箱这一个身份键;`list --owner` / `history --owner` 是归一化后的**精确匹配**,没有 `me` 缩写,「自己」用 `--mine`。
|
|
39
39
|
- `close` / `log` 只对自己名下的任务生效,不在名下直接拒绝;`set --owner ""` 对已在公共池里的任务是幂等空转(退 0);`set --owner <自己>` 会把公共池里的任务认领到自己名下。
|
|
40
40
|
|
|
41
41
|
## 历史层
|
|
@@ -54,7 +54,10 @@
|
|
|
54
54
|
- **「本地有未推送的台账提交:… 处理:先 git push;或者加 --no-sync 只写本地(之后要记得推)」**:没有「自动补推再写」这一步,就是让你先推。
|
|
55
55
|
- **「有还没入库的同名台账文件(提交会被它们挡住)」**:同名文件还没 `git add`,先入库。
|
|
56
56
|
- **「仓库处于中间态(rebase-merge / rebase-apply / MERGE_HEAD):先跑 git status 看提示,完成或中止它(git rebase --abort / git merge --abort)再跑本命令」**:上次 rebase / merge 没收尾。
|
|
57
|
-
- **「找不到台账:从当前目录往上都没有 ledger/ 目录」**:`--repo`
|
|
57
|
+
- **「找不到台账:从当前目录往上都没有 ledger/ 目录」**:`--repo` 指对**工作区根**(直接含有 `ledger/` 的那个目录);第一条写命令会自动把 `ledger/` 建出来,不用先初始化。
|
|
58
|
+
- **「--repo 指到了台账目录本身」**:你把 `ledger/` 当成了根;它的上一级才是工作区根,按报错里给的路径改。
|
|
59
|
+
- **「--repo 指到了台账目录里面」**:你指到了 `ledger/` 下面的某个子目录(如 `ledger/worklog`);工作区根是那个 `ledger/` 的上一级,按报错里给的路径改。
|
|
60
|
+
- **「--repo 指向的不是一个目录」**:路径写错或不存在,要指到工作区根。
|
|
58
61
|
|
|
59
62
|
### 并发 / 锁(退 1 或提示)
|
|
60
63
|
|
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
"""开工前的自举:台账文件不存在就建,没登记过就把自己登记进去、建自己的分片。
|
|
2
2
|
|
|
3
3
|
这三份由**外壳**单独一个 `atom` 包住,它自己不开事务、不拿锁。
|
|
4
|
-
|
|
4
|
+
不参与「提交说明 / where / 打印」。
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
-
from ..core.identity import Identity
|
|
11
|
+
from ..core.identity import Identity
|
|
12
12
|
from ..core.paths import LEDGER_DIR, OWNERS_DIR, POOL_REL, shard_rel, to_repo_rel
|
|
13
|
+
from ..core.roots import as_root
|
|
13
14
|
from ..store.atomic import ensure_dir
|
|
14
15
|
from ..store.owners_index import OwnersIndex
|
|
15
16
|
from ..store.task_store import TaskStore
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def bootstrap(repo: Path, me: Identity) -> Identity:
|
|
19
|
-
"""写序:`owners/` 目录 → 池 → 索引(登记自己)→
|
|
20
|
-
|
|
20
|
+
"""写序:`owners/` 目录 → 池 → 索引(登记自己)→ 我的分片。已登记 ⇒ 零写。"""
|
|
21
|
+
repo = as_root(
|
|
22
|
+
repo, source="工作区根"
|
|
23
|
+
) # 门 3:写前最后确认,绝不在 ledger/ 里再套一层
|
|
24
|
+
ensure_dir(repo / LEDGER_DIR) # 曾是 to_repo_rel(LEDGER_DIR) ⇒ 会拼出 ledger/ledger
|
|
21
25
|
ensure_dir(repo / to_repo_rel(OWNERS_DIR))
|
|
22
26
|
TaskStore(repo, POOL_REL).ensure_file()
|
|
23
27
|
index = OwnersIndex(repo)
|
|
24
|
-
_,
|
|
28
|
+
_, _ = index.ensure(me) # 登记自己(已登记则零写)
|
|
25
29
|
TaskStore(repo, shard_rel(me.email), identity=me).ensure_file()
|
|
26
|
-
|
|
27
|
-
return with_aliases(me, aliases) if aliases else me
|
|
30
|
+
return me
|
|
@@ -20,14 +20,10 @@ from .query import ListQuery
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def current_identity(repo: Path) -> Identity:
|
|
23
|
-
"""env
|
|
23
|
+
"""env 的本机身份(不写盘、不登记)。全站只认邮箱这一个身份键。"""
|
|
24
24
|
from .. import env
|
|
25
|
-
from ..core.identity import with_aliases
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
record = OwnersIndex(repo).get(me.email) or {}
|
|
29
|
-
aliases = tuple(record.get("aliases") or ())
|
|
30
|
-
return with_aliases(me, aliases) if aliases else me
|
|
26
|
+
return env.current(repo)
|
|
31
27
|
|
|
32
28
|
|
|
33
29
|
def require_task(tasks: Mapping[str, Any], tid: str, *, where: Path) -> dict[str, Any]:
|
|
@@ -72,7 +68,7 @@ def status_of(task: Mapping[str, Any]) -> str:
|
|
|
72
68
|
|
|
73
69
|
|
|
74
70
|
def is_mine(task: Mapping[str, Any], me: Identity) -> bool:
|
|
75
|
-
"""
|
|
71
|
+
"""这条任务是不是我的(按邮箱判定)。"""
|
|
76
72
|
return is_me(me, owner_of(task) or "")
|
|
77
73
|
|
|
78
74
|
|
|
@@ -127,8 +127,7 @@ def query_history(repo: Path, me: Identity, q: HistoryQuery) -> HistoryResult:
|
|
|
127
127
|
records.append(row)
|
|
128
128
|
|
|
129
129
|
if q.mine:
|
|
130
|
-
wanted = {
|
|
131
|
-
wanted = {normalize(k) for k in wanted}
|
|
130
|
+
wanted = {normalize(me.email)}
|
|
132
131
|
records = [r for r in records if normalize(r.get("owner") or "") in wanted]
|
|
133
132
|
elif q.owner:
|
|
134
133
|
key = normalize(q.owner)
|
|
@@ -14,7 +14,13 @@ from pathlib import Path
|
|
|
14
14
|
from ..core import history_md
|
|
15
15
|
from ..core.identity import Identity
|
|
16
16
|
from ..core.labels import STATUSES
|
|
17
|
-
from ..core.paths import
|
|
17
|
+
from ..core.paths import (
|
|
18
|
+
LEDGER_DIR,
|
|
19
|
+
LEGACY_HISTORY_REL,
|
|
20
|
+
OWNERS_REL,
|
|
21
|
+
POOL_REL,
|
|
22
|
+
to_repo_rel,
|
|
23
|
+
)
|
|
18
24
|
from ..core.time import now
|
|
19
25
|
from ..core.worklog_md import (
|
|
20
26
|
duplicated_sections,
|
|
@@ -199,6 +205,12 @@ def validate(repo: Path, me: Identity, deep: bool = False) -> ValidationReport:
|
|
|
199
205
|
notices.append(f"存量平铺文件 {rel(repo, legacy)} 只读兼容,不会重写。")
|
|
200
206
|
if not (repo / to_repo_rel(OWNERS_REL)).exists():
|
|
201
207
|
notices.append("还没有 owners.json(索引):跑一条写命令会自动建。")
|
|
208
|
+
stray = repo / LEDGER_DIR / LEDGER_DIR
|
|
209
|
+
if stray.is_dir():
|
|
210
|
+
problems.append(
|
|
211
|
+
f"{rel(repo, stray)} 是错位的台账目录(根被当成 ledger/ 时写出来的幻影);"
|
|
212
|
+
"确认哪份是真台账后删掉多余的,命令一律带 --repo <工作区根>。"
|
|
213
|
+
)
|
|
202
214
|
|
|
203
215
|
return ValidationReport(
|
|
204
216
|
problems=tuple(problems),
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from dataclasses import dataclass
|
|
5
|
+
from dataclasses import dataclass
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
@dataclass(frozen=True)
|
|
@@ -11,7 +11,6 @@ class Identity:
|
|
|
11
11
|
|
|
12
12
|
email: str
|
|
13
13
|
name: str = ""
|
|
14
|
-
aliases: tuple[str, ...] = ()
|
|
15
14
|
|
|
16
15
|
def label(self) -> str:
|
|
17
16
|
"""显示名:没名字就用邮箱。"""
|
|
@@ -23,21 +22,6 @@ def normalize(who: str) -> str:
|
|
|
23
22
|
return (who or "").strip().lower()
|
|
24
23
|
|
|
25
24
|
|
|
26
|
-
def keys(identity: Identity) -> tuple[str, ...]:
|
|
27
|
-
"""这个身份的全部可比标识:自己 + aliases(去重、保持顺序)。"""
|
|
28
|
-
out: list[str] = []
|
|
29
|
-
for value in (identity.email, *identity.aliases):
|
|
30
|
-
key = normalize(value)
|
|
31
|
-
if key and key not in out:
|
|
32
|
-
out.append(key)
|
|
33
|
-
return tuple(out)
|
|
34
|
-
|
|
35
|
-
|
|
36
25
|
def is_me(identity: Identity, who: str | None) -> bool:
|
|
37
|
-
"""`who
|
|
38
|
-
return normalize(who or "")
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def with_aliases(identity: Identity, aliases: tuple[str, ...]) -> Identity:
|
|
42
|
-
"""换一个带 aliases 的同身份对象(索引里读到别名后由外壳装配)。"""
|
|
43
|
-
return replace(identity, aliases=tuple(aliases))
|
|
26
|
+
"""`who`(邮箱)是不是这个人。全站只认邮箱这一个身份键。"""
|
|
27
|
+
return normalize(who or "") == normalize(identity.email)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""工作区根的判定:哪个目录才是「直接含有 `ledger/` 的那个根」。
|
|
2
|
+
|
|
3
|
+
全仓唯一实现 —— `shell/` 与 `actions/` 都调这里,不各写一份(否则口径会漂)。
|
|
4
|
+
判据是**结构**(含不含 `ledger/`、自己在不在 `ledger/` 里),不看目录里有哪些文件:
|
|
5
|
+
项目根自带 `pool.json`、`history/` + `worklog/`、`owners.json` + `owners/` 这类同名文件
|
|
6
|
+
或目录都很常见,按「里面有什么」判会把正常项目根误当成台账目录(2026-10-25 改)。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ..errors import Fail
|
|
14
|
+
from .paths import LEDGER_DIR
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _under_ledger(p: Path) -> bool:
|
|
18
|
+
"""`p` 自己叫 `ledger`,或它落在某个 `ledger/` 目录里面。"""
|
|
19
|
+
if p.name == LEDGER_DIR.name:
|
|
20
|
+
return True
|
|
21
|
+
return any(anc.name == LEDGER_DIR.name for anc in p.parents)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def as_root(repo: str | Path, *, source: str = "--repo") -> Path:
|
|
25
|
+
"""校验「工作区根」,只看结构:
|
|
26
|
+
|
|
27
|
+
- 直接含 `ledger/` ⇒ 就是根。哪怕项目根自己叫 `ledger`、哪怕它自己也有 `pool.json`;
|
|
28
|
+
- 不含 `ledger/`、但自己叫 `ledger` 或落在某个 `ledger/` 下 ⇒ 是台账目录 / 台账里面,拒;
|
|
29
|
+
- 其余(含全新空目录)⇒ 收,第一条写命令会在它下面建 `ledger/`。
|
|
30
|
+
"""
|
|
31
|
+
p = Path(repo).expanduser()
|
|
32
|
+
if not p.is_dir():
|
|
33
|
+
raise Fail(
|
|
34
|
+
f"{source} 指向的不是一个目录:{p}\n"
|
|
35
|
+
f" 处理:指向工作区根(直接含有 ledger/ 的那个目录,不是 ledger/ 本身)。"
|
|
36
|
+
)
|
|
37
|
+
if (p / LEDGER_DIR).is_dir():
|
|
38
|
+
return p.resolve()
|
|
39
|
+
if _under_ledger(p):
|
|
40
|
+
anc = next(a for a in (p, *p.parents) if a.name == LEDGER_DIR.name)
|
|
41
|
+
root = anc.parent
|
|
42
|
+
if anc is p:
|
|
43
|
+
what = f"指到了台账目录本身:{p}"
|
|
44
|
+
why = f"它的上一级 {root} 才是工作区根 —— 在它下面跑会再建出 {p / LEDGER_DIR}。"
|
|
45
|
+
else:
|
|
46
|
+
what = f"指到了台账目录里面:{p}"
|
|
47
|
+
why = f"它已经在 {anc} 这个台账目录底下 —— 工作区根是 {anc.parent}。"
|
|
48
|
+
raise Fail(
|
|
49
|
+
f"{source} {what}\n"
|
|
50
|
+
f" {why}\n"
|
|
51
|
+
f" 处理:把 {source} 指到 {root}\n"
|
|
52
|
+
f" 例:progress.py list --repo {root}"
|
|
53
|
+
)
|
|
54
|
+
return p.resolve()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def root_of(start: str | Path) -> Path | None:
|
|
58
|
+
"""从 `start` 往上定位工作区根;找不到返回 None。
|
|
59
|
+
|
|
60
|
+
① 自己在台账目录里(名字叫 `ledger`、且里面没有再套一层 `ledger/`)⇒ 根是它的上一级。
|
|
61
|
+
按位置判,不看里面有什么:项目根自带 `history/` + `worklog/` 不会被误当成台账目录。
|
|
62
|
+
② 兜底:从**自己**开始往上,取最近的、直接含 `ledger/` 的目录。`start` 必须优先于
|
|
63
|
+
祖先,否则工作区根恰好叫 `ledger` 时会被父目录抢走(2026-10-25 改)。
|
|
64
|
+
"""
|
|
65
|
+
here = Path(start).resolve()
|
|
66
|
+
for anc in (here, *here.parents):
|
|
67
|
+
if anc.name == LEDGER_DIR.name and not (anc / LEDGER_DIR).is_dir():
|
|
68
|
+
root = anc.parent
|
|
69
|
+
if (root / LEDGER_DIR).is_dir():
|
|
70
|
+
return root
|
|
71
|
+
for cand in (here, *here.parents):
|
|
72
|
+
if (cand / LEDGER_DIR).is_dir():
|
|
73
|
+
return cand
|
|
74
|
+
return None
|
package/scripts/ledgerkit/env.py
CHANGED
|
@@ -90,4 +90,4 @@ def current(repo: Path | None = None) -> Identity:
|
|
|
90
90
|
"配好 git config user.email 再跑,任务才会记到正确的名下。"
|
|
91
91
|
)
|
|
92
92
|
name = git_name(repo) or email
|
|
93
|
-
return Identity(email=normalize(email), name=name.strip()
|
|
93
|
+
return Identity(email=normalize(email), name=name.strip())
|
|
@@ -12,7 +12,7 @@ import sys
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
from ..actions.query import HistoryQuery, ListQuery
|
|
15
|
-
from ..core.
|
|
15
|
+
from ..core.roots import as_root, root_of
|
|
16
16
|
from ..core.time import to_utc
|
|
17
17
|
from ..errors import BUSINESS, USAGE, Fail
|
|
18
18
|
from . import render, runner
|
|
@@ -27,7 +27,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
27
27
|
prog="progress.py", description="任务台账(本地文件 + git 同步)"
|
|
28
28
|
)
|
|
29
29
|
parser.add_argument(
|
|
30
|
-
"--repo", help="
|
|
30
|
+
"--repo", help="工作区根(直接含有 ledger/ 的那个目录;默认按当前目录向上找)"
|
|
31
31
|
)
|
|
32
32
|
parser.add_argument(
|
|
33
33
|
"--no-sync", action="store_true", help="只写本地,不取最新 / 不提交 / 不推送"
|
|
@@ -169,22 +169,22 @@ def _split_tags(value: str | None) -> tuple[str, ...]:
|
|
|
169
169
|
|
|
170
170
|
|
|
171
171
|
def resolve_repo(arg: str | None, start: Path | None = None) -> Path:
|
|
172
|
-
"""`--repo`
|
|
172
|
+
"""`--repo` 给了就校验后用;没给就从 `start`(默认 cwd)往上找。
|
|
173
173
|
|
|
174
174
|
找不到 ⇒ Fail:绝不在当前目录自作主张建台账(2026-10-22 设计定稿)。
|
|
175
175
|
建台账是第一条写命令的事,不需要、也没有 `init` 子命令。
|
|
176
176
|
"""
|
|
177
177
|
if arg:
|
|
178
|
-
return
|
|
178
|
+
return as_root(arg, source="--repo")
|
|
179
179
|
here = (start or Path.cwd()).resolve()
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
180
|
+
root = root_of(here)
|
|
181
|
+
if root is None:
|
|
182
|
+
raise Fail(
|
|
183
|
+
"找不到台账:从当前目录往上都没有 ledger/ 目录。\n"
|
|
184
|
+
" 处理:用 --repo <工作区根> 指定(根是直接含 ledger/ 的那个目录,不是 ledger/ 本身);\n"
|
|
185
|
+
" 或先 cd 到项目根再跑。第一条写命令会自动把 ledger/ 建出来,不用先初始化。"
|
|
186
|
+
)
|
|
187
|
+
return root
|
|
188
188
|
|
|
189
189
|
|
|
190
190
|
def _repo_of(args: argparse.Namespace) -> Path:
|
|
@@ -41,6 +41,12 @@ def fail_message(exc: BaseException, *, title: str = "错误") -> None:
|
|
|
41
41
|
_err(f"{title}:{exc}")
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def repo_banner(repo: Any, *, machine: bool) -> None:
|
|
45
|
+
"""只读命令开头自报「读的是哪个台账」(输出是 JSON 时不打,否则污染机器可读结果)。"""
|
|
46
|
+
if not machine:
|
|
47
|
+
_out(f"台账:{repo}")
|
|
48
|
+
|
|
49
|
+
|
|
44
50
|
def _json(data: Any) -> None:
|
|
45
51
|
sys.stdout.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
46
52
|
|
|
@@ -91,6 +91,11 @@ def _run(command: str, args: argparse.Namespace, log: dirty.WriteLog) -> int:
|
|
|
91
91
|
# 只读:不建 GitRepo、不进事务、不提交
|
|
92
92
|
if spec.readonly:
|
|
93
93
|
value = spec.func(repo, me, **params)
|
|
94
|
+
# 先自报读的是哪个台账:`--repo` 指错目录时输出会变成一片「没有任务」,不带这一行
|
|
95
|
+
# 就完全看不出读错了地方。`--count-only` 也出 JSON,同样不打(2026-10-25 改)
|
|
96
|
+
render.repo_banner(
|
|
97
|
+
repo, machine=as_json or bool(getattr(args, "count_only", False))
|
|
98
|
+
)
|
|
94
99
|
_render(spec, value, args, as_json=as_json)
|
|
95
100
|
if getattr(value, "problems", ()):
|
|
96
101
|
return BUSINESS # validate 有 problems ⇒ 退出码 1(§六)
|
|
@@ -219,7 +224,9 @@ def _repo_of(args: argparse.Namespace) -> Path:
|
|
|
219
224
|
"""
|
|
220
225
|
repo = getattr(args, "repo", None)
|
|
221
226
|
if repo:
|
|
222
|
-
|
|
227
|
+
from ..core.roots import as_root # 门 2 兜底:绕过 cli 直调 runner 也不收错位根
|
|
228
|
+
|
|
229
|
+
return as_root(repo, source="--repo")
|
|
223
230
|
from .cli import resolve_repo # 延迟导入:cli 在模块层 import runner
|
|
224
231
|
|
|
225
232
|
return resolve_repo(None)
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""索引 `owners.json`:谁是登记过的人、各自的分片文件是哪个、新人的登记与改名。
|
|
2
2
|
|
|
3
3
|
**分片只认索引,不扫目录** —— 唯一例外是全仓仅此一处的 `disk_shards()`,它只给 `validate` 用。
|
|
4
|
-
`ensure` 是 `aliases` 的唯一写入点。
|
|
5
4
|
"""
|
|
6
5
|
|
|
7
6
|
from __future__ import annotations
|
|
@@ -32,7 +31,7 @@ class OwnersIndex:
|
|
|
32
31
|
return self._store.all()
|
|
33
32
|
|
|
34
33
|
def ensure(self, me: Identity) -> tuple[bool, dict[str, Any]]:
|
|
35
|
-
"""登记 / 补 `joined`
|
|
34
|
+
"""登记 / 补 `joined`。`name` 只在首次登记时写入,之后不回写(名字是给你做对照的被动字段,不该被任何逻辑读)。返回 (这次写了没有, 记录)。"""
|
|
36
35
|
key = normalize(me.email)
|
|
37
36
|
with self._open() as session:
|
|
38
37
|
data = session.data
|
|
@@ -43,23 +42,11 @@ class OwnersIndex:
|
|
|
43
42
|
"file": shard_rel(key).name,
|
|
44
43
|
"name": me.name,
|
|
45
44
|
"joined": now(),
|
|
46
|
-
"aliases": _merge_aliases([], me.aliases),
|
|
47
45
|
}
|
|
48
46
|
workers[key] = record
|
|
49
47
|
session.mark()
|
|
50
48
|
return True, dict(record)
|
|
51
|
-
|
|
52
|
-
if me.name and record.get("name") != me.name:
|
|
53
|
-
record["name"] = me.name
|
|
54
|
-
changed = True
|
|
55
|
-
known = list(record.get("aliases") or [])
|
|
56
|
-
aliases = _merge_aliases(known, me.aliases) # 只增不删
|
|
57
|
-
if aliases != known:
|
|
58
|
-
record["aliases"] = aliases
|
|
59
|
-
changed = True
|
|
60
|
-
if changed:
|
|
61
|
-
session.mark()
|
|
62
|
-
return changed, dict(record)
|
|
49
|
+
return False, dict(record)
|
|
63
50
|
|
|
64
51
|
def shards(self) -> dict[str, Path]:
|
|
65
52
|
"""身份 → 分片绝对路径(只信索引)。"""
|
|
@@ -125,11 +112,3 @@ class _IndexSession:
|
|
|
125
112
|
self.store._flush(self.data) # 同一个包内的受控写回口
|
|
126
113
|
|
|
127
114
|
|
|
128
|
-
def _merge_aliases(known: list, extra: tuple[str, ...] | list[str]) -> list[str]:
|
|
129
|
-
"""把新别名并进去,只增不删、去重保序。"""
|
|
130
|
-
out = list(known)
|
|
131
|
-
for alias in extra or ():
|
|
132
|
-
key = normalize(alias)
|
|
133
|
-
if key and key not in out:
|
|
134
|
-
out.append(key)
|
|
135
|
-
return out
|
|
@@ -22,10 +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`(只存邮箱;显示名在索引里)。"""
|
|
26
26
|
if self.identity is not None:
|
|
27
|
-
data["identity"] = {
|
|
28
|
-
"email": self.identity.email,
|
|
29
|
-
"name": self.identity.name,
|
|
30
|
-
}
|
|
27
|
+
data["identity"] = {"email": self.identity.email}
|
|
31
28
|
super()._flush(data)
|