kankaku 0.4.6 → 0.5.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 (39) hide show
  1. package/README.md +952 -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 +262 -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
@@ -3,10 +3,31 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
3
3
  import { Box, Text } from "@earendil-works/pi-tui";
4
4
  import { isValidClient } from "../domain/client-label.ts";
5
5
  import { exportRows, toCsv, toJson } from "../domain/export.ts";
6
- import { buildSessions, buildTasks } from "../domain/task-view.ts";
6
+ import { buildSessions, buildTasks, detectSameProcessOverlaps, orphanSubagents, uncertainRecords } from "../domain/task-view.ts";
7
+ import { formatWorkTargetLabel } from "../domain/work-target.ts";
8
+ import { findAmbiguousToolNames } from "../domain/subagent-profile.ts";
9
+ import type { SubagentProfile } from "../domain/subagent-profile.ts";
10
+ import type { RejectedChildEnvMarker } from "../config.ts";
11
+ import type { SyncState } from "../domain/sync-plan.ts";
12
+ import type { RegistryClassification } from "../domain/registry-health.ts";
13
+ import type { Catalog } from "../ports/catalog.ts";
7
14
  import type { WorkLog } from "../ports/work-log.ts";
8
- import { formatClients, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient } from "./report.ts";
15
+ import type { SyncSummary, SyncTrigger } from "./sync-runner.ts";
16
+ import {
17
+ countUncertain,
18
+ formatClients,
19
+ formatProjects,
20
+ formatReport,
21
+ formatSessions,
22
+ formatTasks,
23
+ localDay,
24
+ summarize,
25
+ summarizeByClient,
26
+ summarizeByProject,
27
+ } from "./report.ts";
9
28
  import type { SessionClient } from "./session-client.ts";
29
+ import { readNonDefaultSessionDir } from "./session-dir.ts";
30
+ import type { SessionTarget } from "./session-target.ts";
10
31
 
11
32
  const REPORT_ENTRY_TYPE = "kankaku-report";
12
33
 
@@ -23,7 +44,30 @@ export function notifyError(ctx: ExtensionContext, error: unknown): void {
23
44
  ctx.ui.notify(`kankaku: ${message}`, "error");
24
45
  }
25
46
 
26
- const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export"];
47
+ const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export", "doctor"];
48
+ /** Only offered when the hub is configured, so completions are unchanged for users without one. */
49
+ const HUB_COMMAND_TOKENS = ["target", "projects", "catalog", "sync", "backfill"];
50
+ const TARGET_TOKENS = ["pick", "clear"];
51
+ const CATALOG_TOKENS = ["refresh"];
52
+ const SYNC_TOKENS = ["all", "status"];
53
+
54
+ /** Drives `/kankaku sync [all|status]` and `/kankaku backfill`. Present only when the hub is configured. */
55
+ export interface SyncCommandDeps {
56
+ /**
57
+ * Run one sync pass; `full: true` re-evaluates every task (`/kankaku
58
+ * sync all`, `/kankaku backfill`). `trigger`, left unset here (a manual
59
+ * command), marks the automatic `session_start`/`agent_settled` path
60
+ * (`pi-tracker.ts`) so its version short-circuit and throttle never
61
+ * apply to a manual sync. Never throws.
62
+ */
63
+ run: (options?: { full?: boolean; trigger?: SyncTrigger }) => Promise<SyncSummary>;
64
+ /**
65
+ * `/kankaku sync status`: the persisted state, a locally-computed pending
66
+ * count, and (R3) how many tasks changed since their last sync but fall
67
+ * outside this run's revisit window — needs `sync all`. No network.
68
+ */
69
+ status: () => { state: SyncState | undefined; pending: number; staleOutsideWindow: number };
70
+ }
27
71
 
28
72
  export interface KankakuCommandDeps {
29
73
  log: WorkLog;
@@ -36,6 +80,100 @@ export interface KankakuCommandDeps {
36
80
  * configured.
37
81
  */
38
82
  writeExportFile?: (name: string, content: string) => string;
83
+ /**
84
+ * Present only when the hub (PocketBase) is configured. Drives `/kankaku
85
+ * target [pick|clear]` and makes `/kankaku client <name>` validate
86
+ * against the catalog instead of accepting free text.
87
+ */
88
+ sessionTarget?: SessionTarget;
89
+ /** Present only when the hub is configured. Drives `/kankaku catalog refresh` and the hub-aware `/kankaku client <name>`. */
90
+ catalog?: Catalog;
91
+ /** Present only when the hub is configured. Drives `/kankaku sync [all|status]` and `/kankaku backfill`. */
92
+ sync?: SyncCommandDeps;
93
+ /**
94
+ * Read-only machine-wide process-registry health snapshot (see
95
+ * `adapters/machine-process-registry.ts#health`), for `/kankaku doctor`
96
+ * (SUBAGENT-REQ-017): how many entries would be kept vs discarded, and
97
+ * why. Absent entirely when the registry is unavailable for some reason
98
+ * kankaku itself could not construct (never expected in practice, since
99
+ * `MachineProcessRegistry` always degrades gracefully on its own) —
100
+ * doctor then simply omits that section rather than guessing.
101
+ */
102
+ registryHealth?: () => RegistryClassification;
103
+ /**
104
+ * Whether the OS ancestor-chain mechanism itself is actually usable right
105
+ * now (F2): `false` on a platform with no supported mechanism (Windows)
106
+ * *or* when the mechanism is available but a fresh attempt still fails
107
+ * (`ps`/`/proc` missing, timing out, or producing unreadable output) —
108
+ * both collapse to the same "could not check" state, distinct from
109
+ * "checked, no tracked ancestor found". Never folded into
110
+ * `WorkRecord.roleConfidence`: an unprovable ancestor never demotes a
111
+ * process to `uncertain` (see `config.ts#detectRole`'s doc comment) — this
112
+ * is purely a visibility signal for the doctor's own report. Falls back
113
+ * to a bare `process.platform !== "win32"` check when not provided
114
+ * (back-compat with a caller that has not wired the real, ps/proc-aware
115
+ * check yet).
116
+ */
117
+ ancestorDetectionAvailable?: () => boolean;
118
+ /**
119
+ * `KANKAKU_ROLE`, when it held a recognised value for this process (F3's
120
+ * explicit escape hatch) — reported by doctor as the deciding signal for
121
+ * this process's role, UNLESS `childMarkerPresent` or
122
+ * `overrideIgnoredInteractive` below says otherwise (R1): the override no
123
+ * longer beats every other detection signal unconditionally.
124
+ */
125
+ roleOverride?: "orchestrator" | "subagent";
126
+ /**
127
+ * Whether `GENTLE_PI_AGENTS_CHILD=1` (the confirmed child marker) was
128
+ * also present on this process (R1) — when both it and `roleOverride`
129
+ * are set, the marker always wins (`config.ts#detectRole`'s precedence),
130
+ * so doctor flags the contradiction with the resolved outcome instead of
131
+ * claiming the override decided anything.
132
+ */
133
+ childMarkerPresent?: boolean;
134
+ /**
135
+ * Set when `KANKAKU_ROLE=subagent` was present, with no confirmed child
136
+ * marker, but was ignored because this process looked interactive (R1) —
137
+ * doctor reports the resolved outcome (orchestrator) instead of claiming
138
+ * the override decided this process's role.
139
+ */
140
+ overrideIgnoredInteractive?: boolean;
141
+ /**
142
+ * C2 (CRITICAL fix): set when a USER-CONFIGURED child-env marker
143
+ * (`KANKAKU_SUBAGENT_CHILD_ENV`) matched, but was ignored because this
144
+ * process looked interactive — a configured marker, unlike a built-in
145
+ * one, never demotes an interactive session. Doctor escalates the
146
+ * wording when `hasTrackedAncestor` is also `false` (C2 item 3's
147
+ * self-check: the strongest signal the marker is genuinely ambient).
148
+ */
149
+ configuredMarkerIgnoredInteractive?: boolean;
150
+ /** C2 item 3: whether a live tracked ancestor was found for this process — see `configuredMarkerIgnoredInteractive` above. */
151
+ hasTrackedAncestor?: boolean;
152
+ /**
153
+ * C2 (CRITICAL fix, item 1): every `KANKAKU_SUBAGENT_CHILD_ENV` marker
154
+ * `config.ts#validateSubagentChildEnvMarkers` rejected as looking
155
+ * pi/shell/OS/npm-owned rather than genuinely child-only.
156
+ */
157
+ rejectedSubagentChildEnvMarkers?: RejectedChildEnvMarker[];
158
+ /**
159
+ * Set only when this process is itself a subagent whose work log/inflight
160
+ * checkpoints were routed to its orchestrator's kankaku directory (F1, ADR
161
+ * 0023's rewrite): `usedFallback: true` means the orchestrator's
162
+ * directory could not be written to (gone, or no permission) and this
163
+ * process fell back to its own local directory instead — surfaced here so
164
+ * a human can notice and go reunite the record manually, since the
165
+ * append-only log can never be rewritten to fix it after the fact.
166
+ */
167
+ workLogRouting?: { usedFallback: boolean; parentDir: string };
168
+ /**
169
+ * The full active {@link SubagentProfile} set (`config.ts#loadConfig`'s
170
+ * `subagentProfiles`), for `/kankaku doctor` (SUBAGENT-REQ-001/002/003/005/017):
171
+ * which profiles are active, any configured tool names/child-env markers,
172
+ * which profile matched each subagent record, and any tool-name ambiguity
173
+ * among the active set. Omitted entirely (no profile section at all) when
174
+ * a caller has not wired this — back-compat with an older embedder.
175
+ */
176
+ subagentProfiles?: SubagentProfile[];
39
177
  }
40
178
 
41
179
  export interface KankakuCommand {
@@ -101,7 +239,15 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
101
239
  ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
102
240
  }
103
241
 
104
- /** Handle `/kankaku client [<name> | --clear]`; `rest` excludes the leading `client` token. */
242
+ /**
243
+ * Handle `/kankaku client [<name> | --clear]`; `rest` excludes the
244
+ * leading `client` token. When the hub is configured, setting a name
245
+ * (not `--clear` or empty) is the legacy compatibility path: it
246
+ * validates against the catalog (case-insensitive exact match of a
247
+ * client `code` or `name`) and sets the session hub target with no
248
+ * project, instead of the free-text legacy client. `--clear` and the
249
+ * no-argument report stay on the legacy client for both cases.
250
+ */
105
251
  function handleClientCommand(rest: string[], ctx: ExtensionContext): void {
106
252
  if (rest.length === 1 && rest[0] === "--clear") {
107
253
  sessionClient.set(pi, undefined);
@@ -119,6 +265,25 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
119
265
  }
120
266
 
121
267
  const name = rest.join(" ");
268
+
269
+ if (deps.sessionTarget) {
270
+ const clients = (deps.catalog?.read()?.clients ?? []).filter((client) => client.active && !client.unassigned);
271
+ const lowerName = name.toLowerCase();
272
+ const match = clients.find((client) => client.code.toLowerCase() === lowerName || client.name.toLowerCase() === lowerName);
273
+ if (!match) {
274
+ const validCodes = clients
275
+ .map((client) => client.code)
276
+ .sort()
277
+ .join(", ");
278
+ notifyError(ctx, new Error(`unknown client: ${name}${validCodes ? ` (valid: ${validCodes})` : ""}`));
279
+ return;
280
+ }
281
+ deps.sessionTarget.setExplicit(pi, { clientId: match.id });
282
+ deps.refreshIdleStatus(ctx);
283
+ showReport(ctx, { title: "client", lines: [`client set to ${match.name} (${match.code})`] });
284
+ return;
285
+ }
286
+
122
287
  if (!isValidClient(name)) {
123
288
  notifyError(ctx, new Error(`invalid client name: ${name}`));
124
289
  return;
@@ -128,6 +293,282 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
128
293
  showReport(ctx, { title: "client", lines: [`client set to ${name}`] });
129
294
  }
130
295
 
296
+ /** Handle `/kankaku target [pick|clear]`; `rest` excludes the leading `target` token. */
297
+ async function handleTargetCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
298
+ const sessionTarget = deps.sessionTarget;
299
+ if (!sessionTarget) {
300
+ notifyError(ctx, new Error("hub is not configured"));
301
+ return;
302
+ }
303
+
304
+ if (rest.length === 1 && rest[0] === "clear") {
305
+ sessionTarget.clear(pi);
306
+ deps.refreshIdleStatus(ctx);
307
+ showReport(ctx, { title: "target", lines: ["target cleared for this session"] });
308
+ return;
309
+ }
310
+
311
+ if (rest.length === 1 && rest[0] === "pick") {
312
+ await sessionTarget.pick(pi, ctx);
313
+ deps.refreshIdleStatus(ctx);
314
+ const target = sessionTarget.effectiveTarget();
315
+ const line = target ? `target set to ${formatWorkTargetLabel(target)}` : "target skipped";
316
+ showReport(ctx, { title: "target", lines: [line] });
317
+ return;
318
+ }
319
+
320
+ if (rest.length === 0) {
321
+ const target = sessionTarget.effectiveTarget();
322
+ const source = sessionTarget.effectiveSource();
323
+ const line = target !== undefined ? `target: ${formatWorkTargetLabel(target)} (from ${source})` : "target: none";
324
+ showReport(ctx, { title: "target", lines: [line] });
325
+ return;
326
+ }
327
+
328
+ notifyError(ctx, new Error(`unknown target subcommand: ${rest.join(" ")}`));
329
+ }
330
+
331
+ /** Handle `/kankaku catalog refresh`; `rest` excludes the leading `catalog` token. */
332
+ async function handleCatalogCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
333
+ const catalog = deps.catalog;
334
+ if (!catalog) {
335
+ notifyError(ctx, new Error("hub is not configured"));
336
+ return;
337
+ }
338
+
339
+ if (rest.length === 1 && rest[0] === "refresh") {
340
+ const snapshot = await catalog.refresh();
341
+ if (!snapshot) {
342
+ notifyError(ctx, new Error("hub unreachable; catalog not refreshed"));
343
+ return;
344
+ }
345
+ showReport(ctx, {
346
+ title: "catalog",
347
+ lines: [`refreshed: ${snapshot.clients.length} client(s), ${snapshot.projects.length} project(s)`],
348
+ });
349
+ return;
350
+ }
351
+
352
+ notifyError(ctx, new Error(`unknown catalog subcommand: ${rest.join(" ")}`));
353
+ }
354
+
355
+ /** Render a {@link SyncSummary} as report lines: counts, any stop reason, the new watermark, and the unassigned breakdown. */
356
+ function formatSyncSummary(summary: SyncSummary): string[] {
357
+ const lines = [`uploaded ${summary.uploaded}, updated ${summary.updated}, skipped ${summary.skipped}, failed ${summary.failed.length}`];
358
+
359
+ if (summary.locked) lines.push("another sync is already in progress; nothing was attempted");
360
+ if (summary.error) lines.push(`stopped early: ${summary.error}`);
361
+ if (summary.syncedThrough) lines.push(`synced through ${summary.syncedThrough}`);
362
+
363
+ const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
364
+ if (unassignedEntries.length > 0) {
365
+ lines.push("unassigned (Sin determinar):");
366
+ for (const [label, count] of unassignedEntries) lines.push(` ${label}: ${count}`);
367
+ }
368
+
369
+ if (summary.failed.length > 0) {
370
+ lines.push("failed:");
371
+ for (const entry of summary.failed) lines.push(` ${entry.id}: ${entry.reason}`);
372
+ }
373
+
374
+ return lines;
375
+ }
376
+
377
+ /** Handle `/kankaku sync [all|status]`; `rest` excludes the leading `sync` token. */
378
+ async function handleSyncCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
379
+ const sync = deps.sync;
380
+ if (!sync) {
381
+ notifyError(ctx, new Error("hub is not configured"));
382
+ return;
383
+ }
384
+
385
+ if (rest.length === 1 && rest[0] === "status") {
386
+ const { state, pending, staleOutsideWindow } = sync.status();
387
+ const lines = [state?.syncedThrough ? `synced through ${state.syncedThrough}` : "never synced", `pending: ${pending}`];
388
+ if (staleOutsideWindow > 0) {
389
+ lines.push(`${staleOutsideWindow} task(s) never synced fall outside the sync window — run '/kankaku sync all' to upload them`);
390
+ }
391
+ if (state?.lastError) lines.push(`last error: ${state.lastError.message} (at ${state.lastError.at})`);
392
+ showReport(ctx, { title: "sync status", lines });
393
+ return;
394
+ }
395
+
396
+ if (rest.length > 0 && !(rest.length === 1 && rest[0] === "all")) {
397
+ notifyError(ctx, new Error(`unknown sync subcommand: ${rest.join(" ")}`));
398
+ return;
399
+ }
400
+
401
+ const full = rest[0] === "all";
402
+ const summary = await sync.run({ full });
403
+ showReport(ctx, { title: full ? "sync (all)" : "sync", lines: formatSyncSummary(summary) });
404
+ }
405
+
406
+ /** Handle `/kankaku backfill`: a full sync, reported as the "Sin determinar" breakdown that needs reassigning in the web. */
407
+ async function handleBackfillCommand(ctx: ExtensionContext): Promise<void> {
408
+ const sync = deps.sync;
409
+ if (!sync) {
410
+ notifyError(ctx, new Error("hub is not configured"));
411
+ return;
412
+ }
413
+
414
+ const summary = await sync.run({ full: true });
415
+ const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
416
+
417
+ const lines =
418
+ unassignedEntries.length > 0
419
+ ? [
420
+ ...unassignedEntries.map(([label, count]) => `${label}: ${count} task(s) -> Sin determinar`),
421
+ "reassign these in the hub web app's unassigned queue",
422
+ ]
423
+ : ["no unassigned tasks"];
424
+
425
+ if (summary.error) lines.push(`stopped early: ${summary.error}`);
426
+ showReport(ctx, { title: "backfill", lines });
427
+ }
428
+
429
+ /**
430
+ * Handle `/kankaku doctor`: a no-network diagnostic (SUBAGENT-REQ-017)
431
+ * reporting orphan/uncertain record counts (ADR 0022) with why, and
432
+ * whether ancestor-chain detection is available on this platform, so
433
+ * silent undercount stays visible (see README "Subagents").
434
+ */
435
+ function handleDoctorCommand(ctx: ExtensionContext): void {
436
+ const records = log.readAll();
437
+ const orphans = orphanSubagents(records);
438
+ const uncertain = uncertainRecords(records);
439
+ const ancestorDetectionAvailable = deps.ancestorDetectionAvailable ? deps.ancestorDetectionAvailable() : process.platform !== "win32";
440
+
441
+ const lines = [
442
+ `ancestor-chain detection: ${ancestorDetectionAvailable ? "available" : "unavailable"}`,
443
+ `orphan subagent record(s): ${orphans.length}` +
444
+ (orphans.length > 0 ? " — recognised as someone's child (an env marker matched), but no orchestrator could be matched" : ""),
445
+ `uncertain record(s): ${uncertain.length}` +
446
+ (uncertain.length > 0
447
+ ? " — no recognised child-env-marker, but a live tracked ancestor process was found; not counted as a new task, not synced"
448
+ : ""),
449
+ ];
450
+
451
+ if (!ancestorDetectionAvailable) {
452
+ lines.push(
453
+ "on this platform/environment, ancestor-chain detection could not run (Windows, or a failed/unavailable ps/proc read): " +
454
+ "an unmarked subagent system may be counted twice (a genuine child with no recognised marker looks like a fresh top-level " +
455
+ "session). Mark it explicitly with KANKAKU_ROLE=subagent in the child's environment (or KANKAKU_ROLE=orchestrator to force " +
456
+ "the other way).",
457
+ );
458
+ }
459
+
460
+ if (deps.roleOverride) {
461
+ if (deps.childMarkerPresent) {
462
+ // R1: both signals present — the confirmed child marker always
463
+ // wins (config.ts#detectRole), so the override did not decide
464
+ // anything, whatever it said.
465
+ lines.push(
466
+ `role override: KANKAKU_ROLE=${deps.roleOverride} was present, but the confirmed child marker (GENTLE_PI_AGENTS_CHILD=1) takes precedence — resolved role: subagent`,
467
+ );
468
+ } else if (deps.overrideIgnoredInteractive) {
469
+ lines.push(
470
+ "role override: KANKAKU_ROLE=subagent was ignored for this interactive session (likely a leaked shell export) — resolved role: orchestrator",
471
+ );
472
+ } else {
473
+ lines.push(`role override: KANKAKU_ROLE=${deps.roleOverride} (deciding signal for this process's role)`);
474
+ }
475
+ }
476
+
477
+ // C2 item 2/3: a configured child-env marker matched but was ignored
478
+ // for this process's role because it looked interactive — a
479
+ // configured marker, unlike a built-in one, never demotes an
480
+ // interactive session. Escalated when this process also has no
481
+ // tracked ancestor at all (C2 item 3's self-check: the strongest
482
+ // signal the marker is genuinely ambient, not a real subagent
483
+ // mechanism).
484
+ if (deps.configuredMarkerIgnoredInteractive) {
485
+ lines.push(
486
+ deps.hasTrackedAncestor
487
+ ? "configured marker: a KANKAKU_SUBAGENT_CHILD_ENV marker was present but ignored for this interactive session — resolved role: orchestrator"
488
+ : "configured marker: a KANKAKU_SUBAGENT_CHILD_ENV marker was present on this interactive, TOP-LEVEL session (no tracked ancestor) — the marker is likely ambient (set on every process of its kind, not just a subagent's child), not a real subagent mechanism; resolved role: orchestrator",
489
+ );
490
+ }
491
+
492
+ // C2 item 1: markers rejected at config load time as looking
493
+ // pi/shell/OS/npm-owned rather than genuinely child-only.
494
+ if (deps.rejectedSubagentChildEnvMarkers && deps.rejectedSubagentChildEnvMarkers.length > 0) {
495
+ for (const rejected of deps.rejectedSubagentChildEnvMarkers) {
496
+ lines.push(`rejected KANKAKU_SUBAGENT_CHILD_ENV marker "${rejected.name}": ${rejected.reason}`);
497
+ }
498
+ }
499
+
500
+ // SUBAGENT-REQ-001/002/003/005/017 (6b): active profiles, any configured
501
+ // tool names/markers, which profile matched each subagent record, and
502
+ // any tool-name ambiguity among the active set. Omitted entirely when
503
+ // deps.subagentProfiles was not wired (back-compat).
504
+ if (deps.subagentProfiles) {
505
+ const profiles = deps.subagentProfiles;
506
+ lines.push(`subagent profiles active: ${profiles.map((p) => p.id).join(", ")}`);
507
+
508
+ const configured = profiles.find((p) => p.id === "configured");
509
+ if (configured) {
510
+ if (configured.toolNames.length > 0) lines.push(`configured subagent tools: ${configured.toolNames.join(", ")}`);
511
+ if (configured.childEnvMarkers.length > 0) {
512
+ lines.push(`configured child-env markers: ${configured.childEnvMarkers.map((m) => (m.value !== undefined ? `${m.name}=${m.value}` : m.name)).join(", ")}`);
513
+ }
514
+ }
515
+
516
+ const subagentRecords = records.filter((record) => record.role === "subagent");
517
+ if (subagentRecords.length > 0) {
518
+ const counts = new Map<string, number>();
519
+ let unmatched = 0;
520
+ for (const record of subagentRecords) {
521
+ if (record.profile) {
522
+ counts.set(record.profile, (counts.get(record.profile) ?? 0) + 1);
523
+ } else {
524
+ unmatched++;
525
+ }
526
+ }
527
+ const parts = profiles.filter((p) => counts.has(p.id)).map((p) => `${p.id}: ${counts.get(p.id)}`);
528
+ if (unmatched > 0) parts.push(`unmatched: ${unmatched}`);
529
+ lines.push(`profile matches: ${parts.join(", ")}`);
530
+ }
531
+
532
+ for (const { toolName, profileIds } of findAmbiguousToolNames(profiles)) {
533
+ lines.push(`ambiguous tool name "${toolName}": registered by ${profileIds.join(", ")} — never guessed, resolved by child-env marker or left uncertain`);
534
+ }
535
+ }
536
+
537
+ // SUBAGENT-REQ-015 (6c): same-pid overlapping orchestrator records —
538
+ // never observed from any real subagent mechanism today, but flagged
539
+ // here (informational only, never changing buildTasks' own numbers) as
540
+ // the observable signature an in-process nested session would leave.
541
+ for (const overlap of detectSameProcessOverlaps(records)) {
542
+ lines.push(
543
+ `likely in-process nesting: pid ${overlap.pid} has ${overlap.recordIds.length} overlapping orchestrator records (${overlap.recordIds.join(", ")}) — union of their wall time is ${overlap.unionedWallMs}ms`,
544
+ );
545
+ }
546
+
547
+ if (deps.workLogRouting?.usedFallback) {
548
+ lines.push(
549
+ `kankaku: this subagent could not write to its orchestrator's directory (${deps.workLogRouting.parentDir}); ` +
550
+ "fell back to its own local worklog — this record may show as an orphan until reunited manually",
551
+ );
552
+ }
553
+
554
+ if (deps.registryHealth) {
555
+ const { keep, discard } = deps.registryHealth();
556
+ const counts = new Map<string, number>();
557
+ for (const { reason } of discard) counts.set(reason, (counts.get(reason) ?? 0) + 1);
558
+ const byReason = Array.from(counts.entries())
559
+ .map(([reason, count]) => `${reason}: ${count}`)
560
+ .join(", ");
561
+ lines.push(`registry (~/.kankaku/run): ${keep.length} entrie(s) trusted${discard.length > 0 ? `, ${discard.length} discarded (${byReason})` : ""}`);
562
+ }
563
+
564
+ const sessionDir = readNonDefaultSessionDir(ctx.sessionManager);
565
+ if (sessionDir !== undefined) {
566
+ lines.push(`session dir (non-default): ${sessionDir}`);
567
+ }
568
+
569
+ showReport(ctx, { title: "doctor", lines });
570
+ }
571
+
131
572
  /** Handle `/kankaku export [csv|json] [all]`; `rest` excludes the leading `export` token. Default format is csv. */
132
573
  function handleExportCommand(rest: string[], ctx: ExtensionContext): void {
133
574
  if (!deps.writeExportFile) {
@@ -153,7 +594,14 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
153
594
  "'tasks' for this session's tasks ('tasks all' for every session), 'sessions' for today's sessions, " +
154
595
  "'client <name>' to set the session billing client, 'client' to show the effective one and its source, " +
155
596
  "'client --clear' to clear it, 'clients' for per-client totals today ('clients all' for every day), " +
156
- "'export [csv|json] [all]' to write today's (or every) task as a file.",
597
+ "'export [csv|json] [all]' to write today's (or every) task as a file. " +
598
+ "'doctor' to report orphan/uncertain subagent counts and ancestor-detection availability (no network). " +
599
+ "When a hub (PocketBase) is configured: 'target' to show the effective client/project and its source, " +
600
+ "'target pick' to run the picker again, 'target clear' to clear the session target, " +
601
+ "'catalog refresh' to force a catalog refresh, 'projects' for per-project totals today ('projects all' for every day), " +
602
+ "'sync' to push pending tasks to the hub ('sync all' for a full re-evaluation, 'sync status' for the watermark/pending count/last error), " +
603
+ "'backfill' to run a full sync and report how many tasks went to Sin determinar, grouped by their old label. " +
604
+ "With a hub configured, 'client <name>' instead validates against the catalog (code or name) and sets the target.",
157
605
  getArgumentCompletions: (argumentPrefix: string): AutocompleteItem[] => {
158
606
  const clientMatch = /^client\s+(\S*)$/.exec(argumentPrefix);
159
607
  if (clientMatch) {
@@ -162,7 +610,23 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
162
610
  .filter((name) => name.startsWith(prefix))
163
611
  .map((name) => ({ value: name, label: name }));
164
612
  }
165
- return COMMAND_TOKENS.filter((value) => value.startsWith(argumentPrefix)).map((value) => ({ value, label: value }));
613
+ const targetMatch = /^target\s+(\S*)$/.exec(argumentPrefix);
614
+ if (targetMatch) {
615
+ const prefix = targetMatch[1] ?? "";
616
+ return TARGET_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
617
+ }
618
+ const catalogMatch = /^catalog\s+(\S*)$/.exec(argumentPrefix);
619
+ if (catalogMatch) {
620
+ const prefix = catalogMatch[1] ?? "";
621
+ return CATALOG_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
622
+ }
623
+ const syncMatch = /^sync\s+(\S*)$/.exec(argumentPrefix);
624
+ if (syncMatch) {
625
+ const prefix = syncMatch[1] ?? "";
626
+ return SYNC_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
627
+ }
628
+ const tokens = deps.sessionTarget ? [...COMMAND_TOKENS, ...HUB_COMMAND_TOKENS] : COMMAND_TOKENS;
629
+ return tokens.filter((value) => value.startsWith(argumentPrefix)).map((value) => ({ value, label: value }));
166
630
  },
167
631
  handler: async (args, ctx) => {
168
632
  try {
@@ -178,6 +642,31 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
178
642
  return;
179
643
  }
180
644
 
645
+ if (tokens[0] === "target") {
646
+ await handleTargetCommand(tokens.slice(1), ctx);
647
+ return;
648
+ }
649
+
650
+ if (tokens[0] === "catalog") {
651
+ await handleCatalogCommand(tokens.slice(1), ctx);
652
+ return;
653
+ }
654
+
655
+ if (tokens[0] === "sync") {
656
+ await handleSyncCommand(tokens.slice(1), ctx);
657
+ return;
658
+ }
659
+
660
+ if (tokens[0] === "backfill") {
661
+ await handleBackfillCommand(ctx);
662
+ return;
663
+ }
664
+
665
+ if (tokens[0] === "doctor") {
666
+ handleDoctorCommand(ctx);
667
+ return;
668
+ }
669
+
181
670
  const all = tokens.includes("all");
182
671
  const records = log.readAll();
183
672
  const today = localDay(new Date().toISOString());
@@ -191,6 +680,15 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
191
680
  return;
192
681
  }
193
682
 
683
+ if (tokens.includes("projects")) {
684
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
685
+ showReport(ctx, {
686
+ title: all ? "projects (all days)" : "projects (today)",
687
+ lines: formatProjects(summarizeByProject(tasks)).split("\n"),
688
+ });
689
+ return;
690
+ }
691
+
194
692
  if (tokens.includes("tasks")) {
195
693
  const sessionId = ctx.sessionManager.getSessionId();
196
694
  const scoped = all || !sessionId;
@@ -212,9 +710,14 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
212
710
  }
213
711
 
214
712
  const summary = summarize(records, { all });
713
+ const lines = formatReport(summary).split(" | ");
714
+ const uncertainCount = countUncertain(records, { all });
715
+ if (uncertainCount > 0) {
716
+ lines.push(`kankaku: ${uncertainCount} uncertain record(s) excluded from tasks — run /kankaku doctor`);
717
+ }
215
718
  showReport(ctx, {
216
719
  title: all ? "summary (all days)" : "summary (today)",
217
- lines: formatReport(summary).split(" | "),
720
+ lines,
218
721
  });
219
722
  } catch (error) {
220
723
  notifyError(ctx, error);