task-ledger 1.0.1 → 1.1.0

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/SKILL.md CHANGED
@@ -82,7 +82,7 @@ python3 <本 SKILL.md 所在目录>/scripts/progress.py <子命令> --repo <当
82
82
  | `set` | 改状态 / 换负责人 | `--id`(必填)、`--status`、`--owner` |
83
83
  | `close` | 结案:移出状态 + 追加历史 + 封卷过程文档 | `--id`、`--summary`(都必填)、`--finished`(可选,结案时间)、`--category` / `--tags`(可选,结案时覆盖修正一次) |
84
84
  | `list` | 查询在办任务 | `--mine`、`--owner`、`--status`、`--since`、`--until`、`--category`(精确)、`--tag`(包含)、`--json` |
85
- | `history` | 查已结案任务(只读) | `--owner`、`--mine`、`--since`、`--until`、`--limit`、`--category`(精确)、`--tag`(包含)、`--json` |
85
+ | `history` | 查已结案任务(只读) | `--owner`、`--mine`、`--since`、`--until`、`--limit`、`--category`(精确)、`--tag`(包含)、`--json`;另:`--count-only`(只数行数、封顶 1 万输出 `{count, capped}`,显示级统计)、`--months N`(只读最近 N 个月文件,不含存量平铺文件) |
86
86
  | `show` | 单任务详情 | `--id`(必填) |
87
87
  | `tags` | 分类/标签巡检与归并 | `--all`(全量频次表)、`--rename 旧 新`(归并在办任务的同值) |
88
88
  | `validate` | 校验状态表与过程文档是否一致 | — |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-ledger",
3
- "version": "1.0.1",
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
@@ -736,6 +813,47 @@ def history_sources(repo: Path) -> list[Path]:
736
813
  return paths
737
814
 
738
815
 
816
+ # 数行计数的封顶值(2026-10-17 为 dsh-task-ledger 看板统计卡新增):显示级统计封顶,
817
+ # 超出即 capped,精确值走 history --json / 列表。
818
+ COUNT_CAP = 10000
819
+
820
+
821
+ def history_row_count(
822
+ sources: list[Path], since: str | None, until: str | None, cap: int = COUNT_CAP
823
+ ) -> dict[str, Any]:
824
+ """数行计数(只读;2026-10-17 新增,服务看板「本周 / 近一年完成」统计卡)。
825
+
826
+ 只数数据行(`| 2` 开头 = 结案时间以年份开头),到 cap 封顶返回 capped=True。
827
+ 给了 since/until 时按创建时间列(第 2 列)粗筛——取日期部分做字符串比较
828
+ (ISO 日期字典序即时间序),不建对象、不判格式,比 parse_history 便宜一个量级。
829
+ ⚠️ 显示级统计:坏行 / 旧格式行可能多数少数,精确数以 history --json 为准。
830
+ """
831
+ since_d = (since or "")[:10]
832
+ until_d = (until or "")[:10]
833
+ total = 0
834
+ for hp in sources:
835
+ try:
836
+ text = hp.read_text(encoding="utf-8-sig")
837
+ except (OSError, UnicodeDecodeError):
838
+ continue
839
+ for line in text.splitlines():
840
+ if not line.startswith("| 2"):
841
+ continue
842
+ if since_d or until_d:
843
+ cells = line.split("|")
844
+ if len(cells) < 4:
845
+ continue
846
+ created = cells[2].strip()[:10]
847
+ if since_d and created < since_d:
848
+ continue
849
+ if until_d and created > until_d:
850
+ continue
851
+ total += 1
852
+ if total >= cap:
853
+ return {"count": total, "capped": True}
854
+ return {"count": total, "capped": False}
855
+
856
+
739
857
  def worklog_link(log_path: Path, history_path: Path) -> str:
740
858
  """worklog 相对历史文件所在目录的 posix 链接。
741
859
 
@@ -755,18 +873,27 @@ def append_history_line(
755
873
  log_rel: str,
756
874
  category: str | None = None,
757
875
  tags: list[str] | None = None,
876
+ creator: str | None = None,
758
877
  ) -> tuple[str, int]:
759
878
  """把一行历史追加到指定历史文件,返回 (新全文, 升级行数)。
760
879
 
761
- 读目标 → upgrade_history → 解析不了就按现有列数追加(绝不把 8 列写进少列表头)
762
- → 文件不存在则带 8 列表头新建。close 专用:「行落哪个月」之外,
763
- 历史行的落盘逻辑只此一处。分类/标签由 close 传 task 终值。
880
+ 读目标 → upgrade_history → 解析不了就按现有列数追加(绝不把 9 列写进少列表头)
881
+ → 文件不存在则带 9 列表头新建。close 专用:「行落哪个月」之外,
882
+ 历史行的落盘逻辑只此一处。分类/标签/creator 由 close 传 task 终值。
764
883
  """
765
884
  if not history_path.exists():
766
885
  return (
767
886
  f"{HISTORY_TITLE}\n\n{HISTORY_HEADER}\n{HISTORY_SEP}\n"
768
887
  + history_line(
769
- 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,
770
897
  ),
771
898
  0,
772
899
  )
@@ -795,6 +922,7 @@ def append_history_line(
795
922
  log_rel,
796
923
  category,
797
924
  tags,
925
+ creator,
798
926
  ),
799
927
  0,
800
928
  )
@@ -802,7 +930,7 @@ def append_history_line(
802
930
  return (
803
931
  new_text
804
932
  + history_line(
805
- finished, created, title, owner, summary, log_rel, category, tags
933
+ finished, created, title, owner, summary, log_rel, category, tags, creator
806
934
  ),
807
935
  n,
808
936
  )
@@ -1267,6 +1395,7 @@ def cmd_close(args: argparse.Namespace) -> None:
1267
1395
  log_rel,
1268
1396
  final_cat,
1269
1397
  final_tags,
1398
+ task.get("creator"),
1270
1399
  )
1271
1400
 
1272
1401
  if log_path.exists():
@@ -1691,11 +1820,48 @@ def cmd_history(args: argparse.Namespace) -> None:
1691
1820
 
1692
1821
  repo = find_repo(args.repo)
1693
1822
  sources = history_sources(repo)
1823
+ want_owner = (
1824
+ args.owner.strip().lstrip("@") if args.owner else None
1825
+ ) # 容忍「@名字」写法
1826
+ me = git_user() if args.mine else None
1827
+ filter_echo = {
1828
+ "owner": want_owner,
1829
+ "mine": args.mine,
1830
+ "since": since,
1831
+ "until": until,
1832
+ "limit": args.limit,
1833
+ "category": args.category.strip() if args.category else None,
1834
+ "tag": args.tag.strip() if args.tag else None,
1835
+ }
1694
1836
  if not sources:
1837
+ # JSON / 计数模式必须**始终**机器可读:空台账也给 JSON,人话只走 stderr。
1838
+ # (2026-10-17 真机教训:这里原先直接 print 人话后 return,--json 调用方
1839
+ # 拿到非 JSON 输出 ⇒ dsh-task-ledger 看板 /board 整条 500。)
1840
+ if args.count_only:
1841
+ print(json.dumps({"count": 0, "capped": False}, ensure_ascii=False))
1842
+ return
1843
+ if args.json:
1844
+ print(
1845
+ json.dumps(
1846
+ {"count": 0, "filters": filter_echo, "records": []},
1847
+ ensure_ascii=False,
1848
+ indent=2,
1849
+ )
1850
+ )
1851
+ return
1695
1852
  print(
1696
1853
  f"没有已结案记录:ledger/history/ 还没有文件(存量 {HISTORY_REL} 也没有)。"
1697
1854
  )
1698
1855
  return
1856
+ if args.months is not None:
1857
+ if args.months < 1:
1858
+ raise Fail("--months 必须是正整数")
1859
+ # 只取按月归档的文件(存量平铺文件不进了——它是改造前的历史,窗口模式用不到)
1860
+ monthly = [p for p in sources if p.parent.name == HISTORY_DIR.name]
1861
+ sources = monthly[-args.months :]
1862
+ if args.count_only:
1863
+ print(json.dumps(history_row_count(sources, since, until), ensure_ascii=False))
1864
+ return
1699
1865
  records: list[dict[str, Any]] = []
1700
1866
  warnings: list[str] = []
1701
1867
  for hp in sources:
@@ -1710,13 +1876,12 @@ def cmd_history(args: argparse.Namespace) -> None:
1710
1876
  # 8 列行自带分类/标签;旧 4/5/6 列行没有,回链读 worklog 头部兜底
1711
1877
  if not r.get("category") and not r.get("tags"):
1712
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)
1713
1882
  records.extend(recs)
1714
1883
  warnings.extend(f"{rel} {w}" for w in warns)
1715
1884
 
1716
- me = git_user() if args.mine else None
1717
- want_owner = (
1718
- args.owner.strip().lstrip("@") if args.owner else None
1719
- ) # 容忍「@名字」写法
1720
1885
  rows = []
1721
1886
  undated = 0
1722
1887
  for r in records:
@@ -1757,15 +1922,7 @@ def cmd_history(args: argparse.Namespace) -> None:
1757
1922
  json.dumps(
1758
1923
  {
1759
1924
  "count": len(rows),
1760
- "filters": {
1761
- "owner": want_owner,
1762
- "mine": args.mine,
1763
- "since": since,
1764
- "until": until,
1765
- "limit": args.limit,
1766
- "category": args.category.strip() if args.category else None,
1767
- "tag": args.tag.strip() if args.tag else None,
1768
- },
1925
+ "filters": filter_echo,
1769
1926
  "records": rows,
1770
1927
  },
1771
1928
  ensure_ascii=False,
@@ -1914,6 +2071,16 @@ def build_parser() -> argparse.ArgumentParser:
1914
2071
  )
1915
2072
  p.add_argument("--limit", type=int, help="过滤 + 倒序后取前 N 条(最近 N 条)")
1916
2073
  p.add_argument("--json", action="store_true", help="输出结构化 JSON(含摘要)")
2074
+ p.add_argument(
2075
+ "--count-only",
2076
+ action="store_true",
2077
+ help="只数行数(封顶 1 万,输出 {count, capped});显示级统计,精确数以 --json 为准",
2078
+ )
2079
+ p.add_argument(
2080
+ "--months",
2081
+ type=int,
2082
+ help="只读最近 N 个月的历史文件(ledger/history/*.md 按文件名取末 N 个;不含存量平铺文件)",
2083
+ )
1917
2084
  add_repo(p)
1918
2085
  p.set_defaults(func=cmd_history)
1919
2086