kankaku 0.5.0 → 0.6.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.
Files changed (76) hide show
  1. package/README.md +67 -19
  2. package/dist/adapters/cached-catalog.d.ts +36 -0
  3. package/dist/adapters/cached-catalog.js +111 -0
  4. package/dist/adapters/file-modes.d.ts +20 -0
  5. package/dist/adapters/file-modes.js +34 -0
  6. package/dist/adapters/hub-credentials.d.ts +35 -0
  7. package/dist/adapters/hub-credentials.js +58 -0
  8. package/dist/adapters/jsonl-work-log.d.ts +20 -0
  9. package/dist/adapters/jsonl-work-log.js +62 -0
  10. package/dist/adapters/kankaku-dir.d.ts +38 -0
  11. package/dist/adapters/kankaku-dir.js +85 -0
  12. package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
  13. package/dist/adapters/lazy-jsonl-work-log.js +31 -0
  14. package/dist/adapters/pocketbase-catalog.d.ts +14 -0
  15. package/dist/adapters/pocketbase-catalog.js +39 -0
  16. package/dist/adapters/pocketbase-client.d.ts +81 -0
  17. package/dist/adapters/pocketbase-client.js +148 -0
  18. package/dist/adapters/pocketbase-sink.d.ts +52 -0
  19. package/dist/adapters/pocketbase-sink.js +180 -0
  20. package/dist/adapters/sync-runner.d.ts +99 -0
  21. package/dist/adapters/sync-runner.js +242 -0
  22. package/dist/adapters/sync-state-store.d.ts +62 -0
  23. package/dist/adapters/sync-state-store.js +188 -0
  24. package/dist/config.d.ts +168 -0
  25. package/dist/config.js +392 -0
  26. package/dist/domain/ancestry-match.d.ts +49 -0
  27. package/dist/domain/ancestry-match.js +82 -0
  28. package/dist/domain/client-label.d.ts +28 -0
  29. package/dist/domain/client-label.js +44 -0
  30. package/dist/domain/day.d.ts +2 -0
  31. package/dist/domain/day.js +8 -0
  32. package/dist/domain/export.d.ts +38 -0
  33. package/dist/domain/export.js +68 -0
  34. package/dist/domain/hub-entry.d.ts +201 -0
  35. package/dist/domain/hub-entry.js +211 -0
  36. package/dist/domain/index.d.ts +19 -0
  37. package/dist/domain/index.js +19 -0
  38. package/dist/domain/intervals.d.ts +17 -0
  39. package/dist/domain/intervals.js +43 -0
  40. package/dist/domain/registry-health.d.ts +49 -0
  41. package/dist/domain/registry-health.js +58 -0
  42. package/dist/domain/segment-rule.d.ts +10 -0
  43. package/dist/domain/segment-rule.js +1 -0
  44. package/dist/domain/subagent-profile.d.ts +278 -0
  45. package/dist/domain/subagent-profile.js +418 -0
  46. package/dist/domain/sync-plan.d.ts +150 -0
  47. package/dist/domain/sync-plan.js +182 -0
  48. package/dist/domain/task-view.d.ts +113 -0
  49. package/dist/domain/task-view.js +426 -0
  50. package/dist/domain/work-record.d.ts +201 -0
  51. package/dist/domain/work-record.js +69 -0
  52. package/dist/domain/work-target.d.ts +72 -0
  53. package/dist/domain/work-target.js +127 -0
  54. package/dist/domain/work-tracker.d.ts +90 -0
  55. package/dist/domain/work-tracker.js +405 -0
  56. package/dist/hub/index.d.ts +19 -0
  57. package/dist/hub/index.js +19 -0
  58. package/dist/ports/catalog.d.ts +29 -0
  59. package/dist/ports/catalog.js +1 -0
  60. package/dist/ports/clock.d.ts +3 -0
  61. package/dist/ports/clock.js +1 -0
  62. package/dist/ports/index.d.ts +11 -0
  63. package/dist/ports/index.js +1 -0
  64. package/dist/ports/inflight-store.d.ts +15 -0
  65. package/dist/ports/inflight-store.js +1 -0
  66. package/dist/ports/process-registry.d.ts +72 -0
  67. package/dist/ports/process-registry.js +1 -0
  68. package/dist/ports/work-log.d.ts +14 -0
  69. package/dist/ports/work-log.js +1 -0
  70. package/dist/ports/work-sink.d.ts +39 -0
  71. package/dist/ports/work-sink.js +1 -0
  72. package/package.json +20 -2
  73. package/src/adapters/session-target.ts +86 -24
  74. package/src/domain/index.ts +19 -0
  75. package/src/hub/index.ts +19 -0
  76. package/src/ports/index.ts +11 -0
package/dist/config.js ADDED
@@ -0,0 +1,392 @@
1
+ import { BUILTIN_SUBAGENT_PROFILES, buildConfiguredProfile, matchesAnyMarker } from "./domain/subagent-profile.js";
2
+ const DEFAULT_DIR = ".kankaku";
3
+ const DEFAULT_INTERACTIVE_TOOLS = ["ask_user_question", "ask_user_choice"];
4
+ /**
5
+ * Default segment rule: with gentle-ai, the review-with-receipts step runs
6
+ * as `gentle-ai review ...` commands through the `bash` tool, so tag that
7
+ * span `review`.
8
+ */
9
+ const DEFAULT_SEGMENT_RULES = [{ tag: "review", tool: "bash", pattern: /\bgentle-ai review\b/ }];
10
+ /** Tags allowed for a segment rule: letters, digits, `_` and `-`, 1-32 chars. */
11
+ const SAFE_TAG = /^[A-Za-z0-9_-]{1,32}$/;
12
+ /** Property names that behave specially on a plain object; never usable as a tag. */
13
+ const RESERVED_TAGS = new Set(["__proto__", "constructor", "prototype"]);
14
+ function isSafeTag(tag) {
15
+ return SAFE_TAG.test(tag) && !RESERVED_TAGS.has(tag);
16
+ }
17
+ /**
18
+ * Parse `KANKAKU_SEGMENTS`, a `;`-separated list of `tag=tool:regex`
19
+ * entries (example: `review=bash:gentle-ai review;commit=bash:git commit`).
20
+ * Malformed entries (missing tag, tool or regex, an invalid regex source,
21
+ * or a tag that is not a safe identifier such as `__proto__`) are skipped
22
+ * rather than failing the whole variable.
23
+ */
24
+ function parseSegmentRules(raw) {
25
+ const rules = [];
26
+ for (const entry of raw.split(";")) {
27
+ const trimmed = entry.trim();
28
+ if (!trimmed)
29
+ continue;
30
+ const eqIndex = trimmed.indexOf("=");
31
+ if (eqIndex <= 0)
32
+ continue;
33
+ const tag = trimmed.slice(0, eqIndex).trim();
34
+ const rest = trimmed.slice(eqIndex + 1);
35
+ const colonIndex = rest.indexOf(":");
36
+ if (colonIndex <= 0)
37
+ continue;
38
+ const tool = rest.slice(0, colonIndex).trim();
39
+ const regexSource = rest.slice(colonIndex + 1).trim();
40
+ if (!tag || !tool || !regexSource || !isSafeTag(tag))
41
+ continue;
42
+ try {
43
+ rules.push({ tag, tool, pattern: new RegExp(regexSource) });
44
+ }
45
+ catch {
46
+ continue;
47
+ }
48
+ }
49
+ return rules;
50
+ }
51
+ /**
52
+ * SUBAGENT-REQ-002: `KANKAKU_SUBAGENT_TOOLS`, a comma-separated list of
53
+ * additional tool names treated as subagent-launching spans, parsed with
54
+ * the exact same tolerant trim-and-filter-empty convention as
55
+ * `KANKAKU_INTERACTIVE_TOOLS` above.
56
+ */
57
+ function parseSubagentTools(raw) {
58
+ return raw
59
+ .split(",")
60
+ .map((tool) => tool.trim())
61
+ .filter((tool) => tool.length > 0);
62
+ }
63
+ /**
64
+ * SUBAGENT-REQ-003: `KANKAKU_SUBAGENT_CHILD_ENV`, a `;`-separated list of
65
+ * `NAME=VALUE` or bare `NAME` child-process env markers — mirrors
66
+ * `KANKAKU_SEGMENTS`'s tolerant `;`-separated convention. A bare `NAME`
67
+ * marks presence-only (any non-empty value matches, e.g. pi-subagents'
68
+ * depth counter); `NAME=VALUE` requires an exact match. An entry with no
69
+ * name, or a trailing `=` with nothing after it, is malformed and skipped
70
+ * rather than failing the whole variable.
71
+ */
72
+ function parseSubagentChildEnv(raw) {
73
+ const markers = [];
74
+ for (const entry of raw.split(";")) {
75
+ const trimmed = entry.trim();
76
+ if (!trimmed)
77
+ continue;
78
+ const eqIndex = trimmed.indexOf("=");
79
+ if (eqIndex === -1) {
80
+ const name = trimmed;
81
+ if (name)
82
+ markers.push({ name });
83
+ continue;
84
+ }
85
+ const name = trimmed.slice(0, eqIndex).trim();
86
+ const value = trimmed.slice(eqIndex + 1).trim();
87
+ if (!name || !value)
88
+ continue;
89
+ markers.push({ name, value });
90
+ }
91
+ return markers;
92
+ }
93
+ /**
94
+ * C2 (CRITICAL fix, item 1): exact marker names pi sets on EVERY process it
95
+ * runs (verified: `dist/cli/setup.js:5` sets `PI_CODING_AGENT=true`,
96
+ * `dist/rpc-entry.js:6` sets `AI_AGENT=pi` — both unconditionally, not just
97
+ * for a subagent's child) or that are otherwise ordinary, ambient
98
+ * shell/OS/npm-lifecycle environment, never something a real child-only
99
+ * marker would legitimately reuse.
100
+ */
101
+ const DENIED_MARKER_NAMES = new Set(["PI_CODING_AGENT", "AI_AGENT", "PATH", "HOME", "USER", "SHELL", "PWD", "CI", "LANG", "TMUX"]);
102
+ /** C2 item 1: namespace prefixes an ambient variable is overwhelmingly likely to fall under — a genuine child-only marker should never need one of these either. */
103
+ const DENIED_MARKER_PREFIXES = ["PI_", "TERM", "LC_", "NODE_", "NPM_", "KANKAKU_"];
104
+ /** What an environment variable name may look like; anything else is a typo, never a marker. */
105
+ const VALID_MARKER_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
106
+ /** `undefined` when `name` is an acceptable marker name; otherwise a human-readable reason it was rejected. Matching is case-insensitive — an env var name's case carries no meaning here. */
107
+ function deniedMarkerReason(name) {
108
+ // A name that is not a valid environment variable name can never match a
109
+ // real variable: accepting it would silently disable the user's
110
+ // configuration AND hide any ambient name inside it from the denylist
111
+ // below. The usual cause is typing commas, as `KANKAKU_SUBAGENT_TOOLS`
112
+ // takes, where this list is `;`-separated.
113
+ if (!VALID_MARKER_NAME.test(name)) {
114
+ return `"${name}" is not a valid environment variable name — separate several markers with ";" (not ","), each as NAME or NAME=VALUE`;
115
+ }
116
+ const upper = name.toUpperCase();
117
+ if (DENIED_MARKER_NAMES.has(upper)) {
118
+ return `${name} is an ambient variable pi or the shell sets on EVERY process, not a marker exclusive to a subagent's child`;
119
+ }
120
+ for (const prefix of DENIED_MARKER_PREFIXES) {
121
+ if (upper.startsWith(prefix)) {
122
+ return `${name} looks like a pi/npm/shell-namespaced environment variable (prefix "${prefix}"), not a marker a third-party subagent tool would set`;
123
+ }
124
+ }
125
+ return undefined;
126
+ }
127
+ /**
128
+ * C2 (CRITICAL fix, item 1): validate every `KANKAKU_SUBAGENT_CHILD_ENV`
129
+ * marker against the denylist above, config-time. A rejected marker is
130
+ * never added to the `"configured"` profile — so it can never demote a
131
+ * user's own top-level session to `role: "subagent"` in the first place
132
+ * (layered with C2 items 2/3's runtime interactive guard in
133
+ * `config.ts#detectRole`, which still protects a marker this denylist does
134
+ * not happen to catch). `loadConfig` surfaces `rejected` via
135
+ * `KankakuConfig.rejectedSubagentChildEnvMarkers` for `/kankaku doctor` and
136
+ * a one-time `ctx.ui.notify`.
137
+ */
138
+ export function validateSubagentChildEnvMarkers(markers) {
139
+ const accepted = [];
140
+ const rejected = [];
141
+ for (const marker of markers) {
142
+ const reason = deniedMarkerReason(marker.name);
143
+ if (reason) {
144
+ rejected.push({ name: marker.name, reason });
145
+ }
146
+ else {
147
+ accepted.push(marker);
148
+ }
149
+ }
150
+ return { accepted, rejected };
151
+ }
152
+ export function loadConfig(env = process.env) {
153
+ const dir = env["KANKAKU_DIR"]?.trim() || DEFAULT_DIR;
154
+ const interactiveToolsRaw = env["KANKAKU_INTERACTIVE_TOOLS"]?.trim();
155
+ const interactiveTools = interactiveToolsRaw
156
+ ? interactiveToolsRaw
157
+ .split(",")
158
+ .map((tool) => tool.trim())
159
+ .filter((tool) => tool.length > 0)
160
+ : DEFAULT_INTERACTIVE_TOOLS;
161
+ const segmentsRaw = env["KANKAKU_SEGMENTS"]?.trim();
162
+ const segmentRules = segmentsRaw ? parseSegmentRules(segmentsRaw) : DEFAULT_SEGMENT_RULES;
163
+ const client = env["KANKAKU_CLIENT"]?.trim() || undefined;
164
+ const subagentToolsRaw = env["KANKAKU_SUBAGENT_TOOLS"]?.trim();
165
+ const configuredTools = subagentToolsRaw ? parseSubagentTools(subagentToolsRaw) : [];
166
+ const subagentChildEnvRaw = env["KANKAKU_SUBAGENT_CHILD_ENV"]?.trim();
167
+ const parsedMarkers = subagentChildEnvRaw ? parseSubagentChildEnv(subagentChildEnvRaw) : [];
168
+ // C2 (CRITICAL fix, item 1): a denylisted marker name is never handed to
169
+ // buildConfiguredProfile at all — it can never demote a session's role,
170
+ // and is instead surfaced via rejectedSubagentChildEnvMarkers below.
171
+ const { accepted: configuredMarkers, rejected: rejectedSubagentChildEnvMarkers } = validateSubagentChildEnvMarkers(parsedMarkers);
172
+ const configuredProfile = buildConfiguredProfile(configuredTools, configuredMarkers);
173
+ const subagentProfiles = [...BUILTIN_SUBAGENT_PROFILES, ...(configuredProfile ? [configuredProfile] : [])];
174
+ return {
175
+ dir,
176
+ interactiveTools,
177
+ subagentProfiles,
178
+ segmentRules,
179
+ rejectedSubagentChildEnvMarkers,
180
+ ...(client !== undefined ? { client } : {}),
181
+ };
182
+ }
183
+ /**
184
+ * `KANKAKU_ROLE`: an explicit escape hatch for a genuine session
185
+ * `detectRole` gets wrong (no reliable automatic signal exists for it),
186
+ * and for a legacy/JSONL record already written `uncertain`, which can
187
+ * never be rewritten after the fact (the log is append-only) but whose
188
+ * *next* run can be told the truth directly. It does **not** override
189
+ * every other signal unconditionally any more — see `detectRole`'s
190
+ * precedence doc (R1) for the confirmed-child-marker and interactive-
191
+ * session exceptions this now has. An unrecognised value (anything other
192
+ * than exactly `"orchestrator"` or `"subagent"`) is ignored, falling back
193
+ * to normal detection, rather than failing the process or guessing.
194
+ */
195
+ export function readRoleOverride(env = process.env) {
196
+ const raw = env["KANKAKU_ROLE"]?.trim();
197
+ return raw === "orchestrator" || raw === "subagent" ? raw : undefined;
198
+ }
199
+ /**
200
+ * Remove `KANKAKU_ROLE` from `env` in place (R1, layer 2 — non-
201
+ * propagation). `KANKAKU_ROLE` decides only THIS process's role; a child
202
+ * this process spawns (a subagent runner, a tool shell) must never inherit
203
+ * it, since `process.env` is inherited by every OS child by default. Left
204
+ * unstripped, a user who once hit a false `uncertain` and exported
205
+ * `KANKAKU_ROLE=orchestrator` in a shell rc/tmux/CI environment would have
206
+ * every subsequent subagent see it too — layer 1's precedence fix
207
+ * (a confirmed child marker always wins) already neutralises that specific
208
+ * leak for a *recognised* subagent mechanism, but this strips it outright
209
+ * so it can never reach an unrecognised one, or reach an unrelated child
210
+ * process this one spawns for some other reason. Takes the env object as
211
+ * a parameter, rather than reaching for `process.env` itself, so this
212
+ * stays a pure function tests can exercise against a plain object without
213
+ * ever mutating the real environment — `extension.ts` is the one caller
214
+ * that passes the real `process.env`, right after reading the override.
215
+ */
216
+ export function stripRoleOverride(env) {
217
+ delete env["KANKAKU_ROLE"];
218
+ }
219
+ /**
220
+ * Classify this process's role, strictly in this order (R1 rewrote this
221
+ * precedence — a leaked `KANKAKU_ROLE` must never out-rank a *confirmed*
222
+ * signal, and must never silently drop a genuine interactive session):
223
+ *
224
+ * 1. `GENTLE_PI_AGENTS_CHILD=1` — the automatic confirmed-subagent marker,
225
+ * set only by the subagent runner itself, never something a shell
226
+ * rc/tmux/CI environment would export. **Always wins**, even over an
227
+ * explicit `KANKAKU_ROLE=orchestrator` — without this, a leaked
228
+ * `KANKAKU_ROLE=orchestrator` export would turn every one of this
229
+ * process's genuine subagent invocations into a confirmed,
230
+ * independently-billed orchestrator too (the bug this fixes).
231
+ * 2. `KANKAKU_ROLE` (F3's explicit escape hatch), when set to a recognised
232
+ * value and no confirmed marker matched above — with one exception:
233
+ * `KANKAKU_ROLE=subagent` in an **interactive** session (`isInteractive`
234
+ * — see below) is ignored. No subagent mechanism kankaku recognises
235
+ * ever launches its child interactively; an interactive session with
236
+ * this override set and no marker to back it up is therefore almost
237
+ * certainly a leaked shell export, not a real subagent. Honouring it
238
+ * would silently drop this session's own work from every report and
239
+ * the hub (an orphaned subagent record that never anchors a task) with
240
+ * no way to recover it later, since `worklog.jsonl` is append-only.
241
+ * Between the package's two guiding rules — "undercount is recoverable,
242
+ * overcount is not" (which governs the *opposite* risk, inventing extra
243
+ * billing, and does not apply here) and "never silently drop genuine
244
+ * work" — this is governed by the second: the override is ignored, this
245
+ * process is classified `orchestrator` (what it structurally must be),
246
+ * and `overrideIgnoredInteractive` is set so the caller can surface the
247
+ * contradiction instead of resolving it silently. A non-interactive
248
+ * process gets exactly what it asked for. `KANKAKU_ROLE=orchestrator`
249
+ * has no such exception — forcing a session `orchestrator` can never
250
+ * drop work, only (rarely) invent a task that should not exist, a risk
251
+ * the user accepted by setting it explicitly.
252
+ * 3. `hasTrackedAncestor` — whether this process's own OS ancestor chain
253
+ * contains a live, identity-verified entry in the machine-wide process
254
+ * registry (computed by the caller, e.g. `adapters/subagent-startup.ts`,
255
+ * via `adapters/ancestry.ts` + `domain/ancestry-match.ts`; see
256
+ * `ports/process-registry.ts`) — combined with `isInteractive`: only a
257
+ * *non-interactive* process with a tracked ancestor is demoted to
258
+ * `uncertain` (ADR 0022's safe default, inverted). An interactive TUI
259
+ * session on a real terminal is a human's own session even when some
260
+ * ancestor happens to be a tracked pi process (e.g. pi launched from
261
+ * inside another pi's shell tool) — every subagent mechanism kankaku
262
+ * recognises launches its child non-interactively over pipes, so
263
+ * `isInteractive` alone already tells a genuine top-level session apart
264
+ * from one that could plausibly be someone's silent child.
265
+ *
266
+ * `isInteractive` defaults to `true` — the same conservative default used
267
+ * for both the interactive-override exception above and the `uncertain`
268
+ * fallback below, since a caller that does not yet know it (see
269
+ * `extension.ts`'s factory-time synchronous TTY proxy, and F3's later,
270
+ * authoritative `ctx.mode === "tui"` refinement of `roleConfidence` only —
271
+ * never of `role` itself) should never wrongly honour a `subagent` override
272
+ * or demote a session to `uncertain` before it can find out.
273
+ *
274
+ * A process that cannot be shown to be top-level by any of the above must
275
+ * never default to `"orchestrator"` outright — but see F2: when ancestor
276
+ * detection itself is unavailable (platform, or a failed/timed-out probe),
277
+ * `hasTrackedAncestor` is simply `false` (nothing was found), which already
278
+ * falls through to a confirmed orchestrator here — the *old*, pre-ADR-0022
279
+ * behaviour on such a platform, deliberately: marking every unprovable
280
+ * process `uncertain` there would drop all of a Windows user's genuine
281
+ * work, a far worse failure than the narrow overcount this guards against
282
+ * elsewhere. `/kankaku doctor` is responsible for making that unavailable-
283
+ * detection limitation visible; it is never encoded in `roleConfidence`.
284
+ *
285
+ * C2 (CRITICAL fix): `childMarkers` (built-in tier, step 1 above) and
286
+ * `configuredMarkers` (the 5th param) are now two DIFFERENT tiers, not one
287
+ * merged list. A BUILT-IN marker (`GENTLE_PI_AGENTS_CHILD`,
288
+ * `PI_SUBAGENT_DEPTH`) is set only by a real subagent runner and always
289
+ * wins outright, exactly as step 1 describes — verified that no built-in
290
+ * mechanism kankaku recognises ever launches its child interactively (see
291
+ * `domain/subagent-profile.ts#PI_SUBAGENTS_PROFILE`'s doc comment), so this
292
+ * tier never actually needs the interactive exception in practice, and
293
+ * keeping it unconditional avoids a behaviour change for it. A
294
+ * USER-CONFIGURED marker (`KANKAKU_SUBAGENT_CHILD_ENV`), by contrast, names
295
+ * an arbitrary environment variable kankaku cannot verify is child-only —
296
+ * pi itself sets `PI_CODING_AGENT`/`AI_AGENT` on EVERY process, and a naive
297
+ * choice like that (or `CI`, `TMUX`, an exported shell var) would make the
298
+ * user's own top-level interactive session `role: "subagent"` with no
299
+ * parent, never anchoring a task and unrecoverable once written (the log
300
+ * is append-only). So a configured marker gets EXACTLY the same
301
+ * interactive exception `KANKAKU_ROLE=subagent` already has (step 2): it
302
+ * never demotes an interactive session — `configuredMarkerIgnoredInteractive`
303
+ * is set instead, so the caller can self-check and surface the
304
+ * contradiction (C2 item 3) rather than silently trusting an ambient
305
+ * marker. See `config.ts#loadConfig`'s denylist for the config-time half of
306
+ * this fix (rejecting an obviously-ambient marker name outright).
307
+ */
308
+ /** `detectRole`'s default `childMarkers` when a caller does not pass its own active profile set — identical to the single marker this function hardcoded before SUBAGENT-REQ-001/002/003, so every pre-existing 3-arg call site keeps behaving exactly as before. */
309
+ const DEFAULT_CHILD_MARKERS = [{ name: "GENTLE_PI_AGENTS_CHILD", value: "1" }];
310
+ export function detectRole(env = process.env, hasTrackedAncestor = false, isInteractive = true,
311
+ /** SUBAGENT-REQ-002/003: built-in profiles' own child-env markers ONLY (never the user-configured one — see `configuredMarkers` below) — generalises the single hardcoded `GENTLE_PI_AGENTS_CHILD` check without changing precedence. See `domain/subagent-profile.ts#builtinChildMarkers`. */
312
+ childMarkers = DEFAULT_CHILD_MARKERS,
313
+ /** C2: the user-configured profile's child-env marker(s) only (`KANKAKU_SUBAGENT_CHILD_ENV`, already denylist-filtered by `loadConfig`) — a SEPARATE, weaker tier: it can confirm a subagent, but unlike `childMarkers` above, never demotes an interactive session. See `domain/subagent-profile.ts#configuredChildMarkers`. */
314
+ configuredMarkers = []) {
315
+ if (matchesAnyMarker(env, childMarkers))
316
+ return { role: "subagent" };
317
+ const configuredMatch = matchesAnyMarker(env, configuredMarkers);
318
+ if (configuredMatch && !isInteractive)
319
+ return { role: "subagent" };
320
+ let result;
321
+ const override = readRoleOverride(env);
322
+ if (override === "orchestrator") {
323
+ result = { role: "orchestrator" };
324
+ }
325
+ else if (override === "subagent") {
326
+ result = isInteractive ? { role: "orchestrator", overrideIgnoredInteractive: true } : { role: "subagent" };
327
+ }
328
+ else if (hasTrackedAncestor && !isInteractive) {
329
+ result = { role: "orchestrator", roleConfidence: "uncertain" };
330
+ }
331
+ else {
332
+ result = { role: "orchestrator" };
333
+ }
334
+ // The configured marker matched but was ignored (this process is
335
+ // interactive) — flagged regardless of what else decided `result`, so the
336
+ // caller always learns a configured marker is present-but-ambient here.
337
+ return configuredMatch && isInteractive ? { ...result, configuredMarkerIgnoredInteractive: true } : result;
338
+ }
339
+ /** Read `KANKAKU_PB_URL`/`KANKAKU_PB_EMAIL`/`KANKAKU_PB_PASSWORD`. Empty/whitespace-only values are treated as absent. */
340
+ export function loadHubEnvCredentials(env = process.env) {
341
+ return {
342
+ url: env["KANKAKU_PB_URL"]?.trim() || undefined,
343
+ email: env["KANKAKU_PB_EMAIL"]?.trim() || undefined,
344
+ password: env["KANKAKU_PB_PASSWORD"]?.trim() || undefined,
345
+ };
346
+ }
347
+ /** `KANKAKU_MACHINE`, or `hostname()` when unset/blank. Injected so this stays testable without touching `os.hostname`. */
348
+ export function loadMachine(env, hostname) {
349
+ return env["KANKAKU_MACHINE"]?.trim() || hostname();
350
+ }
351
+ /** Hosts allowed to use a plain-HTTP hub URL. */
352
+ function isLocalHost(hostname) {
353
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
354
+ }
355
+ const VALID_PROMPT_MODES = new Set(["none", "truncated", "full"]);
356
+ const DEFAULT_SYNC_WINDOW_HOURS = 24;
357
+ const DEFAULT_SYNC_MIN_INTERVAL_MINUTES = 5;
358
+ export function loadSyncConfig(env = process.env) {
359
+ const promptRaw = env["KANKAKU_SYNC_PROMPT"]?.trim();
360
+ const promptMode = promptRaw && VALID_PROMPT_MODES.has(promptRaw) ? promptRaw : "none";
361
+ const windowRaw = env["KANKAKU_SYNC_WINDOW_HOURS"]?.trim();
362
+ const parsedWindow = windowRaw ? Number(windowRaw) : NaN;
363
+ const windowHours = Number.isFinite(parsedWindow) && parsedWindow > 0 ? parsedWindow : DEFAULT_SYNC_WINDOW_HOURS;
364
+ const syncRecords = env["KANKAKU_SYNC_RECORDS"]?.trim() !== "0";
365
+ const auto = env["KANKAKU_SYNC_AUTO"]?.trim() !== "0";
366
+ // 0 is a valid, explicit "disable throttling" value, distinct from an
367
+ // unset or garbage one (which falls back to the default) — unlike
368
+ // windowHours above, which treats 0 as invalid.
369
+ const minIntervalRaw = env["KANKAKU_SYNC_MIN_INTERVAL_MINUTES"]?.trim();
370
+ const parsedMinInterval = minIntervalRaw ? Number(minIntervalRaw) : NaN;
371
+ const minIntervalMinutes = Number.isFinite(parsedMinInterval) && parsedMinInterval >= 0 ? parsedMinInterval : DEFAULT_SYNC_MIN_INTERVAL_MINUTES;
372
+ return { promptMode, windowHours, syncRecords, auto, minIntervalMinutes };
373
+ }
374
+ /**
375
+ * A hub URL must be HTTPS, unless it points at localhost/127.0.0.1/::1 (a
376
+ * local PocketBase instance for development). Also rejects a URL that does
377
+ * not parse at all.
378
+ */
379
+ export function validateHubUrl(url) {
380
+ let parsed;
381
+ try {
382
+ parsed = new URL(url);
383
+ }
384
+ catch {
385
+ return { ok: false, reason: `kankaku: invalid hub URL: ${url}` };
386
+ }
387
+ if (parsed.protocol === "https:")
388
+ return { ok: true };
389
+ if (parsed.protocol === "http:" && isLocalHost(parsed.hostname))
390
+ return { ok: true };
391
+ return { ok: false, reason: `kankaku: refusing non-HTTPS hub URL (only localhost is allowed over plain HTTP): ${url}` };
392
+ }
@@ -0,0 +1,49 @@
1
+ import type { RegistryEntry } from "../ports/process-registry.ts";
2
+ import type { OrchestratorRef } from "./work-record.ts";
3
+ /**
4
+ * Max allowed drift (ms) between a live process's freshly re-derived start
5
+ * identity and the one recorded in its registry entry. Absorbs
6
+ * second-granularity rounding/reading noise from the underlying OS
7
+ * start-time source (`adapters/ancestry.ts`) — not a measure of real clock
8
+ * error, since both readings of the *same* process instance stay tightly
9
+ * consistent regardless of that source's own precision (see that module's
10
+ * docs). A genuine pid-reuse produces a gap far larger than this.
11
+ */
12
+ export declare const START_ID_TOLERANCE_MS = 2000;
13
+ /**
14
+ * The nearest tracked ancestor whose identity can actually be *proven*: the
15
+ * first `ancestryPids` entry (ordered nearest-parent first, see
16
+ * `adapters/ancestry.ts#walkAncestry`) that has a matching
17
+ * {@link RegistryEntry} AND whose registry-recorded `processStartId`
18
+ * agrees, within {@link START_ID_TOLERANCE_MS}, with `liveStartId(pid)` —
19
+ * a fresh re-derivation of that same pid's actual OS start time, taken
20
+ * from the same ancestry snapshot the caller already has. Matching by pid
21
+ * number alone is not safe: the OS reuses pids, so a stale entry left
22
+ * behind by a dead, never-cleaned-up process can otherwise be
23
+ * misattributed to whatever unrelated live process the kernel later hands
24
+ * that same pid to (a genuine top-level session silently misclassified as
25
+ * someone's subagent forever). A candidate whose identity cannot be
26
+ * verified — no `processStartId` on the entry (legacy/malformed), or no
27
+ * live start id available for that pid (platform without ancestor-chain
28
+ * support, or a snapshot gap) — is never matched; the walk continues past
29
+ * it exactly like an untracked hop, so a further genuine ancestor can still
30
+ * be found. Returns `undefined` when no ancestor pid is trackable at all —
31
+ * callers must treat that exactly like "no registry available" (safe
32
+ * fallback to the pre-registry behaviour), never invent a match.
33
+ */
34
+ export declare function findAncestorEntry(ancestryPids: number[], entries: RegistryEntry[], liveStartId: (pid: number) => number | undefined): RegistryEntry | undefined;
35
+ /**
36
+ * Resolve the ultimate, top-level {@link OrchestratorRef} for `ancestorEntry`
37
+ * (the nearest verified ancestor {@link findAncestorEntry} returned) — F4's
38
+ * nested-subagent fix. When that ancestor is itself a `subagent`-role entry
39
+ * that already resolved its own verified `orchestratorRef` (a
40
+ * subagent-of-subagent chain: this process's parent is itself someone's
41
+ * child), that inherited ref is returned instead of one built from the
42
+ * ancestor's own identity — so a grandchild's `orchestratorRef` (and, via
43
+ * its `dir`, `extension.ts`'s F1 write-routing target) always points at
44
+ * the real top-level orchestrator, never a middle hop. Falls back to an
45
+ * ancestor's own identity when it is an orchestrator, or a subagent that
46
+ * never resolved a ref of its own (e.g. it discovered no tracked ancestor
47
+ * at its own startup) — never invents one. `undefined` in, `undefined` out.
48
+ */
49
+ export declare function resolveOrchestratorRef(ancestorEntry: RegistryEntry | undefined): OrchestratorRef | undefined;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Max allowed drift (ms) between a live process's freshly re-derived start
3
+ * identity and the one recorded in its registry entry. Absorbs
4
+ * second-granularity rounding/reading noise from the underlying OS
5
+ * start-time source (`adapters/ancestry.ts`) — not a measure of real clock
6
+ * error, since both readings of the *same* process instance stay tightly
7
+ * consistent regardless of that source's own precision (see that module's
8
+ * docs). A genuine pid-reuse produces a gap far larger than this.
9
+ */
10
+ export const START_ID_TOLERANCE_MS = 2000;
11
+ /** Whether two start identities are close enough to be the same process instance. `undefined` on either side is always "no", never a coincidental match. */
12
+ function sameProcessInstance(recorded, live) {
13
+ if (recorded === undefined || live === undefined)
14
+ return false;
15
+ return Math.abs(recorded - live) <= START_ID_TOLERANCE_MS;
16
+ }
17
+ /**
18
+ * The nearest tracked ancestor whose identity can actually be *proven*: the
19
+ * first `ancestryPids` entry (ordered nearest-parent first, see
20
+ * `adapters/ancestry.ts#walkAncestry`) that has a matching
21
+ * {@link RegistryEntry} AND whose registry-recorded `processStartId`
22
+ * agrees, within {@link START_ID_TOLERANCE_MS}, with `liveStartId(pid)` —
23
+ * a fresh re-derivation of that same pid's actual OS start time, taken
24
+ * from the same ancestry snapshot the caller already has. Matching by pid
25
+ * number alone is not safe: the OS reuses pids, so a stale entry left
26
+ * behind by a dead, never-cleaned-up process can otherwise be
27
+ * misattributed to whatever unrelated live process the kernel later hands
28
+ * that same pid to (a genuine top-level session silently misclassified as
29
+ * someone's subagent forever). A candidate whose identity cannot be
30
+ * verified — no `processStartId` on the entry (legacy/malformed), or no
31
+ * live start id available for that pid (platform without ancestor-chain
32
+ * support, or a snapshot gap) — is never matched; the walk continues past
33
+ * it exactly like an untracked hop, so a further genuine ancestor can still
34
+ * be found. Returns `undefined` when no ancestor pid is trackable at all —
35
+ * callers must treat that exactly like "no registry available" (safe
36
+ * fallback to the pre-registry behaviour), never invent a match.
37
+ */
38
+ export function findAncestorEntry(ancestryPids, entries, liveStartId) {
39
+ if (ancestryPids.length === 0 || entries.length === 0)
40
+ return undefined;
41
+ const byPid = new Map();
42
+ for (const entry of entries) {
43
+ // First entry for a given pid wins; a pid should only ever have one
44
+ // live registry file, but a torn write racing a sweep could in theory
45
+ // leave two candidates behind — deterministic tie-break, not correctness-critical.
46
+ if (!byPid.has(entry.pid))
47
+ byPid.set(entry.pid, entry);
48
+ }
49
+ for (const pid of ancestryPids) {
50
+ const entry = byPid.get(pid);
51
+ if (!entry)
52
+ continue;
53
+ if (sameProcessInstance(entry.processStartId, liveStartId(pid)))
54
+ return entry;
55
+ // Either unprovable, or this pid has genuinely been reused by a
56
+ // different process instance since the entry was written: not our
57
+ // ancestor. Keep walking — a further, verifiable ancestor may still exist.
58
+ }
59
+ return undefined;
60
+ }
61
+ /**
62
+ * Resolve the ultimate, top-level {@link OrchestratorRef} for `ancestorEntry`
63
+ * (the nearest verified ancestor {@link findAncestorEntry} returned) — F4's
64
+ * nested-subagent fix. When that ancestor is itself a `subagent`-role entry
65
+ * that already resolved its own verified `orchestratorRef` (a
66
+ * subagent-of-subagent chain: this process's parent is itself someone's
67
+ * child), that inherited ref is returned instead of one built from the
68
+ * ancestor's own identity — so a grandchild's `orchestratorRef` (and, via
69
+ * its `dir`, `extension.ts`'s F1 write-routing target) always points at
70
+ * the real top-level orchestrator, never a middle hop. Falls back to an
71
+ * ancestor's own identity when it is an orchestrator, or a subagent that
72
+ * never resolved a ref of its own (e.g. it discovered no tracked ancestor
73
+ * at its own startup) — never invents one. `undefined` in, `undefined` out.
74
+ */
75
+ export function resolveOrchestratorRef(ancestorEntry) {
76
+ if (ancestorEntry === undefined)
77
+ return undefined;
78
+ if (ancestorEntry.role === "subagent" && ancestorEntry.orchestratorRef !== undefined) {
79
+ return ancestorEntry.orchestratorRef;
80
+ }
81
+ return { pid: ancestorEntry.pid, project: ancestorEntry.project, startedAt: ancestorEntry.startedAt, dir: ancestorEntry.dir };
82
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Client (billing target) label resolution: pure, no I/O.
3
+ *
4
+ * A client name identifies who a piece of work is billed to. It can come
5
+ * from three sources, in decreasing precedence: the pi session (set with
6
+ * `/kankaku client <name>`), the `KANKAKU_CLIENT` environment variable, or
7
+ * the project's `.kankaku/config.json`.
8
+ */
9
+ export interface ClientSources {
10
+ /** Set for the current pi session via `/kankaku client <name>`. Highest precedence. */
11
+ session?: string;
12
+ /** From the `KANKAKU_CLIENT` environment variable. */
13
+ env?: string;
14
+ /** From the project's `.kankaku/config.json`. Lowest precedence. */
15
+ project?: string;
16
+ }
17
+ export type ClientSourceName = "session" | "env" | "project";
18
+ /** `true` when `value` is a non-empty, safe client name. */
19
+ export declare function isValidClient(value: string): boolean;
20
+ /**
21
+ * Resolve the effective client name from `session`, `env` and `project`
22
+ * sources, in that precedence order. Each candidate is trimmed; an empty
23
+ * string or a value that does not match the safe client-name pattern is
24
+ * treated as absent and resolution falls through to the next source.
25
+ */
26
+ export declare function resolveClient(sources: ClientSources): string | undefined;
27
+ /** Which source produced {@link resolveClient}'s result, or `undefined` when none applies. */
28
+ export declare function resolveClientSource(sources: ClientSources): ClientSourceName | undefined;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Client (billing target) label resolution: pure, no I/O.
3
+ *
4
+ * A client name identifies who a piece of work is billed to. It can come
5
+ * from three sources, in decreasing precedence: the pi session (set with
6
+ * `/kankaku client <name>`), the `KANKAKU_CLIENT` environment variable, or
7
+ * the project's `.kankaku/config.json`.
8
+ */
9
+ /** Safe client name: letters, digits, `.`, `_`, `-`, 1-64 chars. */
10
+ const CLIENT_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
11
+ /** Property names that behave specially on a plain object; never usable as a client name. */
12
+ const RESERVED_NAMES = new Set(["__proto__", "constructor", "prototype"]);
13
+ /** `true` when `value` is a non-empty, safe client name. */
14
+ export function isValidClient(value) {
15
+ return CLIENT_PATTERN.test(value) && !RESERVED_NAMES.has(value);
16
+ }
17
+ /** Trim and validate a candidate client name; `undefined` when absent or invalid. */
18
+ function normalize(value) {
19
+ if (value === undefined)
20
+ return undefined;
21
+ const trimmed = value.trim();
22
+ if (trimmed.length === 0)
23
+ return undefined;
24
+ return isValidClient(trimmed) ? trimmed : undefined;
25
+ }
26
+ /**
27
+ * Resolve the effective client name from `session`, `env` and `project`
28
+ * sources, in that precedence order. Each candidate is trimmed; an empty
29
+ * string or a value that does not match the safe client-name pattern is
30
+ * treated as absent and resolution falls through to the next source.
31
+ */
32
+ export function resolveClient(sources) {
33
+ return normalize(sources.session) ?? normalize(sources.env) ?? normalize(sources.project);
34
+ }
35
+ /** Which source produced {@link resolveClient}'s result, or `undefined` when none applies. */
36
+ export function resolveClientSource(sources) {
37
+ if (normalize(sources.session) !== undefined)
38
+ return "session";
39
+ if (normalize(sources.env) !== undefined)
40
+ return "env";
41
+ if (normalize(sources.project) !== undefined)
42
+ return "project";
43
+ return undefined;
44
+ }
@@ -0,0 +1,2 @@
1
+ /** Local (not UTC) calendar day of an ISO timestamp, as `YYYY-MM-DD`. */
2
+ export declare function localDay(iso: string): string;
@@ -0,0 +1,8 @@
1
+ /** Local (not UTC) calendar day of an ISO timestamp, as `YYYY-MM-DD`. */
2
+ export function localDay(iso) {
3
+ const date = new Date(iso);
4
+ const year = date.getFullYear();
5
+ const month = String(date.getMonth() + 1).padStart(2, "0");
6
+ const day = String(date.getDate()).padStart(2, "0");
7
+ return `${year}-${month}-${day}`;
8
+ }
@@ -0,0 +1,38 @@
1
+ import type { WorkStatus } from "./work-record.ts";
2
+ import type { TaskView } from "./task-view.ts";
3
+ /** One flat, spreadsheet-friendly row per {@link TaskView}. */
4
+ export interface ExportRow {
5
+ id: string;
6
+ /** Local calendar day (`YYYY-MM-DD`) the task started on. */
7
+ day: string;
8
+ startedAt: string;
9
+ endedAt: string;
10
+ /** Empty string when the task has no resolved client. */
11
+ client: string;
12
+ /** Empty string when the orchestrator record has no session name. */
13
+ sessionName: string;
14
+ /** Empty string when the orchestrator record has no session id. */
15
+ sessionId: string;
16
+ project: string;
17
+ status: WorkStatus;
18
+ /** First 200 chars of the task's prompt, with newlines collapsed to spaces. */
19
+ prompt: string;
20
+ wallMs: number;
21
+ waitingMs: number;
22
+ workMs: number;
23
+ cost: number;
24
+ tokensIn: number;
25
+ tokensOut: number;
26
+ cacheRead: number;
27
+ subagentCount: number;
28
+ /** JSON-encoded `TaskView.segments` map. */
29
+ segments: string;
30
+ /** Empty string when the orchestrator record has no model. */
31
+ model: string;
32
+ }
33
+ /** Build one flat {@link ExportRow} per task, in the same order as `tasks`. */
34
+ export declare function exportRows(tasks: TaskView[]): ExportRow[];
35
+ /** Render `rows` as RFC 4180 CSV: a header row, one row per record, `\n` line endings. */
36
+ export declare function toCsv(rows: ExportRow[]): string;
37
+ /** Render `rows` as pretty-printed (2-space indent) JSON. */
38
+ export declare function toJson(rows: ExportRow[]): string;