dsh-task-ledger 0.0.1 → 0.0.5

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/README.md CHANGED
@@ -3,6 +3,6 @@
3
3
  Name reserved for an upcoming [DeepSeek Harness](https://www.npmjs.com/package/@deepseek-ai/dsh) (dsh) plugin:
4
4
 
5
5
  - a **task ledger board** UI (per-workspace todos, history by owner, reports)
6
- - the **koc-ledger-progress** skill bundled for global agent use
6
+ - the **task-ledger** skill bundled for global agent use
7
7
 
8
8
  First functional release in development.
@@ -0,0 +1,6 @@
1
+ # bundle 补丁:把本 npm 包作为插件插入宿主组合(顶层 YAML 数组,条目 = loader patch 项)。
2
+ # 参考 dsh-task-dispatch-table/cordis.patch.yml 同款格式。
3
+ - insert:
4
+ - id: dsh-task-ledger
5
+ name: dsh-task-ledger
6
+ config: {}
@@ -0,0 +1,54 @@
1
+ import type { HistoryRecord, LedgerFilter, LedgerWorkspace, TaskRow } from './types.js';
2
+ /** 已结案列表默认分页大小(「加载更多」= 调用方加大 limit 重取)。 */
3
+ export declare const DEFAULT_HISTORY_LIMIT = 500;
4
+ /** 首页统计卡的两个完成类计数。 */
5
+ export interface ClosedCounts {
6
+ /** 本周已完成(最近 2 个月文件内、创建时间落在本周)。 */
7
+ readonly weekDone: number;
8
+ /** 近一年完成(最近 13 个月文件行数;capped = 已触顶,值即下限)。 */
9
+ readonly yearDone: number;
10
+ readonly yearCapped: boolean;
11
+ }
12
+ export interface BoardSnapshot {
13
+ readonly at: string;
14
+ readonly workspaces: readonly LedgerWorkspace[];
15
+ /** 全部在办任务(UI 自己筛「我的 / 待认领」——筛选是前端的事)。 */
16
+ readonly tasks: readonly TaskRow[];
17
+ readonly stats: {
18
+ readonly myActive: number;
19
+ readonly open: number;
20
+ };
21
+ }
22
+ /**
23
+ * 当前 git 身份(cwd = 该工作区)——「我的待办」判定源。
24
+ * 与 agent 在会话里跑 skill 时的取值环境一致;取不到回 null(非 git 目录 / 没配身份),
25
+ * 该工作区的「我的」统计按 0 计(不谎报)。
26
+ */
27
+ export declare function getCurrentIdentity(workspace: LedgerWorkspace): Promise<string | null>;
28
+ /**
29
+ * 首页快照(在办路径):并发跑全部工作区的 list + 身份解析。
30
+ * 与历史量彻底无关——progress.json 只装没结案的任务(close 会移走),永远有界。
31
+ */
32
+ export declare function getBoardSnapshot(workspaces: readonly LedgerWorkspace[]): Promise<BoardSnapshot>;
33
+ /**
34
+ * 完成类统计(数行封顶,与总量脱钩):本周 = 最近 2 个月文件 + 创建时间落在本周;
35
+ * 近一年 = 最近 13 个月文件行数。任一工作区失败即抛(聚合数部分失败 = 错数,不兜)。
36
+ */
37
+ export declare function getClosedCounts(workspaces: readonly LedgerWorkspace[]): Promise<ClosedCounts>;
38
+ /**
39
+ * 已结案列表:默认近 13 个月 + limit 500,跨工作区合并后按创建时间倒序。
40
+ * shortcut: 分页用「加大 limit 重取」的笨办法(skill 脚本无 offset);
41
+ * 单工作区内 limit 已生效,合并边界的全局精确排序暂不保证(500 条一页无感)。
42
+ * 要全量老数据:调用方显式传 months: undefined(不拼 --months,脚本读全部,按秒计)。
43
+ */
44
+ export declare function queryClosed(workspaces: readonly LedgerWorkspace[], filter?: LedgerFilter): Promise<HistoryRecord[]>;
45
+ export interface FilterOptions {
46
+ readonly owners: string[];
47
+ readonly categories: string[];
48
+ readonly tags: string[];
49
+ }
50
+ /**
51
+ * 筛选项候选:在办(全量)+ 近 13 个月历史的去重值(owner / category / tag)。
52
+ * 按需调用(/filters 路由),非首屏——历史侧在极端量级下是百毫秒级。
53
+ */
54
+ export declare function getFilterOptions(workspaces: readonly LedgerWorkspace[]): Promise<FilterOptions>;
@@ -0,0 +1,125 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readHistory, readTasks } from './ledger-read.js';
3
+ import { runLedgerScript } from './script.js';
4
+ /** 历史窗口:只读最近 13 个月的文件(2026-10-17 拍板;脚本默认读全部,窗口由插件显式传)。 */
5
+ const RECENT_MONTHS = 13;
6
+ /** 已结案列表默认分页大小(「加载更多」= 调用方加大 limit 重取)。 */
7
+ export const DEFAULT_HISTORY_LIMIT = 500;
8
+ /** 完成类统计的封顶值:超出显示「1万+」(2026-10-17 拍板,显示级封顶,精确值走搜索)。 */
9
+ const COUNT_CAP = 10_000;
10
+ /** 周一所在日期(YYYY-MM-DD)。skill 的 --since 只给日期按当天 00:00 解析。 */
11
+ function mondayDateString(now) {
12
+ const date = new Date(now);
13
+ const mondayOffset = (date.getDay() + 6) % 7;
14
+ date.setDate(date.getDate() - mondayOffset);
15
+ const pad = (value) => String(value).padStart(2, '0');
16
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
17
+ }
18
+ /**
19
+ * 当前 git 身份(cwd = 该工作区)——「我的待办」判定源。
20
+ * 与 agent 在会话里跑 skill 时的取值环境一致;取不到回 null(非 git 目录 / 没配身份),
21
+ * 该工作区的「我的」统计按 0 计(不谎报)。
22
+ */
23
+ export function getCurrentIdentity(workspace) {
24
+ return new Promise((resolve) => {
25
+ const child = spawn('git', ['config', 'user.name'], { cwd: workspace.path, stdio: ['ignore', 'pipe', 'ignore'] });
26
+ let stdout = '';
27
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
28
+ child.on('error', () => { resolve(null); });
29
+ child.on('close', (code) => {
30
+ const name = stdout.trim();
31
+ resolve(code === 0 && name !== '' ? name : null);
32
+ });
33
+ });
34
+ }
35
+ /**
36
+ * 首页快照(在办路径):并发跑全部工作区的 list + 身份解析。
37
+ * 与历史量彻底无关——progress.json 只装没结案的任务(close 会移走),永远有界。
38
+ */
39
+ export async function getBoardSnapshot(workspaces) {
40
+ const perWorkspace = await Promise.all(workspaces.map(async (workspace) => ({
41
+ workspace,
42
+ tasks: await readTasks(workspace),
43
+ identity: await getCurrentIdentity(workspace),
44
+ })));
45
+ const tasks = perWorkspace.flatMap((entry) => entry.tasks);
46
+ 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;
48
+ return { at: new Date().toISOString(), workspaces, tasks, stats: { myActive, open } };
49
+ }
50
+ /**
51
+ * 完成类统计(数行封顶,与总量脱钩):本周 = 最近 2 个月文件 + 创建时间落在本周;
52
+ * 近一年 = 最近 13 个月文件行数。任一工作区失败即抛(聚合数部分失败 = 错数,不兜)。
53
+ */
54
+ export async function getClosedCounts(workspaces) {
55
+ const monday = mondayDateString(new Date());
56
+ const perWorkspace = await Promise.all(workspaces.map(async (workspace) => {
57
+ const [week, year] = await Promise.all([
58
+ runLedgerScript('history', ['--repo', workspace.path, '--json', '--count-only', '--months', '2', '--since', monday]),
59
+ runLedgerScript('history', ['--repo', workspace.path, '--json', '--count-only', '--months', String(RECENT_MONTHS)]),
60
+ ]);
61
+ if (!week.ok)
62
+ throw new Error(`${workspace.title}: ${week.error}`);
63
+ if (!year.ok)
64
+ throw new Error(`${workspace.title}: ${year.error}`);
65
+ return { week: week.data.count ?? 0, year: year.data.count ?? 0, yearCapped: year.data.capped === true };
66
+ }));
67
+ const yearCapped = perWorkspace.some((entry) => entry.yearCapped);
68
+ return {
69
+ weekDone: perWorkspace.reduce((sum, entry) => sum + entry.week, 0),
70
+ yearDone: yearCapped ? COUNT_CAP : perWorkspace.reduce((sum, entry) => sum + entry.year, 0),
71
+ yearCapped,
72
+ };
73
+ }
74
+ /**
75
+ * 已结案列表:默认近 13 个月 + limit 500,跨工作区合并后按创建时间倒序。
76
+ * shortcut: 分页用「加大 limit 重取」的笨办法(skill 脚本无 offset);
77
+ * 单工作区内 limit 已生效,合并边界的全局精确排序暂不保证(500 条一页无感)。
78
+ * 要全量老数据:调用方显式传 months: undefined(不拼 --months,脚本读全部,按秒计)。
79
+ */
80
+ export async function queryClosed(workspaces, filter = {}) {
81
+ const windowed = { months: RECENT_MONTHS, ...filter };
82
+ const perWorkspace = await Promise.all(workspaces.map((workspace) => readHistory(workspace, windowed)));
83
+ return perWorkspace.flat()
84
+ .sort((a, b) => String(b.created ?? b.time ?? '').localeCompare(String(a.created ?? a.time ?? '')))
85
+ .slice(0, filter.limit ?? DEFAULT_HISTORY_LIMIT);
86
+ }
87
+ /**
88
+ * 筛选项候选:在办(全量)+ 近 13 个月历史的去重值(owner / category / tag)。
89
+ * 按需调用(/filters 路由),非首屏——历史侧在极端量级下是百毫秒级。
90
+ */
91
+ export async function getFilterOptions(workspaces) {
92
+ const perWorkspace = await Promise.all(workspaces.map(async (workspace) => ({
93
+ tasks: await readTasks(workspace),
94
+ history: await readHistory(workspace, { months: RECENT_MONTHS }),
95
+ })));
96
+ const owners = new Set();
97
+ const categories = new Set();
98
+ const tags = new Set();
99
+ const addTask = (task) => {
100
+ if (task.owner !== undefined && task.owner !== null && task.owner !== '')
101
+ owners.add(task.owner);
102
+ if (task.creator !== undefined && task.creator !== null && task.creator !== '')
103
+ owners.add(task.creator);
104
+ if (task.category !== undefined && task.category !== null && task.category !== '')
105
+ categories.add(task.category);
106
+ for (const tag of task.tags ?? [])
107
+ tags.add(tag);
108
+ };
109
+ const addRecord = (record) => {
110
+ if (record.owner !== undefined && record.owner !== '')
111
+ owners.add(record.owner);
112
+ if (record.category !== undefined && record.category !== null && record.category !== '')
113
+ categories.add(record.category);
114
+ for (const tag of record.tags ?? [])
115
+ tags.add(tag);
116
+ };
117
+ for (const entry of perWorkspace) {
118
+ for (const task of entry.tasks)
119
+ addTask(task);
120
+ for (const record of entry.history)
121
+ addRecord(record);
122
+ }
123
+ const sorted = (values) => [...values].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
124
+ return { owners: sorted(owners), categories: sorted(categories), tags: sorted(tags) };
125
+ }
@@ -0,0 +1,20 @@
1
+ import type { HistoryRecord, LedgerFilter, LedgerWorkspace, TaskRow } from './types.js';
2
+ /**
3
+ * LedgerFilter → skill 脚本参数(只拼调用方给了的;undefined 一律不拼,
4
+ * 让脚本走自己的默认——插件不替脚本决定默认值)。
5
+ */
6
+ export declare function filterArgs(filter: LedgerFilter): string[];
7
+ /**
8
+ * 在办任务(包 `list --json`;结果打上 workspace)。
9
+ * 脚本失败抛错——路由层转 500 + 人话,不静默返空(空 = 撒谎)。
10
+ */
11
+ export declare function readTasks(workspace: LedgerWorkspace, filter?: LedgerFilter): Promise<TaskRow[]>;
12
+ /** 已结案记录(包 `history --json`;结果打上 workspace)。 */
13
+ export declare function readHistory(workspace: LedgerWorkspace, filter?: LedgerFilter): Promise<HistoryRecord[]>;
14
+ /** 单条在办(展开行 / 详情用)。取不到回 null,不抛。 */
15
+ export declare function getTask(workspace: LedgerWorkspace, taskId: string): Promise<TaskRow | null>;
16
+ /**
17
+ * 过程文档正文(`task.worklog` 相对 `ledger/` 的路径)。
18
+ * 读不到回 null(文件被清 / 路径坏都不炸——由调用方展示「过程文档缺失」)。
19
+ */
20
+ export declare function readWorklog(workspace: LedgerWorkspace, task: TaskRow): Promise<string | null>;
@@ -0,0 +1,64 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { runLedgerScript } from './script.js';
4
+ /**
5
+ * LedgerFilter → skill 脚本参数(只拼调用方给了的;undefined 一律不拼,
6
+ * 让脚本走自己的默认——插件不替脚本决定默认值)。
7
+ */
8
+ export function filterArgs(filter) {
9
+ const args = [];
10
+ if (filter.status !== undefined)
11
+ args.push('--status', filter.status);
12
+ if (filter.owner !== undefined)
13
+ args.push('--owner', filter.owner);
14
+ if (filter.category !== undefined)
15
+ args.push('--category', filter.category);
16
+ if (filter.tag !== undefined)
17
+ args.push('--tag', filter.tag);
18
+ if (filter.since !== undefined)
19
+ args.push('--since', filter.since);
20
+ if (filter.until !== undefined)
21
+ args.push('--until', filter.until);
22
+ if (filter.limit !== undefined)
23
+ args.push('--limit', String(filter.limit));
24
+ if (filter.months !== undefined)
25
+ args.push('--months', String(filter.months));
26
+ return args;
27
+ }
28
+ /**
29
+ * 在办任务(包 `list --json`;结果打上 workspace)。
30
+ * 脚本失败抛错——路由层转 500 + 人话,不静默返空(空 = 撒谎)。
31
+ */
32
+ export async function readTasks(workspace, filter = {}) {
33
+ const result = await runLedgerScript('list', ['--repo', workspace.path, '--json', ...filterArgs(filter)]);
34
+ if (!result.ok)
35
+ throw new Error(`${workspace.title}: ${result.error}`);
36
+ return Object.entries(result.data).map(([id, task]) => ({ ...task, id, workspace }));
37
+ }
38
+ /** 已结案记录(包 `history --json`;结果打上 workspace)。 */
39
+ export async function readHistory(workspace, filter = {}) {
40
+ const result = await runLedgerScript('history', ['--repo', workspace.path, '--json', ...filterArgs(filter)]);
41
+ if (!result.ok)
42
+ throw new Error(`${workspace.title}: ${result.error}`);
43
+ return (result.data.records ?? []).map((record) => ({ ...record, workspace }));
44
+ }
45
+ /** 单条在办(展开行 / 详情用)。取不到回 null,不抛。 */
46
+ export async function getTask(workspace, taskId) {
47
+ const rows = await readTasks(workspace);
48
+ return rows.find((row) => row.id === taskId) ?? null;
49
+ }
50
+ /**
51
+ * 过程文档正文(`task.worklog` 相对 `ledger/` 的路径)。
52
+ * 读不到回 null(文件被清 / 路径坏都不炸——由调用方展示「过程文档缺失」)。
53
+ */
54
+ export async function readWorklog(workspace, task) {
55
+ const relative = task.worklog;
56
+ if (relative === undefined || relative === null || relative === '')
57
+ return null;
58
+ try {
59
+ return await readFile(path.join(workspace.path, 'ledger', relative), 'utf-8');
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 数据面路由薄壳:只做协议(参数解析 + JSON 响应 + 错误转 500),
3
+ * 一行业务逻辑都不多写——全部调 board / ledger-read 的方法(2026-10-17 定稿)。
4
+ * 宿主事实(handler = 原始 Node req/res、发头后不抛错、重复 path 注册直接 throw)
5
+ * 见 external/host-webserver.md。
6
+ */
7
+ import type { IncomingMessage, ServerResponse } from 'node:http';
8
+ import { type RegistryWorkspace } from './workspace.js';
9
+ /** 宿主 webServer.register 的最小面(WebRoute 形状见 external/host-webserver.md)。 */
10
+ export interface WebServerLike {
11
+ register(route: {
12
+ kind: 'exact' | 'prefix';
13
+ path: string;
14
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
15
+ }): unknown;
16
+ }
17
+ /** 宿主 workspaceRegistry 的最小面(事实见 external/dsh-workspace-registry.md)。 */
18
+ export interface WorkspaceRegistryLike {
19
+ list(): readonly RegistryWorkspace[];
20
+ }
21
+ export interface RouteDeps {
22
+ readonly registry: WorkspaceRegistryLike;
23
+ }
24
+ export declare function registerRoutes(webServer: WebServerLike, deps: RouteDeps): void;
@@ -0,0 +1,140 @@
1
+ import { getBoardSnapshot, getClosedCounts, getFilterOptions, queryClosed } from './board.js';
2
+ import { getTask, readTasks, readWorklog } from './ledger-read.js';
3
+ import { resolveWorkspaces } from './workspace.js';
4
+ /** API 前缀(唯一真源就在这一处)。 */
5
+ const API_PREFIX = '/api/task-ledger';
6
+ function sendJson(res, status, body) {
7
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
8
+ res.end(JSON.stringify(body));
9
+ }
10
+ /** URL query → LedgerFilter(只认白名单键;limit/months 非正数忽略)。 */
11
+ function filterFromQuery(url) {
12
+ const filter = {};
13
+ const str = (key) => {
14
+ const value = url.searchParams.get(key);
15
+ return value !== null && value !== '' ? value : null;
16
+ };
17
+ const int = (key) => {
18
+ const raw = str(key);
19
+ if (raw === null)
20
+ return undefined;
21
+ const value = Number.parseInt(raw, 10);
22
+ return Number.isFinite(value) && value > 0 ? value : undefined;
23
+ };
24
+ const status = str('status');
25
+ if (status !== null)
26
+ filter.status = status;
27
+ const owner = str('owner');
28
+ if (owner !== null)
29
+ filter.owner = owner;
30
+ const category = str('category');
31
+ if (category !== null)
32
+ filter.category = category;
33
+ const tag = str('tag');
34
+ if (tag !== null)
35
+ filter.tag = tag;
36
+ const since = str('since');
37
+ if (since !== null)
38
+ filter.since = since;
39
+ const until = str('until');
40
+ if (until !== null)
41
+ filter.until = until;
42
+ const limit = int('limit');
43
+ if (limit !== undefined)
44
+ filter.limit = limit;
45
+ const months = int('months');
46
+ if (months !== undefined)
47
+ filter.months = months;
48
+ return filter;
49
+ }
50
+ /** 每请求重新解析工作区(registry 可能刚变;existsSync × N 是微秒级,不缓存)。 */
51
+ function workspacesOf(deps) {
52
+ return resolveWorkspaces(deps.registry.list());
53
+ }
54
+ export function registerRoutes(webServer, deps) {
55
+ webServer.register({
56
+ kind: 'exact',
57
+ path: `${API_PREFIX}/workspaces`,
58
+ handler: (_req, res) => { sendJson(res, 200, { workspaces: workspacesOf(deps) }); },
59
+ });
60
+ // 首页一次拿全:在办快照 + 两个完成类统计。
61
+ webServer.register({
62
+ kind: 'exact',
63
+ path: `${API_PREFIX}/board`,
64
+ handler: async (_req, res) => {
65
+ try {
66
+ const workspaces = workspacesOf(deps);
67
+ const [snapshot, closed] = await Promise.all([getBoardSnapshot(workspaces), getClosedCounts(workspaces)]);
68
+ sendJson(res, 200, { ...snapshot, closed });
69
+ }
70
+ catch (error) {
71
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
72
+ }
73
+ },
74
+ });
75
+ webServer.register({
76
+ kind: 'exact',
77
+ path: `${API_PREFIX}/tasks`,
78
+ handler: async (req, res) => {
79
+ try {
80
+ const filter = filterFromQuery(new URL(req.url ?? '/', 'http://localhost'));
81
+ const perWorkspace = await Promise.all(workspacesOf(deps).map((workspace) => readTasks(workspace, filter)));
82
+ sendJson(res, 200, { tasks: perWorkspace.flat() });
83
+ }
84
+ catch (error) {
85
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
86
+ }
87
+ },
88
+ });
89
+ // 任务详情 + 过程文档正文(query 传 ws/id,不做路径段解析——任务号含中文和连字符)。
90
+ webServer.register({
91
+ kind: 'exact',
92
+ path: `${API_PREFIX}/task`,
93
+ handler: async (req, res) => {
94
+ try {
95
+ const url = new URL(req.url ?? '/', 'http://localhost');
96
+ const workspace = workspacesOf(deps).find((item) => item.id === url.searchParams.get('ws'));
97
+ const taskId = url.searchParams.get('id');
98
+ if (workspace === undefined || taskId === null) {
99
+ sendJson(res, 400, { error: '缺少 ws / id 参数' });
100
+ return;
101
+ }
102
+ const task = await getTask(workspace, taskId);
103
+ if (task === null) {
104
+ sendJson(res, 404, { error: '任务不存在' });
105
+ return;
106
+ }
107
+ sendJson(res, 200, { task, worklog: await readWorklog(workspace, task) });
108
+ }
109
+ catch (error) {
110
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
111
+ }
112
+ },
113
+ });
114
+ webServer.register({
115
+ kind: 'exact',
116
+ path: `${API_PREFIX}/history`,
117
+ handler: async (req, res) => {
118
+ try {
119
+ const filter = filterFromQuery(new URL(req.url ?? '/', 'http://localhost'));
120
+ sendJson(res, 200, { records: await queryClosed(workspacesOf(deps), filter) });
121
+ }
122
+ catch (error) {
123
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
124
+ }
125
+ },
126
+ });
127
+ webServer.register({
128
+ kind: 'exact',
129
+ path: `${API_PREFIX}/filters`,
130
+ handler: async (_req, res) => {
131
+ try {
132
+ sendJson(res, 200, await getFilterOptions(workspacesOf(deps)));
133
+ }
134
+ catch (error) {
135
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
136
+ }
137
+ },
138
+ });
139
+ // POST /sessions(按钮会话交接)归「任务按钮与会话交接」任务——写入路径 A/B 拍板后再接。
140
+ }
@@ -0,0 +1,23 @@
1
+ export type LedgerScriptResult<T> = {
2
+ ok: true;
3
+ data: T;
4
+ } | {
5
+ ok: false;
6
+ error: string;
7
+ };
8
+ /**
9
+ * 全插件唯一碰 Python 的地方:spawn python3 跑内嵌 skill 脚本,收 stdout JSON。
10
+ * 不解析任何台账格式——解析真源只有 skill 脚本一份(2026-10-17 用户拍板)。
11
+ *
12
+ * 出错一律归一为 { ok:false, error }(人话,直接可上界面):
13
+ * 起不了 python3 / 超时(kill)/ 非零退出(stderr 原文)/ 输出不是 JSON。
14
+ *
15
+ * @param sub - 子命令(list / history / show…)
16
+ * @param args - 参数数组,由调用方自拼(含 --repo / --json / --months 等)
17
+ * @param opts.scriptPath - 测试用:覆盖脚本路径
18
+ * @param opts.timeoutMs - 覆盖超时
19
+ */
20
+ export declare function runLedgerScript<T = unknown>(sub: string, args: readonly string[], opts?: {
21
+ scriptPath?: string;
22
+ timeoutMs?: number;
23
+ }): Promise<LedgerScriptResult<T>>;
@@ -0,0 +1,59 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ /**
4
+ * 内嵌 skill 脚本的默认路径:dist/host/script.js → 包根 skill/scripts/progress.py。
5
+ * 构建时 scripts/copy-skill.mjs 把 packages/skill 拷到 packages/plugin/skill/。
6
+ */
7
+ const DEFAULT_SCRIPT_PATH = fileURLToPath(new URL('../../skill/scripts/progress.py', import.meta.url));
8
+ /** 单次脚本调用超时上限(ms)。实测单仓 5 万行全量 1.2s,10s 是宽松上限。 */
9
+ const DEFAULT_TIMEOUT_MS = 10_000;
10
+ /**
11
+ * 全插件唯一碰 Python 的地方:spawn python3 跑内嵌 skill 脚本,收 stdout JSON。
12
+ * 不解析任何台账格式——解析真源只有 skill 脚本一份(2026-10-17 用户拍板)。
13
+ *
14
+ * 出错一律归一为 { ok:false, error }(人话,直接可上界面):
15
+ * 起不了 python3 / 超时(kill)/ 非零退出(stderr 原文)/ 输出不是 JSON。
16
+ *
17
+ * @param sub - 子命令(list / history / show…)
18
+ * @param args - 参数数组,由调用方自拼(含 --repo / --json / --months 等)
19
+ * @param opts.scriptPath - 测试用:覆盖脚本路径
20
+ * @param opts.timeoutMs - 覆盖超时
21
+ */
22
+ export function runLedgerScript(sub, args, opts) {
23
+ const scriptPath = opts?.scriptPath ?? DEFAULT_SCRIPT_PATH;
24
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
25
+ return new Promise((resolve) => {
26
+ const child = spawn('python3', [scriptPath, sub, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
27
+ let stdout = '';
28
+ let stderr = '';
29
+ let settled = false;
30
+ const timer = setTimeout(() => {
31
+ child.kill();
32
+ settle({ ok: false, error: `skill 脚本超时(>${timeoutMs}ms):${sub} ${args.join(' ')}` });
33
+ }, timeoutMs);
34
+ const settle = (result) => {
35
+ if (settled)
36
+ return;
37
+ settled = true;
38
+ clearTimeout(timer);
39
+ resolve(result);
40
+ };
41
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
42
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
43
+ child.on('error', (error) => {
44
+ settle({ ok: false, error: `起不了 python3(${error.message})——本插件需要 python3 运行内嵌 skill 脚本` });
45
+ });
46
+ child.on('close', (code) => {
47
+ if (code !== 0) {
48
+ settle({ ok: false, error: stderr.trim() || `skill 脚本退出码 ${String(code)}` });
49
+ return;
50
+ }
51
+ try {
52
+ settle({ ok: true, data: JSON.parse(stdout) });
53
+ }
54
+ catch {
55
+ settle({ ok: false, error: `skill 脚本输出不是 JSON:${stdout.slice(0, 200)}` });
56
+ }
57
+ });
58
+ });
59
+ }
@@ -0,0 +1,27 @@
1
+ /** 宿主 ctx.skills.register 的最小面(外部事实文档的结构面)。 */
2
+ export interface SkillRegistration {
3
+ name: string;
4
+ description: string;
5
+ content: string;
6
+ whenToUse?: string;
7
+ path?: string;
8
+ source?: string;
9
+ resourceBase?: {
10
+ kind: 'directory';
11
+ path: string;
12
+ };
13
+ metadata?: Record<string, unknown>;
14
+ }
15
+ /** 宿主 skills 服务的最小面。 */
16
+ export interface SkillsRegistry {
17
+ register(skill: SkillRegistration): () => void;
18
+ }
19
+ /**
20
+ * 组装注册体:读内嵌 SKILL.md → 取 name / description → 正文里的脚本路径占位符
21
+ * 替换为绝对路径。
22
+ *
23
+ * 相对资源解析的二选一(external/dsh-skill-registration.md §三)**未拍板**;
24
+ * 最小闭环选「插值绝对路径」——resourceBase 的消费者呈现未验证,不押注。
25
+ * 拍板后如改走 resourceBase,只动本函数,调用方无感。
26
+ */
27
+ export declare function buildSkillRegistration(skillDir?: string): SkillRegistration;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 内嵌 skill 的注册体组装(任务 2「skill 注册最小闭环」)。
3
+ * 宿主事实(0.2.0-rc.2 npm pack 亲验):external/dsh-skill-registration.md。
4
+ */
5
+ import { readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ /** 内嵌 skill 目录:dist/host/skill-registration.js → 包根 skill/(copy-skill 产物)。 */
8
+ const SKILL_DIR = fileURLToPath(new URL('../../skill', import.meta.url));
9
+ /** SKILL.md 里指示脚本位置的占位符(注册时替换为绝对路径,模型据此找到脚本)。 */
10
+ const SKILL_DIR_TOKEN = '<本 SKILL.md 所在目录>';
11
+ /** frontmatter 最小解析:只取 name / description(不引 YAML 库;值去首尾引号)。 */
12
+ function parseFrontmatter(text) {
13
+ const match = /^---\n([\s\S]*?)\n---/.exec(text);
14
+ if (match === null)
15
+ throw new Error('SKILL.md 缺 frontmatter(--- 包裹的头)');
16
+ const field = (key) => {
17
+ const line = (match[1] ?? '').split('\n').find((item) => item.startsWith(`${key}:`));
18
+ if (line === undefined)
19
+ throw new Error(`SKILL.md frontmatter 缺 ${key}`);
20
+ return line.slice(key.length + 1).trim().replace(/^["']|["']$/g, '');
21
+ };
22
+ return { name: field('name'), description: field('description') };
23
+ }
24
+ /**
25
+ * 组装注册体:读内嵌 SKILL.md → 取 name / description → 正文里的脚本路径占位符
26
+ * 替换为绝对路径。
27
+ *
28
+ * 相对资源解析的二选一(external/dsh-skill-registration.md §三)**未拍板**;
29
+ * 最小闭环选「插值绝对路径」——resourceBase 的消费者呈现未验证,不押注。
30
+ * 拍板后如改走 resourceBase,只动本函数,调用方无感。
31
+ */
32
+ export function buildSkillRegistration(skillDir = SKILL_DIR) {
33
+ const text = readFileSync(`${skillDir}/SKILL.md`, 'utf-8');
34
+ const { name, description } = parseFrontmatter(text);
35
+ const content = text.split(SKILL_DIR_TOKEN).join(skillDir);
36
+ if (content.includes(SKILL_DIR_TOKEN)) {
37
+ throw new Error('占位符替换失败:SKILL.md 里仍残留脚本路径占位符');
38
+ }
39
+ if (!content.includes(`${skillDir}/scripts/progress.py`)) {
40
+ throw new Error('占位符替换异常:替换后找不到脚本路径');
41
+ }
42
+ return { name, description, content, path: `${skillDir}/SKILL.md` };
43
+ }