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.
- package/README.md +952 -13
- package/package.json +4 -2
- package/src/adapters/agent-info.ts +86 -0
- package/src/adapters/ancestry.ts +260 -0
- package/src/adapters/cached-catalog.ts +131 -0
- package/src/adapters/file-modes.ts +35 -0
- package/src/adapters/hub-credentials.ts +95 -0
- package/src/adapters/jsonl-work-log.ts +4 -1
- package/src/adapters/kankaku-command.ts +510 -7
- package/src/adapters/kankaku-dir.ts +92 -0
- package/src/adapters/machine-process-registry.ts +256 -0
- package/src/adapters/pi-tracker.ts +408 -14
- package/src/adapters/pocketbase-catalog.ts +60 -0
- package/src/adapters/pocketbase-client.ts +197 -0
- package/src/adapters/pocketbase-sink.ts +224 -0
- package/src/adapters/process-identity-memo.ts +102 -0
- package/src/adapters/process-identity.ts +162 -0
- package/src/adapters/project-config.ts +73 -1
- package/src/adapters/report.ts +79 -6
- package/src/adapters/session-dir.ts +28 -0
- package/src/adapters/session-target.ts +262 -0
- package/src/adapters/subagent-startup.ts +66 -0
- package/src/adapters/sync-runner.ts +301 -0
- package/src/adapters/sync-state-store.ts +227 -0
- package/src/adapters/target-picker.ts +82 -0
- package/src/config.ts +444 -6
- package/src/domain/ancestry-match.ts +84 -0
- package/src/domain/hub-entry.ts +339 -0
- package/src/domain/registry-health.ts +87 -0
- package/src/domain/subagent-profile.ts +495 -0
- package/src/domain/sync-plan.ts +251 -0
- package/src/domain/task-view.ts +307 -23
- package/src/domain/work-record.ts +157 -1
- package/src/domain/work-target.ts +185 -0
- package/src/domain/work-tracker.ts +248 -51
- package/src/extension.ts +303 -6
- package/src/ports/catalog.ts +31 -0
- package/src/ports/process-registry.ts +75 -0
- 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
|
+
}
|
package/src/adapters/report.ts
CHANGED
|
@@ -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
|
-
/**
|
|
46
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { formatWorkTargetLabel, resolveWorkTarget, resolveWorkTargetSource } from "../domain/work-target.ts";
|
|
3
|
+
import type { WorkTarget, WorkTargetCandidate, WorkTargetSessionOverride, WorkTargetSourceName } from "../domain/work-target.ts";
|
|
4
|
+
import type { WorkRole } from "../domain/work-record.ts";
|
|
5
|
+
import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
|
|
6
|
+
import { pickTarget } from "./target-picker.ts";
|
|
7
|
+
|
|
8
|
+
/** Persisted as a `kankaku-target` custom session entry so the session-level target survives a reload. */
|
|
9
|
+
export interface KankakuTargetEntryData {
|
|
10
|
+
clientId?: string;
|
|
11
|
+
projectId?: string;
|
|
12
|
+
/** `true` when the user explicitly declined the picker; distinct from "no entry yet". */
|
|
13
|
+
skipped?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const TARGET_ENTRY_TYPE = "kankaku-target";
|
|
17
|
+
|
|
18
|
+
export interface SessionTargetDeps {
|
|
19
|
+
role: WorkRole;
|
|
20
|
+
catalog: Catalog;
|
|
21
|
+
/** Lazily reads `clientId`/`projectId` from `<kankaku dir>/config.json`. */
|
|
22
|
+
resolveProjectConfigIds: () => WorkTargetCandidate | undefined;
|
|
23
|
+
/** Persist `clientId`/`projectId` into `<kankaku dir>/config.json`, merging existing keys. */
|
|
24
|
+
persistProjectConfig: (ids: WorkTargetCandidate) => void;
|
|
25
|
+
/** Current working directory, matched against catalog `repo_paths`. Defaults to `process.cwd()`. */
|
|
26
|
+
cwd?: () => string;
|
|
27
|
+
/**
|
|
28
|
+
* Overall deadline, in ms, for the very first (no-cache) catalog fetch —
|
|
29
|
+
* see {@link getSnapshot}. Bounds auth, pagination and the 401 retry
|
|
30
|
+
* together via an `AbortSignal` composed with each request's own
|
|
31
|
+
* per-request timeout, instead of leaving that awaited path bounded only
|
|
32
|
+
* per-request. Defaults to 5000.
|
|
33
|
+
*/
|
|
34
|
+
firstFetchDeadlineMs?: number;
|
|
35
|
+
/** Injectable for tests; defaults to the global timer functions. */
|
|
36
|
+
setTimeout?: (handler: () => void, ms: number) => NodeJS.Timeout;
|
|
37
|
+
clearTimeout?: (timer: NodeJS.Timeout) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_FIRST_FETCH_DEADLINE_MS = 5000;
|
|
41
|
+
|
|
42
|
+
export interface SessionTarget {
|
|
43
|
+
/** Restore the session-level target (or its remembered "skipped" state) from the last `kankaku-target` entry. */
|
|
44
|
+
restore(ctx: ExtensionContext): void;
|
|
45
|
+
/**
|
|
46
|
+
* The `session_start` flow: resolves silently from the project config
|
|
47
|
+
* file or catalog `repo_paths`, or — only when nothing resolves and no
|
|
48
|
+
* session entry (pick or skip) already exists — shows the picker. A
|
|
49
|
+
* no-op unless `role === "orchestrator"` and `ctx.hasUI`.
|
|
50
|
+
*/
|
|
51
|
+
ensurePicked(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void>;
|
|
52
|
+
/** Force the picker again, e.g. `/kankaku target pick`. Ignores any existing session override. */
|
|
53
|
+
pick(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void>;
|
|
54
|
+
/** Set the session target directly, bypassing the picker (the legacy `/kankaku client <name>` compatibility path). */
|
|
55
|
+
setExplicit(pi: ExtensionAPI, ids: WorkTargetCandidate): void;
|
|
56
|
+
/** Clear the session-level override; resolution falls back to the project config file / `repo_paths`. */
|
|
57
|
+
clear(pi: ExtensionAPI): void;
|
|
58
|
+
/** Current effective target (session > project config > repoPaths), regardless of role. */
|
|
59
|
+
effectiveTarget(): WorkTarget | undefined;
|
|
60
|
+
/** Which source produced {@link effectiveTarget}. */
|
|
61
|
+
effectiveSource(): WorkTargetSourceName | undefined;
|
|
62
|
+
/** Role-gated target for the in-progress run (`undefined` for a subagent); caches the project config read for the run. */
|
|
63
|
+
runTarget(): WorkTarget | undefined;
|
|
64
|
+
/** Role-gated target to show while idle, reusing the run's cached project config read when still held. */
|
|
65
|
+
idleTarget(): WorkTarget | undefined;
|
|
66
|
+
/** Drop the per-run cached project config read; call when a run settles or the session shuts down. */
|
|
67
|
+
endRun(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function candidateFrom(target: WorkTarget): WorkTargetCandidate {
|
|
71
|
+
return { clientId: target.clientId, ...(target.projectId !== undefined ? { projectId: target.projectId } : {}) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function entryDataFrom(ids: WorkTargetCandidate): KankakuTargetEntryData {
|
|
75
|
+
return { clientId: ids.clientId, ...(ids.projectId !== undefined ? { projectId: ids.projectId } : {}) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Owns the session-level hub target override (`/kankaku target pick`), its
|
|
80
|
+
* restore/persist round-trip through session entries, and target
|
|
81
|
+
* resolution for both the in-progress run and the idle status line. See
|
|
82
|
+
* README "Hub (PocketBase)" and `domain/work-target.ts#resolveWorkTarget`.
|
|
83
|
+
*/
|
|
84
|
+
export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
|
|
85
|
+
const cwd = deps.cwd ?? (() => process.cwd());
|
|
86
|
+
const scheduleTimeout = deps.setTimeout ?? setTimeout;
|
|
87
|
+
const cancelTimeout = deps.clearTimeout ?? clearTimeout;
|
|
88
|
+
const firstFetchDeadlineMs = deps.firstFetchDeadlineMs ?? DEFAULT_FIRST_FETCH_DEADLINE_MS;
|
|
89
|
+
|
|
90
|
+
/** Session-level override, restored on `session_start` or set by an explicit pick/skip/legacy command. */
|
|
91
|
+
let sessionOverride: WorkTargetSessionOverride;
|
|
92
|
+
/** Project config ids read once per run (first record build) so checkpoints do not hit the filesystem repeatedly. */
|
|
93
|
+
let runProjectIds: { value: WorkTargetCandidate | undefined } | undefined;
|
|
94
|
+
/** Only notify "hub unreachable" once per process for the silent `ensurePicked`/`pick` path. */
|
|
95
|
+
let notifiedUnreachable = false;
|
|
96
|
+
|
|
97
|
+
function restore(ctx: ExtensionContext): void {
|
|
98
|
+
const entries = ctx.sessionManager.getEntries();
|
|
99
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
100
|
+
const entry = entries[i] as { type: string; customType?: string; data?: unknown };
|
|
101
|
+
if (entry.type === "custom" && entry.customType === TARGET_ENTRY_TYPE) {
|
|
102
|
+
const data = entry.data as KankakuTargetEntryData | undefined;
|
|
103
|
+
if (data?.skipped === true) {
|
|
104
|
+
sessionOverride = "skipped";
|
|
105
|
+
} else if (typeof data?.clientId === "string") {
|
|
106
|
+
sessionOverride = { clientId: data.clientId, ...(typeof data.projectId === "string" ? { projectId: data.projectId } : {}) };
|
|
107
|
+
} else {
|
|
108
|
+
sessionOverride = undefined;
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
sessionOverride = undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function computeTarget(projectIds: WorkTargetCandidate | undefined): WorkTarget | undefined {
|
|
117
|
+
const snapshot = deps.catalog.read();
|
|
118
|
+
return resolveWorkTarget({
|
|
119
|
+
session: sessionOverride,
|
|
120
|
+
project: projectIds,
|
|
121
|
+
cwd: cwd(),
|
|
122
|
+
clients: snapshot?.clients ?? [],
|
|
123
|
+
projects: snapshot?.projects ?? [],
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function effectiveTarget(): WorkTarget | undefined {
|
|
128
|
+
return computeTarget(deps.resolveProjectConfigIds());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function effectiveSource(): WorkTargetSourceName | undefined {
|
|
132
|
+
const snapshot = deps.catalog.read();
|
|
133
|
+
return resolveWorkTargetSource({
|
|
134
|
+
session: sessionOverride,
|
|
135
|
+
project: deps.resolveProjectConfigIds(),
|
|
136
|
+
cwd: cwd(),
|
|
137
|
+
clients: snapshot?.clients ?? [],
|
|
138
|
+
projects: snapshot?.projects ?? [],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function runIds(): WorkTargetCandidate | undefined {
|
|
143
|
+
if (!runProjectIds) {
|
|
144
|
+
runProjectIds = { value: deps.resolveProjectConfigIds() };
|
|
145
|
+
}
|
|
146
|
+
return runProjectIds.value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function runTarget(): WorkTarget | undefined {
|
|
150
|
+
return deps.role === "orchestrator" ? computeTarget(runIds()) : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function idleTarget(): WorkTarget | undefined {
|
|
154
|
+
const ids = runProjectIds ? runProjectIds.value : deps.resolveProjectConfigIds();
|
|
155
|
+
return deps.role === "orchestrator" ? computeTarget(ids) : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function endRun(): void {
|
|
159
|
+
runProjectIds = undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function notifyUnreachableOnce(ctx: ExtensionContext): void {
|
|
163
|
+
if (notifiedUnreachable) return;
|
|
164
|
+
notifiedUnreachable = true;
|
|
165
|
+
if (ctx.hasUI) ctx.ui.notify("kankaku: hub unreachable, using local labels", "warning");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolve a catalog snapshot to show the picker with: the cached
|
|
170
|
+
* snapshot immediately when fresh; the cached snapshot immediately with
|
|
171
|
+
* a fire-and-forget refresh when stale (not deadline-bound: it is never
|
|
172
|
+
* awaited, so a slow or hung refresh here cannot block anything); or,
|
|
173
|
+
* when there is no cache at all, one awaited refresh bounded by an
|
|
174
|
+
* *overall* deadline ({@link SessionTargetDeps.firstFetchDeadlineMs},
|
|
175
|
+
* default 5000ms) — not merely the hub client's own per-request timeout,
|
|
176
|
+
* which alone does not bound the whole sequence of a lazy auth,
|
|
177
|
+
* pagination, and a possible 401 retry. The deadline is enforced with an
|
|
178
|
+
* `AbortSignal` composed, per request, with that request's own
|
|
179
|
+
* per-request timeout (see `pocketbase-client.ts#rawFetch`). Notifies
|
|
180
|
+
* "hub unreachable" at most once when no snapshot is available at all,
|
|
181
|
+
* whether because the hub failed outright or because the deadline fired
|
|
182
|
+
* first — both are treated identically.
|
|
183
|
+
*/
|
|
184
|
+
async function getSnapshot(ctx: ExtensionContext): Promise<CatalogSnapshot | undefined> {
|
|
185
|
+
const cached = deps.catalog.read();
|
|
186
|
+
if (cached) {
|
|
187
|
+
if (deps.catalog.isStale()) {
|
|
188
|
+
void deps.catalog.refresh();
|
|
189
|
+
}
|
|
190
|
+
return cached;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const timer = scheduleTimeout(() => controller.abort(), firstFetchDeadlineMs);
|
|
195
|
+
let fresh: CatalogSnapshot | undefined;
|
|
196
|
+
try {
|
|
197
|
+
fresh = await deps.catalog.refresh(controller.signal);
|
|
198
|
+
} finally {
|
|
199
|
+
cancelTimeout(timer);
|
|
200
|
+
}
|
|
201
|
+
if (!fresh) {
|
|
202
|
+
notifyUnreachableOnce(ctx);
|
|
203
|
+
}
|
|
204
|
+
return fresh;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function runPicker(pi: ExtensionAPI, ctx: ExtensionContext, snapshot: CatalogSnapshot): Promise<void> {
|
|
208
|
+
const result = await pickTarget(ctx, snapshot);
|
|
209
|
+
|
|
210
|
+
if (result.kind === "skipped") {
|
|
211
|
+
sessionOverride = "skipped";
|
|
212
|
+
pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, { skipped: true });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const ids = candidateFrom(result.target);
|
|
217
|
+
sessionOverride = ids;
|
|
218
|
+
pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(ids));
|
|
219
|
+
|
|
220
|
+
const remember = await ctx.ui.confirm("kankaku", `Remember ${formatWorkTargetLabel(result.target)} for this repository?`);
|
|
221
|
+
if (remember) {
|
|
222
|
+
deps.persistProjectConfig(ids);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function ensurePicked(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
227
|
+
if (deps.role !== "orchestrator" || !ctx.hasUI) return;
|
|
228
|
+
if (sessionOverride !== undefined) return;
|
|
229
|
+
|
|
230
|
+
const snapshot = await getSnapshot(ctx);
|
|
231
|
+
if (!snapshot) return;
|
|
232
|
+
|
|
233
|
+
const projectIds = deps.resolveProjectConfigIds();
|
|
234
|
+
const resolved = resolveWorkTarget({
|
|
235
|
+
project: projectIds,
|
|
236
|
+
cwd: cwd(),
|
|
237
|
+
clients: snapshot.clients,
|
|
238
|
+
projects: snapshot.projects,
|
|
239
|
+
});
|
|
240
|
+
if (resolved) return;
|
|
241
|
+
|
|
242
|
+
await runPicker(pi, ctx, snapshot);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function pick(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
246
|
+
const snapshot = await getSnapshot(ctx);
|
|
247
|
+
if (!snapshot) return;
|
|
248
|
+
await runPicker(pi, ctx, snapshot);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function setExplicit(pi: ExtensionAPI, ids: WorkTargetCandidate): void {
|
|
252
|
+
sessionOverride = ids;
|
|
253
|
+
pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(ids));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function clear(pi: ExtensionAPI): void {
|
|
257
|
+
sessionOverride = undefined;
|
|
258
|
+
pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, {});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { restore, ensurePicked, pick, setExplicit, clear, effectiveTarget, effectiveSource, runTarget, idleTarget, endRun };
|
|
262
|
+
}
|