dsh-task-ledger 0.0.101 → 0.0.103

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 (71) hide show
  1. package/dist/client.js +3 -3
  2. package/dist/client.js.map +1 -1
  3. package/dist/host/errors.d.ts +2 -2
  4. package/dist/host/errors.js +2 -2
  5. package/dist/host/repositories/runner.d.ts +2 -2
  6. package/dist/host/repositories/runner.js +13 -10
  7. package/dist/host/services/session.service.js +1 -1
  8. package/dist/host/skill/registration.d.ts +2 -1
  9. package/dist/host/skill/registration.js +4 -3
  10. package/package.json +2 -2
  11. package/skill/README.md +4 -2
  12. package/skill/SKILL.md +5 -3
  13. package/skill/assets/worklog.md +1 -1
  14. package/skill/package.json +1 -1
  15. package/skill/references/init-identity.md +3 -3
  16. package/skill/references/log-note.md +1 -1
  17. package/skill/references/troubleshooting.md +1 -1
  18. package/skill/scripts/bin/ledger-darwin-arm64 +0 -0
  19. package/skill/scripts/bin/ledger-linux-amd64 +0 -0
  20. package/skill/scripts/bin/ledger-windows-amd64.exe +0 -0
  21. package/skill/scripts/progress +44 -0
  22. package/skill/scripts/ledgerkit/__init__.py +0 -0
  23. package/skill/scripts/ledgerkit/actions/__init__.py +0 -0
  24. package/skill/scripts/ledgerkit/actions/atom.py +0 -154
  25. package/skill/scripts/ledgerkit/actions/bootstrap.py +0 -37
  26. package/skill/scripts/ledgerkit/actions/claim.py +0 -88
  27. package/skill/scripts/ledgerkit/actions/close_task.py +0 -191
  28. package/skill/scripts/ledgerkit/actions/guard.py +0 -158
  29. package/skill/scripts/ledgerkit/actions/handoff.py +0 -93
  30. package/skill/scripts/ledgerkit/actions/init_identity.py +0 -90
  31. package/skill/scripts/ledgerkit/actions/list_tasks.py +0 -74
  32. package/skill/scripts/ledgerkit/actions/log_progress.py +0 -67
  33. package/skill/scripts/ledgerkit/actions/new_task.py +0 -129
  34. package/skill/scripts/ledgerkit/actions/query.py +0 -35
  35. package/skill/scripts/ledgerkit/actions/query_history.py +0 -220
  36. package/skill/scripts/ledgerkit/actions/registry.py +0 -82
  37. package/skill/scripts/ledgerkit/actions/relabel.py +0 -89
  38. package/skill/scripts/ledgerkit/actions/release.py +0 -76
  39. package/skill/scripts/ledgerkit/actions/result.py +0 -90
  40. package/skill/scripts/ledgerkit/actions/set_task.py +0 -71
  41. package/skill/scripts/ledgerkit/actions/show_task.py +0 -55
  42. package/skill/scripts/ledgerkit/actions/validate.py +0 -189
  43. package/skill/scripts/ledgerkit/core/__init__.py +0 -0
  44. package/skill/scripts/ledgerkit/core/history_md.py +0 -152
  45. package/skill/scripts/ledgerkit/core/identity.py +0 -54
  46. package/skill/scripts/ledgerkit/core/ids.py +0 -58
  47. package/skill/scripts/ledgerkit/core/labels.py +0 -89
  48. package/skill/scripts/ledgerkit/core/paths.py +0 -92
  49. package/skill/scripts/ledgerkit/core/roots.py +0 -74
  50. package/skill/scripts/ledgerkit/core/time.py +0 -103
  51. package/skill/scripts/ledgerkit/core/worklog_md.py +0 -435
  52. package/skill/scripts/ledgerkit/env.py +0 -94
  53. package/skill/scripts/ledgerkit/errors.py +0 -15
  54. package/skill/scripts/ledgerkit/notices.py +0 -28
  55. package/skill/scripts/ledgerkit/shell/__init__.py +0 -0
  56. package/skill/scripts/ledgerkit/shell/cli.py +0 -532
  57. package/skill/scripts/ledgerkit/shell/gitops.py +0 -668
  58. package/skill/scripts/ledgerkit/shell/merge.py +0 -266
  59. package/skill/scripts/ledgerkit/shell/presync.py +0 -23
  60. package/skill/scripts/ledgerkit/shell/render.py +0 -216
  61. package/skill/scripts/ledgerkit/shell/runner.py +0 -232
  62. package/skill/scripts/ledgerkit/store/__init__.py +0 -0
  63. package/skill/scripts/ledgerkit/store/atomic.py +0 -130
  64. package/skill/scripts/ledgerkit/store/dirty.py +0 -108
  65. package/skill/scripts/ledgerkit/store/history_store.py +0 -134
  66. package/skill/scripts/ledgerkit/store/json_store.py +0 -166
  67. package/skill/scripts/ledgerkit/store/locks.py +0 -64
  68. package/skill/scripts/ledgerkit/store/owners_index.py +0 -127
  69. package/skill/scripts/ledgerkit/store/task_store.py +0 -28
  70. package/skill/scripts/ledgerkit/store/worklog_store.py +0 -178
  71. package/skill/scripts/progress.py +0 -20
@@ -1,90 +0,0 @@
1
- """动作的返回值长什么样。字段名是**公开契约**,插件看板直接读,外壳不许重新算。
2
-
3
- 写动作返回 `Result`,只读动作返回各自的视图对象(不再套一层统一的壳)。
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- from dataclasses import dataclass, field
9
- from pathlib import Path
10
- from typing import Any
11
-
12
-
13
- def rel(repo: Path, path: Path) -> str:
14
- """绝对路径 → 相对 repo 的字符串(输出给人 / 给界面的一律这个基准)。"""
15
- try:
16
- return str(Path(path).resolve().relative_to(Path(repo).resolve()))
17
- except ValueError:
18
- return str(path)
19
-
20
-
21
- @dataclass(frozen=True)
22
- class WorklogView:
23
- """「这条任务的过程文档要怎么显示」。"""
24
-
25
- path: str | None = None # 相对 repo
26
- text: str | None = None # 全文;读不到时为 None
27
- error: str | None = None # 读不到的原因(缺失 / 读不了),给人看
28
-
29
-
30
- @dataclass(frozen=True)
31
- class Result:
32
- """写动作的返回。"""
33
-
34
- changed: tuple[str, ...] = () # 改了哪些**字段名**(不是文件名、不是任务号)
35
- where: str | None = None # 主要落在哪个文件(**相对 repo**)
36
- already_done: bool = False # 重跑发现已经做过 ⇒ 退出码仍是 0
37
- message: str | None = None # git 提交说明;None ⇒ 外壳用默认模板
38
- worklog: WorklogView | None = None
39
- payload: dict[str, Any] = field(default_factory=dict)
40
- notices: tuple[str, ...] = ()
41
-
42
-
43
- @dataclass(frozen=True)
44
- class TaskRow:
45
- """人话表格的一行(9 字段投影)。`--json` 吐的是 `rows`(原样任务对象),不是它。"""
46
-
47
- tid: str
48
- title: str
49
- status: str
50
- owner: str | None = None # 显示名
51
- created: str | None = None
52
- started: str | None = None
53
- category: str | None = None
54
- tags: tuple[str, ...] = ()
55
- where: str | None = None # 所在文件(相对 repo)
56
-
57
-
58
- @dataclass(frozen=True)
59
- class ListResult:
60
- rows: dict[str, dict[str, Any]] = field(default_factory=dict) # tid → 原样任务对象
61
- table: tuple[TaskRow, ...] = ()
62
- notices: tuple[str, ...] = ()
63
-
64
-
65
- @dataclass(frozen=True)
66
- class ShowResult:
67
- task: dict[str, Any] = field(default_factory=dict)
68
- worklog: WorklogView | None = None
69
- others: tuple[str, ...] = () # 同一个任务号还出现在哪些文件里
70
- #: 字段是从**在办索引**读来的(老文档没有状态行 / 文档丢了),不是文档自述
71
- legacy: bool = False
72
- notices: tuple[str, ...] = ()
73
-
74
-
75
- @dataclass(frozen=True)
76
- class HistoryResult:
77
- count: int = 0
78
- filters: dict[str, Any] = field(default_factory=dict) # 回显生效的筛选
79
- records: tuple[dict[str, Any], ...] = ()
80
- warnings: tuple[str, ...] = ()
81
- capped: bool = False
82
-
83
-
84
- @dataclass(frozen=True)
85
- class ValidationReport:
86
- problems: tuple[str, ...] = () # 只有它影响退出码
87
- notices: tuple[str, ...] = () # 只提示,不改退出码
88
- checked_files: int = 0
89
- total_tasks: int = 0
90
- deep: bool = False # 本次是深度检查(--deep)还是默认浅度
@@ -1,71 +0,0 @@
1
- """改归属 / 改标签的入口:决定走哪个动作,**自己不加锁、不改任何文件**。
2
-
3
- 它是全树唯一的跨动作引用点(import `release` / `handoff` / `claim` / `relabel`)。
4
-
5
- 任务状态只随命令语义变(认领 → 进行中、释放 → 待认领、结案 → 历史),
6
- **没有单独的「改状态」入口**:`--status` 于 2026-10-23 删除 —— 全仓没有任何
7
- 产品调用方(看板只有 claim / release / close 三个写路由),且它能制造
8
- :「归我名下、状态却还是待认领」的坏状态(看板上显示成没人认领)。
9
-
10
- **也没有改分类的入口**(2026-10-27 删除 `--category`):分类在 `new` 时定死,
11
- 之后谁都改不了;标题与创建时间同理,建完即不可变。`--tags` 仍可随时改。
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- from pathlib import Path
17
-
18
- from ..core.identity import Identity, is_me
19
- from ..core.paths import shard_rel
20
- from ..errors import Fail
21
- from ..store.task_store import TaskStore
22
- from . import claim as claim_mod
23
- from . import handoff as handoff_mod
24
- from . import relabel as relabel_mod
25
- from . import release as release_mod
26
- from .guard import require_uuid
27
- from .result import Result, rel
28
-
29
- _MIXED = "--tags 与 --release / --owner 不能同给:一个改标签,一个改归属。"
30
-
31
-
32
- def set_task(
33
- repo: Path,
34
- me: Identity,
35
- tid: str,
36
- *,
37
- owner: str | None = None,
38
- release: bool = False,
39
- tags: str | tuple[str, ...] | None = None,
40
- ) -> Result:
41
- """路由:`--release` = 释放;别人的 uuid = 交接;自己的 uuid = 已认领则空转、没认领先认领;
42
- `--tags` = 改标签(写分片 + 过程文档头部标签行)。
43
- """
44
- if release:
45
- if owner is not None:
46
- raise Fail("--release 与 --owner 不能同时给:释放不带人,交接不带释放。")
47
- if tags:
48
- raise Fail(_MIXED)
49
- return release_mod.release(repo, me, tid)
50
- if owner is not None:
51
- if tags:
52
- raise Fail(_MIXED)
53
- else:
54
- if tags:
55
- return relabel_mod.relabel(repo, me, tid, tags=tags)
56
- raise Fail(
57
- "set 必须给一样:`--release`(释放回公共池)/ `--owner <对方 uuid>`(交接)/ "
58
- "`--tags <a、b>`(改标签)。"
59
- )
60
- # 交接目标只收 uuid:名字查不到就当场报错,绝不去猜「他大概是这个人」
61
- target = require_uuid(owner, field="--owner")
62
- if not is_me(me, target):
63
- return handoff_mod.handoff(repo, me, tid, to=target)
64
- mine = TaskStore(repo, shard_rel(me.uuid), identity=me)
65
- if mine.get(tid) is not None:
66
- return Result(
67
- already_done=True,
68
- where=rel(repo, mine.path),
69
- notices=(f"任务 {tid} 已经在你名下,无需操作。",),
70
- )
71
- return claim_mod.claim(repo, me, tid)
@@ -1,55 +0,0 @@
1
- """只读:看一条任务。**文档优先**——读到文档就知道状态,不再翻索引(2026-10-27)。
2
-
3
- 两条路径:
4
- - 文档存在且头部有状态行 ⇒ 完全按文档自述回答(`status` / 负责人 / 更新时间 / 标题),
5
- pool、分片、历史一个都不读,已结案的任务同样看得到;
6
- - 文档没有状态行(老文档)或根本没有文档 ⇒ 回退 `load_task` 查在办索引,
7
- 返回里标 `legacy=True`,让调用方知道这些字段不是文档自述的。
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from pathlib import Path
13
-
14
- from ..core import worklog_md
15
- from ..core.identity import Identity
16
- from ..store.worklog_store import WorklogStore
17
- from .guard import load_task
18
- from .result import ShowResult, WorklogView, rel
19
-
20
-
21
- def show_task(repo: Path, me: Identity, tid: str) -> ShowResult:
22
- logs = WorklogStore(repo)
23
- path = logs.find(tid) # 只按任务号找文件名:不碰 pool / 分片 / 历史
24
- text = logs.read(path) if path is not None else ""
25
- status = worklog_md.read_status(text) if text else None
26
- if status is not None and path is not None:
27
- head_tid, title = worklog_md.head_title(text)
28
- return ShowResult(
29
- task={
30
- "tid": head_tid or tid,
31
- "title": title,
32
- "status": status.status,
33
- "owner": status.uuid,
34
- "owner_name": status.name,
35
- "created": worklog_md.head_created(text),
36
- "updated": status.when,
37
- "finished": worklog_md.head_frozen(text),
38
- },
39
- worklog=WorklogView(path=rel(repo, path), text=text, error=None),
40
- )
41
-
42
- # 老文档 / 文档缺失:回退查在办(找不到会抛,文案与既往一致)
43
- task, where = load_task(repo, tid)
44
- if path is None:
45
- view = WorklogView(
46
- path=None, text=None, error="这条任务没有过程文档(或文档已不在原处)。"
47
- )
48
- else:
49
- view = WorklogView(path=rel(repo, path), text=text or None, error=None)
50
- return ShowResult(
51
- task=task,
52
- worklog=view,
53
- others=tuple(rel(repo, p) for p in where),
54
- legacy=True,
55
- )
@@ -1,189 +0,0 @@
1
- """只读:台账体检。两档:默认浅度只查格式,`--deep` 才读全部历史。
2
-
3
- 浅度(默认):池 + 各分片的字段格式、索引与文件对不对得上、**当月**历史。
4
- 深度(`--deep`):历史不止当月(全部月份)。
5
-
6
- **两档都不读过程文档正文**(2026-10-27 用户拍板):过程文档是附件、存量可以上万篇,
7
- 逐篇读全文换不来体检结论,只白花时间;文档自身的格式与内容不在体检范围。
8
-
9
- 只查格式,不查业务(该谁结案、逾期与否都不管);也不查「同一任务号落在多个文件」——
10
- 读侧只显示一次(`list` / `show` 都不重复)、写侧已有防护,撞上了不关体检的事(2026-10-24 用户拍板)。
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- from pathlib import Path
16
-
17
- from ..core import history_md
18
- from ..core.identity import Identity
19
- from ..core.labels import STATUSES
20
- from ..core.paths import (
21
- LEDGER_DIR,
22
- OWNERS_REL,
23
- POOL_REL,
24
- to_repo_rel,
25
- )
26
- from ..core.time import now
27
- from ..store.history_store import HistoryStore
28
- from ..store.owners_index import OwnersIndex
29
- from ..store.task_store import TaskStore
30
- from .guard import owner_of, status_of
31
- from .result import ValidationReport, rel
32
-
33
-
34
- def _read_tasks(repo: Path, store: TaskStore, problems: list[str]) -> dict:
35
- """读一个任务文件;撞上**程序自留**的冲突标记 ⇒ 记一条 problem 继续。
36
-
37
- 推送被拒后 rebase 给 JSON 留的 `<<<<<<<` 是程序自己产的坏数据,validate
38
- 要能指出来(退 1 给清单),而不是整条命令退 3(2026-10-24 修)。
39
- 其余读错误(用户手改坏)不在此列,照旧抛出。
40
- """
41
- try:
42
- return store.all()
43
- except ValueError:
44
- text = store.path.read_text(encoding="utf-8", errors="replace")
45
- if "<<<<<<<" in text:
46
- problems.append(
47
- f"{rel(repo, store.path)} 带着冲突标记"
48
- "(推送被拒后 rebase 没解完):先解决冲突,再跑 validate。"
49
- )
50
- return {}
51
- raise
52
-
53
-
54
- def _check_task_files(
55
- repo: Path, index: OwnersIndex, problems: list[str]
56
- ) -> tuple[int, int]:
57
- """池 + 各分片的字段格式(owner / status / 分片归属);返回 (查了几个文件, 几条任务)。"""
58
- checked = 0
59
- total = 0
60
-
61
- pool = TaskStore(repo, POOL_REL)
62
- if pool.path.exists():
63
- checked += 1
64
- for tid, task in _read_tasks(repo, pool, problems).items():
65
- total += 1
66
- if owner_of(task):
67
- problems.append(
68
- f"公共池里的 {tid} 带着 owner(池里的行必须无人认领)。"
69
- )
70
- if task.get("status") not in STATUSES:
71
- problems.append(f"公共池里的 {tid} 状态 {task.get('status')!r} 非法。")
72
-
73
- for key, path in index.shards().items():
74
- if not path.exists():
75
- problems.append(f"索引登记了 {key},但分片文件 {path.name} 不存在。")
76
- continue
77
- checked += 1
78
- store = TaskStore(repo, Path(path).relative_to(repo / "ledger"))
79
- for tid, task in _read_tasks(repo, store, problems).items():
80
- total += 1
81
- owner = owner_of(task)
82
- if not owner:
83
- problems.append(f"分片 {key} 里的 {tid} 没有 owner。")
84
- elif owner != key:
85
- problems.append(
86
- f"分片 {key} 里的 {tid} 的 owner 是 {owner}(分片主人与分区键不一致)。"
87
- )
88
- status = status_of(task)
89
- if status not in STATUSES:
90
- problems.append(f"分片 {key} 里的 {tid} 状态 {status!r} 非法。")
91
-
92
- return checked, total
93
-
94
-
95
- def _check_orphan_shards(index: OwnersIndex, problems: list[str]) -> None:
96
- """目录里有、索引没登记的分片(全仓唯一扫目录的地方)。"""
97
- registered = {p.name for p in index.shards().values()}
98
- for path in index.disk_shards():
99
- if path.name not in registered:
100
- problems.append(
101
- f"owners/ 里的 {path.name} 没有在索引里登记(对它人等于不存在)。"
102
- )
103
-
104
-
105
- def _history_paths(store: HistoryStore, *, deep: bool) -> list[Path]:
106
- """要读的历史文件:浅度只读当月那个月文件(存量平铺文件不读),深度读全部。"""
107
- sources = store.sources()
108
- if deep:
109
- return sources
110
- month = now()[0:4] + now()[5:7]
111
- return [p for p in sources if store.month_key(p) == month]
112
-
113
-
114
- def _check_history(
115
- repo: Path,
116
- paths: list[Path],
117
- open_docs: set[str],
118
- store: HistoryStore,
119
- notices: list[str],
120
- ) -> int:
121
- """历史文件的格式与对账:解析警告、行尾文档链接对得上在办任务没。"""
122
- checked = 0
123
- for path in paths:
124
- checked += 1
125
- rows, warns = history_md.parse_history(store.read(path))
126
- notices.extend(f"{rel(repo, path)}:{w}" for w in warns)
127
- for row in rows:
128
- name = Path(row.get("worklog") or "").name
129
- if name and name in open_docs:
130
- notices.append(
131
- f"历史里有「{row.get('title') or name}」的结案行,"
132
- "但它还挂在在办台账里。"
133
- )
134
- return checked
135
-
136
-
137
- def validate(repo: Path, me: Identity, deep: bool = False) -> ValidationReport:
138
- problems: list[str] = []
139
- notices: list[str] = []
140
- checked = 0
141
- total = 0
142
-
143
- index = OwnersIndex(repo)
144
- history = HistoryStore(repo)
145
-
146
- files, tasks = _check_task_files(repo, index, problems)
147
- checked += files
148
- total += tasks
149
- _check_orphan_shards(index, problems)
150
-
151
- checked += _check_history(
152
- repo,
153
- _history_paths(history, deep=deep),
154
- _open_worklog_names(repo, index),
155
- history,
156
- notices,
157
- )
158
-
159
- if not (repo / to_repo_rel(OWNERS_REL)).exists():
160
- notices.append("还没有 owners.json(索引):跑一条写命令会自动建。")
161
- stray = repo / LEDGER_DIR / LEDGER_DIR
162
- if stray.is_dir():
163
- problems.append(
164
- f"{rel(repo, stray)} 是错位的台账目录(根被当成 ledger/ 时写出来的幻影);"
165
- "确认哪份是真台账后删掉多余的,命令一律带 --repo <工作区根>。"
166
- )
167
-
168
- return ValidationReport(
169
- problems=tuple(problems),
170
- notices=tuple(notices),
171
- checked_files=checked,
172
- total_tasks=total,
173
- deep=deep,
174
- )
175
-
176
-
177
- def _open_worklog_names(repo: Path, index: OwnersIndex) -> set[str]:
178
- """在办任务的过程文档**文件名**集合(池 + 全部分片):历史行按它对账。"""
179
- stores = [TaskStore(repo, POOL_REL)]
180
- for path in index.shards().values():
181
- if path.exists():
182
- stores.append(TaskStore(repo, Path(path).relative_to(repo / "ledger")))
183
- out: set[str] = set()
184
- for store in stores:
185
- for task in store.all().values():
186
- name = Path((task.get("worklog") or "").strip()).name
187
- if name:
188
- out.add(name)
189
- return out
File without changes
@@ -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