dsh-task-ledger 0.0.17 → 0.0.18

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.
@@ -165,17 +165,21 @@ export async function getHomeAggregate(workspaces) {
165
165
  }
166
166
  };
167
167
  for (const entry of perWorkspace) {
168
+ const identity = entry.identity;
168
169
  for (const task of entry.tasks) {
169
170
  const created = dayOf(task.created);
170
171
  if (created === null)
171
172
  continue;
172
173
  const week = ymd(mondayOf(created));
173
- const isMine = entry.identity !== null && task.owner === entry.identity;
174
+ // 新增(我的)= 我创建的(creator);在办任务自带 creator 字段
175
+ const createdMine = identity !== null && (task.creator ?? null) === identity;
174
176
  bump(bucketsAll, week, 'created');
175
- if (isMine)
177
+ if (createdMine)
176
178
  bump(bucketsMine, week, 'created');
179
+ // 未结(我的)= 我负责且未结案(owner)
180
+ const ownedMine = identity !== null && (task.owner ?? null) === identity;
177
181
  bumpRemaining(bucketsAll, created, null);
178
- if (isMine)
182
+ if (ownedMine)
179
183
  bumpRemaining(bucketsMine, created, null);
180
184
  }
181
185
  for (const record of entry.history) {
@@ -183,19 +187,22 @@ export async function getHomeAggregate(workspaces) {
183
187
  if (closed !== null) {
184
188
  const week = ymd(mondayOf(closed));
185
189
  bump(bucketsAll, week, 'done');
186
- if (entry.identity !== null && record.owner === entry.identity)
190
+ if (identity !== null && (record.owner ?? null) === identity)
187
191
  bump(bucketsMine, week, 'done');
188
192
  }
189
193
  const created = dayOf(record.created);
190
194
  if (created !== null) {
191
195
  const week = ymd(mondayOf(created));
192
196
  bump(bucketsAll, week, 'created');
193
- if (entry.identity !== null && record.owner === entry.identity)
197
+ // 新增(我的)= 我创建的(creator);旧 8 列历史无 creator 列 ⇒ 兜底 owner
198
+ const createdMine = identity !== null && ((record.creator ?? null) === identity
199
+ || ((record.creator ?? null) === null && (record.owner ?? null) === identity));
200
+ if (createdMine)
194
201
  bump(bucketsMine, week, 'created');
195
202
  }
196
- const isMine = entry.identity !== null && record.owner === entry.identity;
203
+ const ownedMine = identity !== null && (record.owner ?? null) === identity;
197
204
  bumpRemaining(bucketsAll, created, closed);
198
- if (isMine)
205
+ if (ownedMine)
199
206
  bumpRemaining(bucketsMine, created, closed);
200
207
  }
201
208
  }
@@ -40,6 +40,8 @@ export interface HistoryRecord {
40
40
  category?: string | null;
41
41
  tags?: string[] | null;
42
42
  owner?: string;
43
+ /** 发起人(creator):任务的「创建者」,与 owner 区分(owner 可被 claim 改走)。历史 9 列新增;旧 8 列由 skill 回链 worklog 头部补。 */
44
+ creator?: string | null;
43
45
  /** 「完成了什么」列。 */
44
46
  summary?: string;
45
47
  /** 过程文档链接列。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-task-ledger",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
4
4
  "description": "Task ledger board for dsh (DeepSeek Harness): read-only board over task-ledger skill workspaces. The task-ledger skill is bundled — install the plugin, get the skill globally.",
5
5
  "keywords": [
6
6
  "deepseek",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-ledger",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Task-ledger skill for coding agents: per-workspace progress tracking (ledger/progress.json, 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",
@@ -5,7 +5,7 @@
5
5
  ledger/progress.json 在办任务的状态
6
6
  ledger/worklog/<年月>/<日>-<时分>-<说明>-<随机码>.md
7
7
  单个任务的过程(路径另记在状态表 worklog 字段)
8
- ledger/history/<年月>.md 已结案任务,一行一条(8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / 完成了什么 / 过程文档);
8
+ ledger/history/<年月>.md 已结案任务,一行一条(9 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / 完成了什么 / 过程文档);
9
9
  按结案时间分月归档。存量 ledger/PROGRESS-HISTORY.md 只读兼容、永不重写
10
10
 
11
11
  只用标准库,无第三方依赖。
@@ -46,13 +46,12 @@ HISTORY_DIR = Path(
46
46
  ) # 新历史层:按结案时间分月,ledger/history/<YYYYMM>.md
47
47
  WORKLOG_DIR = Path("ledger/worklog")
48
48
  HISTORY_TITLE = "# 进度历史(已结案)"
49
- # 8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / 完成了什么 / 过程文档。
49
+ # 9 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / 完成了什么 / 过程文档。
50
50
  # owner 独立成列 —— 按人检索是精确列比较,不靠文本切分。
51
- # 分类/标签列(2026-10-15 存量改造时加入):close 从 task 直写;旧 4/5/6 列由
52
- # upgrade_history 升级为 8 列(回链 worklog 头部补值),parse_history 三时代兼容读取。
53
- HISTORY_HEADER = (
54
- "| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 | owner | 完成了什么 | 过程文档 |"
55
- )
51
+ # creator(2026-10-17 加):任务的「发起人」——与 owner 区分(owner 可被 claim 改走,
52
+ # 但「谁建的」不变)。看板「新增(我的)= 我创建的」要它;旧 8 列无此列,
53
+ # upgrade_history / cmd_history 回链 worklog 头部「创建」行补(与 created 同一来源,不丢)。
54
+ HISTORY_HEADER = "| 结案时间 | 创建时间 | 工作包 | 分类 | 标签 | owner | creator | 完成了什么 | 过程文档 |"
56
55
  HISTORY_SEP = "|---|---|---|---|---|---|---|---|"
57
56
  FROZEN_MARK = "✅ 完成封卷"
58
57
  ID_RE = re.compile(r"[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*\Z")
@@ -403,6 +402,11 @@ def owner_cell(owner: str | None) -> str:
403
402
  return f"@{owner}" if owner else ""
404
403
 
405
404
 
405
+ def creator_cell(creator: str | None) -> str:
406
+ """creator 列展示值(与 owner_cell 同一 @ 约定)。"""
407
+ return f"@{creator}" if creator else ""
408
+
409
+
406
410
  def history_line(
407
411
  finished: str,
408
412
  created: str | None,
@@ -412,17 +416,19 @@ def history_line(
412
416
  log_rel: str,
413
417
  category: str | None = None,
414
418
  tags: list[str] | None = None,
419
+ creator: str | None = None,
415
420
  ) -> str:
416
- """历史层的一行(8 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / 完成了什么 / 过程文档)。
421
+ """历史层的一行(9 列:结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / 完成了什么 / 过程文档)。
417
422
 
418
- close 专用,避免两处格式漂移。owner 是独立列、单独过 esc_cell ——
419
- 它来自 git config user.name,含 `|` 也不稀奇。分类/标签由 close 传 task 终值。
423
+ close 专用,避免两处格式漂移。owner / creator 是独立列、单独过 esc_cell ——
424
+ 它们来自 git config user.name,含 `|` 也不稀奇。分类/标签由 close 传 task 终值。
420
425
  """
421
426
  cat_cell = esc_cell(category or "")
422
427
  tag_cell = esc_cell("、".join(tags) if tags else "")
423
428
  return (
424
429
  f"| {finished} | {created or ''} | {esc_cell(title)} | {cat_cell} | {tag_cell} "
425
- f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
430
+ f"| {esc_cell(owner_cell(owner))} | {esc_cell(creator_cell(creator))} "
431
+ f"| {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
426
432
  )
427
433
 
428
434
 
@@ -436,12 +442,13 @@ def history_line_for_cols(
436
442
  log_rel: str,
437
443
  category: str | None = None,
438
444
  tags: list[str] | None = None,
445
+ creator: str | None = None,
439
446
  ) -> str:
440
447
  """「历史层有无法解析的行、不敢整体升级」时,按现有列数追加一行,避免列错位。
441
448
 
442
449
  GFM 按首行列数渲染,列数写错会被静默丢弃 / 错位。cols<=4 ⇒ 旧 4 列(owner 缝在
443
450
  工作包末尾);5 ⇒ 无创建时间列;6 ⇒ 无分类/标签列(存量改造前的最后形态);
444
- 否则 8 列。分类/标签只在 8 列里出现 —— 少列的文件硬塞会把整行挤歪。
451
+ 否则 9 列。分类/标签/creator 只在 9 列里出现 —— 少列的文件硬塞会把整行挤歪。
445
452
  """
446
453
  if cols <= 4:
447
454
  who = f" @{owner}" if owner else ""
@@ -460,7 +467,7 @@ def history_line_for_cols(
460
467
  f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | [{log_rel}]({log_rel}) |\n"
461
468
  )
462
469
  return history_line(
463
- finished, created, title, owner, summary, log_rel, category, tags
470
+ finished, created, title, owner, summary, log_rel, category, tags, creator
464
471
  )
465
472
 
466
473
 
@@ -509,11 +516,13 @@ def _looks_like_header(cells: list[str]) -> bool:
509
516
  def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
510
517
  """解析历史层,返回 (records, warnings)。
511
518
 
512
- 多格式兼容:8 列 = v8(结案时间 / 创建时间 / 工作包 / 分类 / 标签 / …);6 列 = v6
519
+ 多格式兼容:9 列 = v9(结案时间 / 创建时间 / 工作包 / 分类 / 标签 / owner / creator / …);
520
+ 8 列 = v8(存量改造后、2026-10-17 前,无 creator);6 列 = v6
513
521
  (存量改造前最后形态,无分类/标签);5 列且第 2 列是时间 = 旧 ledger 格式
514
522
  (有创建时间列、无 owner 列);5 列 = 无创建时间列;4 列 = 最旧(owner 缝在工作包末尾)。
515
523
  **逐行按自己的列数判别**,表头只用来跳过 —— 旧文件追加新列
516
- 后是混排态,按表头判列数会切错。坏行(列数不在 4/5/6/8、时间列不合法)不中断:跳过 + 警告。
524
+ 后是混排态,按表头判列数会切错。坏行(列数不在 4/5/6/8/9、时间列不合法)不中断:跳过 + 警告。
525
+ 8 列行无 creator 列 ⇒ creator 留 None,cmd_history 回链 worklog 头部「创建」行补(与 created 同源)。
517
526
  """
518
527
  records: list[dict[str, Any]] = []
519
528
  warnings: list[str] = []
@@ -526,13 +535,31 @@ def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
526
535
  created = ""
527
536
  category: str | None = None
528
537
  tags: list[str] = []
529
- if len(cells) == 8:
538
+ creator: str | None = None
539
+ if len(cells) == 9:
540
+ (
541
+ finished,
542
+ created,
543
+ workpackage,
544
+ category,
545
+ tag_cell,
546
+ owner,
547
+ creator,
548
+ summary,
549
+ doc,
550
+ ) = cells
551
+ fmt = "v9"
552
+ category = category.strip() or None
553
+ tags = [t.strip() for t in re.split(r"[,,、]", tag_cell) if t.strip()]
554
+ creator = creator.strip().removeprefix("@") or None
555
+ elif len(cells) == 8:
530
556
  finished, created, workpackage, category, tag_cell, owner, summary, doc = (
531
557
  cells
532
558
  )
533
559
  fmt = "v8"
534
560
  category = category.strip() or None
535
561
  tags = [t.strip() for t in re.split(r"[,,、]", tag_cell) if t.strip()]
562
+ # 旧 8 列无 creator 列:留 None,cmd_history 回链 worklog 头部补(不丢)
536
563
  elif len(cells) == 6:
537
564
  finished, created, workpackage, owner, summary, doc = cells
538
565
  fmt = "v6"
@@ -555,7 +582,7 @@ def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
555
582
  owner = ""
556
583
  fmt = "v4"
557
584
  else:
558
- warnings.append(f"第 {lineno} 行列数不是 4 / 5 / 6 / 8,已跳过")
585
+ warnings.append(f"第 {lineno} 行列数不是 4 / 5 / 6 / 8 / 9,已跳过")
559
586
  continue
560
587
  if not TIME_RE.fullmatch(finished):
561
588
  warnings.append(f"第 {lineno} 行时间列 {finished!r} 不合法,已跳过")
@@ -572,6 +599,7 @@ def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
572
599
  "category": category,
573
600
  "tags": tags,
574
601
  "owner": own or None,
602
+ "creator": creator,
575
603
  "summary": summary,
576
604
  "log": log or None,
577
605
  "fmt": fmt,
@@ -581,11 +609,14 @@ def parse_history(text: str) -> tuple[list[dict[str, Any]], list[str]]:
581
609
  return records, warnings
582
610
 
583
611
 
584
- # worklog 头部「创建」行:`> **创建**:2026-10-06 00:00(@某人)`。用于给旧历史行回填创建时间。
612
+ # worklog 头部「创建」行:`> **创建**:2026-10-06 00:00(@某人)`。
613
+ # 时间给旧历史行回填创建时间;括号内 @某人 即 creator(发起人)。
585
614
  WORKLOG_CREATED_RE = re.compile(
586
615
  r"^\s*>\s*\*\*创建\*\*\s*[::]\s*"
587
616
  r"(\d{4}-\d{2}-\d{2}(?: +\d{1,2}:\d{1,2}(?::\d{1,2})?)?)"
588
617
  )
618
+ # 同一行的发起人(@某人)——creator 列来源。
619
+ WORKLOG_CREATOR_RE = re.compile(r"^\s*>\s*\*\*创建\*\*\s*[::].*?(@(.+?))")
589
620
 
590
621
 
591
622
  def created_for_history(log_cell: str, history_path: Path) -> str | None:
@@ -611,6 +642,28 @@ def created_for_history(log_cell: str, history_path: Path) -> str | None:
611
642
  return None
612
643
 
613
644
 
645
+ def creator_for_history(log_cell: str | None, history_path: Path) -> str | None:
646
+ """顺着历史行的文档链接找到 worklog,读它头部「创建」行的发起人(@某人);找不到返回 None。
647
+
648
+ 旧 8 列历史行没有 creator 列,迁移 / 只读回填都靠它 —— 与 created 同源,不丢数据。
649
+ """
650
+ if not log_cell:
651
+ return None
652
+ m = re.fullmatch(r"\[[^\]]*\]\(([^)]*)\)", log_cell.strip())
653
+ rel = (m.group(1) if m else log_cell).strip()
654
+ if not rel:
655
+ return None
656
+ try:
657
+ text = (history_path.parent / rel).read_text(encoding="utf-8")
658
+ except (OSError, UnicodeDecodeError):
659
+ return None
660
+ for ln in text.splitlines():
661
+ mm = WORKLOG_CREATOR_RE.match(ln)
662
+ if mm:
663
+ return mm.group(1).strip()
664
+ return None
665
+
666
+
614
667
  def labels_for_log(
615
668
  log_cell: str | None, history_path: Path
616
669
  ) -> tuple[str | None, list[str]]:
@@ -633,11 +686,13 @@ def labels_for_log(
633
686
 
634
687
 
635
688
  def upgrade_history(text: str, history_path: Path) -> tuple[str, int] | None:
636
- """旧格式(4 / 5 / 6 列)→ v8(8 列)。全部数据行可解析才返回 (新文本, 转换行数),否则 None。
689
+ """旧格式(4 / 5 / 6 / 8 列)→ v9(9 列)。全部数据行可解析才返回 (新文本, 转换行数),否则 None。
637
690
 
638
691
  None 的语义是「不敢动」:调用方必须退回按原列数追加并明示,绝不半升级、绝不猜。
639
692
  分类/标签列:回链每行的 worklog 头部读「分类/标签」行(读不到留空,不编造)——
640
693
  与 created 的回填同一模式,依赖 Step 2 已给存量 worklog 补行。
694
+ creator 列(2026-10-17 加):旧 8 列无此列,同样回链 worklog 头部「创建」行的
695
+ (@发起人)补 —— **与 created 同源,迁移不丢发起人**。读不到留空。
641
696
  缺创建时间的旧行:先读它链接的 worklog 头部「创建」行,找不到用升级时刻 —— 不允许留空。
642
697
  5 列有两种(判别与 parse_history 一致,靠「第 2 列是不是时间」):旧 ledger 格式
643
698
  (结案 / 创建 / 工作包 / 摘要 / 文档,无 owner)行内自带创建时间,优先用它;
@@ -662,13 +717,33 @@ def upgrade_history(text: str, history_path: Path) -> tuple[str, int] | None:
662
717
  if _looks_like_header(cells):
663
718
  out.append(HISTORY_HEADER)
664
719
  continue
665
- if len(cells) == 8:
666
- # v8 行原样回写:内容已是最终格式(含分类/标签)
720
+ if len(cells) == 9:
721
+ # v9 行原样回写:已含 creator
667
722
  if time_key(cells[0]) is None:
668
723
  return None
669
724
  out.append("| " + " | ".join(cells) + " |")
670
725
  continue
671
726
  explicit_created: str | None = None
727
+ creator: str | None = None
728
+ if len(cells) == 8:
729
+ # v8 → v9:补 creator 列(回链 worklog 头部「创建」行的 @发起人;读不到留空不编造)
730
+ finished, created, workpackage, category, tag_cell, owner, summary, doc = (
731
+ cells
732
+ )
733
+ if time_key(finished) is None:
734
+ return None
735
+ created_key = time_key(created.strip())
736
+ cat = category.strip() or None
737
+ tags = [t.strip() for t in re.split(r"[,,、]", tag_cell) if t.strip()]
738
+ creator = creator_for_history(doc, history_path) or ""
739
+ out.append(
740
+ f"| {finished} | {created_key or ''} | {esc_cell(workpackage)} "
741
+ f"| {esc_cell(cat or '')} | {esc_cell('、'.join(tags) if tags else '')} "
742
+ f"| {esc_cell(owner_cell(owner.lstrip('@')))} | {esc_cell(creator_cell(creator))} "
743
+ f"| {esc_cell(summary)} | {doc} |"
744
+ )
745
+ converted += 1
746
+ continue
672
747
  if len(cells) == 6:
673
748
  finished, created, workpackage, owner, summary, doc = cells
674
749
  # v6 升级:owner 单元格已是展示格式(带 @),剥掉再走 owner_cell,否则升级出 @@
@@ -698,10 +773,12 @@ def upgrade_history(text: str, history_path: Path) -> tuple[str, int] | None:
698
773
  return None
699
774
  created = explicit_created or created_for_history(doc, history_path) or fallback
700
775
  cat, tags = labels_for_log(doc, history_path)
776
+ creator = creator_for_history(doc, history_path) or ""
701
777
  out.append(
702
778
  f"| {finished_key} | {created} | {esc_cell(workpackage)} "
703
779
  f"| {esc_cell(cat or '')} | {esc_cell('、'.join(tags) if tags else '')} "
704
- f"| {esc_cell(owner_cell(owner))} | {esc_cell(summary)} | {doc} |"
780
+ f"| {esc_cell(owner_cell(owner))} | {esc_cell(creator_cell(creator))} "
781
+ f"| {esc_cell(summary)} | {doc} |"
705
782
  )
706
783
  converted += 1
707
784
  return "\n".join(out) + "\n", converted
@@ -796,18 +873,27 @@ def append_history_line(
796
873
  log_rel: str,
797
874
  category: str | None = None,
798
875
  tags: list[str] | None = None,
876
+ creator: str | None = None,
799
877
  ) -> tuple[str, int]:
800
878
  """把一行历史追加到指定历史文件,返回 (新全文, 升级行数)。
801
879
 
802
- 读目标 → upgrade_history → 解析不了就按现有列数追加(绝不把 8 列写进少列表头)
803
- → 文件不存在则带 8 列表头新建。close 专用:「行落哪个月」之外,
804
- 历史行的落盘逻辑只此一处。分类/标签由 close 传 task 终值。
880
+ 读目标 → upgrade_history → 解析不了就按现有列数追加(绝不把 9 列写进少列表头)
881
+ → 文件不存在则带 9 列表头新建。close 专用:「行落哪个月」之外,
882
+ 历史行的落盘逻辑只此一处。分类/标签/creator 由 close 传 task 终值。
805
883
  """
806
884
  if not history_path.exists():
807
885
  return (
808
886
  f"{HISTORY_TITLE}\n\n{HISTORY_HEADER}\n{HISTORY_SEP}\n"
809
887
  + history_line(
810
- finished, created, title, owner, summary, log_rel, category, tags
888
+ finished,
889
+ created,
890
+ title,
891
+ owner,
892
+ summary,
893
+ log_rel,
894
+ category,
895
+ tags,
896
+ creator,
811
897
  ),
812
898
  0,
813
899
  )
@@ -836,6 +922,7 @@ def append_history_line(
836
922
  log_rel,
837
923
  category,
838
924
  tags,
925
+ creator,
839
926
  ),
840
927
  0,
841
928
  )
@@ -843,7 +930,7 @@ def append_history_line(
843
930
  return (
844
931
  new_text
845
932
  + history_line(
846
- finished, created, title, owner, summary, log_rel, category, tags
933
+ finished, created, title, owner, summary, log_rel, category, tags, creator
847
934
  ),
848
935
  n,
849
936
  )
@@ -1308,6 +1395,7 @@ def cmd_close(args: argparse.Namespace) -> None:
1308
1395
  log_rel,
1309
1396
  final_cat,
1310
1397
  final_tags,
1398
+ task.get("creator"),
1311
1399
  )
1312
1400
 
1313
1401
  if log_path.exists():
@@ -1788,6 +1876,9 @@ def cmd_history(args: argparse.Namespace) -> None:
1788
1876
  # 8 列行自带分类/标签;旧 4/5/6 列行没有,回链读 worklog 头部兜底
1789
1877
  if not r.get("category") and not r.get("tags"):
1790
1878
  r["category"], r["tags"] = labels_for_log(r["log"], hp)
1879
+ # v8 行(无 creator 列)回链 worklog 头部「创建」行取发起人,不丢
1880
+ if not r.get("creator"):
1881
+ r["creator"] = creator_for_history(r["log"], hp)
1791
1882
  records.extend(recs)
1792
1883
  warnings.extend(f"{rel} {w}" for w in warns)
1793
1884