@dall337/lore 0.0.0-stage → 0.1.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 (53) hide show
  1. package/License.md +21 -0
  2. package/README.md +210 -2
  3. package/bin/lore.mjs +187 -0
  4. package/hud/dist/client/assets/index-BA5GHj6l.css +1 -0
  5. package/hud/dist/client/assets/index-C1P2dBAT.js +7 -0
  6. package/hud/dist/client/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  7. package/hud/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  8. package/hud/dist/client/assets/jetbrains-mono-cyrillic-600-normal-8K4wrrwR.woff +0 -0
  9. package/hud/dist/client/assets/jetbrains-mono-cyrillic-600-normal-EVf6-Yzo.woff2 +0 -0
  10. package/hud/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  11. package/hud/dist/client/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  12. package/hud/dist/client/assets/jetbrains-mono-greek-600-normal-H7WoG9Et.woff2 +0 -0
  13. package/hud/dist/client/assets/jetbrains-mono-greek-600-normal-mc2nkWzM.woff +0 -0
  14. package/hud/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  15. package/hud/dist/client/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  16. package/hud/dist/client/assets/jetbrains-mono-latin-600-normal-BfsvjouI.woff +0 -0
  17. package/hud/dist/client/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  18. package/hud/dist/client/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  19. package/hud/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  20. package/hud/dist/client/assets/jetbrains-mono-latin-ext-600-normal-BfB_LPfz.woff2 +0 -0
  21. package/hud/dist/client/assets/jetbrains-mono-latin-ext-600-normal-DObL3zCW.woff +0 -0
  22. package/hud/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  23. package/hud/dist/client/assets/jetbrains-mono-vietnamese-600-normal-OWROknRo.woff +0 -0
  24. package/hud/dist/client/index.html +17 -0
  25. package/hud/dist/client/licenses/JetBrainsMono-OFL.txt +93 -0
  26. package/hud/dist/server/main.js +1287 -0
  27. package/package.json +52 -4
  28. package/suite/AGENTS.md +228 -0
  29. package/suite/CLAUDE.md +19 -0
  30. package/suite/claude-settings-additions.json +16 -0
  31. package/suite/gitignore-additions.txt +17 -0
  32. package/suite/memory/INDEX.md +41 -0
  33. package/suite/policies/adversarial-check.md +169 -0
  34. package/suite/policies/board.md +103 -0
  35. package/suite/policies/creative.md +92 -0
  36. package/suite/policies/documentation.md +100 -0
  37. package/suite/policies/git.md +105 -0
  38. package/suite/policies/implementation.md +100 -0
  39. package/suite/policies/memory.md +453 -0
  40. package/suite/policies/orchestration.md +184 -0
  41. package/suite/policies/release.md +56 -0
  42. package/suite/policies/research.md +133 -0
  43. package/suite/policies/reserved/failure-and-recovery.md +6 -0
  44. package/suite/policies/reserved/migration.md +6 -0
  45. package/suite/policies/reserved/planning.md +6 -0
  46. package/suite/policies/reserved/review.md +6 -0
  47. package/suite/policies/reserved/test-authoring.md +6 -0
  48. package/suite/policies/security.md +85 -0
  49. package/suite/policies/tooling.md +153 -0
  50. package/suite/policies/verification.md +60 -0
  51. package/suite/scripts/agents-md-budget.mjs +71 -0
  52. package/suite/scripts/memory-lint.mjs +117 -0
  53. package/suite/scripts/provider-memory.mjs +298 -0
@@ -0,0 +1,1287 @@
1
+ import { existsSync, mkdirSync, promises, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { execFile, execFileSync } from "node:child_process";
6
+ import { createServer, get } from "node:http";
7
+ //#region hud/shared/boardOps.ts
8
+ /**
9
+ * Move a card to `index` within `column` (index counts only that column's
10
+ * cards). Card order lives in the flat `cards` array. Shared so the page's
11
+ * optimistic move and the server's write can never disagree.
12
+ */
13
+ function applyMove(board, id, column, index, today) {
14
+ const card = board.cards.find((c) => c.id === id);
15
+ if (!card) throw new Error(`no card with id "${id}"`);
16
+ if (!board.columns.some((c) => c.id === column)) throw new Error(`unknown column "${column}"`);
17
+ board.cards.splice(board.cards.indexOf(card), 1);
18
+ const target = board.cards.filter((c) => c.column === column);
19
+ const at = Math.max(0, Math.min(Number.isFinite(index) ? index : target.length, target.length));
20
+ if (card.column !== column) card.updated = today;
21
+ card.column = column;
22
+ if (at < target.length) board.cards.splice(board.cards.indexOf(target[at]), 0, card);
23
+ else if (target.length > 0) board.cards.splice(board.cards.indexOf(target[target.length - 1]) + 1, 0, card);
24
+ else board.cards.push(card);
25
+ }
26
+ /** The column carrying a role, if the board assigns it. */
27
+ var columnWithRole = (board, role) => board.columns.find((c) => c.role === role);
28
+ var shiftDays = (date, days) => new Date(Date.parse(date) + days * 864e5).toISOString().slice(0, 10);
29
+ /**
30
+ * The PRs a journal's text points at that belong to ITS work: opened from the
31
+ * day before the journal started to three weeks after. Older numbers in the
32
+ * text are citations of earlier work, not this journal's PRs.
33
+ */
34
+ function ownPrs(j, prs) {
35
+ const from = shiftDays(j.date, -1);
36
+ const to = shiftDays(j.date, 21);
37
+ const out = [];
38
+ for (const n of j.prRefs) {
39
+ const p = prs.get(n);
40
+ const opened = p?.createdAt?.slice(0, 10);
41
+ if (p && opened && opened >= from && opened <= to) out.push(p);
42
+ }
43
+ return out;
44
+ }
45
+ /** "fix(scaffold): yarn installs…" → "Yarn installs…" */
46
+ function cleanPrTitle(title) {
47
+ const t = title.replace(/^[a-z]+(\([^)]*\))?!?:\s*/i, "").trim() || title;
48
+ return t.charAt(0).toUpperCase() + t.slice(1);
49
+ }
50
+ /** "2026-10-01 — P4: `x`" / "Review (2026-10-05)" / "X — 2026-10-07" → the bare name. */
51
+ function cleanJournalTitle(title) {
52
+ return title.replace(/^\d{4}-\d{2}-\d{2}\s*[—–-]\s*/, "").replace(/\s*[—–-]\s*\(?\d{4}-\d{2}-\d{2}\)?\s*$/, "").replace(/\s*\(\d{4}-\d{2}-\d{2}\)\s*$/, "").replace(/`/g, "").trim() || title;
53
+ }
54
+ var INBOX_KEY = /^(pr|issue):\d+$|^journal:[\w.-]+\.md$/;
55
+ /** Where an Inbox kind lands: the first role the board assigns, else the first column. */
56
+ function landing(board, roles) {
57
+ for (const role of roles) {
58
+ const col = columnWithRole(board, role);
59
+ if (col) return col.id;
60
+ }
61
+ return board.columns[0].id;
62
+ }
63
+ function inboxItems(board, journals, prs, issues) {
64
+ const prMap = new Map(prs.map((p) => [p.number, p]));
65
+ const dismissed = new Set(board.inboxDismissed ?? []);
66
+ const linkedPrs = /* @__PURE__ */ new Set();
67
+ const linkedIssues = /* @__PURE__ */ new Set();
68
+ const linkedJournals = /* @__PURE__ */ new Set();
69
+ for (const c of board.cards) {
70
+ c.links?.prs?.forEach((n) => linkedPrs.add(n));
71
+ c.links?.issues?.forEach((n) => linkedIssues.add(n));
72
+ c.links?.journals?.forEach((f) => linkedJournals.add(f));
73
+ }
74
+ const own = new Map(journals.map((j) => [j.file, ownPrs(j, prMap)]));
75
+ for (const f of linkedJournals) own.get(f)?.forEach((p) => linkedPrs.add(p.number));
76
+ const prColumn = landing(board, ["review", "active"]);
77
+ const journalColumn = landing(board, ["active"]);
78
+ const issueColumn = landing(board, ["intake"]);
79
+ const active = journals.filter((j) => j.status === "open");
80
+ const items = [];
81
+ const prItems = /* @__PURE__ */ new Set();
82
+ for (const p of prs) {
83
+ if (p.state !== "OPEN" || linkedPrs.has(p.number) || dismissed.has(`pr:${p.number}`)) continue;
84
+ const withJournals = active.filter((j) => own.get(j.file)?.some((x) => x.number === p.number)).map((j) => j.file);
85
+ prItems.add(p.number);
86
+ items.push({
87
+ key: `pr:${p.number}`,
88
+ kind: "pr",
89
+ label: `#${p.number}`,
90
+ title: p.title,
91
+ when: p.updatedAt,
92
+ url: p.url,
93
+ suggest: {
94
+ title: cleanPrTitle(p.title),
95
+ column: prColumn,
96
+ links: {
97
+ prs: [p.number],
98
+ ...withJournals.length ? { journals: withJournals } : {}
99
+ }
100
+ }
101
+ });
102
+ }
103
+ for (const j of active) {
104
+ if (linkedJournals.has(j.file) || dismissed.has(`journal:${j.file}`)) continue;
105
+ const mine = own.get(j.file) ?? [];
106
+ if (mine.some((p) => linkedPrs.has(p.number) || prItems.has(p.number))) continue;
107
+ items.push({
108
+ key: `journal:${j.file}`,
109
+ kind: "journal",
110
+ label: j.date,
111
+ title: j.title,
112
+ when: j.modified,
113
+ file: j.file,
114
+ suggest: {
115
+ title: cleanJournalTitle(j.title),
116
+ column: journalColumn,
117
+ links: {
118
+ journals: [j.file],
119
+ ...mine.length ? { prs: mine.map((p) => p.number) } : {}
120
+ }
121
+ }
122
+ });
123
+ }
124
+ for (const i of issues) {
125
+ if (i.state !== "OPEN" || linkedIssues.has(i.number) || dismissed.has(`issue:${i.number}`)) continue;
126
+ items.push({
127
+ key: `issue:${i.number}`,
128
+ kind: "issue",
129
+ label: `#${i.number}`,
130
+ title: i.title,
131
+ when: i.updatedAt,
132
+ url: i.url,
133
+ suggest: {
134
+ title: i.title,
135
+ column: issueColumn,
136
+ links: { issues: [i.number] }
137
+ }
138
+ });
139
+ }
140
+ return items.sort((a, b) => b.when.localeCompare(a.when));
141
+ }
142
+ //#endregion
143
+ //#region hud/shared/types.ts
144
+ var COLUMN_ROLES = [
145
+ "intake",
146
+ "active",
147
+ "review",
148
+ "blocked",
149
+ "done"
150
+ ];
151
+ //#endregion
152
+ //#region hud/server/board.ts
153
+ var BoardError = class extends Error {
154
+ status;
155
+ constructor(message, status = 400) {
156
+ super(message);
157
+ this.status = status;
158
+ }
159
+ };
160
+ var today = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
161
+ var DEFAULT_COLUMNS = [
162
+ {
163
+ id: "backlog",
164
+ title: "Backlog",
165
+ led: "empty",
166
+ hint: "Captured, not sequenced",
167
+ role: "intake"
168
+ },
169
+ {
170
+ id: "next",
171
+ title: "Next",
172
+ led: "blue",
173
+ hint: "Sequenced, ready to start"
174
+ },
175
+ {
176
+ id: "doing",
177
+ title: "In progress",
178
+ led: "pulse",
179
+ hint: "Active now",
180
+ role: "active"
181
+ },
182
+ {
183
+ id: "review",
184
+ title: "Waiting on owner",
185
+ led: "amber",
186
+ hint: "Reviews, rulings, merges",
187
+ role: "review"
188
+ },
189
+ {
190
+ id: "blocked",
191
+ title: "Blocked",
192
+ led: "red",
193
+ hint: "External dependency",
194
+ role: "blocked"
195
+ },
196
+ {
197
+ id: "done",
198
+ title: "Done",
199
+ led: "green",
200
+ hint: "Merged or shipped",
201
+ role: "done"
202
+ }
203
+ ];
204
+ var LEDS = /* @__PURE__ */ new Set([
205
+ "empty",
206
+ "blue",
207
+ "pulse",
208
+ "amber",
209
+ "red",
210
+ "green"
211
+ ]);
212
+ var emptyBoard = () => ({
213
+ version: 1,
214
+ columns: structuredClone(DEFAULT_COLUMNS),
215
+ cards: [],
216
+ areas: {},
217
+ journals: {}
218
+ });
219
+ function validate(raw) {
220
+ const fail = (message) => {
221
+ throw new BoardError(`board.json: ${message}`, 500);
222
+ };
223
+ if (!raw || typeof raw !== "object") fail("not an object");
224
+ const b = raw;
225
+ if (!Array.isArray(b.columns) || b.columns.length === 0) fail("\"columns\" must be a non-empty array");
226
+ if (!Array.isArray(b.cards)) fail("\"cards\" must be an array");
227
+ const columnIds = /* @__PURE__ */ new Set();
228
+ const roles = /* @__PURE__ */ new Set();
229
+ for (const col of b.columns) {
230
+ if (!col || typeof col.id !== "string" || typeof col.title !== "string") fail("every column needs a string \"id\" and \"title\"");
231
+ if (columnIds.has(col.id)) fail(`duplicate column id "${col.id}"`);
232
+ columnIds.add(col.id);
233
+ if (!LEDS.has(col.led)) fail(`column "${col.id}" has unknown led "${String(col.led)}"`);
234
+ if (col.role !== void 0) {
235
+ if (!COLUMN_ROLES.includes(col.role)) fail(`column "${col.id}" has unknown role "${String(col.role)}" (${COLUMN_ROLES.join(", ")})`);
236
+ if (roles.has(col.role)) fail(`role "${col.role}" is on more than one column`);
237
+ roles.add(col.role);
238
+ }
239
+ }
240
+ if (b.areas !== void 0 && (!b.areas || typeof b.areas !== "object" || Array.isArray(b.areas) || Object.values(b.areas).some((v) => typeof v !== "string"))) fail("\"areas\" must map area keys to descriptions");
241
+ const seen = /* @__PURE__ */ new Set();
242
+ for (const card of b.cards) {
243
+ if (!card || typeof card.id !== "string" || typeof card.title !== "string") fail("every card needs a string \"id\" and \"title\"");
244
+ if (seen.has(card.id)) fail(`duplicate card id "${card.id}"`);
245
+ seen.add(card.id);
246
+ if (!columnIds.has(card.column)) fail(`card "${card.id}" is in unknown column "${card.column}"`);
247
+ if (card.area !== void 0 && !(Array.isArray(card.area) && card.area.every((a) => typeof a === "string"))) fail(`card "${card.id}": "area" must be an array of area keys`);
248
+ }
249
+ if (b.inboxDismissed !== void 0 && !(Array.isArray(b.inboxDismissed) && b.inboxDismissed.every((k) => typeof k === "string"))) fail("\"inboxDismissed\" must be an array of strings");
250
+ return {
251
+ ...b,
252
+ version: 1,
253
+ columns: b.columns,
254
+ cards: b.cards,
255
+ journals: b.journals ?? {}
256
+ };
257
+ }
258
+ function createBoardStore(boardPath) {
259
+ let queue = Promise.resolve();
260
+ /** Text of our own last write — lets the file watcher ignore self-writes. */
261
+ let lastWritten = "";
262
+ /** `create: false` reports a missing board as null instead of seeding one. */
263
+ async function load(create) {
264
+ let text;
265
+ try {
266
+ text = await promises.readFile(boardPath, "utf8");
267
+ } catch (err) {
268
+ if (err.code !== "ENOENT") throw err;
269
+ if (!create) return null;
270
+ const fresh = emptyBoard();
271
+ await write(fresh);
272
+ return fresh;
273
+ }
274
+ let parsed;
275
+ try {
276
+ parsed = JSON.parse(text.replace(/^/, ""));
277
+ } catch (err) {
278
+ throw new BoardError(`board.json is not valid JSON: ${err.message}`, 500);
279
+ }
280
+ return validate(parsed);
281
+ }
282
+ const read = async () => await load(true);
283
+ async function write(board) {
284
+ const text = JSON.stringify(board, null, 2) + "\n";
285
+ const tmp = `${boardPath}.tmp`;
286
+ await promises.mkdir(path.dirname(boardPath), { recursive: true });
287
+ await promises.writeFile(tmp, text, "utf8");
288
+ lastWritten = text;
289
+ for (let attempt = 0;; attempt++) try {
290
+ await promises.rename(tmp, boardPath);
291
+ return;
292
+ } catch (err) {
293
+ if (attempt >= 4) throw err;
294
+ await new Promise((r) => setTimeout(r, 60 * (attempt + 1)));
295
+ }
296
+ }
297
+ /** Serialize read-modify-write operations. */
298
+ function mutate(op) {
299
+ const run = queue.then(async () => {
300
+ const board = await read();
301
+ op(board);
302
+ validate(board);
303
+ await write(board);
304
+ return board;
305
+ });
306
+ queue = run.catch(() => void 0);
307
+ return run;
308
+ }
309
+ function findCard(board, id) {
310
+ const card = board.cards.find((c) => c.id === id);
311
+ if (!card) throw new BoardError(`no card with id "${id}"`, 404);
312
+ return card;
313
+ }
314
+ return {
315
+ read,
316
+ peek: () => load(false),
317
+ isOwnWrite: (text) => text === lastWritten,
318
+ move(id, column, index) {
319
+ return mutate((board) => {
320
+ if (!board.columns.some((c) => c.id === column)) throw new BoardError(`unknown column "${column}"`);
321
+ findCard(board, id);
322
+ applyMove(board, id, column, index, today());
323
+ });
324
+ },
325
+ /**
326
+ * Create or merge-update a card. A field left out keeps the card's value
327
+ * (title and column included); a `null` field clears it.
328
+ */
329
+ upsert(input) {
330
+ return mutate((board) => {
331
+ const existing = input.id ? board.cards.find((c) => c.id === input.id) : void 0;
332
+ const title = (input.title ?? existing?.title ?? "").trim();
333
+ if (!title) throw new BoardError("a card needs a title");
334
+ const column = input.column ?? existing?.column ?? board.columns[0].id;
335
+ if (!board.columns.some((c) => c.id === column)) throw new BoardError(`unknown column "${column}"`);
336
+ const card = existing ?? {
337
+ id: input.id || randomUUID(),
338
+ created: today()
339
+ };
340
+ Object.assign(card, input, {
341
+ id: card.id,
342
+ title,
343
+ column,
344
+ updated: today()
345
+ });
346
+ for (const key of Object.keys(card)) if (card[key] === null) delete card[key];
347
+ if (!existing) board.cards.push(card);
348
+ });
349
+ },
350
+ remove(id) {
351
+ return mutate((board) => {
352
+ const card = findCard(board, id);
353
+ board.cards.splice(board.cards.indexOf(card), 1);
354
+ });
355
+ },
356
+ /** Dismiss (or restore) one Inbox item; `key === '*'` with dismissed=false restores all. */
357
+ setInboxDismissed(key, dismissed) {
358
+ if (key !== "*" && !INBOX_KEY.test(key)) return Promise.reject(new BoardError(`not an inbox key: ${key}`));
359
+ return mutate((board) => {
360
+ const set = new Set(board.inboxDismissed ?? []);
361
+ if (key === "*") set.clear();
362
+ else if (dismissed) set.add(key);
363
+ else set.delete(key);
364
+ board.inboxDismissed = [...set].sort();
365
+ });
366
+ },
367
+ setJournal(file, override) {
368
+ return mutate((board) => {
369
+ board.journals ??= {};
370
+ if (override) board.journals[file] = {
371
+ ...override,
372
+ at: (/* @__PURE__ */ new Date()).toISOString()
373
+ };
374
+ else delete board.journals[file];
375
+ });
376
+ }
377
+ };
378
+ }
379
+ //#endregion
380
+ //#region hud/server/config.ts
381
+ var POLICY_DIRS = [
382
+ "lore/policies",
383
+ "policies",
384
+ "suite/policies"
385
+ ];
386
+ var slashes = (p) => p.split(path.sep).join("/");
387
+ var inside = (root, full) => {
388
+ const r = path.relative(root, full);
389
+ return r === "" || !r.startsWith("..") && !path.isAbsolute(r);
390
+ };
391
+ /** Forward-slash path relative to the main checkout. */
392
+ var rel = (config, full) => slashes(path.relative(config.mainRoot, full));
393
+ /** A path as the owner should read it: relative inside the main checkout, absolute outside it. */
394
+ var display = (config, full) => inside(config.mainRoot, full) ? rel(config, full) : slashes(path.resolve(full));
395
+ /** Read-only git: never takes the index lock or refreshes it. */
396
+ function git(cwd, args) {
397
+ try {
398
+ return execFileSync("git", args, {
399
+ cwd,
400
+ encoding: "utf8",
401
+ windowsHide: true,
402
+ stdio: [
403
+ "ignore",
404
+ "pipe",
405
+ "ignore"
406
+ ],
407
+ env: {
408
+ ...process.env,
409
+ GIT_OPTIONAL_LOCKS: "0"
410
+ }
411
+ }).trim();
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
416
+ /** The main checkout of a repository: the first worktree git lists, unless that one is bare. */
417
+ function mainWorktree(repoRoot) {
418
+ const first = git(repoRoot, [
419
+ "worktree",
420
+ "list",
421
+ "--porcelain"
422
+ ])?.split(/\r?\n\r?\n/)[0] ?? "";
423
+ const dir = /^worktree (.+)$/m.exec(first)?.[1];
424
+ return dir && !/^bare$/m.test(first) ? path.resolve(dir) : repoRoot;
425
+ }
426
+ /** "https://github.com/acme/widgets.git" or "git@github.com:acme/widgets.git" → "widgets". */
427
+ function repoNameFromUrl(url) {
428
+ return url.trim().replace(/[\\/]+$/, "").split(/[\\/:]/).pop()?.replace(/\.git$/i, "") || null;
429
+ }
430
+ function remoteName(repoRoot) {
431
+ const remotes = git(repoRoot, ["remote"])?.split(/\r?\n/).filter(Boolean) ?? [];
432
+ const remote = remotes.includes("origin") ? "origin" : remotes[0];
433
+ const url = remote ? git(repoRoot, [
434
+ "remote",
435
+ "get-url",
436
+ remote
437
+ ]) : null;
438
+ return url ? repoNameFromUrl(url) : null;
439
+ }
440
+ function readConfigFile(dataDir) {
441
+ const file = path.join(dataDir, "config.json");
442
+ if (!existsSync(file)) return {};
443
+ try {
444
+ const value = JSON.parse(readFileSync(file, "utf8").replace(/^/, ""));
445
+ return value && typeof value === "object" ? value : {};
446
+ } catch (err) {
447
+ throw new Error(`${file} is not valid JSON: ${err.message}`);
448
+ }
449
+ }
450
+ function resolveConfig(repo, data) {
451
+ const start = path.resolve(repo);
452
+ if (!existsSync(start) || !statSync(start).isDirectory()) throw new Error(`not a directory: ${start}`);
453
+ const top = git(start, ["rev-parse", "--show-toplevel"]);
454
+ const repoRoot = path.resolve(top || start);
455
+ const mainRoot = top ? mainWorktree(repoRoot) : repoRoot;
456
+ const dataDir = data ? path.resolve(data) : path.join(mainRoot, ".lore");
457
+ const file = readConfigFile(dataDir);
458
+ const text = (value) => typeof value === "string" && value.trim() ? value.trim() : null;
459
+ const pick = (value) => text(value) ? path.resolve(mainRoot, text(value)) : null;
460
+ const policiesDir = pick(file.policies) ?? POLICY_DIRS.map((d) => path.join(mainRoot, d)).find((d) => existsSync(d) && statSync(d).isDirectory()) ?? null;
461
+ const port = Number.isInteger(file.port) && file.port > 0 && file.port < 65536 ? file.port : null;
462
+ return {
463
+ repoRoot,
464
+ mainRoot,
465
+ repoName: text(file.name) ?? (top ? remoteName(repoRoot) : null) ?? path.basename(mainRoot),
466
+ dataDir,
467
+ boardPath: path.join(dataDir, "board.json"),
468
+ journalsDir: pick(file.journals) ?? path.join(mainRoot, ".research"),
469
+ policiesDir,
470
+ port
471
+ };
472
+ }
473
+ /**
474
+ * A stable default port for one repository, so its address can be
475
+ * bookmarked: derived from the main checkout's path, in 5200–5999.
476
+ */
477
+ function defaultPortFor(mainRoot) {
478
+ const key = path.resolve(mainRoot).toLowerCase();
479
+ return 5200 + parseInt(createHash("sha1").update(key).digest("hex").slice(0, 8), 16) % 800;
480
+ }
481
+ /**
482
+ * A policy's name from its file: `research.md` → `research`, and a
483
+ * `-policy` suffix (`persistent-research-policy.md`) is dropped too.
484
+ */
485
+ var policyName = (file) => path.basename(file, ".md").replace(/-policy$/i, "");
486
+ /** The repository's policies, reserved scopes included, from the policies folder. */
487
+ function listPolicies(config) {
488
+ const dir = config.policiesDir;
489
+ if (!dir) return [];
490
+ const out = [];
491
+ for (const [sub, reserved] of [[dir, false], [path.join(dir, "reserved"), true]]) {
492
+ if (!existsSync(sub)) continue;
493
+ for (const f of readdirSync(sub).sort()) {
494
+ const full = path.join(sub, f);
495
+ if (f.endsWith(".md") && statSync(full).isFile()) out.push({
496
+ name: policyName(f),
497
+ path: display(config, full),
498
+ reserved
499
+ });
500
+ }
501
+ }
502
+ return out;
503
+ }
504
+ /**
505
+ * Would git leave the board file out of commits? true = ignored, false = it
506
+ * would show as untracked, null = not a git repository (or git missing), or
507
+ * the board lives outside the main checkout, where git never sees it.
508
+ */
509
+ function boardIgnored(config) {
510
+ if (!inside(config.mainRoot, config.boardPath)) return null;
511
+ try {
512
+ execFileSync("git", [
513
+ "check-ignore",
514
+ "-q",
515
+ rel(config, config.boardPath)
516
+ ], {
517
+ cwd: config.mainRoot,
518
+ windowsHide: true,
519
+ stdio: "ignore",
520
+ env: {
521
+ ...process.env,
522
+ GIT_OPTIONAL_LOCKS: "0"
523
+ }
524
+ });
525
+ return true;
526
+ } catch (err) {
527
+ return err.status === 1 ? false : null;
528
+ }
529
+ }
530
+ //#endregion
531
+ //#region hud/server/http.ts
532
+ var MIME = {
533
+ ".html": "text/html; charset=utf-8",
534
+ ".js": "text/javascript; charset=utf-8",
535
+ ".css": "text/css; charset=utf-8",
536
+ ".json": "application/json; charset=utf-8",
537
+ ".svg": "image/svg+xml",
538
+ ".png": "image/png",
539
+ ".ico": "image/x-icon",
540
+ ".woff2": "font/woff2",
541
+ ".woff": "font/woff",
542
+ ".txt": "text/plain; charset=utf-8"
543
+ };
544
+ function send(res, status, body) {
545
+ res.statusCode = status;
546
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
547
+ res.setHeader("Cache-Control", "no-store");
548
+ res.end(JSON.stringify(body));
549
+ }
550
+ async function readJson(req) {
551
+ const chunks = [];
552
+ let size = 0;
553
+ for await (const chunk of req) {
554
+ size += chunk.length;
555
+ if (size > 1e6) throw new BoardError("request body too large", 413);
556
+ chunks.push(chunk);
557
+ }
558
+ try {
559
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
560
+ if (!value || typeof value !== "object") throw new Error("not an object");
561
+ return value;
562
+ } catch {
563
+ throw new BoardError("request body must be a JSON object");
564
+ }
565
+ }
566
+ /** Names, mtimes and sizes of the watched files: what counts as a change. */
567
+ async function snapshot(dir, accept) {
568
+ const names = (await promises.readdir(dir).catch(() => [])).filter(accept).sort();
569
+ return (await Promise.all(names.map(async (f) => {
570
+ const s = await promises.stat(path.join(dir, f)).catch(() => null);
571
+ return s ? `${f}:${s.mtimeMs}:${s.size}` : "";
572
+ }))).join("|");
573
+ }
574
+ /**
575
+ * Debounced fs.watch on one directory that fires only when a watched file's
576
+ * name, mtime or size changed. Windows also reports access-time updates, so
577
+ * merely reading an old journal would otherwise count as an edit. A missing
578
+ * directory (no journals yet) or a dropped watcher is retried, so a folder
579
+ * created later still goes live.
580
+ */
581
+ function watchDir(dir, accept, fire) {
582
+ let watcher = null;
583
+ let timer;
584
+ let last = snapshot(dir, accept);
585
+ const settle = async () => {
586
+ const [before, now] = [await last, await snapshot(dir, accept)];
587
+ last = Promise.resolve(now);
588
+ if (now !== before) fire();
589
+ };
590
+ const arm = () => {
591
+ if (watcher) return;
592
+ try {
593
+ watcher = watch(dir, (_event, file) => {
594
+ if (file && !accept(file.toString())) return;
595
+ clearTimeout(timer);
596
+ timer = setTimeout(() => void settle(), 200);
597
+ });
598
+ watcher.on("error", () => {
599
+ watcher?.close();
600
+ watcher = null;
601
+ });
602
+ } catch {
603
+ watcher = null;
604
+ }
605
+ };
606
+ arm();
607
+ const retry = setInterval(arm, 3e3);
608
+ retry.unref();
609
+ return () => {
610
+ clearInterval(retry);
611
+ clearTimeout(timer);
612
+ watcher?.close();
613
+ };
614
+ }
615
+ var LOOPBACK = (host, port) => host === `127.0.0.1:${port}` || host === `localhost:${port}` || host === `[::1]:${port}`;
616
+ async function serveStatic(req, res, clientDir) {
617
+ let pathname;
618
+ try {
619
+ pathname = decodeURIComponent(new URL(req.url ?? "/", "http://local").pathname);
620
+ } catch {
621
+ return send(res, 400, { error: "bad path" });
622
+ }
623
+ if (pathname === "/") pathname = "/index.html";
624
+ const root = path.resolve(clientDir);
625
+ const full = path.resolve(root, `.${pathname}`);
626
+ if (!full.startsWith(root + path.sep)) return send(res, 404, { error: "not found" });
627
+ try {
628
+ if (!(await promises.stat(full)).isFile()) return send(res, 404, { error: "not found" });
629
+ res.statusCode = 200;
630
+ res.setHeader("Content-Type", MIME[path.extname(full).toLowerCase()] ?? "application/octet-stream");
631
+ res.setHeader("Cache-Control", pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-store");
632
+ res.setHeader("X-Content-Type-Options", "nosniff");
633
+ if (req.method === "HEAD") return res.end();
634
+ res.end(await promises.readFile(full));
635
+ } catch {
636
+ send(res, 404, { error: "not found" });
637
+ }
638
+ }
639
+ function createHudServer(ctx) {
640
+ const clients = /* @__PURE__ */ new Set();
641
+ const push = (event) => {
642
+ for (const res of clients) res.write(`event: ${event}\ndata: {}\n\n`);
643
+ };
644
+ const stopBoard = watchDir(path.dirname(ctx.watch.boardPath), (f) => f === path.basename(ctx.watch.boardPath), async () => {
645
+ const text = await promises.readFile(ctx.watch.boardPath, "utf8").catch(() => "");
646
+ if (!ctx.board.isOwnWrite(text)) push("board");
647
+ });
648
+ const stopJournals = watchDir(ctx.watch.journalsDir, (f) => f.endsWith(".md"), () => push("journals"));
649
+ const heartbeat = setInterval(() => {
650
+ for (const res of clients) res.write(": ping\n\n");
651
+ }, 25e3);
652
+ heartbeat.unref();
653
+ async function api(req, res, url) {
654
+ const route = `${req.method} ${url.pathname.slice(4)}`;
655
+ const origin = req.headers.origin;
656
+ if (origin && (origin === "null" || new URL(origin).host !== req.headers.host)) throw new BoardError("cross-origin request refused", 403);
657
+ if (req.method === "POST" && !String(req.headers["content-type"]).startsWith("application/json")) throw new BoardError("POST bodies must be application/json", 415);
658
+ switch (route) {
659
+ case "GET /meta": return send(res, 200, ctx.meta());
660
+ case "GET /events":
661
+ res.writeHead(200, {
662
+ "Content-Type": "text/event-stream",
663
+ "Cache-Control": "no-store",
664
+ Connection: "keep-alive"
665
+ });
666
+ res.write(": connected\n\n");
667
+ clients.add(res);
668
+ req.on("close", () => clients.delete(res));
669
+ return;
670
+ case "GET /board": return send(res, 200, await ctx.board.read());
671
+ case "POST /board/move": {
672
+ const b = await readJson(req);
673
+ return send(res, 200, await ctx.board.move(String(b.id), String(b.column), Number(b.index)));
674
+ }
675
+ case "POST /board/card": {
676
+ const b = await readJson(req);
677
+ return send(res, 200, await ctx.board.upsert(b.card ?? {}));
678
+ }
679
+ case "POST /board/delete": {
680
+ const b = await readJson(req);
681
+ return send(res, 200, await ctx.board.remove(String(b.id)));
682
+ }
683
+ case "POST /board/journal": {
684
+ const b = await readJson(req);
685
+ const override = b.state === "closed" ? {
686
+ state: "closed",
687
+ note: b.note ? String(b.note) : void 0
688
+ } : null;
689
+ return send(res, 200, await ctx.board.setJournal(String(b.file), override));
690
+ }
691
+ case "POST /board/inbox": {
692
+ const b = await readJson(req);
693
+ return send(res, 200, await ctx.board.setInboxDismissed(String(b.key), b.dismissed !== false));
694
+ }
695
+ case "GET /journals": {
696
+ const overrides = (await ctx.board.read().catch(() => null))?.journals ?? {};
697
+ return send(res, 200, await ctx.journals.list(overrides));
698
+ }
699
+ case "GET /journal": {
700
+ const file = url.searchParams.get("file") ?? "";
701
+ return send(res, 200, {
702
+ file,
703
+ text: await ctx.journals.read(file)
704
+ });
705
+ }
706
+ case "GET /repo": return send(res, 200, await ctx.repo.get(url.searchParams.has("force")));
707
+ default: return send(res, 404, { error: `no route ${route}` });
708
+ }
709
+ }
710
+ const server = createServer(async (req, res) => {
711
+ const address = server.address();
712
+ const port = typeof address === "object" && address ? address.port : 0;
713
+ if (!LOOPBACK(req.headers.host, port)) return send(res, 403, { error: "this server answers loopback requests only" });
714
+ const url = new URL(req.url ?? "/", "http://local");
715
+ try {
716
+ if (url.pathname.startsWith("/api/")) return await api(req, res, url);
717
+ if ((req.method === "GET" || req.method === "HEAD") && ctx.clientDir) return await serveStatic(req, res, ctx.clientDir);
718
+ return send(res, 404, { error: "not found" });
719
+ } catch (err) {
720
+ const status = err instanceof BoardError ? err.status : 500;
721
+ if (!res.headersSent) send(res, status, { error: err.message });
722
+ else res.end();
723
+ }
724
+ });
725
+ return {
726
+ server,
727
+ close: () => new Promise((resolve) => {
728
+ stopBoard();
729
+ stopJournals();
730
+ clearInterval(heartbeat);
731
+ for (const res of clients) res.end();
732
+ clients.clear();
733
+ server.close(() => resolve());
734
+ server.closeAllConnections?.();
735
+ })
736
+ };
737
+ }
738
+ //#endregion
739
+ //#region hud/server/instance.ts
740
+ /**
741
+ * GET a small JSON document from the local HUD. Plain node:http with no
742
+ * keep-alive: a pooled fetch socket left open makes a short-lived CLI
743
+ * process linger, and on Windows exiting around one can abort Node.
744
+ */
745
+ function getJson(url, timeoutMs = 1500) {
746
+ return new Promise((resolve, reject) => {
747
+ const req = get(url, {
748
+ agent: false,
749
+ timeout: timeoutMs
750
+ }, (res) => {
751
+ let body = "";
752
+ res.setEncoding("utf8");
753
+ res.on("data", (chunk) => body += chunk);
754
+ res.on("end", () => {
755
+ try {
756
+ resolve(JSON.parse(body));
757
+ } catch (err) {
758
+ reject(err);
759
+ }
760
+ });
761
+ });
762
+ req.on("timeout", () => req.destroy(/* @__PURE__ */ new Error(`no answer from ${url}`)));
763
+ req.on("error", reject);
764
+ });
765
+ }
766
+ var instanceFile = (config) => path.join(config.dataDir, "hud.json");
767
+ function readInstance(config) {
768
+ try {
769
+ const value = JSON.parse(readFileSync(instanceFile(config), "utf8"));
770
+ return Number.isInteger(value.pid) && Number.isInteger(value.port) && typeof value.url === "string" ? value : null;
771
+ } catch {
772
+ return null;
773
+ }
774
+ }
775
+ function writeInstance(config, instance) {
776
+ mkdirSync(config.dataDir, { recursive: true });
777
+ writeFileSync(instanceFile(config), JSON.stringify(instance, null, 2) + "\n");
778
+ }
779
+ /** Remove the entry; with a pid, only if the entry is that process's (a newer HUD may have replaced it). */
780
+ function clearInstance(config, pid) {
781
+ const file = instanceFile(config);
782
+ if (!existsSync(file)) return;
783
+ if (pid !== void 0 && readInstance(config)?.pid !== pid) return;
784
+ rmSync(file, { force: true });
785
+ }
786
+ function processAlive(pid) {
787
+ try {
788
+ process.kill(pid, 0);
789
+ return true;
790
+ } catch (err) {
791
+ return err.code === "EPERM";
792
+ }
793
+ }
794
+ /** The HUD this repository has running, confirmed by asking it; null if none. */
795
+ async function liveInstance(config) {
796
+ const instance = readInstance(config);
797
+ if (!instance || !processAlive(instance.pid)) return null;
798
+ try {
799
+ return (await getJson(`${instance.url}/api/meta`)).pid === instance.pid ? instance : null;
800
+ } catch {
801
+ return null;
802
+ }
803
+ }
804
+ /** Stop the repository's running HUD. Returns what was stopped, or null; a stale entry is cleared either way. */
805
+ async function stopInstance(config) {
806
+ const instance = await liveInstance(config);
807
+ if (!instance) {
808
+ clearInstance(config);
809
+ return null;
810
+ }
811
+ process.kill(instance.pid);
812
+ for (let i = 0; i < 50 && processAlive(instance.pid); i++) await new Promise((r) => setTimeout(r, 100));
813
+ clearInstance(config, instance.pid);
814
+ return instance;
815
+ }
816
+ var DAY_MS = 864e5;
817
+ var SAFE_NAME = /^[\w.-]+\.md$/;
818
+ /** `## Next Actions (updated)` → `next actions`. */
819
+ function sectionKey(heading) {
820
+ return heading.replace(/\(.*?\)/g, "").replace(/[*_`:]/g, "").trim().toLowerCase();
821
+ }
822
+ function splitSections(text) {
823
+ const sections = /* @__PURE__ */ new Map();
824
+ const prose = [];
825
+ let title = null;
826
+ let current = null;
827
+ let inFence = false;
828
+ for (const line of text.split(/\r?\n/)) {
829
+ if (/^\s*```/.test(line)) inFence = !inFence;
830
+ if (!inFence) {
831
+ prose.push(line);
832
+ const h1 = /^#\s+(.+)/.exec(line);
833
+ if (h1 && title === null) {
834
+ title = h1[1].trim();
835
+ continue;
836
+ }
837
+ const h2 = /^##\s+(.+)/.exec(line);
838
+ if (h2) {
839
+ current = [];
840
+ sections.set(sectionKey(h2[1]), current);
841
+ continue;
842
+ }
843
+ }
844
+ current?.push(line);
845
+ }
846
+ return {
847
+ title,
848
+ sections,
849
+ prose
850
+ };
851
+ }
852
+ /** Markdown → one plain line for summaries. */
853
+ function collapse(lines, max) {
854
+ if (!lines) return "";
855
+ const text = lines.join("\n").replace(/```[\s\S]*?```/g, " ").replace(/^>\s?/gm, "").replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, "· ").replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/(\*\*|__|~~|`)/g, "").replace(/\s+/g, " ").replace(/^· /, "").trim();
856
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
857
+ }
858
+ var LIST_ITEM = /^( ?)(?:[-*+]|\d+[.)])\s+(.*)$/;
859
+ function topLevelItems(lines) {
860
+ if (!lines) return [];
861
+ const items = [];
862
+ for (const line of lines) {
863
+ const m = LIST_ITEM.exec(line);
864
+ if (!m) continue;
865
+ const body = m[2].trim();
866
+ if (/^\[[xX]\]/.test(body) || /^~~/.test(body)) continue;
867
+ items.push(body.replace(/^\[ \]\s*/, ""));
868
+ }
869
+ return items;
870
+ }
871
+ var EMPTYISH = /^[\s(_*~—–-]*(pending|tbd|todo|none yet|not yet|n\/a|to be written|tbd\.)?[\s)_*.~—–-]*$/i;
872
+ /** A Next Actions list that only says the work is over ("None.", "Closed."). */
873
+ var NOTHING_NEXT = /^[*_~\s]*(none|closed|done|nothing|n\/a|complete|completed|shipped|merged|fixed)\b/i;
874
+ var COMPLETE = /^\s*[*_]*status[*_]*\s*:\s*[*_]*\s*(complete|completed|done|closed|shipped|merged|fixed)\b/im;
875
+ var PR_REF = /(?<![\w&/])#(\d{2,4})\b/g;
876
+ /** `Policies: research, implementation` / `**Policies:** research` / `- Policies: …` */
877
+ var POLICIES_LINE = /^\s*(?:[-*]\s+)?[*_]{0,2}policies[*_]{0,2}\s*:[*_]{0,2}\s*(.+)$/i;
878
+ var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
879
+ /** Names on the first `Policies:` line, normalized (`policies/research.md` → `research`). */
880
+ function declaredPolicies(prose) {
881
+ for (const line of prose) {
882
+ const m = POLICIES_LINE.exec(line);
883
+ if (!m) continue;
884
+ const names = m[1].replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").split(/[\s,;·|]+/).map((t) => t.replace(/[`*_[\]()]/g, "").replace(/^(?:\.\.\/)*(?:lore\/)?(?:policies\/)?(?:reserved\/)?/i, "").replace(/\.md$/i, "").replace(/-policy$/i, "").toLowerCase()).filter((t) => /^[a-z0-9][a-z0-9-]*$/.test(t) && t !== "policy" && t !== "and");
885
+ return [...new Set(names)];
886
+ }
887
+ return [];
888
+ }
889
+ /**
890
+ * Known policies the text refers to — by file (`research.md`,
891
+ * `persistent-research-policy.md`) or by name ("research policy") — minus
892
+ * the declared ones.
893
+ */
894
+ function mentionedPolicies(text, policies, declared) {
895
+ const out = [];
896
+ for (const p of policies) {
897
+ const { name, stem } = typeof p === "string" ? {
898
+ name: p,
899
+ stem: void 0
900
+ } : p;
901
+ if (declared.includes(name) || out.includes(name)) continue;
902
+ const files = [.../* @__PURE__ */ new Set([name, stem ?? name])].map(escapeRe).join("|");
903
+ const e = escapeRe(name);
904
+ if (new RegExp(`(?:^|[^\\w-])(?:${files})\\.md\\b|(?:^|[^\\w-])${e}[ -]polic(?:y|ies)\\b`, "i").test(text)) out.push(name);
905
+ }
906
+ return out;
907
+ }
908
+ function parseJournal(text, fallbackTitle, policyNames = []) {
909
+ const { title, sections, prose } = splitSections(text);
910
+ const conclusionText = collapse(sections.get("final conclusions") ?? sections.get("final conclusion"), 360);
911
+ const prRefs = [...new Set([...text.matchAll(PR_REF)].map((m) => Number(m[1])))].sort((a, b) => b - a);
912
+ const nextLines = sections.get("next actions");
913
+ const next = topLevelItems(nextLines);
914
+ const nothingNext = next.length ? next.every((item) => NOTHING_NEXT.test(item)) : NOTHING_NEXT.test(collapse(nextLines, 80));
915
+ const declared = declaredPolicies(prose);
916
+ return {
917
+ title: title ?? fallbackTitle,
918
+ objective: collapse(sections.get("objective") ?? sections.get("current objective") ?? sections.get("original request"), 320),
919
+ conclusion: EMPTYISH.test(conclusionText) ? null : conclusionText,
920
+ completeMarker: COMPLETE.test(text) || nothingNext,
921
+ nextActions: next.slice(0, 8).map((s) => s.length > 220 ? `${s.slice(0, 219)}…` : s),
922
+ openQuestions: topLevelItems(sections.get("remaining questions")).filter((q) => !/✅|resolved|answered/i.test(q)).length,
923
+ prRefs,
924
+ policies: {
925
+ declared,
926
+ mentioned: mentionedPolicies(text, policyNames, declared)
927
+ }
928
+ };
929
+ }
930
+ /**
931
+ * Was the journal written after it was closed? A close never touches the file,
932
+ * so a later mtime means someone picked the work back up. A bare YYYY-MM-DD
933
+ * close (hand-written) compares at day precision: only a later day counts.
934
+ */
935
+ function writtenAfter(mtime, closedAt) {
936
+ if (/^\d{4}-\d{2}-\d{2}$/.test(closedAt)) return mtime.toISOString().slice(0, 10) > closedAt;
937
+ const at = Date.parse(closedAt);
938
+ return Number.isFinite(at) && mtime.getTime() > at;
939
+ }
940
+ function createJournalIndex(journalsDir, policyNames = () => []) {
941
+ const cache = /* @__PURE__ */ new Map();
942
+ function resolve(file) {
943
+ if (!SAFE_NAME.test(file)) throw new Error(`not a journal name: ${file}`);
944
+ const full = path.join(journalsDir, file);
945
+ if (path.dirname(full) !== path.resolve(journalsDir)) throw new Error(`outside the journals folder: ${file}`);
946
+ return full;
947
+ }
948
+ async function list(overrides = {}) {
949
+ let names;
950
+ try {
951
+ names = (await promises.readdir(journalsDir, { withFileTypes: true })).filter((d) => d.isFile() && d.name.endsWith(".md")).map((d) => d.name);
952
+ } catch (err) {
953
+ if (err.code === "ENOENT") return [];
954
+ throw err;
955
+ }
956
+ const known = policyNames();
957
+ const knownKey = JSON.stringify(known);
958
+ const now = Date.now();
959
+ const out = await Promise.all(names.map(async (file) => {
960
+ const full = path.join(journalsDir, file);
961
+ const stat = await promises.stat(full);
962
+ const key = `${stat.mtimeMs}:${stat.size}:${knownKey}`;
963
+ let hit = cache.get(file);
964
+ if (!hit || hit.key !== key) {
965
+ const slug = file.replace(/\.md$/, "").replace(/^\d{4}-\d{2}-\d{2}-/, "").replace(/[-_]/g, " ");
966
+ hit = {
967
+ key,
968
+ parsed: parseJournal(await promises.readFile(full, "utf8"), slug, known)
969
+ };
970
+ cache.set(file, hit);
971
+ }
972
+ const p = hit.parsed;
973
+ const ageDays = Math.floor((now - stat.mtimeMs) / DAY_MS);
974
+ const override = overrides[file];
975
+ const closed = override?.state === "closed";
976
+ const editedAfterClose = closed && writtenAfter(stat.mtime, override.at);
977
+ let status;
978
+ if (closed && !editedAfterClose) status = "closed";
979
+ else if (p.conclusion !== null || p.completeMarker) status = "concluded";
980
+ else status = ageDays > 7 ? "stale" : "open";
981
+ return {
982
+ file,
983
+ date: /^\d{4}-\d{2}-\d{2}/.exec(file)?.[0] ?? stat.mtime.toISOString().slice(0, 10),
984
+ title: p.title,
985
+ objective: p.objective,
986
+ status,
987
+ conclusion: p.conclusion,
988
+ nextActions: p.nextActions,
989
+ openQuestions: p.openQuestions,
990
+ modified: stat.mtime.toISOString(),
991
+ ageDays,
992
+ prRefs: p.prRefs,
993
+ policies: p.policies,
994
+ override,
995
+ editedAfterClose
996
+ };
997
+ }));
998
+ for (const name of cache.keys()) if (!names.includes(name)) cache.delete(name);
999
+ return out.sort((a, b) => b.modified.localeCompare(a.modified));
1000
+ }
1001
+ async function read(file) {
1002
+ return promises.readFile(resolve(file), "utf8");
1003
+ }
1004
+ return {
1005
+ list,
1006
+ read
1007
+ };
1008
+ }
1009
+ //#endregion
1010
+ //#region hud/server/repo.ts
1011
+ var GH_TTL_MS = 6e4;
1012
+ var READ_ONLY_GIT = {
1013
+ ...process.env,
1014
+ GIT_OPTIONAL_LOCKS: "0"
1015
+ };
1016
+ function run(cmd, args, cwd) {
1017
+ return new Promise((resolve, reject) => {
1018
+ execFile(cmd, args, {
1019
+ cwd,
1020
+ timeout: 3e4,
1021
+ maxBuffer: 16777216,
1022
+ windowsHide: true,
1023
+ env: READ_ONLY_GIT
1024
+ }, (err, stdout, stderr) => {
1025
+ if (err?.code === "ENOENT") reject(/* @__PURE__ */ new Error(`${cmd} is not installed`));
1026
+ else if (err) reject(/* @__PURE__ */ new Error(`${cmd} ${args[0]} ${args[1] ?? ""}: ${(stderr || err.message).trim()}`));
1027
+ else resolve(stdout);
1028
+ });
1029
+ });
1030
+ }
1031
+ /** gh's failure, in the words the owner needs. */
1032
+ function explainGhError(message) {
1033
+ if (/gh is not installed/.test(message)) return "the gh CLI is not installed";
1034
+ if (/not a git repository/i.test(message)) return "this folder is not a git repository";
1035
+ if (/no git remotes|none of the git remotes|could not determine (base )?repo/i.test(message)) return "this repository has no GitHub remote";
1036
+ if (/gh auth login|not logged in|authentication|HTTP 401/i.test(message)) return "gh is not logged in (run `gh auth login`)";
1037
+ return message;
1038
+ }
1039
+ function createRepoState(repoRoot) {
1040
+ let cached = null;
1041
+ let cachedAt = 0;
1042
+ let inflight = null;
1043
+ async function fetchGitHub() {
1044
+ const [prs, issues, release] = await Promise.all([
1045
+ run("gh", [
1046
+ "pr",
1047
+ "list",
1048
+ "--state",
1049
+ "all",
1050
+ "--limit",
1051
+ "400",
1052
+ "--json",
1053
+ "number,title,state,url,createdAt,updatedAt,mergedAt,closedAt,headRefName,isDraft"
1054
+ ], repoRoot),
1055
+ run("gh", [
1056
+ "issue",
1057
+ "list",
1058
+ "--state",
1059
+ "all",
1060
+ "--limit",
1061
+ "400",
1062
+ "--json",
1063
+ "number,title,state,url,createdAt,updatedAt,closedAt,labels"
1064
+ ], repoRoot),
1065
+ run("gh", [
1066
+ "release",
1067
+ "view",
1068
+ "--json",
1069
+ "tagName,publishedAt,url"
1070
+ ], repoRoot).catch(() => "null")
1071
+ ]);
1072
+ const rel = JSON.parse(release);
1073
+ return {
1074
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
1075
+ prs: JSON.parse(prs),
1076
+ issues: JSON.parse(issues).map((i) => ({
1077
+ ...i,
1078
+ labels: (i.labels ?? []).map((l) => l.name)
1079
+ })),
1080
+ release: rel ? {
1081
+ tag: rel.tagName,
1082
+ publishedAt: rel.publishedAt,
1083
+ url: rel.url
1084
+ } : null
1085
+ };
1086
+ }
1087
+ async function github(force) {
1088
+ if (!force && cached && Date.now() - cachedAt < GH_TTL_MS) return cached;
1089
+ inflight ??= fetchGitHub().then((fresh) => {
1090
+ cached = fresh;
1091
+ cachedAt = Date.now();
1092
+ return fresh;
1093
+ }).catch((err) => ({
1094
+ ...cached ?? {
1095
+ prs: [],
1096
+ issues: [],
1097
+ release: null
1098
+ },
1099
+ fetchedAt: cached?.fetchedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1100
+ error: explainGhError(err.message),
1101
+ lastGood: cached !== null
1102
+ })).finally(() => {
1103
+ inflight = null;
1104
+ });
1105
+ return inflight;
1106
+ }
1107
+ async function git() {
1108
+ try {
1109
+ const [branch, log, status] = await Promise.all([
1110
+ run("git", [
1111
+ "rev-parse",
1112
+ "--abbrev-ref",
1113
+ "HEAD"
1114
+ ], repoRoot),
1115
+ run("git", [
1116
+ "log",
1117
+ "-1",
1118
+ "--format=%h%x09%s"
1119
+ ], repoRoot),
1120
+ run("git", ["status", "--porcelain"], repoRoot)
1121
+ ]);
1122
+ const [head, ...subject] = log.trim().split(" ");
1123
+ return {
1124
+ branch: branch.trim(),
1125
+ head,
1126
+ subject: subject.join(" "),
1127
+ dirty: status.split("\n").filter(Boolean).length
1128
+ };
1129
+ } catch {
1130
+ return null;
1131
+ }
1132
+ }
1133
+ return { async get(force = false) {
1134
+ const [gh, g] = await Promise.all([github(force), git()]);
1135
+ return {
1136
+ ...gh,
1137
+ git: g
1138
+ };
1139
+ } };
1140
+ }
1141
+ //#endregion
1142
+ //#region hud/server/main.ts
1143
+ var PORT_TRIES = 10;
1144
+ /** The repository's running HUD, if any (confirmed by asking it). */
1145
+ async function findHud(options) {
1146
+ const config = resolveConfig(options.repo, options.data);
1147
+ return {
1148
+ config,
1149
+ live: await liveInstance(config)
1150
+ };
1151
+ }
1152
+ /** Stop the repository's running HUD; null when none was running. */
1153
+ async function stopHud(options) {
1154
+ return stopInstance(resolveConfig(options.repo, options.data));
1155
+ }
1156
+ function defaultClientDir() {
1157
+ const here = path.dirname(fileURLToPath(import.meta.url));
1158
+ for (const candidate of [path.join(here, "..", "client"), path.join(here, "..", "dist", "client")]) if (existsSync(path.join(candidate, "index.html"))) return candidate;
1159
+ return null;
1160
+ }
1161
+ function services(config, version) {
1162
+ const board = createBoardStore(config.boardPath);
1163
+ const policyNames = () => listPolicies(config).map((p) => ({
1164
+ name: p.name,
1165
+ stem: path.basename(p.path, ".md")
1166
+ }));
1167
+ const journals = createJournalIndex(config.journalsDir, policyNames);
1168
+ const meta = () => ({
1169
+ repo: config.repoName,
1170
+ journals: display(config, config.journalsDir),
1171
+ boardFile: display(config, config.boardPath),
1172
+ policies: listPolicies(config),
1173
+ boardIgnored: boardIgnored(config),
1174
+ version,
1175
+ pid: process.pid
1176
+ });
1177
+ return {
1178
+ board,
1179
+ journals,
1180
+ meta
1181
+ };
1182
+ }
1183
+ async function startHud(options) {
1184
+ const config = resolveConfig(options.repo, options.data);
1185
+ const { board, journals, meta } = services(config, options.version ?? "dev");
1186
+ const hud = createHudServer({
1187
+ board,
1188
+ journals,
1189
+ repo: createRepoState(config.repoRoot),
1190
+ meta,
1191
+ clientDir: options.clientDir === void 0 ? defaultClientDir() : options.clientDir,
1192
+ watch: {
1193
+ boardPath: config.boardPath,
1194
+ journalsDir: config.journalsDir
1195
+ }
1196
+ });
1197
+ const first = options.port ?? config.port ?? defaultPortFor(config.mainRoot);
1198
+ const tries = options.port === void 0 ? PORT_TRIES : 1;
1199
+ let port = first;
1200
+ for (let i = 0;; i++) try {
1201
+ await new Promise((resolve, reject) => {
1202
+ hud.server.once("error", reject);
1203
+ hud.server.listen(port, "127.0.0.1", () => {
1204
+ hud.server.off("error", reject);
1205
+ resolve();
1206
+ });
1207
+ });
1208
+ break;
1209
+ } catch (err) {
1210
+ if (err.code !== "EADDRINUSE" || i + 1 >= tries) throw err;
1211
+ port += 1;
1212
+ }
1213
+ const address = hud.server.address();
1214
+ const actual = typeof address === "object" && address ? address.port : port;
1215
+ const url = `http://127.0.0.1:${actual}`;
1216
+ const version = options.version ?? "dev";
1217
+ if (options.register) writeInstance(config, {
1218
+ pid: process.pid,
1219
+ port: actual,
1220
+ url,
1221
+ version,
1222
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1223
+ repo: config.repoName
1224
+ });
1225
+ return {
1226
+ url,
1227
+ config,
1228
+ meta: meta(),
1229
+ close: async () => {
1230
+ if (options.register) clearInstance(config, process.pid);
1231
+ await hud.close();
1232
+ }
1233
+ };
1234
+ }
1235
+ var MAX_TITLES = 6;
1236
+ var clip = (s, n = 52) => s.length > n ? `${s.slice(0, n - 1)}…` : s;
1237
+ /**
1238
+ * The board in a few lines, for an agent's session start. Offline and
1239
+ * read-only: it never creates the board and never calls GitHub.
1240
+ */
1241
+ async function brief(options) {
1242
+ const config = resolveConfig(options.repo, options.data);
1243
+ const { board: store, journals, meta } = services(config, "brief");
1244
+ const m = meta();
1245
+ const out = [`=== LORE board · ${m.repo} (${m.boardFile} · \`lore hud\` to open it) ===`];
1246
+ let board = null;
1247
+ try {
1248
+ board = await store.peek();
1249
+ if (!board) out.push(`No board yet. Ask the owner whether to bootstrap one, or run \`lore hud\` to start an empty board.`);
1250
+ else if (board.cards.length === 0) out.push(`The board is empty: bootstrap it with the owner (read the repository, propose areas and cards, write what they approve).`);
1251
+ else {
1252
+ const quiet = /* @__PURE__ */ new Set([columnWithRole(board, "intake")?.id, columnWithRole(board, "done")?.id]);
1253
+ for (const col of board.columns) {
1254
+ const cards = board.cards.filter((c) => c.column === col.id);
1255
+ if (quiet.has(col.id) || !cards.length) continue;
1256
+ const titles = cards.slice(0, MAX_TITLES).map((c) => clip(c.title));
1257
+ const more = cards.length > MAX_TITLES ? ` · +${cards.length - MAX_TITLES}` : "";
1258
+ out.push(`${col.title} (${cards.length}): ${titles.join(" · ")}${more}`);
1259
+ }
1260
+ const intake = columnWithRole(board, "intake");
1261
+ const done = columnWithRole(board, "done");
1262
+ const count = (id) => board.cards.filter((c) => c.column === id).length;
1263
+ const top = done && board.cards.find((c) => c.column === done.id);
1264
+ const tail = [intake ? `${intake.title} ${count(intake.id)}` : null, done ? `${done.title} ${count(done.id)}${top ? ` (top: ${clip(top.title, 44)})` : ""}` : null].filter(Boolean);
1265
+ if (tail.length) out.push(tail.join(" · "));
1266
+ }
1267
+ } catch (err) {
1268
+ out.push(`${m.boardFile} unreadable: ${err.message} — fix it before relying on the board.`);
1269
+ }
1270
+ try {
1271
+ const list = await journals.list(board?.journals ?? {});
1272
+ const by = (s) => list.filter((j) => j.status === s);
1273
+ const open = by("open");
1274
+ if (list.length) out.push(`Journals (${m.journals}): ${open.length} open · ${by("stale").length} stale · ${by("concluded").length} concluded` + (open.length ? ` — newest open: ${open.slice(0, 3).map((j) => clip(j.file.replace(/\.md$/, ""), 40)).join(", ")}` : ""));
1275
+ if (board && board.cards.length) {
1276
+ const untracked = inboxItems(board, list, [], []);
1277
+ if (untracked.length) out.push(`Inbox — active journals no card links (add or link a card if this is your work): ` + untracked.slice(0, 4).map((i) => clip((i.file ?? i.label).replace(/\.md$/, ""), 44)).join(", ") + (untracked.length > 4 ? ` · +${untracked.length - 4}` : ""));
1278
+ }
1279
+ } catch {}
1280
+ const live = await liveInstance(config);
1281
+ out.push(live ? `HUD: ${live.url} (running)` : "HUD: not running — `lore hud` starts it and opens the browser.");
1282
+ const policy = m.policies.find((p) => p.name === "board");
1283
+ out.push(`Keep the board current: when work moves — it starts, a PR opens or merges, a ruling is owed — update ${m.boardFile} in the same session` + (policy ? ` (${policy.path}).` : "."));
1284
+ return out.join("\n");
1285
+ }
1286
+ //#endregion
1287
+ export { brief, findHud, getJson, resolveConfig, startHud, stopHud };