kankaku-pi 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1438 -0
- package/dist/adapters/cached-catalog.d.ts +42 -0
- package/dist/adapters/cached-catalog.js +121 -0
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/file-modes.d.ts +20 -0
- package/dist/adapters/file-modes.js +34 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -0
- package/dist/adapters/hub-credentials.d.ts +35 -0
- package/dist/adapters/hub-credentials.js +58 -0
- package/dist/adapters/jsonl-work-log.d.ts +20 -0
- package/dist/adapters/jsonl-work-log.js +62 -0
- package/dist/adapters/kankaku-dir.d.ts +38 -0
- package/dist/adapters/kankaku-dir.js +85 -0
- package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
- package/dist/adapters/lazy-jsonl-work-log.js +31 -0
- package/dist/adapters/pocketbase-catalog.d.ts +16 -0
- package/dist/adapters/pocketbase-catalog.js +56 -0
- package/dist/adapters/pocketbase-client.d.ts +81 -0
- package/dist/adapters/pocketbase-client.js +148 -0
- package/dist/adapters/pocketbase-sink.d.ts +53 -0
- package/dist/adapters/pocketbase-sink.js +181 -0
- package/dist/adapters/project-config.d.ts +42 -0
- package/dist/adapters/project-config.js +108 -0
- package/dist/adapters/report-data.d.ts +12 -0
- package/dist/adapters/report-data.js +8 -0
- package/dist/adapters/report-views.d.ts +45 -0
- package/dist/adapters/report-views.js +73 -0
- package/dist/adapters/report.d.ts +112 -0
- package/dist/adapters/report.js +236 -0
- package/dist/adapters/sync-runner.d.ts +114 -0
- package/dist/adapters/sync-runner.js +273 -0
- package/dist/adapters/sync-state-store.d.ts +62 -0
- package/dist/adapters/sync-state-store.js +188 -0
- package/dist/config.d.ts +168 -0
- package/dist/config.js +392 -0
- package/dist/domain/ancestry-match.d.ts +49 -0
- package/dist/domain/ancestry-match.js +82 -0
- package/dist/domain/client-label.d.ts +28 -0
- package/dist/domain/client-label.js +44 -0
- package/dist/domain/day.d.ts +2 -0
- package/dist/domain/day.js +8 -0
- package/dist/domain/export.d.ts +38 -0
- package/dist/domain/export.js +68 -0
- package/dist/domain/hub-entry.d.ts +234 -0
- package/dist/domain/hub-entry.js +265 -0
- package/dist/domain/index.d.ts +19 -0
- package/dist/domain/index.js +19 -0
- package/dist/domain/intervals.d.ts +17 -0
- package/dist/domain/intervals.js +43 -0
- package/dist/domain/registry-health.d.ts +49 -0
- package/dist/domain/registry-health.js +58 -0
- package/dist/domain/segment-rule.d.ts +10 -0
- package/dist/domain/segment-rule.js +1 -0
- package/dist/domain/subagent-profile.d.ts +278 -0
- package/dist/domain/subagent-profile.js +418 -0
- package/dist/domain/sync-plan.d.ts +151 -0
- package/dist/domain/sync-plan.js +196 -0
- package/dist/domain/task-view.d.ts +117 -0
- package/dist/domain/task-view.js +428 -0
- package/dist/domain/work-record.d.ts +236 -0
- package/dist/domain/work-record.js +91 -0
- package/dist/domain/work-target.d.ts +101 -0
- package/dist/domain/work-target.js +149 -0
- package/dist/domain/work-tracker.d.ts +90 -0
- package/dist/domain/work-tracker.js +405 -0
- package/dist/hub/index.d.ts +25 -0
- package/dist/hub/index.js +25 -0
- package/dist/ports/catalog.d.ts +31 -0
- package/dist/ports/catalog.js +1 -0
- package/dist/ports/clock.d.ts +3 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/index.d.ts +11 -0
- package/dist/ports/index.js +1 -0
- package/dist/ports/inflight-store.d.ts +15 -0
- package/dist/ports/inflight-store.js +1 -0
- package/dist/ports/process-registry.d.ts +72 -0
- package/dist/ports/process-registry.js +1 -0
- package/dist/ports/work-log.d.ts +14 -0
- package/dist/ports/work-log.js +1 -0
- package/dist/ports/work-sink.d.ts +39 -0
- package/dist/ports/work-sink.js +1 -0
- package/package.json +66 -0
- package/src/adapters/agent-info.ts +86 -0
- package/src/adapters/ancestry.ts +260 -0
- package/src/adapters/cached-catalog.ts +147 -0
- package/src/adapters/export-writer.ts +33 -0
- package/src/adapters/file-inflight-store.ts +115 -0
- package/src/adapters/file-modes.ts +35 -0
- package/src/adapters/hub-actions.ts +82 -0
- package/src/adapters/hub-credentials.ts +95 -0
- package/src/adapters/jsonl-work-log.ts +67 -0
- package/src/adapters/kankaku-command.ts +717 -0
- package/src/adapters/kankaku-dir.ts +102 -0
- package/src/adapters/lazy-file-inflight-store.ts +43 -0
- package/src/adapters/lazy-jsonl-work-log.ts +39 -0
- package/src/adapters/machine-process-registry.ts +256 -0
- package/src/adapters/panel/kankaku-panel.ts +419 -0
- package/src/adapters/panel/panel-items.ts +87 -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 +89 -0
- package/src/adapters/panel/screens/export.ts +123 -0
- package/src/adapters/panel/screens/report.ts +143 -0
- package/src/adapters/panel/screens/sync.ts +136 -0
- package/src/adapters/panel/screens/target.ts +384 -0
- package/src/adapters/pi-tracker.ts +753 -0
- package/src/adapters/pocketbase-catalog.ts +89 -0
- package/src/adapters/pocketbase-client.ts +197 -0
- package/src/adapters/pocketbase-sink.ts +236 -0
- package/src/adapters/process-identity-memo.ts +102 -0
- package/src/adapters/process-identity.ts +162 -0
- package/src/adapters/project-config.ts +116 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +335 -0
- package/src/adapters/session-client.ts +116 -0
- package/src/adapters/session-dir.ts +28 -0
- package/src/adapters/session-target.ts +431 -0
- package/src/adapters/status-bar.ts +86 -0
- package/src/adapters/subagent-startup.ts +66 -0
- package/src/adapters/sync-runner.ts +340 -0
- package/src/adapters/sync-state-store.ts +227 -0
- package/src/adapters/target-picker.ts +127 -0
- package/src/config.ts +536 -0
- package/src/domain/ancestry-match.ts +84 -0
- package/src/domain/client-label.ts +56 -0
- package/src/domain/day.ts +8 -0
- package/src/domain/export.ts +107 -0
- package/src/domain/hub-entry.ts +433 -0
- package/src/domain/index.ts +19 -0
- package/src/domain/intervals.ts +53 -0
- package/src/domain/panel-model.ts +270 -0
- package/src/domain/registry-health.ts +87 -0
- package/src/domain/segment-rule.ts +10 -0
- package/src/domain/subagent-profile.ts +495 -0
- package/src/domain/sync-plan.ts +266 -0
- package/src/domain/task-view.ts +526 -0
- package/src/domain/work-record.ts +320 -0
- package/src/domain/work-target.ts +234 -0
- package/src/domain/work-tracker.ts +485 -0
- package/src/extension.ts +346 -0
- package/src/hub/index.ts +25 -0
- package/src/ports/catalog.ts +33 -0
- package/src/ports/clock.ts +3 -0
- package/src/ports/index.ts +11 -0
- package/src/ports/inflight-store.ts +16 -0
- package/src/ports/process-registry.ts +75 -0
- package/src/ports/work-log.ts +15 -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
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { WorkTargetCandidate } from "../domain/work-target.ts";
|
|
4
|
+
import { resolveKankakuDir } from "./kankaku-dir.ts";
|
|
5
|
+
|
|
6
|
+
const CONFIG_FILE_NAME = "config.json";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read the project's default billing client from `<dir>/config.json`
|
|
10
|
+
* (`{ "client": "acme" }`), the lowest-precedence source in
|
|
11
|
+
* `domain/client-label.ts#resolveClient`. `dir` is the kankaku dir (same
|
|
12
|
+
* directory as the work log).
|
|
13
|
+
*
|
|
14
|
+
* Tolerates a missing file, malformed JSON, a non-object document, or a
|
|
15
|
+
* `client` field that is not a string — all return `undefined` rather than
|
|
16
|
+
* throwing, since this file is optional and hand-edited.
|
|
17
|
+
*/
|
|
18
|
+
export function readProjectClient(dir: string): string | undefined {
|
|
19
|
+
const filePath = join(dir, CONFIG_FILE_NAME);
|
|
20
|
+
if (!existsSync(filePath)) return undefined;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8"));
|
|
24
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
25
|
+
const client = (parsed as Record<string, unknown>)["client"];
|
|
26
|
+
return typeof client === "string" ? client : undefined;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Reads the project client from a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
|
|
33
|
+
export class LazyProjectClientSource {
|
|
34
|
+
private readonly dirOrRelative: string;
|
|
35
|
+
private readonly fallbackCwd: () => string;
|
|
36
|
+
|
|
37
|
+
constructor(dirOrRelative: string, fallbackCwd: () => string = () => process.cwd()) {
|
|
38
|
+
this.dirOrRelative = dirOrRelative;
|
|
39
|
+
this.fallbackCwd = fallbackCwd;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
read(): string | undefined {
|
|
43
|
+
return readProjectClient(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()));
|
|
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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi-free home for {@link KankakuReportData}, the shape every `/kankaku`
|
|
3
|
+
* report view and the panel's report screen produce. Extracted out of
|
|
4
|
+
* `kankaku-command.ts` (which still re-exports it) so `report-views.ts` can
|
|
5
|
+
* be published through `kankaku-pi/hub` without pulling in anything that
|
|
6
|
+
* touches `@earendil-works/*` — see AGENTS.md "Code conventions".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Durable report rendered inside the chat transcript; never sent to the LLM. */
|
|
10
|
+
export interface KankakuReportData {
|
|
11
|
+
title: string;
|
|
12
|
+
lines: string[];
|
|
13
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five `/kankaku` report views (summary/tasks/sessions/clients/
|
|
3
|
+
* projects), each as one pure `WorkRecord[] -> KankakuReportData` builder.
|
|
4
|
+
* Extracted from `kankaku-command.ts`'s subcommand handlers (see
|
|
5
|
+
* odd/tasks/kankaku-panel.md P3) so the `/kankaku` subcommands and the
|
|
6
|
+
* panel's report screen (`panel/screens/report.ts`) always compute the
|
|
7
|
+
* exact same lines — neither ever re-implements the other's logic.
|
|
8
|
+
*/
|
|
9
|
+
import { buildSessions, buildTasks } from "../domain/task-view.ts";
|
|
10
|
+
import { exportRows, toCsv, toJson } from "../domain/export.ts";
|
|
11
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
12
|
+
import type { KankakuReportData } from "./report-data.ts";
|
|
13
|
+
import { countUncertain, formatClients, formatProjects, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient, summarizeByProject } from "./report.ts";
|
|
14
|
+
|
|
15
|
+
/** Shared by every view except `buildTasksView`: `all` includes every day, otherwise only today's local day. */
|
|
16
|
+
export interface ReportViewOptions {
|
|
17
|
+
all: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `buildTasksView`'s own options: `all` scopes to every session instead of local-day range (tasks are never day-filtered — see `kankaku-command.ts`'s original `tasks` handler). */
|
|
21
|
+
export interface TasksViewOptions {
|
|
22
|
+
all: boolean;
|
|
23
|
+
sessionId: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The plain-text summary view (`/kankaku` with no view token): role/task totals, plus a one-line hint when uncertain records were excluded (SUBAGENT-REQ-017). */
|
|
27
|
+
export function buildSummaryView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData {
|
|
28
|
+
const { all } = options;
|
|
29
|
+
const summary = summarize(records, { all });
|
|
30
|
+
const lines = formatReport(summary).split(" | ");
|
|
31
|
+
const uncertainCount = countUncertain(records, { all });
|
|
32
|
+
if (uncertainCount > 0) {
|
|
33
|
+
lines.push(`kankaku: ${uncertainCount} uncertain record(s) excluded from tasks — run /kankaku doctor`);
|
|
34
|
+
}
|
|
35
|
+
return { title: all ? "summary (all days)" : "summary (today)", lines };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `/kankaku clients [all]`: per-client totals. */
|
|
39
|
+
export function buildClientsView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData {
|
|
40
|
+
const { all } = options;
|
|
41
|
+
const today = localDay(new Date().toISOString());
|
|
42
|
+
const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
|
|
43
|
+
return { title: all ? "clients (all days)" : "clients (today)", lines: formatClients(summarizeByClient(tasks)).split("\n") };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `/kankaku projects [all]`: per-project totals. */
|
|
47
|
+
export function buildProjectsView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData {
|
|
48
|
+
const { all } = options;
|
|
49
|
+
const today = localDay(new Date().toISOString());
|
|
50
|
+
const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
|
|
51
|
+
return { title: all ? "projects (all days)" : "projects (today)", lines: formatProjects(summarizeByProject(tasks)).split("\n") };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `/kankaku sessions [all]`: per-session totals. */
|
|
55
|
+
export function buildSessionsView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData {
|
|
56
|
+
const { all } = options;
|
|
57
|
+
const today = localDay(new Date().toISOString());
|
|
58
|
+
const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
|
|
59
|
+
return { title: all ? "sessions (all days)" : "sessions (today)", lines: formatSessions(buildSessions(tasks)).split("\n") };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `/kankaku tasks [all]`: one line per task. Unlike every other view, tasks
|
|
64
|
+
* are never restricted by local day — `all` (or a missing `sessionId`)
|
|
65
|
+
* instead scopes from "this session" to "every session".
|
|
66
|
+
*/
|
|
67
|
+
export function buildTasksView(records: WorkRecord[], options: TasksViewOptions): KankakuReportData {
|
|
68
|
+
const { all, sessionId } = options;
|
|
69
|
+
const scoped = all || !sessionId;
|
|
70
|
+
const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
|
|
71
|
+
return { title: scoped ? "tasks (every session)" : "tasks (this session)", lines: formatTasks(tasks).split("\n") };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** {@link buildExportContent}'s options: `format` picks csv/json, `all` includes every day instead of just today's local day (mirrors every other view except `buildTasksView`). */
|
|
75
|
+
export interface ExportContentOptions {
|
|
76
|
+
format: "csv" | "json";
|
|
77
|
+
all: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `/kankaku export [csv|json] [all]`'s file content: the flat export rows
|
|
82
|
+
* for today's (or every) task, rendered as csv or json, with the file name
|
|
83
|
+
* `/kankaku export` and the panel's export screen (`panel/screens/
|
|
84
|
+
* export.ts`) both use. Extracted from `kankaku-command.ts`'s
|
|
85
|
+
* `handleExportCommand` (see odd/tasks/kankaku-panel.md P4) so the
|
|
86
|
+
* subcommand and the panel never drift. `rowCount` is exposed only for the
|
|
87
|
+
* subcommand's "wrote N row(s) to <path>" confirmation line — the panel's
|
|
88
|
+
* own confirmation is simpler ("wrote <path>").
|
|
89
|
+
*/
|
|
90
|
+
export function buildExportContent(records: WorkRecord[], options: ExportContentOptions): { name: string; content: string; rowCount: number } {
|
|
91
|
+
const { format, all } = options;
|
|
92
|
+
const today = localDay(new Date().toISOString());
|
|
93
|
+
const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
|
|
94
|
+
const rows = exportRows(tasks);
|
|
95
|
+
const content = format === "json" ? toJson(rows) : toCsv(rows);
|
|
96
|
+
const name = `tasks-${all ? "all" : today}.${format}`;
|
|
97
|
+
return { name, content, rowCount: rows.length };
|
|
98
|
+
}
|