@hadooppei/hwcode 1.0.8 → 1.0.9

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.
@@ -1,5 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import {
3
+ chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync,
4
+ statSync, writeFileSync,
5
+ } from "node:fs";
3
6
  import { homedir } from "node:os";
4
7
  import { basename, dirname, join, resolve, sep } from "node:path";
5
8
 
@@ -7,13 +10,27 @@ import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
7
10
  import { userRuntimePaths } from "../runtime/paths.ts";
8
11
  import { compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.ts";
9
12
  import type {
10
- KnowledgeCandidate, KnowledgeCatalog, KnowledgeCatalogEntry, KnowledgeScope,
11
- KnowledgeSnapshot, KnowledgeTrack, PersistKnowledgeResult,
13
+ KnowledgeCandidate, KnowledgeCatalog, KnowledgeCatalogEntry, KnowledgeManifest, KnowledgeReviewCursor,
14
+ KnowledgeReviewTask, KnowledgeScope, KnowledgeSnapshot, KnowledgeTrack, PersistKnowledgeResult,
12
15
  } from "./types.ts";
13
16
 
14
17
  const STORAGE = KNOWLEDGE_RUNTIME_DEFAULTS.storage;
15
- const EMPTY_CATALOG = (): KnowledgeCatalog => ({ version: 2, updatedAt: new Date().toISOString(), items: [] });
16
18
  const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
19
+ const SAFE_GENERATION_RE = /^[0-9TZ-]+-[a-f0-9]{12}$/u;
20
+ const PROJECT_SCOPE_RE = /^project:[a-f0-9]{16}$/u;
21
+
22
+ export class KnowledgeCommitBusyError extends Error {}
23
+
24
+ function emptyCatalog(): KnowledgeCatalog {
25
+ return { version: 3, updatedAt: new Date().toISOString(), items: [] };
26
+ }
27
+
28
+ function emptyManifest(): KnowledgeManifest {
29
+ return {
30
+ version: 3, generationId: "", createdAt: new Date().toISOString(), leaderToken: "",
31
+ catalog: emptyCatalog(), reviews: {},
32
+ };
33
+ }
17
34
 
18
35
  function ensureDirectory(path: string): void {
19
36
  mkdirSync(path, { recursive: true, mode: 0o700 });
@@ -30,30 +47,65 @@ function atomicWrite(path: string, content: string): void {
30
47
 
31
48
  export function ensureKnowledgeDirectories(home = homedir()): void {
32
49
  const paths = userRuntimePaths(home);
33
- for (const path of [paths.knowledge, paths.knowledgeRules, paths.knowledgeTopics, paths.knowledgePending]) {
34
- ensureDirectory(path);
35
- }
50
+ for (const path of [paths.knowledge, paths.knowledgeGenerations, paths.knowledgeRuntime]) ensureDirectory(path);
36
51
  }
37
52
 
38
53
  export function projectKnowledgeKey(projectRoot: string): string {
39
54
  return createHash("sha256").update(resolve(projectRoot)).digest("hex").slice(0, 16);
40
55
  }
41
56
 
42
- export function loadKnowledgeCatalog(home = homedir()): KnowledgeCatalog {
43
- const path = userRuntimePaths(home).knowledgeCatalog;
44
- if (!existsSync(path)) return EMPTY_CATALOG();
57
+ function generationDirectory(generationId: string, home: string): string | undefined {
58
+ if (!SAFE_GENERATION_RE.test(generationId)) return undefined;
59
+ const root = resolve(userRuntimePaths(home).knowledgeGenerations);
60
+ const directory = resolve(root, generationId);
61
+ return directory.startsWith(`${root}${sep}`) ? directory : undefined;
62
+ }
63
+
64
+ function parseManifest(path: string): KnowledgeManifest | undefined {
45
65
  try {
46
- const parsed = JSON.parse(readFileSync(path, "utf8")) as KnowledgeCatalog;
47
- if (parsed.version === 2 && Array.isArray(parsed.items)) return parsed;
66
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as KnowledgeManifest;
67
+ if (parsed.version !== 3 || !SAFE_GENERATION_RE.test(parsed.generationId)
68
+ || parsed.catalog?.version !== 3 || !Array.isArray(parsed.catalog.items)
69
+ || !parsed.reviews || typeof parsed.reviews !== "object") return undefined;
70
+ return parsed;
48
71
  } catch {
49
- // The active schema never falls back to legacy index.json or records/ data.
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ function validManifestAt(manifest: KnowledgeManifest, directory: string): boolean {
77
+ return manifest.catalog.items.every((entry) => {
78
+ if (!SAFE_ID_RE.test(entry.id) || basename(entry.file) !== `${entry.id}.md`) return false;
79
+ if (entry.scope !== "global" && !PROJECT_SCOPE_RE.test(entry.scope)) return false;
80
+ const path = resolve(directory, entry.file);
81
+ return path.startsWith(`${directory}${sep}`) && existsSync(path);
82
+ });
83
+ }
84
+
85
+ function validManifest(manifest: KnowledgeManifest, home: string): boolean {
86
+ const directory = generationDirectory(manifest.generationId, home);
87
+ return Boolean(directory && validManifestAt(manifest, directory));
88
+ }
89
+
90
+ export function loadCurrentManifest(home = homedir()): KnowledgeManifest {
91
+ ensureKnowledgeDirectories(home);
92
+ const paths = userRuntimePaths(home);
93
+ const preferred = existsSync(paths.knowledgeCurrent) ? readFileSync(paths.knowledgeCurrent, "utf8").trim() : "";
94
+ const candidates = [preferred, ...readdirSync(paths.knowledgeGenerations, { withFileTypes: true })
95
+ .filter((entry) => entry.isDirectory() && SAFE_GENERATION_RE.test(entry.name))
96
+ .map((entry) => entry.name).sort().reverse()]
97
+ .filter((value, index, values) => value && values.indexOf(value) === index);
98
+ for (const generationId of candidates) {
99
+ const directory = generationDirectory(generationId, home);
100
+ if (!directory) continue;
101
+ const manifest = parseManifest(join(directory, "manifest.json"));
102
+ if (manifest && manifest.generationId === generationId && validManifest(manifest, home)) return manifest;
50
103
  }
51
- return EMPTY_CATALOG();
104
+ return emptyManifest();
52
105
  }
53
106
 
54
- function saveCatalog(catalog: KnowledgeCatalog, home: string): void {
55
- catalog.updatedAt = new Date().toISOString();
56
- atomicWrite(userRuntimePaths(home).knowledgeCatalog, `${JSON.stringify(catalog, null, 2)}\n`);
107
+ export function loadKnowledgeCatalog(home = homedir()): KnowledgeCatalog {
108
+ return loadCurrentManifest(home).catalog;
57
109
  }
58
110
 
59
111
  function normalizeSlug(value: string): string {
@@ -81,26 +133,20 @@ function normalizeCandidate(value: unknown, projectKey: string): KnowledgeCandid
81
133
  if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
82
134
  || typeof raw.body !== "string" || typeof raw.confidence !== "number") return undefined;
83
135
  if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence) return undefined;
84
- const storageHint: KnowledgeTrack = raw.storageHint === "rule" ? "rule" : "topic";
85
136
  const explicitUserDirective = raw.explicitUserDirective === true;
86
- const requestedScope = raw.scope === "global" ? "global" : `project:${projectKey}`;
87
- const scope: KnowledgeScope = requestedScope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
137
+ const scope: KnowledgeScope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
88
138
  const body = compactKnowledgeText(raw.body, 20_000);
89
139
  const evidence = asStringArray(raw.evidence, 8);
90
140
  if (!body || evidence.length === 0) return undefined;
91
- const ruleEligible = body.length <= STORAGE.maxRuleChars
92
- && body.split("\n").length <= STORAGE.maxRuleFileLines
141
+ const requestedTrack: KnowledgeTrack = raw.storageHint === "rule" ? "rule" : "topic";
142
+ const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
93
143
  && (explicitUserDirective || raw.confidence >= 0.9);
94
144
  return {
95
- key: compactKnowledgeText(raw.key, 160),
96
- title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
145
+ key: compactKnowledgeText(raw.key, 160), title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
97
146
  summary: compactKnowledgeText(raw.summary, STORAGE.maxSummaryChars),
98
147
  keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
99
- scope,
100
- body,
101
- evidence,
102
- confidence: Math.min(1, raw.confidence),
103
- storageHint: storageHint === "rule" && ruleEligible ? "rule" : "topic",
148
+ scope, body, evidence, confidence: Math.min(1, raw.confidence),
149
+ storageHint: requestedTrack === "rule" && ruleEligible ? "rule" : "topic",
104
150
  action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
105
151
  explicitUserDirective,
106
152
  };
@@ -109,41 +155,19 @@ function normalizeCandidate(value: unknown, projectKey: string): KnowledgeCandid
109
155
  function renderKnowledgeFile(candidate: KnowledgeCandidate): string {
110
156
  if (candidate.storageHint === "rule") return `# ${candidate.title}\n\n${candidate.body}\n`;
111
157
  return [
112
- `# ${candidate.title}`,
113
- "",
114
- candidate.summary,
115
- "",
116
- `Keywords: ${candidate.keywords.join(", ")}`,
117
- "",
118
- candidate.body,
119
- "",
120
- "## Evidence",
121
- "",
122
- ...candidate.evidence.map((item) => `- ${item}`),
123
- "",
158
+ `# ${candidate.title}`, "", candidate.summary, "", `Keywords: ${candidate.keywords.join(", ")}`, "",
159
+ candidate.body, "", "## Evidence", "", ...candidate.evidence.map((item) => `- ${item}`), "",
124
160
  ].join("\n");
125
161
  }
126
162
 
127
- function storedRuleCharacters(home: string): number {
128
- const directory = userRuntimePaths(home).knowledgeRules;
129
- if (!existsSync(directory)) return 0;
130
- return readdirSync(directory, { withFileTypes: true })
131
- .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
132
- .reduce((total, entry) => total + readFileSync(join(directory, entry.name), "utf8").length, 0);
133
- }
134
-
135
- function savePending(candidate: KnowledgeCandidate, reason: string, home: string): void {
136
- const paths = userRuntimePaths(home);
137
- const name = `${Date.now()}-${normalizeSlug(candidate.title)}-${randomUUID().slice(0, 8)}.json`;
138
- atomicWrite(join(paths.knowledgePending, name), `${JSON.stringify({ reason, candidate, createdAt: new Date().toISOString() }, null, 2)}\n`);
163
+ function applicable(entry: KnowledgeCatalogEntry, projectKey: string): boolean {
164
+ return entry.scope === "global" || entry.scope === `project:${projectKey}`;
139
165
  }
140
166
 
141
167
  function generateMemory(catalog: KnowledgeCatalog, projectKey?: string): string {
142
168
  const lines = [
143
- "# HWCode Knowledge Index",
144
- "",
145
- "Detailed topics are loaded only through hwcode_knowledge_lookup. Search by ID or keywords.",
146
- "",
169
+ "# HWCode Knowledge Index", "",
170
+ "Detailed topics are loaded only through hwcode_knowledge_lookup. Search by ID or keywords.", "",
147
171
  ];
148
172
  const topics = catalog.items.filter((item) => item.track === "topic" && (!projectKey || applicable(item, projectKey)))
149
173
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
@@ -159,93 +183,183 @@ function generateMemory(catalog: KnowledgeCatalog, projectKey?: string): string
159
183
  return `${lines.join("\n")}\n`;
160
184
  }
161
185
 
162
- function writeMemory(catalog: KnowledgeCatalog, home: string): void {
163
- atomicWrite(userRuntimePaths(home).knowledgeMemory, generateMemory(catalog));
186
+ function processExists(pid: number): boolean {
187
+ try { process.kill(pid, 0); return true; } catch (error) {
188
+ return (error as NodeJS.ErrnoException).code === "EPERM";
189
+ }
164
190
  }
165
191
 
166
- export function persistKnowledgeCandidates(
167
- values: unknown[],
168
- projectKey: string,
169
- home = homedir(),
170
- ): PersistKnowledgeResult {
171
- ensureKnowledgeDirectories(home);
172
- const catalog = loadKnowledgeCatalog(home);
173
- const result: PersistKnowledgeResult = { saved: 0, updated: 0, pending: 0, skipped: 0 };
174
- for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
175
- const candidate = normalizeCandidate(value, projectKey);
176
- if (!candidate) { result.skipped++; continue; }
177
- const fingerprint = normalizedFingerprint(candidate);
178
- const hash = contentHash(candidate);
179
- const existingIndex = catalog.items.findIndex((item) => item.fingerprint === fingerprint);
180
- const existing = existingIndex >= 0 ? catalog.items[existingIndex] : undefined;
181
- if (existing?.contentHash === hash) {
182
- existing.evidenceCount += candidate.evidence.length;
183
- existing.updatedAt = new Date().toISOString();
184
- result.updated++;
185
- continue;
192
+ function acquireCommitLock(leaderToken: string, home: string): () => void {
193
+ const paths = userRuntimePaths(home);
194
+ ensureDirectory(paths.knowledgeRuntime);
195
+ const attempt = (): boolean => {
196
+ try {
197
+ mkdirSync(paths.knowledgeCommitLock, { mode: 0o700 });
198
+ writeFileSync(join(paths.knowledgeCommitLock, "owner.json"), JSON.stringify({ pid: process.pid, leaderToken }), { mode: 0o600 });
199
+ return true;
200
+ } catch (error) {
201
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
202
+ return false;
186
203
  }
187
- if (existing && !(candidate.action === "revise" && (candidate.explicitUserDirective || candidate.confidence >= 0.92))) {
188
- savePending(candidate, "conflicting-or-ambiguous-update", home);
189
- result.pending++;
190
- continue;
204
+ };
205
+ if (!attempt()) {
206
+ try {
207
+ const owner = JSON.parse(readFileSync(join(paths.knowledgeCommitLock, "owner.json"), "utf8")) as {
208
+ pid?: number; leaderToken?: string;
209
+ };
210
+ if (owner.leaderToken !== leaderToken || (typeof owner.pid === "number" && !processExists(owner.pid))) {
211
+ rmSync(paths.knowledgeCommitLock, { recursive: true, force: true });
212
+ }
213
+ } catch {
214
+ const age = Date.now() - statSync(paths.knowledgeCommitLock).mtimeMs;
215
+ if (age > KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs) rmSync(paths.knowledgeCommitLock, { recursive: true, force: true });
191
216
  }
192
- if (!existing && candidate.storageHint === "rule"
193
- && storedRuleCharacters(home) + renderKnowledgeFile(candidate).length > STORAGE.maxRulesPromptChars) {
194
- candidate.storageHint = "topic";
217
+ if (!attempt()) throw new KnowledgeCommitBusyError("Knowledge repository is busy");
218
+ }
219
+ return () => rmSync(paths.knowledgeCommitLock, { recursive: true, force: true });
220
+ }
221
+
222
+ function newGenerationId(): string {
223
+ return `${new Date().toISOString().replace(/[:.]/gu, "-")}-${randomUUID().replace(/-/gu, "").slice(0, 12)}`;
224
+ }
225
+
226
+ function copyCurrentContent(manifest: KnowledgeManifest, target: string, home: string): void {
227
+ for (const name of ["rules", "topics", "pending"]) ensureDirectory(join(target, name));
228
+ if (!manifest.generationId) return;
229
+ const source = generationDirectory(manifest.generationId, home);
230
+ if (!source) return;
231
+ for (const name of ["rules", "topics", "pending"]) {
232
+ const sourceDirectory = join(source, name);
233
+ if (!existsSync(sourceDirectory)) continue;
234
+ for (const entry of readdirSync(sourceDirectory, { withFileTypes: true })) {
235
+ if (!entry.isFile()) continue;
236
+ const sourceFile = join(sourceDirectory, entry.name);
237
+ const targetFile = join(target, name, entry.name);
238
+ try { linkSync(sourceFile, targetFile); } catch { copyFileSync(sourceFile, targetFile); }
195
239
  }
196
- const now = new Date().toISOString();
197
- const id = existing?.id ?? `${normalizeSlug(candidate.title)}-${fingerprint.slice(0, 8)}`;
198
- const track = existing?.track ?? candidate.storageHint;
199
- candidate.storageHint = track;
200
- const relativeFile = `${track === "rule" ? "rules" : "topics"}/${id}.md`;
201
- const absoluteFile = join(userRuntimePaths(home).knowledge, relativeFile);
202
- atomicWrite(absoluteFile, renderKnowledgeFile(candidate));
203
- const entry: KnowledgeCatalogEntry = {
204
- id, fingerprint, contentHash: hash, title: candidate.title, summary: candidate.summary,
205
- keywords: candidate.keywords, scope: candidate.scope, track, file: relativeFile,
206
- evidenceCount: (existing?.evidenceCount ?? 0) + candidate.evidence.length,
207
- createdAt: existing?.createdAt ?? now, updatedAt: now,
208
- };
209
- if (existingIndex >= 0) { catalog.items[existingIndex] = entry; result.updated++; }
210
- else { catalog.items.push(entry); result.saved++; }
211
240
  }
212
- saveCatalog(catalog, home);
213
- writeMemory(catalog, home);
214
- return result;
215
241
  }
216
242
 
217
- function applicable(entry: KnowledgeCatalogEntry, projectKey: string): boolean {
218
- return entry.scope === "global" || entry.scope === `project:${projectKey}`;
243
+ function storedRuleCharacters(directory: string): number {
244
+ return readdirSync(join(directory, "rules"), { withFileTypes: true })
245
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
246
+ .reduce((total, entry) => total + readFileSync(join(directory, "rules", entry.name), "utf8").length, 0);
247
+ }
248
+
249
+ function reviewCursor(task: KnowledgeReviewTask): KnowledgeReviewCursor {
250
+ return {
251
+ sessionId: task.sessionId, sessionKey: task.sessionKey, sessionFileHash: task.sessionFileHash,
252
+ projectRoot: task.projectRoot, projectKey: task.projectKey, lastReviewedEntryId: task.lastEntryId,
253
+ lastReviewedAt: new Date().toISOString(), lastDeltaDigest: task.deltaDigest,
254
+ lastReviewKey: task.reviewKey, fileSize: task.fileSize, fileMtimeMs: task.fileMtimeMs,
255
+ };
219
256
  }
220
257
 
221
- function readCatalogFile(entry: KnowledgeCatalogEntry, home: string): string | undefined {
258
+ export function commitKnowledgeReview(
259
+ values: unknown[], task: KnowledgeReviewTask, leaderToken: string, home = homedir(),
260
+ ): PersistKnowledgeResult {
261
+ ensureKnowledgeDirectories(home);
262
+ const release = acquireCommitLock(leaderToken, home);
263
+ const paths = userRuntimePaths(home);
264
+ let temporary = "";
265
+ try {
266
+ const current = loadCurrentManifest(home);
267
+ if (current.reviews[task.sessionKey]?.lastReviewKey === task.reviewKey) {
268
+ return { saved: 0, updated: 0, pending: 0, skipped: 0 };
269
+ }
270
+ const generationId = newGenerationId();
271
+ temporary = join(paths.knowledgeGenerations, `.tmp-${generationId}-${process.pid}`);
272
+ ensureDirectory(temporary);
273
+ copyCurrentContent(current, temporary, home);
274
+ const catalog: KnowledgeCatalog = structuredClone(current.catalog);
275
+ const result: PersistKnowledgeResult = { saved: 0, updated: 0, pending: 0, skipped: 0 };
276
+ for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
277
+ const candidate = normalizeCandidate(value, task.projectKey);
278
+ if (!candidate) { result.skipped++; continue; }
279
+ const fingerprint = normalizedFingerprint(candidate);
280
+ const hash = contentHash(candidate);
281
+ const existingIndex = catalog.items.findIndex((item) => item.fingerprint === fingerprint);
282
+ const existing = existingIndex >= 0 ? catalog.items[existingIndex] : undefined;
283
+ if (existing?.contentHash === hash) {
284
+ existing.evidenceCount += candidate.evidence.length;
285
+ existing.updatedAt = new Date().toISOString();
286
+ result.updated++;
287
+ continue;
288
+ }
289
+ if (existing && !(candidate.action === "revise" && (candidate.explicitUserDirective || candidate.confidence >= 0.92))) {
290
+ const name = `${Date.now()}-${normalizeSlug(candidate.title)}-${randomUUID().slice(0, 8)}.json`;
291
+ atomicWrite(join(temporary, "pending", name), `${JSON.stringify({ reason: "conflicting-or-ambiguous-update", candidate }, null, 2)}\n`);
292
+ result.pending++;
293
+ continue;
294
+ }
295
+ if (!existing && candidate.storageHint === "rule"
296
+ && storedRuleCharacters(temporary) + renderKnowledgeFile(candidate).length > STORAGE.maxRulesPromptChars) {
297
+ candidate.storageHint = "topic";
298
+ }
299
+ const now = new Date().toISOString();
300
+ const id = existing?.id ?? `${normalizeSlug(candidate.title)}-${fingerprint.slice(0, 8)}`;
301
+ const track = existing?.track ?? candidate.storageHint;
302
+ candidate.storageHint = track;
303
+ const relativeFile = `${track === "rule" ? "rules" : "topics"}/${id}.md`;
304
+ atomicWrite(join(temporary, relativeFile), renderKnowledgeFile(candidate));
305
+ const entry: KnowledgeCatalogEntry = {
306
+ id, fingerprint, contentHash: hash, title: candidate.title, summary: candidate.summary,
307
+ keywords: candidate.keywords, scope: candidate.scope, track, file: relativeFile,
308
+ evidenceCount: (existing?.evidenceCount ?? 0) + candidate.evidence.length,
309
+ createdAt: existing?.createdAt ?? now, updatedAt: now,
310
+ };
311
+ if (existingIndex >= 0) { catalog.items[existingIndex] = entry; result.updated++; }
312
+ else { catalog.items.push(entry); result.saved++; }
313
+ }
314
+ catalog.updatedAt = new Date().toISOString();
315
+ const manifest: KnowledgeManifest = {
316
+ version: 3, generationId, createdAt: new Date().toISOString(), leaderToken, catalog,
317
+ reviews: { ...current.reviews, [task.sessionKey]: reviewCursor(task) },
318
+ };
319
+ atomicWrite(join(temporary, "MEMORY.md"), generateMemory(catalog));
320
+ atomicWrite(join(temporary, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
321
+ if (!validManifestAt(manifest, temporary)) throw new Error("Refusing to publish an incomplete knowledge generation");
322
+ const finalDirectory = join(paths.knowledgeGenerations, generationId);
323
+ renameSync(temporary, finalDirectory);
324
+ temporary = "";
325
+ atomicWrite(paths.knowledgeCurrent, `${generationId}\n`);
326
+ return result;
327
+ } finally {
328
+ if (temporary) rmSync(temporary, { recursive: true, force: true });
329
+ release();
330
+ }
331
+ }
332
+
333
+ function readCatalogFile(entry: KnowledgeCatalogEntry, manifest: KnowledgeManifest, home: string): string | undefined {
222
334
  if (!SAFE_ID_RE.test(entry.id) || basename(entry.file) !== `${entry.id}.md`) return undefined;
223
- const root = resolve(userRuntimePaths(home).knowledge);
335
+ const root = generationDirectory(manifest.generationId, home);
336
+ if (!root) return undefined;
224
337
  const path = resolve(root, entry.file);
225
338
  if (!path.startsWith(`${root}${sep}`) || !existsSync(path)) return undefined;
226
339
  return sanitizeKnowledgeText(readFileSync(path, "utf8"));
227
340
  }
228
341
 
229
342
  export function loadKnowledgeSnapshot(projectKey: string, home = homedir()): KnowledgeSnapshot {
230
- ensureKnowledgeDirectories(home);
231
- const catalog = loadKnowledgeCatalog(home);
232
- const ruleParts: string[] = [];
233
- const paths = userRuntimePaths(home);
234
- for (const file of readdirSync(paths.knowledgeRules, { withFileTypes: true })
235
- .filter((entry) => entry.isFile() && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name))) {
236
- const relativeFile = `rules/${file.name}`;
237
- const entry = catalog.items.find((item) => item.file === relativeFile);
238
- if (entry && !applicable(entry, projectKey)) continue;
239
- ruleParts.push(sanitizeKnowledgeText(readFileSync(join(paths.knowledgeRules, file.name), "utf8")).trim());
343
+ const manifest = loadCurrentManifest(home);
344
+ if (!manifest.generationId) {
345
+ return { rulesPrompt: "", memoryPrompt: generateMemory(manifest.catalog, projectKey), catalog: manifest.catalog };
240
346
  }
241
- const memoryPrompt = generateMemory(catalog, projectKey);
242
- return { rulesPrompt: ruleParts.join("\n\n"), memoryPrompt, catalog };
347
+ const rules = manifest.catalog.items.filter((entry) => entry.track === "rule" && applicable(entry, projectKey))
348
+ .sort((left, right) => left.file.localeCompare(right.file))
349
+ .map((entry) => readCatalogFile(entry, manifest, home)).filter((content): content is string => Boolean(content));
350
+ return {
351
+ generationId: manifest.generationId, rulesPrompt: rules.join("\n\n"),
352
+ memoryPrompt: generateMemory(manifest.catalog, projectKey), catalog: manifest.catalog,
353
+ };
243
354
  }
244
355
 
245
- export function loadKnowledgeById(id: string, projectKey: string, home = homedir()): { entry: KnowledgeCatalogEntry; content: string } | undefined {
356
+ export function loadKnowledgeById(
357
+ id: string, projectKey: string, home = homedir(),
358
+ ): { entry: KnowledgeCatalogEntry; content: string } | undefined {
246
359
  if (!SAFE_ID_RE.test(id)) return undefined;
247
- const entry = loadKnowledgeCatalog(home).items.find((item) => item.id === id && applicable(item, projectKey));
360
+ const manifest = loadCurrentManifest(home);
361
+ const entry = manifest.catalog.items.find((item) => item.id === id && applicable(item, projectKey));
248
362
  if (!entry) return undefined;
249
- const content = readCatalogFile(entry, home);
363
+ const content = readCatalogFile(entry, manifest, home);
250
364
  return content ? { entry, content } : undefined;
251
365
  }
@@ -32,23 +32,39 @@ export interface KnowledgeCatalogEntry {
32
32
  }
33
33
 
34
34
  export interface KnowledgeCatalog {
35
- version: 2;
35
+ version: 3;
36
36
  updatedAt: string;
37
37
  items: KnowledgeCatalogEntry[];
38
38
  }
39
39
 
40
40
  export interface KnowledgeSnapshot {
41
+ generationId?: string;
41
42
  rulesPrompt: string;
42
43
  memoryPrompt: string;
43
44
  catalog: KnowledgeCatalog;
44
45
  }
45
46
 
46
- export interface KnowledgeReviewState {
47
- version: 1;
47
+ export interface KnowledgeReviewCursor {
48
+ sessionId: string;
49
+ sessionKey: string;
50
+ sessionFileHash: string;
51
+ projectRoot: string;
52
+ projectKey: string;
48
53
  lastReviewedEntryId?: string;
49
54
  lastReviewedAt?: string;
50
- lastDeltaDigest?: string;
51
- lastResult?: "saved" | "noop";
55
+ lastDeltaDigest: string;
56
+ lastReviewKey: string;
57
+ fileSize: number;
58
+ fileMtimeMs: number;
59
+ }
60
+
61
+ export interface KnowledgeManifest {
62
+ version: 3;
63
+ generationId: string;
64
+ createdAt: string;
65
+ leaderToken: string;
66
+ catalog: KnowledgeCatalog;
67
+ reviews: Record<string, KnowledgeReviewCursor>;
52
68
  }
53
69
 
54
70
  export interface PersistKnowledgeResult {
@@ -57,3 +73,19 @@ export interface PersistKnowledgeResult {
57
73
  pending: number;
58
74
  skipped: number;
59
75
  }
76
+
77
+ export interface KnowledgeReviewTask {
78
+ requestId: string;
79
+ reviewKey: string;
80
+ sessionId: string;
81
+ sessionKey: string;
82
+ sessionFileHash: string;
83
+ projectRoot: string;
84
+ projectKey: string;
85
+ firstEntryId: string;
86
+ lastEntryId: string;
87
+ delta: string;
88
+ deltaDigest: string;
89
+ fileSize: number;
90
+ fileMtimeMs: number;
91
+ }
@@ -1,12 +1,20 @@
1
- import type { PersistKnowledgeResult } from "./types.ts";
1
+ import type { KnowledgeReviewTask, PersistKnowledgeResult } from "./types.ts";
2
2
 
3
3
  export type KnowledgeWorkerInput =
4
- | { type: "configure"; sessionId: string; projectKey: string; dirty: boolean }
5
- | { type: "activity"; state: "busy" | "settled"; dirty?: boolean }
6
- | { type: "review_result"; requestId: string; raw?: string; error?: string }
4
+ | { type: "configure"; modelAvailable: boolean; sessionsRoot: string }
5
+ | { type: "review_result"; leaderToken: string; requestId: string; raw?: string; error?: string }
6
+ | { type: "scan_now" }
7
7
  | { type: "stop" };
8
8
 
9
9
  export type KnowledgeWorkerOutput =
10
- | { type: "review_due"; requestId: string }
11
- | { type: "review_saved"; requestId: string; result: PersistKnowledgeResult }
12
- | { type: "review_failed"; requestId: string; error: string };
10
+ | { type: "leadership"; state: "leader" | "standby" | "ineligible"; leaderToken?: string }
11
+ | { type: "review_request"; leaderToken: string; requestId: string; task: KnowledgeReviewTask }
12
+ | { type: "review_cancel"; requestId: string; reason: string }
13
+ | { type: "review_saved"; requestId: string; generationId?: string; result: PersistKnowledgeResult }
14
+ | { type: "review_failed"; requestId?: string; error: string }
15
+ | { type: "generation_changed"; generationId: string };
16
+
17
+ export interface CoordinatorBroadcast {
18
+ type: "generation_changed";
19
+ generationId: string;
20
+ }
@@ -20,7 +20,7 @@ export const CLOUD_RUNTIME_DEFAULTS = Object.freeze({
20
20
  });
21
21
 
22
22
  /**
23
- * Defaults for the cross-workflow knowledge base (`~/.hwcode/knowledge/`).
23
+ * Defaults for the cross-workflow knowledge base (`~/.hwcode/knowledge-v3/`).
24
24
  *
25
25
  * A Worker checks settled sessions on a fixed interval. Short rules are loaded
26
26
  * in full; detailed topics are routed through a bounded MEMORY.md index so the
@@ -29,8 +29,11 @@ export const CLOUD_RUNTIME_DEFAULTS = Object.freeze({
29
29
  export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
30
30
  review: Object.freeze({
31
31
  intervalMs: 60_000,
32
+ idleMs: 60_000,
33
+ capabilityPollMs: 5_000,
34
+ modelTimeoutMs: 120_000,
32
35
  maxDeltaChars: 30_000,
33
- maxCandidates: 3,
36
+ maxCandidates: 8,
34
37
  minimumConfidence: 0.72,
35
38
  }),
36
39
  storage: Object.freeze({
@@ -12,7 +12,7 @@ const CLOUD_VAULT_FILE = "credentials.enc";
12
12
  const CLOUD_KNOWN_HOSTS_FILE = "known_hosts";
13
13
  const CLOUD_TEMPLATES_SUBDIR = "templates";
14
14
  const CLOUD_TERRAFORM_SUBDIR = "terraform";
15
- const KNOWLEDGE_SUBDIR = "knowledge";
15
+ const KNOWLEDGE_SUBDIR = "knowledge-v3";
16
16
 
17
17
  export interface ProjectRuntimePaths {
18
18
  root: string;
@@ -29,11 +29,13 @@ export interface UserRuntimePaths {
29
29
  cloudKnownHosts: string;
30
30
  cloudTerraformTemplates: string;
31
31
  knowledge: string;
32
- knowledgeRules: string;
33
- knowledgeTopics: string;
34
- knowledgePending: string;
35
- knowledgeCatalog: string;
36
- knowledgeMemory: string;
32
+ knowledgeGenerations: string;
33
+ knowledgeCurrent: string;
34
+ knowledgeRuntime: string;
35
+ knowledgeCoordinatorSocket: string;
36
+ knowledgeLeader: string;
37
+ knowledgeCommitLock: string;
38
+ knowledgeElectionLock: string;
37
39
  }
38
40
 
39
41
  export function projectRuntimePaths(projectRoot: string): ProjectRuntimePaths {
@@ -60,11 +62,14 @@ export function userRuntimePaths(home = homedir()): UserRuntimePaths {
60
62
  cloudKnownHosts: join(cloud, CLOUD_KNOWN_HOSTS_FILE),
61
63
  cloudTerraformTemplates: join(cloud, CLOUD_TEMPLATES_SUBDIR, CLOUD_TERRAFORM_SUBDIR),
62
64
  knowledge,
63
- knowledgeRules: join(knowledge, "rules"),
64
- knowledgeTopics: join(knowledge, "topics"),
65
- knowledgePending: join(knowledge, "pending"),
66
- knowledgeCatalog: join(knowledge, "catalog.json"),
67
- knowledgeMemory: join(knowledge, "MEMORY.md"),
65
+ knowledgeGenerations: join(knowledge, "generations"),
66
+ knowledgeCurrent: join(knowledge, "CURRENT"),
67
+ knowledgeRuntime: join(knowledge, "runtime"),
68
+ // Unix-domain socket paths are short on purpose (macOS caps them at roughly 104 bytes).
69
+ knowledgeCoordinatorSocket: join(root, "knowledge-v3.sock"),
70
+ knowledgeLeader: join(knowledge, "runtime", "leader.json"),
71
+ knowledgeCommitLock: join(knowledge, "runtime", "commit.lock"),
72
+ knowledgeElectionLock: join(knowledge, "runtime", "election.lock"),
68
73
  };
69
74
  }
70
75