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,42 @@
|
|
|
1
|
+
import type { Client, HubTask, Project } from "../domain/work-target.ts";
|
|
2
|
+
import type { Clock } from "../ports/clock.ts";
|
|
3
|
+
import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
|
|
4
|
+
export interface CachedCatalogDeps {
|
|
5
|
+
/** Absolute path to the cache file, e.g. `~/.kankaku/catalog.json`. */
|
|
6
|
+
filePath: string;
|
|
7
|
+
/** The configured hub URL; a cache written for a different URL is ignored. */
|
|
8
|
+
url: string;
|
|
9
|
+
clock: Clock;
|
|
10
|
+
/** Cache TTL in ms. Defaults to 6 hours. */
|
|
11
|
+
ttlMs?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Fetch a fresh `{ clients, projects, tasks }` triple, e.g.
|
|
14
|
+
* `createPocketBaseCatalogFetcher(...)`. `tasks` is optional here (unlike
|
|
15
|
+
* on the real PocketBase fetcher) so a test double that only cares about
|
|
16
|
+
* clients/projects keeps compiling unchanged.
|
|
17
|
+
*/
|
|
18
|
+
fetchCatalog: (signal?: AbortSignal) => Promise<{
|
|
19
|
+
clients: Client[];
|
|
20
|
+
projects: Project[];
|
|
21
|
+
tasks?: HubTask[];
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Disk-backed {@link Catalog}: `read()` is a synchronous, cheap read of the
|
|
26
|
+
* last known snapshot (memoised after the first disk read so repeated
|
|
27
|
+
* calls in one process never re-stat/re-parse); `refresh()` fetches, caches
|
|
28
|
+
* to disk atomically (tmp + rename, mirroring `file-inflight-store.ts`),
|
|
29
|
+
* and never throws — a failed refresh resolves `undefined` and leaves the
|
|
30
|
+
* previous snapshot (if any) untouched.
|
|
31
|
+
*/
|
|
32
|
+
export declare class CachedCatalog implements Catalog {
|
|
33
|
+
private readonly deps;
|
|
34
|
+
private memo;
|
|
35
|
+
private memoized;
|
|
36
|
+
constructor(deps: CachedCatalogDeps);
|
|
37
|
+
read(): CatalogSnapshot | undefined;
|
|
38
|
+
isStale(): boolean;
|
|
39
|
+
refresh(signal?: AbortSignal): Promise<CatalogSnapshot | undefined>;
|
|
40
|
+
private readDisk;
|
|
41
|
+
private writeDisk;
|
|
42
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { OWNER_DIR_MODE, OWNER_FILE_MODE } from "./file-modes.js";
|
|
4
|
+
/** Six hours in milliseconds — clients and projects change rarely. */
|
|
5
|
+
const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000;
|
|
6
|
+
function isClientArray(value) {
|
|
7
|
+
return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof item.id === "string");
|
|
8
|
+
}
|
|
9
|
+
function isProjectArray(value) {
|
|
10
|
+
return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof item.id === "string");
|
|
11
|
+
}
|
|
12
|
+
function isHubTaskArray(value) {
|
|
13
|
+
return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof item.id === "string");
|
|
14
|
+
}
|
|
15
|
+
function isCatalogSnapshot(value) {
|
|
16
|
+
if (!value || typeof value !== "object")
|
|
17
|
+
return false;
|
|
18
|
+
const record = value;
|
|
19
|
+
return (typeof record["fetchedAt"] === "number" &&
|
|
20
|
+
typeof record["url"] === "string" &&
|
|
21
|
+
isClientArray(record["clients"]) &&
|
|
22
|
+
isProjectArray(record["projects"]) &&
|
|
23
|
+
(record["tasks"] === undefined || isHubTaskArray(record["tasks"])));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Disk-backed {@link Catalog}: `read()` is a synchronous, cheap read of the
|
|
27
|
+
* last known snapshot (memoised after the first disk read so repeated
|
|
28
|
+
* calls in one process never re-stat/re-parse); `refresh()` fetches, caches
|
|
29
|
+
* to disk atomically (tmp + rename, mirroring `file-inflight-store.ts`),
|
|
30
|
+
* and never throws — a failed refresh resolves `undefined` and leaves the
|
|
31
|
+
* previous snapshot (if any) untouched.
|
|
32
|
+
*/
|
|
33
|
+
export class CachedCatalog {
|
|
34
|
+
deps;
|
|
35
|
+
memo;
|
|
36
|
+
memoized = false;
|
|
37
|
+
constructor(deps) {
|
|
38
|
+
this.deps = deps;
|
|
39
|
+
}
|
|
40
|
+
read() {
|
|
41
|
+
if (!this.memoized) {
|
|
42
|
+
this.memo = this.readDisk();
|
|
43
|
+
this.memoized = true;
|
|
44
|
+
}
|
|
45
|
+
return this.memo;
|
|
46
|
+
}
|
|
47
|
+
isStale() {
|
|
48
|
+
const snapshot = this.read();
|
|
49
|
+
if (!snapshot)
|
|
50
|
+
return true;
|
|
51
|
+
const ttl = this.deps.ttlMs ?? DEFAULT_TTL_MS;
|
|
52
|
+
return this.deps.clock.now() - snapshot.fetchedAt > ttl;
|
|
53
|
+
}
|
|
54
|
+
async refresh(signal) {
|
|
55
|
+
try {
|
|
56
|
+
const { clients, projects, tasks } = await this.deps.fetchCatalog(signal);
|
|
57
|
+
const snapshot = {
|
|
58
|
+
fetchedAt: this.deps.clock.now(),
|
|
59
|
+
url: this.deps.url,
|
|
60
|
+
clients,
|
|
61
|
+
projects,
|
|
62
|
+
...(tasks !== undefined ? { tasks } : {}),
|
|
63
|
+
};
|
|
64
|
+
this.writeDisk(snapshot);
|
|
65
|
+
this.memo = snapshot;
|
|
66
|
+
this.memoized = true;
|
|
67
|
+
return snapshot;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
readDisk() {
|
|
74
|
+
try {
|
|
75
|
+
if (!existsSync(this.deps.filePath))
|
|
76
|
+
return undefined;
|
|
77
|
+
const parsed = JSON.parse(readFileSync(this.deps.filePath, "utf8"));
|
|
78
|
+
if (!isCatalogSnapshot(parsed))
|
|
79
|
+
return undefined;
|
|
80
|
+
if (parsed.url !== this.deps.url)
|
|
81
|
+
return undefined;
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
writeDisk(snapshot) {
|
|
89
|
+
try {
|
|
90
|
+
// This cache lives under `~/.kankaku` (machine-wide, not a project's
|
|
91
|
+
// own KANKAKU_DIR) — but `~/.kankaku` itself is never mode-tightened
|
|
92
|
+
// here (R2): when pi runs with cwd === $HOME, a project's own default
|
|
93
|
+
// KANKAKU_DIR (`.kankaku`, relative) resolves to this exact same
|
|
94
|
+
// path, and a project's kankaku dir must never be tightened
|
|
95
|
+
// (AGENTS.md). Only what this package exclusively owns is touched:
|
|
96
|
+
// an EXISTING directory is never chmod'd — `mkdirSync`'s own `mode`
|
|
97
|
+
// option only ever applies to a directory this call actually
|
|
98
|
+
// creates, never one that already existed (Node skips the mkdir
|
|
99
|
+
// syscall for an existing path segment entirely, mode and all), so
|
|
100
|
+
// passing `mode` here is safe even though this path may be a
|
|
101
|
+
// project's own dir (G3). When this call IS the first writer (no
|
|
102
|
+
// `~/.kankaku` yet at all, e.g. a machine with the hub configured but
|
|
103
|
+
// no project ever run from $HOME), it is created owner-only
|
|
104
|
+
// (`OWNER_DIR_MODE`, 0700) rather than left at the umask default. The
|
|
105
|
+
// cache file itself is always written fresh via tmp+rename with
|
|
106
|
+
// OWNER_FILE_MODE below, which already guarantees 0600 on every
|
|
107
|
+
// write regardless of whatever mode an older file at this path (or
|
|
108
|
+
// an older kankaku build) left behind — a rename replaces the whole
|
|
109
|
+
// inode, so a stale looser mode can never survive a write.
|
|
110
|
+
mkdirSync(dirname(this.deps.filePath), { recursive: true, mode: OWNER_DIR_MODE });
|
|
111
|
+
const tmp = `${this.deps.filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
112
|
+
// Owner-only: this file names every client/project the machine's user has touched.
|
|
113
|
+
writeFileSync(tmp, JSON.stringify(snapshot), { mode: OWNER_FILE_MODE });
|
|
114
|
+
renameSync(tmp, this.deps.filePath);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Best-effort cache write: a failure here must not fail the refresh
|
|
118
|
+
// itself, since the in-memory snapshot is still usable this process.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write `content` to `<dir>/export/<name>` (creating the `export`
|
|
3
|
+
* subdirectory as needed, overwriting any existing file with the same
|
|
4
|
+
* name) and return the absolute path it was written to.
|
|
5
|
+
*/
|
|
6
|
+
export declare function writeExport(dir: string, name: string, content: string): string;
|
|
7
|
+
/** Writes exports under a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
|
|
8
|
+
export declare class LazyExportWriter {
|
|
9
|
+
private readonly dirOrRelative;
|
|
10
|
+
private readonly fallbackCwd;
|
|
11
|
+
constructor(dirOrRelative: string, fallbackCwd?: () => string);
|
|
12
|
+
write(name: string, content: string): string;
|
|
13
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { resolveKankakuDir } from "./kankaku-dir.js";
|
|
4
|
+
const EXPORT_SUBDIR = "export";
|
|
5
|
+
/**
|
|
6
|
+
* Write `content` to `<dir>/export/<name>` (creating the `export`
|
|
7
|
+
* subdirectory as needed, overwriting any existing file with the same
|
|
8
|
+
* name) and return the absolute path it was written to.
|
|
9
|
+
*/
|
|
10
|
+
export function writeExport(dir, name, content) {
|
|
11
|
+
const exportDir = join(dir, EXPORT_SUBDIR);
|
|
12
|
+
mkdirSync(exportDir, { recursive: true });
|
|
13
|
+
const filePath = resolve(join(exportDir, name));
|
|
14
|
+
writeFileSync(filePath, content);
|
|
15
|
+
return filePath;
|
|
16
|
+
}
|
|
17
|
+
/** Writes exports under a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
|
|
18
|
+
export class LazyExportWriter {
|
|
19
|
+
dirOrRelative;
|
|
20
|
+
fallbackCwd;
|
|
21
|
+
constructor(dirOrRelative, fallbackCwd = () => process.cwd()) {
|
|
22
|
+
this.dirOrRelative = dirOrRelative;
|
|
23
|
+
this.fallbackCwd = fallbackCwd;
|
|
24
|
+
}
|
|
25
|
+
write(name, content) {
|
|
26
|
+
return writeExport(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()), name, content);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every file kankaku writes under `~/.kankaku` (the machine-wide registry
|
|
3
|
+
* `run/`, the catalog cache) carries absolute project paths, session ids, or
|
|
4
|
+
* hub identifiers — restricted to the owner (F4). Not applied to a
|
|
5
|
+
* project's own `<KANKAKU_DIR>` (`worklog.jsonl`, `inflight/`, …), which is
|
|
6
|
+
* project-local, frequently committed alongside, and out of scope here.
|
|
7
|
+
*/
|
|
8
|
+
export declare const OWNER_DIR_MODE = 448;
|
|
9
|
+
export declare const OWNER_FILE_MODE = 384;
|
|
10
|
+
/**
|
|
11
|
+
* Create `dir` (recursive) with `mode`, then best-effort tighten it when it
|
|
12
|
+
* already existed with a looser mode — `mkdirSync`'s own `mode` option only
|
|
13
|
+
* applies to a directory it actually creates in this call, never to one
|
|
14
|
+
* that already existed (e.g. left by an older kankaku build, or a
|
|
15
|
+
* permissive umask). Never throws: a permission-tightening failure must
|
|
16
|
+
* never block the write this call exists to make.
|
|
17
|
+
*/
|
|
18
|
+
export declare function ensureDirMode(dir: string, mode?: number): void;
|
|
19
|
+
/** Best-effort `chmod` down to `mode` when the current mode is looser than it; never throws. */
|
|
20
|
+
export declare function tightenMode(path: string, mode?: number): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, statSync } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Every file kankaku writes under `~/.kankaku` (the machine-wide registry
|
|
4
|
+
* `run/`, the catalog cache) carries absolute project paths, session ids, or
|
|
5
|
+
* hub identifiers — restricted to the owner (F4). Not applied to a
|
|
6
|
+
* project's own `<KANKAKU_DIR>` (`worklog.jsonl`, `inflight/`, …), which is
|
|
7
|
+
* project-local, frequently committed alongside, and out of scope here.
|
|
8
|
+
*/
|
|
9
|
+
export const OWNER_DIR_MODE = 0o700;
|
|
10
|
+
export const OWNER_FILE_MODE = 0o600;
|
|
11
|
+
/**
|
|
12
|
+
* Create `dir` (recursive) with `mode`, then best-effort tighten it when it
|
|
13
|
+
* already existed with a looser mode — `mkdirSync`'s own `mode` option only
|
|
14
|
+
* applies to a directory it actually creates in this call, never to one
|
|
15
|
+
* that already existed (e.g. left by an older kankaku build, or a
|
|
16
|
+
* permissive umask). Never throws: a permission-tightening failure must
|
|
17
|
+
* never block the write this call exists to make.
|
|
18
|
+
*/
|
|
19
|
+
export function ensureDirMode(dir, mode = OWNER_DIR_MODE) {
|
|
20
|
+
mkdirSync(dir, { recursive: true, mode });
|
|
21
|
+
tightenMode(dir, mode);
|
|
22
|
+
}
|
|
23
|
+
/** Best-effort `chmod` down to `mode` when the current mode is looser than it; never throws. */
|
|
24
|
+
export function tightenMode(path, mode = OWNER_FILE_MODE) {
|
|
25
|
+
try {
|
|
26
|
+
const current = statSync(path).mode & 0o777;
|
|
27
|
+
if ((current & ~mode) !== 0)
|
|
28
|
+
chmodSync(path, mode);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Best effort: another process may have already removed the path, or
|
|
32
|
+
// this filesystem does not support POSIX modes at all.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared line-building for the hub-facing `/kankaku` subcommands (`sync`,
|
|
3
|
+
* `sync all`, `sync status`, `backfill`, `catalog refresh`) and the panel's
|
|
4
|
+
* `sync`/`export` screens (`adapters/panel/screens/sync.ts`), extracted from
|
|
5
|
+
* `kankaku-command.ts` so the subcommands and the panel can never drift —
|
|
6
|
+
* see odd/tasks/kankaku-panel.md P4.
|
|
7
|
+
*/
|
|
8
|
+
import type { CatalogSnapshot } from "../ports/catalog.ts";
|
|
9
|
+
import type { SyncStatusSnapshot, SyncSummary } from "./sync-runner.ts";
|
|
10
|
+
/**
|
|
11
|
+
* `/kankaku sync status`'s report lines: the watermark, the pending count,
|
|
12
|
+
* how many tasks fell outside this run's revisit window (R3), and the last
|
|
13
|
+
* error, when any. Exact body of `handleSyncCommand`'s `status` branch.
|
|
14
|
+
*/
|
|
15
|
+
export declare function buildSyncStatusLines(status: SyncStatusSnapshot): string[];
|
|
16
|
+
/**
|
|
17
|
+
* Render a {@link SyncSummary} as report lines: counts, any stop reason, the
|
|
18
|
+
* new watermark, and the unassigned/failed breakdowns. Used by `/kankaku
|
|
19
|
+
* sync`/`sync all` and the panel's sync screen.
|
|
20
|
+
*/
|
|
21
|
+
export declare function formatSyncSummaryLines(summary: SyncSummary): string[];
|
|
22
|
+
/**
|
|
23
|
+
* `/kankaku backfill`'s report lines: tasks routed to Sin determinar this
|
|
24
|
+
* run, grouped by their old free-text label, or "no unassigned tasks" when
|
|
25
|
+
* none were. Exact body of `handleBackfillCommand`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function formatBackfillLines(summary: SyncSummary): string[];
|
|
28
|
+
/**
|
|
29
|
+
* `/kankaku catalog refresh`'s report lines: client/project counts, or an
|
|
30
|
+
* unreachable notice when the refresh failed (`snapshot` is `undefined`).
|
|
31
|
+
* The subcommand still notifies that failure as an error rather than a
|
|
32
|
+
* report (see `handleCatalogCommand`); the panel's sync screen shows
|
|
33
|
+
* whatever this returns either way.
|
|
34
|
+
*/
|
|
35
|
+
export declare function formatCatalogRefreshLines(snapshot: CatalogSnapshot | undefined): string[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/kankaku sync status`'s report lines: the watermark, the pending count,
|
|
3
|
+
* how many tasks fell outside this run's revisit window (R3), and the last
|
|
4
|
+
* error, when any. Exact body of `handleSyncCommand`'s `status` branch.
|
|
5
|
+
*/
|
|
6
|
+
export function buildSyncStatusLines(status) {
|
|
7
|
+
const { state, pending, staleOutsideWindow } = status;
|
|
8
|
+
const lines = [state?.syncedThrough ? `synced through ${state.syncedThrough}` : "never synced", `pending: ${pending}`];
|
|
9
|
+
if (staleOutsideWindow > 0) {
|
|
10
|
+
lines.push(`${staleOutsideWindow} task(s) never synced fall outside the sync window — run '/kankaku sync all' to upload them`);
|
|
11
|
+
}
|
|
12
|
+
if (state?.lastError)
|
|
13
|
+
lines.push(`last error: ${state.lastError.message} (at ${state.lastError.at})`);
|
|
14
|
+
return lines;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Render a {@link SyncSummary} as report lines: counts, any stop reason, the
|
|
18
|
+
* new watermark, and the unassigned/failed breakdowns. Used by `/kankaku
|
|
19
|
+
* sync`/`sync all` and the panel's sync screen.
|
|
20
|
+
*/
|
|
21
|
+
export function formatSyncSummaryLines(summary) {
|
|
22
|
+
const lines = [`uploaded ${summary.uploaded}, updated ${summary.updated}, skipped ${summary.skipped}, failed ${summary.failed.length}`];
|
|
23
|
+
if (summary.locked)
|
|
24
|
+
lines.push("another sync is already in progress; nothing was attempted");
|
|
25
|
+
if (summary.error)
|
|
26
|
+
lines.push(`stopped early: ${summary.error}`);
|
|
27
|
+
if (summary.syncedThrough)
|
|
28
|
+
lines.push(`synced through ${summary.syncedThrough}`);
|
|
29
|
+
const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
|
|
30
|
+
if (unassignedEntries.length > 0) {
|
|
31
|
+
lines.push("unassigned (Sin determinar):");
|
|
32
|
+
for (const [label, count] of unassignedEntries)
|
|
33
|
+
lines.push(` ${label}: ${count}`);
|
|
34
|
+
}
|
|
35
|
+
if (summary.failed.length > 0) {
|
|
36
|
+
lines.push("failed:");
|
|
37
|
+
for (const entry of summary.failed)
|
|
38
|
+
lines.push(` ${entry.id}: ${entry.reason}`);
|
|
39
|
+
}
|
|
40
|
+
return lines;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `/kankaku backfill`'s report lines: tasks routed to Sin determinar this
|
|
44
|
+
* run, grouped by their old free-text label, or "no unassigned tasks" when
|
|
45
|
+
* none were. Exact body of `handleBackfillCommand`.
|
|
46
|
+
*/
|
|
47
|
+
export function formatBackfillLines(summary) {
|
|
48
|
+
const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
|
|
49
|
+
const lines = unassignedEntries.length > 0
|
|
50
|
+
? [
|
|
51
|
+
...unassignedEntries.map(([label, count]) => `${label}: ${count} task(s) -> Sin determinar`),
|
|
52
|
+
"reassign these in the hub web app's unassigned queue",
|
|
53
|
+
]
|
|
54
|
+
: ["no unassigned tasks"];
|
|
55
|
+
if (summary.error)
|
|
56
|
+
lines.push(`stopped early: ${summary.error}`);
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* `/kankaku catalog refresh`'s report lines: client/project counts, or an
|
|
61
|
+
* unreachable notice when the refresh failed (`snapshot` is `undefined`).
|
|
62
|
+
* The subcommand still notifies that failure as an error rather than a
|
|
63
|
+
* report (see `handleCatalogCommand`); the panel's sync screen shows
|
|
64
|
+
* whatever this returns either way.
|
|
65
|
+
*/
|
|
66
|
+
export function formatCatalogRefreshLines(snapshot) {
|
|
67
|
+
if (!snapshot)
|
|
68
|
+
return ["hub unreachable; catalog not refreshed"];
|
|
69
|
+
return [`refreshed: ${snapshot.clients.length} client(s), ${snapshot.projects.length} project(s)`];
|
|
70
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface HubCredentials {
|
|
2
|
+
url: string;
|
|
3
|
+
email: string;
|
|
4
|
+
password: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ResolveHubCredentialsDeps {
|
|
7
|
+
env: NodeJS.ProcessEnv;
|
|
8
|
+
/**
|
|
9
|
+
* Resolves the home directory; called lazily, inside this function, and
|
|
10
|
+
* defensively. A throwing provider (no `HOME`, a sandboxed environment
|
|
11
|
+
* without a resolvable home directory) is treated the same as "no home
|
|
12
|
+
* directory" rather than propagating — env-only credentials must still
|
|
13
|
+
* resolve, and the hub-unconfigured case must stay a no-op regardless of
|
|
14
|
+
* the host environment. Injectable for tests; defaults to `os.homedir` at
|
|
15
|
+
* the call site (extension.ts) — passed as a reference, never invoked
|
|
16
|
+
* there, so a throw never escapes before this function's own try/catch.
|
|
17
|
+
*/
|
|
18
|
+
homeDir: () => string;
|
|
19
|
+
}
|
|
20
|
+
export interface ResolveHubCredentialsResult {
|
|
21
|
+
/** `undefined` when unconfigured (no url/email/password from any source) or the URL is refused. */
|
|
22
|
+
credentials: HubCredentials | undefined;
|
|
23
|
+
/** Set only when a hub URL was given but rejected by {@link validateHubUrl}; surface it once via `ctx.ui.notify`. */
|
|
24
|
+
invalidReason?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve hub credentials: `KANKAKU_PB_URL`/`_EMAIL`/`_PASSWORD` take
|
|
28
|
+
* precedence per field over `~/.kankaku/credentials.json` (so a partially
|
|
29
|
+
* set environment still combines with the file rather than failing
|
|
30
|
+
* closed). Never reads the project's own `.kankaku/config.json` — that
|
|
31
|
+
* file is project-local and frequently committed.
|
|
32
|
+
*/
|
|
33
|
+
/** Resolve `homeDir()` defensively: any failure (no `HOME`, a sandboxed environment) yields `undefined` instead of throwing. Exported so callers with their own homedir-dependent path (e.g. `extension.ts`'s catalog cache) can share the same guard. */
|
|
34
|
+
export declare function safeHomeDir(homeDir: () => string): string | undefined;
|
|
35
|
+
export declare function resolveHubCredentials(deps: ResolveHubCredentialsDeps): ResolveHubCredentialsResult;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadHubEnvCredentials, validateHubUrl } from "../config.js";
|
|
4
|
+
const CREDENTIALS_FILE = join(".kankaku", "credentials.json");
|
|
5
|
+
/**
|
|
6
|
+
* Read `<homeDir>/.kankaku/credentials.json`. Tolerates a missing file,
|
|
7
|
+
* malformed JSON, a non-object document, or non-string fields — all
|
|
8
|
+
* return `{}` rather than throwing, since this file is optional and
|
|
9
|
+
* hand-edited (mirrors `project-config.ts#readProjectClient`).
|
|
10
|
+
*/
|
|
11
|
+
function readCredentialsFile(filePath) {
|
|
12
|
+
if (!existsSync(filePath))
|
|
13
|
+
return {};
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
16
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
17
|
+
return {};
|
|
18
|
+
const record = parsed;
|
|
19
|
+
return {
|
|
20
|
+
url: typeof record["url"] === "string" ? record["url"] : undefined,
|
|
21
|
+
email: typeof record["email"] === "string" ? record["email"] : undefined,
|
|
22
|
+
password: typeof record["password"] === "string" ? record["password"] : undefined,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve hub credentials: `KANKAKU_PB_URL`/`_EMAIL`/`_PASSWORD` take
|
|
31
|
+
* precedence per field over `~/.kankaku/credentials.json` (so a partially
|
|
32
|
+
* set environment still combines with the file rather than failing
|
|
33
|
+
* closed). Never reads the project's own `.kankaku/config.json` — that
|
|
34
|
+
* file is project-local and frequently committed.
|
|
35
|
+
*/
|
|
36
|
+
/** Resolve `homeDir()` defensively: any failure (no `HOME`, a sandboxed environment) yields `undefined` instead of throwing. Exported so callers with their own homedir-dependent path (e.g. `extension.ts`'s catalog cache) can share the same guard. */
|
|
37
|
+
export function safeHomeDir(homeDir) {
|
|
38
|
+
try {
|
|
39
|
+
return homeDir();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function resolveHubCredentials(deps) {
|
|
46
|
+
const fromEnv = loadHubEnvCredentials(deps.env);
|
|
47
|
+
const homeDir = safeHomeDir(deps.homeDir);
|
|
48
|
+
const fromFile = homeDir !== undefined ? readCredentialsFile(join(homeDir, CREDENTIALS_FILE)) : {};
|
|
49
|
+
const url = fromEnv.url ?? fromFile.url;
|
|
50
|
+
const email = fromEnv.email ?? fromFile.email;
|
|
51
|
+
const password = fromEnv.password ?? fromFile.password;
|
|
52
|
+
if (!url || !email || !password)
|
|
53
|
+
return { credentials: undefined };
|
|
54
|
+
const validation = validateHubUrl(url);
|
|
55
|
+
if (!validation.ok)
|
|
56
|
+
return { credentials: undefined, invalidReason: validation.reason };
|
|
57
|
+
return { credentials: { url, email, password } };
|
|
58
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { WorkLog } from "../ports/work-log.ts";
|
|
2
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Append-only JSONL {@link WorkLog} backed by `<dir>/worklog.jsonl`.
|
|
5
|
+
* A record is written with a single `appendFileSync` call so a parent and
|
|
6
|
+
* its subagent children can write concurrently without interleaving lines.
|
|
7
|
+
*/
|
|
8
|
+
export declare class JsonlWorkLog implements WorkLog {
|
|
9
|
+
private readonly dir;
|
|
10
|
+
constructor(dir: string);
|
|
11
|
+
private get filePath();
|
|
12
|
+
append(record: WorkRecord): void;
|
|
13
|
+
/**
|
|
14
|
+
* Cheap change signal: `mtimeMs:size` of the log file, computed with a
|
|
15
|
+
* single `statSync` rather than reading the file. `"0:0"` when the file
|
|
16
|
+
* does not exist yet (before the first `append`).
|
|
17
|
+
*/
|
|
18
|
+
version(): string;
|
|
19
|
+
readAll(): WorkRecord[];
|
|
20
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { dedupeById } from "../domain/task-view.js";
|
|
4
|
+
import { isWorkRecord } from "../domain/work-record.js";
|
|
5
|
+
const LOG_FILE_NAME = "worklog.jsonl";
|
|
6
|
+
/**
|
|
7
|
+
* Append-only JSONL {@link WorkLog} backed by `<dir>/worklog.jsonl`.
|
|
8
|
+
* A record is written with a single `appendFileSync` call so a parent and
|
|
9
|
+
* its subagent children can write concurrently without interleaving lines.
|
|
10
|
+
*/
|
|
11
|
+
export class JsonlWorkLog {
|
|
12
|
+
dir;
|
|
13
|
+
constructor(dir) {
|
|
14
|
+
this.dir = dir;
|
|
15
|
+
}
|
|
16
|
+
get filePath() {
|
|
17
|
+
return join(this.dir, LOG_FILE_NAME);
|
|
18
|
+
}
|
|
19
|
+
append(record) {
|
|
20
|
+
mkdirSync(this.dir, { recursive: true });
|
|
21
|
+
appendFileSync(this.filePath, `${JSON.stringify(record)}\n`);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Cheap change signal: `mtimeMs:size` of the log file, computed with a
|
|
25
|
+
* single `statSync` rather than reading the file. `"0:0"` when the file
|
|
26
|
+
* does not exist yet (before the first `append`).
|
|
27
|
+
*/
|
|
28
|
+
version() {
|
|
29
|
+
try {
|
|
30
|
+
const stats = statSync(this.filePath);
|
|
31
|
+
return `${stats.mtimeMs}:${stats.size}`;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return "0:0";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
readAll() {
|
|
38
|
+
if (!existsSync(this.filePath))
|
|
39
|
+
return [];
|
|
40
|
+
const content = readFileSync(this.filePath, "utf8");
|
|
41
|
+
const records = [];
|
|
42
|
+
for (const line of content.split("\n")) {
|
|
43
|
+
const trimmed = line.trim();
|
|
44
|
+
if (!trimmed)
|
|
45
|
+
continue;
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(trimmed);
|
|
48
|
+
if (isWorkRecord(parsed)) {
|
|
49
|
+
records.push(parsed);
|
|
50
|
+
}
|
|
51
|
+
// Tolerate a structurally invalid record (e.g. an incompatible
|
|
52
|
+
// schema or a torn write that still parses as JSON); skip it.
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Tolerate malformed lines (e.g. a torn write); skip them.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// One id can be in the file twice (written at settle, then re-appended by
|
|
59
|
+
// crash recovery): every reader gets it once — see dedupeById.
|
|
60
|
+
return dedupeById(records);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the kankaku data directory: an absolute `dirOrRelative` is used
|
|
3
|
+
* as-is, a relative one is joined against `cwd`. Shared by every lazy
|
|
4
|
+
* adapter so the rule lives in exactly one place.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveKankakuDir(dirOrRelative: string, cwd: string): string;
|
|
7
|
+
export interface WritableTargetResult {
|
|
8
|
+
dir: string;
|
|
9
|
+
/** `true` when `candidateDir` failed its writability probe and `dir` is `fallbackDir` instead. */
|
|
10
|
+
usedFallback: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ResolveWritableTargetDeps {
|
|
13
|
+
/**
|
|
14
|
+
* Proves this process can actually write to `dir` — not merely that it
|
|
15
|
+
* exists — and throws on any failure. Injectable for tests (never touch
|
|
16
|
+
* real disk to simulate an unwritable directory); defaults to
|
|
17
|
+
* {@link defaultWritabilityProbe}.
|
|
18
|
+
*/
|
|
19
|
+
probe?: (dir: string) => void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the target directory for a write that would normally go to
|
|
23
|
+
* `candidateDir` (F1: routing a verified subagent's work log/inflight
|
|
24
|
+
* checkpoints to its orchestrator's kankaku directory instead of its own
|
|
25
|
+
* cwd-relative one — ADR 0023's rewrite): `candidateDir` when it can be
|
|
26
|
+
* proven writable — created if it does not exist yet — `fallbackDir` (the
|
|
27
|
+
* process's own local directory) otherwise, so a parent directory that no
|
|
28
|
+
* longer exists, or that this process lacks permission to write to,
|
|
29
|
+
* degrades to a safe, local fallback instead of throwing and losing the
|
|
30
|
+
* record entirely. `usedFallback` lets the caller surface this (`/kankaku
|
|
31
|
+
* doctor`) so a human can notice and reunite the record manually — the
|
|
32
|
+
* append-only log can never be rewritten to fix it after the fact. Never
|
|
33
|
+
* throws: `fallbackDir` itself is never probed here — its own writability
|
|
34
|
+
* is handled the ordinary way, by whichever adapter eventually writes to
|
|
35
|
+
* it, exactly as it always has been for a process that never needed to
|
|
36
|
+
* route anywhere.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveWritableTarget(candidateDir: string, fallbackDir: string, deps?: ResolveWritableTargetDeps): WritableTargetResult;
|