dsh-task-ledger 0.0.6 → 0.0.8

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.
@@ -1,12 +1,13 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { readHistory, readTasks } from './ledger-read.js';
3
- import { runLedgerScript } from './script.js';
4
3
  /** 历史窗口:只读最近 13 个月的文件(2026-10-17 拍板;脚本默认读全部,窗口由插件显式传)。 */
5
4
  const RECENT_MONTHS = 13;
6
5
  /** 已结案列表默认分页大小(「加载更多」= 调用方加大 limit 重取)。 */
7
- export const DEFAULT_HISTORY_LIMIT = 500;
8
- /** 完成类统计的封顶值:超出显示「1万+」(2026-10-17 拍板,显示级封顶,精确值走搜索)。 */
9
- const COUNT_CAP = 10_000;
6
+ const DEFAULT_HISTORY_LIMIT = 500;
7
+ /** 停滞判定阈值:doing / blocked 且 created 至今超过这么多天(2026-10-17 拍板,显示级)。 */
8
+ export const STALE_DAYS = 14;
9
+ /** 趋势图桶数:近 8 周(2026-10-17 定稿,与 dsh-context「近 7 天」同思路,按周更耐看)。 */
10
+ export const WEEKLY_WEEKS = 8;
10
11
  /** 周一所在日期(YYYY-MM-DD)。skill 的 --since 只给日期按当天 00:00 解析。 */
11
12
  function mondayDateString(now) {
12
13
  const date = new Date(now);
@@ -15,6 +16,16 @@ function mondayDateString(now) {
15
16
  const pad = (value) => String(value).padStart(2, '0');
16
17
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
17
18
  }
19
+ /** 「YYYY-MM-DD HH:mm:ss」→ 距今天数(解析不了 / 未来都按 0;不编造)。 */
20
+ function daysSince(stamp, now) {
21
+ if (stamp === null || stamp === undefined || stamp === '')
22
+ return null;
23
+ const parsed = new Date(stamp.replace(' ', 'T'));
24
+ if (Number.isNaN(parsed.getTime()))
25
+ return null;
26
+ const days = Math.floor((now.getTime() - parsed.getTime()) / 86_400_000);
27
+ return days > 0 ? days : 0;
28
+ }
18
29
  /**
19
30
  * 当前 git 身份(cwd = 该工作区)——「我的待办」判定源。
20
31
  * 与 agent 在会话里跑 skill 时的取值环境一致;取不到回 null(非 git 目录 / 没配身份),
@@ -37,41 +48,80 @@ export function getCurrentIdentity(workspace) {
37
48
  * 与历史量彻底无关——progress.json 只装没结案的任务(close 会移走),永远有界。
38
49
  */
39
50
  export async function getBoardSnapshot(workspaces) {
51
+ const now = new Date();
40
52
  const perWorkspace = await Promise.all(workspaces.map(async (workspace) => ({
41
53
  workspace,
42
54
  tasks: await readTasks(workspace),
43
55
  identity: await getCurrentIdentity(workspace),
44
56
  })));
45
- const tasks = perWorkspace.flatMap((entry) => entry.tasks);
57
+ const withMeta = perWorkspace.flatMap((entry) => entry.tasks.map((task) => {
58
+ const working = task.status === 'doing' || task.status === 'blocked';
59
+ const ageDays = working ? daysSince(task.started ?? task.created, now) : null;
60
+ return { ...task, ageDays, stale: ageDays !== null && ageDays > STALE_DAYS };
61
+ }));
46
62
  const myActive = perWorkspace.reduce((sum, entry) => sum + entry.tasks.filter((task) => task.owner === entry.identity && (task.status === 'doing' || task.status === 'blocked')).length, 0);
47
- const open = tasks.filter((task) => task.status === 'open').length;
63
+ const myBlocked = perWorkspace.reduce((sum, entry) => sum + entry.tasks.filter((task) => task.owner === entry.identity && task.status === 'blocked').length, 0);
64
+ const openTasks = withMeta.filter((task) => task.status === 'open');
65
+ const blockedTasks = withMeta.filter((task) => task.status === 'blocked');
66
+ const oldestOpenDays = openTasks.length === 0
67
+ ? null
68
+ : openTasks.reduce((max, task) => Math.max(max, daysSince(task.created, now) ?? 0), 0);
69
+ const longestBlockedDays = blockedTasks.length === 0
70
+ ? null
71
+ : blockedTasks.reduce((max, task) => Math.max(max, task.ageDays ?? 0), 0);
48
72
  const identities = {};
49
73
  for (const entry of perWorkspace)
50
74
  identities[entry.workspace.id] = entry.identity;
51
- return { at: new Date().toISOString(), workspaces, tasks, stats: { myActive, open }, identities };
75
+ return {
76
+ at: now.toISOString(),
77
+ workspaces,
78
+ tasks: withMeta,
79
+ stats: {
80
+ myActive,
81
+ myBlocked,
82
+ open: openTasks.length,
83
+ doing: withMeta.filter((task) => task.status === 'doing').length,
84
+ blocked: blockedTasks.length,
85
+ stale: withMeta.filter((task) => task.stale).length,
86
+ staleDays: STALE_DAYS,
87
+ oldestOpenDays,
88
+ longestBlockedDays,
89
+ },
90
+ identities,
91
+ };
92
+ }
93
+ /** 取一条记录的结案日期部分(history 首列 `time`;旧行 / 缺列返回 null)。 */
94
+ function closedDateOf(record) {
95
+ const value = record.time;
96
+ if (value === undefined || value === null || value === '')
97
+ return null;
98
+ return String(value).slice(0, 10);
52
99
  }
53
100
  /**
54
- * 完成类统计(数行封顶,与总量脱钩):本周 = 最近 2 个月文件 + 创建时间落在本周;
55
- * 近一年 = 最近 13 个月文件行数。任一工作区失败即抛(聚合数部分失败 = 错数,不兜)。
101
+ * 完成类统计 + 近 8 周趋势:读一次 13 个月窗口的 history,按**结案时间**落周桶。
102
+ * 任一工作区失败即抛(聚合数部分失败 = 错数,不兜)。
56
103
  */
57
- export async function getClosedCounts(workspaces) {
58
- const monday = mondayDateString(new Date());
59
- const perWorkspace = await Promise.all(workspaces.map(async (workspace) => {
60
- const [week, year] = await Promise.all([
61
- runLedgerScript('history', ['--repo', workspace.path, '--json', '--count-only', '--months', '2', '--since', monday]),
62
- runLedgerScript('history', ['--repo', workspace.path, '--json', '--count-only', '--months', String(RECENT_MONTHS)]),
63
- ]);
64
- if (!week.ok)
65
- throw new Error(`${workspace.title}: ${week.error}`);
66
- if (!year.ok)
67
- throw new Error(`${workspace.title}: ${year.error}`);
68
- return { week: week.data.count ?? 0, year: year.data.count ?? 0, yearCapped: year.data.capped === true };
69
- }));
70
- const yearCapped = perWorkspace.some((entry) => entry.yearCapped);
104
+ export async function getClosedSummary(workspaces) {
105
+ const now = new Date();
106
+ const thisMonday = mondayDateString(now);
107
+ const weeks = [];
108
+ for (let offset = WEEKLY_WEEKS - 1; offset >= 0; offset -= 1) {
109
+ weeks.push(mondayDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - offset * 7)));
110
+ }
111
+ const perWorkspace = await Promise.all(workspaces.map((workspace) => readHistory(workspace, { months: RECENT_MONTHS })));
112
+ const counts = new Map(weeks.map((week) => [week, 0]));
113
+ for (const record of perWorkspace.flat()) {
114
+ const date = closedDateOf(record);
115
+ if (date === null)
116
+ continue;
117
+ const monday = mondayDateString(new Date(`${date}T00:00:00`));
118
+ const bucket = counts.get(monday);
119
+ if (bucket !== undefined)
120
+ counts.set(monday, bucket + 1);
121
+ }
71
122
  return {
72
- weekDone: perWorkspace.reduce((sum, entry) => sum + entry.week, 0),
73
- yearDone: yearCapped ? COUNT_CAP : perWorkspace.reduce((sum, entry) => sum + entry.year, 0),
74
- yearCapped,
123
+ weekDone: counts.get(thisMonday) ?? 0,
124
+ weekly: weeks.map((week) => ({ week, count: counts.get(week) ?? 0 })),
75
125
  };
76
126
  }
77
127
  /**
@@ -1,4 +1,4 @@
1
- import { getBoardSnapshot, getClosedCounts, getFilterOptions, queryClosed } from './board.js';
1
+ import { getBoardSnapshot, getClosedSummary, getFilterOptions, queryClosed } from './board.js';
2
2
  import { getTask, readTasks, readWorklog } from './ledger-read.js';
3
3
  import { resolveWorkspaces } from './workspace.js';
4
4
  /** API 前缀(唯一真源就在这一处)。 */
@@ -57,14 +57,14 @@ export function registerRoutes(webServer, deps) {
57
57
  path: `${API_PREFIX}/workspaces`,
58
58
  handler: (_req, res) => { sendJson(res, 200, { workspaces: workspacesOf(deps) }); },
59
59
  });
60
- // 首页一次拿全:在办快照 + 两个完成类统计。
60
+ // 首页一次拿全:在办快照(含停滞 / 天数 / 身份)+ 完成类统计与近 8 周趋势。
61
61
  webServer.register({
62
62
  kind: 'exact',
63
63
  path: `${API_PREFIX}/board`,
64
64
  handler: async (_req, res) => {
65
65
  try {
66
66
  const workspaces = workspacesOf(deps);
67
- const [snapshot, closed] = await Promise.all([getBoardSnapshot(workspaces), getClosedCounts(workspaces)]);
67
+ const [snapshot, closed] = await Promise.all([getBoardSnapshot(workspaces), getClosedSummary(workspaces)]);
68
68
  sendJson(res, 200, { ...snapshot, closed });
69
69
  }
70
70
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-task-ledger",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
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.1",
3
+ "version": "1.0.2",
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",
@@ -1732,7 +1732,35 @@ def cmd_history(args: argparse.Namespace) -> None:
1732
1732
 
1733
1733
  repo = find_repo(args.repo)
1734
1734
  sources = history_sources(repo)
1735
+ want_owner = (
1736
+ args.owner.strip().lstrip("@") if args.owner else None
1737
+ ) # 容忍「@名字」写法
1738
+ me = git_user() if args.mine else None
1739
+ filter_echo = {
1740
+ "owner": want_owner,
1741
+ "mine": args.mine,
1742
+ "since": since,
1743
+ "until": until,
1744
+ "limit": args.limit,
1745
+ "category": args.category.strip() if args.category else None,
1746
+ "tag": args.tag.strip() if args.tag else None,
1747
+ }
1735
1748
  if not sources:
1749
+ # JSON / 计数模式必须**始终**机器可读:空台账也给 JSON,人话只走 stderr。
1750
+ # (2026-10-17 真机教训:这里原先直接 print 人话后 return,--json 调用方
1751
+ # 拿到非 JSON 输出 ⇒ dsh-task-ledger 看板 /board 整条 500。)
1752
+ if args.count_only:
1753
+ print(json.dumps({"count": 0, "capped": False}, ensure_ascii=False))
1754
+ return
1755
+ if args.json:
1756
+ print(
1757
+ json.dumps(
1758
+ {"count": 0, "filters": filter_echo, "records": []},
1759
+ ensure_ascii=False,
1760
+ indent=2,
1761
+ )
1762
+ )
1763
+ return
1736
1764
  print(
1737
1765
  f"没有已结案记录:ledger/history/ 还没有文件(存量 {HISTORY_REL} 也没有)。"
1738
1766
  )
@@ -1763,10 +1791,6 @@ def cmd_history(args: argparse.Namespace) -> None:
1763
1791
  records.extend(recs)
1764
1792
  warnings.extend(f"{rel} {w}" for w in warns)
1765
1793
 
1766
- me = git_user() if args.mine else None
1767
- want_owner = (
1768
- args.owner.strip().lstrip("@") if args.owner else None
1769
- ) # 容忍「@名字」写法
1770
1794
  rows = []
1771
1795
  undated = 0
1772
1796
  for r in records:
@@ -1807,15 +1831,7 @@ def cmd_history(args: argparse.Namespace) -> None:
1807
1831
  json.dumps(
1808
1832
  {
1809
1833
  "count": len(rows),
1810
- "filters": {
1811
- "owner": want_owner,
1812
- "mine": args.mine,
1813
- "since": since,
1814
- "until": until,
1815
- "limit": args.limit,
1816
- "category": args.category.strip() if args.category else None,
1817
- "tag": args.tag.strip() if args.tag else None,
1818
- },
1834
+ "filters": filter_echo,
1819
1835
  "records": rows,
1820
1836
  },
1821
1837
  ensure_ascii=False,