kankaku 0.6.0 → 0.7.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.
- package/README.md +103 -14
- package/dist/adapters/cached-catalog.d.ts +8 -2
- package/dist/adapters/cached-catalog.js +13 -3
- package/dist/adapters/pocketbase-catalog.d.ts +7 -5
- package/dist/adapters/pocketbase-catalog.js +22 -5
- package/dist/adapters/pocketbase-sink.d.ts +2 -1
- package/dist/adapters/pocketbase-sink.js +2 -1
- package/dist/domain/hub-entry.d.ts +12 -3
- package/dist/domain/hub-entry.js +14 -4
- package/dist/domain/task-view.d.ts +4 -0
- package/dist/domain/task-view.js +2 -0
- package/dist/domain/work-record.d.ts +18 -0
- package/dist/domain/work-record.js +18 -0
- package/dist/domain/work-target.d.ts +30 -1
- package/dist/domain/work-target.js +33 -11
- package/dist/ports/catalog.d.ts +4 -2
- package/package.json +1 -1
- package/src/adapters/cached-catalog.ts +22 -6
- package/src/adapters/hub-actions.ts +83 -0
- package/src/adapters/kankaku-command.ts +236 -244
- package/src/adapters/panel/kankaku-panel.ts +274 -0
- package/src/adapters/panel/panel-items.ts +77 -0
- package/src/adapters/panel/panel-lines.ts +13 -0
- package/src/adapters/panel/panel-theme.ts +32 -0
- package/src/adapters/panel/screens/about.ts +69 -0
- package/src/adapters/panel/screens/doctor.ts +85 -0
- package/src/adapters/panel/screens/export.ts +119 -0
- package/src/adapters/panel/screens/report.ts +141 -0
- package/src/adapters/panel/screens/sync.ts +126 -0
- package/src/adapters/panel/screens/target.ts +367 -0
- package/src/adapters/pi-tracker.ts +110 -6
- package/src/adapters/pocketbase-catalog.ts +36 -7
- package/src/adapters/pocketbase-sink.ts +15 -3
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +66 -16
- package/src/adapters/session-target.ts +111 -4
- package/src/adapters/target-picker.ts +46 -1
- package/src/domain/hub-entry.ts +20 -5
- package/src/domain/panel-model.ts +267 -0
- package/src/domain/task-view.ts +6 -0
- package/src/domain/work-record.ts +23 -0
- package/src/domain/work-target.ts +60 -11
- package/src/extension.ts +19 -8
- package/src/ports/catalog.ts +4 -2
|
@@ -2,8 +2,7 @@ import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { isValidClient } from "../domain/client-label.ts";
|
|
5
|
-
import {
|
|
6
|
-
import { buildSessions, buildTasks, detectSameProcessOverlaps, orphanSubagents, uncertainRecords } from "../domain/task-view.ts";
|
|
5
|
+
import { detectSameProcessOverlaps, orphanSubagents, uncertainRecords } from "../domain/task-view.ts";
|
|
7
6
|
import { formatWorkTargetLabel } from "../domain/work-target.ts";
|
|
8
7
|
import { findAmbiguousToolNames } from "../domain/subagent-profile.ts";
|
|
9
8
|
import type { SubagentProfile } from "../domain/subagent-profile.ts";
|
|
@@ -12,19 +11,9 @@ import type { SyncState } from "../domain/sync-plan.ts";
|
|
|
12
11
|
import type { RegistryClassification } from "../domain/registry-health.ts";
|
|
13
12
|
import type { Catalog } from "../ports/catalog.ts";
|
|
14
13
|
import type { WorkLog } from "../ports/work-log.ts";
|
|
14
|
+
import { buildSyncStatusLines, formatBackfillLines, formatCatalogRefreshLines, formatSyncSummaryLines } from "./hub-actions.ts";
|
|
15
|
+
import { buildClientsView, buildExportContent, buildProjectsView, buildSessionsView, buildSummaryView, buildTasksView } from "./report-views.ts";
|
|
15
16
|
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";
|
|
28
17
|
import type { SessionClient } from "./session-client.ts";
|
|
29
18
|
import { readNonDefaultSessionDir } from "./session-dir.ts";
|
|
30
19
|
import type { SessionTarget } from "./session-target.ts";
|
|
@@ -44,10 +33,172 @@ export function notifyError(ctx: ExtensionContext, error: unknown): void {
|
|
|
44
33
|
ctx.ui.notify(`kankaku: ${message}`, "error");
|
|
45
34
|
}
|
|
46
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Append a {@link KankakuReportData} as a durable entry in the chat
|
|
38
|
+
* transcript when a UI is attached, or fall back to a one-shot notify
|
|
39
|
+
* otherwise. Exact body of `/kankaku`'s own `showReport` (which now
|
|
40
|
+
* delegates here), so the panel's "pin to chat" action
|
|
41
|
+
* (`screens/report.ts`, `screens/doctor.ts`) renders identically to every
|
|
42
|
+
* existing subcommand's report.
|
|
43
|
+
*/
|
|
44
|
+
export function appendReportEntry(pi: ExtensionAPI, ctx: ExtensionContext, report: KankakuReportData): void {
|
|
45
|
+
if (ctx.hasUI) {
|
|
46
|
+
pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Handle `/kankaku doctor`'s line-building: a no-network diagnostic
|
|
54
|
+
* (SUBAGENT-REQ-017) reporting orphan/uncertain record counts (ADR 0022)
|
|
55
|
+
* with why, and whether ancestor-chain detection is available on this
|
|
56
|
+
* platform, so silent undercount stays visible (see README "Subagents").
|
|
57
|
+
* Extracted from `handleDoctorCommand` so the panel's doctor screen
|
|
58
|
+
* (`screens/doctor.ts`) can never drift from `/kankaku doctor`'s own output.
|
|
59
|
+
*/
|
|
60
|
+
export function buildDoctorLines(deps: KankakuCommandDeps, ctx: ExtensionContext): string[] {
|
|
61
|
+
const records = deps.log.readAll();
|
|
62
|
+
const orphans = orphanSubagents(records);
|
|
63
|
+
const uncertain = uncertainRecords(records);
|
|
64
|
+
const ancestorDetectionAvailable = deps.ancestorDetectionAvailable ? deps.ancestorDetectionAvailable() : process.platform !== "win32";
|
|
65
|
+
|
|
66
|
+
const lines = [
|
|
67
|
+
`ancestor-chain detection: ${ancestorDetectionAvailable ? "available" : "unavailable"}`,
|
|
68
|
+
`orphan subagent record(s): ${orphans.length}` +
|
|
69
|
+
(orphans.length > 0 ? " — recognised as someone's child (an env marker matched), but no orchestrator could be matched" : ""),
|
|
70
|
+
`uncertain record(s): ${uncertain.length}` +
|
|
71
|
+
(uncertain.length > 0
|
|
72
|
+
? " — no recognised child-env-marker, but a live tracked ancestor process was found; not counted as a new task, not synced"
|
|
73
|
+
: ""),
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
if (!ancestorDetectionAvailable) {
|
|
77
|
+
lines.push(
|
|
78
|
+
"on this platform/environment, ancestor-chain detection could not run (Windows, or a failed/unavailable ps/proc read): " +
|
|
79
|
+
"an unmarked subagent system may be counted twice (a genuine child with no recognised marker looks like a fresh top-level " +
|
|
80
|
+
"session). Mark it explicitly with KANKAKU_ROLE=subagent in the child's environment (or KANKAKU_ROLE=orchestrator to force " +
|
|
81
|
+
"the other way).",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (deps.roleOverride) {
|
|
86
|
+
if (deps.childMarkerPresent) {
|
|
87
|
+
// R1: both signals present — the confirmed child marker always
|
|
88
|
+
// wins (config.ts#detectRole), so the override did not decide
|
|
89
|
+
// anything, whatever it said.
|
|
90
|
+
lines.push(
|
|
91
|
+
`role override: KANKAKU_ROLE=${deps.roleOverride} was present, but the confirmed child marker (GENTLE_PI_AGENTS_CHILD=1) takes precedence — resolved role: subagent`,
|
|
92
|
+
);
|
|
93
|
+
} else if (deps.overrideIgnoredInteractive) {
|
|
94
|
+
lines.push(
|
|
95
|
+
"role override: KANKAKU_ROLE=subagent was ignored for this interactive session (likely a leaked shell export) — resolved role: orchestrator",
|
|
96
|
+
);
|
|
97
|
+
} else {
|
|
98
|
+
lines.push(`role override: KANKAKU_ROLE=${deps.roleOverride} (deciding signal for this process's role)`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// C2 item 2/3: a configured child-env marker matched but was ignored
|
|
103
|
+
// for this process's role because it looked interactive — a
|
|
104
|
+
// configured marker, unlike a built-in one, never demotes an
|
|
105
|
+
// interactive session. Escalated when this process also has no
|
|
106
|
+
// tracked ancestor at all (C2 item 3's self-check: the strongest
|
|
107
|
+
// signal the marker is genuinely ambient, not a real subagent
|
|
108
|
+
// mechanism).
|
|
109
|
+
if (deps.configuredMarkerIgnoredInteractive) {
|
|
110
|
+
lines.push(
|
|
111
|
+
deps.hasTrackedAncestor
|
|
112
|
+
? "configured marker: a KANKAKU_SUBAGENT_CHILD_ENV marker was present but ignored for this interactive session — resolved role: orchestrator"
|
|
113
|
+
: "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",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// C2 item 1: markers rejected at config load time as looking
|
|
118
|
+
// pi/shell/OS/npm-owned rather than genuinely child-only.
|
|
119
|
+
if (deps.rejectedSubagentChildEnvMarkers && deps.rejectedSubagentChildEnvMarkers.length > 0) {
|
|
120
|
+
for (const rejected of deps.rejectedSubagentChildEnvMarkers) {
|
|
121
|
+
lines.push(`rejected KANKAKU_SUBAGENT_CHILD_ENV marker "${rejected.name}": ${rejected.reason}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// SUBAGENT-REQ-001/002/003/005/017 (6b): active profiles, any configured
|
|
126
|
+
// tool names/markers, which profile matched each subagent record, and
|
|
127
|
+
// any tool-name ambiguity among the active set. Omitted entirely when
|
|
128
|
+
// deps.subagentProfiles was not wired (back-compat).
|
|
129
|
+
if (deps.subagentProfiles) {
|
|
130
|
+
const profiles = deps.subagentProfiles;
|
|
131
|
+
lines.push(`subagent profiles active: ${profiles.map((p) => p.id).join(", ")}`);
|
|
132
|
+
|
|
133
|
+
const configured = profiles.find((p) => p.id === "configured");
|
|
134
|
+
if (configured) {
|
|
135
|
+
if (configured.toolNames.length > 0) lines.push(`configured subagent tools: ${configured.toolNames.join(", ")}`);
|
|
136
|
+
if (configured.childEnvMarkers.length > 0) {
|
|
137
|
+
lines.push(`configured child-env markers: ${configured.childEnvMarkers.map((m) => (m.value !== undefined ? `${m.name}=${m.value}` : m.name)).join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const subagentRecords = records.filter((record) => record.role === "subagent");
|
|
142
|
+
if (subagentRecords.length > 0) {
|
|
143
|
+
const counts = new Map<string, number>();
|
|
144
|
+
let unmatched = 0;
|
|
145
|
+
for (const record of subagentRecords) {
|
|
146
|
+
if (record.profile) {
|
|
147
|
+
counts.set(record.profile, (counts.get(record.profile) ?? 0) + 1);
|
|
148
|
+
} else {
|
|
149
|
+
unmatched++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const parts = profiles.filter((p) => counts.has(p.id)).map((p) => `${p.id}: ${counts.get(p.id)}`);
|
|
153
|
+
if (unmatched > 0) parts.push(`unmatched: ${unmatched}`);
|
|
154
|
+
lines.push(`profile matches: ${parts.join(", ")}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const { toolName, profileIds } of findAmbiguousToolNames(profiles)) {
|
|
158
|
+
lines.push(`ambiguous tool name "${toolName}": registered by ${profileIds.join(", ")} — never guessed, resolved by child-env marker or left uncertain`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// SUBAGENT-REQ-015 (6c): same-pid overlapping orchestrator records —
|
|
163
|
+
// never observed from any real subagent mechanism today, but flagged
|
|
164
|
+
// here (informational only, never changing buildTasks' own numbers) as
|
|
165
|
+
// the observable signature an in-process nested session would leave.
|
|
166
|
+
for (const overlap of detectSameProcessOverlaps(records)) {
|
|
167
|
+
lines.push(
|
|
168
|
+
`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`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (deps.workLogRouting?.usedFallback) {
|
|
173
|
+
lines.push(
|
|
174
|
+
`kankaku: this subagent could not write to its orchestrator's directory (${deps.workLogRouting.parentDir}); ` +
|
|
175
|
+
"fell back to its own local worklog — this record may show as an orphan until reunited manually",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (deps.registryHealth) {
|
|
180
|
+
const { keep, discard } = deps.registryHealth();
|
|
181
|
+
const counts = new Map<string, number>();
|
|
182
|
+
for (const { reason } of discard) counts.set(reason, (counts.get(reason) ?? 0) + 1);
|
|
183
|
+
const byReason = Array.from(counts.entries())
|
|
184
|
+
.map(([reason, count]) => `${reason}: ${count}`)
|
|
185
|
+
.join(", ");
|
|
186
|
+
lines.push(`registry (~/.kankaku/run): ${keep.length} entrie(s) trusted${discard.length > 0 ? `, ${discard.length} discarded (${byReason})` : ""}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const sessionDir = readNonDefaultSessionDir(ctx.sessionManager);
|
|
190
|
+
if (sessionDir !== undefined) {
|
|
191
|
+
lines.push(`session dir (non-default): ${sessionDir}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return lines;
|
|
195
|
+
}
|
|
196
|
+
|
|
47
197
|
const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export", "doctor"];
|
|
48
198
|
/** 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"];
|
|
199
|
+
const HUB_COMMAND_TOKENS = ["target", "task", "projects", "catalog", "sync", "backfill"];
|
|
50
200
|
const TARGET_TOKENS = ["pick", "clear"];
|
|
201
|
+
const TASK_TOKENS = ["pick", "clear"];
|
|
51
202
|
const CATALOG_TOKENS = ["refresh"];
|
|
52
203
|
const SYNC_TOKENS = ["all", "status"];
|
|
53
204
|
|
|
@@ -174,6 +325,14 @@ export interface KankakuCommandDeps {
|
|
|
174
325
|
* a caller has not wired this — back-compat with an older embedder.
|
|
175
326
|
*/
|
|
176
327
|
subagentProfiles?: SubagentProfile[];
|
|
328
|
+
/**
|
|
329
|
+
* Opens the `/kankaku` overlay panel (`adapters/panel/kankaku-panel.ts`)
|
|
330
|
+
* when present. `/kankaku` with no arguments prefers this over the plain
|
|
331
|
+
* summary report whenever a UI is attached; absent (or without a UI),
|
|
332
|
+
* behaviour is unchanged — the summary report, exactly as before this
|
|
333
|
+
* panel existed.
|
|
334
|
+
*/
|
|
335
|
+
openPanel?: (ctx: ExtensionContext) => Promise<void>;
|
|
177
336
|
}
|
|
178
337
|
|
|
179
338
|
export interface KankakuCommand {
|
|
@@ -232,11 +391,7 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
232
391
|
});
|
|
233
392
|
|
|
234
393
|
function showReport(ctx: ExtensionContext, report: KankakuReportData): void {
|
|
235
|
-
|
|
236
|
-
pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
|
|
394
|
+
appendReportEntry(pi, ctx, report);
|
|
240
395
|
}
|
|
241
396
|
|
|
242
397
|
/**
|
|
@@ -328,6 +483,35 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
328
483
|
notifyError(ctx, new Error(`unknown target subcommand: ${rest.join(" ")}`));
|
|
329
484
|
}
|
|
330
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Handle `/kankaku task [pick|clear]`; `rest` excludes the leading `task`
|
|
488
|
+
* token. Default (no args) is `pick`. `pickTask`/`clearTask`
|
|
489
|
+
* (`session-target.ts`) notify their own outcome directly, so this only
|
|
490
|
+
* dispatches and refreshes the idle status line.
|
|
491
|
+
*/
|
|
492
|
+
async function handleTaskCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
|
|
493
|
+
const sessionTarget = deps.sessionTarget;
|
|
494
|
+
if (!sessionTarget) {
|
|
495
|
+
notifyError(ctx, new Error("hub is not configured"));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (rest.length === 1 && rest[0] === "clear") {
|
|
500
|
+
sessionTarget.clearTask(pi);
|
|
501
|
+
deps.refreshIdleStatus(ctx);
|
|
502
|
+
showReport(ctx, { title: "task", lines: ["task link cleared for this session"] });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (rest.length === 0 || (rest.length === 1 && rest[0] === "pick")) {
|
|
507
|
+
await sessionTarget.pickTask(pi, ctx);
|
|
508
|
+
deps.refreshIdleStatus(ctx);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
notifyError(ctx, new Error(`unknown task subcommand: ${rest.join(" ")}`));
|
|
513
|
+
}
|
|
514
|
+
|
|
331
515
|
/** Handle `/kankaku catalog refresh`; `rest` excludes the leading `catalog` token. */
|
|
332
516
|
async function handleCatalogCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
|
|
333
517
|
const catalog = deps.catalog;
|
|
@@ -342,38 +526,13 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
342
526
|
notifyError(ctx, new Error("hub unreachable; catalog not refreshed"));
|
|
343
527
|
return;
|
|
344
528
|
}
|
|
345
|
-
showReport(ctx, {
|
|
346
|
-
title: "catalog",
|
|
347
|
-
lines: [`refreshed: ${snapshot.clients.length} client(s), ${snapshot.projects.length} project(s)`],
|
|
348
|
-
});
|
|
529
|
+
showReport(ctx, { title: "catalog", lines: formatCatalogRefreshLines(snapshot) });
|
|
349
530
|
return;
|
|
350
531
|
}
|
|
351
532
|
|
|
352
533
|
notifyError(ctx, new Error(`unknown catalog subcommand: ${rest.join(" ")}`));
|
|
353
534
|
}
|
|
354
535
|
|
|
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
536
|
/** Handle `/kankaku sync [all|status]`; `rest` excludes the leading `sync` token. */
|
|
378
537
|
async function handleSyncCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
|
|
379
538
|
const sync = deps.sync;
|
|
@@ -383,13 +542,7 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
383
542
|
}
|
|
384
543
|
|
|
385
544
|
if (rest.length === 1 && rest[0] === "status") {
|
|
386
|
-
|
|
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 });
|
|
545
|
+
showReport(ctx, { title: "sync status", lines: buildSyncStatusLines(sync.status()) });
|
|
393
546
|
return;
|
|
394
547
|
}
|
|
395
548
|
|
|
@@ -400,7 +553,7 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
400
553
|
|
|
401
554
|
const full = rest[0] === "all";
|
|
402
555
|
const summary = await sync.run({ full });
|
|
403
|
-
showReport(ctx, { title: full ? "sync (all)" : "sync", lines:
|
|
556
|
+
showReport(ctx, { title: full ? "sync (all)" : "sync", lines: formatSyncSummaryLines(summary) });
|
|
404
557
|
}
|
|
405
558
|
|
|
406
559
|
/** Handle `/kankaku backfill`: a full sync, reported as the "Sin determinar" breakdown that needs reassigning in the web. */
|
|
@@ -412,161 +565,16 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
412
565
|
}
|
|
413
566
|
|
|
414
567
|
const summary = await sync.run({ full: true });
|
|
415
|
-
|
|
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 });
|
|
568
|
+
showReport(ctx, { title: "backfill", lines: formatBackfillLines(summary) });
|
|
427
569
|
}
|
|
428
570
|
|
|
429
571
|
/**
|
|
430
|
-
* Handle `/kankaku doctor`:
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
* silent undercount stays visible (see README "Subagents").
|
|
572
|
+
* Handle `/kankaku doctor`: builds the lines (shared with the panel's
|
|
573
|
+
* doctor screen — see {@link buildDoctorLines}) and shows them as the
|
|
574
|
+
* durable report.
|
|
434
575
|
*/
|
|
435
576
|
function handleDoctorCommand(ctx: ExtensionContext): void {
|
|
436
|
-
|
|
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 });
|
|
577
|
+
showReport(ctx, { title: "doctor", lines: buildDoctorLines(deps, ctx) });
|
|
570
578
|
}
|
|
571
579
|
|
|
572
580
|
/** Handle `/kankaku export [csv|json] [all]`; `rest` excludes the leading `export` token. Default format is csv. */
|
|
@@ -578,14 +586,9 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
578
586
|
|
|
579
587
|
const all = rest.includes("all");
|
|
580
588
|
const format: "csv" | "json" = rest.includes("json") ? "json" : "csv";
|
|
581
|
-
const
|
|
582
|
-
const today = localDay(new Date().toISOString());
|
|
583
|
-
const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
|
|
584
|
-
const rows = exportRows(tasks);
|
|
585
|
-
const content = format === "json" ? toJson(rows) : toCsv(rows);
|
|
586
|
-
const name = `tasks-${all ? "all" : today}.${format}`;
|
|
589
|
+
const { name, content, rowCount } = buildExportContent(log.readAll(), { format, all });
|
|
587
590
|
const path = deps.writeExportFile(name, content);
|
|
588
|
-
showReport(ctx, { title: "export", lines: [`wrote ${
|
|
591
|
+
showReport(ctx, { title: "export", lines: [`wrote ${rowCount} row(s) to ${path}`] });
|
|
589
592
|
}
|
|
590
593
|
|
|
591
594
|
pi.registerCommand("kankaku", {
|
|
@@ -598,6 +601,8 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
598
601
|
"'doctor' to report orphan/uncertain subagent counts and ancestor-detection availability (no network). " +
|
|
599
602
|
"When a hub (PocketBase) is configured: 'target' to show the effective client/project and its source, " +
|
|
600
603
|
"'target pick' to run the picker again, 'target clear' to clear the session target, " +
|
|
604
|
+
"'task' (or 'task pick') to link this session to an open/doing hub task of the effective project, " +
|
|
605
|
+
"'task clear' to drop the link, " +
|
|
601
606
|
"'catalog refresh' to force a catalog refresh, 'projects' for per-project totals today ('projects all' for every day), " +
|
|
602
607
|
"'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
608
|
"'backfill' to run a full sync and report how many tasks went to Sin determinar, grouped by their old label. " +
|
|
@@ -615,6 +620,11 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
615
620
|
const prefix = targetMatch[1] ?? "";
|
|
616
621
|
return TARGET_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
|
|
617
622
|
}
|
|
623
|
+
const taskMatch = /^task\s+(\S*)$/.exec(argumentPrefix);
|
|
624
|
+
if (taskMatch) {
|
|
625
|
+
const prefix = taskMatch[1] ?? "";
|
|
626
|
+
return TASK_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
|
|
627
|
+
}
|
|
618
628
|
const catalogMatch = /^catalog\s+(\S*)$/.exec(argumentPrefix);
|
|
619
629
|
if (catalogMatch) {
|
|
620
630
|
const prefix = catalogMatch[1] ?? "";
|
|
@@ -647,6 +657,11 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
647
657
|
return;
|
|
648
658
|
}
|
|
649
659
|
|
|
660
|
+
if (tokens[0] === "task") {
|
|
661
|
+
await handleTaskCommand(tokens.slice(1), ctx);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
650
665
|
if (tokens[0] === "catalog") {
|
|
651
666
|
await handleCatalogCommand(tokens.slice(1), ctx);
|
|
652
667
|
return;
|
|
@@ -667,58 +682,35 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
667
682
|
return;
|
|
668
683
|
}
|
|
669
684
|
|
|
685
|
+
if (tokens.length === 0 && ctx.hasUI && deps.openPanel) {
|
|
686
|
+
await deps.openPanel(ctx);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
670
690
|
const all = tokens.includes("all");
|
|
671
691
|
const records = log.readAll();
|
|
672
|
-
const today = localDay(new Date().toISOString());
|
|
673
692
|
|
|
674
693
|
if (tokens.includes("clients")) {
|
|
675
|
-
|
|
676
|
-
showReport(ctx, {
|
|
677
|
-
title: all ? "clients (all days)" : "clients (today)",
|
|
678
|
-
lines: formatClients(summarizeByClient(tasks)).split("\n"),
|
|
679
|
-
});
|
|
694
|
+
showReport(ctx, buildClientsView(records, { all }));
|
|
680
695
|
return;
|
|
681
696
|
}
|
|
682
697
|
|
|
683
698
|
if (tokens.includes("projects")) {
|
|
684
|
-
|
|
685
|
-
showReport(ctx, {
|
|
686
|
-
title: all ? "projects (all days)" : "projects (today)",
|
|
687
|
-
lines: formatProjects(summarizeByProject(tasks)).split("\n"),
|
|
688
|
-
});
|
|
699
|
+
showReport(ctx, buildProjectsView(records, { all }));
|
|
689
700
|
return;
|
|
690
701
|
}
|
|
691
702
|
|
|
692
703
|
if (tokens.includes("tasks")) {
|
|
693
|
-
|
|
694
|
-
const scoped = all || !sessionId;
|
|
695
|
-
const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
|
|
696
|
-
showReport(ctx, {
|
|
697
|
-
title: scoped ? "tasks (every session)" : "tasks (this session)",
|
|
698
|
-
lines: formatTasks(tasks).split("\n"),
|
|
699
|
-
});
|
|
704
|
+
showReport(ctx, buildTasksView(records, { all, sessionId: ctx.sessionManager.getSessionId() }));
|
|
700
705
|
return;
|
|
701
706
|
}
|
|
702
707
|
|
|
703
708
|
if (tokens.includes("sessions")) {
|
|
704
|
-
|
|
705
|
-
showReport(ctx, {
|
|
706
|
-
title: all ? "sessions (all days)" : "sessions (today)",
|
|
707
|
-
lines: formatSessions(buildSessions(tasks)).split("\n"),
|
|
708
|
-
});
|
|
709
|
+
showReport(ctx, buildSessionsView(records, { all }));
|
|
709
710
|
return;
|
|
710
711
|
}
|
|
711
712
|
|
|
712
|
-
|
|
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
|
-
}
|
|
718
|
-
showReport(ctx, {
|
|
719
|
-
title: all ? "summary (all days)" : "summary (today)",
|
|
720
|
-
lines,
|
|
721
|
-
});
|
|
713
|
+
showReport(ctx, buildSummaryView(records, { all }));
|
|
722
714
|
} catch (error) {
|
|
723
715
|
notifyError(ctx, error);
|
|
724
716
|
}
|