kankaku 0.4.6 → 0.5.1

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 (39) hide show
  1. package/README.md +971 -13
  2. package/package.json +4 -2
  3. package/src/adapters/agent-info.ts +86 -0
  4. package/src/adapters/ancestry.ts +260 -0
  5. package/src/adapters/cached-catalog.ts +131 -0
  6. package/src/adapters/file-modes.ts +35 -0
  7. package/src/adapters/hub-credentials.ts +95 -0
  8. package/src/adapters/jsonl-work-log.ts +4 -1
  9. package/src/adapters/kankaku-command.ts +510 -7
  10. package/src/adapters/kankaku-dir.ts +92 -0
  11. package/src/adapters/machine-process-registry.ts +256 -0
  12. package/src/adapters/pi-tracker.ts +408 -14
  13. package/src/adapters/pocketbase-catalog.ts +60 -0
  14. package/src/adapters/pocketbase-client.ts +197 -0
  15. package/src/adapters/pocketbase-sink.ts +224 -0
  16. package/src/adapters/process-identity-memo.ts +102 -0
  17. package/src/adapters/process-identity.ts +162 -0
  18. package/src/adapters/project-config.ts +73 -1
  19. package/src/adapters/report.ts +79 -6
  20. package/src/adapters/session-dir.ts +28 -0
  21. package/src/adapters/session-target.ts +324 -0
  22. package/src/adapters/subagent-startup.ts +66 -0
  23. package/src/adapters/sync-runner.ts +301 -0
  24. package/src/adapters/sync-state-store.ts +227 -0
  25. package/src/adapters/target-picker.ts +82 -0
  26. package/src/config.ts +444 -6
  27. package/src/domain/ancestry-match.ts +84 -0
  28. package/src/domain/hub-entry.ts +339 -0
  29. package/src/domain/registry-health.ts +87 -0
  30. package/src/domain/subagent-profile.ts +495 -0
  31. package/src/domain/sync-plan.ts +251 -0
  32. package/src/domain/task-view.ts +307 -23
  33. package/src/domain/work-record.ts +157 -1
  34. package/src/domain/work-target.ts +185 -0
  35. package/src/domain/work-tracker.ts +248 -51
  36. package/src/extension.ts +303 -6
  37. package/src/ports/catalog.ts +31 -0
  38. package/src/ports/process-registry.ts +75 -0
  39. package/src/ports/work-sink.ts +35 -0
@@ -0,0 +1,162 @@
1
+ import { detectRole, readRoleOverride, stripRoleOverride } from "../config.ts";
2
+ import type { RoleOverride } from "../config.ts";
3
+ import { resolveOrchestratorRef } from "../domain/ancestry-match.ts";
4
+ import type { OrchestratorRef } from "../domain/work-record.ts";
5
+ import { GENTLE_PI_PROFILE, builtinChildMarkers, configuredChildMarkers, matchesAnyMarker, resolveChildProfile } from "../domain/subagent-profile.ts";
6
+ import type { SubagentProfile } from "../domain/subagent-profile.ts";
7
+ import type { ProcessRegistry, RegistryEntry } from "../ports/process-registry.ts";
8
+ import { resolveSubagentStartup } from "./subagent-startup.ts";
9
+ import type { AncestrySnapshot } from "./ancestry.ts";
10
+
11
+ /** `resolveProcessIdentity`'s default `subagentProfiles` when a caller does not pass its own active profile set: exactly gentle-pi's own marker, matching this module's behaviour before SUBAGENT-REQ-001/002/003 existed — so a caller that has not opted into the wider profile set (e.g. an existing test) keeps behaving byte-for-byte the same. */
12
+ const DEFAULT_SUBAGENT_PROFILES: readonly SubagentProfile[] = [GENTLE_PI_PROFILE];
13
+
14
+ /**
15
+ * Everything about THIS OS process's identity that `role` (and everything
16
+ * derived from it — `orchestratorRef`, F1's write-routing target) depends
17
+ * on. Computed once by {@link resolveProcessIdentity}; see
18
+ * `process-identity-memo.ts` for why a caller must never simply call this
19
+ * again on a same-process factory re-invocation (G1).
20
+ */
21
+ export interface ProcessIdentity {
22
+ role: "orchestrator" | "subagent";
23
+ /** `KANKAKU_ROLE`, as read BEFORE this call strips it from `deps.env` — never re-derivable afterwards. */
24
+ roleOverride: RoleOverride | undefined;
25
+ /** Whether `GENTLE_PI_AGENTS_CHILD=1` was present (never stripped, so re-reading `env` later would still agree — kept here anyway so every process-level fact lives in one place). */
26
+ childMarkerPresent: boolean;
27
+ /** See `config.ts#RoleDetection.overrideIgnoredInteractive`. */
28
+ overrideIgnoredInteractive: true | undefined;
29
+ /** C2: see `config.ts#RoleDetection.configuredMarkerIgnoredInteractive`. */
30
+ configuredMarkerIgnoredInteractive: true | undefined;
31
+ /** Whether a live, identity-verified tracked ancestor was found via the machine-wide registry (F2/F3's `hasTrackedAncestor`). */
32
+ hasTrackedAncestor: boolean;
33
+ /** The nearest verified tracked ancestor's own registry entry, if any. */
34
+ ancestorEntry: RegistryEntry | undefined;
35
+ /** F4: the real top-level orchestrator's ref, resolved through a possible subagent-of-subagent chain. `undefined` for an `orchestrator`-role process. */
36
+ orchestratorRef: OrchestratorRef | undefined;
37
+ /** F5: this process's own OS start-time identity, derived without a subprocess spawn. */
38
+ ownProcessStartId: number;
39
+ /** Live start-identity lookup from the same ancestry snapshot `resolveSubagentStartup` took (or a fail-safe always-unknown function when it never took one — F5). */
40
+ liveStartId: (pid: number) => number | undefined;
41
+ /**
42
+ * SUBAGENT-REQ-005/017: the {@link SubagentProfile} `id` whose child-env
43
+ * marker(s) confirmed this process's `role: "subagent"` classification,
44
+ * when exactly one profile's marker matched. `undefined` when no known
45
+ * marker matched (role, if `subagent`, then came from ancestry alone) or
46
+ * when 2+ matched at once (never guessed). Always `undefined` for an
47
+ * `orchestrator`-role process.
48
+ */
49
+ profile: string | undefined;
50
+ }
51
+
52
+ export interface ResolveProcessIdentityDeps {
53
+ /** This process's own env. Mutated in place: the override is stripped after being read (R1, layer 2). */
54
+ env: NodeJS.ProcessEnv;
55
+ registry: ProcessRegistry;
56
+ /** This process's own OS parent pid (`process.ppid`). */
57
+ ppid: number;
58
+ /** `Date.now`, injected. */
59
+ now: () => number;
60
+ /** `process.uptime`, injected. */
61
+ uptimeSeconds: () => number;
62
+ /** A cheap, synchronous interactivity proxy (`process.stdout.isTTY`) — see `config.ts#detectRole`'s doc comment. */
63
+ isInteractiveGuess: boolean;
64
+ /** Injectable for tests; forwarded to `resolveSubagentStartup`. */
65
+ snapshotAncestry?: () => AncestrySnapshot;
66
+ /**
67
+ * SUBAGENT-REQ-001/002/003/005: the full active {@link SubagentProfile}
68
+ * set (`config.ts#loadConfig`'s `subagentProfiles`) whose child-env
69
+ * markers confirm a subagent, generalised beyond gentle-pi's own. Defaults
70
+ * to gentle-pi alone — this module's exact pre-6b behaviour — so a caller
71
+ * that has not opted into the wider set (an existing test, or any
72
+ * embedder that has not been updated) sees no change at all.
73
+ */
74
+ subagentProfiles?: readonly SubagentProfile[];
75
+ }
76
+
77
+ /**
78
+ * Compute this OS process's role/ancestry identity exactly once: reads
79
+ * `KANKAKU_ROLE` and the confirmed-child marker, walks the machine-wide
80
+ * registry/ancestor chain only when it could find something (F5), decides
81
+ * `role` (R1's precedence), strips the override from `deps.env` so no
82
+ * child this process spawns ever inherits it (R1, layer 2), and resolves
83
+ * the verified `orchestratorRef` (F4) a subagent routes its writes to
84
+ * (F1's write-routing target, alongside this process's own resolved dir —
85
+ * see `extension.ts`).
86
+ *
87
+ * Mirrors what used to be inlined directly in `extension.ts` (and still is,
88
+ * independently, in `scripts/e2e-cross-worktree-real-processes.ts`'s
89
+ * `runStartup`, which exercises this exact sequence against real OS
90
+ * processes) — pulled out into its own function so
91
+ * `process-identity-memo.ts` (G1) can freeze its result across a
92
+ * same-process factory re-invocation (`/new`/`/resume`/`/fork`/`/reload`)
93
+ * without `extension.ts` duplicating this sequence, and so this exact
94
+ * sequence has one place to be tested directly.
95
+ */
96
+ export function resolveProcessIdentity(deps: ResolveProcessIdentityDeps): ProcessIdentity {
97
+ const startup = resolveSubagentStartup({
98
+ registry: deps.registry,
99
+ ppid: deps.ppid,
100
+ now: deps.now,
101
+ uptimeSeconds: deps.uptimeSeconds,
102
+ ...(deps.snapshotAncestry !== undefined ? { snapshotAncestry: deps.snapshotAncestry } : {}),
103
+ });
104
+ const hasTrackedAncestor = startup.ancestorEntry !== undefined;
105
+
106
+ // SUBAGENT-REQ-001/002/003/005: the full active profile set's markers,
107
+ // generalising the single hardcoded GENTLE_PI_AGENTS_CHILD check this
108
+ // module used before — see DEFAULT_SUBAGENT_PROFILES for why an omitted
109
+ // `deps.subagentProfiles` is a complete no-op. C2: split into two tiers —
110
+ // built-in markers always win; the user-configured one never demotes an
111
+ // interactive session (see `config.ts#detectRole`'s doc comment).
112
+ const profiles = deps.subagentProfiles ?? DEFAULT_SUBAGENT_PROFILES;
113
+ const builtinMarkers = builtinChildMarkers(profiles);
114
+ const userConfiguredMarkers = configuredChildMarkers(profiles);
115
+
116
+ // R1 (BLOCKER): read KANKAKU_ROLE, and whether A confirmed BUILT-IN child
117
+ // marker (from any recognised profile) is present, exactly once here —
118
+ // then strip the override from this process's own env so a child this
119
+ // process spawns never inherits it. `childMarkerPresent` deliberately
120
+ // reflects the built-in tier only — it backs the doctor's "the confirmed
121
+ // child marker takes precedence" message (`kankaku-command.ts`), which is
122
+ // only true for this tier (C2).
123
+ const childMarkerPresent = matchesAnyMarker(deps.env, builtinMarkers);
124
+ const roleOverride = readRoleOverride(deps.env);
125
+ // `hasTrackedAncestor` is irrelevant to `role` itself (only to the
126
+ // separately-deferred `roleConfidence`, resolved later by the caller),
127
+ // so `false` is passed here purely to obtain `role`/`overrideIgnoredInteractive` cheaply.
128
+ const detection = detectRole(deps.env, false, deps.isInteractiveGuess, builtinMarkers, userConfiguredMarkers);
129
+ const { role } = detection;
130
+ const overrideIgnoredInteractive = detection.overrideIgnoredInteractive === true ? true : undefined;
131
+ const configuredMarkerIgnoredInteractive = detection.configuredMarkerIgnoredInteractive === true ? true : undefined;
132
+ stripRoleOverride(deps.env);
133
+
134
+ // SUBAGENT-REQ-005/017 + C2 item 3: WHICH profile's marker confirmed this
135
+ // process, if exactly one did (never guessed — see resolveChildProfile) —
136
+ // but only ever attributed when this process actually ended up
137
+ // classified `subagent`. A configured marker present but ignored for
138
+ // interactivity (role stays `orchestrator`) must never carry a
139
+ // `profile`: an orchestrator record with `profile: "configured"` would
140
+ // be a self-contradictory pair no doctor/report reader could make sense
141
+ // of.
142
+ const profile = role === "subagent" ? resolveChildProfile(profiles, deps.env).profile?.id : undefined;
143
+
144
+ // F4: resolves through a subagent-of-subagent chain to the real top-level
145
+ // orchestrator (never a middle hop), carrying that orchestrator's `dir`
146
+ // for F1's write routing.
147
+ const orchestratorRef = role === "subagent" ? resolveOrchestratorRef(startup.ancestorEntry) : undefined;
148
+
149
+ return {
150
+ role,
151
+ roleOverride,
152
+ childMarkerPresent,
153
+ overrideIgnoredInteractive,
154
+ configuredMarkerIgnoredInteractive,
155
+ hasTrackedAncestor,
156
+ ancestorEntry: startup.ancestorEntry,
157
+ orchestratorRef,
158
+ ownProcessStartId: startup.ownProcessStartId,
159
+ liveStartId: startup.liveStartId,
160
+ profile,
161
+ };
162
+ }
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import type { WorkTargetCandidate } from "../domain/work-target.ts";
3
4
  import { resolveKankakuDir } from "./kankaku-dir.ts";
4
5
 
5
6
  const CONFIG_FILE_NAME = "config.json";
@@ -42,3 +43,74 @@ export class LazyProjectClientSource {
42
43
  return readProjectClient(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()));
43
44
  }
44
45
  }
46
+
47
+ /**
48
+ * Read `clientId`/`projectId` from `<dir>/config.json`, the lowest-precedence
49
+ * source in `domain/work-target.ts#resolveWorkTarget`. Tolerates the same
50
+ * failure modes as {@link readProjectClient}. `undefined` when `clientId`
51
+ * is absent or not a string (a `projectId` without a `clientId` is not a
52
+ * valid candidate); a non-string `projectId` is dropped, keeping `clientId`.
53
+ */
54
+ export function readProjectTargetIds(dir: string): WorkTargetCandidate | undefined {
55
+ const filePath = join(dir, CONFIG_FILE_NAME);
56
+ if (!existsSync(filePath)) return undefined;
57
+
58
+ try {
59
+ const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8"));
60
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
61
+ const record = parsed as Record<string, unknown>;
62
+ const clientId = record["clientId"];
63
+ if (typeof clientId !== "string") return undefined;
64
+ const projectId = record["projectId"];
65
+ return typeof projectId === "string" ? { clientId, projectId } : { clientId };
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Merge `clientId`/`projectId` into `<dir>/config.json`, preserving every
73
+ * other existing key (including the legacy `client` label). Writes
74
+ * atomically (tmp + rename), mirroring `file-inflight-store.ts`. A missing
75
+ * or malformed existing file is treated as `{}` rather than failing.
76
+ */
77
+ export function writeProjectTargetIds(dir: string, ids: WorkTargetCandidate): void {
78
+ const filePath = join(dir, CONFIG_FILE_NAME);
79
+ let existing: Record<string, unknown> = {};
80
+ if (existsSync(filePath)) {
81
+ try {
82
+ const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8"));
83
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
84
+ existing = parsed as Record<string, unknown>;
85
+ }
86
+ } catch {
87
+ existing = {};
88
+ }
89
+ }
90
+
91
+ const merged = { ...existing, clientId: ids.clientId, ...(ids.projectId !== undefined ? { projectId: ids.projectId } : {}) };
92
+
93
+ mkdirSync(dir, { recursive: true });
94
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
95
+ writeFileSync(tmp, JSON.stringify(merged, null, 2));
96
+ renameSync(tmp, filePath);
97
+ }
98
+
99
+ /** Reads target ids from a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
100
+ export class LazyProjectTargetSource {
101
+ private readonly dirOrRelative: string;
102
+ private readonly fallbackCwd: () => string;
103
+
104
+ constructor(dirOrRelative: string, fallbackCwd: () => string = () => process.cwd()) {
105
+ this.dirOrRelative = dirOrRelative;
106
+ this.fallbackCwd = fallbackCwd;
107
+ }
108
+
109
+ read(): WorkTargetCandidate | undefined {
110
+ return readProjectTargetIds(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()));
111
+ }
112
+
113
+ write(ids: WorkTargetCandidate): void {
114
+ writeProjectTargetIds(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()), ids);
115
+ }
116
+ }
@@ -1,4 +1,4 @@
1
- import { buildTasks } from "../domain/task-view.ts";
1
+ import { buildTasks, uncertainRecords } from "../domain/task-view.ts";
2
2
  import type { SessionView, TaskView } from "../domain/task-view.ts";
3
3
  import { localDay } from "../domain/day.ts";
4
4
  import { finiteOrZero } from "../domain/work-record.ts";
@@ -42,10 +42,16 @@ function emptyTotals(): RoleTotals {
42
42
  return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, segments: {} };
43
43
  }
44
44
 
45
- /** Add per-tag milliseconds from `segments` (missing on older records) into `into`. */
46
- function addSegments(into: Record<string, number>, segments: Record<string, number> | undefined): void {
45
+ /**
46
+ * Add per-tag milliseconds from `segments` (missing on older records) into
47
+ * `into`, a `Map` rather than a plain object so a tag from a hand-edited
48
+ * worklog line named `__proto__` or `constructor` accumulates as a normal
49
+ * entry instead of silently reading (and arithmetically corrupting) an
50
+ * inherited `Object.prototype` value.
51
+ */
52
+ function addSegments(into: Map<string, number>, segments: Record<string, number> | undefined): void {
47
53
  for (const [tag, ms] of Object.entries(segments ?? {})) {
48
- into[tag] = (into[tag] ?? 0) + ms;
54
+ into.set(tag, (into.get(tag) ?? 0) + ms);
49
55
  }
50
56
  }
51
57
 
@@ -63,6 +69,10 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
63
69
  subagent: emptyTotals(),
64
70
  tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, segments: {} },
65
71
  };
72
+ // Accumulated in Maps (see `addSegments`) and only converted to the
73
+ // returned plain objects at the very end, via `Object.fromEntries`.
74
+ const segmentsByRole: Record<WorkRole, Map<string, number>> = { orchestrator: new Map(), subagent: new Map() };
75
+ const taskSegments = new Map<string, number>();
66
76
 
67
77
  for (const record of records) {
68
78
  if (targetDay !== undefined && localDay(record.startedAt) !== targetDay) continue;
@@ -72,7 +82,7 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
72
82
  totals.wallMs += record.wallMs;
73
83
  totals.count += 1;
74
84
  totals.cost += finiteOrZero(record.usage.cost);
75
- addSegments(totals.segments, record.segments);
85
+ addSegments(segmentsByRole[record.role], record.segments);
76
86
  }
77
87
 
78
88
  const tasks = buildTasks(records).filter((task) => targetDay === undefined || localDay(task.startedAt) === targetDay);
@@ -81,12 +91,27 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
81
91
  summary.tasks.wallMs += task.wallMs;
82
92
  summary.tasks.workMs += task.workMs;
83
93
  summary.tasks.cost += task.usage.cost;
84
- addSegments(summary.tasks.segments, task.segments);
94
+ addSegments(taskSegments, task.segments);
85
95
  }
86
96
 
97
+ summary.orchestrator.segments = Object.fromEntries(segmentsByRole.orchestrator);
98
+ summary.subagent.segments = Object.fromEntries(segmentsByRole.subagent);
99
+ summary.tasks.segments = Object.fromEntries(taskSegments);
100
+
87
101
  return summary;
88
102
  }
89
103
 
104
+ /**
105
+ * Count of `uncertain` records (ADR 0022) within the same day scope
106
+ * `summarize` uses, so the `/kankaku` report can surface a one-line hint
107
+ * when some records are silently excluded from the task count
108
+ * (SUBAGENT-REQ-017): an undercount must never be silent.
109
+ */
110
+ export function countUncertain(records: WorkRecord[], options: SummarizeOptions): number {
111
+ const targetDay = options.all ? undefined : (options.day ?? localDay(new Date().toISOString()));
112
+ return uncertainRecords(records).filter((record) => targetDay === undefined || localDay(record.startedAt) === targetDay).length;
113
+ }
114
+
90
115
  function formatMinutes(ms: number): string {
91
116
  const totalSeconds = Math.round(ms / 1000);
92
117
  const minutes = Math.floor(totalSeconds / 60);
@@ -187,6 +212,54 @@ export function summarizeByClient(tasks: TaskView[]): Map<string, ClientTotals>
187
212
  return totals;
188
213
  }
189
214
 
215
+ export interface ProjectTotals {
216
+ /** Display name: the project's `projectName`, or `"(no project)"` for the ungrouped bucket. */
217
+ name: string;
218
+ wallMs: number;
219
+ waitingMs: number;
220
+ workMs: number;
221
+ /** Estimated cost in USD, summed across this project's tasks. */
222
+ cost: number;
223
+ count: number;
224
+ }
225
+
226
+ /** Key (and display name) under which tasks without a resolved project are grouped. */
227
+ const NO_PROJECT = "(no project)";
228
+
229
+ /**
230
+ * Aggregate tasks by hub project (see `domain/work-target.ts`), keyed by
231
+ * `projectId` (so two projects that happen to share a display name are
232
+ * never merged) with the name denormalised alongside for display. Tasks
233
+ * without a `projectId` are grouped under `"(no project)"`.
234
+ */
235
+ export function summarizeByProject(tasks: TaskView[]): Map<string, ProjectTotals> {
236
+ const totals = new Map<string, ProjectTotals>();
237
+ for (const task of tasks) {
238
+ const key = task.projectId ?? NO_PROJECT;
239
+ const name = task.projectId !== undefined ? (task.projectName ?? task.projectId) : NO_PROJECT;
240
+ const entry = totals.get(key) ?? { name, wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, count: 0 };
241
+ entry.wallMs += task.wallMs;
242
+ entry.waitingMs += task.waitingMs;
243
+ entry.workMs += task.workMs;
244
+ entry.cost += finiteOrZero(task.usage.cost);
245
+ entry.count += 1;
246
+ totals.set(key, entry);
247
+ }
248
+ return totals;
249
+ }
250
+
251
+ /** Render one line per project, sorted alphabetically by display name, with work/waiting/wall time, cost, and task count. */
252
+ export function formatProjects(totals: Map<string, ProjectTotals>): string {
253
+ if (totals.size === 0) return "no projects";
254
+ return Array.from(totals.values())
255
+ .sort((a, b) => a.name.localeCompare(b.name))
256
+ .map(
257
+ (t) =>
258
+ `${t.name} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)} tasks ${t.count}`,
259
+ )
260
+ .join("\n");
261
+ }
262
+
190
263
  /** Render one line per client, sorted alphabetically, with work/waiting/wall time, cost, and task count. */
191
264
  export function formatClients(totals: Map<string, ClientTotals>): string {
192
265
  if (totals.size === 0) return "no clients";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `SessionManager#usesDefaultSessionDir()`/`#getSessionDir()` are real
3
+ * methods on pi's session manager (mirrors what pi's own
4
+ * `formatResumeCommand` does to decide whether to print `--session-dir`),
5
+ * but `usesDefaultSessionDir` is not part of the `ReadonlySessionManager`
6
+ * type `ctx.sessionManager` is typed as — so this is a guarded duck-typed
7
+ * call, not a typed one: an older pi version (or any future shape change)
8
+ * that lacks the method degrades to "no sessionDir," never a crash.
9
+ * Returns the session dir only when it is genuinely non-default, exactly
10
+ * the condition under which pi itself would print `--session-dir`. Shared
11
+ * by `pi-tracker.ts` (record metadata) and `kankaku-command.ts` (the
12
+ * `/kankaku doctor` display) — kept as its own module so neither adapter
13
+ * has to import the other.
14
+ */
15
+ export function readNonDefaultSessionDir(sessionManager: unknown): string | undefined {
16
+ const candidate = sessionManager as { usesDefaultSessionDir?: () => boolean; getSessionDir?: () => string };
17
+ if (typeof candidate.usesDefaultSessionDir !== "function" || typeof candidate.getSessionDir !== "function") return undefined;
18
+ try {
19
+ if (candidate.usesDefaultSessionDir()) return undefined;
20
+ const dir = candidate.getSessionDir();
21
+ // An unpersisted session (e.g. `--no-session`) can report
22
+ // usesDefaultSessionDir() === false with an empty getSessionDir() — not
23
+ // a real custom directory, so there is nothing meaningful to carry.
24
+ return dir ? dir : undefined;
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }