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,127 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Client, HubTask, Project, WorkTarget } from "../domain/work-target.ts";
3
+
4
+ const SKIP_OPTION = "— skip —";
5
+ const NO_PROJECT_OPTION = "(no project)";
6
+
7
+ export interface PickerCatalog {
8
+ clients: Client[];
9
+ projects: Project[];
10
+ }
11
+
12
+ export type PickResult = { kind: "picked"; target: WorkTarget } | { kind: "skipped" };
13
+
14
+ interface LabeledOption<T> {
15
+ label: string;
16
+ item: T;
17
+ }
18
+
19
+ /**
20
+ * Build `label -> item` options, sorted by name. When two items share the
21
+ * same name, disambiguate every colliding label by appending ` (code)` so
22
+ * every option maps back to exactly one id.
23
+ */
24
+ function labelOptions<T extends { name: string; code?: string }>(items: T[]): LabeledOption<T>[] {
25
+ const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
26
+ const nameCounts = new Map<string, number>();
27
+ for (const item of sorted) {
28
+ nameCounts.set(item.name, (nameCounts.get(item.name) ?? 0) + 1);
29
+ }
30
+
31
+ return sorted.map((item) => {
32
+ const collides = (nameCounts.get(item.name) ?? 0) > 1;
33
+ const label = collides && item.code ? `${item.name} (${item.code})` : item.name;
34
+ return { label, item };
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Run the client/project picker (`ctx.ui.select`) against an already-loaded
40
+ * catalog snapshot. Pure UI interaction: no network, no persistence — the
41
+ * caller (`session-target.ts`) decides what to do with the result.
42
+ *
43
+ * Declining at either step — choosing "— skip —" or dismissing the dialog
44
+ * (`undefined`) — cancels the whole pick, not just that step.
45
+ */
46
+ export async function pickTarget(ctx: ExtensionContext, catalog: PickerCatalog): Promise<PickResult> {
47
+ const pickableClients = catalog.clients.filter((client) => client.active && !client.unassigned);
48
+ const clientOptions = labelOptions(pickableClients);
49
+
50
+ const clientChoice = await ctx.ui.select("kankaku — client", [...clientOptions.map((option) => option.label), SKIP_OPTION]);
51
+ if (clientChoice === undefined || clientChoice === SKIP_OPTION) return { kind: "skipped" };
52
+
53
+ const client = clientOptions.find((option) => option.label === clientChoice)?.item;
54
+ if (!client) return { kind: "skipped" };
55
+
56
+ const pickableProjects = catalog.projects.filter((project) => project.active && project.clientId === client.id);
57
+ const projectOptions = labelOptions(pickableProjects);
58
+
59
+ const projectChoice = await ctx.ui.select("kankaku — project", [
60
+ ...projectOptions.map((option) => option.label),
61
+ NO_PROJECT_OPTION,
62
+ SKIP_OPTION,
63
+ ]);
64
+ if (projectChoice === undefined || projectChoice === SKIP_OPTION) return { kind: "skipped" };
65
+
66
+ const project = projectChoice === NO_PROJECT_OPTION ? undefined : projectOptions.find((option) => option.label === projectChoice)?.item;
67
+
68
+ const target: WorkTarget = {
69
+ clientId: client.id,
70
+ clientCode: client.code,
71
+ clientName: client.name,
72
+ ...(project !== undefined
73
+ ? {
74
+ projectId: project.id,
75
+ ...(project.code !== undefined ? { projectCode: project.code } : {}),
76
+ projectName: project.name,
77
+ }
78
+ : {}),
79
+ };
80
+
81
+ return { kind: "picked", target };
82
+ }
83
+
84
+ export type PickHubTaskResult = { kind: "picked"; task: HubTask } | { kind: "skipped" } | { kind: "empty" };
85
+
86
+ /**
87
+ * Build `label -> task` options for {@link pickHubTask}, sorted by title.
88
+ * When two tasks share the same title, disambiguate every colliding label
89
+ * with the task's `externalRef` when it has one, or its bare id otherwise —
90
+ * unlike {@link labelOptions}, a hub task has no `code`, and every task
91
+ * needs a disambiguator, not just the ones lucky enough to have one.
92
+ */
93
+ function labelTaskOptions(tasks: HubTask[]): LabeledOption<HubTask>[] {
94
+ const sorted = [...tasks].sort((a, b) => a.title.localeCompare(b.title));
95
+ const titleCounts = new Map<string, number>();
96
+ for (const task of sorted) {
97
+ titleCounts.set(task.title, (titleCounts.get(task.title) ?? 0) + 1);
98
+ }
99
+
100
+ return sorted.map((task) => {
101
+ const collides = (titleCounts.get(task.title) ?? 0) > 1;
102
+ const label = collides ? `${task.title} (${task.externalRef ?? task.id})` : task.title;
103
+ return { label, item: task };
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Run the hub-task picker (`ctx.ui.select`) for `projectId`'s open/doing
109
+ * tasks. Pure UI interaction: no network, no persistence — the caller
110
+ * (`session-target.ts#pickTask`) decides what to do with the result.
111
+ * `{ kind: "empty" }` is returned without ever prompting when the project
112
+ * has no open/doing task, so the caller can tell "nothing to pick from"
113
+ * apart from "the user skipped".
114
+ */
115
+ export async function pickHubTask(ctx: ExtensionContext, tasks: HubTask[], projectId: string): Promise<PickHubTaskResult> {
116
+ const pickable = tasks.filter((task) => task.status !== "done" && task.projectId === projectId);
117
+ if (pickable.length === 0) return { kind: "empty" };
118
+
119
+ const taskOptions = labelTaskOptions(pickable);
120
+ const choice = await ctx.ui.select("kankaku — task", [...taskOptions.map((option) => option.label), SKIP_OPTION]);
121
+ if (choice === undefined || choice === SKIP_OPTION) return { kind: "skipped" };
122
+
123
+ const task = taskOptions.find((option) => option.label === choice)?.item;
124
+ if (!task) return { kind: "skipped" };
125
+
126
+ return { kind: "picked", task };
127
+ }
package/src/config.ts ADDED
@@ -0,0 +1,536 @@
1
+ import type { SegmentRule } from "./domain/segment-rule.ts";
2
+ import type { PromptPrivacyMode } from "./domain/hub-entry.ts";
3
+ import { BUILTIN_SUBAGENT_PROFILES, buildConfiguredProfile, matchesAnyMarker } from "./domain/subagent-profile.ts";
4
+ import type { ChildEnvMarker, SubagentProfile } from "./domain/subagent-profile.ts";
5
+
6
+ export interface KankakuConfig {
7
+ /** Directory for the work log, relative to the project cwd unless absolute. */
8
+ dir: string;
9
+ /** Tool names whose execution span counts as waiting time. */
10
+ interactiveTools: string[];
11
+ /**
12
+ * Every active {@link SubagentProfile} (ADR 0020): the built-ins
13
+ * (gentle-pi, pi's bundled reference example, pi-subagents) plus, when
14
+ * `KANKAKU_SUBAGENT_TOOLS`/`KANKAKU_SUBAGENT_CHILD_ENV` are set, one
15
+ * additional `"configured"` profile — always additive, never replacing
16
+ * gentle-pi's own recognition. `SUBAGENT_TOOL` is no longer a hardcoded
17
+ * constant; `domain/work-tracker.ts` matches subagent tool calls against
18
+ * the union of every profile's `toolNames`.
19
+ */
20
+ subagentProfiles: SubagentProfile[];
21
+ /** Rules that tag a tool execution's span under a named segment (e.g. `review`). */
22
+ segmentRules: SegmentRule[];
23
+ /** Default billing client for this project, from `KANKAKU_CLIENT`. See `domain/client-label.ts`. */
24
+ client?: string;
25
+ /**
26
+ * C2 (CRITICAL fix): every `KANKAKU_SUBAGENT_CHILD_ENV` entry rejected by
27
+ * {@link validateSubagentChildEnvMarkers} — a marker name that looks like
28
+ * an ambient pi/shell/OS/npm environment variable, not a genuine
29
+ * child-only marker. Always present (empty when nothing was configured,
30
+ * or everything configured was accepted), so a caller never has to guard
31
+ * against it being `undefined`. `/kankaku doctor` and a one-time
32
+ * `ctx.ui.notify` are expected to surface this (see `adapters/pi-tracker.ts`).
33
+ */
34
+ rejectedSubagentChildEnvMarkers: RejectedChildEnvMarker[];
35
+ }
36
+
37
+ /** One `KANKAKU_SUBAGENT_CHILD_ENV` marker {@link validateSubagentChildEnvMarkers} rejected, with why. */
38
+ export interface RejectedChildEnvMarker {
39
+ name: string;
40
+ reason: string;
41
+ }
42
+
43
+ const DEFAULT_DIR = ".kankaku";
44
+ const DEFAULT_INTERACTIVE_TOOLS = ["ask_user_question", "ask_user_choice"];
45
+
46
+ /**
47
+ * Default segment rule: with gentle-ai, the review-with-receipts step runs
48
+ * as `gentle-ai review ...` commands through the `bash` tool, so tag that
49
+ * span `review`.
50
+ */
51
+ const DEFAULT_SEGMENT_RULES: SegmentRule[] = [{ tag: "review", tool: "bash", pattern: /\bgentle-ai review\b/ }];
52
+
53
+ /** Tags allowed for a segment rule: letters, digits, `_` and `-`, 1-32 chars. */
54
+ const SAFE_TAG = /^[A-Za-z0-9_-]{1,32}$/;
55
+ /** Property names that behave specially on a plain object; never usable as a tag. */
56
+ const RESERVED_TAGS = new Set(["__proto__", "constructor", "prototype"]);
57
+
58
+ function isSafeTag(tag: string): boolean {
59
+ return SAFE_TAG.test(tag) && !RESERVED_TAGS.has(tag);
60
+ }
61
+
62
+ /**
63
+ * Parse `KANKAKU_SEGMENTS`, a `;`-separated list of `tag=tool:regex`
64
+ * entries (example: `review=bash:gentle-ai review;commit=bash:git commit`).
65
+ * Malformed entries (missing tag, tool or regex, an invalid regex source,
66
+ * or a tag that is not a safe identifier such as `__proto__`) are skipped
67
+ * rather than failing the whole variable.
68
+ */
69
+ function parseSegmentRules(raw: string): SegmentRule[] {
70
+ const rules: SegmentRule[] = [];
71
+
72
+ for (const entry of raw.split(";")) {
73
+ const trimmed = entry.trim();
74
+ if (!trimmed) continue;
75
+
76
+ const eqIndex = trimmed.indexOf("=");
77
+ if (eqIndex <= 0) continue;
78
+
79
+ const tag = trimmed.slice(0, eqIndex).trim();
80
+ const rest = trimmed.slice(eqIndex + 1);
81
+ const colonIndex = rest.indexOf(":");
82
+ if (colonIndex <= 0) continue;
83
+
84
+ const tool = rest.slice(0, colonIndex).trim();
85
+ const regexSource = rest.slice(colonIndex + 1).trim();
86
+ if (!tag || !tool || !regexSource || !isSafeTag(tag)) continue;
87
+
88
+ try {
89
+ rules.push({ tag, tool, pattern: new RegExp(regexSource) });
90
+ } catch {
91
+ continue;
92
+ }
93
+ }
94
+
95
+ return rules;
96
+ }
97
+
98
+ /**
99
+ * SUBAGENT-REQ-002: `KANKAKU_SUBAGENT_TOOLS`, a comma-separated list of
100
+ * additional tool names treated as subagent-launching spans, parsed with
101
+ * the exact same tolerant trim-and-filter-empty convention as
102
+ * `KANKAKU_INTERACTIVE_TOOLS` above.
103
+ */
104
+ function parseSubagentTools(raw: string): string[] {
105
+ return raw
106
+ .split(",")
107
+ .map((tool) => tool.trim())
108
+ .filter((tool) => tool.length > 0);
109
+ }
110
+
111
+ /**
112
+ * SUBAGENT-REQ-003: `KANKAKU_SUBAGENT_CHILD_ENV`, a `;`-separated list of
113
+ * `NAME=VALUE` or bare `NAME` child-process env markers — mirrors
114
+ * `KANKAKU_SEGMENTS`'s tolerant `;`-separated convention. A bare `NAME`
115
+ * marks presence-only (any non-empty value matches, e.g. pi-subagents'
116
+ * depth counter); `NAME=VALUE` requires an exact match. An entry with no
117
+ * name, or a trailing `=` with nothing after it, is malformed and skipped
118
+ * rather than failing the whole variable.
119
+ */
120
+ function parseSubagentChildEnv(raw: string): ChildEnvMarker[] {
121
+ const markers: ChildEnvMarker[] = [];
122
+
123
+ for (const entry of raw.split(";")) {
124
+ const trimmed = entry.trim();
125
+ if (!trimmed) continue;
126
+
127
+ const eqIndex = trimmed.indexOf("=");
128
+ if (eqIndex === -1) {
129
+ const name = trimmed;
130
+ if (name) markers.push({ name });
131
+ continue;
132
+ }
133
+
134
+ const name = trimmed.slice(0, eqIndex).trim();
135
+ const value = trimmed.slice(eqIndex + 1).trim();
136
+ if (!name || !value) continue;
137
+ markers.push({ name, value });
138
+ }
139
+
140
+ return markers;
141
+ }
142
+
143
+ /**
144
+ * C2 (CRITICAL fix, item 1): exact marker names pi sets on EVERY process it
145
+ * runs (verified: `dist/cli/setup.js:5` sets `PI_CODING_AGENT=true`,
146
+ * `dist/rpc-entry.js:6` sets `AI_AGENT=pi` — both unconditionally, not just
147
+ * for a subagent's child) or that are otherwise ordinary, ambient
148
+ * shell/OS/npm-lifecycle environment, never something a real child-only
149
+ * marker would legitimately reuse.
150
+ */
151
+ const DENIED_MARKER_NAMES = new Set(["PI_CODING_AGENT", "AI_AGENT", "PATH", "HOME", "USER", "SHELL", "PWD", "CI", "LANG", "TMUX"]);
152
+
153
+ /** 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. */
154
+ const DENIED_MARKER_PREFIXES = ["PI_", "TERM", "LC_", "NODE_", "NPM_", "KANKAKU_"];
155
+
156
+ /** What an environment variable name may look like; anything else is a typo, never a marker. */
157
+ const VALID_MARKER_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
158
+
159
+ /** `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. */
160
+ function deniedMarkerReason(name: string): string | undefined {
161
+ // A name that is not a valid environment variable name can never match a
162
+ // real variable: accepting it would silently disable the user's
163
+ // configuration AND hide any ambient name inside it from the denylist
164
+ // below. The usual cause is typing commas, as `KANKAKU_SUBAGENT_TOOLS`
165
+ // takes, where this list is `;`-separated.
166
+ if (!VALID_MARKER_NAME.test(name)) {
167
+ return `"${name}" is not a valid environment variable name — separate several markers with ";" (not ","), each as NAME or NAME=VALUE`;
168
+ }
169
+ const upper = name.toUpperCase();
170
+ if (DENIED_MARKER_NAMES.has(upper)) {
171
+ return `${name} is an ambient variable pi or the shell sets on EVERY process, not a marker exclusive to a subagent's child`;
172
+ }
173
+ for (const prefix of DENIED_MARKER_PREFIXES) {
174
+ if (upper.startsWith(prefix)) {
175
+ return `${name} looks like a pi/npm/shell-namespaced environment variable (prefix "${prefix}"), not a marker a third-party subagent tool would set`;
176
+ }
177
+ }
178
+ return undefined;
179
+ }
180
+
181
+ /**
182
+ * C2 (CRITICAL fix, item 1): validate every `KANKAKU_SUBAGENT_CHILD_ENV`
183
+ * marker against the denylist above, config-time. A rejected marker is
184
+ * never added to the `"configured"` profile — so it can never demote a
185
+ * user's own top-level session to `role: "subagent"` in the first place
186
+ * (layered with C2 items 2/3's runtime interactive guard in
187
+ * `config.ts#detectRole`, which still protects a marker this denylist does
188
+ * not happen to catch). `loadConfig` surfaces `rejected` via
189
+ * `KankakuConfig.rejectedSubagentChildEnvMarkers` for `/kankaku doctor` and
190
+ * a one-time `ctx.ui.notify`.
191
+ */
192
+ export function validateSubagentChildEnvMarkers(markers: readonly ChildEnvMarker[]): {
193
+ accepted: ChildEnvMarker[];
194
+ rejected: RejectedChildEnvMarker[];
195
+ } {
196
+ const accepted: ChildEnvMarker[] = [];
197
+ const rejected: RejectedChildEnvMarker[] = [];
198
+ for (const marker of markers) {
199
+ const reason = deniedMarkerReason(marker.name);
200
+ if (reason) {
201
+ rejected.push({ name: marker.name, reason });
202
+ } else {
203
+ accepted.push(marker);
204
+ }
205
+ }
206
+ return { accepted, rejected };
207
+ }
208
+
209
+ export function loadConfig(env: NodeJS.ProcessEnv = process.env): KankakuConfig {
210
+ const dir = env["KANKAKU_DIR"]?.trim() || DEFAULT_DIR;
211
+ const interactiveToolsRaw = env["KANKAKU_INTERACTIVE_TOOLS"]?.trim();
212
+ const interactiveTools = interactiveToolsRaw
213
+ ? interactiveToolsRaw
214
+ .split(",")
215
+ .map((tool) => tool.trim())
216
+ .filter((tool) => tool.length > 0)
217
+ : DEFAULT_INTERACTIVE_TOOLS;
218
+
219
+ const segmentsRaw = env["KANKAKU_SEGMENTS"]?.trim();
220
+ const segmentRules = segmentsRaw ? parseSegmentRules(segmentsRaw) : DEFAULT_SEGMENT_RULES;
221
+
222
+ const client = env["KANKAKU_CLIENT"]?.trim() || undefined;
223
+
224
+ const subagentToolsRaw = env["KANKAKU_SUBAGENT_TOOLS"]?.trim();
225
+ const configuredTools = subagentToolsRaw ? parseSubagentTools(subagentToolsRaw) : [];
226
+ const subagentChildEnvRaw = env["KANKAKU_SUBAGENT_CHILD_ENV"]?.trim();
227
+ const parsedMarkers = subagentChildEnvRaw ? parseSubagentChildEnv(subagentChildEnvRaw) : [];
228
+ // C2 (CRITICAL fix, item 1): a denylisted marker name is never handed to
229
+ // buildConfiguredProfile at all — it can never demote a session's role,
230
+ // and is instead surfaced via rejectedSubagentChildEnvMarkers below.
231
+ const { accepted: configuredMarkers, rejected: rejectedSubagentChildEnvMarkers } = validateSubagentChildEnvMarkers(parsedMarkers);
232
+ const configuredProfile = buildConfiguredProfile(configuredTools, configuredMarkers);
233
+ const subagentProfiles: SubagentProfile[] = [...BUILTIN_SUBAGENT_PROFILES, ...(configuredProfile ? [configuredProfile] : [])];
234
+
235
+ return {
236
+ dir,
237
+ interactiveTools,
238
+ subagentProfiles,
239
+ segmentRules,
240
+ rejectedSubagentChildEnvMarkers,
241
+ ...(client !== undefined ? { client } : {}),
242
+ };
243
+ }
244
+
245
+ export interface RoleDetection {
246
+ role: "orchestrator" | "subagent";
247
+ /**
248
+ * Set only when this process could not be positively proven top-level
249
+ * (ADR 0022's four-state classification, applied on top of the still-
250
+ * binary `role`): no recognised child-env-marker matched, but a live
251
+ * tracked ancestor process was found via the machine-wide process
252
+ * registry, AND this process is not itself an interactive session (see
253
+ * `isInteractive` below — F3). See `domain/task-view.ts`'s
254
+ * `roleConfidence` handling.
255
+ */
256
+ roleConfidence?: "uncertain";
257
+ /**
258
+ * Set when `KANKAKU_ROLE=subagent` was present, with no confirmed child
259
+ * marker, but was ignored because this process looked interactive (see
260
+ * this function's precedence doc — R1). The caller (`extension.ts`) is
261
+ * expected to surface this once via `ctx.ui.notify` at `session_start`
262
+ * and report it in `/kankaku doctor`, so the contradiction is never
263
+ * silent.
264
+ */
265
+ overrideIgnoredInteractive?: true;
266
+ /**
267
+ * C2 item 2/3: set when a USER-CONFIGURED child-env marker
268
+ * (`KANKAKU_SUBAGENT_CHILD_ENV`, the `configuredMarkers` 5th param below)
269
+ * matched, but was ignored for this process's `role` because it looked
270
+ * interactive — a configured marker, unlike a BUILT-IN one, never demotes
271
+ * an interactive session (see this function's precedence doc). The
272
+ * caller is expected to surface this once (mirroring
273
+ * `overrideIgnoredInteractive`) via `ctx.ui.notify` and `/kankaku
274
+ * doctor`, and to escalate the wording when this process also has no
275
+ * tracked ancestor at all — the strongest signal the marker is genuinely
276
+ * ambient (C2 item 3's self-check), not a real subagent mechanism.
277
+ */
278
+ configuredMarkerIgnoredInteractive?: true;
279
+ }
280
+
281
+ export type RoleOverride = "orchestrator" | "subagent";
282
+
283
+ /**
284
+ * `KANKAKU_ROLE`: an explicit escape hatch for a genuine session
285
+ * `detectRole` gets wrong (no reliable automatic signal exists for it),
286
+ * and for a legacy/JSONL record already written `uncertain`, which can
287
+ * never be rewritten after the fact (the log is append-only) but whose
288
+ * *next* run can be told the truth directly. It does **not** override
289
+ * every other signal unconditionally any more — see `detectRole`'s
290
+ * precedence doc (R1) for the confirmed-child-marker and interactive-
291
+ * session exceptions this now has. An unrecognised value (anything other
292
+ * than exactly `"orchestrator"` or `"subagent"`) is ignored, falling back
293
+ * to normal detection, rather than failing the process or guessing.
294
+ */
295
+ export function readRoleOverride(env: NodeJS.ProcessEnv = process.env): RoleOverride | undefined {
296
+ const raw = env["KANKAKU_ROLE"]?.trim();
297
+ return raw === "orchestrator" || raw === "subagent" ? raw : undefined;
298
+ }
299
+
300
+ /**
301
+ * Remove `KANKAKU_ROLE` from `env` in place (R1, layer 2 — non-
302
+ * propagation). `KANKAKU_ROLE` decides only THIS process's role; a child
303
+ * this process spawns (a subagent runner, a tool shell) must never inherit
304
+ * it, since `process.env` is inherited by every OS child by default. Left
305
+ * unstripped, a user who once hit a false `uncertain` and exported
306
+ * `KANKAKU_ROLE=orchestrator` in a shell rc/tmux/CI environment would have
307
+ * every subsequent subagent see it too — layer 1's precedence fix
308
+ * (a confirmed child marker always wins) already neutralises that specific
309
+ * leak for a *recognised* subagent mechanism, but this strips it outright
310
+ * so it can never reach an unrecognised one, or reach an unrelated child
311
+ * process this one spawns for some other reason. Takes the env object as
312
+ * a parameter, rather than reaching for `process.env` itself, so this
313
+ * stays a pure function tests can exercise against a plain object without
314
+ * ever mutating the real environment — `extension.ts` is the one caller
315
+ * that passes the real `process.env`, right after reading the override.
316
+ */
317
+ export function stripRoleOverride(env: NodeJS.ProcessEnv): void {
318
+ delete env["KANKAKU_ROLE"];
319
+ }
320
+
321
+ /**
322
+ * Classify this process's role, strictly in this order (R1 rewrote this
323
+ * precedence — a leaked `KANKAKU_ROLE` must never out-rank a *confirmed*
324
+ * signal, and must never silently drop a genuine interactive session):
325
+ *
326
+ * 1. `GENTLE_PI_AGENTS_CHILD=1` — the automatic confirmed-subagent marker,
327
+ * set only by the subagent runner itself, never something a shell
328
+ * rc/tmux/CI environment would export. **Always wins**, even over an
329
+ * explicit `KANKAKU_ROLE=orchestrator` — without this, a leaked
330
+ * `KANKAKU_ROLE=orchestrator` export would turn every one of this
331
+ * process's genuine subagent invocations into a confirmed,
332
+ * independently-billed orchestrator too (the bug this fixes).
333
+ * 2. `KANKAKU_ROLE` (F3's explicit escape hatch), when set to a recognised
334
+ * value and no confirmed marker matched above — with one exception:
335
+ * `KANKAKU_ROLE=subagent` in an **interactive** session (`isInteractive`
336
+ * — see below) is ignored. No subagent mechanism kankaku recognises
337
+ * ever launches its child interactively; an interactive session with
338
+ * this override set and no marker to back it up is therefore almost
339
+ * certainly a leaked shell export, not a real subagent. Honouring it
340
+ * would silently drop this session's own work from every report and
341
+ * the hub (an orphaned subagent record that never anchors a task) with
342
+ * no way to recover it later, since `worklog.jsonl` is append-only.
343
+ * Between the package's two guiding rules — "undercount is recoverable,
344
+ * overcount is not" (which governs the *opposite* risk, inventing extra
345
+ * billing, and does not apply here) and "never silently drop genuine
346
+ * work" — this is governed by the second: the override is ignored, this
347
+ * process is classified `orchestrator` (what it structurally must be),
348
+ * and `overrideIgnoredInteractive` is set so the caller can surface the
349
+ * contradiction instead of resolving it silently. A non-interactive
350
+ * process gets exactly what it asked for. `KANKAKU_ROLE=orchestrator`
351
+ * has no such exception — forcing a session `orchestrator` can never
352
+ * drop work, only (rarely) invent a task that should not exist, a risk
353
+ * the user accepted by setting it explicitly.
354
+ * 3. `hasTrackedAncestor` — whether this process's own OS ancestor chain
355
+ * contains a live, identity-verified entry in the machine-wide process
356
+ * registry (computed by the caller, e.g. `adapters/subagent-startup.ts`,
357
+ * via `adapters/ancestry.ts` + `domain/ancestry-match.ts`; see
358
+ * `ports/process-registry.ts`) — combined with `isInteractive`: only a
359
+ * *non-interactive* process with a tracked ancestor is demoted to
360
+ * `uncertain` (ADR 0022's safe default, inverted). An interactive TUI
361
+ * session on a real terminal is a human's own session even when some
362
+ * ancestor happens to be a tracked pi process (e.g. pi launched from
363
+ * inside another pi's shell tool) — every subagent mechanism kankaku
364
+ * recognises launches its child non-interactively over pipes, so
365
+ * `isInteractive` alone already tells a genuine top-level session apart
366
+ * from one that could plausibly be someone's silent child.
367
+ *
368
+ * `isInteractive` defaults to `true` — the same conservative default used
369
+ * for both the interactive-override exception above and the `uncertain`
370
+ * fallback below, since a caller that does not yet know it (see
371
+ * `extension.ts`'s factory-time synchronous TTY proxy, and F3's later,
372
+ * authoritative `ctx.mode === "tui"` refinement of `roleConfidence` only —
373
+ * never of `role` itself) should never wrongly honour a `subagent` override
374
+ * or demote a session to `uncertain` before it can find out.
375
+ *
376
+ * A process that cannot be shown to be top-level by any of the above must
377
+ * never default to `"orchestrator"` outright — but see F2: when ancestor
378
+ * detection itself is unavailable (platform, or a failed/timed-out probe),
379
+ * `hasTrackedAncestor` is simply `false` (nothing was found), which already
380
+ * falls through to a confirmed orchestrator here — the *old*, pre-ADR-0022
381
+ * behaviour on such a platform, deliberately: marking every unprovable
382
+ * process `uncertain` there would drop all of a Windows user's genuine
383
+ * work, a far worse failure than the narrow overcount this guards against
384
+ * elsewhere. `/kankaku doctor` is responsible for making that unavailable-
385
+ * detection limitation visible; it is never encoded in `roleConfidence`.
386
+ *
387
+ * C2 (CRITICAL fix): `childMarkers` (built-in tier, step 1 above) and
388
+ * `configuredMarkers` (the 5th param) are now two DIFFERENT tiers, not one
389
+ * merged list. A BUILT-IN marker (`GENTLE_PI_AGENTS_CHILD`,
390
+ * `PI_SUBAGENT_DEPTH`) is set only by a real subagent runner and always
391
+ * wins outright, exactly as step 1 describes — verified that no built-in
392
+ * mechanism kankaku recognises ever launches its child interactively (see
393
+ * `domain/subagent-profile.ts#PI_SUBAGENTS_PROFILE`'s doc comment), so this
394
+ * tier never actually needs the interactive exception in practice, and
395
+ * keeping it unconditional avoids a behaviour change for it. A
396
+ * USER-CONFIGURED marker (`KANKAKU_SUBAGENT_CHILD_ENV`), by contrast, names
397
+ * an arbitrary environment variable kankaku cannot verify is child-only —
398
+ * pi itself sets `PI_CODING_AGENT`/`AI_AGENT` on EVERY process, and a naive
399
+ * choice like that (or `CI`, `TMUX`, an exported shell var) would make the
400
+ * user's own top-level interactive session `role: "subagent"` with no
401
+ * parent, never anchoring a task and unrecoverable once written (the log
402
+ * is append-only). So a configured marker gets EXACTLY the same
403
+ * interactive exception `KANKAKU_ROLE=subagent` already has (step 2): it
404
+ * never demotes an interactive session — `configuredMarkerIgnoredInteractive`
405
+ * is set instead, so the caller can self-check and surface the
406
+ * contradiction (C2 item 3) rather than silently trusting an ambient
407
+ * marker. See `config.ts#loadConfig`'s denylist for the config-time half of
408
+ * this fix (rejecting an obviously-ambient marker name outright).
409
+ */
410
+ /** `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. */
411
+ const DEFAULT_CHILD_MARKERS: ChildEnvMarker[] = [{ name: "GENTLE_PI_AGENTS_CHILD", value: "1" }];
412
+
413
+ export function detectRole(
414
+ env: NodeJS.ProcessEnv = process.env,
415
+ hasTrackedAncestor = false,
416
+ isInteractive = true,
417
+ /** 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`. */
418
+ childMarkers: readonly ChildEnvMarker[] = DEFAULT_CHILD_MARKERS,
419
+ /** 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`. */
420
+ configuredMarkers: readonly ChildEnvMarker[] = [],
421
+ ): RoleDetection {
422
+ if (matchesAnyMarker(env, childMarkers)) return { role: "subagent" };
423
+
424
+ const configuredMatch = matchesAnyMarker(env, configuredMarkers);
425
+ if (configuredMatch && !isInteractive) return { role: "subagent" };
426
+
427
+ let result: RoleDetection;
428
+ const override = readRoleOverride(env);
429
+
430
+ if (override === "orchestrator") {
431
+ result = { role: "orchestrator" };
432
+ } else if (override === "subagent") {
433
+ result = isInteractive ? { role: "orchestrator", overrideIgnoredInteractive: true } : { role: "subagent" };
434
+ } else if (hasTrackedAncestor && !isInteractive) {
435
+ result = { role: "orchestrator", roleConfidence: "uncertain" };
436
+ } else {
437
+ result = { role: "orchestrator" };
438
+ }
439
+
440
+ // The configured marker matched but was ignored (this process is
441
+ // interactive) — flagged regardless of what else decided `result`, so the
442
+ // caller always learns a configured marker is present-but-ambient here.
443
+ return configuredMatch && isInteractive ? { ...result, configuredMarkerIgnoredInteractive: true } : result;
444
+ }
445
+
446
+ /** Hub (PocketBase) credentials read from the environment; any field can be absent. */
447
+ export interface HubEnvCredentials {
448
+ url?: string;
449
+ email?: string;
450
+ password?: string;
451
+ }
452
+
453
+ /** Read `KANKAKU_PB_URL`/`KANKAKU_PB_EMAIL`/`KANKAKU_PB_PASSWORD`. Empty/whitespace-only values are treated as absent. */
454
+ export function loadHubEnvCredentials(env: NodeJS.ProcessEnv = process.env): HubEnvCredentials {
455
+ return {
456
+ url: env["KANKAKU_PB_URL"]?.trim() || undefined,
457
+ email: env["KANKAKU_PB_EMAIL"]?.trim() || undefined,
458
+ password: env["KANKAKU_PB_PASSWORD"]?.trim() || undefined,
459
+ };
460
+ }
461
+
462
+ /** `KANKAKU_MACHINE`, or `hostname()` when unset/blank. Injected so this stays testable without touching `os.hostname`. */
463
+ export function loadMachine(env: NodeJS.ProcessEnv, hostname: () => string): string {
464
+ return env["KANKAKU_MACHINE"]?.trim() || hostname();
465
+ }
466
+
467
+ /** Hosts allowed to use a plain-HTTP hub URL. */
468
+ function isLocalHost(hostname: string): boolean {
469
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
470
+ }
471
+
472
+ /** Hub sync (Phase 2) configuration, read from the environment. See README "Hub (PocketBase)" sync section. */
473
+ export interface SyncConfig {
474
+ /** `KANKAKU_SYNC_PROMPT`. Defaults to `"none"` — the conservative default (proposal §8). */
475
+ promptMode: PromptPrivacyMode;
476
+ /** `KANKAKU_SYNC_WINDOW_HOURS`. Defaults to 24; falls back to the default for a non-positive or non-numeric value. */
477
+ windowHours: number;
478
+ /** `KANKAKU_SYNC_RECORDS`. Defaults to enabled; `"0"` disables uploading `work_records` children. */
479
+ syncRecords: boolean;
480
+ /** `KANKAKU_SYNC_AUTO`. Defaults to enabled; `"0"` disables every automatic sync: the fire-and-forget session_start/agent_settled ones and the awaited session_shutdown one. */
481
+ auto: boolean;
482
+ /**
483
+ * `KANKAKU_SYNC_MIN_INTERVAL_MINUTES`. How often the *automatic*
484
+ * (`session_start`/`agent_settled`) sync path is allowed to actually run
485
+ * a sync, at most. Defaults to 5; `0` disables throttling entirely. Never
486
+ * applies to a manual `/kankaku sync`, `sync all`, or `backfill`. See
487
+ * `adapters/sync-runner.ts#runSync`.
488
+ */
489
+ minIntervalMinutes: number;
490
+ }
491
+
492
+ const VALID_PROMPT_MODES = new Set<PromptPrivacyMode>(["none", "truncated", "full"]);
493
+ const DEFAULT_SYNC_WINDOW_HOURS = 24;
494
+ const DEFAULT_SYNC_MIN_INTERVAL_MINUTES = 5;
495
+
496
+ export function loadSyncConfig(env: NodeJS.ProcessEnv = process.env): SyncConfig {
497
+ const promptRaw = env["KANKAKU_SYNC_PROMPT"]?.trim();
498
+ const promptMode: PromptPrivacyMode = promptRaw && VALID_PROMPT_MODES.has(promptRaw as PromptPrivacyMode) ? (promptRaw as PromptPrivacyMode) : "none";
499
+
500
+ const windowRaw = env["KANKAKU_SYNC_WINDOW_HOURS"]?.trim();
501
+ const parsedWindow = windowRaw ? Number(windowRaw) : NaN;
502
+ const windowHours = Number.isFinite(parsedWindow) && parsedWindow > 0 ? parsedWindow : DEFAULT_SYNC_WINDOW_HOURS;
503
+
504
+ const syncRecords = env["KANKAKU_SYNC_RECORDS"]?.trim() !== "0";
505
+ const auto = env["KANKAKU_SYNC_AUTO"]?.trim() !== "0";
506
+
507
+ // 0 is a valid, explicit "disable throttling" value, distinct from an
508
+ // unset or garbage one (which falls back to the default) — unlike
509
+ // windowHours above, which treats 0 as invalid.
510
+ const minIntervalRaw = env["KANKAKU_SYNC_MIN_INTERVAL_MINUTES"]?.trim();
511
+ const parsedMinInterval = minIntervalRaw ? Number(minIntervalRaw) : NaN;
512
+ const minIntervalMinutes = Number.isFinite(parsedMinInterval) && parsedMinInterval >= 0 ? parsedMinInterval : DEFAULT_SYNC_MIN_INTERVAL_MINUTES;
513
+
514
+ return { promptMode, windowHours, syncRecords, auto, minIntervalMinutes };
515
+ }
516
+
517
+ export type HubUrlValidation = { ok: true } | { ok: false; reason: string };
518
+
519
+ /**
520
+ * A hub URL must be HTTPS, unless it points at localhost/127.0.0.1/::1 (a
521
+ * local PocketBase instance for development). Also rejects a URL that does
522
+ * not parse at all.
523
+ */
524
+ export function validateHubUrl(url: string): HubUrlValidation {
525
+ let parsed: URL;
526
+ try {
527
+ parsed = new URL(url);
528
+ } catch {
529
+ return { ok: false, reason: `kankaku: invalid hub URL: ${url}` };
530
+ }
531
+
532
+ if (parsed.protocol === "https:") return { ok: true };
533
+ if (parsed.protocol === "http:" && isLocalHost(parsed.hostname)) return { ok: true };
534
+
535
+ return { ok: false, reason: `kankaku: refusing non-HTTPS hub URL (only localhost is allowed over plain HTTP): ${url}` };
536
+ }