kankaku-pi 1.0.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/LICENSE +21 -0
- package/README.md +1438 -0
- package/dist/adapters/cached-catalog.d.ts +42 -0
- package/dist/adapters/cached-catalog.js +121 -0
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/file-modes.d.ts +20 -0
- package/dist/adapters/file-modes.js +34 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -0
- package/dist/adapters/hub-credentials.d.ts +35 -0
- package/dist/adapters/hub-credentials.js +58 -0
- package/dist/adapters/jsonl-work-log.d.ts +20 -0
- package/dist/adapters/jsonl-work-log.js +62 -0
- package/dist/adapters/kankaku-dir.d.ts +38 -0
- package/dist/adapters/kankaku-dir.js +85 -0
- package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
- package/dist/adapters/lazy-jsonl-work-log.js +31 -0
- package/dist/adapters/pocketbase-catalog.d.ts +16 -0
- package/dist/adapters/pocketbase-catalog.js +56 -0
- package/dist/adapters/pocketbase-client.d.ts +81 -0
- package/dist/adapters/pocketbase-client.js +148 -0
- package/dist/adapters/pocketbase-sink.d.ts +53 -0
- package/dist/adapters/pocketbase-sink.js +181 -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 +114 -0
- package/dist/adapters/sync-runner.js +273 -0
- package/dist/adapters/sync-state-store.d.ts +62 -0
- package/dist/adapters/sync-state-store.js +188 -0
- package/dist/config.d.ts +168 -0
- package/dist/config.js +392 -0
- package/dist/domain/ancestry-match.d.ts +49 -0
- package/dist/domain/ancestry-match.js +82 -0
- package/dist/domain/client-label.d.ts +28 -0
- package/dist/domain/client-label.js +44 -0
- package/dist/domain/day.d.ts +2 -0
- package/dist/domain/day.js +8 -0
- package/dist/domain/export.d.ts +38 -0
- package/dist/domain/export.js +68 -0
- package/dist/domain/hub-entry.d.ts +234 -0
- package/dist/domain/hub-entry.js +265 -0
- package/dist/domain/index.d.ts +19 -0
- package/dist/domain/index.js +19 -0
- package/dist/domain/intervals.d.ts +17 -0
- package/dist/domain/intervals.js +43 -0
- package/dist/domain/registry-health.d.ts +49 -0
- package/dist/domain/registry-health.js +58 -0
- package/dist/domain/segment-rule.d.ts +10 -0
- package/dist/domain/segment-rule.js +1 -0
- package/dist/domain/subagent-profile.d.ts +278 -0
- package/dist/domain/subagent-profile.js +418 -0
- package/dist/domain/sync-plan.d.ts +151 -0
- package/dist/domain/sync-plan.js +196 -0
- package/dist/domain/task-view.d.ts +117 -0
- package/dist/domain/task-view.js +428 -0
- package/dist/domain/work-record.d.ts +236 -0
- package/dist/domain/work-record.js +91 -0
- package/dist/domain/work-target.d.ts +101 -0
- package/dist/domain/work-target.js +149 -0
- package/dist/domain/work-tracker.d.ts +90 -0
- package/dist/domain/work-tracker.js +405 -0
- package/dist/hub/index.d.ts +25 -0
- package/dist/hub/index.js +25 -0
- package/dist/ports/catalog.d.ts +31 -0
- package/dist/ports/catalog.js +1 -0
- package/dist/ports/clock.d.ts +3 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/index.d.ts +11 -0
- package/dist/ports/index.js +1 -0
- package/dist/ports/inflight-store.d.ts +15 -0
- package/dist/ports/inflight-store.js +1 -0
- package/dist/ports/process-registry.d.ts +72 -0
- package/dist/ports/process-registry.js +1 -0
- package/dist/ports/work-log.d.ts +14 -0
- package/dist/ports/work-log.js +1 -0
- package/dist/ports/work-sink.d.ts +39 -0
- package/dist/ports/work-sink.js +1 -0
- package/package.json +66 -0
- package/src/adapters/agent-info.ts +86 -0
- package/src/adapters/ancestry.ts +260 -0
- package/src/adapters/cached-catalog.ts +147 -0
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +115 -0
- package/src/adapters/file-modes.ts +35 -0
- package/src/adapters/hub-actions.ts +82 -0
- package/src/adapters/hub-credentials.ts +95 -0
- package/src/adapters/jsonl-work-log.ts +67 -0
- package/src/adapters/kankaku-command.ts +717 -0
- package/src/adapters/kankaku-dir.ts +102 -0
- package/src/adapters/lazy-file-inflight-store.ts +43 -0
- package/src/adapters/lazy-jsonl-work-log.ts +39 -0
- package/src/adapters/machine-process-registry.ts +256 -0
- package/src/adapters/panel/kankaku-panel.ts +419 -0
- package/src/adapters/panel/panel-items.ts +87 -0
- package/src/adapters/panel/panel-lines.ts +13 -0
- package/src/adapters/panel/panel-theme.ts +32 -0
- package/src/adapters/panel/screens/about.ts +69 -0
- package/src/adapters/panel/screens/doctor.ts +89 -0
- package/src/adapters/panel/screens/export.ts +123 -0
- package/src/adapters/panel/screens/report.ts +143 -0
- package/src/adapters/panel/screens/sync.ts +136 -0
- package/src/adapters/panel/screens/target.ts +384 -0
- package/src/adapters/pi-tracker.ts +753 -0
- package/src/adapters/pocketbase-catalog.ts +89 -0
- package/src/adapters/pocketbase-client.ts +197 -0
- package/src/adapters/pocketbase-sink.ts +236 -0
- package/src/adapters/process-identity-memo.ts +102 -0
- package/src/adapters/process-identity.ts +162 -0
- package/src/adapters/project-config.ts +116 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +335 -0
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/session-dir.ts +28 -0
- package/src/adapters/session-target.ts +431 -0
- package/src/adapters/status-bar.ts +86 -0
- package/src/adapters/subagent-startup.ts +66 -0
- package/src/adapters/sync-runner.ts +340 -0
- package/src/adapters/sync-state-store.ts +227 -0
- package/src/adapters/target-picker.ts +127 -0
- package/src/config.ts +536 -0
- package/src/domain/ancestry-match.ts +84 -0
- package/src/domain/client-label.ts +56 -0
- package/src/domain/day.ts +8 -0
- package/src/domain/export.ts +107 -0
- package/src/domain/hub-entry.ts +433 -0
- package/src/domain/index.ts +19 -0
- package/src/domain/intervals.ts +53 -0
- package/src/domain/panel-model.ts +270 -0
- package/src/domain/registry-health.ts +87 -0
- package/src/domain/segment-rule.ts +10 -0
- package/src/domain/subagent-profile.ts +495 -0
- package/src/domain/sync-plan.ts +266 -0
- package/src/domain/task-view.ts +526 -0
- package/src/domain/work-record.ts +320 -0
- package/src/domain/work-target.ts +234 -0
- package/src/domain/work-tracker.ts +485 -0
- package/src/extension.ts +346 -0
- package/src/hub/index.ts +25 -0
- package/src/ports/catalog.ts +33 -0
- package/src/ports/clock.ts +3 -0
- package/src/ports/index.ts +11 -0
- package/src/ports/inflight-store.ts +16 -0
- package/src/ports/process-registry.ts +75 -0
- package/src/ports/work-log.ts +15 -0
- package/src/ports/work-sink.ts +35 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/** Current schema version for {@link WorkRecord}. */
|
|
2
|
+
export const WORK_RECORD_SCHEMA = 1;
|
|
3
|
+
|
|
4
|
+
export type WorkRole = "orchestrator" | "subagent";
|
|
5
|
+
|
|
6
|
+
export type WorkStatus = "completed" | "aborted" | "interrupted";
|
|
7
|
+
|
|
8
|
+
export interface UsageTotals {
|
|
9
|
+
input: number;
|
|
10
|
+
output: number;
|
|
11
|
+
cacheRead: number;
|
|
12
|
+
cacheWrite: number;
|
|
13
|
+
cost: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SubagentSpan {
|
|
17
|
+
toolCallId: string;
|
|
18
|
+
agent: string;
|
|
19
|
+
mode: string;
|
|
20
|
+
taskId?: string;
|
|
21
|
+
ms: number;
|
|
22
|
+
/**
|
|
23
|
+
* The {@link SubagentProfile}'s `id` that matched this tool call
|
|
24
|
+
* (`domain/subagent-profile.ts`, ADR 0020), when unambiguous. Omitted
|
|
25
|
+
* when no profile's tool name matched at all, or when 2+ profiles
|
|
26
|
+
* registered the same tool name and could not be told apart
|
|
27
|
+
* (SUBAGENT-REQ-005) — `/kankaku doctor` surfaces both cases. Optional so
|
|
28
|
+
* an older-format span (written before profiles existed) still validates.
|
|
29
|
+
*/
|
|
30
|
+
profile?: string;
|
|
31
|
+
/**
|
|
32
|
+
* C1 (CRITICAL fix, SUBAGENT-REQ-006 revised): nested LLM usage the
|
|
33
|
+
* matched profile's `readResult` forwarded from this span's tool result,
|
|
34
|
+
* kept SEPARATE from the orchestrator's own `WorkRecordCore.usage` —
|
|
35
|
+
* never folded in at write time (`domain/work-tracker.ts#onToolEnd`).
|
|
36
|
+
* `domain/task-view.ts#buildTasks` (ADR 0006: aggregation across
|
|
37
|
+
* spans/children stays in exactly this one place) is the only place that
|
|
38
|
+
* decides whether to add it to a task's total, based on whether a joined
|
|
39
|
+
* child record with the SAME `profile` already carries this same cost
|
|
40
|
+
* through its own confirmed-marker ancestry join — see
|
|
41
|
+
* `task-view.ts#unjoinedForwardedUsage`. Never set for an ambiguous
|
|
42
|
+
* tool-name match (nothing money-affecting is ever taken from one — see
|
|
43
|
+
* `domain/subagent-profile.ts#safeAmbiguousResultInfo`) or when the
|
|
44
|
+
* matched profile's `readResult` reported no usage at all. Optional so an
|
|
45
|
+
* older-format span (written before this field existed) still validates.
|
|
46
|
+
*/
|
|
47
|
+
forwardedUsage?: Partial<UsageTotals>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Identity of the tracked ancestor process a `subagent` record discovered
|
|
52
|
+
* via the machine-wide process registry (`~/.kankaku/run/<pid>.json`, see
|
|
53
|
+
* `ports/process-registry.ts`). Used to reunite a cross-worktree child with
|
|
54
|
+
* its orchestrator locally, before `buildTasks` runs (ADR 0023) — never
|
|
55
|
+
* set on an `orchestrator` record.
|
|
56
|
+
*/
|
|
57
|
+
export interface OrchestratorRef {
|
|
58
|
+
pid: number;
|
|
59
|
+
project: string;
|
|
60
|
+
startedAt: string;
|
|
61
|
+
/**
|
|
62
|
+
* The real orchestrator's resolved, absolute kankaku directory (its
|
|
63
|
+
* `RegistryEntry.dir`), when known — carried through a nested
|
|
64
|
+
* subagent-of-subagent chain via `domain/ancestry-match.ts#resolveOrchestratorRef`
|
|
65
|
+
* so a grandchild can route its writes (F1) straight to the true root's
|
|
66
|
+
* directory without a fresh registry lookup for an ancestor that may no
|
|
67
|
+
* longer even be alive. Optional so an older-format entry/record (written
|
|
68
|
+
* before this field existed) still validates and — when absent — the
|
|
69
|
+
* reader simply falls back to its own local directory rather than
|
|
70
|
+
* routing anywhere (see `adapters/extension.ts`'s write-routing).
|
|
71
|
+
*/
|
|
72
|
+
dir?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fields the pure {@link WorkTracker} state machine can compute on its own,
|
|
77
|
+
* with no knowledge of the pi process or session it runs in.
|
|
78
|
+
*/
|
|
79
|
+
/** What started a record: absent = a user prompt (every record before this field existed). */
|
|
80
|
+
export type RunTrigger = "extension";
|
|
81
|
+
|
|
82
|
+
/** The `prompt` of a record no user prompt started — see {@link RunTrigger}. */
|
|
83
|
+
export const EXTENSION_RUN_PROMPT = "(no user prompt — run started by an extension)";
|
|
84
|
+
|
|
85
|
+
export interface WorkRecordCore {
|
|
86
|
+
schema: number;
|
|
87
|
+
id: string;
|
|
88
|
+
prompt: string;
|
|
89
|
+
/**
|
|
90
|
+
* `"extension"` when an extension, not the user, started this record's
|
|
91
|
+
* first run (e.g. gentle-pi waking the orchestrator because a background
|
|
92
|
+
* subagent finished). Optional and additive: `WORK_RECORD_SCHEMA` is
|
|
93
|
+
* unchanged and an older record without it still validates.
|
|
94
|
+
*/
|
|
95
|
+
trigger?: RunTrigger;
|
|
96
|
+
startedAt: string;
|
|
97
|
+
settledAt: string;
|
|
98
|
+
wallMs: number;
|
|
99
|
+
waitingMs: number;
|
|
100
|
+
workMs: number;
|
|
101
|
+
/**
|
|
102
|
+
* Agent loops inside this record: the first one plus every
|
|
103
|
+
* `agent.continue()` pi ran before settling it (auto-retry after a provider
|
|
104
|
+
* error, overflow recovery, a queued steer/follow-up). Informational only —
|
|
105
|
+
* never used for time or cost.
|
|
106
|
+
*/
|
|
107
|
+
runs: number;
|
|
108
|
+
turns: number;
|
|
109
|
+
tools: Record<string, number>;
|
|
110
|
+
subagents: SubagentSpan[];
|
|
111
|
+
/**
|
|
112
|
+
* Union milliseconds per tag spent in tool calls matched by a
|
|
113
|
+
* {@link SegmentRule} (e.g. `review`). Optional so older persisted
|
|
114
|
+
* records without this field still satisfy the type; callers reading
|
|
115
|
+
* from disk should treat a missing value as `{}`.
|
|
116
|
+
*/
|
|
117
|
+
segments?: Record<string, number>;
|
|
118
|
+
usage: UsageTotals;
|
|
119
|
+
status: WorkStatus;
|
|
120
|
+
/**
|
|
121
|
+
* `true` when at least one turn of this run reported a real (finite)
|
|
122
|
+
* provider `cost` figure — as opposed to every turn's cost being
|
|
123
|
+
* absent/non-finite (a subscription/OAuth provider that reports token
|
|
124
|
+
* usage but no cost). Omitted (never `false`) when no turn ever observed
|
|
125
|
+
* one, so an old persisted record without this field reads exactly the
|
|
126
|
+
* same as a run that genuinely never saw a cost figure — both correctly
|
|
127
|
+
* map to `cost_quality: "unknown"` in `domain/hub-entry.ts`. Optional:
|
|
128
|
+
* adding it did not bump `WORK_RECORD_SCHEMA`.
|
|
129
|
+
*/
|
|
130
|
+
costObserved?: true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Process/session metadata the adapter layer attaches before persisting a record. */
|
|
134
|
+
export interface WorkRecordMetadata {
|
|
135
|
+
role: WorkRole;
|
|
136
|
+
pid: number;
|
|
137
|
+
parentPid: number;
|
|
138
|
+
project: string;
|
|
139
|
+
sessionId?: string;
|
|
140
|
+
sessionFile?: string;
|
|
141
|
+
mode?: string;
|
|
142
|
+
model?: string;
|
|
143
|
+
/**
|
|
144
|
+
* The model's reasoning effort when the record settled, as pi names it
|
|
145
|
+
* (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`). Optional and
|
|
146
|
+
* additive; absent on older records and on a pi too old to report it.
|
|
147
|
+
*/
|
|
148
|
+
thinkingLevel?: string;
|
|
149
|
+
/** Who this work is billed to. See {@link resolveClient} in `client-label.ts`. */
|
|
150
|
+
client?: string;
|
|
151
|
+
/** pi's session display name at the time this record settled. */
|
|
152
|
+
sessionName?: string;
|
|
153
|
+
/**
|
|
154
|
+
* Absolute session directory, set only when pi's session manager reports
|
|
155
|
+
* it as *non-default* (`SessionManager#usesDefaultSessionDir()` false) —
|
|
156
|
+
* exactly the condition under which pi's own `formatResumeCommand` adds
|
|
157
|
+
* `--session-dir` to the printed resume command. Omitted for an ordinary
|
|
158
|
+
* default-location session, so most records never carry this at all.
|
|
159
|
+
* Local-only today: the hub has no field for it yet (see README
|
|
160
|
+
* "Subagents" / AGENTS.md for the recommended `session_dir` migration).
|
|
161
|
+
*/
|
|
162
|
+
sessionDir?: string;
|
|
163
|
+
/** Hub (PocketBase) client record id, when a hub target is active for this run. See `domain/work-target.ts`. */
|
|
164
|
+
clientId?: string;
|
|
165
|
+
/** Hub client display name, denormalised alongside `clientId` for readability. */
|
|
166
|
+
clientName?: string;
|
|
167
|
+
/** Hub (PocketBase) project record id, when the active hub target has a project. */
|
|
168
|
+
projectId?: string;
|
|
169
|
+
/** Hub project display name, denormalised alongside `projectId`. */
|
|
170
|
+
projectName?: string;
|
|
171
|
+
/** Hub `tasks` record id linked for this session (`/kankaku task pick`), when one is active. See `domain/work-target.ts#HubTask`. */
|
|
172
|
+
hubTaskId?: string;
|
|
173
|
+
/** Hub task title, denormalised alongside `hubTaskId`. */
|
|
174
|
+
hubTaskTitle?: string;
|
|
175
|
+
/** This machine's hostname, or `KANKAKU_MACHINE`, set only when the hub is configured. */
|
|
176
|
+
machine?: string;
|
|
177
|
+
/**
|
|
178
|
+
* Set only on an `orchestrator` record that could not be positively
|
|
179
|
+
* proven top-level (ADR 0022's four-state classification, applied on top
|
|
180
|
+
* of this still-binary `role`): no recognised child-env-marker matched,
|
|
181
|
+
* but a live tracked ancestor process was found in the machine-wide
|
|
182
|
+
* registry. Never counted as a new task locally or synced to the hub
|
|
183
|
+
* until the ambiguity is resolved (see README "Subagents"). Omitted
|
|
184
|
+
* entirely for a confirmed orchestrator, so a record from a build
|
|
185
|
+
* predating this field is indistinguishable from a confirmed one.
|
|
186
|
+
*/
|
|
187
|
+
roleConfidence?: "uncertain";
|
|
188
|
+
/**
|
|
189
|
+
* The tracked ancestor a `subagent` record discovered via the
|
|
190
|
+
* machine-wide process registry. See {@link OrchestratorRef}.
|
|
191
|
+
*/
|
|
192
|
+
orchestratorRef?: OrchestratorRef;
|
|
193
|
+
/**
|
|
194
|
+
* The {@link SubagentProfile} `id` (ADR 0020) whose child-env marker(s)
|
|
195
|
+
* confirmed THIS process's `role: "subagent"` classification — e.g.
|
|
196
|
+
* `"gentle-pi"` or `"pi-subagents"`. Set only when exactly one profile's
|
|
197
|
+
* marker matched (SUBAGENT-REQ-005 never guesses); omitted when this
|
|
198
|
+
* process's role came from ancestry alone (no known marker present, e.g.
|
|
199
|
+
* pi's bundled reference example) or from 2+ markers matching at once.
|
|
200
|
+
* `/kankaku doctor` reports which profile matched each record
|
|
201
|
+
* (SUBAGENT-REQ-017). Never set on an `orchestrator` record.
|
|
202
|
+
*/
|
|
203
|
+
profile?: string;
|
|
204
|
+
/**
|
|
205
|
+
* Coding agent that MEASURED this record, lowercase slug (e.g. `"pi"`).
|
|
206
|
+
* Who measured, not who syncs — a different process (a standalone
|
|
207
|
+
* `kankaku` TUI, or a session for a different agent sharing the same
|
|
208
|
+
* `worklog.jsonl`) may later push this record to the hub, and must never
|
|
209
|
+
* overwrite this identity with its own. See kankaku-hub `docs/contract.md`
|
|
210
|
+
* "Agent and measurement quality" and `domain/hub-entry.ts`. Optional and
|
|
211
|
+
* additive: `WORK_RECORD_SCHEMA` is unchanged and an older record without
|
|
212
|
+
* it still validates.
|
|
213
|
+
*/
|
|
214
|
+
agent?: string;
|
|
215
|
+
/** The measuring agent's own version, when it could be determined without a hot-path cost. Never guessed — omitted rather than sent wrong. */
|
|
216
|
+
agentVersion?: string;
|
|
217
|
+
/** The integration that wrote this record, lowercase slug (e.g. `"kankaku"`). Same who-measured-not-who-syncs rule as {@link agent}. */
|
|
218
|
+
plugin?: string;
|
|
219
|
+
/** This integration's own version, from its `package.json`, read once. */
|
|
220
|
+
pluginVersion?: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
|
|
224
|
+
|
|
225
|
+
export function emptyUsage(): UsageTotals {
|
|
226
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A finite number, or `0` for `undefined`/`NaN`/`Infinity`/non-numbers. */
|
|
230
|
+
export function finiteOrZero(value: unknown): number {
|
|
231
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Share of prompt input tokens served from the provider's prompt cache:
|
|
236
|
+
* `cacheRead / (input + cacheRead + cacheWrite)`. pi's `usage.input` maps to
|
|
237
|
+
* the provider's `input_tokens`, which already excludes cached tokens, so
|
|
238
|
+
* the three fields are disjoint and this sum is the true denominator.
|
|
239
|
+
* Returns `undefined` when the denominator is `0` (nothing to compute a
|
|
240
|
+
* ratio from) rather than `0`, so callers never render a misleading `0%`.
|
|
241
|
+
* Non-finite fields count as `0`, mirroring {@link finiteOrZero}.
|
|
242
|
+
*/
|
|
243
|
+
export function cacheHitRatio(usage: { input: number; cacheRead: number; cacheWrite: number }): number | undefined {
|
|
244
|
+
const input = finiteOrZero(usage.input);
|
|
245
|
+
const cacheRead = finiteOrZero(usage.cacheRead);
|
|
246
|
+
const cacheWrite = finiteOrZero(usage.cacheWrite);
|
|
247
|
+
const denominator = input + cacheRead + cacheWrite;
|
|
248
|
+
return denominator === 0 ? undefined : cacheRead / denominator;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const ROLES = new Set<WorkRole>(["orchestrator", "subagent"]);
|
|
252
|
+
const STATUSES = new Set<WorkStatus>(["completed", "aborted", "interrupted"]);
|
|
253
|
+
|
|
254
|
+
/** A finite, non-negative number: durations such as `wallMs` can never be negative. */
|
|
255
|
+
function isNonNegativeFinite(value: unknown): boolean {
|
|
256
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function isOrchestratorRef(value: unknown): value is OrchestratorRef {
|
|
260
|
+
if (!value || typeof value !== "object") return false;
|
|
261
|
+
const ref = value as Record<string, unknown>;
|
|
262
|
+
return (
|
|
263
|
+
typeof ref["pid"] === "number" &&
|
|
264
|
+
typeof ref["project"] === "string" &&
|
|
265
|
+
typeof ref["startedAt"] === "string" &&
|
|
266
|
+
(ref["dir"] === undefined || typeof ref["dir"] === "string")
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Runtime guard for a {@link WorkRecord} read back from disk. `readAll`
|
|
272
|
+
* skips lines that parse as JSON but fail this check, so a torn write or a
|
|
273
|
+
* record from an incompatible schema does not crash task/session views.
|
|
274
|
+
*/
|
|
275
|
+
export function isWorkRecord(value: unknown): value is WorkRecord {
|
|
276
|
+
if (!value || typeof value !== "object") return false;
|
|
277
|
+
const record = value as Record<string, unknown>;
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
typeof record["schema"] === "number" &&
|
|
281
|
+
typeof record["id"] === "string" &&
|
|
282
|
+
ROLES.has(record["role"] as WorkRole) &&
|
|
283
|
+
typeof record["pid"] === "number" &&
|
|
284
|
+
typeof record["parentPid"] === "number" &&
|
|
285
|
+
typeof record["project"] === "string" &&
|
|
286
|
+
typeof record["prompt"] === "string" &&
|
|
287
|
+
typeof record["startedAt"] === "string" &&
|
|
288
|
+
typeof record["settledAt"] === "string" &&
|
|
289
|
+
isNonNegativeFinite(record["wallMs"]) &&
|
|
290
|
+
isNonNegativeFinite(record["waitingMs"]) &&
|
|
291
|
+
isNonNegativeFinite(record["workMs"]) &&
|
|
292
|
+
typeof record["runs"] === "number" &&
|
|
293
|
+
typeof record["turns"] === "number" &&
|
|
294
|
+
typeof record["tools"] === "object" &&
|
|
295
|
+
record["tools"] !== null &&
|
|
296
|
+
Array.isArray(record["subagents"]) &&
|
|
297
|
+
typeof record["usage"] === "object" &&
|
|
298
|
+
record["usage"] !== null &&
|
|
299
|
+
STATUSES.has(record["status"] as WorkStatus) &&
|
|
300
|
+
(record["client"] === undefined || typeof record["client"] === "string") &&
|
|
301
|
+
(record["sessionName"] === undefined || typeof record["sessionName"] === "string") &&
|
|
302
|
+
(record["sessionDir"] === undefined || typeof record["sessionDir"] === "string") &&
|
|
303
|
+
(record["thinkingLevel"] === undefined || typeof record["thinkingLevel"] === "string") &&
|
|
304
|
+
(record["clientId"] === undefined || typeof record["clientId"] === "string") &&
|
|
305
|
+
(record["clientName"] === undefined || typeof record["clientName"] === "string") &&
|
|
306
|
+
(record["projectId"] === undefined || typeof record["projectId"] === "string") &&
|
|
307
|
+
(record["projectName"] === undefined || typeof record["projectName"] === "string") &&
|
|
308
|
+
(record["hubTaskId"] === undefined || typeof record["hubTaskId"] === "string") &&
|
|
309
|
+
(record["hubTaskTitle"] === undefined || typeof record["hubTaskTitle"] === "string") &&
|
|
310
|
+
(record["machine"] === undefined || typeof record["machine"] === "string") &&
|
|
311
|
+
(record["roleConfidence"] === undefined || record["roleConfidence"] === "uncertain") &&
|
|
312
|
+
(record["orchestratorRef"] === undefined || isOrchestratorRef(record["orchestratorRef"])) &&
|
|
313
|
+
(record["costObserved"] === undefined || record["costObserved"] === true) &&
|
|
314
|
+
(record["profile"] === undefined || typeof record["profile"] === "string") &&
|
|
315
|
+
(record["agent"] === undefined || typeof record["agent"] === "string") &&
|
|
316
|
+
(record["agentVersion"] === undefined || typeof record["agentVersion"] === "string") &&
|
|
317
|
+
(record["plugin"] === undefined || typeof record["plugin"] === "string") &&
|
|
318
|
+
(record["pluginVersion"] === undefined || typeof record["pluginVersion"] === "string")
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub work-target resolution: pure, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* A `WorkTarget` identifies who a piece of work is billed to (client) and,
|
|
5
|
+
* optionally, which project it belongs to, both by PocketBase record id.
|
|
6
|
+
* Names travel alongside for display; no aggregation ever keys on a name
|
|
7
|
+
* (see `kankaku-pocketbase-proposal.md` D1).
|
|
8
|
+
*
|
|
9
|
+
* Resolution mirrors `client-label.ts#resolveClient`'s precedence pattern:
|
|
10
|
+
* session > project config file > catalog `repo_paths` match for the cwd >
|
|
11
|
+
* none. An id from any source that no longer resolves to an active,
|
|
12
|
+
* non-"unassigned" catalog row is treated as absent for that source and
|
|
13
|
+
* resolution falls through to the next one.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface Client {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
code: string;
|
|
20
|
+
active: boolean;
|
|
21
|
+
/** `true` only for the single "Sin determinar" row; never offered by the picker. */
|
|
22
|
+
unassigned?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Project {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
code?: string;
|
|
29
|
+
clientId: string;
|
|
30
|
+
/** Absolute paths that map to this project; enables auto-selection by cwd. */
|
|
31
|
+
repoPaths: string[];
|
|
32
|
+
active: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A hub `tasks` row: something kankaku can link a session's `task_entries`
|
|
37
|
+
* row to (`task_entries.task`), never create. See `domain/hub-entry.ts` for
|
|
38
|
+
* the create-only assignment rule this feeds.
|
|
39
|
+
*/
|
|
40
|
+
export interface HubTask {
|
|
41
|
+
id: string;
|
|
42
|
+
title: string;
|
|
43
|
+
projectId: string;
|
|
44
|
+
status: "open" | "doing" | "done";
|
|
45
|
+
externalRef?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WorkTarget {
|
|
49
|
+
clientId: string;
|
|
50
|
+
clientCode: string;
|
|
51
|
+
clientName: string;
|
|
52
|
+
projectId?: string;
|
|
53
|
+
projectCode?: string;
|
|
54
|
+
projectName?: string;
|
|
55
|
+
/** Hub `tasks` record id, kept only when it belongs to `projectId`. See {@link resolveWorkTarget}. */
|
|
56
|
+
hubTaskId?: string;
|
|
57
|
+
/** Denormalised alongside `hubTaskId` for display. */
|
|
58
|
+
hubTaskTitle?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** ids picked from the session, or from the project's `.kankaku/config.json`. */
|
|
62
|
+
export interface WorkTargetCandidate {
|
|
63
|
+
clientId: string;
|
|
64
|
+
projectId?: string;
|
|
65
|
+
/**
|
|
66
|
+
* A hub task the user picked for this session (`/kankaku task pick`).
|
|
67
|
+
* Only ever set on the session-level candidate — the project config file
|
|
68
|
+
* and `repoPaths` matching never carry one (task linking is session-only,
|
|
69
|
+
* see AGENTS.md).
|
|
70
|
+
*/
|
|
71
|
+
hubTaskId?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The session-level override: `undefined` when no session entry exists yet
|
|
76
|
+
* (resolution falls through to the project/repo-paths sources), the literal
|
|
77
|
+
* `"skipped"` when the user explicitly declined the picker (resolution
|
|
78
|
+
* stops here, at "no target", and does not fall through), or explicit ids.
|
|
79
|
+
*/
|
|
80
|
+
export type WorkTargetSessionOverride = "skipped" | WorkTargetCandidate | undefined;
|
|
81
|
+
|
|
82
|
+
export interface ResolveWorkTargetInput {
|
|
83
|
+
session?: WorkTargetSessionOverride;
|
|
84
|
+
/** ids from the project's `.kankaku/config.json`. */
|
|
85
|
+
project?: WorkTargetCandidate;
|
|
86
|
+
/** Current working directory, matched against `Project.repoPaths`. */
|
|
87
|
+
cwd: string;
|
|
88
|
+
clients: Client[];
|
|
89
|
+
projects: Project[];
|
|
90
|
+
/** The hub's known tasks, to validate a candidate's `hubTaskId` against. Defaults to `[]` so existing callers compile unchanged. */
|
|
91
|
+
tasks?: HubTask[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type WorkTargetSourceName = "session" | "project" | "repoPaths";
|
|
95
|
+
|
|
96
|
+
/** A client usable as a work target: exists, active, and not the "unassigned" row. */
|
|
97
|
+
function findUsableClient(clients: Client[], clientId: string): Client | undefined {
|
|
98
|
+
const client = clients.find((candidate) => candidate.id === clientId);
|
|
99
|
+
if (!client || !client.active || client.unassigned) return undefined;
|
|
100
|
+
return client;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A project usable as a work target: exists, active, and belongs to `clientId`. */
|
|
104
|
+
function findUsableProject(projects: Project[], projectId: string, clientId: string): Project | undefined {
|
|
105
|
+
const project = projects.find((candidate) => candidate.id === projectId);
|
|
106
|
+
if (!project || !project.active || project.clientId !== clientId) return undefined;
|
|
107
|
+
return project;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A hub task usable as a link target: exists and belongs to `projectId`.
|
|
112
|
+
* Any {@link HubTask.status} is accepted — a task marked `done` mid-session
|
|
113
|
+
* keeps linking — only the project match matters here.
|
|
114
|
+
*/
|
|
115
|
+
function findUsableTask(tasks: HubTask[], taskId: string, projectId: string): HubTask | undefined {
|
|
116
|
+
const task = tasks.find((candidate) => candidate.id === taskId);
|
|
117
|
+
if (!task || task.projectId !== projectId) return undefined;
|
|
118
|
+
return task;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildTarget(client: Client, project: Project | undefined, task: HubTask | undefined): WorkTarget {
|
|
122
|
+
return {
|
|
123
|
+
clientId: client.id,
|
|
124
|
+
clientCode: client.code,
|
|
125
|
+
clientName: client.name,
|
|
126
|
+
...(project !== undefined
|
|
127
|
+
? {
|
|
128
|
+
projectId: project.id,
|
|
129
|
+
...(project.code !== undefined ? { projectCode: project.code } : {}),
|
|
130
|
+
projectName: project.name,
|
|
131
|
+
}
|
|
132
|
+
: {}),
|
|
133
|
+
...(task !== undefined ? { hubTaskId: task.id, hubTaskTitle: task.title } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve a candidate (from the session override or the project config
|
|
139
|
+
* file) into a target, or `undefined` when the client id does not resolve
|
|
140
|
+
* to a usable client. A projectId that does not resolve to a usable
|
|
141
|
+
* project of that client is dropped — the client-only target is still
|
|
142
|
+
* returned — rather than failing the whole candidate. A `hubTaskId` is
|
|
143
|
+
* kept only when it resolves to a task of the resolved project; a target
|
|
144
|
+
* with no project never carries one.
|
|
145
|
+
*/
|
|
146
|
+
function resolveCandidate(candidate: WorkTargetCandidate, clients: Client[], projects: Project[], tasks: HubTask[]): WorkTarget | undefined {
|
|
147
|
+
const client = findUsableClient(clients, candidate.clientId);
|
|
148
|
+
if (!client) return undefined;
|
|
149
|
+
const project = candidate.projectId !== undefined ? findUsableProject(projects, candidate.projectId, client.id) : undefined;
|
|
150
|
+
const task =
|
|
151
|
+
project !== undefined && candidate.hubTaskId !== undefined ? findUsableTask(tasks, candidate.hubTaskId, project.id) : undefined;
|
|
152
|
+
return buildTarget(client, project, task);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The longest-matching active project whose `repoPaths` contains `cwd`, exactly or as an ancestor directory. */
|
|
156
|
+
function matchByRepoPath(cwd: string, projects: Project[]): Project | undefined {
|
|
157
|
+
let best: Project | undefined;
|
|
158
|
+
let bestLength = -1;
|
|
159
|
+
|
|
160
|
+
for (const project of projects) {
|
|
161
|
+
if (!project.active) continue;
|
|
162
|
+
for (const repoPath of project.repoPaths) {
|
|
163
|
+
if (!isCwdWithin(cwd, repoPath)) continue;
|
|
164
|
+
if (repoPath.length > bestLength) {
|
|
165
|
+
bestLength = repoPath.length;
|
|
166
|
+
best = project;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return best;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `true` when `cwd` equals `repoPath`, or is a subdirectory of it. */
|
|
175
|
+
function isCwdWithin(cwd: string, repoPath: string): boolean {
|
|
176
|
+
if (cwd === repoPath) return true;
|
|
177
|
+
const withSeparator = repoPath.endsWith("/") ? repoPath : `${repoPath}/`;
|
|
178
|
+
return cwd.startsWith(withSeparator);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Which source (if any) {@link resolveWorkTarget} would use, computed independently so callers can display it. */
|
|
182
|
+
export function resolveWorkTargetSource(input: ResolveWorkTargetInput): WorkTargetSourceName | undefined {
|
|
183
|
+
const { session } = input;
|
|
184
|
+
const tasks = input.tasks ?? [];
|
|
185
|
+
if (session === "skipped") return undefined;
|
|
186
|
+
if (session !== undefined && resolveCandidate(session, input.clients, input.projects, tasks) !== undefined) return "session";
|
|
187
|
+
|
|
188
|
+
if (input.project !== undefined && resolveCandidate(input.project, input.clients, input.projects, tasks) !== undefined) return "project";
|
|
189
|
+
|
|
190
|
+
const matched = matchByRepoPath(input.cwd, input.projects);
|
|
191
|
+
if (matched && findUsableClient(input.clients, matched.clientId)) return "repoPaths";
|
|
192
|
+
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Resolve the effective {@link WorkTarget}: session override > project
|
|
198
|
+
* config file > catalog `repo_paths` match for `cwd` > none. A `"skipped"`
|
|
199
|
+
* session override stops resolution immediately (the user explicitly
|
|
200
|
+
* declined), returning `undefined` rather than falling through.
|
|
201
|
+
*/
|
|
202
|
+
export function resolveWorkTarget(input: ResolveWorkTargetInput): WorkTarget | undefined {
|
|
203
|
+
const { session } = input;
|
|
204
|
+
const tasks = input.tasks ?? [];
|
|
205
|
+
if (session === "skipped") return undefined;
|
|
206
|
+
|
|
207
|
+
if (session !== undefined) {
|
|
208
|
+
const resolved = resolveCandidate(session, input.clients, input.projects, tasks);
|
|
209
|
+
if (resolved) return resolved;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (input.project !== undefined) {
|
|
213
|
+
const resolved = resolveCandidate(input.project, input.clients, input.projects, tasks);
|
|
214
|
+
if (resolved) return resolved;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const matched = matchByRepoPath(input.cwd, input.projects);
|
|
218
|
+
if (matched) {
|
|
219
|
+
const client = findUsableClient(input.clients, matched.clientId);
|
|
220
|
+
if (client) return buildTarget(client, matched, undefined);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `<clientName>`, `<clientName> · <projectName>`, or with a linked hub task
|
|
228
|
+
* appended as `... › <hubTaskTitle>` — the display label used by the
|
|
229
|
+
* status bar and the "remember" prompt.
|
|
230
|
+
*/
|
|
231
|
+
export function formatWorkTargetLabel(target: WorkTarget): string {
|
|
232
|
+
const base = target.projectName ? `${target.clientName} · ${target.projectName}` : target.clientName;
|
|
233
|
+
return target.hubTaskTitle ? `${base} › ${target.hubTaskTitle}` : base;
|
|
234
|
+
}
|