kankaku 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -115,7 +115,7 @@ left untouched — and installs the slash commands:
115
115
  `--plugin-dir` flag needed**.
116
116
 
117
117
  It also installs the `/kankaku:*` slash commands (`/kankaku:report`,
118
- `/kankaku:status`, `/kankaku:task`, `/kankaku:sync`, …) as user commands under
118
+ `/kankaku:status`, `/kankaku:task`, `/kankaku:target`, `/kankaku:sync`, …) as user commands under
119
119
  `~/.claude/commands/kankaku/<name>.md`, generated from the plugin's own
120
120
  `commands/*.md` with the plugin's absolute install path filled in. Setup
121
121
  lists each file it wrote or left unchanged; `kankaku doctor` reports Claude
package/dist/cli.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "Terminal app that shows today's kankaku work across every project on disk",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "ink": "^7.1.1",
41
- "kankaku-pi": "^1.3.1",
42
- "kankaku-claude": "^1.3.1",
41
+ "kankaku-claude": "^1.4.1",
43
42
  "kankaku-hub": "^0.3.0",
43
+ "kankaku-pi": "^1.4.1",
44
44
  "react": "^19.3.0"
45
45
  },
46
46
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-pi",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
5
  "license": "MIT",
6
6
  "author": "soyunninja",
@@ -61,6 +61,7 @@ export class CachedCatalog implements Catalog {
61
61
  private readonly deps: CachedCatalogDeps;
62
62
  private memo: CatalogSnapshot | undefined;
63
63
  private memoized = false;
64
+ private pendingAdoptions = new Set<Map<string, HubTask>>();
64
65
 
65
66
  constructor(deps: CachedCatalogDeps) {
66
67
  this.deps = deps;
@@ -74,6 +75,16 @@ export class CachedCatalog implements Catalog {
74
75
  return this.memo;
75
76
  }
76
77
 
78
+ /** Adopt a POST response without relying on an eventually-consistent list refresh. */
79
+ adoptTask(task: HubTask): void {
80
+ const snapshot = this.read();
81
+ if (!snapshot) throw new Error("Cannot adopt a task without a catalog snapshot");
82
+ const adopted = { ...snapshot, tasks: [...(snapshot.tasks ?? []).filter((item) => item.id !== task.id), task] };
83
+ this.writeDisk(adopted, true);
84
+ this.memo = adopted;
85
+ for (const pending of this.pendingAdoptions) pending.set(task.id, task);
86
+ }
87
+
77
88
  isStale(): boolean {
78
89
  const snapshot = this.read();
79
90
  if (!snapshot) return true;
@@ -82,6 +93,8 @@ export class CachedCatalog implements Catalog {
82
93
  }
83
94
 
84
95
  async refresh(signal?: AbortSignal): Promise<CatalogSnapshot | undefined> {
96
+ const adopted = new Map<string, HubTask>();
97
+ this.pendingAdoptions.add(adopted);
85
98
  try {
86
99
  const { clients, projects, tasks } = await this.deps.fetchCatalog(signal);
87
100
  const snapshot: CatalogSnapshot = {
@@ -89,7 +102,7 @@ export class CachedCatalog implements Catalog {
89
102
  url: this.deps.url,
90
103
  clients,
91
104
  projects,
92
- ...(tasks !== undefined ? { tasks } : {}),
105
+ ...(tasks !== undefined || adopted.size > 0 ? { tasks: [...(tasks ?? []).filter((task) => !adopted.has(task.id)), ...adopted.values()] } : {}),
93
106
  };
94
107
  this.writeDisk(snapshot);
95
108
  this.memo = snapshot;
@@ -97,6 +110,8 @@ export class CachedCatalog implements Catalog {
97
110
  return snapshot;
98
111
  } catch {
99
112
  return undefined;
113
+ } finally {
114
+ this.pendingAdoptions.delete(adopted);
100
115
  }
101
116
  }
102
117
 
@@ -112,7 +127,7 @@ export class CachedCatalog implements Catalog {
112
127
  }
113
128
  }
114
129
 
115
- private writeDisk(snapshot: CatalogSnapshot): void {
130
+ private writeDisk(snapshot: CatalogSnapshot, required = false): void {
116
131
  try {
117
132
  // This cache lives under `~/.kankaku` (machine-wide, not a project's
118
133
  // own KANKAKU_DIR) — but `~/.kankaku` itself is never mode-tightened
@@ -139,7 +154,8 @@ export class CachedCatalog implements Catalog {
139
154
  // Owner-only: this file names every client/project the machine's user has touched.
140
155
  writeFileSync(tmp, JSON.stringify(snapshot), { mode: OWNER_FILE_MODE });
141
156
  renameSync(tmp, this.deps.filePath);
142
- } catch {
157
+ } catch (error) {
158
+ if (required) throw error;
143
159
  // Best-effort cache write: a failure here must not fail the refresh
144
160
  // itself, since the in-memory snapshot is still usable this process.
145
161
  }
@@ -194,7 +194,7 @@ const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export
194
194
  /** Only offered when the hub is configured, so completions are unchanged for users without one. */
195
195
  const HUB_COMMAND_TOKENS = ["target", "task", "projects", "catalog", "sync", "backfill"];
196
196
  const TARGET_TOKENS = ["pick", "clear"];
197
- const TASK_TOKENS = ["pick", "clear"];
197
+ const TASK_TOKENS = ["pick", "clear", "new"];
198
198
  const CATALOG_TOKENS = ["refresh"];
199
199
  const SYNC_TOKENS = ["all", "status"];
200
200
 
@@ -480,7 +480,7 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
480
480
  }
481
481
 
482
482
  /**
483
- * Handle `/kankaku task [pick|clear]`; `rest` excludes the leading `task`
483
+ * Handle `/kankaku task [pick|clear|new <title> [--ref <externalRef>]]`; `rest` excludes the leading `task`
484
484
  * token. Default (no args) is `pick`. `pickTask`/`clearTask`
485
485
  * (`session-target.ts`) notify their own outcome directly, so this only
486
486
  * dispatches and refreshes the idle status line.
@@ -505,6 +505,19 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
505
505
  return;
506
506
  }
507
507
 
508
+ if (rest[0] === "new") {
509
+ const refIndex = rest.indexOf("--ref");
510
+ const title = rest.slice(1, refIndex === -1 ? undefined : refIndex).join(" ").trim();
511
+ const externalRef = refIndex === -1 ? undefined : rest[refIndex + 1]?.trim();
512
+ if (!title || (refIndex !== -1 && (!externalRef || refIndex !== rest.length - 2))) {
513
+ notifyError(ctx, new Error("usage: /kankaku task new <title> [--ref <externalRef>]"));
514
+ return;
515
+ }
516
+ await sessionTarget.newTask(pi, ctx, title, externalRef);
517
+ deps.refreshIdleStatus(ctx);
518
+ return;
519
+ }
520
+
508
521
  notifyError(ctx, new Error(`unknown task subcommand: ${rest.join(" ")}`));
509
522
  }
510
523
 
@@ -598,7 +611,8 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
598
611
  "When a hub (PocketBase) is configured: 'target' to show the effective client/project and its source, " +
599
612
  "'target pick' to run the picker again, 'target clear' to clear the session target, " +
600
613
  "'task' (or 'task pick') to link this session to an open/doing hub task of the effective project, " +
601
- "'task clear' to drop the link, " +
614
+ "'task clear' to drop the link, 'task new <title> [--ref <externalRef>]' to create and link an open task; " +
615
+ "the visual task picker also offers creation, " +
602
616
  "'catalog refresh' to force a catalog refresh, 'projects' for per-project totals today ('projects all' for every day), " +
603
617
  "'sync' to push pending tasks to the hub ('sync all' for a full re-evaluation, 'sync status' for the watermark/pending count/last error), " +
604
618
  "'backfill' to run a full sync and report how many tasks went to Sin determinar, grouped by their old label. " +
@@ -23,6 +23,23 @@ interface TaskRecord extends PocketBaseRecord {
23
23
  external_ref?: string;
24
24
  }
25
25
 
26
+ export interface CreateHubTaskInput {
27
+ title: string;
28
+ projectId: string;
29
+ externalRef?: string;
30
+ }
31
+
32
+ /** Explicit user-driven creation; never called from hook paths. */
33
+ export async function createHubTask(client: PocketBaseClient, input: CreateHubTaskInput, signal?: AbortSignal): Promise<HubTask> {
34
+ const record = await client.request<TaskRecord>("POST", "/api/collections/tasks/records", {
35
+ title: input.title,
36
+ project: input.projectId,
37
+ status: "open",
38
+ ...(input.externalRef !== undefined ? { external_ref: input.externalRef } : {}),
39
+ }, signal);
40
+ return mapTask(record);
41
+ }
42
+
26
43
  const TASK_STATUSES = new Set(["open", "doing", "done"]);
27
44
 
28
45
  function mapClient(record: ClientRecord): Client {
@@ -3,6 +3,7 @@ import { formatWorkTargetLabel, resolveWorkTarget, resolveWorkTargetSource } fro
3
3
  import type { WorkTarget, WorkTargetCandidate, WorkTargetSessionOverride, WorkTargetSourceName } from "../domain/work-target.ts";
4
4
  import type { WorkRole } from "../domain/work-record.ts";
5
5
  import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
6
+ import type { TaskCreator } from "./target-picker.ts";
6
7
  import { pickHubTask, pickTarget } from "./target-picker.ts";
7
8
 
8
9
  /** Persisted as a `kankaku-target` custom session entry so the session-level target survives a reload. */
@@ -14,8 +15,7 @@ export interface KankakuTargetEntryData {
14
15
  /**
15
16
  * A hub task picked for this session (`/kankaku task pick`). Session-only:
16
17
  * never written to the project's `.kankaku/config.json`, and dropped by
17
- * any target change (`pick`/`setExplicit`/`clear`/`ensurePicked`'s
18
- * silent resolution) since those all build a fresh candidate without it.
18
+ * target changes unless a new task is selected in the target picker.
19
19
  */
20
20
  hubTaskId?: string;
21
21
  }
@@ -25,6 +25,8 @@ export const TARGET_ENTRY_TYPE = "kankaku-target";
25
25
  export interface SessionTargetDeps {
26
26
  role: WorkRole;
27
27
  catalog: Catalog;
28
+ /** Creates and adopts the returned task before linking it. */
29
+ createTask?: TaskCreator;
28
30
  /** Lazily reads `clientId`/`projectId` from `<kankaku dir>/config.json`. */
29
31
  resolveProjectConfigIds: () => WorkTargetCandidate | undefined;
30
32
  /** Persist `clientId`/`projectId` into `<kankaku dir>/config.json`, merging existing keys. */
@@ -81,6 +83,8 @@ export interface SessionTarget {
81
83
  pickTask(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void>;
82
84
  /** `/kankaku task clear`: drops the linked hub task, keeping the rest of the session target. A no-op when none is linked. */
83
85
  clearTask(pi: ExtensionAPI): void;
86
+ /** Create through the shared creator and link only in the current session. */
87
+ newTask(pi: ExtensionAPI, ctx: ExtensionContext, title: string, externalRef?: string): Promise<void>;
84
88
  /**
85
89
  * Set the linked hub task directly, bypassing the picker — the panel's
86
90
  * target screen (`adapters/panel/screens/target.ts`) uses this once the
@@ -291,7 +295,7 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
291
295
  }
292
296
 
293
297
  async function runPicker(pi: ExtensionAPI, ctx: ExtensionContext, snapshot: CatalogSnapshot): Promise<void> {
294
- const result = await pickTarget(ctx, snapshot);
298
+ const result = await pickTarget(ctx, snapshot, deps.createTask);
295
299
 
296
300
  if (result.kind === "skipped") {
297
301
  sessionOverride = "skipped";
@@ -299,13 +303,17 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
299
303
  return;
300
304
  }
301
305
 
302
- const ids = candidateFrom(result.target);
306
+ const projectIds = candidateFrom(result.target);
307
+ const ids: WorkTargetCandidate = {
308
+ ...projectIds,
309
+ ...(result.target.hubTaskId !== undefined ? { hubTaskId: result.target.hubTaskId } : {}),
310
+ };
303
311
  sessionOverride = ids;
304
312
  pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(ids));
305
313
 
306
314
  const remember = await ctx.ui.confirm("kankaku", `Remember ${formatWorkTargetLabel(result.target)} for this repository?`);
307
315
  if (remember) {
308
- deps.persistProjectConfig(ids);
316
+ deps.persistProjectConfig(projectIds);
309
317
  }
310
318
  }
311
319
 
@@ -390,7 +398,7 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
390
398
  }
391
399
 
392
400
  const tasks = deps.catalog.read()?.tasks ?? [];
393
- const result = await pickHubTask(ctx, tasks, target.projectId);
401
+ const result = await pickHubTask(ctx, tasks, target.projectId, deps.createTask);
394
402
 
395
403
  if (result.kind === "empty") {
396
404
  ctx.ui.notify(`kankaku: No open tasks for ${target.projectName ?? target.projectId} in the hub`, "warning");
@@ -404,6 +412,19 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
404
412
  if (updated) ctx.ui.notify(`kankaku: task set to ${formatWorkTargetLabel(updated)}`);
405
413
  }
406
414
 
415
+ async function newTask(pi: ExtensionAPI, ctx: ExtensionContext, title: string, externalRef?: string): Promise<void> {
416
+ if (deps.role !== "orchestrator" || !ctx.hasUI) return;
417
+ const target = effectiveTarget();
418
+ if (!target || target.projectId === undefined) {
419
+ ctx.ui.notify("kankaku: Pick a client and project first (/kankaku target pick)", "warning");
420
+ return;
421
+ }
422
+ if (!deps.createTask) throw new Error("task creation is not configured");
423
+ const task = await deps.createTask(target.projectId, title, externalRef);
424
+ setTask(pi, task.id);
425
+ ctx.ui.notify(`kankaku: created and linked: ${task.title}`);
426
+ }
427
+
407
428
  function clearTask(pi: ExtensionAPI): void {
408
429
  if (sessionOverride === undefined || sessionOverride === "skipped") return;
409
430
  if (sessionOverride.hubTaskId === undefined) return;
@@ -420,6 +441,7 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
420
441
  clear,
421
442
  pickTask,
422
443
  clearTask,
444
+ newTask,
423
445
  setTask,
424
446
  rememberTarget,
425
447
  effectiveTarget,
@@ -3,10 +3,13 @@ import type { Client, HubTask, Project, WorkTarget } from "../domain/work-target
3
3
 
4
4
  const SKIP_OPTION = "— skip —";
5
5
  const NO_PROJECT_OPTION = "(no project)";
6
+ const CREATE_OPTION = "+ create task +";
7
+ export type TaskCreator = (projectId: string, title: string, externalRef?: string) => Promise<HubTask>;
6
8
 
7
9
  export interface PickerCatalog {
8
10
  clients: Client[];
9
11
  projects: Project[];
12
+ tasks?: HubTask[];
10
13
  }
11
14
 
12
15
  export type PickResult = { kind: "picked"; target: WorkTarget } | { kind: "skipped" };
@@ -36,14 +39,15 @@ function labelOptions<T extends { name: string; code?: string }>(items: T[]): La
36
39
  }
37
40
 
38
41
  /**
39
- * Run the client/project picker (`ctx.ui.select`) against an already-loaded
42
+ * Run the client/project/task picker (`ctx.ui.select`) against an already-loaded
40
43
  * catalog snapshot. Pure UI interaction: no network, no persistence — the
41
44
  * caller (`session-target.ts`) decides what to do with the result.
42
45
  *
43
- * Declining at either step — choosing "— skip —" or dismissing the dialog
46
+ * Declining at either client/project step — choosing "— skip —" or dismissing the dialog
44
47
  * (`undefined`) — cancels the whole pick, not just that step.
48
+ * Skipping the optional task step keeps the selected client/project.
45
49
  */
46
- export async function pickTarget(ctx: ExtensionContext, catalog: PickerCatalog): Promise<PickResult> {
50
+ export async function pickTarget(ctx: ExtensionContext, catalog: PickerCatalog, createTask?: TaskCreator): Promise<PickResult> {
47
51
  const pickableClients = catalog.clients.filter((client) => client.active && !client.unassigned);
48
52
  const clientOptions = labelOptions(pickableClients);
49
53
 
@@ -78,6 +82,14 @@ export async function pickTarget(ctx: ExtensionContext, catalog: PickerCatalog):
78
82
  : {}),
79
83
  };
80
84
 
85
+ if (project) {
86
+ const taskResult = await pickHubTask(ctx, catalog.tasks ?? [], project.id, createTask);
87
+ if (taskResult.kind === "picked") {
88
+ target.hubTaskId = taskResult.task.id;
89
+ target.hubTaskTitle = taskResult.task.title;
90
+ }
91
+ }
92
+
81
93
  return { kind: "picked", target };
82
94
  }
83
95
 
@@ -112,13 +124,29 @@ function labelTaskOptions(tasks: HubTask[]): LabeledOption<HubTask>[] {
112
124
  * has no open/doing task, so the caller can tell "nothing to pick from"
113
125
  * apart from "the user skipped".
114
126
  */
115
- export async function pickHubTask(ctx: ExtensionContext, tasks: HubTask[], projectId: string): Promise<PickHubTaskResult> {
127
+ export async function pickHubTask(ctx: ExtensionContext, tasks: HubTask[], projectId: string, createTask?: TaskCreator): Promise<PickHubTaskResult> {
116
128
  const pickable = tasks.filter((task) => task.status !== "done" && task.projectId === projectId);
117
- if (pickable.length === 0) return { kind: "empty" };
129
+ if (pickable.length === 0 && !createTask) return { kind: "empty" };
118
130
 
119
131
  const taskOptions = labelTaskOptions(pickable);
120
- const choice = await ctx.ui.select("kankaku — task", [...taskOptions.map((option) => option.label), SKIP_OPTION]);
132
+ const labels = taskOptions.map((option) => option.label);
133
+ let createOption = CREATE_OPTION;
134
+ for (let suffix = 2; labels.includes(createOption); suffix++) {
135
+ createOption = `${CREATE_OPTION} (${suffix})`;
136
+ }
137
+ const choice = await ctx.ui.select("kankaku — task", [...labels, ...(createTask ? [createOption] : []), SKIP_OPTION]);
121
138
  if (choice === undefined || choice === SKIP_OPTION) return { kind: "skipped" };
139
+ if (choice === createOption && createTask) {
140
+ const title = (await ctx.ui.input("kankaku — task title"))?.trim();
141
+ if (!title) return { kind: "skipped" };
142
+ const externalRef = (await ctx.ui.input("kankaku — external reference (optional)"))?.trim();
143
+ try {
144
+ return { kind: "picked", task: await createTask(projectId, title, externalRef || undefined) };
145
+ } catch {
146
+ ctx.ui.notify("kankaku: task creation failed; no task linked", "error");
147
+ return { kind: "skipped" };
148
+ }
149
+ }
122
150
 
123
151
  const task = taskOptions.find((option) => option.label === choice)?.item;
124
152
  if (!task) return { kind: "skipped" };
@@ -11,7 +11,7 @@ import { LazyProjectClientSource, LazyProjectTargetSource } from "./adapters/pro
11
11
  import { LazyExportWriter } from "./adapters/export-writer.ts";
12
12
  import { resolveHubCredentials, safeHomeDir } from "./adapters/hub-credentials.ts";
13
13
  import { PocketBaseClient } from "./adapters/pocketbase-client.ts";
14
- import { createPocketBaseCatalogFetcher } from "./adapters/pocketbase-catalog.ts";
14
+ import { createHubTask, createPocketBaseCatalogFetcher } from "./adapters/pocketbase-catalog.ts";
15
15
  import { CachedCatalog } from "./adapters/cached-catalog.ts";
16
16
  import { createSessionTarget } from "./adapters/session-target.ts";
17
17
  import { resolveKankakuDir, resolveWritableTarget } from "./adapters/kankaku-dir.ts";
@@ -212,7 +212,7 @@ export default function kankaku(pi: ExtensionAPI): void {
212
212
  // when no home directory is available so an env-only hub configuration
213
213
  // still works without one (the cache just does not survive a reboot).
214
214
  const homeDirForCache = safeHomeDir(homedir) ?? tmpdir();
215
- catalog = new CachedCatalog({
215
+ const cachedCatalog = new CachedCatalog({
216
216
  // Machine-wide cache: several projects on the same machine share one
217
217
  // catalog fetch, and it survives across projects.
218
218
  filePath: join(homeDirForCache, ".kankaku", "catalog.json"),
@@ -221,10 +221,16 @@ export default function kankaku(pi: ExtensionAPI): void {
221
221
  fetchCatalog: createPocketBaseCatalogFetcher(client),
222
222
  });
223
223
 
224
+ catalog = cachedCatalog;
224
225
  const projectTarget = new LazyProjectTargetSource(config.dir);
225
226
  sessionTarget = createSessionTarget({
226
227
  role,
227
228
  catalog,
229
+ createTask: async (projectId, title, externalRef) => {
230
+ const task = await createHubTask(client, { projectId, title, ...(externalRef ? { externalRef } : {}) });
231
+ cachedCatalog.adoptTask(task);
232
+ return task;
233
+ },
228
234
  resolveProjectConfigIds: () => projectTarget.read(),
229
235
  persistProjectConfig: (ids) => projectTarget.write(ids),
230
236
  });