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