kankaku 0.7.0 → 0.8.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/README.md +93 -23
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -0
- package/dist/adapters/project-config.d.ts +42 -0
- package/dist/adapters/project-config.js +108 -0
- package/dist/adapters/report-data.d.ts +12 -0
- package/dist/adapters/report-data.js +8 -0
- package/dist/adapters/report-views.d.ts +45 -0
- package/dist/adapters/report-views.js +73 -0
- package/dist/adapters/report.d.ts +112 -0
- package/dist/adapters/report.js +236 -0
- package/dist/adapters/sync-runner.d.ts +21 -6
- package/dist/adapters/sync-runner.js +8 -1
- package/dist/domain/hub-entry.d.ts +26 -2
- package/dist/domain/hub-entry.js +50 -6
- package/dist/domain/sync-plan.js +10 -0
- package/dist/domain/work-record.d.ts +17 -0
- package/dist/domain/work-record.js +5 -1
- package/dist/hub/index.d.ts +6 -0
- package/dist/hub/index.js +6 -0
- package/package.json +1 -1
- package/src/adapters/hub-actions.ts +2 -3
- package/src/adapters/kankaku-command.ts +5 -9
- package/src/adapters/panel/kankaku-panel.ts +164 -19
- package/src/adapters/panel/panel-items.ts +13 -3
- package/src/adapters/panel/screens/doctor.ts +4 -0
- package/src/adapters/panel/screens/export.ts +4 -0
- package/src/adapters/panel/screens/report.ts +2 -0
- package/src/adapters/panel/screens/sync.ts +10 -0
- package/src/adapters/panel/screens/target.ts +18 -1
- package/src/adapters/pi-tracker.ts +8 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +1 -1
- package/src/adapters/sync-runner.ts +21 -7
- package/src/domain/hub-entry.ts +87 -8
- package/src/domain/panel-model.ts +10 -7
- package/src/domain/sync-plan.ts +10 -0
- package/src/domain/work-record.ts +22 -1
- package/src/hub/index.ts +6 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { SessionView, TaskView } from "../domain/task-view.ts";
|
|
2
|
+
import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
|
|
3
|
+
export { localDay } from "../domain/day.ts";
|
|
4
|
+
export interface RoleTotals {
|
|
5
|
+
workMs: number;
|
|
6
|
+
waitingMs: number;
|
|
7
|
+
wallMs: number;
|
|
8
|
+
count: number;
|
|
9
|
+
/** Estimated cost in USD, as priced by pi's model table. */
|
|
10
|
+
cost: number;
|
|
11
|
+
/** Prompt input tokens, summed across every record of this role. */
|
|
12
|
+
input: number;
|
|
13
|
+
/** Prompt-cache read tokens, summed across every record of this role. */
|
|
14
|
+
cacheRead: number;
|
|
15
|
+
/** Prompt-cache write tokens, summed across every record of this role. */
|
|
16
|
+
cacheWrite: number;
|
|
17
|
+
/** Per-tag total milliseconds summed across every record of this role. */
|
|
18
|
+
segments: Record<string, number>;
|
|
19
|
+
}
|
|
20
|
+
export interface TaskTotals {
|
|
21
|
+
count: number;
|
|
22
|
+
wallMs: number;
|
|
23
|
+
workMs: number;
|
|
24
|
+
/** Estimated cost in USD, orchestrator and subagents combined. */
|
|
25
|
+
cost: number;
|
|
26
|
+
/** Prompt input tokens, orchestrator and subagents combined. */
|
|
27
|
+
input: number;
|
|
28
|
+
/** Prompt-cache read tokens, orchestrator and subagents combined. */
|
|
29
|
+
cacheRead: number;
|
|
30
|
+
/** Prompt-cache write tokens, orchestrator and subagents combined. */
|
|
31
|
+
cacheWrite: number;
|
|
32
|
+
/** Per-tag total milliseconds summed across every task (orchestrator and subagents). */
|
|
33
|
+
segments: Record<string, number>;
|
|
34
|
+
}
|
|
35
|
+
export type Summary = Record<WorkRole, RoleTotals> & {
|
|
36
|
+
tasks: TaskTotals;
|
|
37
|
+
};
|
|
38
|
+
export interface SummarizeOptions {
|
|
39
|
+
/** Local day in `YYYY-MM-DD` format. Defaults to today when `all` is not set. */
|
|
40
|
+
day?: string;
|
|
41
|
+
/** Include every record regardless of day. */
|
|
42
|
+
all?: boolean;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Aggregate work records by role, restricted to one local day unless `all`
|
|
46
|
+
* is set. Also computes a `tasks` segment (union-based `wallMs`/`workMs`
|
|
47
|
+
* over one orchestrator run and its subagents) for tasks whose `startedAt`
|
|
48
|
+
* falls on the same day.
|
|
49
|
+
*/
|
|
50
|
+
export declare function summarize(records: WorkRecord[], options: SummarizeOptions): Summary;
|
|
51
|
+
/**
|
|
52
|
+
* Count of `uncertain` records (ADR 0022) within the same day scope
|
|
53
|
+
* `summarize` uses, so the `/kankaku` report can surface a one-line hint
|
|
54
|
+
* when some records are silently excluded from the task count
|
|
55
|
+
* (SUBAGENT-REQ-017): an undercount must never be silent.
|
|
56
|
+
*/
|
|
57
|
+
export declare function countUncertain(records: WorkRecord[], options: SummarizeOptions): number;
|
|
58
|
+
/** Render a short, human-readable summary for the `/kankaku` command. */
|
|
59
|
+
export declare function formatReport(summary: Summary): string;
|
|
60
|
+
/** Render one line per task: time, client (when present), union-based wall/work, cost, non-zero segment tags, subagent count, and a truncated prompt (`…` marks a cut). */
|
|
61
|
+
export declare function formatTasks(tasks: TaskView[]): string;
|
|
62
|
+
export interface ClientTotals {
|
|
63
|
+
wallMs: number;
|
|
64
|
+
waitingMs: number;
|
|
65
|
+
workMs: number;
|
|
66
|
+
/** Estimated cost in USD, summed across this client's tasks. */
|
|
67
|
+
cost: number;
|
|
68
|
+
/** Prompt input tokens, summed across this client's tasks. */
|
|
69
|
+
input: number;
|
|
70
|
+
/** Prompt-cache read tokens, summed across this client's tasks. */
|
|
71
|
+
cacheRead: number;
|
|
72
|
+
/** Prompt-cache write tokens, summed across this client's tasks. */
|
|
73
|
+
cacheWrite: number;
|
|
74
|
+
count: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Aggregate tasks by billing client (see `domain/client-label.ts`), summing
|
|
78
|
+
* work/waiting/wall time, cost, and task count. Tasks without a `client`
|
|
79
|
+
* are grouped under `"(none)"`. Returned as a `Map` rather than a plain
|
|
80
|
+
* object so an attacker-controlled client name can never repoint a
|
|
81
|
+
* prototype property.
|
|
82
|
+
*/
|
|
83
|
+
export declare function summarizeByClient(tasks: TaskView[]): Map<string, ClientTotals>;
|
|
84
|
+
export interface ProjectTotals {
|
|
85
|
+
/** Display name: the project's `projectName`, or `"(no project)"` for the ungrouped bucket. */
|
|
86
|
+
name: string;
|
|
87
|
+
wallMs: number;
|
|
88
|
+
waitingMs: number;
|
|
89
|
+
workMs: number;
|
|
90
|
+
/** Estimated cost in USD, summed across this project's tasks. */
|
|
91
|
+
cost: number;
|
|
92
|
+
/** Prompt input tokens, summed across this project's tasks. */
|
|
93
|
+
input: number;
|
|
94
|
+
/** Prompt-cache read tokens, summed across this project's tasks. */
|
|
95
|
+
cacheRead: number;
|
|
96
|
+
/** Prompt-cache write tokens, summed across this project's tasks. */
|
|
97
|
+
cacheWrite: number;
|
|
98
|
+
count: number;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Aggregate tasks by hub project (see `domain/work-target.ts`), keyed by
|
|
102
|
+
* `projectId` (so two projects that happen to share a display name are
|
|
103
|
+
* never merged) with the name denormalised alongside for display. Tasks
|
|
104
|
+
* without a `projectId` are grouped under `"(no project)"`.
|
|
105
|
+
*/
|
|
106
|
+
export declare function summarizeByProject(tasks: TaskView[]): Map<string, ProjectTotals>;
|
|
107
|
+
/** Render one line per project, sorted alphabetically by display name, with work/waiting/wall time, cost, and task count. */
|
|
108
|
+
export declare function formatProjects(totals: Map<string, ProjectTotals>): string;
|
|
109
|
+
/** Render one line per client, sorted alphabetically, with work/waiting/wall time, cost, and task count. */
|
|
110
|
+
export declare function formatClients(totals: Map<string, ClientTotals>): string;
|
|
111
|
+
/** Render one line per session: truncated id, time range, union-based wall/work, cost, non-zero segment tags, and task count. */
|
|
112
|
+
export declare function formatSessions(sessions: SessionView[]): string;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { buildTasks, uncertainRecords } from "../domain/task-view.js";
|
|
2
|
+
import { localDay } from "../domain/day.js";
|
|
3
|
+
import { cacheHitRatio, finiteOrZero } from "../domain/work-record.js";
|
|
4
|
+
export { localDay } from "../domain/day.js";
|
|
5
|
+
const ROLES = ["orchestrator", "subagent"];
|
|
6
|
+
function emptyTotals() {
|
|
7
|
+
return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} };
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Add per-tag milliseconds from `segments` (missing on older records) into
|
|
11
|
+
* `into`, a `Map` rather than a plain object so a tag from a hand-edited
|
|
12
|
+
* worklog line named `__proto__` or `constructor` accumulates as a normal
|
|
13
|
+
* entry instead of silently reading (and arithmetically corrupting) an
|
|
14
|
+
* inherited `Object.prototype` value.
|
|
15
|
+
*/
|
|
16
|
+
function addSegments(into, segments) {
|
|
17
|
+
for (const [tag, ms] of Object.entries(segments ?? {})) {
|
|
18
|
+
into.set(tag, (into.get(tag) ?? 0) + ms);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Aggregate work records by role, restricted to one local day unless `all`
|
|
23
|
+
* is set. Also computes a `tasks` segment (union-based `wallMs`/`workMs`
|
|
24
|
+
* over one orchestrator run and its subagents) for tasks whose `startedAt`
|
|
25
|
+
* falls on the same day.
|
|
26
|
+
*/
|
|
27
|
+
export function summarize(records, options) {
|
|
28
|
+
const targetDay = options.all ? undefined : (options.day ?? localDay(new Date().toISOString()));
|
|
29
|
+
const summary = {
|
|
30
|
+
orchestrator: emptyTotals(),
|
|
31
|
+
subagent: emptyTotals(),
|
|
32
|
+
tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} },
|
|
33
|
+
};
|
|
34
|
+
// Accumulated in Maps (see `addSegments`) and only converted to the
|
|
35
|
+
// returned plain objects at the very end, via `Object.fromEntries`.
|
|
36
|
+
const segmentsByRole = { orchestrator: new Map(), subagent: new Map() };
|
|
37
|
+
const taskSegments = new Map();
|
|
38
|
+
for (const record of records) {
|
|
39
|
+
if (targetDay !== undefined && localDay(record.startedAt) !== targetDay)
|
|
40
|
+
continue;
|
|
41
|
+
const totals = summary[record.role];
|
|
42
|
+
totals.workMs += record.workMs;
|
|
43
|
+
totals.waitingMs += record.waitingMs;
|
|
44
|
+
totals.wallMs += record.wallMs;
|
|
45
|
+
totals.count += 1;
|
|
46
|
+
totals.cost += finiteOrZero(record.usage.cost);
|
|
47
|
+
totals.input += finiteOrZero(record.usage.input);
|
|
48
|
+
totals.cacheRead += finiteOrZero(record.usage.cacheRead);
|
|
49
|
+
totals.cacheWrite += finiteOrZero(record.usage.cacheWrite);
|
|
50
|
+
addSegments(segmentsByRole[record.role], record.segments);
|
|
51
|
+
}
|
|
52
|
+
const tasks = buildTasks(records).filter((task) => targetDay === undefined || localDay(task.startedAt) === targetDay);
|
|
53
|
+
for (const task of tasks) {
|
|
54
|
+
summary.tasks.count += 1;
|
|
55
|
+
summary.tasks.wallMs += task.wallMs;
|
|
56
|
+
summary.tasks.workMs += task.workMs;
|
|
57
|
+
summary.tasks.cost += task.usage.cost;
|
|
58
|
+
summary.tasks.input += finiteOrZero(task.usage.input);
|
|
59
|
+
summary.tasks.cacheRead += finiteOrZero(task.usage.cacheRead);
|
|
60
|
+
summary.tasks.cacheWrite += finiteOrZero(task.usage.cacheWrite);
|
|
61
|
+
addSegments(taskSegments, task.segments);
|
|
62
|
+
}
|
|
63
|
+
summary.orchestrator.segments = Object.fromEntries(segmentsByRole.orchestrator);
|
|
64
|
+
summary.subagent.segments = Object.fromEntries(segmentsByRole.subagent);
|
|
65
|
+
summary.tasks.segments = Object.fromEntries(taskSegments);
|
|
66
|
+
return summary;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Count of `uncertain` records (ADR 0022) within the same day scope
|
|
70
|
+
* `summarize` uses, so the `/kankaku` report can surface a one-line hint
|
|
71
|
+
* when some records are silently excluded from the task count
|
|
72
|
+
* (SUBAGENT-REQ-017): an undercount must never be silent.
|
|
73
|
+
*/
|
|
74
|
+
export function countUncertain(records, options) {
|
|
75
|
+
const targetDay = options.all ? undefined : (options.day ?? localDay(new Date().toISOString()));
|
|
76
|
+
return uncertainRecords(records).filter((record) => targetDay === undefined || localDay(record.startedAt) === targetDay).length;
|
|
77
|
+
}
|
|
78
|
+
function formatMinutes(ms) {
|
|
79
|
+
const totalSeconds = Math.round(ms / 1000);
|
|
80
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
81
|
+
const seconds = totalSeconds % 60;
|
|
82
|
+
return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
83
|
+
}
|
|
84
|
+
/** Estimated USD cost with two decimals, e.g. `$1.23`. */
|
|
85
|
+
function formatCost(cost) {
|
|
86
|
+
return `$${cost.toFixed(2)}`;
|
|
87
|
+
}
|
|
88
|
+
function formatTime(iso) {
|
|
89
|
+
const date = new Date(iso);
|
|
90
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
91
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
92
|
+
return `${hours}:${minutes}`;
|
|
93
|
+
}
|
|
94
|
+
/** Render `cache hit NN%` (integer percent) for a usage totals triple, or `undefined` when the ratio is undefined (nothing recorded). */
|
|
95
|
+
function formatCacheHit(usage) {
|
|
96
|
+
const ratio = cacheHitRatio(usage);
|
|
97
|
+
return ratio === undefined ? undefined : `cache hit ${Math.round(ratio * 100)}%`;
|
|
98
|
+
}
|
|
99
|
+
/** Render non-zero segment tags as `tag Xm00s` pairs, sorted alphabetically, joined by `, `. Undefined when none are non-zero. */
|
|
100
|
+
function formatSegmentTags(segments) {
|
|
101
|
+
const tags = Object.keys(segments)
|
|
102
|
+
.filter((tag) => segments[tag] > 0)
|
|
103
|
+
.sort();
|
|
104
|
+
if (tags.length === 0)
|
|
105
|
+
return undefined;
|
|
106
|
+
return tags.map((tag) => `${tag} ${formatMinutes(segments[tag])}`).join(", ");
|
|
107
|
+
}
|
|
108
|
+
/** Render a short, human-readable summary for the `/kankaku` command. */
|
|
109
|
+
export function formatReport(summary) {
|
|
110
|
+
const lines = ROLES.map((role) => {
|
|
111
|
+
const totals = summary[role];
|
|
112
|
+
const cacheHit = formatCacheHit(totals);
|
|
113
|
+
const cacheHitPart = cacheHit !== undefined ? `, ${cacheHit}` : "";
|
|
114
|
+
return `${role}: work ${formatMinutes(totals.workMs)}, waiting ${formatMinutes(totals.waitingMs)}, ${totals.count} record(s), ${formatCost(totals.cost)}${cacheHitPart}`;
|
|
115
|
+
});
|
|
116
|
+
const tasksCacheHit = formatCacheHit(summary.tasks);
|
|
117
|
+
const tasksCacheHitPart = tasksCacheHit !== undefined ? `, ${tasksCacheHit}` : "";
|
|
118
|
+
lines.push(`tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}${tasksCacheHitPart}`);
|
|
119
|
+
const segmentTags = formatSegmentTags(summary.tasks.segments);
|
|
120
|
+
if (segmentTags !== undefined) {
|
|
121
|
+
lines.push(`segments: ${segmentTags}`);
|
|
122
|
+
}
|
|
123
|
+
return lines.join(" | ");
|
|
124
|
+
}
|
|
125
|
+
/** Maximum visible width of a task prompt before it is truncated with an ellipsis marker. */
|
|
126
|
+
const PROMPT_DISPLAY_LIMIT = 60;
|
|
127
|
+
/** Truncate `text` to `limit` visible characters, appending `…` (counted within the limit) when it was cut. */
|
|
128
|
+
function truncateWithEllipsis(text, limit) {
|
|
129
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
130
|
+
}
|
|
131
|
+
/** Render one line per task: time, client (when present), union-based wall/work, cost, non-zero segment tags, subagent count, and a truncated prompt (`…` marks a cut). */
|
|
132
|
+
export function formatTasks(tasks) {
|
|
133
|
+
if (tasks.length === 0)
|
|
134
|
+
return "no tasks";
|
|
135
|
+
return tasks
|
|
136
|
+
.map((task) => {
|
|
137
|
+
const prompt = truncateWithEllipsis(task.prompt, PROMPT_DISPLAY_LIMIT);
|
|
138
|
+
const segmentTags = formatSegmentTags(task.segments);
|
|
139
|
+
const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
|
|
140
|
+
const clientPart = task.client !== undefined ? ` client:${task.client}` : "";
|
|
141
|
+
const cacheHit = formatCacheHit(task.usage);
|
|
142
|
+
const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
|
|
143
|
+
return `${formatTime(task.startedAt)}${clientPart} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)}${cacheHitPart}${segmentPart} subagents ${task.subagents.length} ${prompt}`;
|
|
144
|
+
})
|
|
145
|
+
.join("\n");
|
|
146
|
+
}
|
|
147
|
+
/** Client name under which tasks without a resolved client are grouped. */
|
|
148
|
+
const NO_CLIENT = "(none)";
|
|
149
|
+
/**
|
|
150
|
+
* Aggregate tasks by billing client (see `domain/client-label.ts`), summing
|
|
151
|
+
* work/waiting/wall time, cost, and task count. Tasks without a `client`
|
|
152
|
+
* are grouped under `"(none)"`. Returned as a `Map` rather than a plain
|
|
153
|
+
* object so an attacker-controlled client name can never repoint a
|
|
154
|
+
* prototype property.
|
|
155
|
+
*/
|
|
156
|
+
export function summarizeByClient(tasks) {
|
|
157
|
+
const totals = new Map();
|
|
158
|
+
for (const task of tasks) {
|
|
159
|
+
const key = task.client ?? NO_CLIENT;
|
|
160
|
+
const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
|
|
161
|
+
entry.wallMs += task.wallMs;
|
|
162
|
+
entry.waitingMs += task.waitingMs;
|
|
163
|
+
entry.workMs += task.workMs;
|
|
164
|
+
entry.cost += finiteOrZero(task.usage.cost);
|
|
165
|
+
entry.input += finiteOrZero(task.usage.input);
|
|
166
|
+
entry.cacheRead += finiteOrZero(task.usage.cacheRead);
|
|
167
|
+
entry.cacheWrite += finiteOrZero(task.usage.cacheWrite);
|
|
168
|
+
entry.count += 1;
|
|
169
|
+
totals.set(key, entry);
|
|
170
|
+
}
|
|
171
|
+
return totals;
|
|
172
|
+
}
|
|
173
|
+
/** Key (and display name) under which tasks without a resolved project are grouped. */
|
|
174
|
+
const NO_PROJECT = "(no project)";
|
|
175
|
+
/**
|
|
176
|
+
* Aggregate tasks by hub project (see `domain/work-target.ts`), keyed by
|
|
177
|
+
* `projectId` (so two projects that happen to share a display name are
|
|
178
|
+
* never merged) with the name denormalised alongside for display. Tasks
|
|
179
|
+
* without a `projectId` are grouped under `"(no project)"`.
|
|
180
|
+
*/
|
|
181
|
+
export function summarizeByProject(tasks) {
|
|
182
|
+
const totals = new Map();
|
|
183
|
+
for (const task of tasks) {
|
|
184
|
+
const key = task.projectId ?? NO_PROJECT;
|
|
185
|
+
const name = task.projectId !== undefined ? (task.projectName ?? task.projectId) : NO_PROJECT;
|
|
186
|
+
const entry = totals.get(key) ?? { name, wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
|
|
187
|
+
entry.wallMs += task.wallMs;
|
|
188
|
+
entry.waitingMs += task.waitingMs;
|
|
189
|
+
entry.workMs += task.workMs;
|
|
190
|
+
entry.cost += finiteOrZero(task.usage.cost);
|
|
191
|
+
entry.input += finiteOrZero(task.usage.input);
|
|
192
|
+
entry.cacheRead += finiteOrZero(task.usage.cacheRead);
|
|
193
|
+
entry.cacheWrite += finiteOrZero(task.usage.cacheWrite);
|
|
194
|
+
entry.count += 1;
|
|
195
|
+
totals.set(key, entry);
|
|
196
|
+
}
|
|
197
|
+
return totals;
|
|
198
|
+
}
|
|
199
|
+
/** Render one line per project, sorted alphabetically by display name, with work/waiting/wall time, cost, and task count. */
|
|
200
|
+
export function formatProjects(totals) {
|
|
201
|
+
if (totals.size === 0)
|
|
202
|
+
return "no projects";
|
|
203
|
+
return Array.from(totals.values())
|
|
204
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
205
|
+
.map((t) => {
|
|
206
|
+
const cacheHit = formatCacheHit(t);
|
|
207
|
+
const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
|
|
208
|
+
return `${t.name} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)}${cacheHitPart} tasks ${t.count}`;
|
|
209
|
+
})
|
|
210
|
+
.join("\n");
|
|
211
|
+
}
|
|
212
|
+
/** Render one line per client, sorted alphabetically, with work/waiting/wall time, cost, and task count. */
|
|
213
|
+
export function formatClients(totals) {
|
|
214
|
+
if (totals.size === 0)
|
|
215
|
+
return "no clients";
|
|
216
|
+
return Array.from(totals.entries())
|
|
217
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
218
|
+
.map(([client, t]) => {
|
|
219
|
+
const cacheHit = formatCacheHit(t);
|
|
220
|
+
const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
|
|
221
|
+
return `${client} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)}${cacheHitPart} tasks ${t.count}`;
|
|
222
|
+
})
|
|
223
|
+
.join("\n");
|
|
224
|
+
}
|
|
225
|
+
/** Render one line per session: truncated id, time range, union-based wall/work, cost, non-zero segment tags, and task count. */
|
|
226
|
+
export function formatSessions(sessions) {
|
|
227
|
+
if (sessions.length === 0)
|
|
228
|
+
return "no sessions";
|
|
229
|
+
return sessions
|
|
230
|
+
.map((session) => {
|
|
231
|
+
const segmentTags = formatSegmentTags(session.segments);
|
|
232
|
+
const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
|
|
233
|
+
return `${session.sessionId.slice(0, 8)} ${formatTime(session.startedAt)}–${formatTime(session.endedAt)} wall ${formatMinutes(session.wallMs)} work ${formatMinutes(session.workMs)} ${formatCost(session.usage.cost)}${segmentPart} tasks ${session.tasks.length}`;
|
|
234
|
+
})
|
|
235
|
+
.join("\n");
|
|
236
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Never throws to its caller: every failure mode is folded into the
|
|
6
6
|
* returned {@link SyncSummary}.
|
|
7
7
|
*/
|
|
8
|
+
import type { SyncState } from "../domain/sync-plan.ts";
|
|
8
9
|
import type { TaskView } from "../domain/task-view.ts";
|
|
9
10
|
import type { Clock } from "../ports/clock.ts";
|
|
10
11
|
import type { WorkLog } from "../ports/work-log.ts";
|
|
@@ -70,8 +71,26 @@ export declare function runSync(deps: SyncRunnerDeps, options?: {
|
|
|
70
71
|
full?: boolean;
|
|
71
72
|
trigger?: SyncTrigger;
|
|
72
73
|
}): Promise<SyncSummary>;
|
|
73
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Number of tasks pending a sync right now — computed locally, no network.
|
|
76
|
+
*
|
|
77
|
+
* @deprecated Superseded by {@link computeSyncStatus}, which returns the same
|
|
78
|
+
* `pending` count together with `state` and `staleOutsideWindow`. Nothing in
|
|
79
|
+
* kankaku calls this any more; it stays only because it is part of the
|
|
80
|
+
* published `kankaku/hub` surface. It will be removed in the next minor.
|
|
81
|
+
*/
|
|
74
82
|
export declare function pendingCount(tasks: TaskView[], state: ReturnType<SyncStateStore["read"]>, target: string, windowHours?: number): number;
|
|
83
|
+
/**
|
|
84
|
+
* {@link computeSyncStatus}'s return shape, exported so a caller (e.g.
|
|
85
|
+
* `hub-actions.ts#buildSyncStatusLines`, or a future standalone TUI reusing
|
|
86
|
+
* `kankaku/hub`) can depend on this type without importing anything that
|
|
87
|
+
* touches `@earendil-works/*` — see AGENTS.md "Code conventions".
|
|
88
|
+
*/
|
|
89
|
+
export interface SyncStatusSnapshot {
|
|
90
|
+
state: SyncState | undefined;
|
|
91
|
+
pending: number;
|
|
92
|
+
staleOutsideWindow: number;
|
|
93
|
+
}
|
|
75
94
|
/**
|
|
76
95
|
* `/kankaku sync status`: the persisted state, a locally-computed pending
|
|
77
96
|
* count, and (R3) how many tasks changed since their last sync but fall
|
|
@@ -79,11 +98,7 @@ export declare function pendingCount(tasks: TaskView[], state: ReturnType<SyncSt
|
|
|
79
98
|
* (see `domain/sync-plan.ts#SyncPlan.staleOutsideWindow`, and README "Hub
|
|
80
99
|
* (PocketBase)" > "Sync" > "Limitations"). No network.
|
|
81
100
|
*/
|
|
82
|
-
export declare function computeSyncStatus(log: WorkLog, stateStore: SyncStateStore, target: string, windowHours?: number):
|
|
83
|
-
state: ReturnType<SyncStateStore["read"]>;
|
|
84
|
-
pending: number;
|
|
85
|
-
staleOutsideWindow: number;
|
|
86
|
-
};
|
|
101
|
+
export declare function computeSyncStatus(log: WorkLog, stateStore: SyncStateStore, target: string, windowHours?: number): SyncStatusSnapshot;
|
|
87
102
|
/**
|
|
88
103
|
* Wrap an async function so concurrent callers share one in-flight call
|
|
89
104
|
* instead of starting a new one each — kankaku's single-flight guard for
|
|
@@ -198,7 +198,14 @@ function persistError(deps, state, message, logVersionAtRead) {
|
|
|
198
198
|
lastRunAt: deps.clock.now(),
|
|
199
199
|
});
|
|
200
200
|
}
|
|
201
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* Number of tasks pending a sync right now — computed locally, no network.
|
|
203
|
+
*
|
|
204
|
+
* @deprecated Superseded by {@link computeSyncStatus}, which returns the same
|
|
205
|
+
* `pending` count together with `state` and `staleOutsideWindow`. Nothing in
|
|
206
|
+
* kankaku calls this any more; it stays only because it is part of the
|
|
207
|
+
* published `kankaku/hub` surface. It will be removed in the next minor.
|
|
208
|
+
*/
|
|
202
209
|
export function pendingCount(tasks, state, target, windowHours) {
|
|
203
210
|
const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
|
|
204
211
|
return plan.toSync.length + plan.correctionsDeferred;
|
|
@@ -145,18 +145,35 @@ export interface TaskEntryPayload {
|
|
|
145
145
|
legacy_client_label: string;
|
|
146
146
|
repo_project: string;
|
|
147
147
|
schema: number;
|
|
148
|
+
/** Coding agent that MEASURED this task, lowercase slug — the orchestrator record's own {@link WorkRecordMetadata.agent} when it carries one (who measured, not who syncs), else `ctx.agent` (the syncing process's own identity) as a legacy fallback. See {@link WorkRecordMetadata.agent}. */
|
|
148
149
|
agent: string;
|
|
149
150
|
agent_version?: string;
|
|
150
151
|
/** Non-default session directory, see `TaskView.sessionDir`. A measurement field, not assignment: sent on both create and update. */
|
|
151
152
|
session_dir?: string;
|
|
153
|
+
/** The integration that wrote this task's records, lowercase slug — same record-wins-over-ctx rule as {@link agent}. */
|
|
152
154
|
plugin: string;
|
|
153
155
|
plugin_version?: string;
|
|
154
156
|
waiting_quality: WaitingQuality;
|
|
155
157
|
cost_quality: CostQuality;
|
|
156
158
|
subagent_linkage: SubagentLinkage;
|
|
157
159
|
}
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
+
/**
|
|
161
|
+
* `TaskEntryPayload` minus the assignment fields — what an update sends. See
|
|
162
|
+
* the module docs' CRITICAL rule.
|
|
163
|
+
*
|
|
164
|
+
* `agent`/`agent_version`/`plugin`/`plugin_version` are optional here (unlike
|
|
165
|
+
* on `TaskEntryPayload`, where they are always sent on create): an update for
|
|
166
|
+
* a task whose orchestrator record carries no who-measured identity (a
|
|
167
|
+
* legacy record, written before this feature existed) omits them entirely,
|
|
168
|
+
* so a re-sync by a DIFFERENT process never overwrites a row's original
|
|
169
|
+
* identity with its own. See {@link buildTaskEntryUpdatePayload}.
|
|
170
|
+
*/
|
|
171
|
+
export type TaskEntryUpdatePayload = Omit<TaskEntryPayload, "client" | "project" | "task" | "legacy_client_label" | "agent" | "agent_version" | "plugin" | "plugin_version"> & {
|
|
172
|
+
agent?: string;
|
|
173
|
+
agent_version?: string;
|
|
174
|
+
plugin?: string;
|
|
175
|
+
plugin_version?: string;
|
|
176
|
+
};
|
|
160
177
|
/** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
|
|
161
178
|
export declare function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryPayload;
|
|
162
179
|
/**
|
|
@@ -164,6 +181,13 @@ export declare function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntr
|
|
|
164
181
|
* fields only — never `client`, `project`, `task` or `legacy_client_label`,
|
|
165
182
|
* so a re-sync can never undo a reassignment made in the web. See the
|
|
166
183
|
* module docs' CRITICAL rule.
|
|
184
|
+
*
|
|
185
|
+
* `agent`/`agent_version`/`plugin`/`plugin_version` are included ONLY when
|
|
186
|
+
* the orchestrator record itself carries a who-measured `agent` — otherwise
|
|
187
|
+
* they are OMITTED entirely (never sent as `ctx`'s own identity), so a
|
|
188
|
+
* re-sync of a legacy record by a different process never overwrites the
|
|
189
|
+
* row's original identity in the hub. See `domain/work-record.ts`'s
|
|
190
|
+
* "Measurement rules" and {@link resolveTaskIdentity}.
|
|
167
191
|
*/
|
|
168
192
|
export declare function buildTaskEntryUpdatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryUpdatePayload;
|
|
169
193
|
/** One `work_records` row — raw per-`WorkRecord` detail, always `rollup: false`. */
|
package/dist/domain/hub-entry.js
CHANGED
|
@@ -126,9 +126,34 @@ export function computeSubagentLinkage(task) {
|
|
|
126
126
|
return "not_applicable";
|
|
127
127
|
return task.subagents.length >= spanCount ? "linked" : "unlinked";
|
|
128
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* The who-measured identity (`agent`/`agentVersion`/`plugin`/`pluginVersion`)
|
|
131
|
+
* for a task's payload: the orchestrator record's own fields, taken as a
|
|
132
|
+
* unit, when it carries an `agent` — a missing version on the record is
|
|
133
|
+
* omitted, never backfilled from `ctx` — otherwise `ctx`'s identity (the
|
|
134
|
+
* syncing process's own), exactly as before this feature existed. See
|
|
135
|
+
* `domain/work-record.ts#WorkRecordMetadata.agent`'s doc comment.
|
|
136
|
+
*/
|
|
137
|
+
function resolveTaskIdentity(task, ctx) {
|
|
138
|
+
const orchestrator = task.orchestrator;
|
|
139
|
+
if (orchestrator.agent !== undefined) {
|
|
140
|
+
// The record's four fields are taken as a unit. A record written by an
|
|
141
|
+
// integration that stamped `agent` but not `plugin` gets the syncing
|
|
142
|
+
// context's `plugin` ONLY on create, so the row is never blank; the
|
|
143
|
+
// update payload below never resends a plugin the record does not own.
|
|
144
|
+
return {
|
|
145
|
+
agent: orchestrator.agent,
|
|
146
|
+
agentVersion: orchestrator.agentVersion,
|
|
147
|
+
plugin: orchestrator.plugin ?? ctx.plugin,
|
|
148
|
+
pluginVersion: orchestrator.plugin !== undefined ? orchestrator.pluginVersion : undefined,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { agent: ctx.agent, agentVersion: ctx.agentVersion, plugin: ctx.plugin, pluginVersion: ctx.pluginVersion };
|
|
152
|
+
}
|
|
129
153
|
/** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
|
|
130
154
|
export function buildTaskEntryCreatePayload(task, ctx) {
|
|
131
155
|
const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects, ctx.tasks);
|
|
156
|
+
const identity = resolveTaskIdentity(task, ctx);
|
|
132
157
|
return {
|
|
133
158
|
task_id: task.id,
|
|
134
159
|
client: assignment.clientId,
|
|
@@ -158,11 +183,11 @@ export function buildTaskEntryCreatePayload(task, ctx) {
|
|
|
158
183
|
legacy_client_label: assignment.legacyClientLabel,
|
|
159
184
|
repo_project: task.project,
|
|
160
185
|
schema: task.orchestrator.schema,
|
|
161
|
-
agent:
|
|
162
|
-
...(
|
|
186
|
+
agent: identity.agent,
|
|
187
|
+
...(identity.agentVersion !== undefined ? { agent_version: identity.agentVersion } : {}),
|
|
163
188
|
...(task.sessionDir !== undefined ? { session_dir: task.sessionDir } : {}),
|
|
164
|
-
plugin:
|
|
165
|
-
...(
|
|
189
|
+
plugin: identity.plugin,
|
|
190
|
+
...(identity.pluginVersion !== undefined ? { plugin_version: identity.pluginVersion } : {}),
|
|
166
191
|
waiting_quality: computeWaitingQuality(),
|
|
167
192
|
cost_quality: computeCostQuality(task),
|
|
168
193
|
subagent_linkage: computeSubagentLinkage(task),
|
|
@@ -173,10 +198,29 @@ export function buildTaskEntryCreatePayload(task, ctx) {
|
|
|
173
198
|
* fields only — never `client`, `project`, `task` or `legacy_client_label`,
|
|
174
199
|
* so a re-sync can never undo a reassignment made in the web. See the
|
|
175
200
|
* module docs' CRITICAL rule.
|
|
201
|
+
*
|
|
202
|
+
* `agent`/`agent_version`/`plugin`/`plugin_version` are included ONLY when
|
|
203
|
+
* the orchestrator record itself carries a who-measured `agent` — otherwise
|
|
204
|
+
* they are OMITTED entirely (never sent as `ctx`'s own identity), so a
|
|
205
|
+
* re-sync of a legacy record by a different process never overwrites the
|
|
206
|
+
* row's original identity in the hub. See `domain/work-record.ts`'s
|
|
207
|
+
* "Measurement rules" and {@link resolveTaskIdentity}.
|
|
176
208
|
*/
|
|
177
209
|
export function buildTaskEntryUpdatePayload(task, ctx) {
|
|
178
|
-
const { client: _client, project: _project, task: _task, legacy_client_label: _legacy, ...rest } = buildTaskEntryCreatePayload(task, ctx);
|
|
179
|
-
|
|
210
|
+
const { client: _client, project: _project, task: _task, legacy_client_label: _legacy, agent, agent_version, plugin, plugin_version, ...rest } = buildTaskEntryCreatePayload(task, ctx);
|
|
211
|
+
if (task.orchestrator.agent === undefined)
|
|
212
|
+
return rest;
|
|
213
|
+
// `plugin`/`plugin_version` are resent only when the record itself carries
|
|
214
|
+
// them; a record with `agent` but no `plugin` must never have the syncing
|
|
215
|
+
// process's plugin written over the row's original one.
|
|
216
|
+
const recordHasPlugin = task.orchestrator.plugin !== undefined;
|
|
217
|
+
return {
|
|
218
|
+
...rest,
|
|
219
|
+
agent,
|
|
220
|
+
...(agent_version !== undefined ? { agent_version } : {}),
|
|
221
|
+
...(recordHasPlugin ? { plugin } : {}),
|
|
222
|
+
...(recordHasPlugin && plugin_version !== undefined ? { plugin_version } : {}),
|
|
223
|
+
};
|
|
180
224
|
}
|
|
181
225
|
/**
|
|
182
226
|
* Build one `work_records` payload from a raw {@link WorkRecord}
|
package/dist/domain/sync-plan.js
CHANGED
|
@@ -71,6 +71,16 @@ export function computeTaskContentHash(task) {
|
|
|
71
71
|
// `undefined` is dropped by the serialiser, so a task that never knew
|
|
72
72
|
// its reasoning effort keeps the hash it had before this field existed.
|
|
73
73
|
thinkingLevel: task.orchestrator.thinkingLevel,
|
|
74
|
+
// A task gaining a who-measured identity
|
|
75
|
+
// (domain/hub-entry.ts#resolveTaskIdentity) must resync, since it
|
|
76
|
+
// changes which agent/plugin the row is attributed to; the version
|
|
77
|
+
// fields are left out on purpose so a version-only bump alone does not
|
|
78
|
+
// force a resync. The keys are added CONDITIONALLY: `stableStringify`
|
|
79
|
+
// renders an `undefined` value as `"agent":undefined`, so an
|
|
80
|
+
// unconditional key would change every legacy task's hash and force a
|
|
81
|
+
// full resync right after upgrading (see the golden-hash test).
|
|
82
|
+
...(task.orchestrator.agent !== undefined ? { agent: task.orchestrator.agent } : {}),
|
|
83
|
+
...(task.orchestrator.plugin !== undefined ? { plugin: task.orchestrator.plugin } : {}),
|
|
74
84
|
}));
|
|
75
85
|
}
|
|
76
86
|
/**
|
|
@@ -192,6 +192,23 @@ export interface WorkRecordMetadata {
|
|
|
192
192
|
* (SUBAGENT-REQ-017). Never set on an `orchestrator` record.
|
|
193
193
|
*/
|
|
194
194
|
profile?: string;
|
|
195
|
+
/**
|
|
196
|
+
* Coding agent that MEASURED this record, lowercase slug (e.g. `"pi"`).
|
|
197
|
+
* Who measured, not who syncs — a different process (a standalone
|
|
198
|
+
* `kankaku` TUI, or a session for a different agent sharing the same
|
|
199
|
+
* `worklog.jsonl`) may later push this record to the hub, and must never
|
|
200
|
+
* overwrite this identity with its own. See kankaku-hub `docs/contract.md`
|
|
201
|
+
* "Agent and measurement quality" and `domain/hub-entry.ts`. Optional and
|
|
202
|
+
* additive: `WORK_RECORD_SCHEMA` is unchanged and an older record without
|
|
203
|
+
* it still validates.
|
|
204
|
+
*/
|
|
205
|
+
agent?: string;
|
|
206
|
+
/** The measuring agent's own version, when it could be determined without a hot-path cost. Never guessed — omitted rather than sent wrong. */
|
|
207
|
+
agentVersion?: string;
|
|
208
|
+
/** The integration that wrote this record, lowercase slug (e.g. `"kankaku"`). Same who-measured-not-who-syncs rule as {@link agent}. */
|
|
209
|
+
plugin?: string;
|
|
210
|
+
/** This integration's own version, from its `package.json`, read once. */
|
|
211
|
+
pluginVersion?: string;
|
|
195
212
|
}
|
|
196
213
|
export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
|
|
197
214
|
export declare function emptyUsage(): UsageTotals;
|
|
@@ -83,5 +83,9 @@ export function isWorkRecord(value) {
|
|
|
83
83
|
(record["roleConfidence"] === undefined || record["roleConfidence"] === "uncertain") &&
|
|
84
84
|
(record["orchestratorRef"] === undefined || isOrchestratorRef(record["orchestratorRef"])) &&
|
|
85
85
|
(record["costObserved"] === undefined || record["costObserved"] === true) &&
|
|
86
|
-
(record["profile"] === undefined || typeof record["profile"] === "string")
|
|
86
|
+
(record["profile"] === undefined || typeof record["profile"] === "string") &&
|
|
87
|
+
(record["agent"] === undefined || typeof record["agent"] === "string") &&
|
|
88
|
+
(record["agentVersion"] === undefined || typeof record["agentVersion"] === "string") &&
|
|
89
|
+
(record["plugin"] === undefined || typeof record["plugin"] === "string") &&
|
|
90
|
+
(record["pluginVersion"] === undefined || typeof record["pluginVersion"] === "string"));
|
|
87
91
|
}
|
package/dist/hub/index.d.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* for which ones and why.
|
|
8
8
|
*/
|
|
9
9
|
export * from "../adapters/cached-catalog.ts";
|
|
10
|
+
export * from "../adapters/export-writer.ts";
|
|
10
11
|
export * from "../adapters/file-modes.ts";
|
|
12
|
+
export * from "../adapters/hub-actions.ts";
|
|
11
13
|
export * from "../adapters/hub-credentials.ts";
|
|
12
14
|
export * from "../adapters/jsonl-work-log.ts";
|
|
13
15
|
export * from "../adapters/kankaku-dir.ts";
|
|
@@ -15,5 +17,9 @@ export * from "../adapters/lazy-jsonl-work-log.ts";
|
|
|
15
17
|
export * from "../adapters/pocketbase-catalog.ts";
|
|
16
18
|
export * from "../adapters/pocketbase-client.ts";
|
|
17
19
|
export * from "../adapters/pocketbase-sink.ts";
|
|
20
|
+
export * from "../adapters/project-config.ts";
|
|
21
|
+
export * from "../adapters/report-data.ts";
|
|
22
|
+
export * from "../adapters/report.ts";
|
|
23
|
+
export * from "../adapters/report-views.ts";
|
|
18
24
|
export * from "../adapters/sync-runner.ts";
|
|
19
25
|
export * from "../adapters/sync-state-store.ts";
|
package/dist/hub/index.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* for which ones and why.
|
|
8
8
|
*/
|
|
9
9
|
export * from "../adapters/cached-catalog.js";
|
|
10
|
+
export * from "../adapters/export-writer.js";
|
|
10
11
|
export * from "../adapters/file-modes.js";
|
|
12
|
+
export * from "../adapters/hub-actions.js";
|
|
11
13
|
export * from "../adapters/hub-credentials.js";
|
|
12
14
|
export * from "../adapters/jsonl-work-log.js";
|
|
13
15
|
export * from "../adapters/kankaku-dir.js";
|
|
@@ -15,5 +17,9 @@ export * from "../adapters/lazy-jsonl-work-log.js";
|
|
|
15
17
|
export * from "../adapters/pocketbase-catalog.js";
|
|
16
18
|
export * from "../adapters/pocketbase-client.js";
|
|
17
19
|
export * from "../adapters/pocketbase-sink.js";
|
|
20
|
+
export * from "../adapters/project-config.js";
|
|
21
|
+
export * from "../adapters/report-data.js";
|
|
22
|
+
export * from "../adapters/report.js";
|
|
23
|
+
export * from "../adapters/report-views.js";
|
|
18
24
|
export * from "../adapters/sync-runner.js";
|
|
19
25
|
export * from "../adapters/sync-state-store.js";
|
package/package.json
CHANGED