dsh-task-ledger 0.0.44 → 0.0.46
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/dist/client.js +33 -6
- package/dist/client.js.map +1 -1
- package/dist/host/board.d.ts +13 -108
- package/dist/host/board.js +19 -285
- package/dist/host/errors.d.ts +42 -0
- package/dist/host/errors.js +55 -0
- package/dist/host/repositories/identity.repository.d.ts +9 -0
- package/dist/host/repositories/identity.repository.js +20 -0
- package/dist/host/{ledger-read.d.ts → repositories/ledger.repository.d.ts} +10 -4
- package/dist/host/{ledger-read.js → repositories/ledger.repository.js} +19 -7
- package/dist/host/repositories/records.d.ts +5 -0
- package/dist/host/repositories/records.js +48 -0
- package/dist/host/{script.js → repositories/runner.js} +3 -2
- package/dist/host/routes.js +7 -5
- package/dist/host/services/dates.d.ts +37 -0
- package/dist/host/services/dates.js +71 -0
- package/dist/host/services/history.service.d.ts +24 -0
- package/dist/host/services/history.service.js +61 -0
- package/dist/host/services/home.service.d.ts +13 -0
- package/dist/host/services/home.service.js +50 -0
- package/dist/host/services/trend.service.d.ts +26 -0
- package/dist/host/services/trend.service.js +134 -0
- package/dist/host/{skill-registration.js → skill/registration.js} +3 -2
- package/dist/host/types.d.ts +94 -0
- package/dist/host/workspace.d.ts +2 -0
- package/dist/host/workspace.js +6 -9
- package/dist/index.js +1 -1
- package/package.json +4 -3
- /package/dist/host/{script.d.ts → repositories/runner.d.ts} +0 -0
- /package/dist/host/{skill-registration.d.ts → skill/registration.d.ts} +0 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import { runLedgerScript } from './
|
|
4
|
+
import { runLedgerScript } from './runner.js';
|
|
5
|
+
import { parseHistoryRecords, parseTaskList } from './records.js';
|
|
6
|
+
/** 台账目录名(各工作区根下的这一个目录;布局知识只在本文件出现一次)。 */
|
|
7
|
+
const LEDGER_DIR = 'ledger';
|
|
4
8
|
/**
|
|
5
9
|
* LedgerFilter → skill 脚本参数(只拼调用方给了的;undefined 一律不拼,
|
|
6
10
|
* 让脚本走自己的默认——插件不替脚本决定默认值)。
|
|
@@ -26,21 +30,29 @@ export function filterArgs(filter) {
|
|
|
26
30
|
return args;
|
|
27
31
|
}
|
|
28
32
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
33
|
+
* 台账工作区探测:`<path>/ledger/progress.json` 在才算(未初始化 = 后端不可见,
|
|
34
|
+
* 2026-10-17 拍板)。全 host 对台账目录的 IO 只在本文件——别处再写 existsSync 即为分层倒退。
|
|
35
|
+
*/
|
|
36
|
+
export function hasLedger(workspacePath) {
|
|
37
|
+
return existsSync(path.join(workspacePath, LEDGER_DIR, 'progress.json'));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 在办任务(包 `list --json`;结果过 records 入口校验后打上 workspace)。
|
|
41
|
+
* 脚本失败 / 形状不符抛错——由 service 层捕获进 degraded,路由层转 500 + 人话,
|
|
42
|
+
* 不静默返空(空 = 撒谎)。
|
|
31
43
|
*/
|
|
32
44
|
export async function readTasks(workspace, filter = {}) {
|
|
33
45
|
const result = await runLedgerScript('list', ['--repo', workspace.path, '--json', ...filterArgs(filter)]);
|
|
34
46
|
if (!result.ok)
|
|
35
47
|
throw new Error(`${workspace.title}: ${result.error}`);
|
|
36
|
-
return
|
|
48
|
+
return parseTaskList(result.data, workspace);
|
|
37
49
|
}
|
|
38
|
-
/** 已结案记录(包 `history --json
|
|
50
|
+
/** 已结案记录(包 `history --json`;`records` 必须存在且为数组,缺了即坏数据)。 */
|
|
39
51
|
export async function readHistory(workspace, filter = {}) {
|
|
40
52
|
const result = await runLedgerScript('history', ['--repo', workspace.path, '--json', ...filterArgs(filter)]);
|
|
41
53
|
if (!result.ok)
|
|
42
54
|
throw new Error(`${workspace.title}: ${result.error}`);
|
|
43
|
-
return (result.data
|
|
55
|
+
return parseHistoryRecords(result.data, workspace);
|
|
44
56
|
}
|
|
45
57
|
/** 单条在办(展开行 / 详情用)。取不到回 null,不抛。 */
|
|
46
58
|
export async function getTask(workspace, taskId) {
|
|
@@ -56,7 +68,7 @@ export async function readWorklog(workspace, task) {
|
|
|
56
68
|
if (relative === undefined || relative === null || relative === '')
|
|
57
69
|
return null;
|
|
58
70
|
try {
|
|
59
|
-
return await readFile(path.join(workspace.path,
|
|
71
|
+
return await readFile(path.join(workspace.path, LEDGER_DIR, relative), 'utf-8');
|
|
60
72
|
}
|
|
61
73
|
catch {
|
|
62
74
|
return null;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { HistoryRecord, LedgerWorkspace, TaskRow } from '../types.js';
|
|
2
|
+
/** `list --json`(任务号为键的对象)→ TaskRow[](打上 id 与 workspace)。 */
|
|
3
|
+
export declare function parseTaskList(raw: unknown, workspace: LedgerWorkspace): TaskRow[];
|
|
4
|
+
/** `history --json`(`{count, filters, records}`)→ HistoryRecord[](打上 workspace)。 */
|
|
5
|
+
export declare function parseHistoryRecords(raw: unknown, workspace: LedgerWorkspace): HistoryRecord[];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 入口校验(纯函数):skill 脚本 JSON → 领域对象。
|
|
3
|
+
*
|
|
4
|
+
* 口径:**容器级严格、元素级宽松**。
|
|
5
|
+
* - 容器:`list --json` 顶层必须是 plain object(任务号为键);`history --json` 的 `records`
|
|
6
|
+
* 必须存在且为数组(现状 `records ?? []` 的容忍在脚本回归时会静默产出空历史 = 撒谎);
|
|
7
|
+
* - 元素:是 object 就收,字段缺失一律放行(TaskRecord 全字段可选是脚本既有契约,
|
|
8
|
+
* 旧 8 列 history 不因此被误杀)。
|
|
9
|
+
* 失败抛 {@link LedgerDataError}——由 service 层捕获进 degraded,不静默。
|
|
10
|
+
*/
|
|
11
|
+
import { LedgerDataError } from '../errors.js';
|
|
12
|
+
function isPlainObject(value) {
|
|
13
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
/** `list --json`(任务号为键的对象)→ TaskRow[](打上 id 与 workspace)。 */
|
|
16
|
+
export function parseTaskList(raw, workspace) {
|
|
17
|
+
if (!isPlainObject(raw)) {
|
|
18
|
+
throw new LedgerDataError(workspace.id, `${workspace.title}: 台账 list 输出不是「任务号为键的对象」,拒绝当成空列表`);
|
|
19
|
+
}
|
|
20
|
+
const rows = [];
|
|
21
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
22
|
+
if (!isPlainObject(value)) {
|
|
23
|
+
throw new LedgerDataError(workspace.id, `${workspace.title}: 任务 ${id} 不是对象,台账格式可能已变`);
|
|
24
|
+
}
|
|
25
|
+
if (id === '') {
|
|
26
|
+
throw new LedgerDataError(workspace.id, `${workspace.title}: 台账存在空任务号,格式可能已变`);
|
|
27
|
+
}
|
|
28
|
+
rows.push({ ...value, id, workspace });
|
|
29
|
+
}
|
|
30
|
+
return rows;
|
|
31
|
+
}
|
|
32
|
+
/** `history --json`(`{count, filters, records}`)→ HistoryRecord[](打上 workspace)。 */
|
|
33
|
+
export function parseHistoryRecords(raw, workspace) {
|
|
34
|
+
if (!isPlainObject(raw)) {
|
|
35
|
+
throw new LedgerDataError(workspace.id, `${workspace.title}: 台账 history 输出不是对象,拒绝当成空历史`);
|
|
36
|
+
}
|
|
37
|
+
const records = raw.records;
|
|
38
|
+
if (!Array.isArray(records)) {
|
|
39
|
+
throw new LedgerDataError(workspace.id, `${workspace.title}: 台账 history 输出缺 records 数组,格式可能已变`);
|
|
40
|
+
}
|
|
41
|
+
const rows = [];
|
|
42
|
+
for (const record of records) {
|
|
43
|
+
if (!isPlainObject(record))
|
|
44
|
+
continue; // 元素级宽松:坏行跳过,不整板炸
|
|
45
|
+
rows.push({ ...record, workspace });
|
|
46
|
+
}
|
|
47
|
+
return rows;
|
|
48
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
/**
|
|
4
|
-
* 内嵌 skill 脚本的默认路径:dist/host/
|
|
4
|
+
* 内嵌 skill 脚本的默认路径:dist/host/repositories/runner.js → 包根 skill/scripts/progress.py
|
|
5
|
+
* (比原 host/script.js 深一层 ⇒ 相对路径多一级 `../`)。
|
|
5
6
|
* 构建时 scripts/copy-skill.mjs 把 packages/skill 拷到 packages/plugin/skill/。
|
|
6
7
|
*/
|
|
7
|
-
const DEFAULT_SCRIPT_PATH = fileURLToPath(new URL('
|
|
8
|
+
const DEFAULT_SCRIPT_PATH = fileURLToPath(new URL('../../../skill/scripts/progress.py', import.meta.url));
|
|
8
9
|
/** 单次脚本调用超时上限(ms)。实测单仓 5 万行全量 1.2s,10s 是宽松上限。 */
|
|
9
10
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
10
11
|
/**
|
package/dist/host/routes.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getHomeAggregate, getFilterOptions, queryClosed } from './board.js';
|
|
2
|
-
import { getTask, readTasks, readWorklog } from './ledger
|
|
2
|
+
import { getTask, readTasks, readWorklog } from './repositories/ledger.repository.js';
|
|
3
3
|
import { resolveWorkspaces } from './workspace.js';
|
|
4
4
|
/** API 前缀(唯一真源就在这一处)。 */
|
|
5
5
|
const API_PREFIX = '/api/task-ledger';
|
|
@@ -47,7 +47,7 @@ function filterFromQuery(url) {
|
|
|
47
47
|
filter.months = months;
|
|
48
48
|
return filter;
|
|
49
49
|
}
|
|
50
|
-
/** 每请求重新解析工作区(registry
|
|
50
|
+
/** 每请求重新解析工作区(registry 可能刚变;探测走 hasLedger(),微秒级,不缓存)。 */
|
|
51
51
|
function workspacesOf(deps) {
|
|
52
52
|
return resolveWorkspaces(deps.registry.list());
|
|
53
53
|
}
|
|
@@ -64,7 +64,7 @@ export function registerRoutes(webServer, deps) {
|
|
|
64
64
|
handler: async (_req, res) => {
|
|
65
65
|
try {
|
|
66
66
|
const home = await getHomeAggregate(workspacesOf(deps));
|
|
67
|
-
sendJson(res, 200, { ...home.snapshot, closed: home.closed, trend: home.trend });
|
|
67
|
+
sendJson(res, 200, { ...home.snapshot, closed: home.closed, trend: home.trend, degraded: home.degraded });
|
|
68
68
|
}
|
|
69
69
|
catch (error) {
|
|
70
70
|
sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -116,7 +116,8 @@ export function registerRoutes(webServer, deps) {
|
|
|
116
116
|
handler: async (req, res) => {
|
|
117
117
|
try {
|
|
118
118
|
const filter = filterFromQuery(new URL(req.url ?? '/', 'http://localhost'));
|
|
119
|
-
|
|
119
|
+
const result = await queryClosed(workspacesOf(deps), filter);
|
|
120
|
+
sendJson(res, 200, { records: result.records, degraded: result.degraded });
|
|
120
121
|
}
|
|
121
122
|
catch (error) {
|
|
122
123
|
sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -128,7 +129,8 @@ export function registerRoutes(webServer, deps) {
|
|
|
128
129
|
path: `${API_PREFIX}/filters`,
|
|
129
130
|
handler: async (_req, res) => {
|
|
130
131
|
try {
|
|
131
|
-
|
|
132
|
+
const result = await getFilterOptions(workspacesOf(deps));
|
|
133
|
+
sendJson(res, 200, { ...result.options, degraded: result.degraded });
|
|
132
134
|
}
|
|
133
135
|
catch (error) {
|
|
134
136
|
sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日期口径(纯函数层,零 IO):本机时区、周一为一周之始(与 skill 脚本同口径)。
|
|
3
|
+
* 所有函数显式收 `now`——不自己读时钟,便于单测与跨时区复现。
|
|
4
|
+
*/
|
|
5
|
+
/** Date → `YYYY-MM-DD`。 */
|
|
6
|
+
export declare function ymd(date: Date): string;
|
|
7
|
+
/** 时刻串(`YYYY-MM-DD HH:mm:ss` / `YYYY-MM-DD`)→ 日期部分;解析不了回 null。 */
|
|
8
|
+
export declare function dayOf(stamp: string | null | undefined): string | null;
|
|
9
|
+
/** 某日期所在周的周一(00:00)。 */
|
|
10
|
+
export declare function mondayOf(day: string): Date;
|
|
11
|
+
/**
|
|
12
|
+
* 第 offset 周的起止(offset 0 = 本周,-1 = 上周;返回日期串,含边界)。
|
|
13
|
+
* ⚠️ 本周只到今天(周还没结束),上周才是完整周一~周日——否则「本周」会把明天之后的
|
|
14
|
+
* 日子也画进区间,tooltip 里的截止日撒谎。
|
|
15
|
+
*/
|
|
16
|
+
export declare function weekWindow(offset: number, now: Date): {
|
|
17
|
+
from: string;
|
|
18
|
+
to: string;
|
|
19
|
+
};
|
|
20
|
+
/** 第 offset 月的起止(offset 0 = 本月,-1 = 上月;含边界)。
|
|
21
|
+
* 同 {@link weekWindow}:本月只到今天,上月才是完整自然月。 */
|
|
22
|
+
export declare function monthWindow(offset: number, now: Date): {
|
|
23
|
+
from: string;
|
|
24
|
+
to: string;
|
|
25
|
+
};
|
|
26
|
+
/** 日期是否落在 [from, to](含边界,字符串比较即可——ISO 字典序即时间序)。 */
|
|
27
|
+
export declare function inWindow(day: string | null, window: {
|
|
28
|
+
from: string;
|
|
29
|
+
to: string;
|
|
30
|
+
}): boolean;
|
|
31
|
+
/** 「YYYY-MM-DD HH:mm:ss」→ 距今天数(解析不了 / 未来都按 0;不编造)。 */
|
|
32
|
+
export declare function daysSince(stamp: string | null | undefined, now: Date): number | null;
|
|
33
|
+
/** 近 N 周的桶骨架(`{week, end}`,历史周 end=周日、当前周 end=今天)。 */
|
|
34
|
+
export declare function buildWeeks(count: number, now: Date): readonly {
|
|
35
|
+
week: string;
|
|
36
|
+
end: string;
|
|
37
|
+
}[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日期口径(纯函数层,零 IO):本机时区、周一为一周之始(与 skill 脚本同口径)。
|
|
3
|
+
* 所有函数显式收 `now`——不自己读时钟,便于单测与跨时区复现。
|
|
4
|
+
*/
|
|
5
|
+
function pad(value) {
|
|
6
|
+
return String(value).padStart(2, '0');
|
|
7
|
+
}
|
|
8
|
+
/** Date → `YYYY-MM-DD`。 */
|
|
9
|
+
export function ymd(date) {
|
|
10
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
11
|
+
}
|
|
12
|
+
/** 时刻串(`YYYY-MM-DD HH:mm:ss` / `YYYY-MM-DD`)→ 日期部分;解析不了回 null。 */
|
|
13
|
+
export function dayOf(stamp) {
|
|
14
|
+
if (stamp === null || stamp === undefined || stamp === '')
|
|
15
|
+
return null;
|
|
16
|
+
const day = String(stamp).slice(0, 10);
|
|
17
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
|
|
18
|
+
}
|
|
19
|
+
/** 某日期所在周的周一(00:00)。 */
|
|
20
|
+
export function mondayOf(day) {
|
|
21
|
+
const date = new Date(`${day}T00:00:00`);
|
|
22
|
+
date.setDate(date.getDate() - ((date.getDay() + 6) % 7));
|
|
23
|
+
return date;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 第 offset 周的起止(offset 0 = 本周,-1 = 上周;返回日期串,含边界)。
|
|
27
|
+
* ⚠️ 本周只到今天(周还没结束),上周才是完整周一~周日——否则「本周」会把明天之后的
|
|
28
|
+
* 日子也画进区间,tooltip 里的截止日撒谎。
|
|
29
|
+
*/
|
|
30
|
+
export function weekWindow(offset, now) {
|
|
31
|
+
const monday = mondayOf(ymd(now));
|
|
32
|
+
monday.setDate(monday.getDate() + offset * 7);
|
|
33
|
+
const sunday = new Date(monday);
|
|
34
|
+
sunday.setDate(sunday.getDate() + 6);
|
|
35
|
+
const last = offset === 0 ? now : sunday;
|
|
36
|
+
return { from: ymd(monday), to: ymd(last < sunday ? last : sunday) };
|
|
37
|
+
}
|
|
38
|
+
/** 第 offset 月的起止(offset 0 = 本月,-1 = 上月;含边界)。
|
|
39
|
+
* 同 {@link weekWindow}:本月只到今天,上月才是完整自然月。 */
|
|
40
|
+
export function monthWindow(offset, now) {
|
|
41
|
+
const first = new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
|
42
|
+
const last = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0);
|
|
43
|
+
const end = offset === 0 ? now : last;
|
|
44
|
+
return { from: ymd(first), to: ymd(end < last ? end : last) };
|
|
45
|
+
}
|
|
46
|
+
/** 日期是否落在 [from, to](含边界,字符串比较即可——ISO 字典序即时间序)。 */
|
|
47
|
+
export function inWindow(day, window) {
|
|
48
|
+
return day !== null && day >= window.from && day <= window.to;
|
|
49
|
+
}
|
|
50
|
+
/** 「YYYY-MM-DD HH:mm:ss」→ 距今天数(解析不了 / 未来都按 0;不编造)。 */
|
|
51
|
+
export function daysSince(stamp, now) {
|
|
52
|
+
if (stamp === null || stamp === undefined || stamp === '')
|
|
53
|
+
return null;
|
|
54
|
+
const parsed = new Date(String(stamp).replace(' ', 'T'));
|
|
55
|
+
if (Number.isNaN(parsed.getTime()))
|
|
56
|
+
return null;
|
|
57
|
+
const days = Math.floor((now.getTime() - parsed.getTime()) / 86_400_000);
|
|
58
|
+
return days > 0 ? days : 0;
|
|
59
|
+
}
|
|
60
|
+
/** 近 N 周的桶骨架(`{week, end}`,历史周 end=周日、当前周 end=今天)。 */
|
|
61
|
+
export function buildWeeks(count, now) {
|
|
62
|
+
const weeks = [];
|
|
63
|
+
for (let offset = count - 1; offset >= 0; offset -= 1)
|
|
64
|
+
weeks.push(weekWindow(-offset, now).from);
|
|
65
|
+
return weeks.map((week, i) => {
|
|
66
|
+
const monday = new Date(`${week}T00:00:00`);
|
|
67
|
+
const sunday = new Date(monday);
|
|
68
|
+
sunday.setDate(sunday.getDate() + 6);
|
|
69
|
+
return { week, end: i === weeks.length - 1 ? ymd(now) : ymd(sunday) };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type DegradedInfo } from '../errors.js';
|
|
2
|
+
import type { FilterOptions, HistoryRecord, LedgerFilter, LedgerWorkspace, TrendScope } from '../types.js';
|
|
3
|
+
/** 已结案列表默认分页大小(「加载更多」= 调用方加大 limit 重取)。 */
|
|
4
|
+
export declare const DEFAULT_HISTORY_LIMIT = 500;
|
|
5
|
+
/**
|
|
6
|
+
* 已结案列表:默认近 13 个月 + limit 500,跨工作区合并后按创建时间倒序。
|
|
7
|
+
* shortcut: 分页用「加大 limit 重取」的笨办法(skill 脚本无 offset);
|
|
8
|
+
* 单工作区内 limit 已生效,合并边界的全局精确排序暂不保证(500 条一页无感)。
|
|
9
|
+
* 要全量老数据:调用方显式传 months: undefined(不拼 --months,脚本读全部,按秒计)。
|
|
10
|
+
*/
|
|
11
|
+
export declare function queryClosed(workspaces: readonly LedgerWorkspace[], filter?: LedgerFilter): Promise<{
|
|
12
|
+
records: HistoryRecord[];
|
|
13
|
+
degraded: readonly DegradedInfo[];
|
|
14
|
+
}>;
|
|
15
|
+
/** 候选值收集(纯函数):owner/category/tag 去重 + 排序。 */
|
|
16
|
+
export declare function collectFilterOptions(scopes: readonly TrendScope[]): FilterOptions;
|
|
17
|
+
/**
|
|
18
|
+
* 筛选项候选:在办(全量)+ 近 13 个月历史的去重值(owner / category / tag)。
|
|
19
|
+
* 按需调用(/filters 路由),非首屏——历史侧在极端量级下是百毫秒级。单工作区坏 ⇒ 进 degraded。
|
|
20
|
+
*/
|
|
21
|
+
export declare function getFilterOptions(workspaces: readonly LedgerWorkspace[]): Promise<{
|
|
22
|
+
options: FilterOptions;
|
|
23
|
+
degraded: readonly DegradedInfo[];
|
|
24
|
+
}>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 已结案列表 / 筛选项候选用例(history tab 与筛选控件用;非首屏,按需调用)。
|
|
3
|
+
*/
|
|
4
|
+
import { readHistory, readTasks } from '../repositories/ledger.repository.js';
|
|
5
|
+
import { settleWorkspaces } from '../errors.js';
|
|
6
|
+
import { RECENT_MONTHS, readTrendScope } from './trend.service.js';
|
|
7
|
+
/** 已结案列表默认分页大小(「加载更多」= 调用方加大 limit 重取)。 */
|
|
8
|
+
export const DEFAULT_HISTORY_LIMIT = 500;
|
|
9
|
+
/**
|
|
10
|
+
* 已结案列表:默认近 13 个月 + limit 500,跨工作区合并后按创建时间倒序。
|
|
11
|
+
* shortcut: 分页用「加大 limit 重取」的笨办法(skill 脚本无 offset);
|
|
12
|
+
* 单工作区内 limit 已生效,合并边界的全局精确排序暂不保证(500 条一页无感)。
|
|
13
|
+
* 要全量老数据:调用方显式传 months: undefined(不拼 --months,脚本读全部,按秒计)。
|
|
14
|
+
*/
|
|
15
|
+
export async function queryClosed(workspaces, filter = {}) {
|
|
16
|
+
const windowed = { months: RECENT_MONTHS, ...filter };
|
|
17
|
+
const { data, degraded } = await settleWorkspaces(workspaces, 'history', (workspace) => readHistory(workspace, windowed), (rows) => rows.flat()
|
|
18
|
+
.sort((a, b) => String(b.created ?? b.time ?? '').localeCompare(String(a.created ?? a.time ?? '')))
|
|
19
|
+
.slice(0, filter.limit ?? DEFAULT_HISTORY_LIMIT));
|
|
20
|
+
return { records: data, degraded };
|
|
21
|
+
}
|
|
22
|
+
/** 候选值收集(纯函数):owner/category/tag 去重 + 排序。 */
|
|
23
|
+
export function collectFilterOptions(scopes) {
|
|
24
|
+
const owners = new Set();
|
|
25
|
+
const categories = new Set();
|
|
26
|
+
const tags = new Set();
|
|
27
|
+
const addTask = (task) => {
|
|
28
|
+
if (task.owner !== undefined && task.owner !== null && task.owner !== '')
|
|
29
|
+
owners.add(task.owner);
|
|
30
|
+
if (task.creator !== undefined && task.creator !== null && task.creator !== '')
|
|
31
|
+
owners.add(task.creator);
|
|
32
|
+
if (task.category !== undefined && task.category !== null && task.category !== '')
|
|
33
|
+
categories.add(task.category);
|
|
34
|
+
for (const tag of task.tags ?? [])
|
|
35
|
+
tags.add(tag);
|
|
36
|
+
};
|
|
37
|
+
const addRecord = (record) => {
|
|
38
|
+
if (record.owner !== undefined && record.owner !== '')
|
|
39
|
+
owners.add(record.owner);
|
|
40
|
+
if (record.category !== undefined && record.category !== null && record.category !== '')
|
|
41
|
+
categories.add(record.category);
|
|
42
|
+
for (const tag of record.tags ?? [])
|
|
43
|
+
tags.add(tag);
|
|
44
|
+
};
|
|
45
|
+
for (const scope of scopes) {
|
|
46
|
+
for (const task of scope.tasks)
|
|
47
|
+
addTask(task);
|
|
48
|
+
for (const record of scope.history)
|
|
49
|
+
addRecord(record);
|
|
50
|
+
}
|
|
51
|
+
const sorted = (values) => [...values].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
|
|
52
|
+
return { owners: sorted(owners), categories: sorted(categories), tags: sorted(tags) };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 筛选项候选:在办(全量)+ 近 13 个月历史的去重值(owner / category / tag)。
|
|
56
|
+
* 按需调用(/filters 路由),非首屏——历史侧在极端量级下是百毫秒级。单工作区坏 ⇒ 进 degraded。
|
|
57
|
+
*/
|
|
58
|
+
export async function getFilterOptions(workspaces) {
|
|
59
|
+
const { data, degraded } = await settleWorkspaces(workspaces, 'history', readTrendScope, collectFilterOptions);
|
|
60
|
+
return { options: data, degraded };
|
|
61
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DegradedInfo } from '../errors.js';
|
|
2
|
+
import type { BoardSnapshot, LedgerWorkspace, TaskScope } from '../types.js';
|
|
3
|
+
/** 停滞判定阈值:doing / blocked 且 created 至今超过这么多天(2026-10-17 拍板,显示级)。 */
|
|
4
|
+
export declare const STALE_DAYS = 14;
|
|
5
|
+
/** 快路径读法:单工作区 readTasks + git 身份(并行;供 settle 逐工作区调)。 */
|
|
6
|
+
export declare function readTaskScope(workspace: LedgerWorkspace): Promise<TaskScope>;
|
|
7
|
+
/** 在办行加工 + 六个统计面 + identities(纯函数;`now` 显式传入)。 */
|
|
8
|
+
export declare function computeSnapshot(scopes: readonly TaskScope[], now: Date): BoardSnapshot;
|
|
9
|
+
/** 快路径端点:在办快照(统计卡 + 身份 + 行加工)。单工作区坏 ⇒ 进 degraded,其余照常。 */
|
|
10
|
+
export declare function getSnapshot(workspaces: readonly LedgerWorkspace[]): Promise<{
|
|
11
|
+
snapshot: BoardSnapshot;
|
|
12
|
+
degraded: readonly DegradedInfo[];
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 首页**快路径**用例:在办快照 + 统计卡。
|
|
3
|
+
* 只读 progress.json(list)+ git 身份,**不碰 history**——首屏耗时与历史总量解耦
|
|
4
|
+
* (20 万条量级实测:527ms vs 合并端点 4.4s,见 docs/design/host-data-plane.md §四)。
|
|
5
|
+
* 慢路径(趋势)在 ./trend.service.ts;组合入口(/board 兼容全量)在 ../board.ts。
|
|
6
|
+
*/
|
|
7
|
+
import { readTasks } from '../repositories/ledger.repository.js';
|
|
8
|
+
import { getCurrentIdentity } from '../repositories/identity.repository.js';
|
|
9
|
+
import { settleWorkspaces } from '../errors.js';
|
|
10
|
+
import { daysSince } from './dates.js';
|
|
11
|
+
/** 停滞判定阈值:doing / blocked 且 created 至今超过这么多天(2026-10-17 拍板,显示级)。 */
|
|
12
|
+
export const STALE_DAYS = 14;
|
|
13
|
+
/** 快路径读法:单工作区 readTasks + git 身份(并行;供 settle 逐工作区调)。 */
|
|
14
|
+
export async function readTaskScope(workspace) {
|
|
15
|
+
const [tasks, identity] = await Promise.all([readTasks(workspace), getCurrentIdentity(workspace)]);
|
|
16
|
+
return { workspace, tasks, identity };
|
|
17
|
+
}
|
|
18
|
+
/** 在办行加工 + 六个统计面 + identities(纯函数;`now` 显式传入)。 */
|
|
19
|
+
export function computeSnapshot(scopes, now) {
|
|
20
|
+
const withMeta = scopes.flatMap((scope) => scope.tasks.map((task) => {
|
|
21
|
+
const working = task.status === 'doing' || task.status === 'blocked';
|
|
22
|
+
const ageDays = working ? daysSince(task.started ?? task.created, now) : null;
|
|
23
|
+
return { ...task, ageDays, stale: ageDays !== null && ageDays > STALE_DAYS };
|
|
24
|
+
}));
|
|
25
|
+
const openTasks = withMeta.filter((task) => task.status === 'open');
|
|
26
|
+
const blockedTasks = withMeta.filter((task) => task.status === 'blocked');
|
|
27
|
+
const myActive = scopes.reduce((sum, scope) => sum + scope.tasks.filter((task) => task.owner === scope.identity && (task.status === 'doing' || task.status === 'blocked')).length, 0);
|
|
28
|
+
const myBlocked = scopes.reduce((sum, scope) => sum + scope.tasks.filter((task) => task.owner === scope.identity && task.status === 'blocked').length, 0);
|
|
29
|
+
const identities = {};
|
|
30
|
+
for (const scope of scopes)
|
|
31
|
+
identities[scope.workspace.id] = scope.identity;
|
|
32
|
+
const stats = {
|
|
33
|
+
myActive,
|
|
34
|
+
myBlocked,
|
|
35
|
+
open: openTasks.length,
|
|
36
|
+
doing: withMeta.filter((task) => task.status === 'doing').length,
|
|
37
|
+
blocked: blockedTasks.length,
|
|
38
|
+
stale: withMeta.filter((task) => task.stale).length,
|
|
39
|
+
staleDays: STALE_DAYS,
|
|
40
|
+
oldestOpenDays: openTasks.length === 0
|
|
41
|
+
? null
|
|
42
|
+
: openTasks.reduce((max, task) => Math.max(max, daysSince(task.created, now) ?? 0), 0),
|
|
43
|
+
};
|
|
44
|
+
return { at: now.toISOString(), workspaces: scopes.map((scope) => scope.workspace), tasks: withMeta, stats, identities };
|
|
45
|
+
}
|
|
46
|
+
/** 快路径端点:在办快照(统计卡 + 身份 + 行加工)。单工作区坏 ⇒ 进 degraded,其余照常。 */
|
|
47
|
+
export async function getSnapshot(workspaces) {
|
|
48
|
+
const { data: scopes, degraded } = await settleWorkspaces(workspaces, 'list', readTaskScope, (rows) => rows);
|
|
49
|
+
return { snapshot: computeSnapshot(scopes, new Date()), degraded };
|
|
50
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type DegradedInfo } from '../errors.js';
|
|
2
|
+
import type { ClosedSummary, LedgerFilter, LedgerWorkspace, TrendPeriods, TrendScope } from '../types.js';
|
|
3
|
+
/** 历史窗口:只读最近 13 个月的文件(2026-10-17 拍板;脚本默认读全部,窗口由插件显式传)。 */
|
|
4
|
+
export declare const RECENT_MONTHS = 13;
|
|
5
|
+
/** 趋势图桶数:近 8 周(2026-10-17 定稿)。 */
|
|
6
|
+
export declare const WEEKLY_WEEKS = 8;
|
|
7
|
+
/** 慢路径读法:单工作区 readTasks + readHistory(months) + git 身份(并行;供 settle 逐工作区调)。 */
|
|
8
|
+
export declare function readTrendScope(workspace: LedgerWorkspace): Promise<TrendScope>;
|
|
9
|
+
/**
|
|
10
|
+
* 近 N 周分组桶(完成按结案时间、新增按创建时间)× 所有 / 我的(纯函数)。
|
|
11
|
+
*
|
|
12
|
+
* 新增必须**合并两个来源**:已结案的任务被 close 移出 progress.json,只在 history 里;
|
|
13
|
+
* 在办的只在 progress.json 里。两者不相交(close 是移动),合并即全集、不会重复计。
|
|
14
|
+
* remaining:某任务在「第 i 周截止时」仍未完成 ⇔ 创建 ≤ weekEnd_i 且(未结案 或 结案 > weekEnd_i)。
|
|
15
|
+
*/
|
|
16
|
+
export declare function computeClosedSummary(scopes: readonly TrendScope[], now: Date): ClosedSummary;
|
|
17
|
+
/** 四个自然周期(只算「全部工作区」范围;图表切换不带动卡片)——纯函数。 */
|
|
18
|
+
export declare function computeTrendPeriods(scopes: readonly TrendScope[], now: Date): TrendPeriods;
|
|
19
|
+
/** 慢路径端点:趋势 + 周期(读 history)。异步加载,首屏不等。单工作区坏 ⇒ 进 degraded。 */
|
|
20
|
+
export declare function getTrend(workspaces: readonly LedgerWorkspace[]): Promise<{
|
|
21
|
+
closed: ClosedSummary;
|
|
22
|
+
trend: TrendPeriods;
|
|
23
|
+
degraded: readonly DegradedInfo[];
|
|
24
|
+
}>;
|
|
25
|
+
/** 趋势端点用的历史 filter(窗口由本模块策略决定,repository 零默认值注入)。 */
|
|
26
|
+
export declare function trendHistoryFilter(extra?: LedgerFilter): LedgerFilter;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 首页**慢路径**用例:近 8 周趋势 + 四个自然周期。
|
|
3
|
+
* 读 history(months=RECENT_MONTHS)+ 在办 + git 身份——首屏不等它(异步/按需拉)。
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ 窗口必须是 13 个月而不是 2:8 周桶在月初会横跨 3 个自然月(如 10 月第 1 周回溯到 8 月),
|
|
6
|
+
* `--months` 是**文件级**过滤,缩到 2 会静默漏掉 8 月结案记录、趋势少算(2026-10-18 架构评审结论)。
|
|
7
|
+
*/
|
|
8
|
+
import { readHistory, readTasks } from '../repositories/ledger.repository.js';
|
|
9
|
+
import { getCurrentIdentity } from '../repositories/identity.repository.js';
|
|
10
|
+
import { settleWorkspaces } from '../errors.js';
|
|
11
|
+
import { buildWeeks, dayOf, inWindow, monthWindow, mondayOf, weekWindow, ymd } from './dates.js';
|
|
12
|
+
/** 历史窗口:只读最近 13 个月的文件(2026-10-17 拍板;脚本默认读全部,窗口由插件显式传)。 */
|
|
13
|
+
export const RECENT_MONTHS = 13;
|
|
14
|
+
/** 趋势图桶数:近 8 周(2026-10-17 定稿)。 */
|
|
15
|
+
export const WEEKLY_WEEKS = 8;
|
|
16
|
+
/** 慢路径读法:单工作区 readTasks + readHistory(months) + git 身份(并行;供 settle 逐工作区调)。 */
|
|
17
|
+
export async function readTrendScope(workspace) {
|
|
18
|
+
const [tasks, history, identity] = await Promise.all([
|
|
19
|
+
readTasks(workspace),
|
|
20
|
+
readHistory(workspace, { months: RECENT_MONTHS }),
|
|
21
|
+
getCurrentIdentity(workspace),
|
|
22
|
+
]);
|
|
23
|
+
return { workspace, tasks, history, identity };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 近 N 周分组桶(完成按结案时间、新增按创建时间)× 所有 / 我的(纯函数)。
|
|
27
|
+
*
|
|
28
|
+
* 新增必须**合并两个来源**:已结案的任务被 close 移出 progress.json,只在 history 里;
|
|
29
|
+
* 在办的只在 progress.json 里。两者不相交(close 是移动),合并即全集、不会重复计。
|
|
30
|
+
* remaining:某任务在「第 i 周截止时」仍未完成 ⇔ 创建 ≤ weekEnd_i 且(未结案 或 结案 > weekEnd_i)。
|
|
31
|
+
*/
|
|
32
|
+
export function computeClosedSummary(scopes, now) {
|
|
33
|
+
const weeks = buildWeeks(WEEKLY_WEEKS, now);
|
|
34
|
+
const weekEnds = weeks.map((slot) => slot.end);
|
|
35
|
+
const make = () => new Map(weeks.map((slot) => [slot.week, { done: 0, created: 0, remaining: 0 }]));
|
|
36
|
+
const bucketsAll = make();
|
|
37
|
+
const bucketsMine = make();
|
|
38
|
+
const bump = (map, week, field) => {
|
|
39
|
+
const bucket = map.get(week);
|
|
40
|
+
if (bucket !== undefined)
|
|
41
|
+
map.set(week, { ...bucket, [field]: bucket[field] + 1 });
|
|
42
|
+
};
|
|
43
|
+
const bumpRemaining = (map, createdDay, closeDay) => {
|
|
44
|
+
if (createdDay === null)
|
|
45
|
+
return;
|
|
46
|
+
for (let i = 0; i < weeks.length; i += 1) {
|
|
47
|
+
if (createdDay <= weekEnds[i] && (closeDay === null || closeDay > weekEnds[i])) {
|
|
48
|
+
const bucket = map.get(weeks[i].week);
|
|
49
|
+
if (bucket !== undefined)
|
|
50
|
+
bucket.remaining += 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
for (const scope of scopes) {
|
|
55
|
+
const identity = scope.identity;
|
|
56
|
+
for (const task of scope.tasks) {
|
|
57
|
+
const created = dayOf(task.created);
|
|
58
|
+
if (created === null)
|
|
59
|
+
continue;
|
|
60
|
+
const week = ymd(mondayOf(created));
|
|
61
|
+
const createdMine = identity !== null && (task.creator ?? null) === identity;
|
|
62
|
+
bump(bucketsAll, week, 'created');
|
|
63
|
+
if (createdMine)
|
|
64
|
+
bump(bucketsMine, week, 'created');
|
|
65
|
+
const ownedMine = identity !== null && (task.owner ?? null) === identity;
|
|
66
|
+
bumpRemaining(bucketsAll, created, null);
|
|
67
|
+
if (ownedMine)
|
|
68
|
+
bumpRemaining(bucketsMine, created, null);
|
|
69
|
+
}
|
|
70
|
+
for (const record of scope.history) {
|
|
71
|
+
const closed = dayOf(record.time);
|
|
72
|
+
if (closed !== null) {
|
|
73
|
+
const week = ymd(mondayOf(closed));
|
|
74
|
+
bump(bucketsAll, week, 'done');
|
|
75
|
+
if (identity !== null && (record.owner ?? null) === identity)
|
|
76
|
+
bump(bucketsMine, week, 'done');
|
|
77
|
+
}
|
|
78
|
+
const created = dayOf(record.created);
|
|
79
|
+
if (created !== null) {
|
|
80
|
+
const week = ymd(mondayOf(created));
|
|
81
|
+
bump(bucketsAll, week, 'created');
|
|
82
|
+
// 新增(我的)= 我创建的(creator);旧 8 列历史无 creator 列 ⇒ 兜底 owner
|
|
83
|
+
const createdMine = identity !== null && ((record.creator ?? null) === identity
|
|
84
|
+
|| ((record.creator ?? null) === null && (record.owner ?? null) === identity));
|
|
85
|
+
if (createdMine)
|
|
86
|
+
bump(bucketsMine, week, 'created');
|
|
87
|
+
}
|
|
88
|
+
const ownedMine = identity !== null && (record.owner ?? null) === identity;
|
|
89
|
+
bumpRemaining(bucketsAll, created, closed);
|
|
90
|
+
if (ownedMine)
|
|
91
|
+
bumpRemaining(bucketsMine, created, closed);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const toBuckets = (map) => weeks.map((slot, i) => ({
|
|
95
|
+
week: slot.week, end: weekEnds[i], ...(map.get(slot.week) ?? { done: 0, created: 0, remaining: 0 }),
|
|
96
|
+
}));
|
|
97
|
+
return { weekly: { all: toBuckets(bucketsAll), mine: toBuckets(bucketsMine) } };
|
|
98
|
+
}
|
|
99
|
+
/** 四个自然周期(只算「全部工作区」范围;图表切换不带动卡片)——纯函数。 */
|
|
100
|
+
export function computeTrendPeriods(scopes, now) {
|
|
101
|
+
const windows = {
|
|
102
|
+
thisWeek: weekWindow(0, now),
|
|
103
|
+
lastWeek: weekWindow(-1, now),
|
|
104
|
+
thisMonth: monthWindow(0, now),
|
|
105
|
+
lastMonth: monthWindow(-1, now),
|
|
106
|
+
};
|
|
107
|
+
const doneEntries = scopes.flatMap((scope) => scope.history.map((record) => ({ day: dayOf(record.time), owner: record.owner ?? null })));
|
|
108
|
+
const createdEntries = scopes.flatMap((scope) => [
|
|
109
|
+
...scope.tasks.map((task) => ({ day: dayOf(task.created), owner: task.owner ?? null })),
|
|
110
|
+
...scope.history.map((record) => ({ day: dayOf(record.created), owner: record.owner ?? null })),
|
|
111
|
+
]);
|
|
112
|
+
const period = (window) => ({
|
|
113
|
+
done: doneEntries.filter((entry) => inWindow(entry.day, window)).length,
|
|
114
|
+
created: createdEntries.filter((entry) => inWindow(entry.day, window)).length,
|
|
115
|
+
from: window.from,
|
|
116
|
+
to: window.to,
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
thisWeek: period(windows.thisWeek),
|
|
120
|
+
lastWeek: period(windows.lastWeek),
|
|
121
|
+
thisMonth: period(windows.thisMonth),
|
|
122
|
+
lastMonth: period(windows.lastMonth),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** 慢路径端点:趋势 + 周期(读 history)。异步加载,首屏不等。单工作区坏 ⇒ 进 degraded。 */
|
|
126
|
+
export async function getTrend(workspaces) {
|
|
127
|
+
const now = new Date();
|
|
128
|
+
const { data: scopes, degraded } = await settleWorkspaces(workspaces, 'history', readTrendScope, (rows) => rows);
|
|
129
|
+
return { closed: computeClosedSummary(scopes, now), trend: computeTrendPeriods(scopes, now), degraded };
|
|
130
|
+
}
|
|
131
|
+
/** 趋势端点用的历史 filter(窗口由本模块策略决定,repository 零默认值注入)。 */
|
|
132
|
+
export function trendHistoryFilter(extra = {}) {
|
|
133
|
+
return { months: RECENT_MONTHS, ...extra };
|
|
134
|
+
}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
/** 内嵌 skill 目录:dist/host/skill
|
|
8
|
-
|
|
7
|
+
/** 内嵌 skill 目录:dist/host/skill/registration.js → 包根 skill/(copy-skill 产物;
|
|
8
|
+
* 比原 host/skill-registration.js 深一层 ⇒ 相对路径多一级 `../`)。 */
|
|
9
|
+
const SKILL_DIR = fileURLToPath(new URL('../../../skill', import.meta.url));
|
|
9
10
|
/** SKILL.md 里指示脚本位置的占位符(注册时替换为绝对路径,模型据此找到脚本)。 */
|
|
10
11
|
const SKILL_DIR_TOKEN = '<本 SKILL.md 所在目录>';
|
|
11
12
|
/** frontmatter 最小解析:只取 name / description(不引 YAML 库;值去首尾引号)。 */
|