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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1438 -0
  3. package/dist/adapters/cached-catalog.d.ts +42 -0
  4. package/dist/adapters/cached-catalog.js +121 -0
  5. package/dist/adapters/export-writer.d.ts +13 -0
  6. package/dist/adapters/export-writer.js +28 -0
  7. package/dist/adapters/file-modes.d.ts +20 -0
  8. package/dist/adapters/file-modes.js +34 -0
  9. package/dist/adapters/hub-actions.d.ts +35 -0
  10. package/dist/adapters/hub-actions.js +70 -0
  11. package/dist/adapters/hub-credentials.d.ts +35 -0
  12. package/dist/adapters/hub-credentials.js +58 -0
  13. package/dist/adapters/jsonl-work-log.d.ts +20 -0
  14. package/dist/adapters/jsonl-work-log.js +62 -0
  15. package/dist/adapters/kankaku-dir.d.ts +38 -0
  16. package/dist/adapters/kankaku-dir.js +85 -0
  17. package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
  18. package/dist/adapters/lazy-jsonl-work-log.js +31 -0
  19. package/dist/adapters/pocketbase-catalog.d.ts +16 -0
  20. package/dist/adapters/pocketbase-catalog.js +56 -0
  21. package/dist/adapters/pocketbase-client.d.ts +81 -0
  22. package/dist/adapters/pocketbase-client.js +148 -0
  23. package/dist/adapters/pocketbase-sink.d.ts +53 -0
  24. package/dist/adapters/pocketbase-sink.js +181 -0
  25. package/dist/adapters/project-config.d.ts +42 -0
  26. package/dist/adapters/project-config.js +108 -0
  27. package/dist/adapters/report-data.d.ts +12 -0
  28. package/dist/adapters/report-data.js +8 -0
  29. package/dist/adapters/report-views.d.ts +45 -0
  30. package/dist/adapters/report-views.js +73 -0
  31. package/dist/adapters/report.d.ts +112 -0
  32. package/dist/adapters/report.js +236 -0
  33. package/dist/adapters/sync-runner.d.ts +114 -0
  34. package/dist/adapters/sync-runner.js +273 -0
  35. package/dist/adapters/sync-state-store.d.ts +62 -0
  36. package/dist/adapters/sync-state-store.js +188 -0
  37. package/dist/config.d.ts +168 -0
  38. package/dist/config.js +392 -0
  39. package/dist/domain/ancestry-match.d.ts +49 -0
  40. package/dist/domain/ancestry-match.js +82 -0
  41. package/dist/domain/client-label.d.ts +28 -0
  42. package/dist/domain/client-label.js +44 -0
  43. package/dist/domain/day.d.ts +2 -0
  44. package/dist/domain/day.js +8 -0
  45. package/dist/domain/export.d.ts +38 -0
  46. package/dist/domain/export.js +68 -0
  47. package/dist/domain/hub-entry.d.ts +234 -0
  48. package/dist/domain/hub-entry.js +265 -0
  49. package/dist/domain/index.d.ts +19 -0
  50. package/dist/domain/index.js +19 -0
  51. package/dist/domain/intervals.d.ts +17 -0
  52. package/dist/domain/intervals.js +43 -0
  53. package/dist/domain/registry-health.d.ts +49 -0
  54. package/dist/domain/registry-health.js +58 -0
  55. package/dist/domain/segment-rule.d.ts +10 -0
  56. package/dist/domain/segment-rule.js +1 -0
  57. package/dist/domain/subagent-profile.d.ts +278 -0
  58. package/dist/domain/subagent-profile.js +418 -0
  59. package/dist/domain/sync-plan.d.ts +151 -0
  60. package/dist/domain/sync-plan.js +196 -0
  61. package/dist/domain/task-view.d.ts +117 -0
  62. package/dist/domain/task-view.js +428 -0
  63. package/dist/domain/work-record.d.ts +236 -0
  64. package/dist/domain/work-record.js +91 -0
  65. package/dist/domain/work-target.d.ts +101 -0
  66. package/dist/domain/work-target.js +149 -0
  67. package/dist/domain/work-tracker.d.ts +90 -0
  68. package/dist/domain/work-tracker.js +405 -0
  69. package/dist/hub/index.d.ts +25 -0
  70. package/dist/hub/index.js +25 -0
  71. package/dist/ports/catalog.d.ts +31 -0
  72. package/dist/ports/catalog.js +1 -0
  73. package/dist/ports/clock.d.ts +3 -0
  74. package/dist/ports/clock.js +1 -0
  75. package/dist/ports/index.d.ts +11 -0
  76. package/dist/ports/index.js +1 -0
  77. package/dist/ports/inflight-store.d.ts +15 -0
  78. package/dist/ports/inflight-store.js +1 -0
  79. package/dist/ports/process-registry.d.ts +72 -0
  80. package/dist/ports/process-registry.js +1 -0
  81. package/dist/ports/work-log.d.ts +14 -0
  82. package/dist/ports/work-log.js +1 -0
  83. package/dist/ports/work-sink.d.ts +39 -0
  84. package/dist/ports/work-sink.js +1 -0
  85. package/package.json +66 -0
  86. package/src/adapters/agent-info.ts +86 -0
  87. package/src/adapters/ancestry.ts +260 -0
  88. package/src/adapters/cached-catalog.ts +147 -0
  89. package/src/adapters/export-writer.ts +33 -0
  90. package/src/adapters/file-inflight-store.ts +115 -0
  91. package/src/adapters/file-modes.ts +35 -0
  92. package/src/adapters/hub-actions.ts +82 -0
  93. package/src/adapters/hub-credentials.ts +95 -0
  94. package/src/adapters/jsonl-work-log.ts +67 -0
  95. package/src/adapters/kankaku-command.ts +717 -0
  96. package/src/adapters/kankaku-dir.ts +102 -0
  97. package/src/adapters/lazy-file-inflight-store.ts +43 -0
  98. package/src/adapters/lazy-jsonl-work-log.ts +39 -0
  99. package/src/adapters/machine-process-registry.ts +256 -0
  100. package/src/adapters/panel/kankaku-panel.ts +419 -0
  101. package/src/adapters/panel/panel-items.ts +87 -0
  102. package/src/adapters/panel/panel-lines.ts +13 -0
  103. package/src/adapters/panel/panel-theme.ts +32 -0
  104. package/src/adapters/panel/screens/about.ts +69 -0
  105. package/src/adapters/panel/screens/doctor.ts +89 -0
  106. package/src/adapters/panel/screens/export.ts +123 -0
  107. package/src/adapters/panel/screens/report.ts +143 -0
  108. package/src/adapters/panel/screens/sync.ts +136 -0
  109. package/src/adapters/panel/screens/target.ts +384 -0
  110. package/src/adapters/pi-tracker.ts +753 -0
  111. package/src/adapters/pocketbase-catalog.ts +89 -0
  112. package/src/adapters/pocketbase-client.ts +197 -0
  113. package/src/adapters/pocketbase-sink.ts +236 -0
  114. package/src/adapters/process-identity-memo.ts +102 -0
  115. package/src/adapters/process-identity.ts +162 -0
  116. package/src/adapters/project-config.ts +116 -0
  117. package/src/adapters/report-data.ts +13 -0
  118. package/src/adapters/report-views.ts +98 -0
  119. package/src/adapters/report.ts +335 -0
  120. package/src/adapters/session-client.ts +116 -0
  121. package/src/adapters/session-dir.ts +28 -0
  122. package/src/adapters/session-target.ts +431 -0
  123. package/src/adapters/status-bar.ts +86 -0
  124. package/src/adapters/subagent-startup.ts +66 -0
  125. package/src/adapters/sync-runner.ts +340 -0
  126. package/src/adapters/sync-state-store.ts +227 -0
  127. package/src/adapters/target-picker.ts +127 -0
  128. package/src/config.ts +536 -0
  129. package/src/domain/ancestry-match.ts +84 -0
  130. package/src/domain/client-label.ts +56 -0
  131. package/src/domain/day.ts +8 -0
  132. package/src/domain/export.ts +107 -0
  133. package/src/domain/hub-entry.ts +433 -0
  134. package/src/domain/index.ts +19 -0
  135. package/src/domain/intervals.ts +53 -0
  136. package/src/domain/panel-model.ts +270 -0
  137. package/src/domain/registry-health.ts +87 -0
  138. package/src/domain/segment-rule.ts +10 -0
  139. package/src/domain/subagent-profile.ts +495 -0
  140. package/src/domain/sync-plan.ts +266 -0
  141. package/src/domain/task-view.ts +526 -0
  142. package/src/domain/work-record.ts +320 -0
  143. package/src/domain/work-target.ts +234 -0
  144. package/src/domain/work-tracker.ts +485 -0
  145. package/src/extension.ts +346 -0
  146. package/src/hub/index.ts +25 -0
  147. package/src/ports/catalog.ts +33 -0
  148. package/src/ports/clock.ts +3 -0
  149. package/src/ports/index.ts +11 -0
  150. package/src/ports/inflight-store.ts +16 -0
  151. package/src/ports/process-registry.ts +75 -0
  152. package/src/ports/work-log.ts +15 -0
  153. package/src/ports/work-sink.ts +35 -0
@@ -0,0 +1,102 @@
1
+ import { mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+
4
+ /**
5
+ * Resolve the kankaku data directory: an absolute `dirOrRelative` is used
6
+ * as-is, a relative one is joined against `cwd`. Shared by every lazy
7
+ * adapter so the rule lives in exactly one place.
8
+ */
9
+ export function resolveKankakuDir(dirOrRelative: string, cwd: string): string {
10
+ return isAbsolute(dirOrRelative) ? dirOrRelative : join(cwd, dirOrRelative);
11
+ }
12
+
13
+ export interface WritableTargetResult {
14
+ dir: string;
15
+ /** `true` when `candidateDir` failed its writability probe and `dir` is `fallbackDir` instead. */
16
+ usedFallback: boolean;
17
+ }
18
+
19
+ export interface ResolveWritableTargetDeps {
20
+ /**
21
+ * Proves this process can actually write to `dir` — not merely that it
22
+ * exists — and throws on any failure. Injectable for tests (never touch
23
+ * real disk to simulate an unwritable directory); defaults to
24
+ * {@link defaultWritabilityProbe}.
25
+ */
26
+ probe?: (dir: string) => void;
27
+ }
28
+
29
+ /** Matches this probe's own marker filename: `.kankaku-write-probe.<pid>.<timestamp>.tmp`. */
30
+ const PROBE_NAME_PATTERN = /^\.kankaku-write-probe\.\d+\.(\d+)\.tmp$/;
31
+ /** A marker older than this is assumed abandoned (its writer was SIGKILLed between the write and its own unlink) — R4. */
32
+ const PROBE_STALE_MS = 60_000;
33
+
34
+ /**
35
+ * Best-effort removal of a stale writability-probe marker (R4): a probe
36
+ * that gets SIGKILLed between its `writeFileSync` and its own `unlinkSync`
37
+ * leaves `.kankaku-write-probe.<pid>.<timestamp>.tmp` behind forever —
38
+ * nothing else in `dir` ever looks at it again otherwise. Runs
39
+ * opportunistically every time the probe itself runs (mirrors
40
+ * `machine-process-registry.ts`'s own-write-time sweep pattern), so no
41
+ * separate cleanup process is needed. Only removes a marker whose
42
+ * filename-embedded timestamp (not the file's mtime, so this stays
43
+ * deterministic and easy to test) is older than a minute — never a fresh
44
+ * one, including this call's own marker (written after this sweep runs) or
45
+ * a concurrent process's own in-flight probe.
46
+ */
47
+ function sweepStaleWriteProbes(dir: string, now: number): void {
48
+ let names: string[];
49
+ try {
50
+ names = readdirSync(dir);
51
+ } catch {
52
+ return; // dir just created and already empty, or unreadable — nothing to sweep.
53
+ }
54
+
55
+ for (const name of names) {
56
+ const match = PROBE_NAME_PATTERN.exec(name);
57
+ if (!match) continue;
58
+ const timestamp = Number(match[1]);
59
+ if (!Number.isFinite(timestamp) || now - timestamp <= PROBE_STALE_MS) continue;
60
+ try {
61
+ unlinkSync(join(dir, name));
62
+ } catch {
63
+ // Best effort: already gone, or a concurrent sweep got there first.
64
+ }
65
+ }
66
+ }
67
+
68
+ /** `mkdirSync` + a temp marker file write/unlink: proves both creatability and write permission, not just existence (a read-only *existing* directory would otherwise pass a bare `mkdirSync` check silently, since recursive mkdir on an existing dir never fails). Also sweeps any stale marker left behind by an earlier, SIGKILLed probe (R4) before writing its own. */
69
+ function defaultWritabilityProbe(dir: string): void {
70
+ mkdirSync(dir, { recursive: true });
71
+ sweepStaleWriteProbes(dir, Date.now());
72
+ const marker = join(dir, `.kankaku-write-probe.${process.pid}.${Date.now()}.tmp`);
73
+ writeFileSync(marker, "");
74
+ unlinkSync(marker);
75
+ }
76
+
77
+ /**
78
+ * Resolve the target directory for a write that would normally go to
79
+ * `candidateDir` (F1: routing a verified subagent's work log/inflight
80
+ * checkpoints to its orchestrator's kankaku directory instead of its own
81
+ * cwd-relative one — ADR 0023's rewrite): `candidateDir` when it can be
82
+ * proven writable — created if it does not exist yet — `fallbackDir` (the
83
+ * process's own local directory) otherwise, so a parent directory that no
84
+ * longer exists, or that this process lacks permission to write to,
85
+ * degrades to a safe, local fallback instead of throwing and losing the
86
+ * record entirely. `usedFallback` lets the caller surface this (`/kankaku
87
+ * doctor`) so a human can notice and reunite the record manually — the
88
+ * append-only log can never be rewritten to fix it after the fact. Never
89
+ * throws: `fallbackDir` itself is never probed here — its own writability
90
+ * is handled the ordinary way, by whichever adapter eventually writes to
91
+ * it, exactly as it always has been for a process that never needed to
92
+ * route anywhere.
93
+ */
94
+ export function resolveWritableTarget(candidateDir: string, fallbackDir: string, deps: ResolveWritableTargetDeps = {}): WritableTargetResult {
95
+ const probe = deps.probe ?? defaultWritabilityProbe;
96
+ try {
97
+ probe(candidateDir);
98
+ return { dir: candidateDir, usedFallback: false };
99
+ } catch {
100
+ return { dir: fallbackDir, usedFallback: true };
101
+ }
102
+ }
@@ -0,0 +1,43 @@
1
+ import type { WorkRecord } from "../domain/work-record.ts";
2
+ import type { InflightStore } from "../ports/inflight-store.ts";
3
+ import { FileInflightStore } from "./file-inflight-store.ts";
4
+ import { resolveKankakuDir } from "./kankaku-dir.ts";
5
+
6
+ /**
7
+ * {@link InflightStore} that resolves a relative checkpoint directory
8
+ * lazily, mirroring {@link LazyJsonlWorkLog}: against the project of the
9
+ * first saved record, or against `fallbackCwd()` when `clear` or
10
+ * `recoverStale` happens before any record was saved in this process
11
+ * (e.g. `session_start`, which runs before `before_agent_start`).
12
+ */
13
+ export class LazyFileInflightStore implements InflightStore {
14
+ private readonly dirOrRelative: string;
15
+ private readonly pid: number;
16
+ private readonly fallbackCwd: () => string;
17
+ private resolved: FileInflightStore | undefined;
18
+
19
+ constructor(dirOrRelative: string, pid: number, fallbackCwd: () => string = () => process.cwd()) {
20
+ this.dirOrRelative = dirOrRelative;
21
+ this.pid = pid;
22
+ this.fallbackCwd = fallbackCwd;
23
+ }
24
+
25
+ private resolveFor(cwd: string): FileInflightStore {
26
+ if (!this.resolved) {
27
+ this.resolved = new FileInflightStore(resolveKankakuDir(this.dirOrRelative, cwd), this.pid);
28
+ }
29
+ return this.resolved;
30
+ }
31
+
32
+ save(record: WorkRecord): void {
33
+ this.resolveFor(record.project).save(record);
34
+ }
35
+
36
+ clear(): void {
37
+ this.resolveFor(this.fallbackCwd()).clear();
38
+ }
39
+
40
+ recoverStale(isAlive: (pid: number) => boolean): WorkRecord[] {
41
+ return this.resolveFor(this.fallbackCwd()).recoverStale(isAlive);
42
+ }
43
+ }
@@ -0,0 +1,39 @@
1
+ import type { WorkRecord } from "../domain/work-record.ts";
2
+ import type { WorkLog } from "../ports/work-log.ts";
3
+ import { JsonlWorkLog } from "./jsonl-work-log.ts";
4
+ import { resolveKankakuDir } from "./kankaku-dir.ts";
5
+
6
+ /**
7
+ * {@link WorkLog} that resolves a relative log directory lazily: against the
8
+ * project of the first appended record, or against `fallbackCwd()` when a
9
+ * read happens before any record was written in this process.
10
+ */
11
+ export class LazyJsonlWorkLog implements WorkLog {
12
+ private readonly dirOrRelative: string;
13
+ private readonly fallbackCwd: () => string;
14
+ private resolved: JsonlWorkLog | undefined;
15
+
16
+ constructor(dirOrRelative: string, fallbackCwd: () => string = () => process.cwd()) {
17
+ this.dirOrRelative = dirOrRelative;
18
+ this.fallbackCwd = fallbackCwd;
19
+ }
20
+
21
+ private resolveFor(cwd: string): JsonlWorkLog {
22
+ if (!this.resolved) {
23
+ this.resolved = new JsonlWorkLog(resolveKankakuDir(this.dirOrRelative, cwd));
24
+ }
25
+ return this.resolved;
26
+ }
27
+
28
+ append(record: WorkRecord): void {
29
+ this.resolveFor(record.project).append(record);
30
+ }
31
+
32
+ readAll(): WorkRecord[] {
33
+ return this.resolveFor(this.fallbackCwd()).readAll();
34
+ }
35
+
36
+ version(): string {
37
+ return this.resolveFor(this.fallbackCwd()).version();
38
+ }
39
+ }
@@ -0,0 +1,256 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { classifyRegistryEntries, DEFAULT_MAX_ENTRY_AGE_MS } from "../domain/registry-health.ts";
4
+ import type { RegistryClassification } from "../domain/registry-health.ts";
5
+ import type { ProcessRegistry, RegistryEntry, RegistrySweepDeps } from "../ports/process-registry.ts";
6
+ import { ensureDirMode, OWNER_FILE_MODE, tightenMode } from "./file-modes.ts";
7
+
8
+ const RUN_DIR_NAME = "run";
9
+ const JSON_EXT = ".json";
10
+
11
+ function isOrchestratorRef(value: unknown): boolean {
12
+ if (value === undefined) return true;
13
+ if (!value || typeof value !== "object") return false;
14
+ const ref = value as Record<string, unknown>;
15
+ return (
16
+ typeof ref["pid"] === "number" &&
17
+ typeof ref["project"] === "string" &&
18
+ typeof ref["startedAt"] === "string" &&
19
+ (ref["dir"] === undefined || typeof ref["dir"] === "string")
20
+ );
21
+ }
22
+
23
+ function isRegistryEntry(value: unknown): value is RegistryEntry {
24
+ if (!value || typeof value !== "object") return false;
25
+ const record = value as Record<string, unknown>;
26
+ return (
27
+ typeof record["pid"] === "number" &&
28
+ typeof record["parentPid"] === "number" &&
29
+ (record["role"] === "orchestrator" || record["role"] === "subagent") &&
30
+ typeof record["project"] === "string" &&
31
+ typeof record["dir"] === "string" &&
32
+ typeof record["startedAt"] === "string" &&
33
+ isOrchestratorRef(record["orchestratorRef"]) &&
34
+ (record["processStartId"] === undefined || typeof record["processStartId"] === "number")
35
+ );
36
+ }
37
+
38
+ function safeUnlink(filePath: string): void {
39
+ try {
40
+ unlinkSync(filePath);
41
+ } catch {
42
+ // Best effort: another process (or a concurrent sweep) may have already removed it.
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Delete `filePath` only when its on-disk bytes, re-read right now, are
48
+ * still byte-for-byte `expectedText` (F4, TOCTOU fix). Between this sweep's
49
+ * directory scan (where `expectedText` was captured) and this call, the pid
50
+ * this file is named after may have been reused by a brand new process that
51
+ * already wrote its own fresh entry to the very same path — deleting it then
52
+ * would destroy a live registration this sweep never actually judged.
53
+ * Skipping (rather than deleting) on any mismatch, a read failure, or the
54
+ * file already being gone is always the safe choice: a file that is
55
+ * genuinely stale gets a further chance on the next sweep.
56
+ */
57
+ function safeUnlinkIfUnchanged(filePath: string, expectedText: string): void {
58
+ let current: string;
59
+ try {
60
+ current = readFileSync(filePath, "utf8");
61
+ } catch {
62
+ return; // already gone, or unreadable — nothing this call should touch.
63
+ }
64
+ if (current !== expectedText) return; // raced: a fresh entry now lives at this path.
65
+ safeUnlink(filePath);
66
+ }
67
+
68
+ /** Default `isAlive`: probe with signal 0 — mirrors `pi-tracker.ts`/`sync-state-store.ts`'s own default. */
69
+ function defaultIsAlive(pid: number): boolean {
70
+ try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ } catch (error) {
74
+ return (error as NodeJS.ErrnoException).code === "EPERM";
75
+ }
76
+ }
77
+
78
+ /**
79
+ * {@link ProcessRegistry} backed by `<homeDir>/.kankaku/run/<pid>.json`,
80
+ * machine-wide and independent of any project's `KANKAKU_DIR` (ADR 0023).
81
+ * `record()` writes atomically (tmp file + rename), mirroring
82
+ * `file-inflight-store.ts`. Every operation degrades to "registry
83
+ * unavailable" (a no-op write, an empty read) on any failure — including a
84
+ * `homeDir()` that throws (no `HOME`, a sandboxed environment) — rather
85
+ * than propagating, since this is an enhancement to subagent detection and
86
+ * must never block or crash the extension it improves.
87
+ */
88
+ export class MachineProcessRegistry implements ProcessRegistry {
89
+ private readonly homeDir: () => string;
90
+
91
+ constructor(homeDir: () => string) {
92
+ this.homeDir = homeDir;
93
+ }
94
+
95
+ private runDir(): string | undefined {
96
+ try {
97
+ return join(this.homeDir(), ".kankaku", RUN_DIR_NAME);
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ record(entry: RegistryEntry, isAlive: (pid: number) => boolean = defaultIsAlive, sweepDeps: RegistrySweepDeps = {}): void {
104
+ const dir = this.runDir();
105
+ if (dir === undefined) return;
106
+
107
+ try {
108
+ ensureDirMode(dir);
109
+ const target = join(dir, `${entry.pid}${JSON_EXT}`);
110
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
111
+ writeFileSync(tmp, JSON.stringify(entry), { mode: OWNER_FILE_MODE });
112
+ renameSync(tmp, target);
113
+ } catch {
114
+ // Best effort: a write failure (no permission, disk full) must not
115
+ // block or fail the run it would otherwise track.
116
+ return;
117
+ }
118
+
119
+ this.sweep(dir, entry.pid, isAlive, sweepDeps);
120
+ }
121
+
122
+ removeOwn(pid: number, processStartId: number | undefined): void {
123
+ const dir = this.runDir();
124
+ if (dir === undefined) return;
125
+
126
+ const target = join(dir, `${pid}${JSON_EXT}`);
127
+ let parsed: unknown;
128
+ try {
129
+ parsed = JSON.parse(readFileSync(target, "utf8"));
130
+ } catch {
131
+ return; // nothing to remove, or unreadable — best effort.
132
+ }
133
+
134
+ if (!isRegistryEntry(parsed) || parsed.pid !== pid || parsed.processStartId !== processStartId) {
135
+ // Not verifiably this process's own entry (already overwritten by a
136
+ // pid-reuse successor, or a mismatched identity) — never touch it.
137
+ return;
138
+ }
139
+
140
+ safeUnlink(target);
141
+ }
142
+
143
+ readAll(): RegistryEntry[] {
144
+ const dir = this.runDir();
145
+ if (dir === undefined || !existsSync(dir)) return [];
146
+
147
+ let names: string[];
148
+ try {
149
+ names = readdirSync(dir);
150
+ } catch {
151
+ return [];
152
+ }
153
+
154
+ const entries: RegistryEntry[] = [];
155
+ for (const name of names) {
156
+ if (!name.endsWith(JSON_EXT)) continue; // skips .tmp files too (they never end in plain .json)
157
+ try {
158
+ const parsed: unknown = JSON.parse(readFileSync(join(dir, name), "utf8"));
159
+ if (isRegistryEntry(parsed)) entries.push(parsed);
160
+ // Tolerate a structurally invalid entry (e.g. a torn write); skip it.
161
+ } catch {
162
+ // Tolerate malformed/corrupt files; skip them.
163
+ }
164
+ }
165
+ return entries;
166
+ }
167
+
168
+ /**
169
+ * Read-only registry health snapshot for `/kankaku doctor`
170
+ * (SUBAGENT-REQ-017): every currently-persisted entry, classified as kept
171
+ * or discarded-and-why via the same pure classifier a real sweep uses —
172
+ * but without deleting anything, and (by default) without re-verifying
173
+ * any pid's live start identity, so this stays a cheap, no-extra-spawn
174
+ * diagnostic: a `stale-reuse` verdict therefore only ever shows up here
175
+ * when the caller explicitly injects a fresh `liveStartId` (e.g. from an
176
+ * ancestry snapshot it already has); otherwise such entries simply read
177
+ * as "kept" here even though a real `record()` sweep, run by a process
178
+ * that *does* have that live data, would already have discarded them.
179
+ */
180
+ health(deps: { isAlive?: (pid: number) => boolean; liveStartId?: (pid: number) => number | undefined; now?: number } = {}): RegistryClassification {
181
+ const entries = this.readAll();
182
+ return classifyRegistryEntries(entries, -1, {
183
+ isAlive: deps.isAlive ?? defaultIsAlive,
184
+ liveStartId: deps.liveStartId ?? (() => undefined),
185
+ now: deps.now ?? Date.now(),
186
+ maxAgeMs: DEFAULT_MAX_ENTRY_AGE_MS,
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Opportunistic sweep, run whenever this process writes its own entry
192
+ * (mirrors `file-inflight-store.ts`'s stray-tmp-file sweep pattern), so
193
+ * `run/` does not grow unbounded and never keeps serving a stale
194
+ * identity (SUBAGENT-REQ-010, and the PID-reuse fix). Delegates the
195
+ * actual keep/discard decision to the pure
196
+ * `domain/registry-health.ts#classifyRegistryEntries`, fed with every
197
+ * *structurally valid* on-disk entry (a corrupt/torn file is simply
198
+ * skipped here, same as `readAll`) plus whatever this call's own
199
+ * `isAlive`/`liveStartId` can tell it. Never removes the entry this call
200
+ * itself just wrote, regardless of what those report for it.
201
+ */
202
+ private sweep(dir: string, ownPid: number, isAlive: (pid: number) => boolean, sweepDeps: RegistrySweepDeps): void {
203
+ let names: string[];
204
+ try {
205
+ names = readdirSync(dir);
206
+ } catch {
207
+ return;
208
+ }
209
+
210
+ const entries: RegistryEntry[] = [];
211
+ const pathByPid = new Map<number, string>();
212
+ // Raw file text as read during this scan, keyed by pid — kept so a
213
+ // later deletion can re-verify byte-for-byte against what was actually
214
+ // judged stale (see the TOCTOU re-check below), never against a
215
+ // re-parsed/re-serialized copy that could mask a real change.
216
+ const textByPid = new Map<number, string>();
217
+ for (const name of names) {
218
+ if (!name.endsWith(JSON_EXT)) continue;
219
+ const pid = Number(name.slice(0, -JSON_EXT.length));
220
+ if (!Number.isFinite(pid)) continue;
221
+ const path = join(dir, name);
222
+ // Best-effort mode tightening for every entry file this sweep
223
+ // encounters (F4), not just this process's own — an older kankaku
224
+ // build, or a filesystem with a permissive default, may have left one
225
+ // world/group-readable.
226
+ tightenMode(path, OWNER_FILE_MODE);
227
+ try {
228
+ const text = readFileSync(path, "utf8");
229
+ const parsed: unknown = JSON.parse(text);
230
+ if (isRegistryEntry(parsed)) {
231
+ entries.push(parsed);
232
+ pathByPid.set(pid, path);
233
+ textByPid.set(pid, text);
234
+ }
235
+ // A structurally invalid/corrupt file is left alone here — readAll
236
+ // already tolerates it by skipping, and this sweep only acts on
237
+ // entries it can positively classify.
238
+ } catch {
239
+ // Tolerate malformed/corrupt files; skip them.
240
+ }
241
+ }
242
+
243
+ const { discard } = classifyRegistryEntries(entries, ownPid, {
244
+ isAlive,
245
+ liveStartId: sweepDeps.liveStartId ?? (() => undefined),
246
+ now: sweepDeps.now ?? Date.now(),
247
+ maxAgeMs: DEFAULT_MAX_ENTRY_AGE_MS,
248
+ });
249
+
250
+ for (const { entry } of discard) {
251
+ const path = pathByPid.get(entry.pid);
252
+ const expectedText = textByPid.get(entry.pid);
253
+ if (path && expectedText !== undefined) safeUnlinkIfUnchanged(path, expectedText);
254
+ }
255
+ }
256
+ }