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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1438 -0
  3. package/dist/adapters/cached-catalog.d.ts +42 -0
  4. package/dist/adapters/cached-catalog.js +121 -0
  5. package/dist/adapters/export-writer.d.ts +13 -0
  6. package/dist/adapters/export-writer.js +28 -0
  7. package/dist/adapters/file-modes.d.ts +20 -0
  8. package/dist/adapters/file-modes.js +34 -0
  9. package/dist/adapters/hub-actions.d.ts +35 -0
  10. package/dist/adapters/hub-actions.js +70 -0
  11. package/dist/adapters/hub-credentials.d.ts +35 -0
  12. package/dist/adapters/hub-credentials.js +58 -0
  13. package/dist/adapters/jsonl-work-log.d.ts +20 -0
  14. package/dist/adapters/jsonl-work-log.js +62 -0
  15. package/dist/adapters/kankaku-dir.d.ts +38 -0
  16. package/dist/adapters/kankaku-dir.js +85 -0
  17. package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
  18. package/dist/adapters/lazy-jsonl-work-log.js +31 -0
  19. package/dist/adapters/pocketbase-catalog.d.ts +16 -0
  20. package/dist/adapters/pocketbase-catalog.js +56 -0
  21. package/dist/adapters/pocketbase-client.d.ts +81 -0
  22. package/dist/adapters/pocketbase-client.js +148 -0
  23. package/dist/adapters/pocketbase-sink.d.ts +53 -0
  24. package/dist/adapters/pocketbase-sink.js +181 -0
  25. package/dist/adapters/project-config.d.ts +42 -0
  26. package/dist/adapters/project-config.js +108 -0
  27. package/dist/adapters/report-data.d.ts +12 -0
  28. package/dist/adapters/report-data.js +8 -0
  29. package/dist/adapters/report-views.d.ts +45 -0
  30. package/dist/adapters/report-views.js +73 -0
  31. package/dist/adapters/report.d.ts +112 -0
  32. package/dist/adapters/report.js +236 -0
  33. package/dist/adapters/sync-runner.d.ts +114 -0
  34. package/dist/adapters/sync-runner.js +273 -0
  35. package/dist/adapters/sync-state-store.d.ts +62 -0
  36. package/dist/adapters/sync-state-store.js +188 -0
  37. package/dist/config.d.ts +168 -0
  38. package/dist/config.js +392 -0
  39. package/dist/domain/ancestry-match.d.ts +49 -0
  40. package/dist/domain/ancestry-match.js +82 -0
  41. package/dist/domain/client-label.d.ts +28 -0
  42. package/dist/domain/client-label.js +44 -0
  43. package/dist/domain/day.d.ts +2 -0
  44. package/dist/domain/day.js +8 -0
  45. package/dist/domain/export.d.ts +38 -0
  46. package/dist/domain/export.js +68 -0
  47. package/dist/domain/hub-entry.d.ts +234 -0
  48. package/dist/domain/hub-entry.js +265 -0
  49. package/dist/domain/index.d.ts +19 -0
  50. package/dist/domain/index.js +19 -0
  51. package/dist/domain/intervals.d.ts +17 -0
  52. package/dist/domain/intervals.js +43 -0
  53. package/dist/domain/registry-health.d.ts +49 -0
  54. package/dist/domain/registry-health.js +58 -0
  55. package/dist/domain/segment-rule.d.ts +10 -0
  56. package/dist/domain/segment-rule.js +1 -0
  57. package/dist/domain/subagent-profile.d.ts +278 -0
  58. package/dist/domain/subagent-profile.js +418 -0
  59. package/dist/domain/sync-plan.d.ts +151 -0
  60. package/dist/domain/sync-plan.js +196 -0
  61. package/dist/domain/task-view.d.ts +117 -0
  62. package/dist/domain/task-view.js +428 -0
  63. package/dist/domain/work-record.d.ts +236 -0
  64. package/dist/domain/work-record.js +91 -0
  65. package/dist/domain/work-target.d.ts +101 -0
  66. package/dist/domain/work-target.js +149 -0
  67. package/dist/domain/work-tracker.d.ts +90 -0
  68. package/dist/domain/work-tracker.js +405 -0
  69. package/dist/hub/index.d.ts +25 -0
  70. package/dist/hub/index.js +25 -0
  71. package/dist/ports/catalog.d.ts +31 -0
  72. package/dist/ports/catalog.js +1 -0
  73. package/dist/ports/clock.d.ts +3 -0
  74. package/dist/ports/clock.js +1 -0
  75. package/dist/ports/index.d.ts +11 -0
  76. package/dist/ports/index.js +1 -0
  77. package/dist/ports/inflight-store.d.ts +15 -0
  78. package/dist/ports/inflight-store.js +1 -0
  79. package/dist/ports/process-registry.d.ts +72 -0
  80. package/dist/ports/process-registry.js +1 -0
  81. package/dist/ports/work-log.d.ts +14 -0
  82. package/dist/ports/work-log.js +1 -0
  83. package/dist/ports/work-sink.d.ts +39 -0
  84. package/dist/ports/work-sink.js +1 -0
  85. package/package.json +66 -0
  86. package/src/adapters/agent-info.ts +86 -0
  87. package/src/adapters/ancestry.ts +260 -0
  88. package/src/adapters/cached-catalog.ts +147 -0
  89. package/src/adapters/export-writer.ts +33 -0
  90. package/src/adapters/file-inflight-store.ts +115 -0
  91. package/src/adapters/file-modes.ts +35 -0
  92. package/src/adapters/hub-actions.ts +82 -0
  93. package/src/adapters/hub-credentials.ts +95 -0
  94. package/src/adapters/jsonl-work-log.ts +67 -0
  95. package/src/adapters/kankaku-command.ts +717 -0
  96. package/src/adapters/kankaku-dir.ts +102 -0
  97. package/src/adapters/lazy-file-inflight-store.ts +43 -0
  98. package/src/adapters/lazy-jsonl-work-log.ts +39 -0
  99. package/src/adapters/machine-process-registry.ts +256 -0
  100. package/src/adapters/panel/kankaku-panel.ts +419 -0
  101. package/src/adapters/panel/panel-items.ts +87 -0
  102. package/src/adapters/panel/panel-lines.ts +13 -0
  103. package/src/adapters/panel/panel-theme.ts +32 -0
  104. package/src/adapters/panel/screens/about.ts +69 -0
  105. package/src/adapters/panel/screens/doctor.ts +89 -0
  106. package/src/adapters/panel/screens/export.ts +123 -0
  107. package/src/adapters/panel/screens/report.ts +143 -0
  108. package/src/adapters/panel/screens/sync.ts +136 -0
  109. package/src/adapters/panel/screens/target.ts +384 -0
  110. package/src/adapters/pi-tracker.ts +753 -0
  111. package/src/adapters/pocketbase-catalog.ts +89 -0
  112. package/src/adapters/pocketbase-client.ts +197 -0
  113. package/src/adapters/pocketbase-sink.ts +236 -0
  114. package/src/adapters/process-identity-memo.ts +102 -0
  115. package/src/adapters/process-identity.ts +162 -0
  116. package/src/adapters/project-config.ts +116 -0
  117. package/src/adapters/report-data.ts +13 -0
  118. package/src/adapters/report-views.ts +98 -0
  119. package/src/adapters/report.ts +335 -0
  120. package/src/adapters/session-client.ts +116 -0
  121. package/src/adapters/session-dir.ts +28 -0
  122. package/src/adapters/session-target.ts +431 -0
  123. package/src/adapters/status-bar.ts +86 -0
  124. package/src/adapters/subagent-startup.ts +66 -0
  125. package/src/adapters/sync-runner.ts +340 -0
  126. package/src/adapters/sync-state-store.ts +227 -0
  127. package/src/adapters/target-picker.ts +127 -0
  128. package/src/config.ts +536 -0
  129. package/src/domain/ancestry-match.ts +84 -0
  130. package/src/domain/client-label.ts +56 -0
  131. package/src/domain/day.ts +8 -0
  132. package/src/domain/export.ts +107 -0
  133. package/src/domain/hub-entry.ts +433 -0
  134. package/src/domain/index.ts +19 -0
  135. package/src/domain/intervals.ts +53 -0
  136. package/src/domain/panel-model.ts +270 -0
  137. package/src/domain/registry-health.ts +87 -0
  138. package/src/domain/segment-rule.ts +10 -0
  139. package/src/domain/subagent-profile.ts +495 -0
  140. package/src/domain/sync-plan.ts +266 -0
  141. package/src/domain/task-view.ts +526 -0
  142. package/src/domain/work-record.ts +320 -0
  143. package/src/domain/work-target.ts +234 -0
  144. package/src/domain/work-tracker.ts +485 -0
  145. package/src/extension.ts +346 -0
  146. package/src/hub/index.ts +25 -0
  147. package/src/ports/catalog.ts +33 -0
  148. package/src/ports/clock.ts +3 -0
  149. package/src/ports/index.ts +11 -0
  150. package/src/ports/inflight-store.ts +16 -0
  151. package/src/ports/process-registry.ts +75 -0
  152. package/src/ports/work-log.ts +15 -0
  153. package/src/ports/work-sink.ts +35 -0
@@ -0,0 +1,418 @@
1
+ function readGentleAgents(result) {
2
+ if (!result || typeof result !== "object")
3
+ return undefined;
4
+ const details = result.details;
5
+ if (!details || typeof details !== "object")
6
+ return undefined;
7
+ const gentleAgents = details.gentleAgents;
8
+ if (!gentleAgents || typeof gentleAgents !== "object")
9
+ return undefined;
10
+ return gentleAgents;
11
+ }
12
+ function stringField(source, key) {
13
+ const value = source?.[key];
14
+ return typeof value === "string" ? value : undefined;
15
+ }
16
+ /**
17
+ * Read pi's generic, profile-agnostic nested-usage convention: the tool
18
+ * RESULT's own top-level `usage` field (never nested under a profile's
19
+ * custom `details`). Tolerant of a missing/malformed field or non-numeric
20
+ * members — returns `undefined` (not an empty object) when nothing usable
21
+ * was found, so "no usage reported" stays distinguishable from "usage
22
+ * reported as all-zero".
23
+ */
24
+ function readStandardUsage(result) {
25
+ if (!result || typeof result !== "object")
26
+ return undefined;
27
+ const raw = result.usage;
28
+ if (!raw || typeof raw !== "object")
29
+ return undefined;
30
+ const usage = {};
31
+ const fields = ["input", "output", "cacheRead", "cacheWrite", "cost"];
32
+ for (const field of fields) {
33
+ const value = raw[field];
34
+ if (typeof value === "number" && Number.isFinite(value))
35
+ usage[field] = value;
36
+ }
37
+ return Object.keys(usage).length > 0 ? usage : undefined;
38
+ }
39
+ function readAgentModeArgs(args) {
40
+ const agent = stringField(args, "agent");
41
+ const mode = stringField(args, "mode");
42
+ return { ...(agent !== undefined ? { agent } : {}), ...(mode !== undefined ? { mode } : {}) };
43
+ }
44
+ /**
45
+ * gentle-pi (first-class, ADR 0020): the richest, most robust profile —
46
+ * explicit `taskId` join, live `status`, `mode` (task/background) and
47
+ * cross-worktree `cwd`, all read from `result.details.gentleAgents`
48
+ * exactly as `work-tracker.ts#extractTaskId` did before this module
49
+ * existed. Verified against gentle-pi 3.3.0 source
50
+ * (`extensions/gentle-agents.ts`, `lib/agents-protocol.ts`,
51
+ * `lib/agents-runner.ts`): its tool result never carries a top-level
52
+ * `usage` field (children are separate OS processes, cost tracked
53
+ * independently through the existing ancestry/registry join) — this
54
+ * profile therefore never reports `usage`, so 6c's forwarding never
55
+ * touches gentle-pi's numbers.
56
+ */
57
+ export const GENTLE_PI_PROFILE = {
58
+ id: "gentle-pi",
59
+ toolNames: ["subagent_run"],
60
+ childEnvMarkers: [{ name: "GENTLE_PI_AGENTS_CHILD", value: "1" }],
61
+ joinKeyConfidence: "explicit-id",
62
+ readLaunchArgs: readAgentModeArgs,
63
+ readResult(result) {
64
+ const gentleAgents = readGentleAgents(result);
65
+ if (!gentleAgents)
66
+ return {};
67
+ const taskId = stringField(gentleAgents, "taskId");
68
+ const agent = stringField(gentleAgents, "agent");
69
+ const status = stringField(gentleAgents, "status");
70
+ const mode = stringField(gentleAgents, "mode");
71
+ const cwd = stringField(gentleAgents, "cwd");
72
+ return {
73
+ ...(taskId !== undefined ? { taskId } : {}),
74
+ ...(agent !== undefined ? { agent } : {}),
75
+ ...(status !== undefined ? { status } : {}),
76
+ ...(mode !== undefined ? { mode } : {}),
77
+ ...(cwd !== undefined ? { cwd } : {}),
78
+ };
79
+ },
80
+ };
81
+ /**
82
+ * pi's bundled reference example extension
83
+ * (`examples/extensions/subagent/index.ts`, tool `subagent`). Verified: its
84
+ * `spawn()` call passes no `env` option at all (the child simply inherits
85
+ * the parent's environment unmodified) — so it sets NO child-identifying
86
+ * env marker, and a child of this kind can only ever be recognised through
87
+ * ancestry (ADR 0020: "always starts uncertain"). Because it has no
88
+ * marker, it can never become a *confirmed* `role: "subagent"` record
89
+ * (see `resolveChildProfile`) and therefore can never be double-joined —
90
+ * safe to forward `usage` unconditionally.
91
+ */
92
+ /**
93
+ * C1 investigation note (real-shape disambiguation, verified against the
94
+ * bundled reference example's actual source above): its real tool RESULT
95
+ * never actually sets a top-level `usage` field at all — every `execute()`
96
+ * return statement in `examples/extensions/subagent/index.ts` returns only
97
+ * `{content, details, isError?}`; nested-call usage lives per-result inside
98
+ * `details.results[].usage`, not where pi's documented convention (and
99
+ * `readStandardUsage` below) looks. `pi-subagents` (npm, verified against
100
+ * 0.28.0 `src/runs/foreground/subagent-executor.ts` and
101
+ * `src/runs/foreground/execution.ts`) is the same: no `execute()` return
102
+ * anywhere in that package sets a top-level `usage` either. So for BOTH
103
+ * real packages examined, `readStandardUsage(result)` returns `undefined`
104
+ * today regardless of which one actually answered the call — this
105
+ * profile's generic top-level-`usage` read exists for pi's DOCUMENTED
106
+ * convention (`docs/extensions.md`: "If a tool makes nested LLM calls,
107
+ * return their combined Usage as usage"), which a well-behaved third-party
108
+ * "subagent"-named tool, or a future version of either package, could
109
+ * start following at any time. That forward-looking risk is exactly what
110
+ * C1 guards against — see `safeAmbiguousResultInfo`.
111
+ */
112
+ export const PI_REFERENCE_PROFILE = {
113
+ id: "pi-reference",
114
+ toolNames: ["subagent"],
115
+ childEnvMarkers: [],
116
+ joinKeyConfidence: "ancestry",
117
+ readLaunchArgs: readAgentModeArgs,
118
+ readResult(result) {
119
+ const usage = readStandardUsage(result);
120
+ return { ...(usage !== undefined ? { usage } : {}) };
121
+ },
122
+ };
123
+ /**
124
+ * pi-subagents (npm package, tool `subagent`, action-based). Verified
125
+ * against the installed 0.28.0 source (`src/shared/types.ts`
126
+ * `getSubagentDepthEnv`): every child it spawns (foreground AND the
127
+ * detached background runner) carries `PI_SUBAGENT_DEPTH` — NOT
128
+ * `PI_SUBAGENT_PARENT_SESSION`, an earlier, unverified assumption this
129
+ * profile deliberately does not use. Because this marker CAN confirm a
130
+ * child as `role: "subagent"` (ancestry-joined, its own usage/cost already
131
+ * counted through that join), this profile's `readResult` deliberately
132
+ * never forwards `usage` even when present — forwarding it here would risk
133
+ * counting the same nested LLM work twice (once via the join, once via the
134
+ * forwarded figure). See `GENTLE_PI_PROFILE`/`PI_REFERENCE_PROFILE` for the
135
+ * two cases where forwarding is safe.
136
+ */
137
+ export const PI_SUBAGENTS_PROFILE = {
138
+ id: "pi-subagents",
139
+ toolNames: ["subagent"],
140
+ childEnvMarkers: [{ name: "PI_SUBAGENT_DEPTH" }],
141
+ joinKeyConfidence: "ancestry",
142
+ readLaunchArgs(args) {
143
+ const agent = stringField(args, "agent");
144
+ const mode = stringField(args, "action");
145
+ return { ...(agent !== undefined ? { agent } : {}), ...(mode !== undefined ? { mode } : {}) };
146
+ },
147
+ readResult() {
148
+ return {};
149
+ },
150
+ };
151
+ export const BUILTIN_SUBAGENT_PROFILES = [GENTLE_PI_PROFILE, PI_REFERENCE_PROFILE, PI_SUBAGENTS_PROFILE];
152
+ /**
153
+ * SUBAGENT-REQ-001/002/003: the user-configured profile built from
154
+ * `KANKAKU_SUBAGENT_TOOLS`/`KANKAKU_SUBAGENT_CHILD_ENV` (parsed in
155
+ * `config.ts`, mirroring `KANKAKU_INTERACTIVE_TOOLS`'s tolerant
156
+ * comma-split convention). Always additive to the built-ins, never
157
+ * replacing gentle-pi's own recognition. `undefined` when neither is
158
+ * configured, so `loadConfig` never adds an inert profile. Forwards
159
+ * `result.usage` generically (like `PI_REFERENCE_PROFILE`) — a documented,
160
+ * unavoidable residual risk: kankaku cannot know whether an arbitrary
161
+ * configured child process also runs kankaku itself and would otherwise be
162
+ * ancestry-joined, so a user who configures BOTH a child-env marker AND a
163
+ * tool that forwards `usage` for the same third-party package accepts that
164
+ * narrow double-count risk (see README "Subagents").
165
+ */
166
+ export function buildConfiguredProfile(toolNames, childEnvMarkers) {
167
+ if (toolNames.length === 0 && childEnvMarkers.length === 0)
168
+ return undefined;
169
+ return {
170
+ id: "configured",
171
+ toolNames,
172
+ childEnvMarkers,
173
+ joinKeyConfidence: "ancestry",
174
+ readLaunchArgs: readAgentModeArgs,
175
+ readResult(result) {
176
+ const usage = readStandardUsage(result);
177
+ return { ...(usage !== undefined ? { usage } : {}) };
178
+ },
179
+ };
180
+ }
181
+ /** Every profile whose `toolNames` include `toolName`, in the given order. */
182
+ export function matchToolProfiles(profiles, toolName) {
183
+ return profiles.filter((profile) => profile.toolNames.includes(toolName));
184
+ }
185
+ /**
186
+ * SUBAGENT-REQ-005: resolve which profile should be used to open/read a
187
+ * subagent span for a given tool name. Exactly one match resolves
188
+ * unambiguously; zero means this is not a recognised subagent tool call at
189
+ * all; two or more (e.g. `"subagent"`, registered by both the pi reference
190
+ * example and pi-subagents) is a genuine name collision — never guessed:
191
+ * `profile` stays `undefined` and `ambiguous` is `true` so the caller can
192
+ * still open a best-effort span (see `readLaunchInfo`/`readResultInfo`)
193
+ * without ever claiming a specific profile matched.
194
+ */
195
+ /**
196
+ * C1 investigation note — real disambiguation was investigated and
197
+ * rejected for the "subagent" name collision between `PI_REFERENCE_PROFILE`
198
+ * and `PI_SUBAGENTS_PROFILE`, both by args/result SHAPE and by pi's own
199
+ * `getAllTools()[].sourceInfo.path`:
200
+ *
201
+ * - Args shape: pi-subagents' `action` field ("list"/"get"/"create"/
202
+ * "update"/"delete"/"status"/"interrupt"/"resume"/"doctor" — verified
203
+ * 0.28.0 `src/extension/schemas.ts`) is absent from pi-reference's schema
204
+ * entirely, so its PRESENCE would be conclusive — but it is only ever
205
+ * set for pi-subagents' management/diagnostic calls, never for the
206
+ * money-affecting single/parallel/chain execution calls (`agent`+`task`,
207
+ * `tasks[]`, `chain[]`) that are the whole point of C1: those look
208
+ * identical in both packages' schemas (`agent`, `task`, `tasks`,
209
+ * `chain`, `cwd` all present in both). Result shape is no better: neither
210
+ * package's real result carries a top-level `usage` at all (see
211
+ * `PI_REFERENCE_PROFILE`'s doc comment) — no distinguishing signal is
212
+ * present in exactly the calls that matter.
213
+ * - `sourceInfo.path`: `pi.getAllTools()` does expose which extension
214
+ * registered a given tool name (`docs/extensions.md` "pi.getAllTools()").
215
+ * But that same doc explicitly warns, for the structurally identical
216
+ * `sourceInfo` on `pi.getCommands()`: "Use sourceInfo as the canonical
217
+ * provenance field. Do not infer ownership from command names or from ad
218
+ * hoc path parsing." Matching a tool's `sourceInfo.path` against a
219
+ * hardcoded substring (a package name, an examples/ path) IS ad hoc path
220
+ * parsing — the path is not guaranteed to contain any stable, portable
221
+ * substring across install layouts (a monorepo, a symlinked/hoisted
222
+ * dependency, a vendored fork). This was rejected as unreliable, not
223
+ * merely inconvenient.
224
+ *
225
+ * Neither route was conclusive, so kankaku stays ambiguous by design
226
+ * (SUBAGENT-REQ-005: never guessed) and instead fixes the CONSEQUENCE of
227
+ * ambiguity — see `safeAmbiguousResultInfo`/`mergeAgreeingLaunchInfo` and
228
+ * `domain/work-tracker.ts#onToolEnd`.
229
+ */
230
+ export function resolveToolProfile(profiles, toolName) {
231
+ const candidates = matchToolProfiles(profiles, toolName);
232
+ if (candidates.length === 1)
233
+ return { profile: candidates[0], ambiguous: false, candidates };
234
+ if (candidates.length === 0)
235
+ return { profile: undefined, ambiguous: false, candidates };
236
+ return { profile: undefined, ambiguous: true, candidates };
237
+ }
238
+ /** Every tool name registered by 2+ profiles at once, with the colliding profile ids — a static property of the active profile set, independent of any record. */
239
+ export function findAmbiguousToolNames(profiles) {
240
+ const byTool = new Map();
241
+ for (const profile of profiles) {
242
+ for (const toolName of profile.toolNames) {
243
+ const ids = byTool.get(toolName) ?? [];
244
+ ids.push(profile.id);
245
+ byTool.set(toolName, ids);
246
+ }
247
+ }
248
+ const ambiguous = [];
249
+ for (const [toolName, profileIds] of byTool) {
250
+ if (profileIds.length > 1)
251
+ ambiguous.push({ toolName, profileIds });
252
+ }
253
+ return ambiguous;
254
+ }
255
+ /** Best-effort merge of `readLaunchArgs` across several candidate profiles (an ambiguous tool-name match): first defined field, in profile order, wins. */
256
+ export function readLaunchInfo(candidates, args) {
257
+ let agent;
258
+ let mode;
259
+ for (const profile of candidates) {
260
+ const info = profile.readLaunchArgs(args);
261
+ if (agent === undefined && info.agent !== undefined)
262
+ agent = info.agent;
263
+ if (mode === undefined && info.mode !== undefined)
264
+ mode = info.mode;
265
+ }
266
+ return { ...(agent !== undefined ? { agent } : {}), ...(mode !== undefined ? { mode } : {}) };
267
+ }
268
+ /** Best-effort merge of `readResult` across several candidate profiles (an ambiguous tool-name match): first defined field, in profile order, wins — never a profile-specific field none of the candidates actually provided. */
269
+ export function readResultInfo(candidates, result) {
270
+ const merged = {};
271
+ for (const profile of candidates) {
272
+ const info = profile.readResult(result);
273
+ if (merged.taskId === undefined && info.taskId !== undefined)
274
+ merged.taskId = info.taskId;
275
+ if (merged.agent === undefined && info.agent !== undefined)
276
+ merged.agent = info.agent;
277
+ if (merged.status === undefined && info.status !== undefined)
278
+ merged.status = info.status;
279
+ if (merged.mode === undefined && info.mode !== undefined)
280
+ merged.mode = info.mode;
281
+ if (merged.cwd === undefined && info.cwd !== undefined)
282
+ merged.cwd = info.cwd;
283
+ if (merged.usage === undefined && info.usage !== undefined)
284
+ merged.usage = info.usage;
285
+ }
286
+ return merged;
287
+ }
288
+ /**
289
+ * C1 (CRITICAL fix): the launch-args counterpart of `readResultInfo`'s
290
+ * caution, used specifically for a genuinely AMBIGUOUS tool-name match
291
+ * (2+ candidate profiles, none of them the winner — SUBAGENT-REQ-005).
292
+ * Unlike `readLaunchInfo`'s "first defined field wins" merge (kept as-is,
293
+ * still used for the unambiguous single-candidate case, where there is
294
+ * nothing to disagree about), this only keeps a field when every candidate
295
+ * that reports a value for it reports the SAME value — "agent/mode if they
296
+ * read identically, else omitted". Two candidates disagreeing (e.g. one
297
+ * profile reads `mode` from `args.mode`, another from `args.action`, and
298
+ * they differ) means kankaku genuinely does not know which is right, so
299
+ * the field is dropped rather than silently picking one. Never reads
300
+ * anything money- or join-affecting — launch args never carry `usage` or
301
+ * `taskId` in the first place, only descriptive `agent`/`mode`.
302
+ */
303
+ export function mergeAgreeingLaunchInfo(candidates, args) {
304
+ let agent;
305
+ let agentConflict = false;
306
+ let mode;
307
+ let modeConflict = false;
308
+ for (const profile of candidates) {
309
+ const info = profile.readLaunchArgs(args);
310
+ if (info.agent !== undefined) {
311
+ if (agent === undefined)
312
+ agent = info.agent;
313
+ else if (agent !== info.agent)
314
+ agentConflict = true;
315
+ }
316
+ if (info.mode !== undefined) {
317
+ if (mode === undefined)
318
+ mode = info.mode;
319
+ else if (mode !== info.mode)
320
+ modeConflict = true;
321
+ }
322
+ }
323
+ return {
324
+ ...(agent !== undefined && !agentConflict ? { agent } : {}),
325
+ ...(mode !== undefined && !modeConflict ? { mode } : {}),
326
+ };
327
+ }
328
+ /**
329
+ * C1 (CRITICAL fix): the result-reading counterpart for a genuinely
330
+ * AMBIGUOUS tool-name match. `readResultInfo`'s own best-effort merge stays
331
+ * available (and is still exactly right for the UNAMBIGUOUS single-
332
+ * candidate case), but when 2+ profiles registered the same tool name and
333
+ * neither could be told apart, nothing MONEY- or JOIN-affecting is ever
334
+ * taken from any candidate: `usage` (would silently double-bill the day a
335
+ * package sharing an ambiguous tool name, e.g. "subagent", starts
336
+ * following pi's documented top-level `usage` convention — see
337
+ * `PI_REFERENCE_PROFILE`) and `taskId` (a join key) are always stripped.
338
+ * Purely descriptive fields (`agent`/`status`/`mode`/`cwd`) are kept from
339
+ * the best-effort merge — they affect neither billing nor task/child
340
+ * joining, only how a span/record is labelled for a human reading it.
341
+ * `profile` itself is never part of this shape; the caller already leaves
342
+ * it `undefined` for an ambiguous match (see `resolveToolProfile`).
343
+ */
344
+ export function safeAmbiguousResultInfo(candidates, result) {
345
+ const merged = readResultInfo(candidates, result);
346
+ return {
347
+ ...(merged.agent !== undefined ? { agent: merged.agent } : {}),
348
+ ...(merged.status !== undefined ? { status: merged.status } : {}),
349
+ ...(merged.mode !== undefined ? { mode: merged.mode } : {}),
350
+ ...(merged.cwd !== undefined ? { cwd: merged.cwd } : {}),
351
+ };
352
+ }
353
+ /**
354
+ * Whether any of `markers` is present in `env`: an exact-value marker
355
+ * requires an exact match, a presence-only marker (`value: undefined`)
356
+ * matches any non-empty value. Shared by `profileMarkerMatches` (one
357
+ * profile's own markers) and `config.ts#detectRole` (the full active set,
358
+ * generalised beyond the single hardcoded `GENTLE_PI_AGENTS_CHILD` check).
359
+ */
360
+ export function matchesAnyMarker(env, markers) {
361
+ return markers.some((marker) => {
362
+ const actual = env[marker.name];
363
+ if (actual === undefined || actual === "")
364
+ return false;
365
+ return marker.value === undefined || actual === marker.value;
366
+ });
367
+ }
368
+ /** Whether `profile`'s child-env marker(s) are present in `env`. A profile with no markers at all (e.g. `PI_REFERENCE_PROFILE`) never matches, by construction. */
369
+ export function profileMarkerMatches(profile, env) {
370
+ return matchesAnyMarker(env, profile.childEnvMarkers);
371
+ }
372
+ /**
373
+ * SUBAGENT-REQ-005: resolve which profile's child-env marker(s) confirm
374
+ * THIS process as a subagent of a known kind — the child-side counterpart
375
+ * of `resolveToolProfile`. Exactly one profile's marker present resolves
376
+ * unambiguously; none present means this process's role, if any, must come
377
+ * from ancestry instead (see `config.ts#detectRole`); two or more present
378
+ * at once (a genuine marker collision, not expected among the built-ins) is
379
+ * never guessed either — `profile` stays `undefined`, but every match is
380
+ * still reported so `/kankaku doctor` can surface it.
381
+ */
382
+ export function resolveChildProfile(profiles, env) {
383
+ const matchedProfiles = profiles.filter((profile) => profileMarkerMatches(profile, env));
384
+ return { profile: matchedProfiles.length === 1 ? matchedProfiles[0] : undefined, matchedProfiles };
385
+ }
386
+ /** Every distinct child-env marker declared by any of `profiles`, de-duplicated by `name` (first-declared value wins) — used to generalise `detectRole`'s single hardcoded marker check. */
387
+ export function allChildMarkers(profiles) {
388
+ const byName = new Map();
389
+ for (const profile of profiles) {
390
+ for (const marker of profile.childEnvMarkers) {
391
+ if (!byName.has(marker.name))
392
+ byName.set(marker.name, marker);
393
+ }
394
+ }
395
+ return Array.from(byName.values());
396
+ }
397
+ /**
398
+ * C2 (CRITICAL fix): the "always confirms, regardless of interactivity"
399
+ * tier for `config.ts#detectRole`'s 4th param — every active profile's
400
+ * markers EXCEPT the single user-configured one (`id === "configured"`,
401
+ * built from `KANKAKU_SUBAGENT_CHILD_ENV` in `config.ts#loadConfig`). Only
402
+ * a real subagent runner (gentle-pi, pi-subagents) sets a built-in marker,
403
+ * so this tier keeps today's unconditional precedence.
404
+ */
405
+ export function builtinChildMarkers(profiles) {
406
+ return allChildMarkers(profiles.filter((profile) => profile.id !== "configured"));
407
+ }
408
+ /**
409
+ * C2 (CRITICAL fix): the "never demotes an interactive session" tier for
410
+ * `config.ts#detectRole`'s 5th param — the user-configured profile's own
411
+ * markers only (empty when no `"configured"` profile is active). Kept
412
+ * separate from `builtinChildMarkers` because kankaku cannot verify an
413
+ * arbitrary configured environment variable name is genuinely child-only
414
+ * (see `config.ts#loadConfig`'s denylist and `detectRole`'s doc comment).
415
+ */
416
+ export function configuredChildMarkers(profiles) {
417
+ return allChildMarkers(profiles.filter((profile) => profile.id === "configured"));
418
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Decide which tasks a sync run should push, without touching the network
3
+ * or the filesystem. Pure, no I/O — see `sync-runner.ts` for the adapter
4
+ * that drives this with a real clock, `WorkLog` and `WorkSink`.
5
+ *
6
+ * A task is not final the moment it is first written: a background
7
+ * subagent can settle after its orchestrator and extend the task's union
8
+ * (see `task-view.ts`), which is exactly why every sync also revisits a
9
+ * trailing window behind the watermark instead of only pushing brand-new
10
+ * tasks (proposal §6.0).
11
+ */
12
+ import type { TaskView } from "./task-view.ts";
13
+ /** Persisted sync state (`<KANKAKU_DIR>/sync-state.json`). */
14
+ export interface SyncState {
15
+ /** High-watermark ISO timestamp: everything with `endedAt` at or before `syncedThrough - window` is considered done. `undefined` before the first successful sync. */
16
+ syncedThrough?: string;
17
+ /** Content hash per task id (see {@link computeTaskContentHash}), kept for as long as the task exists — never pruned by the revisit window (see {@link pruneHashes}). */
18
+ hashes: Record<string, string>;
19
+ /** The hub URL this state was synced against; a state written for a different URL is treated as absent (full sync). */
20
+ target: string;
21
+ /** Set after a network/5xx failure stopped a run short; cleared by the next fully-successful run. */
22
+ lastError?: {
23
+ message: string;
24
+ at: string;
25
+ };
26
+ /**
27
+ * The `WorkLog`'s cheap change signal (`version()`) as of the last real
28
+ * (non-short-circuited) automatic-or-manual sync attempt. The automatic
29
+ * path (`session_start`/`agent_settled`) compares this against the
30
+ * current value to skip entirely — no `readAll()`, no network — when
31
+ * nothing has changed and the last attempt did not error. See
32
+ * `adapters/sync-runner.ts#runSync`.
33
+ */
34
+ logVersion?: string | number;
35
+ /**
36
+ * `Clock`-based timestamp (ms) of the last real (non-short-circuited)
37
+ * automatic sync attempt, persisted so the automatic path's throttle
38
+ * (`KANKAKU_SYNC_MIN_INTERVAL_MINUTES`) holds across processes, not just
39
+ * within one. Written by every run that reaches the sink, manual or
40
+ * automatic; only the automatic path READS it to throttle itself.
41
+ */
42
+ lastRunAt?: number;
43
+ }
44
+ export interface SyncPlanOptions {
45
+ /** The configured hub URL. A state whose `target` differs triggers a full sync. */
46
+ target: string;
47
+ /** Revisit window in hours, applied behind `syncedThrough`. Defaults to 24. */
48
+ windowHours?: number;
49
+ /** Force every task to be (re-)evaluated, e.g. `/kankaku sync all`. */
50
+ full?: boolean;
51
+ }
52
+ export interface SyncPlan {
53
+ /** Tasks whose content changed (or were never synced) and therefore need a request: new work in the window oldest-first, then corrections to rows the hub already holds, newest-first (see {@link planSync}). */
54
+ toSync: TaskView[];
55
+ /** How many eligible tasks were skipped because their stored hash already matched — no request needed for them. */
56
+ unchangedCount: number;
57
+ /** `true` when this run evaluated every task rather than only the revisit window. */
58
+ isFullSync: boolean;
59
+ /**
60
+ * Tasks outside this run's revisit window that were NEVER synced to this
61
+ * hub: an ordinary incremental sync does not look that far back for new
62
+ * work, so only `sync all` (or `backfill`) uploads them. A task the hub
63
+ * already holds never appears here — if it changed it is in `toSync`.
64
+ * Always empty for a full sync. Surfaced by `/kankaku sync status`.
65
+ */
66
+ staleOutsideWindow: TaskView[];
67
+ /** Already-synced rows that changed but were left for a later run by {@link MAX_CORRECTIONS_PER_RUN}. */
68
+ correctionsDeferred: number;
69
+ }
70
+ /** How many already-synced, out-of-window rows one incremental run corrects at most; the rest wait for the next run (or `sync all`). */
71
+ export declare const MAX_CORRECTIONS_PER_RUN = 50;
72
+ /**
73
+ * Content hash of everything a re-sync could change: the measurement
74
+ * fields (never the assignment — a reassignment made in the web is never
75
+ * visible locally, and must never trigger a resync on its own). A task
76
+ * whose hash matches the last stored one is unchanged and can be skipped
77
+ * without a request. Includes the derived measurement-quality fields
78
+ * (`domain/hub-entry.ts`) too, not just the raw numbers they are computed
79
+ * from: a background subagent that joins *after* this task was first
80
+ * synced can turn `cost_quality`/`subagent_linkage` from `"unknown"`/
81
+ * `"unlinked"` into a better answer without any of the other numeric
82
+ * fields necessarily changing (e.g. a joined child with no cost of its own
83
+ * still flips `subagent_linkage`) — that must still trigger a resync.
84
+ * `waiting_quality` is a true constant (`domain/hub-entry.ts`'s
85
+ * `computeWaitingQuality`) and is deliberately left out: it can never
86
+ * change between two evaluations of the same task. `sessionDir` is also a
87
+ * measurement-style field (`domain/hub-entry.ts`'s `session_dir`, sent on
88
+ * both create and update) — its own change, e.g. a resume that switches to
89
+ * a non-default session directory, must trigger a resync on its own even
90
+ * when nothing else changed.
91
+ */
92
+ export declare function computeTaskContentHash(task: TaskView): string;
93
+ /**
94
+ * Decide which tasks need a request this run.
95
+ *
96
+ * Eligibility: every task, when there is no state yet, the state's
97
+ * `target` differs from the configured hub URL, or `options.full` is set
98
+ * (a full sync); otherwise only tasks with `endedAt` after
99
+ * `syncedThrough - window`. Within the eligible set, a task is only
100
+ * included in `toSync` when its current content hash differs from the one
101
+ * stored in `state.hashes` (absent, i.e. never synced, always counts as
102
+ * different).
103
+ */
104
+ export declare function planSync(tasks: TaskView[], state: SyncState | undefined, options: SyncPlanOptions): SyncPlan;
105
+ /**
106
+ * Drop hash entries for task ids that no longer exist in `tasks` (which
107
+ * should not normally happen since `worklog.jsonl` is append-only — this
108
+ * is a defensive backstop, not the normal path). `sync-state.json`'s
109
+ * `hashes` are otherwise kept **forever** for every task this process
110
+ * still knows about, regardless of how far outside the revisit window its
111
+ * `endedAt` has fallen (G2 fix).
112
+ *
113
+ * This used to also drop a hash once its task's `endedAt` fell behind
114
+ * `syncedThrough - window` — but `planSync`'s `staleOutsideWindow` treats a
115
+ * *missing* hash exactly like "content changed" (there is no third state
116
+ * for "unchanged but I forgot"), so that window-based pruning made every
117
+ * task older than the window look permanently changed, forever, the moment
118
+ * its hash was first pruned: `/kankaku sync status` would report a
119
+ * never-shrinking "changed outside the window" count that trained users
120
+ * to ignore it (the bug this rewrite fixes).
121
+ *
122
+ * Never pruning by window instead means `hashes` grows with the total
123
+ * number of distinct tasks a directory has ever synced, not with time — an
124
+ * FNV-1a hash is 8 hex chars and a task id (a `crypto.randomUUID()`) is 36,
125
+ * so each retained entry costs roughly 50 bytes of JSON. Measured: 10,000
126
+ * entries serialize to well under 1MB (see `tests/sync-plan.test.ts`'s
127
+ * state-size-bound test) — even a directory with a decade of daily,
128
+ * multi-task-per-day history stays a small, instantly-parseable file. A
129
+ * coarser design (e.g. one rolling digest per closed day) would bound the
130
+ * file even tighter, but cannot answer "which specific task changed" —
131
+ * `staleOutsideWindow` needs exactly that, per-task precision, to stay
132
+ * useful — so it was rejected in favour of this simpler, still-cheap
133
+ * per-task scheme.
134
+ *
135
+ * No migration is needed for an existing `sync-state.json`: it already has
136
+ * exactly this shape (`Record<taskId, hash>`), just with some outside-
137
+ * window entries already missing from a build that pruned them. The first
138
+ * sync after upgrading treats each of those exactly like "never synced" —
139
+ * a real fact this process cannot know is false, since the old hash is
140
+ * genuinely gone — and reports it once via `staleOutsideWindow`; once that
141
+ * task's hash is recorded again (an ordinary `sync all`, or simply being
142
+ * observed unchanged), this function never drops it again. See
143
+ * `tests/sync-plan.test.ts`'s "first sync after upgrading" test.
144
+ *
145
+ * Accumulated in a `Map` and emitted via `Object.fromEntries` (never
146
+ * `pruned[id] = ...` on a plain object), since a task id ultimately traces
147
+ * back to free-text worklog content: a value like `__proto__` written to a
148
+ * plain object would silently no-op (the inherited accessor ignores a
149
+ * non-object assignment) instead of being kept as an own property.
150
+ */
151
+ export declare function pruneHashes(hashes: Record<string, string>, tasks: TaskView[]): Record<string, string>;