dsh-task-ledger 0.0.100 → 0.0.102

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 (68) hide show
  1. package/dist/client.js +2 -2
  2. package/dist/host/repositories/runner.d.ts +1 -1
  3. package/dist/host/repositories/runner.js +10 -7
  4. package/dist/host/skill/registration.d.ts +2 -1
  5. package/dist/host/skill/registration.js +4 -3
  6. package/package.json +1 -1
  7. package/skill/README.md +4 -2
  8. package/skill/SKILL.md +17 -14
  9. package/skill/assets/worklog.md +3 -3
  10. package/skill/package.json +1 -1
  11. package/skill/references/init-identity.md +3 -3
  12. package/skill/references/log-note.md +1 -1
  13. package/skill/references/state-model.md +1 -1
  14. package/skill/references/troubleshooting.md +1 -1
  15. package/skill/scripts/bin/ledger-darwin-arm64 +0 -0
  16. package/skill/scripts/bin/ledger-linux-amd64 +0 -0
  17. package/skill/scripts/bin/ledger-windows-amd64.exe +0 -0
  18. package/skill/scripts/progress +44 -0
  19. package/skill/scripts/ledgerkit/__init__.py +0 -0
  20. package/skill/scripts/ledgerkit/actions/__init__.py +0 -0
  21. package/skill/scripts/ledgerkit/actions/atom.py +0 -154
  22. package/skill/scripts/ledgerkit/actions/bootstrap.py +0 -37
  23. package/skill/scripts/ledgerkit/actions/claim.py +0 -88
  24. package/skill/scripts/ledgerkit/actions/close_task.py +0 -193
  25. package/skill/scripts/ledgerkit/actions/guard.py +0 -158
  26. package/skill/scripts/ledgerkit/actions/handoff.py +0 -93
  27. package/skill/scripts/ledgerkit/actions/init_identity.py +0 -90
  28. package/skill/scripts/ledgerkit/actions/list_tasks.py +0 -74
  29. package/skill/scripts/ledgerkit/actions/log_progress.py +0 -67
  30. package/skill/scripts/ledgerkit/actions/new_task.py +0 -130
  31. package/skill/scripts/ledgerkit/actions/query.py +0 -35
  32. package/skill/scripts/ledgerkit/actions/query_history.py +0 -220
  33. package/skill/scripts/ledgerkit/actions/registry.py +0 -82
  34. package/skill/scripts/ledgerkit/actions/relabel.py +0 -105
  35. package/skill/scripts/ledgerkit/actions/release.py +0 -76
  36. package/skill/scripts/ledgerkit/actions/result.py +0 -90
  37. package/skill/scripts/ledgerkit/actions/set_task.py +0 -73
  38. package/skill/scripts/ledgerkit/actions/show_task.py +0 -55
  39. package/skill/scripts/ledgerkit/actions/validate.py +0 -189
  40. package/skill/scripts/ledgerkit/core/__init__.py +0 -0
  41. package/skill/scripts/ledgerkit/core/history_md.py +0 -152
  42. package/skill/scripts/ledgerkit/core/identity.py +0 -54
  43. package/skill/scripts/ledgerkit/core/ids.py +0 -58
  44. package/skill/scripts/ledgerkit/core/labels.py +0 -90
  45. package/skill/scripts/ledgerkit/core/paths.py +0 -92
  46. package/skill/scripts/ledgerkit/core/roots.py +0 -74
  47. package/skill/scripts/ledgerkit/core/time.py +0 -103
  48. package/skill/scripts/ledgerkit/core/worklog_md.py +0 -437
  49. package/skill/scripts/ledgerkit/env.py +0 -94
  50. package/skill/scripts/ledgerkit/errors.py +0 -15
  51. package/skill/scripts/ledgerkit/notices.py +0 -28
  52. package/skill/scripts/ledgerkit/shell/__init__.py +0 -0
  53. package/skill/scripts/ledgerkit/shell/cli.py +0 -536
  54. package/skill/scripts/ledgerkit/shell/gitops.py +0 -668
  55. package/skill/scripts/ledgerkit/shell/merge.py +0 -266
  56. package/skill/scripts/ledgerkit/shell/presync.py +0 -23
  57. package/skill/scripts/ledgerkit/shell/render.py +0 -216
  58. package/skill/scripts/ledgerkit/shell/runner.py +0 -232
  59. package/skill/scripts/ledgerkit/store/__init__.py +0 -0
  60. package/skill/scripts/ledgerkit/store/atomic.py +0 -130
  61. package/skill/scripts/ledgerkit/store/dirty.py +0 -108
  62. package/skill/scripts/ledgerkit/store/history_store.py +0 -134
  63. package/skill/scripts/ledgerkit/store/json_store.py +0 -166
  64. package/skill/scripts/ledgerkit/store/locks.py +0 -64
  65. package/skill/scripts/ledgerkit/store/owners_index.py +0 -127
  66. package/skill/scripts/ledgerkit/store/task_store.py +0 -28
  67. package/skill/scripts/ledgerkit/store/worklog_store.py +0 -180
  68. package/skill/scripts/progress.py +0 -20
@@ -1,152 +0,0 @@
1
- """历史表格的文本操作:拼十一列一行、解析十一列一行。
2
-
3
- **只认十一列这一种格式**:存量九列的文件按 `docs/history-creator-upgrade.md`
4
- 那份手册一次性升级,脚本不做运行时迁移。
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import re
10
- from collections.abc import Iterable
11
- from typing import Any
12
-
13
- from .paths import to_history_rel
14
- from .time import TIME_RE, time_key
15
-
16
- HISTORY_TITLE = "# 进度历史(已结案)"
17
- HISTORY_HEADER = (
18
- "| 结案时间 | 创建时间 | 标题 | 分类 | 标签 "
19
- "| owner | owner_name | creator | creator_name | 完成了什么 | 过程文档 |"
20
- )
21
- HISTORY_SEP = "|---|---|---|---|---|---|---|---|---|---|---|"
22
-
23
- _LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]*)\)")
24
- _TAG_SPLIT_RE = re.compile(r"[,,、]")
25
-
26
- Row = dict[str, Any] # 一条历史行:见 parse_history 的返回
27
-
28
-
29
- def esc_cell(text: str) -> str:
30
- """表格单元格:折叠空白并转义竖线,避免把历史表格写坏。"""
31
- return " ".join((text or "").split()).replace("|", "\\|")
32
-
33
-
34
- def owner_cell(owner: str | None) -> str:
35
- """owner 列的值:裸 uuid,**不加 `@`**(`@` 只加在名字列;匹配一律用裸 uuid)。"""
36
- return owner or ""
37
-
38
-
39
- def creator_cell(creator: str | None) -> str:
40
- """creator 列的值(与 owner_cell 同一约定:裸 uuid,不加 `@`)。"""
41
- return creator or ""
42
-
43
-
44
- def name_cell(name: str | None) -> str:
45
- """名字列的展示值:名字前加 `@`(与过程文档 `@名字 · uuid` 同一口径)。"""
46
- return f"@{name}" if name else ""
47
-
48
-
49
- def split_row(line: str) -> list[str]:
50
- """按 `|` 切表格行:转义竖线 `\\|` 不当分隔符,切完反转义、去首尾空单元。"""
51
- cells = re.split(r"(?<!\\)\|", line.strip())
52
- if cells and cells[0] == "":
53
- cells = cells[1:]
54
- if cells and cells[-1] == "":
55
- cells = cells[:-1]
56
- return [c.strip().replace("\\|", "|") for c in cells]
57
-
58
-
59
- def is_sep_row(cells: list[str]) -> bool:
60
- return bool(cells) and all(re.fullmatch(r":?-+:?", c) for c in cells)
61
-
62
-
63
- def _looks_like_header(cells: list[str]) -> bool:
64
- return bool(cells) and cells[0] in ("时间", "结案时间")
65
-
66
-
67
- def history_line(
68
- finished: str,
69
- created: str | None,
70
- title: str,
71
- owner: str | None,
72
- summary: str,
73
- worklog: str | None,
74
- category: str | None = None,
75
- tags: Iterable[str] | None = None,
76
- creator: str | None = None,
77
- owner_name: str | None = None,
78
- creator_name: str | None = None,
79
- ) -> str:
80
- """十一列的一行。`worklog` 收**相对 `ledger/`** 的路径,这里负责换成相对 `history/` 的链接。
81
-
82
- owner / creator 传**裸 uuid**、名字传**裸名字**,两处都别先加 `@`:
83
- `@` 由 `name_cell` 只加在名字列(与过程文档 `@名字 · uuid` 同一口径),uuid 列永远是裸值。
84
- `owner_name` / `creator_name` 是**落盘的显示名快照**(结案那一刻的名字,之后改名不改旧行)。
85
- """
86
- log_rel = to_history_rel(worklog) if worklog else ""
87
- tag_cell = esc_cell("、".join(tags) if tags else "")
88
- link = f"[{log_rel}]({log_rel})" if log_rel else ""
89
- return (
90
- f"| {finished} | {created or ''} | {esc_cell(title)} "
91
- f"| {esc_cell(category or '')} | {tag_cell} "
92
- f"| {esc_cell(owner_cell(owner))} | {esc_cell(name_cell(owner_name))} "
93
- f"| {esc_cell(creator_cell(creator))} | {esc_cell(name_cell(creator_name))} "
94
- f"| {esc_cell(summary)} | {link} |\n"
95
- )
96
-
97
-
98
- def parse_history(text: str) -> tuple[list[Row], list[str]]:
99
- """解析十一列历史表 → (records, warnings)。坏行(列数不对 / 时间列不合法)跳过并警告。
100
-
101
- 返回的键:finished / created / title / category / tags / owner / owner_name /
102
- creator / creator_name / summary / worklog / fmt / line。owner 与 creator 两列
103
- **剥掉 `@`**(存储值是裸 uuid);两个名字列剥掉展示用的 `@`(落盘快照,可能为空)。
104
- """
105
- records: list[Row] = []
106
- warnings: list[str] = []
107
- for lineno, raw in enumerate(text.splitlines(), 1):
108
- if not raw.strip().startswith("|"):
109
- continue
110
- cells = split_row(raw)
111
- if not cells or is_sep_row(cells) or _looks_like_header(cells):
112
- continue
113
- if len(cells) != 11:
114
- warnings.append(f"第 {lineno} 行不是十一列(历史表只认十一列),已跳过")
115
- continue
116
- (
117
- finished,
118
- created,
119
- title,
120
- category,
121
- tag_cell,
122
- owner,
123
- owner_name,
124
- creator,
125
- creator_name,
126
- summary,
127
- doc,
128
- ) = cells
129
- if not TIME_RE.fullmatch(finished):
130
- warnings.append(f"第 {lineno} 行时间列 {finished!r} 不合法,已跳过")
131
- continue
132
- m = _LINK_RE.fullmatch(doc.strip())
133
- records.append(
134
- {
135
- "finished": finished,
136
- "created": time_key(created) if created else None,
137
- "title": title,
138
- "category": category.strip() or None,
139
- "tags": tuple(
140
- t.strip() for t in _TAG_SPLIT_RE.split(tag_cell) if t.strip()
141
- ),
142
- "owner": owner.strip().removeprefix("@") or None,
143
- "owner_name": owner_name.strip().removeprefix("@") or None,
144
- "creator": creator.strip().removeprefix("@") or None,
145
- "creator_name": creator_name.strip().removeprefix("@") or None,
146
- "summary": summary,
147
- "worklog": (m.group(1) if m else doc.strip()) or None,
148
- "fmt": "v11",
149
- "line": lineno,
150
- }
151
- )
152
- return records, warnings
@@ -1,54 +0,0 @@
1
- """身份这个值对象:uuid 是唯一键,name 只用于显示与查询筛选。
2
-
3
- **只做字符串换算,不读文件**(读身份归 env.py)。名字永远不参与归属判定:
4
- 认领 / 释放 / 交接 / 查「我的」,一律只比 uuid。
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import re
10
- from dataclasses import dataclass
11
-
12
- #: 合法身份键的形态(8-4-4-4-12 的十六进制 UUID)。`--owner` 只收这种值。
13
- UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
14
-
15
-
16
- @dataclass(frozen=True)
17
- class Identity:
18
- """一个人的身份。`uuid` 是唯一键,`name` 只用于显示。"""
19
-
20
- uuid: str
21
- name: str = ""
22
-
23
- def label(self) -> str:
24
- """显示名:没名字就用 uuid 前 8 位。只给人看(报错文案、界面)。"""
25
- return self.name or self.uuid[:8]
26
-
27
- def stamp(self) -> str:
28
- """**落盘**用的身份串:`名字 · uuid` —— 名字在前给人读,uuid 在后给机器对。
29
-
30
- 与 `label()` 一样**不带 `@`**(`@` 由各落点自己加:头部各行拼 ` · @{stamp}`、
31
- `exec_line` 拼 ` · @{who} · `)。
32
-
33
- 过程文档(创建行 / 状态行 / 封卷行 / 执行记录)是这个串唯一的落点:文件是最终产物,
34
- 创建人和封卷人可能不是同一个人、也可能中途改过名,所以**两个都要留**,不靠
35
- 历史表回链。没名字就只写 uuid。
36
- """
37
- if not self.name:
38
- return self.uuid
39
- return f"{self.name} · {self.uuid}"
40
-
41
-
42
- def normalize(who: str) -> str:
43
- """归一化:去首尾空白 + 转小写。全仓只此一处。"""
44
- return (who or "").strip().lower()
45
-
46
-
47
- def looks_like_uuid(who: str | None) -> bool:
48
- """是不是一个 UUID 形态的身份键(名字、邮箱一律 False)。"""
49
- return bool(UUID_RE.match(normalize(who or "")))
50
-
51
-
52
- def is_me(identity: Identity, who: str | None) -> bool:
53
- """`who`(uuid)是不是这个人。全站只认 uuid 这一个身份键。"""
54
- return normalize(who or "") == normalize(identity.uuid)
@@ -1,58 +0,0 @@
1
- """任务号:生成、形态校验。只做字符串换算。"""
2
-
3
- from __future__ import annotations
4
-
5
- import re
6
- import secrets
7
- from collections.abc import Mapping
8
-
9
- from ..errors import Fail
10
- from .time import now
11
-
12
- ID_RE = re.compile(r"[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*\Z")
13
-
14
-
15
- def slugify(title: str) -> str:
16
- """任务号用:保留 ASCII 字母数字与汉字,其余转连字符,截到 40 字。"""
17
- s = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", title.strip().lower())
18
- return s.strip("-")[:40].strip("-")
19
-
20
-
21
- def id_stamp(created: str) -> str:
22
- """`created`(YYYY-MM-DD HH:MM:SS)→ 任务号前缀 `YYYYMMDD-HHMM`(任务号只到分)。"""
23
- return (
24
- f"{created[0:4]}{created[5:7]}{created[8:10]}-{created[11:13]}{created[14:16]}"
25
- )
26
-
27
-
28
- def make_id(title: str, tasks: Mapping[str, object], created: str | None = None) -> str:
29
- """按标题生成一个不撞号的任务号。
30
-
31
- 随机码不能省:`make_id` 只在**在办**任务里去重,而结案的过程文档长期留在磁盘上,
32
- 去掉随机码会让新任务撞上已结案文档的名字。
33
- """
34
- slug = slugify(title)
35
- if not slug:
36
- raise Fail(
37
- "标题里没有可用字符(字母、数字或汉字),无法自动生成任务号。"
38
- f'用 --id 指定一个,例如 --id "task-{secrets.token_hex(2)}"。'
39
- )
40
- stamp = id_stamp(created or now())
41
- for _ in range(20):
42
- tid = f"{stamp}-{slug}-{secrets.token_hex(2)}"
43
- if tid not in tasks:
44
- return tid
45
- raise Fail("任务号连续冲突 20 次,请检查台账是否异常。")
46
-
47
-
48
- def require_task_id(tid: str) -> str:
49
- """任务号形态校验(全仓唯一入口)。不合法直接抛 Fail。"""
50
- value = (tid or "").strip()
51
- if not value:
52
- raise Fail("任务号不能为空。")
53
- if not ID_RE.fullmatch(value.lower()):
54
- raise Fail(
55
- f"任务号 {tid!r} 不合法:只能用小写字母、数字、汉字和连字符,"
56
- "且不能以连字符开头或结尾。"
57
- )
58
- return value
@@ -1,90 +0,0 @@
1
- """分类与标签:校验、拼行、拆行。只做文本换算。"""
2
-
3
- from __future__ import annotations
4
-
5
- import re
6
- from collections.abc import Iterable
7
-
8
- from ..errors import Fail
9
-
10
- STATUSES = ("open", "doing")
11
- FORMAT_VERSION = "20261022000000"
12
-
13
- LABEL_MAX_CHARS = 4 # 含 CJK 时按「字」算
14
- LABEL_MAX_WORDS_EN = 3 # 纯 ASCII 时按「词」算
15
- TAGS_MAX_COUNT = 5
16
-
17
- CJK_RE = re.compile(r"[\u4e00-\u9fff]")
18
- LABEL_CAT_RE = re.compile(r"分类\*\*\s*[::]\s*([^||·]+)")
19
- LABEL_TAG_RE = re.compile(r"标签\*\*\s*[::]\s*(.+)$")
20
- _SPLIT_RE = re.compile(r"[,,、]")
21
-
22
-
23
- def normalize_label(value: str, *, field: str) -> str:
24
- """分类 / 单个标签的校验:去空白、限长。空串直接 Fail(给了就要有意义)。"""
25
- text = (value or "").strip()
26
- if not text:
27
- raise Fail(f"{field} 不能为空。")
28
- if CJK_RE.search(text):
29
- if len(text) > LABEL_MAX_CHARS:
30
- raise Fail(f"{field} {text!r} 太长:含中文时最多 {LABEL_MAX_CHARS} 个字。")
31
- elif len(text.split()) > LABEL_MAX_WORDS_EN:
32
- raise Fail(f"{field} {text!r} 太长:英文最多 {LABEL_MAX_WORDS_EN} 个词。")
33
- return text
34
-
35
-
36
- def parse_tags(raw: str | Iterable[str] | None) -> tuple[str, ...]:
37
- """标签串(「、」「,」「,」分隔)→ 去重保序的元组;超上限报错。"""
38
- if raw is None:
39
- return ()
40
- items = list(raw) if not isinstance(raw, str) else _SPLIT_RE.split(raw)
41
- out: list[str] = []
42
- for item in items:
43
- text = normalize_label(str(item).strip(), field="标签")
44
- if text not in out:
45
- out.append(text)
46
- if len(out) > TAGS_MAX_COUNT:
47
- raise Fail(f"标签最多 {TAGS_MAX_COUNT} 个,收到 {len(out)} 个。")
48
- return tuple(out)
49
-
50
-
51
- def label_line(category: str | None, tags: Iterable[str] | None) -> str:
52
- """过程文档头部的「分类 / 标签」行;两者皆空返回空串(此行不写)。"""
53
- segs: list[str] = []
54
- if category:
55
- segs.append(f"**分类**:{category}")
56
- tag_list = [t for t in (tags or ()) if t]
57
- if tag_list:
58
- segs.append(f"**标签**:{'、'.join(tag_list)}")
59
- return "- " + " · ".join(segs) if segs else ""
60
-
61
-
62
- def parse_label_line(text: str) -> tuple[str | None, tuple[str, ...]]:
63
- """从过程文档头部(第一个 `## ` 小节之前)解析分类与标签。
64
-
65
- 跳过 HTML 注释块:模板注释里举例写着「**分类**:…」,那不是数据。
66
- """
67
- in_comment = False
68
- for line in text.splitlines():
69
- if line.startswith("## "):
70
- break
71
- stripped = line.lstrip()
72
- if in_comment:
73
- if "-->" in stripped:
74
- in_comment = False
75
- continue
76
- if stripped.startswith("<!--"):
77
- in_comment = "-->" not in stripped
78
- continue
79
- mc = LABEL_CAT_RE.search(line)
80
- mt = LABEL_TAG_RE.search(line)
81
- if not (mc or mt):
82
- continue
83
- category = mc.group(1).strip() if mc else None
84
- tags = (
85
- tuple(t.strip() for t in _SPLIT_RE.split(mt.group(1).strip()) if t.strip())
86
- if mt
87
- else ()
88
- )
89
- return (category or None, tags)
90
- return (None, ())
@@ -1,92 +0,0 @@
1
- """路径与文件名怎么拼:只做换算,一个文件都不碰。
2
-
3
- **基准只有两个**:
4
- - 磁盘与 JSON 里存的(`task["worklog"]` 等)一律**相对 `ledger/`**;
5
- - 输出给人或给界面的(`Result.where`、`payload` 里的 `*_path`)一律**相对 repo**。
6
-
7
- 换算只在这两个函数里做,任何层都不许自己拼 `..`。
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import hashlib
13
- import re
14
- from pathlib import Path
15
-
16
- LEDGER_DIR = Path("ledger")
17
-
18
- POOL_REL = Path("pool.json")
19
- OWNERS_REL = Path("owners.json")
20
- OWNERS_DIR = Path("owners")
21
- HISTORY_DIR = Path("history")
22
- WORKLOG_DIR = Path("worklog")
23
-
24
- SHA_LEN = 12
25
-
26
- # 模板与资产的唯一定位基准:按自身位置算绝对路径,不依赖调用方 cwd。
27
- # ledgerkit/core/paths.py → parents: [core, ledgerkit, scripts, skill]
28
- SKILL_DIR = Path(__file__).resolve().parents[3]
29
- WORKLOG_TEMPLATE = SKILL_DIR / "assets" / "worklog.md"
30
-
31
-
32
- def to_repo_rel(ledger_rel: str | Path) -> Path:
33
- """相对 `ledger/` 的路径 → 相对 repo 的路径。"""
34
- return LEDGER_DIR / Path(ledger_rel)
35
-
36
-
37
- def to_ledger_rel(repo_rel: str | Path) -> str:
38
- """相对 repo 的路径 → 相对 `ledger/` 的路径(已经是 `ledger/` 内的原样返回)。"""
39
- p = Path(repo_rel)
40
- if p.parts and p.parts[0] == LEDGER_DIR.name:
41
- return Path(*p.parts[1:]).as_posix()
42
- return p.as_posix()
43
-
44
-
45
- def to_history_rel(ledger_rel: str | Path) -> str:
46
- """`ledger/` 内的路径 → 从 `ledger/history/<ym>.md` 出发的链接串。
47
-
48
- 深度固定(`history/` 与 `worklog/` 都在 `ledger/` 下),拼 `..` 只许在这一处。
49
- """
50
- return ("../" / Path(ledger_rel)).as_posix()
51
-
52
-
53
- def shard_rel(identity: str) -> Path:
54
- """某人分片相对 `ledger/` 的路径:sha256(归一化身份)[:12] + .json。"""
55
- digest = hashlib.sha256(identity.strip().lower().encode("utf-8")).hexdigest()
56
- return OWNERS_DIR / f"{digest[:SHA_LEN]}.json"
57
-
58
-
59
- def month_of(stamp: str, *, fallback_now: str | None = None) -> str:
60
- """存盘时间 → `YYYYMM`;取不到时用 `fallback_now`,再没有就抛错由调用方定夺。"""
61
- month = re.sub(r"[^0-9]", "", (stamp or "")[:7])[:6]
62
- if len(month) == 6:
63
- return month
64
- if fallback_now:
65
- return re.sub(r"[^0-9]", "", fallback_now[:7])[:6]
66
- return ""
67
-
68
-
69
- def worklog_dir_for(created: str) -> Path:
70
- """过程文档的按月目录(相对 `ledger/`):`worklog/<YYYYMM>/`。"""
71
- return WORKLOG_DIR / month_of(created)
72
-
73
-
74
- def worklog_name(tid: str) -> str:
75
- """过程文档的文件名:任务号剥掉前缀年月日,随机码保留。
76
-
77
- 父目录 `worklog/<YYYYMM>/` 已带年月,文件名里再写一遍是冗余;
78
- 自定义 `--id` 不以「8 位数字-」开头时,文件名就是任务号本身。
79
- """
80
- if len(tid) > 9 and tid[:8].isdigit() and tid[8] == "-":
81
- return f"{tid[6:]}.md"
82
- return f"{tid}.md"
83
-
84
-
85
- def worklog_rel(tid: str, created: str) -> str:
86
- """过程文档相对 `ledger/` 的路径:任务对象与历史末列共用同一口径。"""
87
- return (worklog_dir_for(created) / worklog_name(tid)).as_posix()
88
-
89
-
90
- def history_path_for(finished: str) -> Path:
91
- """结案时间 → 历史文件相对 `ledger/` 的路径:`history/<YYYYMM>.md`。"""
92
- return HISTORY_DIR / f"{month_of(finished)}.md"
@@ -1,74 +0,0 @@
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
@@ -1,103 +0,0 @@
1
- """时间:本地墙钟 ↔ UTC 的换算、格式化、排序键。
2
-
3
- 进门规则:不读文件、不写文件、不打印;只依赖 errors。
4
- 所有**存盘**时间都是 UTC(`YYYY-MM-DD HH:MM:SS`,不带偏移量);
5
- 所有**对外输出**(人话表格与 `--json`)的时间字段都过 `fmt_local` 转本机时区,
6
- 内部筛选 / 排序一律用存盘的 UTC —— 调用方拿到的时间不用再转。
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import re
12
- from datetime import UTC, datetime
13
-
14
- from ..errors import Fail
15
-
16
- _UTC = UTC
17
-
18
- TIME_RE = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?\Z")
19
- _TIME_IN_RE = re.compile(
20
- r"(\d{4})-(\d{1,2})-(\d{1,2})(?: +(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?\Z"
21
- )
22
-
23
-
24
- def now() -> str:
25
- """当前时刻(UTC,到秒)。"""
26
- return datetime.now(_UTC).strftime("%Y-%m-%d %H:%M:%S")
27
-
28
-
29
- def normalize_time(
30
- value: str | datetime,
31
- *,
32
- field: str = "时间",
33
- tid: str | None = None,
34
- date_only_ref: str | None = None,
35
- ) -> str:
36
- """把「正确但不完整」的写法归一化到 `YYYY-MM-DD HH:MM:SS`。
37
-
38
- 接受 `YYYY-MM-DD`、`YYYY-MM-DD HH:MM`、`YYYY-MM-DD HH:MM:SS`,容忍 `/` 与 `T`。
39
- `date_only_ref`:只给了日期时用这个时刻顶替(`--until` 想含整天传 `23:59:59`)。
40
- """
41
- where = f"{tid}: " if tid else ""
42
- if isinstance(value, datetime):
43
- return value.strftime("%Y-%m-%d %H:%M:%S")
44
- if not isinstance(value, str) or not value.strip():
45
- raise Fail(f"{where}{field} 要是一个时间字符串,收到 {value!r}")
46
- s = " ".join(
47
- value.strip().replace("/", "-").replace("T", " ").replace("t", " ").split()
48
- )
49
- m = _TIME_IN_RE.fullmatch(s)
50
- if not m:
51
- raise Fail(
52
- f"{where}{field} 时间格式不对:{value!r}"
53
- "(可用 YYYY-MM-DD、YYYY-MM-DD HH:MM、YYYY-MM-DD HH:MM:SS)"
54
- )
55
- y, mo, d, hh, mm, ss = m.groups()
56
- if date_only_ref is not None and hh is None:
57
- out = f"{int(y):04d}-{int(mo):02d}-{int(d):02d} {date_only_ref}"
58
- else:
59
- out = (
60
- f"{int(y):04d}-{int(mo):02d}-{int(d):02d} "
61
- f"{int(hh or 0):02d}:{int(mm or 0):02d}:{int(ss or 0):02d}"
62
- )
63
- try:
64
- datetime.strptime(out, "%Y-%m-%d %H:%M:%S")
65
- except ValueError as exc:
66
- raise Fail(f"{where}{field} 不是真实时间:{value!r}({exc})") from exc
67
- return out
68
-
69
-
70
- def to_utc(
71
- value: str,
72
- *,
73
- field: str,
74
- tid: str | None = None,
75
- date_only_ref: str | None = None,
76
- ) -> str:
77
- """用户按本地墙钟给的时间 → 存盘用的 UTC 串。只由外壳调用。"""
78
- wall = normalize_time(value, field=field, tid=tid, date_only_ref=date_only_ref)
79
- naive = datetime.strptime(wall, "%Y-%m-%d %H:%M:%S")
80
- return naive.astimezone(_UTC).strftime("%Y-%m-%d %H:%M:%S")
81
-
82
-
83
- def fmt_local(stamp: object) -> str:
84
- """存盘的 UTC → 查看者本地时间,只用于人看的输出。
85
-
86
- 存量老数据(2026-10-22 前写的是写入者本地时间、无标记)无法区分,
87
- 按 UTC 转会显示偏后若干小时 —— 这是已知例外,不在脚本里猜。
88
- """
89
- key = time_key(stamp)
90
- if key is None:
91
- return "" if stamp is None else str(stamp)
92
- try:
93
- dt = datetime.strptime(key, "%Y-%m-%d %H:%M:%S").replace(tzinfo=_UTC)
94
- except ValueError:
95
- return str(stamp)
96
- return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S")
97
-
98
-
99
- def time_key(value: object) -> str | None:
100
- """已存时间(可能只到分)→ 秒精度串,用于比较/排序;不合法返回 None。"""
101
- if not isinstance(value, str) or not TIME_RE.fullmatch(value):
102
- return None
103
- return value if len(value) == 19 else value + ":00"