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,495 @@
1
+ import type { UsageTotals } from "./work-record.ts";
2
+
3
+ /**
4
+ * A child-process env marker a {@link SubagentProfile} recognises as
5
+ * confirmation that THIS process is one of its children (never guessed —
6
+ * see `resolveChildProfile`). `value: undefined` means "any non-empty
7
+ * value counts as present" (e.g. pi-subagents' `PI_SUBAGENT_DEPTH`, a
8
+ * recursion-depth counter, not a fixed sentinel); a defined `value`
9
+ * requires an exact match (e.g. gentle-pi's `GENTLE_PI_AGENTS_CHILD=1`).
10
+ */
11
+ export interface ChildEnvMarker {
12
+ name: string;
13
+ value?: string;
14
+ }
15
+
16
+ /**
17
+ * How strongly a profile's children can be joined back to their
18
+ * orchestrator (ADR 0021): `"explicit-id"` when the tool result carries a
19
+ * stable id kankaku can use directly (gentle-pi's `taskId` — parent-side
20
+ * only, the child cannot read its own); `"ancestry"` when only the
21
+ * machine-wide process registry/ancestor-chain walk can join it;
22
+ * `"none"` for a profile that offers no join signal at all.
23
+ */
24
+ export type JoinKeyConfidence = "explicit-id" | "ancestry" | "none";
25
+
26
+ export interface SubagentLaunchInfo {
27
+ agent?: string;
28
+ mode?: string;
29
+ }
30
+
31
+ export interface SubagentResultInfo {
32
+ taskId?: string;
33
+ agent?: string;
34
+ status?: string;
35
+ mode?: string;
36
+ cwd?: string;
37
+ /**
38
+ * Nested LLM usage the subagent tool result reports on itself (pi's
39
+ * documented convention: "a tool making nested LLM calls should return
40
+ * their combined Usage as `usage`" — see `docs/extensions.md`). A
41
+ * profile whose children are ALSO separately tracked and joined via a
42
+ * confirmed child-env marker (ancestry-based `joinKeyConfidence`) must
43
+ * never report this — see `PI_SUBAGENTS_PROFILE` for why.
44
+ */
45
+ usage?: Partial<UsageTotals>;
46
+ }
47
+
48
+ /**
49
+ * Declares how kankaku recognises one subagent-launching ecosystem
50
+ * package: which tool call(s) open a subagent span, how to read
51
+ * `agent`/`mode` from the launch args and `taskId`/`status`/`mode`/`cwd`/
52
+ * `usage` from the tool result, which env var(s) confirm a child process of
53
+ * this kind, and how strong a join key it offers. See ADR 0020 and
54
+ * SUBAGENT-REQ-001.
55
+ */
56
+ export interface SubagentProfile {
57
+ id: string;
58
+ toolNames: readonly string[];
59
+ childEnvMarkers: readonly ChildEnvMarker[];
60
+ joinKeyConfidence: JoinKeyConfidence;
61
+ readLaunchArgs(args: Record<string, unknown> | undefined): SubagentLaunchInfo;
62
+ readResult(result: unknown): SubagentResultInfo;
63
+ }
64
+
65
+ function readGentleAgents(result: unknown): Record<string, unknown> | undefined {
66
+ if (!result || typeof result !== "object") return undefined;
67
+ const details = (result as { details?: unknown }).details;
68
+ if (!details || typeof details !== "object") return undefined;
69
+ const gentleAgents = (details as { gentleAgents?: unknown }).gentleAgents;
70
+ if (!gentleAgents || typeof gentleAgents !== "object") return undefined;
71
+ return gentleAgents as Record<string, unknown>;
72
+ }
73
+
74
+ function stringField(source: Record<string, unknown> | undefined, key: string): string | undefined {
75
+ const value = source?.[key];
76
+ return typeof value === "string" ? value : undefined;
77
+ }
78
+
79
+ /**
80
+ * Read pi's generic, profile-agnostic nested-usage convention: the tool
81
+ * RESULT's own top-level `usage` field (never nested under a profile's
82
+ * custom `details`). Tolerant of a missing/malformed field or non-numeric
83
+ * members — returns `undefined` (not an empty object) when nothing usable
84
+ * was found, so "no usage reported" stays distinguishable from "usage
85
+ * reported as all-zero".
86
+ */
87
+ function readStandardUsage(result: unknown): Partial<UsageTotals> | undefined {
88
+ if (!result || typeof result !== "object") return undefined;
89
+ const raw = (result as { usage?: unknown }).usage;
90
+ if (!raw || typeof raw !== "object") return undefined;
91
+
92
+ const usage: Partial<UsageTotals> = {};
93
+ const fields: Array<keyof UsageTotals> = ["input", "output", "cacheRead", "cacheWrite", "cost"];
94
+ for (const field of fields) {
95
+ const value = (raw as Record<string, unknown>)[field];
96
+ if (typeof value === "number" && Number.isFinite(value)) usage[field] = value;
97
+ }
98
+ return Object.keys(usage).length > 0 ? usage : undefined;
99
+ }
100
+
101
+ function readAgentModeArgs(args: Record<string, unknown> | undefined): SubagentLaunchInfo {
102
+ const agent = stringField(args, "agent");
103
+ const mode = stringField(args, "mode");
104
+ return { ...(agent !== undefined ? { agent } : {}), ...(mode !== undefined ? { mode } : {}) };
105
+ }
106
+
107
+ /**
108
+ * gentle-pi (first-class, ADR 0020): the richest, most robust profile —
109
+ * explicit `taskId` join, live `status`, `mode` (task/background) and
110
+ * cross-worktree `cwd`, all read from `result.details.gentleAgents`
111
+ * exactly as `work-tracker.ts#extractTaskId` did before this module
112
+ * existed. Verified against gentle-pi 3.3.0 source
113
+ * (`extensions/gentle-agents.ts`, `lib/agents-protocol.ts`,
114
+ * `lib/agents-runner.ts`): its tool result never carries a top-level
115
+ * `usage` field (children are separate OS processes, cost tracked
116
+ * independently through the existing ancestry/registry join) — this
117
+ * profile therefore never reports `usage`, so 6c's forwarding never
118
+ * touches gentle-pi's numbers.
119
+ */
120
+ export const GENTLE_PI_PROFILE: SubagentProfile = {
121
+ id: "gentle-pi",
122
+ toolNames: ["subagent_run"],
123
+ childEnvMarkers: [{ name: "GENTLE_PI_AGENTS_CHILD", value: "1" }],
124
+ joinKeyConfidence: "explicit-id",
125
+ readLaunchArgs: readAgentModeArgs,
126
+ readResult(result) {
127
+ const gentleAgents = readGentleAgents(result);
128
+ if (!gentleAgents) return {};
129
+ const taskId = stringField(gentleAgents, "taskId");
130
+ const agent = stringField(gentleAgents, "agent");
131
+ const status = stringField(gentleAgents, "status");
132
+ const mode = stringField(gentleAgents, "mode");
133
+ const cwd = stringField(gentleAgents, "cwd");
134
+ return {
135
+ ...(taskId !== undefined ? { taskId } : {}),
136
+ ...(agent !== undefined ? { agent } : {}),
137
+ ...(status !== undefined ? { status } : {}),
138
+ ...(mode !== undefined ? { mode } : {}),
139
+ ...(cwd !== undefined ? { cwd } : {}),
140
+ };
141
+ },
142
+ };
143
+
144
+ /**
145
+ * pi's bundled reference example extension
146
+ * (`examples/extensions/subagent/index.ts`, tool `subagent`). Verified: its
147
+ * `spawn()` call passes no `env` option at all (the child simply inherits
148
+ * the parent's environment unmodified) — so it sets NO child-identifying
149
+ * env marker, and a child of this kind can only ever be recognised through
150
+ * ancestry (ADR 0020: "always starts uncertain"). Because it has no
151
+ * marker, it can never become a *confirmed* `role: "subagent"` record
152
+ * (see `resolveChildProfile`) and therefore can never be double-joined —
153
+ * safe to forward `usage` unconditionally.
154
+ */
155
+ /**
156
+ * C1 investigation note (real-shape disambiguation, verified against the
157
+ * bundled reference example's actual source above): its real tool RESULT
158
+ * never actually sets a top-level `usage` field at all — every `execute()`
159
+ * return statement in `examples/extensions/subagent/index.ts` returns only
160
+ * `{content, details, isError?}`; nested-call usage lives per-result inside
161
+ * `details.results[].usage`, not where pi's documented convention (and
162
+ * `readStandardUsage` below) looks. `pi-subagents` (npm, verified against
163
+ * 0.28.0 `src/runs/foreground/subagent-executor.ts` and
164
+ * `src/runs/foreground/execution.ts`) is the same: no `execute()` return
165
+ * anywhere in that package sets a top-level `usage` either. So for BOTH
166
+ * real packages examined, `readStandardUsage(result)` returns `undefined`
167
+ * today regardless of which one actually answered the call — this
168
+ * profile's generic top-level-`usage` read exists for pi's DOCUMENTED
169
+ * convention (`docs/extensions.md`: "If a tool makes nested LLM calls,
170
+ * return their combined Usage as usage"), which a well-behaved third-party
171
+ * "subagent"-named tool, or a future version of either package, could
172
+ * start following at any time. That forward-looking risk is exactly what
173
+ * C1 guards against — see `safeAmbiguousResultInfo`.
174
+ */
175
+ export const PI_REFERENCE_PROFILE: SubagentProfile = {
176
+ id: "pi-reference",
177
+ toolNames: ["subagent"],
178
+ childEnvMarkers: [],
179
+ joinKeyConfidence: "ancestry",
180
+ readLaunchArgs: readAgentModeArgs,
181
+ readResult(result) {
182
+ const usage = readStandardUsage(result);
183
+ return { ...(usage !== undefined ? { usage } : {}) };
184
+ },
185
+ };
186
+
187
+ /**
188
+ * pi-subagents (npm package, tool `subagent`, action-based). Verified
189
+ * against the installed 0.28.0 source (`src/shared/types.ts`
190
+ * `getSubagentDepthEnv`): every child it spawns (foreground AND the
191
+ * detached background runner) carries `PI_SUBAGENT_DEPTH` — NOT
192
+ * `PI_SUBAGENT_PARENT_SESSION`, an earlier, unverified assumption this
193
+ * profile deliberately does not use. Because this marker CAN confirm a
194
+ * child as `role: "subagent"` (ancestry-joined, its own usage/cost already
195
+ * counted through that join), this profile's `readResult` deliberately
196
+ * never forwards `usage` even when present — forwarding it here would risk
197
+ * counting the same nested LLM work twice (once via the join, once via the
198
+ * forwarded figure). See `GENTLE_PI_PROFILE`/`PI_REFERENCE_PROFILE` for the
199
+ * two cases where forwarding is safe.
200
+ */
201
+ export const PI_SUBAGENTS_PROFILE: SubagentProfile = {
202
+ id: "pi-subagents",
203
+ toolNames: ["subagent"],
204
+ childEnvMarkers: [{ name: "PI_SUBAGENT_DEPTH" }],
205
+ joinKeyConfidence: "ancestry",
206
+ readLaunchArgs(args) {
207
+ const agent = stringField(args, "agent");
208
+ const mode = stringField(args, "action");
209
+ return { ...(agent !== undefined ? { agent } : {}), ...(mode !== undefined ? { mode } : {}) };
210
+ },
211
+ readResult() {
212
+ return {};
213
+ },
214
+ };
215
+
216
+ export const BUILTIN_SUBAGENT_PROFILES: readonly SubagentProfile[] = [GENTLE_PI_PROFILE, PI_REFERENCE_PROFILE, PI_SUBAGENTS_PROFILE];
217
+
218
+ /**
219
+ * SUBAGENT-REQ-001/002/003: the user-configured profile built from
220
+ * `KANKAKU_SUBAGENT_TOOLS`/`KANKAKU_SUBAGENT_CHILD_ENV` (parsed in
221
+ * `config.ts`, mirroring `KANKAKU_INTERACTIVE_TOOLS`'s tolerant
222
+ * comma-split convention). Always additive to the built-ins, never
223
+ * replacing gentle-pi's own recognition. `undefined` when neither is
224
+ * configured, so `loadConfig` never adds an inert profile. Forwards
225
+ * `result.usage` generically (like `PI_REFERENCE_PROFILE`) — a documented,
226
+ * unavoidable residual risk: kankaku cannot know whether an arbitrary
227
+ * configured child process also runs kankaku itself and would otherwise be
228
+ * ancestry-joined, so a user who configures BOTH a child-env marker AND a
229
+ * tool that forwards `usage` for the same third-party package accepts that
230
+ * narrow double-count risk (see README "Subagents").
231
+ */
232
+ export function buildConfiguredProfile(toolNames: readonly string[], childEnvMarkers: readonly ChildEnvMarker[]): SubagentProfile | undefined {
233
+ if (toolNames.length === 0 && childEnvMarkers.length === 0) return undefined;
234
+ return {
235
+ id: "configured",
236
+ toolNames,
237
+ childEnvMarkers,
238
+ joinKeyConfidence: "ancestry",
239
+ readLaunchArgs: readAgentModeArgs,
240
+ readResult(result) {
241
+ const usage = readStandardUsage(result);
242
+ return { ...(usage !== undefined ? { usage } : {}) };
243
+ },
244
+ };
245
+ }
246
+
247
+ /** Every profile whose `toolNames` include `toolName`, in the given order. */
248
+ export function matchToolProfiles(profiles: readonly SubagentProfile[], toolName: string): SubagentProfile[] {
249
+ return profiles.filter((profile) => profile.toolNames.includes(toolName));
250
+ }
251
+
252
+ export interface ToolProfileResolution {
253
+ /** The single matching profile, or `undefined` when there is none or the tool name is ambiguous (never guessed). */
254
+ profile: SubagentProfile | undefined;
255
+ /** `true` when 2+ profiles register this exact tool name (SUBAGENT-REQ-005). */
256
+ ambiguous: boolean;
257
+ candidates: SubagentProfile[];
258
+ }
259
+
260
+ /**
261
+ * SUBAGENT-REQ-005: resolve which profile should be used to open/read a
262
+ * subagent span for a given tool name. Exactly one match resolves
263
+ * unambiguously; zero means this is not a recognised subagent tool call at
264
+ * all; two or more (e.g. `"subagent"`, registered by both the pi reference
265
+ * example and pi-subagents) is a genuine name collision — never guessed:
266
+ * `profile` stays `undefined` and `ambiguous` is `true` so the caller can
267
+ * still open a best-effort span (see `readLaunchInfo`/`readResultInfo`)
268
+ * without ever claiming a specific profile matched.
269
+ */
270
+ /**
271
+ * C1 investigation note — real disambiguation was investigated and
272
+ * rejected for the "subagent" name collision between `PI_REFERENCE_PROFILE`
273
+ * and `PI_SUBAGENTS_PROFILE`, both by args/result SHAPE and by pi's own
274
+ * `getAllTools()[].sourceInfo.path`:
275
+ *
276
+ * - Args shape: pi-subagents' `action` field ("list"/"get"/"create"/
277
+ * "update"/"delete"/"status"/"interrupt"/"resume"/"doctor" — verified
278
+ * 0.28.0 `src/extension/schemas.ts`) is absent from pi-reference's schema
279
+ * entirely, so its PRESENCE would be conclusive — but it is only ever
280
+ * set for pi-subagents' management/diagnostic calls, never for the
281
+ * money-affecting single/parallel/chain execution calls (`agent`+`task`,
282
+ * `tasks[]`, `chain[]`) that are the whole point of C1: those look
283
+ * identical in both packages' schemas (`agent`, `task`, `tasks`,
284
+ * `chain`, `cwd` all present in both). Result shape is no better: neither
285
+ * package's real result carries a top-level `usage` at all (see
286
+ * `PI_REFERENCE_PROFILE`'s doc comment) — no distinguishing signal is
287
+ * present in exactly the calls that matter.
288
+ * - `sourceInfo.path`: `pi.getAllTools()` does expose which extension
289
+ * registered a given tool name (`docs/extensions.md` "pi.getAllTools()").
290
+ * But that same doc explicitly warns, for the structurally identical
291
+ * `sourceInfo` on `pi.getCommands()`: "Use sourceInfo as the canonical
292
+ * provenance field. Do not infer ownership from command names or from ad
293
+ * hoc path parsing." Matching a tool's `sourceInfo.path` against a
294
+ * hardcoded substring (a package name, an examples/ path) IS ad hoc path
295
+ * parsing — the path is not guaranteed to contain any stable, portable
296
+ * substring across install layouts (a monorepo, a symlinked/hoisted
297
+ * dependency, a vendored fork). This was rejected as unreliable, not
298
+ * merely inconvenient.
299
+ *
300
+ * Neither route was conclusive, so kankaku stays ambiguous by design
301
+ * (SUBAGENT-REQ-005: never guessed) and instead fixes the CONSEQUENCE of
302
+ * ambiguity — see `safeAmbiguousResultInfo`/`mergeAgreeingLaunchInfo` and
303
+ * `domain/work-tracker.ts#onToolEnd`.
304
+ */
305
+ export function resolveToolProfile(profiles: readonly SubagentProfile[], toolName: string): ToolProfileResolution {
306
+ const candidates = matchToolProfiles(profiles, toolName);
307
+ if (candidates.length === 1) return { profile: candidates[0], ambiguous: false, candidates };
308
+ if (candidates.length === 0) return { profile: undefined, ambiguous: false, candidates };
309
+ return { profile: undefined, ambiguous: true, candidates };
310
+ }
311
+
312
+ /** 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. */
313
+ export function findAmbiguousToolNames(profiles: readonly SubagentProfile[]): Array<{ toolName: string; profileIds: string[] }> {
314
+ const byTool = new Map<string, string[]>();
315
+ for (const profile of profiles) {
316
+ for (const toolName of profile.toolNames) {
317
+ const ids = byTool.get(toolName) ?? [];
318
+ ids.push(profile.id);
319
+ byTool.set(toolName, ids);
320
+ }
321
+ }
322
+ const ambiguous: Array<{ toolName: string; profileIds: string[] }> = [];
323
+ for (const [toolName, profileIds] of byTool) {
324
+ if (profileIds.length > 1) ambiguous.push({ toolName, profileIds });
325
+ }
326
+ return ambiguous;
327
+ }
328
+
329
+ /** Best-effort merge of `readLaunchArgs` across several candidate profiles (an ambiguous tool-name match): first defined field, in profile order, wins. */
330
+ export function readLaunchInfo(candidates: readonly SubagentProfile[], args: Record<string, unknown> | undefined): SubagentLaunchInfo {
331
+ let agent: string | undefined;
332
+ let mode: string | undefined;
333
+ for (const profile of candidates) {
334
+ const info = profile.readLaunchArgs(args);
335
+ if (agent === undefined && info.agent !== undefined) agent = info.agent;
336
+ if (mode === undefined && info.mode !== undefined) mode = info.mode;
337
+ }
338
+ return { ...(agent !== undefined ? { agent } : {}), ...(mode !== undefined ? { mode } : {}) };
339
+ }
340
+
341
+ /** 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. */
342
+ export function readResultInfo(candidates: readonly SubagentProfile[], result: unknown): SubagentResultInfo {
343
+ const merged: SubagentResultInfo = {};
344
+ for (const profile of candidates) {
345
+ const info = profile.readResult(result);
346
+ if (merged.taskId === undefined && info.taskId !== undefined) merged.taskId = info.taskId;
347
+ if (merged.agent === undefined && info.agent !== undefined) merged.agent = info.agent;
348
+ if (merged.status === undefined && info.status !== undefined) merged.status = info.status;
349
+ if (merged.mode === undefined && info.mode !== undefined) merged.mode = info.mode;
350
+ if (merged.cwd === undefined && info.cwd !== undefined) merged.cwd = info.cwd;
351
+ if (merged.usage === undefined && info.usage !== undefined) merged.usage = info.usage;
352
+ }
353
+ return merged;
354
+ }
355
+
356
+ /**
357
+ * C1 (CRITICAL fix): the launch-args counterpart of `readResultInfo`'s
358
+ * caution, used specifically for a genuinely AMBIGUOUS tool-name match
359
+ * (2+ candidate profiles, none of them the winner — SUBAGENT-REQ-005).
360
+ * Unlike `readLaunchInfo`'s "first defined field wins" merge (kept as-is,
361
+ * still used for the unambiguous single-candidate case, where there is
362
+ * nothing to disagree about), this only keeps a field when every candidate
363
+ * that reports a value for it reports the SAME value — "agent/mode if they
364
+ * read identically, else omitted". Two candidates disagreeing (e.g. one
365
+ * profile reads `mode` from `args.mode`, another from `args.action`, and
366
+ * they differ) means kankaku genuinely does not know which is right, so
367
+ * the field is dropped rather than silently picking one. Never reads
368
+ * anything money- or join-affecting — launch args never carry `usage` or
369
+ * `taskId` in the first place, only descriptive `agent`/`mode`.
370
+ */
371
+ export function mergeAgreeingLaunchInfo(candidates: readonly SubagentProfile[], args: Record<string, unknown> | undefined): SubagentLaunchInfo {
372
+ let agent: string | undefined;
373
+ let agentConflict = false;
374
+ let mode: string | undefined;
375
+ let modeConflict = false;
376
+
377
+ for (const profile of candidates) {
378
+ const info = profile.readLaunchArgs(args);
379
+ if (info.agent !== undefined) {
380
+ if (agent === undefined) agent = info.agent;
381
+ else if (agent !== info.agent) agentConflict = true;
382
+ }
383
+ if (info.mode !== undefined) {
384
+ if (mode === undefined) mode = info.mode;
385
+ else if (mode !== info.mode) modeConflict = true;
386
+ }
387
+ }
388
+
389
+ return {
390
+ ...(agent !== undefined && !agentConflict ? { agent } : {}),
391
+ ...(mode !== undefined && !modeConflict ? { mode } : {}),
392
+ };
393
+ }
394
+
395
+ /**
396
+ * C1 (CRITICAL fix): the result-reading counterpart for a genuinely
397
+ * AMBIGUOUS tool-name match. `readResultInfo`'s own best-effort merge stays
398
+ * available (and is still exactly right for the UNAMBIGUOUS single-
399
+ * candidate case), but when 2+ profiles registered the same tool name and
400
+ * neither could be told apart, nothing MONEY- or JOIN-affecting is ever
401
+ * taken from any candidate: `usage` (would silently double-bill the day a
402
+ * package sharing an ambiguous tool name, e.g. "subagent", starts
403
+ * following pi's documented top-level `usage` convention — see
404
+ * `PI_REFERENCE_PROFILE`) and `taskId` (a join key) are always stripped.
405
+ * Purely descriptive fields (`agent`/`status`/`mode`/`cwd`) are kept from
406
+ * the best-effort merge — they affect neither billing nor task/child
407
+ * joining, only how a span/record is labelled for a human reading it.
408
+ * `profile` itself is never part of this shape; the caller already leaves
409
+ * it `undefined` for an ambiguous match (see `resolveToolProfile`).
410
+ */
411
+ export function safeAmbiguousResultInfo(candidates: readonly SubagentProfile[], result: unknown): SubagentResultInfo {
412
+ const merged = readResultInfo(candidates, result);
413
+ return {
414
+ ...(merged.agent !== undefined ? { agent: merged.agent } : {}),
415
+ ...(merged.status !== undefined ? { status: merged.status } : {}),
416
+ ...(merged.mode !== undefined ? { mode: merged.mode } : {}),
417
+ ...(merged.cwd !== undefined ? { cwd: merged.cwd } : {}),
418
+ };
419
+ }
420
+
421
+ /**
422
+ * Whether any of `markers` is present in `env`: an exact-value marker
423
+ * requires an exact match, a presence-only marker (`value: undefined`)
424
+ * matches any non-empty value. Shared by `profileMarkerMatches` (one
425
+ * profile's own markers) and `config.ts#detectRole` (the full active set,
426
+ * generalised beyond the single hardcoded `GENTLE_PI_AGENTS_CHILD` check).
427
+ */
428
+ export function matchesAnyMarker(env: NodeJS.ProcessEnv, markers: readonly ChildEnvMarker[]): boolean {
429
+ return markers.some((marker) => {
430
+ const actual = env[marker.name];
431
+ if (actual === undefined || actual === "") return false;
432
+ return marker.value === undefined || actual === marker.value;
433
+ });
434
+ }
435
+
436
+ /** 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. */
437
+ export function profileMarkerMatches(profile: SubagentProfile, env: NodeJS.ProcessEnv): boolean {
438
+ return matchesAnyMarker(env, profile.childEnvMarkers);
439
+ }
440
+
441
+ export interface ChildProfileResolution {
442
+ /** The single profile confirmed by an env marker, or `undefined` when none matched or 2+ matched at once (never guessed). */
443
+ profile: SubagentProfile | undefined;
444
+ matchedProfiles: SubagentProfile[];
445
+ }
446
+
447
+ /**
448
+ * SUBAGENT-REQ-005: resolve which profile's child-env marker(s) confirm
449
+ * THIS process as a subagent of a known kind — the child-side counterpart
450
+ * of `resolveToolProfile`. Exactly one profile's marker present resolves
451
+ * unambiguously; none present means this process's role, if any, must come
452
+ * from ancestry instead (see `config.ts#detectRole`); two or more present
453
+ * at once (a genuine marker collision, not expected among the built-ins) is
454
+ * never guessed either — `profile` stays `undefined`, but every match is
455
+ * still reported so `/kankaku doctor` can surface it.
456
+ */
457
+ export function resolveChildProfile(profiles: readonly SubagentProfile[], env: NodeJS.ProcessEnv): ChildProfileResolution {
458
+ const matchedProfiles = profiles.filter((profile) => profileMarkerMatches(profile, env));
459
+ return { profile: matchedProfiles.length === 1 ? matchedProfiles[0] : undefined, matchedProfiles };
460
+ }
461
+
462
+ /** 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. */
463
+ export function allChildMarkers(profiles: readonly SubagentProfile[]): ChildEnvMarker[] {
464
+ const byName = new Map<string, ChildEnvMarker>();
465
+ for (const profile of profiles) {
466
+ for (const marker of profile.childEnvMarkers) {
467
+ if (!byName.has(marker.name)) byName.set(marker.name, marker);
468
+ }
469
+ }
470
+ return Array.from(byName.values());
471
+ }
472
+
473
+ /**
474
+ * C2 (CRITICAL fix): the "always confirms, regardless of interactivity"
475
+ * tier for `config.ts#detectRole`'s 4th param — every active profile's
476
+ * markers EXCEPT the single user-configured one (`id === "configured"`,
477
+ * built from `KANKAKU_SUBAGENT_CHILD_ENV` in `config.ts#loadConfig`). Only
478
+ * a real subagent runner (gentle-pi, pi-subagents) sets a built-in marker,
479
+ * so this tier keeps today's unconditional precedence.
480
+ */
481
+ export function builtinChildMarkers(profiles: readonly SubagentProfile[]): ChildEnvMarker[] {
482
+ return allChildMarkers(profiles.filter((profile) => profile.id !== "configured"));
483
+ }
484
+
485
+ /**
486
+ * C2 (CRITICAL fix): the "never demotes an interactive session" tier for
487
+ * `config.ts#detectRole`'s 5th param — the user-configured profile's own
488
+ * markers only (empty when no `"configured"` profile is active). Kept
489
+ * separate from `builtinChildMarkers` because kankaku cannot verify an
490
+ * arbitrary configured environment variable name is genuinely child-only
491
+ * (see `config.ts#loadConfig`'s denylist and `detectRole`'s doc comment).
492
+ */
493
+ export function configuredChildMarkers(profiles: readonly SubagentProfile[]): ChildEnvMarker[] {
494
+ return allChildMarkers(profiles.filter((profile) => profile.id === "configured"));
495
+ }