task-ledger 1.2.9 → 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.
- package/README.md +29 -245
- package/SKILL.md +173 -88
- package/package.json +22 -22
- package/references/log-note.md +3 -1
- package/references/state-model.md +29 -27
- package/references/troubleshooting.md +98 -25
- package/scripts/ledgerkit/__init__.py +0 -0
- package/scripts/ledgerkit/actions/__init__.py +0 -0
- package/scripts/ledgerkit/actions/atom.py +154 -0
- package/scripts/ledgerkit/actions/bootstrap.py +27 -0
- package/scripts/ledgerkit/actions/claim.py +82 -0
- package/scripts/ledgerkit/actions/close_task.py +180 -0
- package/scripts/ledgerkit/actions/guard.py +140 -0
- package/scripts/ledgerkit/actions/handoff.py +74 -0
- package/scripts/ledgerkit/actions/list_tasks.py +53 -0
- package/scripts/ledgerkit/actions/log_progress.py +60 -0
- package/scripts/ledgerkit/actions/new_task.py +120 -0
- package/scripts/ledgerkit/actions/query.py +33 -0
- package/scripts/ledgerkit/actions/query_history.py +173 -0
- package/scripts/ledgerkit/actions/registry.py +82 -0
- package/scripts/ledgerkit/actions/release.py +51 -0
- package/scripts/ledgerkit/actions/result.py +88 -0
- package/scripts/ledgerkit/actions/set_task.py +43 -0
- package/scripts/ledgerkit/actions/show_task.py +27 -0
- package/scripts/ledgerkit/actions/validate.py +224 -0
- package/scripts/ledgerkit/core/__init__.py +0 -0
- package/scripts/ledgerkit/core/history_md.py +350 -0
- package/scripts/ledgerkit/core/identity.py +43 -0
- package/scripts/ledgerkit/core/ids.py +58 -0
- package/scripts/ledgerkit/core/labels.py +79 -0
- package/scripts/ledgerkit/core/paths.py +93 -0
- package/scripts/ledgerkit/core/time.py +102 -0
- package/scripts/ledgerkit/core/worklog_md.py +232 -0
- package/scripts/ledgerkit/env.py +93 -0
- package/scripts/ledgerkit/errors.py +15 -0
- package/scripts/ledgerkit/notices.py +28 -0
- package/scripts/ledgerkit/shell/__init__.py +0 -0
- package/scripts/ledgerkit/shell/cli.py +259 -0
- package/scripts/ledgerkit/shell/gitops.py +655 -0
- package/scripts/ledgerkit/shell/merge.py +266 -0
- package/scripts/ledgerkit/shell/presync.py +23 -0
- package/scripts/ledgerkit/shell/render.py +165 -0
- package/scripts/ledgerkit/shell/runner.py +225 -0
- package/scripts/ledgerkit/store/__init__.py +0 -0
- package/scripts/ledgerkit/store/atomic.py +130 -0
- package/scripts/ledgerkit/store/dirty.py +108 -0
- package/scripts/ledgerkit/store/history_store.py +189 -0
- package/scripts/ledgerkit/store/json_store.py +166 -0
- package/scripts/ledgerkit/store/locks.py +64 -0
- package/scripts/ledgerkit/store/owners_index.py +135 -0
- package/scripts/ledgerkit/store/task_store.py +31 -0
- package/scripts/ledgerkit/store/worklog_store.py +168 -0
- package/scripts/progress.py +8 -3021
- package/references/taxonomy.md +0 -26
- package/references/worklog.md +0 -39
package/package.json
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
2
|
+
"name": "task-ledger",
|
|
3
|
+
"version": "1.3.1",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"skill",
|
|
7
|
+
"agent-skill",
|
|
8
|
+
"task-ledger",
|
|
9
|
+
"progress",
|
|
10
|
+
"todo"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/cq-guojia/dsh-task-ledger.git"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"SKILL.md",
|
|
19
|
+
"README.md",
|
|
20
|
+
"scripts/",
|
|
21
|
+
"assets/",
|
|
22
|
+
"references/"
|
|
23
|
+
]
|
|
24
24
|
}
|
package/references/log-note.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# 执行记录:什么算一个节点
|
|
2
2
|
|
|
3
3
|
> TL;DR:一件事「明天你想不起来、换个人接手看不出」就记一条;一次状态/结论变化对应一条,不对应一次工具调用。`--note` 一句 60 字内,必须含具体对象。
|
|
4
|
+
>
|
|
5
|
+
> 本节全部是 **agent 口径**:脚本只拒空值、折叠空白,**不校验长度、不校验内容** —— 别指望它帮你把关。
|
|
4
6
|
|
|
5
7
|
## 判据
|
|
6
8
|
|
|
@@ -33,7 +35,7 @@
|
|
|
33
35
|
正例:
|
|
34
36
|
|
|
35
37
|
```
|
|
36
|
-
--note "
|
|
38
|
+
--note "改了认领即 doing(claim.py:56)"
|
|
37
39
|
--note "验证通过:verify 25 项;下一步补 UI 的 overdue 标记"
|
|
38
40
|
--note "卡在远端鉴权,缺 deploy key,等用户给"
|
|
39
41
|
```
|
|
@@ -1,44 +1,46 @@
|
|
|
1
1
|
# 状态与时间模型
|
|
2
2
|
|
|
3
|
-
> TL;DR:状态只有 `open` / `doing`;`done` 由 `close`
|
|
3
|
+
> TL;DR:状态只有 `open` / `doing`;`done` 由 `close` 产生。认领即 `doing`(`claim` / `new --self` 都直接置)。`started` = 首次认领时间,释放不清、重新认领不刷新。`updated` 由 `new/claim/set/log` 刷新。身份由脚本自取,agent 不传 owner。
|
|
4
4
|
|
|
5
5
|
## 状态机
|
|
6
6
|
|
|
7
7
|
```
|
|
8
|
-
open
|
|
9
|
-
|
|
8
|
+
new → 公共池(open,没人认领)
|
|
9
|
+
new --self → 自己名下(doing,记 started)
|
|
10
|
+
公共池 ──claim──▶ 自己名下(doing;started 空则补 now)
|
|
11
|
+
set --owner "" → 公共池(open,没人认领;started 保留)
|
|
12
|
+
set --owner 别人 → 对方名下(预留转接通道,agent 不用)
|
|
13
|
+
close → 历史层 + 封卷 + 移出自己名下
|
|
10
14
|
```
|
|
11
15
|
|
|
12
|
-
- `
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
## 三个标记(看板计算,不是状态)
|
|
16
|
-
|
|
17
|
-
| 标记 | 依据 | 阈值 |
|
|
18
|
-
|---|---|---|
|
|
19
|
-
| 积压 | `created`(建了)至今仍 `open`(没人认领) | 满 30 天 |
|
|
20
|
-
| 逾期 | `started`(接任务)至今,中途更新不影响 | 满 14 天 |
|
|
21
|
-
| 停滞 | `updated`(最后推进)至今 | 满 3 天 |
|
|
22
|
-
|
|
23
|
-
- 真源:`packages/plugin/src/host/services/home.service.ts` 的 `BACKLOG_DAYS = 30` / `OVERDUE_DAYS = 14` / `STALE_DAYS = 3`;看板随响应下发三个天数,界面文案不硬编码。
|
|
24
|
-
- 脚本不算这三个值,`progress.json` 里也不存;agent 要回答「哪些停太久」时用 `list --json` 的 `created` / `updated` / `started` 与当前时间比。
|
|
25
|
-
- 老数据没有 `updated` 时,读侧回退到 `started`,再到 `created`。
|
|
16
|
+
- **`started` 是外卖计时器**:首次认领盖上之后,释放(`set --owner ""`)不清、重新认领不刷新 —— 逾期时钟从首次认领一口气跑到结案,转手不重置。防的是「眼看要超时,释放掉再认领回来洗时钟」;全链路没有重置 `started` 的命令(`claim` 的 `or now()` 只在它为空时才盖值;`set` 原样保留)。接手一个已超时的任务,一接单即逾期:单子已经迟了,谁端着谁负责;责任归属看执行记录流水(谁认领 / 谁释放逐笔有记录),计时器只回答「这单迟没迟」。
|
|
17
|
+
- 旧数据里的 `blocked`:读侧按 `doing` 算 —— `list --status doing` 能捞出,`--status blocked` 反而一条都匹配不到;没有归位命令,也不需要(只是读数)。
|
|
18
|
+
- 认领 / 释放 / 交接一律先写目标、再删原处,崩在中间会同一任务号短暂出现在两处 —— 重跑一次同一条命令即幂等收敛(`already_done`),或按 `validate` 的提示删掉多余那份。
|
|
26
19
|
|
|
27
20
|
## 时间
|
|
28
21
|
|
|
29
|
-
- **精度**:任务号、worklog
|
|
30
|
-
- **默认不传**:`new`
|
|
31
|
-
- **只在补录过去时间时传**:`new --created`、`close --finished`。格式 `YYYY-MM-DD[ HH:MM[:SS]]
|
|
32
|
-
|
|
33
|
-
- **筛选语义**:`list`
|
|
34
|
-
-
|
|
22
|
+
- **精度**:任务号、worklog 目录与文件名到分(`年月日-时分` / `YYYYMM`);其余记录到秒(`YYYY-MM-DD HH:MM:SS`)。
|
|
23
|
+
- **默认不传**:`new` / `claim` / `set` / `log` / `close` 都取当前时刻。
|
|
24
|
+
- **只在补录过去时间时传**:`new --created`、`close --finished`。格式 `YYYY-MM-DD[ HH:MM[:SS]]`,按本地墙钟给、脚本内部转 UTC。`new --created` 补录时 `updated` 一并等于该时间 —— 之后补一条 `log` 把 `updated` 刷回当前。
|
|
25
|
+
- 注意:任务号前缀与 worklog 月份目录按 **UTC** 推 —— UTC+8 下裸日期 `--created 2026-01-05` 存成 `2026-01-04 16:00:00`,任务号前缀变成 `20260104-1600`;临午夜补录要连时间一起传。
|
|
26
|
+
- **筛选语义**:`list` 的 `--since` / `--until` 按**创建时间**;`history` 的按**结案时间**(历史文件按结案月归档,按创建时间比较会漏「上月建、本月结」的记录)。只给日期时起 = 当天 `00:00:00`、止 = 当天 `23:59:59`(含当天)。
|
|
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` 命令的人话输出只展示其中几列:结案时间、负责人、标题。)
|
|
35
29
|
|
|
36
30
|
## 任务号
|
|
37
31
|
|
|
38
|
-
`年月日-时分` +
|
|
32
|
+
`年月日-时分` + 标题里的字母 / 数字 / 汉字 + 4 位随机码(如 `20261015-1430-登录接入-sso-7f3a`)。随机码必须保留:任务号在**公共池、我自己的分片**两处查重(并发安全闸门),并直接判**过程文档路径** `worklog/<创建月>/<去前缀名>.md` 是否已存在 —— 在办、已结案任务的文档都留在磁盘上,命中即号被占过(复用会撞同一篇文档、日后让 `close` 误判 `already_done` 变僵尸)。O(1),不必扫历史。想自己指定就传 `--id`(小写字母、数字、汉字、连字符)。
|
|
39
33
|
|
|
40
34
|
## 身份
|
|
41
35
|
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
- `
|
|
36
|
+
- 主键 = **git 邮箱**(`git var GIT_COMMITTER_IDENT` 里的 email,退回 `git config user.email`),归一化(去空白 + 小写)后比对。
|
|
37
|
+
- 读不到邮箱**不报错**:走兜底链(`USER`/`LOGNAME` → `getpass` → 主机名 → 字面 `default`)并出一条提示,命令照常跑完。看到提示要告诉用户配好 git,否则任务记到兜底身份名下。
|
|
38
|
+
- `owners.json` 里的 `aliases` 会并进身份比对;`list --owner` / `history --owner` 是归一化后的**精确匹配**,没有 `me` 缩写,「自己」用 `--mine`。
|
|
39
|
+
- `close` / `log` 只对自己名下的任务生效,不在名下直接拒绝;`set --owner ""` 对已在公共池里的任务是幂等空转(退 0);`set --owner <自己>` 会把公共池里的任务认领到自己名下。
|
|
40
|
+
|
|
41
|
+
## 历史层
|
|
42
|
+
|
|
43
|
+
- 按**结案时间**分月存放,9 列(creator 在 owner 之后);`close` 从任务直写分类 / 标签终值。
|
|
44
|
+
- 存量平铺历史文件只读兼容、永不重写;`history` 连同它在内、按下面的月份区间合并。
|
|
45
|
+
- **查询区间**:`history` 先由时间参数算出要读的月份文件,再逐个「存在才读、缺月跳过」,不扫磁盘。无时间参数默认最近 3 个自然月;`--months N`(近 N 月)与 `--since/--until`(结案时间闭区间)二选一;一次最多读 13 个历史文件(按月分文件,不区分连续/分散,只数总数),超出直接报错、不读任何文件。末列文档链接只按**文件名**消费(取 basename),链接里的 `../worklog/<年月>/` 前缀只为阅读器跳转服务,解析时忽略。
|
|
46
|
+
- `history --json` 直接按文件现状解析,旧行没有的列不编造;`close` 追加历史行时才会把旧行升级补上 creator。
|
|
@@ -1,37 +1,110 @@
|
|
|
1
1
|
# 排错
|
|
2
2
|
|
|
3
|
-
> TL;DR:非 0
|
|
3
|
+
> TL;DR:非 0 退出就停手、原样报告,不要绕过脚本改台账 JSON 或历史层。**退出码 0 也要看输出**:提交失败会退 1,推送失败仍退 0、原因只写在输出里。并发冲突重跑一次;同步交给脚本,只有提示说脚本已回滚、或说它不替你 rebase 时,才让用户手动合并。
|
|
4
4
|
|
|
5
5
|
## 退出码
|
|
6
6
|
|
|
7
7
|
| 码 | 含义 | 处置 |
|
|
8
8
|
|---|---|---|
|
|
9
|
-
| 0 |
|
|
10
|
-
| 1 | 业务错误(参数值 / 状态 /
|
|
11
|
-
| 2 |
|
|
12
|
-
| 3 |
|
|
9
|
+
| 0 | 成功(同步可能失败:输出里有 `提示:` 就要读) | — |
|
|
10
|
+
| 1 | 业务错误(参数值 / 状态 / 权限不对;**提交失败也是 1**) | 按提示纠正后重试;改不了的报告用户 |
|
|
11
|
+
| 2 | 命令行参数错误(argparse 自退) | 看 `<子命令> --help` 重新拼参数 |
|
|
12
|
+
| 3 | 未预期的内部失败(如台账 JSON 语法损坏、读命令撞上冲突标记) | 上报,不要重试;冲突标记先解冲突 |
|
|
13
13
|
| 130 | 被中断 | 重跑一次 |
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
-
|
|
15
|
+
> 两类退出码 1 不是故障:`validate` 查出问题(照实转述清单);提交阶段失败(文件已写下去,只差进 git,让用户先提交再重跑)。
|
|
16
|
+
|
|
17
|
+
## 常见错误 → 处置
|
|
18
|
+
|
|
19
|
+
引号内是提示原文的稳定片段(省略号处是变量)。按片段在输出里定位。
|
|
20
|
+
|
|
21
|
+
### 用法 / 参数类(退 1,改参数即可)
|
|
22
|
+
|
|
23
|
+
- **「set 必须带 --owner:空串 = 释放回公共池;对方身份 = 交接」**:`set` 只认 `--owner`,没有别的用法。
|
|
24
|
+
- **「任务号 X 不合法:只能用小写字母、数字、汉字和连字符」**:`--id` 传了大写 / 空格 / 符号。
|
|
25
|
+
- **「任务号 X 已经存在了(这不是重跑,是重号)」**:换个 `--id`,或不传让脚本自动生成。
|
|
26
|
+
- **「标题不能为空」/「--note 不能为空」/「结案要写一句「完成了什么」(--summary 不能为空)」**:必填项漏了。
|
|
27
|
+
- **「标题里没有可用字符(字母、数字或汉字),无法自动生成任务号。用 --id 指定一个」**:标题全是符号。
|
|
28
|
+
- **「标签最多 N 个,收到 M 个」/「太长:含中文时最多 N 个字」**:分类 / 标签超了上限(口径见 SKILL.md 的 new 参数节)。
|
|
29
|
+
- **「时间格式不对:(可用 YYYY-MM-DD、YYYY-MM-DD HH:MM、YYYY-MM-DD HH:MM:SS)」**:`--created` / `--finished` 的格式。
|
|
30
|
+
|
|
31
|
+
### 归属 / 状态类(退 1,先弄对归属)
|
|
32
|
+
|
|
33
|
+
- **「任务 X 还没有人认领(在公共池里),先 claim 再改」**:在动池里的任务。要接手先 `claim`。
|
|
34
|
+
- **「任务 X 不在我的分片里(只能给自己的任务记进展)」/「(只能结案自己的任务)」**:`log` / `close` 只能对自己名下的任务用;已结案的任务也会撞这条(结案后不在任何分片里)。
|
|
35
|
+
- **「任务 X 不在我的分片里,也没在公共池里」**:任务号打错,或任务已被别人认领。
|
|
36
|
+
- **「任务 X 已在 Y 的分片里(别人正在做),不能认领」/「任务 X 的负责人是 Y,不是你(Z)」**:动别人名下的任务一律拒绝。
|
|
37
|
+
- **「公共池里没有任务 X(已经被人认领或不存在)」**:`claim` 扑空。
|
|
38
|
+
- **「Y 还没在索引里登记过 ⇒ 不能交接给他」**:`set --owner <别人>` 指向一个没在 `owners.json` 登记过的身份。
|
|
39
|
+
- **「任务 X 在 Y 的分片里已经存在,且内容与本次不一致 ⇒ 拒绝交接」**:对方名下已有一份不一样的同名任务,交不过去。
|
|
40
|
+
- **换人只能「对方释放 → 你认领」**:`set --owner ""` 放回公共池,对方自己 `claim`;不要绕过脚本改 JSON。
|
|
41
|
+
|
|
42
|
+
### 台账本身有问题(退 1 / 退 3)
|
|
43
|
+
|
|
44
|
+
- **「任务 X 在公共池里但状态是 S(应为 open)—— 先跑 validate 看台账是不是乱了」**:池里混进了已认领的任务(多半搬移崩了一半)。跑 `validate` 看全部问题,确认哪份最新后删掉多余那份,或重跑一次对应命令(幂等)。
|
|
45
|
+
- **「X 的 N 不是对象(是 T),这份台账读不了」/「任务 X 在 Y 里不是一条对象,台账可能坏了」**:结构坏了 ⇒ **退 1**(业务错),按提示用 git 恢复(`git checkout -- <文件>`),不要手改。
|
|
46
|
+
- **「X 不是合法 JSON(第 N 行):…」**:语法坏了 ⇒ **退 3**。若文件里有 `<<<<<<<` 冲突标记(推送被拒后 rebase 没解完),先解冲突再重跑;`validate` 会把这种情况列为问题(退 1),别的读命令直接退 3。
|
|
47
|
+
- **「台账索引 owners.json 找不到了,但 owners/ 下还有任务文件」**:索引被手动删 / 改名。用 git 恢复 `owners.json` 再重试,程序不自动修复。
|
|
48
|
+
- **「任务号连续冲突 20 次,请检查台账是否异常」**:随机码连续撞号,先 `validate`。
|
|
49
|
+
- **「历史里有一条没有过程文档链接的旧行…请人工看一眼再重跑」**:`close` 时撞上解析不出来的历史行,人工确认那行后再跑。
|
|
50
|
+
|
|
51
|
+
### 写前被拦(guard 三查,退 1)
|
|
52
|
+
|
|
53
|
+
- **「本地有未提交的台账改动:… 处理:先提交它们(git add <文件> && git commit),再跑本命令」**:先提交再重跑;离线可加 `--no-sync` 只写本地。
|
|
54
|
+
- **「本地有未推送的台账提交:… 处理:先 git push;或者加 --no-sync 只写本地(之后要记得推)」**:没有「自动补推再写」这一步,就是让你先推。
|
|
55
|
+
- **「有还没入库的同名台账文件(提交会被它们挡住)」**:同名文件还没 `git add`,先入库。
|
|
56
|
+
- **「仓库处于中间态(rebase-merge / rebase-apply / MERGE_HEAD):先跑 git status 看提示,完成或中止它(git rebase --abort / git merge --abort)再跑本命令」**:上次 rebase / merge 没收尾。
|
|
57
|
+
- **「找不到台账:从当前目录往上都没有 ledger/ 目录」**:`--repo` 指对项目根;第一条写命令会自动把 `ledger/` 建出来,不用先初始化。
|
|
58
|
+
|
|
59
|
+
### 并发 / 锁(退 1 或提示)
|
|
60
|
+
|
|
61
|
+
- **「另一个会话正在同步同一份台账(已等 60 秒),请稍后重试」**:同仓库另一个 agent 正在写。等它跑完再重跑,不要并行重试。
|
|
62
|
+
- **「拿不到同步锁 …(本次未做任何写入)。处理:确认 git 目录可写;只需写本地文件可加 --no-sync,之后手动提交」**:锁文件是 git 公共目录里的 `dsh-task-ledger.sync.lock`,同仓库所有 worktree 共享;`--no-sync` 也跳不过这把锁。
|
|
63
|
+
- **「X 在本命令执行期间被改动过(可能是编辑器或 git 操作),已中止、未写入任何内容;重跑一次即可」**:写前发现目标文件被改,重跑。(目前只有 `close` 会做这项核对。)
|
|
64
|
+
|
|
65
|
+
### 写后提交 / 推送(退 1 或提示,文件都没丢)
|
|
66
|
+
|
|
67
|
+
- **「git add 失败:… 文件还在磁盘上(本次改动没丢)」**:按错误原话处理(常见:钩子 / 权限),再手动 `git add <文件> && git commit`。
|
|
68
|
+
- **「git commit 失败:… 文件已暂存、本次改动没丢」**:常见是 commit 检查钩子拒了消息,按提示处理后重跑。
|
|
69
|
+
- **「提示:没有上游分支,本地提交已保留、未推送」**:给分支设上游(`git push -u origin <分支>`)后重跑即可推走。
|
|
70
|
+
- **「提示:推送被拒(远端有新提交)… 且本地还有你自己的提交 ⇒ 不自动 rebase」**:脚本故意不动你的非台账提交。手动 `git pull --rebase && git push`;重推时它们会被一并推上去,自行确认。台账改动已提交在本地,不会丢。
|
|
71
|
+
- **「提示:推送被拒,本次台账改动已提交在本地、未推送」**:手动 `git pull --rebase && git push`(普通文本冲突自己解;脚本的「按任务号合并」只在它自动重试时发生)。
|
|
72
|
+
- **「提示:推送被拒后 rebase 撞上冲突,已按任务逐个标出冲突(标记里左边是远端、右边是本机)」**:同一任务被两边同时改。编辑该文件保留要的版本 → `git add` → `git rebase --continue`;不想合就 `git rebase --abort` 回到写之前。
|
|
73
|
+
- **「错误:自动 rebase 后回滚也失败了,仓库可能停在 rebase 中间态」**:**先别跑任何台账命令**。`git rebase --abort`;仍失败就看 `git status` 残留,必要时删 `.git/rebase-merge` / `.git/rebase-apply`。
|
|
74
|
+
- **「X 的手写改动与上游的最新版本冲突,冲突标记已留在工作树里」**:逐个编辑解决冲突 → `git add <文件>` → `git stash drop`,然后重跑。**顺序不能反**:先 drop 再解决,手写的那段就没了。
|
|
75
|
+
- **「提示:写前拉取失败(远端连不上),本次按本地最新内容写;推送也可能失败」**:网络问题,照写;稍后联网了重跑一次同步。
|
|
76
|
+
|
|
77
|
+
### 只提示、不拦(退出码 0)
|
|
78
|
+
|
|
79
|
+
- **「提示:读不到 git 身份(user.email),本次按兜底身份 X 记账」**:命令已成功。让用户配 `git config user.email`,否则任务记不到正确名下。
|
|
80
|
+
- **「提示:格式版本是 … 当前是 …;不拦,但字段可能有出入」**:数据是旧版本写的,功能照常;想统一就按提示升版本。
|
|
81
|
+
- **「提示:写前把本地快进到 …(N 个提交,仅 fast-forward)」**:纯告知。
|
|
82
|
+
- **「提示:写前快进到 … 没成功(工作树有未提交改动),改为只刷新本次要写的台账文件」**:没碍事,照写。
|
|
83
|
+
- **「提示:rebase 成功,但把你的未提交改动放回工作树时冲突了」**:冲突标记只在被点名的文件里,解完 `git add` 后重跑台账命令即可。
|
|
84
|
+
- **「提示:只写了本地文件、未签入 git(也未推送)」**:用了 `--no-sync`;之后自己 `git add ledger && commit && push`。
|
|
85
|
+
- **「提示:当前目录不是 git 仓库,台账只写了本地文件」**:没有 git 也能用;想纳入版本管理就 `git init`,否则继续本地写(多人各写各的,后写覆盖先写)。
|
|
86
|
+
- **「警告:历史表列数不是 9 且无法整体升级,本次按现有列数追加」**:历史文件是旧列数,本次照旧格式追加;要统一就手工升级表头(9 列见 state-model)。
|
|
87
|
+
- **「提示:任务 X 没有过程文档,已跳过封卷,历史末列留空」**:老数据没有 worklog 字段,结案照常,只是没有文档可封。
|
|
88
|
+
- **`无需重复:这次没有新的改动。`**(`claim` / `set` / `close` 的 already_done 通道):上次已经做成过,重跑是幂等空转;`close` 重跑还会顺手补齐上次崩在半途的封卷与移出。
|
|
89
|
+
- **`validate` 的 notices**(同号任务出现在多个文件、历史解析警告、worklog 仍是骨架等):只提示不判错,照实转述。
|
|
90
|
+
|
|
91
|
+
## validate 查什么(有问题 ⇒ 退出码 1)
|
|
92
|
+
|
|
93
|
+
两档:默认**浅度**只查格式与结构(账本 JSON + 当月历史 + 每篇过程文档的模板格式);加 **`--deep`** 才多读全部历史月份、并读正文判「还是不是空骨架」。
|
|
94
|
+
|
|
95
|
+
problems(任一即退 1):① 池里任务带着 owner;② 池里任务 status 不是 open/doing;③ 索引登记了身份但分片文件不在;④ 分片任务没有 owner;⑤ 分片任务 owner 与分片所属键不一致;⑥ 分片任务 status 非法;⑦ `owners/` 里有索引没登记的 `*.json`;⑧ 过程文档格式坏(第一行不是标题 / 头部缺「创建」行 / 前五节缺或顺序不对)。
|
|
96
|
+
notices(只提示):过程文档缺「执行记录」节或有重复的节、历史解析警告、历史有结案行但任务还在办、存量 `PROGRESS-HISTORY.md` 存在、还没有 `owners.json`;`--deep` 另加「过程文档还是空骨架」。
|
|
97
|
+
①~⑦ 里结构类的属人工裁决:读原文确认哪份是真的,删多余那份或把索引改对,**不要用脚本猜**。索引或任一 JSON 损坏 ⇒ 退 3。
|
|
98
|
+
|
|
99
|
+
## 写前取最新与写后同步(机制)
|
|
100
|
+
|
|
101
|
+
- 写命令全程持仓级锁(git 公共目录里的 `dsh-task-ledger.sync.lock`,同仓库所有 worktree 共享,最多等 60 秒;`--no-sync` 也跳不过这把锁)。只读命令不排队。
|
|
102
|
+
- **写前**:先 guard 三查 —— scope 内非 worklog 台账文件有未提交改动 / 有未推送的台账提交 / 有未入库的同名文件,命中即中止;再定向取最新:自举 scope(`owners.json` + `pool.json` + 我的分片)加动作 scope(`new` 加目标文件与当月 worklog 目录;`claim` / `set` 还加索引里其他人的全部分片;`close` 加当月 `history/`;任务没有 `worklog` 字段时退整个 `worklog/`)。干净就刷成远端最新,本地有未提交手写改动就 stash → 快进 → 叠回,冲突即中止。`worklog/` 目录只刷新本地已存在的文件(上游新增的不会自动补下来)。
|
|
103
|
+
- **写后**:只 `git add` 台账路径,然后 `commit --no-verify` —— 提交失败退 1;push 失败只提示、退 0,被拒时自动 `rebase --autostash` 合并一次:不同任务自动合并,同一任务两边都改留冲突标记让人定;本地有非台账提交时不 rebase。
|
|
104
|
+
- **回滚**:命令失败时,写前刷下来的内容按字节还原、本次新建的文件删掉;业务写的自补偿只有两处 —— `new`(删任务行 + 删文档)、`close`(三个文件按字节还原)。
|
|
105
|
+
- **`--no-sync` / `TASK_LEDGER_NO_SYNC=1`**:跳过 guard、取最新、提交与推送;自举照跑;有写入时会提示「只写了本地文件、未签入 git」。注意它挂在**顶层**:`progress.py --no-sync <子命令> …`,放子命令后面是用法错。
|
|
106
|
+
- **环境**:git ≥ 2.27(自动合并依赖 `rebase --autostash`)。不是 git 仓库 ⇒ 同步整体跳过、台账照写,并提示「只写了本地文件」;连 git 二进制都没装也照样写(身份走兜底链 + 提示)。
|
|
34
107
|
|
|
35
108
|
## 硬约束
|
|
36
109
|
|
|
37
|
-
不要直接编辑 `ledger/
|
|
110
|
+
不要直接编辑 `ledger/pool.json`、`ledger/owners.json`、`ledger/owners/*.json`、`ledger/history/*.md`;不要另建 worklog;不要手写「执行记录」节。
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""事务的唯一入口,20 行:把这次要碰的文件按固定顺序一次锁住。
|
|
2
|
+
|
|
3
|
+
只加锁,不管写盘、不碰 git、不打印。
|
|
4
|
+
|
|
5
|
+
三条硬约束:
|
|
6
|
+
1. 锁集**只许宽不许窄**;锁集以外的文件一个字节都不许写。
|
|
7
|
+
2. **所有判定依据必须在锁内重读** —— 锁外读到的快照只能用来决定"锁多宽"。
|
|
8
|
+
3. **任何失败补偿都不许再开一个 `atom`**(文件锁不可重入),只能在同一事务内做完。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..core.identity import Identity, normalize
|
|
20
|
+
from ..core.paths import (
|
|
21
|
+
HISTORY_DIR,
|
|
22
|
+
OWNERS_REL,
|
|
23
|
+
POOL_REL,
|
|
24
|
+
WORKLOG_DIR,
|
|
25
|
+
history_path_for,
|
|
26
|
+
shard_rel,
|
|
27
|
+
worklog_dir_for,
|
|
28
|
+
worklog_rel,
|
|
29
|
+
)
|
|
30
|
+
from ..store.locks import ledger_lock
|
|
31
|
+
from ..store.owners_index import OwnersIndex
|
|
32
|
+
from ..store.task_store import TaskStore
|
|
33
|
+
|
|
34
|
+
ACTIONS = ("bootstrap", "new", "claim", "set", "log", "close", "tags")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Scope:
|
|
39
|
+
"""这次要碰哪些文件。`files` 加锁(也逐个刷),`dirs` 只刷盘(展开成已存在的文件)。"""
|
|
40
|
+
|
|
41
|
+
files: tuple[Path, ...] = ()
|
|
42
|
+
dirs: tuple[Path, ...] = ()
|
|
43
|
+
|
|
44
|
+
def __or__(self, other: Scope) -> Scope:
|
|
45
|
+
return Scope(
|
|
46
|
+
files=_merge(self.files, other.files),
|
|
47
|
+
dirs=_merge(self.dirs, other.dirs),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _merge(a: tuple[Path, ...], b: tuple[Path, ...]) -> tuple[Path, ...]:
|
|
52
|
+
out: list[Path] = []
|
|
53
|
+
for item in (*a, *b):
|
|
54
|
+
if item not in out:
|
|
55
|
+
out.append(item)
|
|
56
|
+
return tuple(out)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _pool(repo: Path) -> Path:
|
|
60
|
+
return POOL_REL
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _shard(repo: Path, me: Identity) -> Path:
|
|
64
|
+
return shard_rel(me.email)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _other_shards(repo: Path, me: Identity) -> tuple[Path, ...]:
|
|
68
|
+
"""索引里除我之外的分片(宁宽:认领的前置要查、交接要写对方那份)。"""
|
|
69
|
+
index = OwnersIndex(repo)
|
|
70
|
+
me_key = normalize(me.email)
|
|
71
|
+
out: list[Path] = []
|
|
72
|
+
for key, record in index.all().items():
|
|
73
|
+
if key == me_key:
|
|
74
|
+
continue
|
|
75
|
+
name = (record or {}).get("file")
|
|
76
|
+
if name:
|
|
77
|
+
out.append(Path("owners") / str(name))
|
|
78
|
+
return tuple(out)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _worklog_of(
|
|
82
|
+
repo: Path, params: dict[str, Any]
|
|
83
|
+
) -> tuple[tuple[Path, ...], tuple[Path, ...]]:
|
|
84
|
+
"""这条任务的过程文档:查得到给文件,查不到退回整个 `worklog/` 目录(宁宽)。"""
|
|
85
|
+
tid = params.get("tid")
|
|
86
|
+
if not tid:
|
|
87
|
+
return (), (WORKLOG_DIR,)
|
|
88
|
+
pool = TaskStore(repo, POOL_REL)
|
|
89
|
+
task = pool.get(str(tid))
|
|
90
|
+
if task is None:
|
|
91
|
+
index = OwnersIndex(repo)
|
|
92
|
+
for path in index.shards().values():
|
|
93
|
+
rel = Path(path).relative_to(repo / "ledger")
|
|
94
|
+
task = TaskStore(repo, rel).get(str(tid))
|
|
95
|
+
if task is not None:
|
|
96
|
+
break
|
|
97
|
+
worklog = (task or {}).get("worklog")
|
|
98
|
+
if worklog:
|
|
99
|
+
return (Path(worklog),), ()
|
|
100
|
+
return (), (WORKLOG_DIR,)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def lock_scope(repo: Path, me: Identity, action: str, params: dict[str, Any]) -> Scope:
|
|
104
|
+
"""算出这次可能碰哪些文件(**在锁外跑**,宁可算宽)。七个分支与 §四 的表逐行对应。"""
|
|
105
|
+
if action not in ACTIONS:
|
|
106
|
+
raise ValueError(f"未知的 lock_scope action:{action!r}")
|
|
107
|
+
mine = _shard(repo, me)
|
|
108
|
+
pool = _pool(repo)
|
|
109
|
+
index = OWNERS_REL
|
|
110
|
+
others = _other_shards(repo, me)
|
|
111
|
+
|
|
112
|
+
if action == "bootstrap":
|
|
113
|
+
return Scope(files=(index, pool, mine))
|
|
114
|
+
|
|
115
|
+
if action == "new":
|
|
116
|
+
mine_flag = bool(params.get("mine"))
|
|
117
|
+
target = mine if mine_flag else pool
|
|
118
|
+
created = params.get("created") or ""
|
|
119
|
+
tid = params.get("tid")
|
|
120
|
+
# 本次要新建那篇文档:路径算得出来(锁集允许含不存在的路径),不放进去写集就超出锁集
|
|
121
|
+
doc = (
|
|
122
|
+
(Path(worklog_rel(str(tid), created)),) if tid and len(created) >= 7 else ()
|
|
123
|
+
)
|
|
124
|
+
month_dir = worklog_dir_for(created) if len(created) >= 7 else WORKLOG_DIR
|
|
125
|
+
return Scope(files=(target, *doc), dirs=(month_dir,))
|
|
126
|
+
|
|
127
|
+
if action == "claim":
|
|
128
|
+
wfiles, wdirs = _worklog_of(repo, params)
|
|
129
|
+
return Scope(files=(pool, mine, *others, *wfiles), dirs=wdirs)
|
|
130
|
+
|
|
131
|
+
if action == "set":
|
|
132
|
+
wfiles, wdirs = _worklog_of(repo, params)
|
|
133
|
+
# 分支取并集:release 写池、handoff 写对方分片、指向自己且未认领先认领(写我的分片);已在名下则空转不写盘
|
|
134
|
+
return Scope(files=(mine, pool, *others, *wfiles), dirs=wdirs)
|
|
135
|
+
|
|
136
|
+
if action == "log":
|
|
137
|
+
wfiles, wdirs = _worklog_of(repo, params)
|
|
138
|
+
return Scope(files=(mine, *wfiles), dirs=wdirs)
|
|
139
|
+
|
|
140
|
+
if action == "close":
|
|
141
|
+
wfiles, wdirs = _worklog_of(repo, params)
|
|
142
|
+
finished = params.get("finished") or ""
|
|
143
|
+
history = history_path_for(finished) if len(finished) >= 7 else HISTORY_DIR
|
|
144
|
+
return Scope(files=(mine, history, *wfiles), dirs=wdirs)
|
|
145
|
+
|
|
146
|
+
# 六个分支已覆盖 ACTIONS 全集(入口校验在前),走到这里说明表与分支不同步
|
|
147
|
+
raise ValueError(f"lock_scope 没有处理 {action!r}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@contextlib.contextmanager
|
|
151
|
+
def atom(repo: Path, *rels: Path) -> Iterator[None]:
|
|
152
|
+
"""按路径排序后依次加锁,退出即完成。不建记账本、不写盘、不提交、不打印。"""
|
|
153
|
+
with ledger_lock(repo, *rels):
|
|
154
|
+
yield
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""开工前的自举:台账文件不存在就建,没登记过就把自己登记进去、建自己的分片。
|
|
2
|
+
|
|
3
|
+
这三份由**外壳**单独一个 `atom` 包住,它自己不开事务、不拿锁。
|
|
4
|
+
返回 `Identity`(把索引里的 `aliases` 并进来),不参与「提交说明 / where / 打印」。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..core.identity import Identity, with_aliases
|
|
12
|
+
from ..core.paths import LEDGER_DIR, OWNERS_DIR, POOL_REL, shard_rel, to_repo_rel
|
|
13
|
+
from ..store.atomic import ensure_dir
|
|
14
|
+
from ..store.owners_index import OwnersIndex
|
|
15
|
+
from ..store.task_store import TaskStore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def bootstrap(repo: Path, me: Identity) -> Identity:
|
|
19
|
+
"""写序:`owners/` 目录 → 池 → 索引(登记自己)→ 我的分片。已登记且没变 ⇒ 零写。"""
|
|
20
|
+
ensure_dir(repo / to_repo_rel(LEDGER_DIR))
|
|
21
|
+
ensure_dir(repo / to_repo_rel(OWNERS_DIR))
|
|
22
|
+
TaskStore(repo, POOL_REL).ensure_file()
|
|
23
|
+
index = OwnersIndex(repo)
|
|
24
|
+
_, record = index.ensure(me)
|
|
25
|
+
TaskStore(repo, shard_rel(me.email), identity=me).ensure_file()
|
|
26
|
+
aliases = tuple(record.get("aliases") or ())
|
|
27
|
+
return with_aliases(me, aliases) if aliases else me
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""认领:先写进我的分片,再删池里那行。**顺序反了任务会凭空消失**。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..core.identity import Identity, normalize
|
|
8
|
+
from ..core.paths import POOL_REL, shard_rel
|
|
9
|
+
from ..core.time import now
|
|
10
|
+
from ..core.worklog_md import exec_line
|
|
11
|
+
from ..errors import Fail
|
|
12
|
+
from ..store.owners_index import OwnersIndex
|
|
13
|
+
from ..store.task_store import TaskStore
|
|
14
|
+
from ..store.worklog_store import WorklogStore
|
|
15
|
+
from .result import Result, rel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def claim(repo: Path, me: Identity, tid: str) -> Result:
|
|
19
|
+
"""写序:我的分片 → 删池里那行 → 过程文档记一笔。"""
|
|
20
|
+
pool = TaskStore(repo, POOL_REL)
|
|
21
|
+
mine = TaskStore(repo, shard_rel(me.email), identity=me)
|
|
22
|
+
me_key = normalize(me.email)
|
|
23
|
+
|
|
24
|
+
# 前置:别人正在改它(他的分片里已有这个号)⇒ 拒绝
|
|
25
|
+
for key, path in OwnersIndex(repo).shards().items():
|
|
26
|
+
if key == me_key:
|
|
27
|
+
continue
|
|
28
|
+
other = TaskStore(repo, Path(path).relative_to(repo / "ledger"))
|
|
29
|
+
if other.get(tid) is not None:
|
|
30
|
+
raise Fail(f"任务 {tid} 已在 {key} 的分片里(别人正在做),不能认领。")
|
|
31
|
+
|
|
32
|
+
task = pool.get(tid) # 锁内重读
|
|
33
|
+
if task is None:
|
|
34
|
+
already = mine.get(tid)
|
|
35
|
+
if already is not None:
|
|
36
|
+
# 重跑:分片里已有这个号(上次崩了一半)⇒ 只补删池里那行
|
|
37
|
+
pool.drop(tid)
|
|
38
|
+
logs = WorklogStore(repo)
|
|
39
|
+
path = logs.find(tid, already.get("worklog"))
|
|
40
|
+
if path is not None:
|
|
41
|
+
logs.append_exec(
|
|
42
|
+
path,
|
|
43
|
+
exec_line(now(), me.email, "claim", "认领(补齐上次未完成的清理)"),
|
|
44
|
+
)
|
|
45
|
+
return Result(
|
|
46
|
+
already_done=True,
|
|
47
|
+
where=rel(repo, mine.path),
|
|
48
|
+
message=f"认领 {tid}:补齐上次未完成的清理",
|
|
49
|
+
notices=(f"任务 {tid} 已经在我的分片里,本次只补删公共池那行。",),
|
|
50
|
+
)
|
|
51
|
+
raise Fail(f"公共池里没有任务 {tid}(已经被人认领或不存在)。")
|
|
52
|
+
|
|
53
|
+
current = task.get("status")
|
|
54
|
+
if current not in ("open", "blocked"):
|
|
55
|
+
raise Fail(
|
|
56
|
+
f"任务 {tid} 在公共池里但状态是 {current}(应为 open)—— "
|
|
57
|
+
"先跑 validate 看台账是不是乱了。"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
moved = dict(task)
|
|
61
|
+
moved["owner"] = me.email
|
|
62
|
+
# 认领即在做:看板把 doing 当「我的在办」算(home.service.ts),置 open 会让
|
|
63
|
+
# 认领后的任务看起来仍没人领。旧版 progress.py 置 doing,分片重构时丢了。
|
|
64
|
+
moved["status"] = "doing"
|
|
65
|
+
moved["started"] = moved.get("started") or now()
|
|
66
|
+
moved["updated"] = now()
|
|
67
|
+
mine.put(tid, moved)
|
|
68
|
+
pool.drop(tid)
|
|
69
|
+
|
|
70
|
+
note = f"任务 {tid} 已记到我的分片,过程文档没找到,跳过执行记录。"
|
|
71
|
+
logs = WorklogStore(repo)
|
|
72
|
+
path = logs.find(tid, moved.get("worklog"))
|
|
73
|
+
if path is not None:
|
|
74
|
+
logs.append_exec(path, exec_line(now(), me.email, "claim", "认领 → doing"))
|
|
75
|
+
note = None
|
|
76
|
+
return Result(
|
|
77
|
+
changed=("owner", "started", "updated"),
|
|
78
|
+
where=rel(repo, mine.path),
|
|
79
|
+
message=f"认领 {tid}:{moved.get('title') or ''}".strip(),
|
|
80
|
+
payload={},
|
|
81
|
+
notices=(note,) if note else (),
|
|
82
|
+
)
|