@okf-harness/core 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.
package/dist/index.js ADDED
@@ -0,0 +1,2717 @@
1
+ // src/config/index.ts
2
+ import { readFile } from "fs/promises";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { z } from "zod";
5
+
6
+ // src/paths/index.ts
7
+ import { realpath } from "fs/promises";
8
+ import path from "path";
9
+ var PATH_OUTSIDE_WORKSPACE = "PATH_OUTSIDE_WORKSPACE";
10
+ var WorkspacePathError = class extends Error {
11
+ constructor(message, workspaceRoot, input) {
12
+ super(message);
13
+ this.workspaceRoot = workspaceRoot;
14
+ this.input = input;
15
+ this.name = "WorkspacePathError";
16
+ }
17
+ workspaceRoot;
18
+ input;
19
+ code = PATH_OUTSIDE_WORKSPACE;
20
+ };
21
+ function toPosixPath(input) {
22
+ return input.replace(/\\/g, "/");
23
+ }
24
+ function toPosixRelativePath(from, to) {
25
+ return toPosixPath(path.relative(from, to));
26
+ }
27
+ async function safeResolveWorkspacePath(workspaceRoot, input) {
28
+ if (input.trim().length === 0) {
29
+ throw new WorkspacePathError("Workspace path input must not be empty.", workspaceRoot, input);
30
+ }
31
+ const resolvedWorkspaceRoot = await realpathOrResolve(workspaceRoot);
32
+ const candidate = path.isAbsolute(input) ? path.resolve(input) : path.resolve(resolvedWorkspaceRoot, input);
33
+ const resolvedCandidate = await realpathExistingPrefix(candidate);
34
+ if (!isPathInside(resolvedWorkspaceRoot, resolvedCandidate)) {
35
+ throw new WorkspacePathError(
36
+ `Path resolves outside workspace: ${input}`,
37
+ resolvedWorkspaceRoot,
38
+ input
39
+ );
40
+ }
41
+ return {
42
+ workspaceRoot: resolvedWorkspaceRoot,
43
+ absolutePath: resolvedCandidate,
44
+ relativePath: toPosixRelativePath(resolvedWorkspaceRoot, resolvedCandidate)
45
+ };
46
+ }
47
+ function isPathInside(root, candidate) {
48
+ const relative = path.relative(root, candidate);
49
+ return relative.length === 0 || !relative.startsWith("..") && !path.isAbsolute(relative);
50
+ }
51
+ async function realpathOrResolve(input) {
52
+ try {
53
+ return await realpath(input);
54
+ } catch (error) {
55
+ if (errorCode(error) === "ENOENT") {
56
+ return path.resolve(input);
57
+ }
58
+ throw error;
59
+ }
60
+ }
61
+ async function realpathExistingPrefix(candidate) {
62
+ const missingSegments = [];
63
+ let current = candidate;
64
+ while (true) {
65
+ try {
66
+ const existing = await realpath(current);
67
+ return path.join(existing, ...missingSegments.reverse());
68
+ } catch (error) {
69
+ if (errorCode(error) !== "ENOENT") {
70
+ throw error;
71
+ }
72
+ const parent = path.dirname(current);
73
+ if (parent === current) {
74
+ return path.resolve(candidate);
75
+ }
76
+ missingSegments.push(path.basename(current));
77
+ current = parent;
78
+ }
79
+ }
80
+ }
81
+ function errorCode(error) {
82
+ if (typeof error !== "object" || error === null || !("code" in error)) {
83
+ return void 0;
84
+ }
85
+ const code = error.code;
86
+ return typeof code === "string" ? code : void 0;
87
+ }
88
+
89
+ // src/config/index.ts
90
+ var CONFIG_INVALID = "CONFIG_INVALID";
91
+ var configRelativePathSchema = z.string().min(1).refine((value) => isSafeConfigRelativePath(value), {
92
+ message: "Path must be a non-empty workspace-relative POSIX path without traversal."
93
+ });
94
+ var workspaceConfigSchema = z.object({
95
+ version: z.union([z.literal(0.1), z.literal("0.1")]).transform(() => "0.1"),
96
+ workspace: z.object({
97
+ name: z.string().min(1),
98
+ created_at: z.string().min(1),
99
+ platform: z.literal("macos")
100
+ }).strict(),
101
+ okf: z.object({
102
+ bundle_root: configRelativePathSchema,
103
+ profile: z.string().min(1)
104
+ }).strict(),
105
+ agents: z.object({
106
+ tier1: z.object({
107
+ claude: z.boolean(),
108
+ codex: z.boolean()
109
+ }).strict(),
110
+ tier2: z.object({
111
+ pi: z.boolean(),
112
+ opencode: z.boolean()
113
+ }).strict()
114
+ }).strict(),
115
+ paths: z.object({
116
+ raw_inbox: configRelativePathSchema,
117
+ raw_sources: configRelativePathSchema,
118
+ wiki_root: configRelativePathSchema,
119
+ manifest: configRelativePathSchema
120
+ }).strict(),
121
+ safety: z.object({
122
+ raw_sources_immutable: z.boolean(),
123
+ require_git_checkpoint_before_agent_write: z.boolean(),
124
+ max_files_changed_per_ingest: z.number().int().positive()
125
+ }).strict()
126
+ }).strict().refine((config) => config.okf.bundle_root === config.paths.wiki_root, {
127
+ path: ["paths", "wiki_root"],
128
+ message: "paths.wiki_root must match okf.bundle_root."
129
+ });
130
+ var WorkspaceConfigError = class extends Error {
131
+ constructor(issues) {
132
+ super(issues.map((issue) => issue.message).join("; "));
133
+ this.issues = issues;
134
+ this.name = "WorkspaceConfigError";
135
+ }
136
+ issues;
137
+ code = CONFIG_INVALID;
138
+ };
139
+ function parseWorkspaceConfig(source) {
140
+ let rawConfig;
141
+ try {
142
+ rawConfig = parseYaml(source);
143
+ } catch (error) {
144
+ return {
145
+ ok: false,
146
+ issues: [
147
+ {
148
+ code: CONFIG_INVALID,
149
+ path: "<yaml>",
150
+ message: error instanceof Error ? error.message : "Invalid YAML."
151
+ }
152
+ ]
153
+ };
154
+ }
155
+ const parsed = workspaceConfigSchema.safeParse(rawConfig);
156
+ if (!parsed.success) {
157
+ return {
158
+ ok: false,
159
+ issues: parsed.error.issues.map((issue) => ({
160
+ code: CONFIG_INVALID,
161
+ path: issue.path.length > 0 ? issue.path.join(".") : "<root>",
162
+ message: issue.message
163
+ }))
164
+ };
165
+ }
166
+ return { ok: true, config: parsed.data };
167
+ }
168
+ async function readWorkspaceConfig(workspaceRoot) {
169
+ let configPath;
170
+ try {
171
+ configPath = (await safeResolveWorkspacePath(workspaceRoot, "okfh.config.yaml")).absolutePath;
172
+ } catch (error) {
173
+ return {
174
+ ok: false,
175
+ issues: [
176
+ {
177
+ code: CONFIG_INVALID,
178
+ path: "okfh.config.yaml",
179
+ message: error instanceof Error ? error.message : "Could not resolve workspace config path."
180
+ }
181
+ ]
182
+ };
183
+ }
184
+ try {
185
+ return parseWorkspaceConfig(await readFile(configPath, "utf8"));
186
+ } catch (error) {
187
+ return {
188
+ ok: false,
189
+ issues: [
190
+ {
191
+ code: CONFIG_INVALID,
192
+ path: "okfh.config.yaml",
193
+ message: error instanceof Error ? error.message : "Could not read workspace config."
194
+ }
195
+ ]
196
+ };
197
+ }
198
+ }
199
+ async function loadWorkspaceConfig(workspaceRoot) {
200
+ const result = await readWorkspaceConfig(workspaceRoot);
201
+ if (!result.ok) {
202
+ throw new WorkspaceConfigError(result.issues);
203
+ }
204
+ return result.config;
205
+ }
206
+ function isSafeConfigRelativePath(value) {
207
+ if (value.startsWith("/") || value.includes("\\")) {
208
+ return false;
209
+ }
210
+ const segments = value.split("/");
211
+ return segments.every((segment) => segment.length > 0 && segment !== "..");
212
+ }
213
+
214
+ // src/graph/index.ts
215
+ import { mkdir, writeFile } from "fs/promises";
216
+ import path4 from "path";
217
+
218
+ // src/okf/concepts.ts
219
+ import { readdir, readFile as readFile2 } from "fs/promises";
220
+ import path2 from "path";
221
+
222
+ // src/okf/frontmatter.ts
223
+ import matter from "gray-matter";
224
+ function parseMarkdownFrontmatter(markdown) {
225
+ if (!markdown.startsWith("---\n") && !markdown.startsWith("---\r\n")) {
226
+ return {
227
+ ok: false,
228
+ hasFrontmatter: false,
229
+ error: "missing",
230
+ message: "Markdown file is missing YAML frontmatter."
231
+ };
232
+ }
233
+ try {
234
+ const parsed = matter(markdown);
235
+ return {
236
+ ok: true,
237
+ hasFrontmatter: true,
238
+ data: parsed.data,
239
+ body: parsed.content,
240
+ raw: parsed.matter
241
+ };
242
+ } catch (error) {
243
+ return {
244
+ ok: false,
245
+ hasFrontmatter: true,
246
+ error: "invalid",
247
+ message: error instanceof Error ? error.message : "Invalid YAML frontmatter."
248
+ };
249
+ }
250
+ }
251
+
252
+ // src/okf/concepts.ts
253
+ var RESERVED_OKF_FILENAMES = /* @__PURE__ */ new Set(["index.md", "log.md"]);
254
+ var SCAN_FAILED = "SCAN_FAILED";
255
+ var ConceptScanError = class extends Error {
256
+ constructor(message, details = {}) {
257
+ super(message);
258
+ this.details = details;
259
+ this.name = "ConceptScanError";
260
+ }
261
+ details;
262
+ code = SCAN_FAILED;
263
+ };
264
+ async function scanConcepts(workspaceRoot, config) {
265
+ let wikiRoot;
266
+ let markdownFiles;
267
+ try {
268
+ wikiRoot = await safeResolveWorkspacePath(workspaceRoot, config.okf.bundle_root);
269
+ const files = await scanMarkdownFiles(wikiRoot.absolutePath);
270
+ markdownFiles = await Promise.all(
271
+ files.map(async (absolutePath) => {
272
+ const bundlePath = toPosixRelativePath(wikiRoot.absolutePath, absolutePath);
273
+ const markdown = await readFile2(absolutePath, "utf8");
274
+ return {
275
+ absolutePath,
276
+ workspacePath: toPosixRelativePath(wikiRoot.workspaceRoot, absolutePath),
277
+ bundlePath,
278
+ conceptId: conceptIdFromPath(bundlePath),
279
+ isReserved: isReservedOkfFile(bundlePath),
280
+ markdown,
281
+ frontmatter: parseMarkdownFrontmatter(markdown)
282
+ };
283
+ })
284
+ );
285
+ } catch (error) {
286
+ throw new ConceptScanError(
287
+ error instanceof Error ? error.message : "Could not scan OKF wiki.",
288
+ {
289
+ wikiRoot: config.okf.bundle_root
290
+ }
291
+ );
292
+ }
293
+ const concepts = markdownFiles.flatMap((file) => {
294
+ if (file.isReserved || !file.frontmatter.ok) {
295
+ return [];
296
+ }
297
+ const conceptType = stringValue(file.frontmatter.data.type);
298
+ if (conceptType === void 0) {
299
+ return [];
300
+ }
301
+ const concept = {
302
+ id: file.conceptId,
303
+ absolutePath: file.absolutePath,
304
+ workspacePath: file.workspacePath,
305
+ bundlePath: file.bundlePath,
306
+ type: conceptType,
307
+ tags: stringArrayValue(file.frontmatter.data.tags),
308
+ frontmatter: file.frontmatter.data,
309
+ body: file.frontmatter.body
310
+ };
311
+ const title = stringValue(file.frontmatter.data.title);
312
+ if (title !== void 0) {
313
+ concept.title = title;
314
+ }
315
+ const description = stringValue(file.frontmatter.data.description);
316
+ if (description !== void 0) {
317
+ concept.description = description;
318
+ }
319
+ const timestamp = stringValue(file.frontmatter.data.timestamp);
320
+ if (timestamp !== void 0) {
321
+ concept.timestamp = timestamp;
322
+ }
323
+ return [concept];
324
+ });
325
+ return {
326
+ workspaceRoot: wikiRoot.workspaceRoot,
327
+ wikiRoot: wikiRoot.absolutePath,
328
+ files: markdownFiles.sort((left, right) => left.bundlePath.localeCompare(right.bundlePath)),
329
+ concepts: concepts.sort((left, right) => left.id.localeCompare(right.id))
330
+ };
331
+ }
332
+ function conceptIdFromPath(markdownPath) {
333
+ const normalized = toPosixPath(markdownPath);
334
+ if (!normalized.endsWith(".md")) {
335
+ throw new Error(`Concept path must end with .md: ${markdownPath}`);
336
+ }
337
+ const withoutWikiPrefix = normalized.startsWith("wiki/") ? normalized.slice("wiki/".length) : normalized;
338
+ return withoutWikiPrefix.slice(0, -".md".length);
339
+ }
340
+ function isReservedOkfFile(bundlePath) {
341
+ return RESERVED_OKF_FILENAMES.has(path2.posix.basename(toPosixPath(bundlePath)));
342
+ }
343
+ async function scanMarkdownFiles(root) {
344
+ const entries = await readdir(root, { withFileTypes: true });
345
+ const nested = await Promise.all(
346
+ entries.map(async (entry) => {
347
+ const absolutePath = path2.join(root, entry.name);
348
+ if (entry.isDirectory()) {
349
+ return scanMarkdownFiles(absolutePath);
350
+ }
351
+ if (entry.isFile() && entry.name.endsWith(".md")) {
352
+ return [absolutePath];
353
+ }
354
+ return [];
355
+ })
356
+ );
357
+ return nested.flat().sort((left, right) => left.localeCompare(right));
358
+ }
359
+ function stringValue(value) {
360
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
361
+ }
362
+ function stringArrayValue(value) {
363
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
364
+ }
365
+
366
+ // src/okf/links.ts
367
+ import path3 from "path";
368
+ function parseMarkdownLinks(markdown) {
369
+ const links = [];
370
+ const lines = markdown.split(/\r?\n/);
371
+ lines.forEach((line, index) => {
372
+ for (const match of line.matchAll(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g)) {
373
+ const raw = match[0];
374
+ const text = match[1];
375
+ const target = match[2];
376
+ if (raw === void 0 || text === void 0 || target === void 0) {
377
+ continue;
378
+ }
379
+ const link = {
380
+ text,
381
+ target,
382
+ raw,
383
+ line: index + 1
384
+ };
385
+ const title = match[3];
386
+ if (title !== void 0) {
387
+ link.title = title;
388
+ }
389
+ links.push(link);
390
+ }
391
+ });
392
+ return links;
393
+ }
394
+ function resolveOkfLinkTarget(target, fromBundlePath) {
395
+ if (target.startsWith("http://") || target.startsWith("https://") || target.startsWith("#")) {
396
+ return void 0;
397
+ }
398
+ const withoutFragment = target.split("#", 1)[0] ?? "";
399
+ if (withoutFragment.length === 0 || !withoutFragment.endsWith(".md")) {
400
+ return void 0;
401
+ }
402
+ if (withoutFragment.startsWith("/")) {
403
+ return conceptIdFromPath(withoutFragment.slice(1));
404
+ }
405
+ if (withoutFragment.startsWith("wiki/")) {
406
+ return conceptIdFromPath(withoutFragment);
407
+ }
408
+ const normalized = path3.posix.normalize(
409
+ path3.posix.join(path3.posix.dirname(fromBundlePath), withoutFragment)
410
+ );
411
+ if (normalized.startsWith("../")) {
412
+ return void 0;
413
+ }
414
+ return conceptIdFromPath(normalized);
415
+ }
416
+
417
+ // src/graph/index.ts
418
+ var GRAPH_WRITE_FAILED = "GRAPH_WRITE_FAILED";
419
+ var GraphWorkspaceError = class extends Error {
420
+ constructor(message, code, details = {}) {
421
+ super(message);
422
+ this.code = code;
423
+ this.details = details;
424
+ this.name = "GraphWorkspaceError";
425
+ }
426
+ code;
427
+ details;
428
+ };
429
+ async function buildWorkspaceGraph(options) {
430
+ const workspaceRoot = path4.resolve(options.workspaceRoot);
431
+ const config = await loadWorkspaceConfig(workspaceRoot);
432
+ const scanResult = await scanConcepts(workspaceRoot, config);
433
+ const nodes = scanResult.files.filter((file) => !file.isReserved).map(graphNodeFromFile);
434
+ const nodeIds = new Set(nodes.map((node) => node.id));
435
+ const { edges, missingTargets, issues } = graphEdgesFromFiles(
436
+ scanResult.files.filter((file) => !file.isReserved),
437
+ nodeIds
438
+ );
439
+ const backlinks = backlinksFromEdges(edges);
440
+ const backlinksPath = path4.join(workspaceRoot, ".okfh/backlinks.json");
441
+ const htmlPath = path4.join(workspaceRoot, ".okfh/reports/graph.html");
442
+ const data = {
443
+ generatedAt: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
444
+ workspaceRoot,
445
+ nodes,
446
+ edges,
447
+ backlinks,
448
+ issues,
449
+ missingTargets
450
+ };
451
+ try {
452
+ await mkdir(path4.dirname(backlinksPath), { recursive: true });
453
+ await mkdir(path4.dirname(htmlPath), { recursive: true });
454
+ await writeFile(backlinksPath, `${JSON.stringify(data, null, 2)}
455
+ `, "utf8");
456
+ await writeFile(htmlPath, renderGraphHtml(data), "utf8");
457
+ } catch (error) {
458
+ throw new GraphWorkspaceError(
459
+ error instanceof Error ? error.message : "Could not write graph artifacts.",
460
+ GRAPH_WRITE_FAILED,
461
+ { backlinksPath, htmlPath }
462
+ );
463
+ }
464
+ return {
465
+ workspaceRoot,
466
+ report: {
467
+ backlinksPath,
468
+ htmlPath
469
+ },
470
+ stats: {
471
+ nodes: nodes.length,
472
+ conceptEdges: edges.filter((edge) => edge.kind === "link").length,
473
+ evidenceEdges: edges.filter((edge) => edge.kind === "citation").length,
474
+ missingTargets: missingTargets.length
475
+ },
476
+ issues,
477
+ missingTargets
478
+ };
479
+ }
480
+ function graphNodeFromFile(file) {
481
+ return {
482
+ id: file.conceptId,
483
+ path: file.workspacePath,
484
+ title: file.frontmatter.ok ? stringValue2(file.frontmatter.data.title) ?? firstHeading(file.markdown) ?? file.conceptId : firstHeading(file.markdown) ?? file.conceptId,
485
+ type: file.frontmatter.ok ? stringValue2(file.frontmatter.data.type) ?? "Unknown" : "Unknown",
486
+ tags: file.frontmatter.ok ? stringArrayValue2(file.frontmatter.data.tags) : []
487
+ };
488
+ }
489
+ function graphEdgesFromFiles(files, nodeIds) {
490
+ const edges = /* @__PURE__ */ new Map();
491
+ const missingTargets = [];
492
+ const issues = [];
493
+ for (const file of files) {
494
+ const body = file.frontmatter.ok ? file.frontmatter.body : stripFrontmatterFence(file.markdown);
495
+ const targets = [
496
+ ...parseMarkdownLinks(body).map((link) => ({ target: link.target, kind: "link" })),
497
+ ...bareReferenceTargets(body).map((target) => ({ target, kind: "citation" }))
498
+ ];
499
+ for (const target of targets) {
500
+ const conceptId = resolveOkfLinkTarget(target.target, file.bundlePath);
501
+ if (conceptId === void 0) {
502
+ continue;
503
+ }
504
+ if (!nodeIds.has(conceptId)) {
505
+ missingTargets.push({
506
+ from: file.conceptId,
507
+ target: target.target,
508
+ path: file.workspacePath
509
+ });
510
+ issues.push({
511
+ code: "MISSING_TARGET",
512
+ path: file.workspacePath,
513
+ message: `Graph link target does not exist: ${target.target}`
514
+ });
515
+ continue;
516
+ }
517
+ if (conceptId === file.conceptId) {
518
+ continue;
519
+ }
520
+ const edge = {
521
+ from: file.conceptId,
522
+ to: conceptId,
523
+ kind: target.kind
524
+ };
525
+ edges.set(`${edge.from}\0${edge.to}\0${edge.kind}`, edge);
526
+ }
527
+ }
528
+ return {
529
+ edges: [...edges.values()].sort(
530
+ (left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to) || left.kind.localeCompare(right.kind)
531
+ ),
532
+ missingTargets,
533
+ issues
534
+ };
535
+ }
536
+ function bareReferenceTargets(markdown) {
537
+ return [...markdown.matchAll(/(^|\s)(\/?(?:wiki\/)?references\/[^\s)]+\.md)\b/gm)].map((match) => match[2]).filter((target) => target !== void 0);
538
+ }
539
+ function backlinksFromEdges(edges) {
540
+ const backlinks = {};
541
+ for (const edge of edges) {
542
+ backlinks[edge.to] = [...backlinks[edge.to] ?? [], edge.from].sort();
543
+ }
544
+ return backlinks;
545
+ }
546
+ function renderGraphHtml(data) {
547
+ const safeJson = JSON.stringify({
548
+ nodes: data.nodes,
549
+ edges: data.edges,
550
+ issues: data.issues,
551
+ missingTargets: data.missingTargets
552
+ }).replace(/</g, "\\u003c");
553
+ return `<!doctype html>
554
+ <html lang="en">
555
+ <head>
556
+ <meta charset="utf-8">
557
+ <meta name="viewport" content="width=device-width, initial-scale=1">
558
+ <title>OKF Harness Graph</title>
559
+ <style>
560
+ body { margin: 0; font: 14px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #172026; background: #f7f8f5; }
561
+ header { display: flex; gap: 12px; align-items: center; padding: 14px 18px; border-bottom: 1px solid #d8ddd2; background: #ffffff; }
562
+ h1 { font-size: 16px; margin: 0; }
563
+ input, select { font: inherit; padding: 6px 8px; border: 1px solid #bac3b4; border-radius: 6px; background: #fff; }
564
+ main { display: grid; grid-template-columns: minmax(0, 1fr) 320px; min-height: calc(100vh - 58px); }
565
+ svg { width: 100%; height: calc(100vh - 58px); background: #f7f8f5; }
566
+ aside { border-left: 1px solid #d8ddd2; padding: 16px; background: #ffffff; overflow: auto; }
567
+ .node { cursor: pointer; }
568
+ .edge { stroke: #83907b; stroke-width: 1.4; opacity: .75; }
569
+ .node circle { fill: #2f6f73; stroke: #fff; stroke-width: 2; }
570
+ .node.reference circle { fill: #8b5e34; }
571
+ .node text { paint-order: stroke; stroke: #f7f8f5; stroke-width: 4; fill: #172026; font-size: 12px; }
572
+ .muted { color: #667064; }
573
+ @media (max-width: 760px) { main { grid-template-columns: 1fr; } aside { border-left: 0; border-top: 1px solid #d8ddd2; } svg { height: 62vh; } }
574
+ </style>
575
+ </head>
576
+ <body>
577
+ <header>
578
+ <h1>OKF Harness Graph</h1>
579
+ <input id="search" type="search" placeholder="Search nodes" aria-label="Search nodes">
580
+ <select id="type" aria-label="Filter by type"><option value="">All types</option></select>
581
+ </header>
582
+ <main>
583
+ <svg id="graph" role="img" aria-label="OKF concept graph"></svg>
584
+ <aside id="details"><p class="muted">Select a node to inspect links and metadata.</p></aside>
585
+ </main>
586
+ <script>
587
+ const graph = ${safeJson};
588
+ const svg = document.querySelector("#graph");
589
+ const details = document.querySelector("#details");
590
+ const search = document.querySelector("#search");
591
+ const type = document.querySelector("#type");
592
+ const types = [...new Set(graph.nodes.map((node) => node.type))].sort();
593
+ for (const item of types) {
594
+ const option = document.createElement("option");
595
+ option.value = item;
596
+ option.textContent = item;
597
+ type.appendChild(option);
598
+ }
599
+ function visibleNodes() {
600
+ const q = search.value.trim().toLowerCase();
601
+ return graph.nodes.filter((node) => {
602
+ const matchesType = !type.value || node.type === type.value;
603
+ const haystack = [node.id, node.title, node.path, node.type].join(" ").toLowerCase();
604
+ return matchesType && (!q || haystack.includes(q));
605
+ });
606
+ }
607
+ function render() {
608
+ const nodes = visibleNodes();
609
+ const ids = new Set(nodes.map((node) => node.id));
610
+ const edges = graph.edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to));
611
+ const width = svg.clientWidth || 900;
612
+ const height = svg.clientHeight || 620;
613
+ const radius = Math.max(120, Math.min(width, height) * 0.34);
614
+ const cx = width / 2;
615
+ const cy = height / 2;
616
+ const positioned = nodes.map((node, index) => {
617
+ const angle = nodes.length <= 1 ? 0 : (Math.PI * 2 * index) / nodes.length - Math.PI / 2;
618
+ return { ...node, x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius };
619
+ });
620
+ const byId = new Map(positioned.map((node) => [node.id, node]));
621
+ svg.replaceChildren();
622
+ for (const edge of edges) {
623
+ const from = byId.get(edge.from);
624
+ const to = byId.get(edge.to);
625
+ if (!from || !to) continue;
626
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
627
+ line.setAttribute("class", "edge");
628
+ line.setAttribute("x1", from.x);
629
+ line.setAttribute("y1", from.y);
630
+ line.setAttribute("x2", to.x);
631
+ line.setAttribute("y2", to.y);
632
+ svg.appendChild(line);
633
+ }
634
+ for (const node of positioned) {
635
+ const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
636
+ group.setAttribute("class", "node " + node.type.toLowerCase());
637
+ group.addEventListener("click", () => showDetails(node));
638
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
639
+ circle.setAttribute("cx", node.x);
640
+ circle.setAttribute("cy", node.y);
641
+ circle.setAttribute("r", "18");
642
+ const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
643
+ label.setAttribute("x", node.x + 24);
644
+ label.setAttribute("y", node.y + 4);
645
+ label.textContent = node.title;
646
+ group.append(circle, label);
647
+ svg.appendChild(group);
648
+ }
649
+ }
650
+ function showDetails(node) {
651
+ const outgoing = graph.edges.filter((edge) => edge.from === node.id);
652
+ const incoming = graph.edges.filter((edge) => edge.to === node.id);
653
+ details.innerHTML = [
654
+ "<h2>" + escapeHtml(node.title) + "</h2>",
655
+ "<p><strong>Path:</strong> " + escapeHtml(node.path) + "</p>",
656
+ "<p><strong>Type:</strong> " + escapeHtml(node.type) + "</p>",
657
+ "<p><strong>Tags:</strong> " + escapeHtml(node.tags.join(", ")) + "</p>",
658
+ "<h3>Outgoing</h3>",
659
+ listEdges(outgoing, "to"),
660
+ "<h3>Backlinks</h3>",
661
+ listEdges(incoming, "from")
662
+ ].join("");
663
+ }
664
+ function listEdges(edges, key) {
665
+ if (edges.length === 0) return '<p class="muted">None</p>';
666
+ return "<ul>" + edges.map((edge) => "<li>" + escapeHtml(edge[key]) + " <span class=\\"muted\\">" + edge.kind + "</span></li>").join("") + "</ul>";
667
+ }
668
+ function escapeHtml(value) {
669
+ return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[char]));
670
+ }
671
+ search.addEventListener("input", render);
672
+ type.addEventListener("change", render);
673
+ addEventListener("resize", render);
674
+ render();
675
+ </script>
676
+ </body>
677
+ </html>
678
+ `;
679
+ }
680
+ function firstHeading(markdown) {
681
+ return /^#\s+(.+?)\s*$/m.exec(markdown)?.[1];
682
+ }
683
+ function stripFrontmatterFence(markdown) {
684
+ if (!markdown.startsWith("---")) {
685
+ return markdown;
686
+ }
687
+ const end = markdown.indexOf("\n---", 3);
688
+ return end === -1 ? markdown : markdown.slice(end + "\n---".length);
689
+ }
690
+ function stringValue2(value) {
691
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
692
+ }
693
+ function stringArrayValue2(value) {
694
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
695
+ }
696
+
697
+ // src/lint/index.ts
698
+ import { createHash as createHash2 } from "crypto";
699
+ import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
700
+ import path6 from "path";
701
+
702
+ // src/source/index.ts
703
+ import { createHash } from "crypto";
704
+ import { access, appendFile, mkdir as mkdir2, readFile as readFile3, rm, stat, writeFile as writeFile2 } from "fs/promises";
705
+ import path5 from "path";
706
+ var MANIFEST_INVALID = "MANIFEST_INVALID";
707
+ var SOURCE_REGISTRATION_FAILED = "SOURCE_REGISTRATION_FAILED";
708
+ var SOURCE_INPUT_NOT_FOUND = "SOURCE_INPUT_NOT_FOUND";
709
+ var SOURCE_INPUT_UNSUPPORTED = "SOURCE_INPUT_UNSUPPORTED";
710
+ var SOURCE_NOT_REGISTERED = "SOURCE_NOT_REGISTERED";
711
+ var SourceManagementError = class extends Error {
712
+ constructor(message, code, workspaceRoot) {
713
+ super(message);
714
+ this.code = code;
715
+ this.workspaceRoot = workspaceRoot;
716
+ this.name = "SourceManagementError";
717
+ }
718
+ code;
719
+ workspaceRoot;
720
+ };
721
+ async function addSource(options) {
722
+ const workspaceRoot = path5.resolve(options.workspaceRoot);
723
+ const config = await loadWorkspaceConfig(workspaceRoot);
724
+ const manifest = await readSourceManifest(workspaceRoot, config);
725
+ if (manifest.issues.length > 0) {
726
+ throw new SourceManagementError("Source manifest contains invalid rows.", MANIFEST_INVALID);
727
+ }
728
+ const now = options.now ?? /* @__PURE__ */ new Date();
729
+ const url = parseHttpUrl(options.input);
730
+ if (url !== void 0) {
731
+ return addUrlSource({
732
+ workspaceRoot,
733
+ config,
734
+ input: options.input,
735
+ url,
736
+ now,
737
+ options,
738
+ manifest
739
+ });
740
+ }
741
+ return addFileSource({ workspaceRoot, config, input: options.input, now, options, manifest });
742
+ }
743
+ async function listSources(options) {
744
+ const workspaceRoot = path5.resolve(options.workspaceRoot);
745
+ const config = await loadWorkspaceConfig(workspaceRoot);
746
+ const manifest = await readSourceManifest(workspaceRoot, config);
747
+ if (manifest.issues.length > 0) {
748
+ throw new SourceManagementError("Source manifest contains invalid rows.", MANIFEST_INVALID);
749
+ }
750
+ return {
751
+ workspaceRoot,
752
+ sources: manifest.entries
753
+ };
754
+ }
755
+ async function createIngestPlan(options) {
756
+ const workspaceRoot = path5.resolve(options.workspaceRoot);
757
+ const config = await loadWorkspaceConfig(workspaceRoot);
758
+ const manifest = await readSourceManifest(workspaceRoot, config);
759
+ if (manifest.issues.length > 0) {
760
+ throw new SourceManagementError("Source manifest contains invalid rows.", MANIFEST_INVALID);
761
+ }
762
+ const source = findRegisteredSource(manifest.entries, options.source);
763
+ if (source === void 0) {
764
+ throw new SourceManagementError(
765
+ `Source is not registered: ${options.source}`,
766
+ SOURCE_NOT_REGISTERED,
767
+ workspaceRoot
768
+ );
769
+ }
770
+ const scanResult = await scanConcepts(workspaceRoot, config);
771
+ const sourceTokens = tokenizeSourceMetadata(source);
772
+ const candidateConcepts = scanResult.concepts.filter((concept) => !concept.id.startsWith("references/")).map((concept) => {
773
+ const conceptTokens = tokenize([
774
+ concept.id,
775
+ concept.title ?? "",
776
+ concept.type,
777
+ concept.tags.join(" ")
778
+ ]);
779
+ const matches = [...sourceTokens].filter((token) => conceptTokens.has(token));
780
+ return {
781
+ concept,
782
+ matches
783
+ };
784
+ }).filter((candidate) => candidate.matches.length > 0).map(({ concept, matches }) => {
785
+ const candidate = {
786
+ id: concept.id,
787
+ path: concept.workspacePath,
788
+ type: concept.type,
789
+ score: matches.length,
790
+ reason: `metadata token match: ${matches.sort().join(", ")}`
791
+ };
792
+ if (concept.title !== void 0) {
793
+ candidate.title = concept.title;
794
+ }
795
+ return candidate;
796
+ }).sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)).slice(0, 10);
797
+ const recommendedReferencePath = source.reference_concept ?? `wiki/references/${safeSlug(referenceTitle(source)) || source.id}.md`;
798
+ return {
799
+ workspaceRoot,
800
+ source,
801
+ recommendedReferencePath,
802
+ candidateConcepts,
803
+ checklist: [
804
+ `Read the full registered source at ${source.path} before writing wiki content.`,
805
+ `Create or update exactly one reference document at ${recommendedReferencePath}.`,
806
+ "Update only affected topic, entity, project, decision, or question concept documents.",
807
+ "Preserve uncertainty and contradictions; do not invent claims or citations.",
808
+ "Run okfh lint --workspace <workspace> --json after wiki edits."
809
+ ]
810
+ };
811
+ }
812
+ async function readSourceManifest(workspaceRootInput, config) {
813
+ const workspaceRoot = path5.resolve(workspaceRootInput);
814
+ const workspaceConfig = config ?? await loadWorkspaceConfig(workspaceRoot);
815
+ const manifestPath = path5.join(workspaceRoot, workspaceConfig.paths.manifest);
816
+ let source = "";
817
+ try {
818
+ source = await readFile3(manifestPath, "utf8");
819
+ } catch (error) {
820
+ if (errorCode2(error) !== "ENOENT") {
821
+ throw error;
822
+ }
823
+ }
824
+ const entries = [];
825
+ const issues = [];
826
+ const lines = source.split(/\r?\n/);
827
+ lines.forEach((line, index) => {
828
+ if (line.trim().length === 0) {
829
+ return;
830
+ }
831
+ let parsed;
832
+ try {
833
+ parsed = JSON.parse(line);
834
+ } catch (error) {
835
+ issues.push({
836
+ code: MANIFEST_INVALID,
837
+ path: workspaceConfig.paths.manifest,
838
+ line: index + 1,
839
+ message: error instanceof Error ? error.message : "Invalid manifest JSON row."
840
+ });
841
+ return;
842
+ }
843
+ const entry = parseManifestEntry(parsed, workspaceConfig);
844
+ if (entry.ok) {
845
+ entries.push(entry.entry);
846
+ return;
847
+ }
848
+ issues.push({
849
+ code: MANIFEST_INVALID,
850
+ path: workspaceConfig.paths.manifest,
851
+ line: index + 1,
852
+ message: entry.message
853
+ });
854
+ });
855
+ return { entries, issues };
856
+ }
857
+ async function addFileSource(context) {
858
+ const sourcePath = path5.resolve(context.input);
859
+ let sourceStat;
860
+ try {
861
+ sourceStat = await stat(sourcePath);
862
+ } catch (error) {
863
+ if (errorCode2(error) === "ENOENT") {
864
+ throw new SourceManagementError(
865
+ `Source file does not exist: ${context.input}`,
866
+ SOURCE_INPUT_NOT_FOUND,
867
+ context.workspaceRoot
868
+ );
869
+ }
870
+ throw error;
871
+ }
872
+ if (!sourceStat.isFile()) {
873
+ throw new SourceManagementError(
874
+ "Source add supports ordinary files and URLs only.",
875
+ SOURCE_INPUT_UNSUPPORTED,
876
+ context.workspaceRoot
877
+ );
878
+ }
879
+ const contents = await readFile3(sourcePath);
880
+ const sha256 = sha256Hex(contents);
881
+ const existing = context.manifest.entries.find(
882
+ (entry2) => entry2.kind === "file" && entry2.sha256 === sha256
883
+ );
884
+ if (existing !== void 0) {
885
+ return {
886
+ workspaceRoot: context.workspaceRoot,
887
+ input: context.input,
888
+ action: "reused",
889
+ dryRun: context.options.dryRun === true,
890
+ source: existing
891
+ };
892
+ }
893
+ const original = path5.basename(sourcePath);
894
+ const rawPath = await nextRawSourcePath({
895
+ workspaceRoot: context.workspaceRoot,
896
+ config: context.config,
897
+ now: context.now,
898
+ original,
899
+ extension: path5.extname(original),
900
+ entries: context.manifest.entries
901
+ });
902
+ const entry = {
903
+ id: nextSourceId(context.manifest.entries, context.now),
904
+ kind: "file",
905
+ original,
906
+ path: rawPath,
907
+ sha256,
908
+ added_at: context.now.toISOString(),
909
+ status: "registered",
910
+ mime: mimeFromFilename(original),
911
+ title: titleFromFilename(original)
912
+ };
913
+ if (context.options.dryRun === true) {
914
+ return {
915
+ workspaceRoot: context.workspaceRoot,
916
+ input: context.input,
917
+ action: "planned",
918
+ dryRun: true,
919
+ source: entry
920
+ };
921
+ }
922
+ const absoluteRawPath = path5.join(context.workspaceRoot, entry.path);
923
+ await mkdir2(path5.dirname(absoluteRawPath), { recursive: true });
924
+ await writeFile2(absoluteRawPath, contents, { flag: "wx" });
925
+ try {
926
+ await appendManifestEntry(context.workspaceRoot, context.config, entry);
927
+ } catch (error) {
928
+ await rm(absoluteRawPath, { force: true });
929
+ throw new SourceManagementError(
930
+ error instanceof Error ? error.message : "Could not append source manifest entry.",
931
+ SOURCE_REGISTRATION_FAILED,
932
+ context.workspaceRoot
933
+ );
934
+ }
935
+ return {
936
+ workspaceRoot: context.workspaceRoot,
937
+ input: context.input,
938
+ action: "registered",
939
+ dryRun: false,
940
+ source: entry
941
+ };
942
+ }
943
+ async function addUrlSource(context) {
944
+ const original = context.url.href;
945
+ const existing = context.manifest.entries.find(
946
+ (entry2) => entry2.kind === "url" && entry2.original === original
947
+ );
948
+ if (existing !== void 0) {
949
+ return {
950
+ workspaceRoot: context.workspaceRoot,
951
+ input: context.input,
952
+ action: "reused",
953
+ dryRun: context.options.dryRun === true,
954
+ source: existing
955
+ };
956
+ }
957
+ const metadata = urlMetadataContents(original, context.now);
958
+ const rawPath = await nextRawSourcePath({
959
+ workspaceRoot: context.workspaceRoot,
960
+ config: context.config,
961
+ now: context.now,
962
+ original: urlSlugSource(context.url),
963
+ extension: ".url.md",
964
+ entries: context.manifest.entries
965
+ });
966
+ const entry = {
967
+ id: nextSourceId(context.manifest.entries, context.now),
968
+ kind: "url",
969
+ original,
970
+ path: rawPath,
971
+ sha256: sha256Hex(metadata),
972
+ added_at: context.now.toISOString(),
973
+ status: "registered",
974
+ mime: "text/markdown",
975
+ title: titleFromUrl(context.url)
976
+ };
977
+ if (context.options.dryRun === true) {
978
+ return {
979
+ workspaceRoot: context.workspaceRoot,
980
+ input: context.input,
981
+ action: "planned",
982
+ dryRun: true,
983
+ source: entry
984
+ };
985
+ }
986
+ const absoluteRawPath = path5.join(context.workspaceRoot, entry.path);
987
+ await mkdir2(path5.dirname(absoluteRawPath), { recursive: true });
988
+ await writeFile2(absoluteRawPath, metadata, { flag: "wx" });
989
+ try {
990
+ await appendManifestEntry(context.workspaceRoot, context.config, entry);
991
+ } catch (error) {
992
+ await rm(absoluteRawPath, { force: true });
993
+ throw new SourceManagementError(
994
+ error instanceof Error ? error.message : "Could not append source manifest entry.",
995
+ SOURCE_REGISTRATION_FAILED,
996
+ context.workspaceRoot
997
+ );
998
+ }
999
+ return {
1000
+ workspaceRoot: context.workspaceRoot,
1001
+ input: context.input,
1002
+ action: "registered",
1003
+ dryRun: false,
1004
+ source: entry
1005
+ };
1006
+ }
1007
+ async function appendManifestEntry(workspaceRoot, config, entry) {
1008
+ const manifestPath = path5.join(workspaceRoot, config.paths.manifest);
1009
+ await mkdir2(path5.dirname(manifestPath), { recursive: true });
1010
+ await appendFile(manifestPath, `${JSON.stringify(entry)}
1011
+ `, "utf8");
1012
+ }
1013
+ async function nextRawSourcePath(options) {
1014
+ const dateParts = sourceDateParts(options.now);
1015
+ const directory = `${options.config.paths.raw_sources}/${dateParts.year}/${dateParts.month}`;
1016
+ const extension = options.extension.length > 0 ? options.extension.toLowerCase() : "";
1017
+ const stem = extension.length > 0 && options.original.toLowerCase().endsWith(extension) ? options.original.slice(0, -extension.length) : options.original;
1018
+ const slug = safeSlug(stem) || "source";
1019
+ const registeredPaths = new Set(options.entries.map((entry) => entry.path));
1020
+ for (let suffix = 1; ; suffix += 1) {
1021
+ const suffixText = suffix === 1 ? "" : `-${suffix}`;
1022
+ const candidate = `${directory}/${slug}${suffixText}${extension}`;
1023
+ if (registeredPaths.has(candidate)) {
1024
+ continue;
1025
+ }
1026
+ if (!await pathExists(path5.join(options.workspaceRoot, candidate))) {
1027
+ return candidate;
1028
+ }
1029
+ }
1030
+ }
1031
+ function parseManifestEntry(value, config) {
1032
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1033
+ return { ok: false, message: "Manifest row must be a JSON object." };
1034
+ }
1035
+ const row = value;
1036
+ const requiredStringFields = ["id", "kind", "original", "path", "sha256", "added_at", "status"];
1037
+ for (const field of requiredStringFields) {
1038
+ if (typeof row[field] !== "string" || row[field].trim().length === 0) {
1039
+ return { ok: false, message: `Manifest row is missing a non-empty ${field} field.` };
1040
+ }
1041
+ }
1042
+ if (row.kind !== "file" && row.kind !== "url") {
1043
+ return { ok: false, message: "Manifest kind must be file or url." };
1044
+ }
1045
+ if (row.status !== "registered") {
1046
+ return { ok: false, message: "Manifest status must be registered." };
1047
+ }
1048
+ if (!/^src_\d{8}_\d{4}$/.test(String(row.id))) {
1049
+ return { ok: false, message: "Manifest source id must match src_YYYYMMDD_NNNN." };
1050
+ }
1051
+ if (!/^[a-f0-9]{64}$/.test(String(row.sha256))) {
1052
+ return { ok: false, message: "Manifest sha256 must be a lowercase hex digest." };
1053
+ }
1054
+ if (!isSafeRawSourcePath(String(row.path), config)) {
1055
+ return { ok: false, message: "Manifest path must be a safe raw source relative path." };
1056
+ }
1057
+ const entry = {
1058
+ id: String(row.id),
1059
+ kind: row.kind,
1060
+ original: String(row.original),
1061
+ path: toPosixPath(String(row.path)),
1062
+ sha256: String(row.sha256),
1063
+ added_at: String(row.added_at),
1064
+ status: "registered"
1065
+ };
1066
+ for (const optional of ["mime", "title", "reference_concept", "notes"]) {
1067
+ if (typeof row[optional] === "string" && row[optional].trim().length > 0) {
1068
+ entry[optional] = String(row[optional]);
1069
+ }
1070
+ }
1071
+ return { ok: true, entry };
1072
+ }
1073
+ function isSafeRawSourcePath(input, config) {
1074
+ const rawPath = toPosixPath(input);
1075
+ if (rawPath.startsWith("/") || rawPath.includes("\\") || rawPath.includes("\0")) {
1076
+ return false;
1077
+ }
1078
+ const segments = rawPath.split("/");
1079
+ if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
1080
+ return false;
1081
+ }
1082
+ return rawPath === config.paths.raw_sources || rawPath.startsWith(`${config.paths.raw_sources}/`);
1083
+ }
1084
+ function parseHttpUrl(input) {
1085
+ try {
1086
+ const url = new URL(input);
1087
+ return url.protocol === "http:" || url.protocol === "https:" ? url : void 0;
1088
+ } catch {
1089
+ return void 0;
1090
+ }
1091
+ }
1092
+ function findRegisteredSource(entries, sourceInput) {
1093
+ const normalizedInput = toPosixPath(sourceInput);
1094
+ return entries.find(
1095
+ (entry) => entry.id === sourceInput || entry.path === normalizedInput || path5.posix.basename(entry.path) === normalizedInput
1096
+ );
1097
+ }
1098
+ function tokenizeSourceMetadata(source) {
1099
+ return tokenize([source.id, source.original, source.path, source.title ?? ""]);
1100
+ }
1101
+ function tokenize(values) {
1102
+ return new Set(
1103
+ values.join(" ").normalize("NFKD").toLowerCase().replace(/[\u0300-\u036f]/g, "").split(/[^\p{Letter}\p{Number}]+/u).filter((token) => token.length >= 3 && !metadataStopWords.has(token))
1104
+ );
1105
+ }
1106
+ function referenceTitle(source) {
1107
+ if (source.title !== void 0) {
1108
+ return source.title;
1109
+ }
1110
+ if (source.kind === "file") {
1111
+ return titleFromFilename(source.original);
1112
+ }
1113
+ return titleFromUrl(new URL(source.original));
1114
+ }
1115
+ function sourceDateParts(now) {
1116
+ const [date] = now.toISOString().split("T");
1117
+ if (date === void 0) {
1118
+ throw new Error("Could not derive source date.");
1119
+ }
1120
+ const [year, month] = date.split("-");
1121
+ if (year === void 0 || month === void 0) {
1122
+ throw new Error("Could not derive source date parts.");
1123
+ }
1124
+ return { year, month, date: date.replace(/-/g, "") };
1125
+ }
1126
+ var metadataStopWords = /* @__PURE__ */ new Set([
1127
+ "raw",
1128
+ "sources",
1129
+ "source",
1130
+ "http",
1131
+ "https",
1132
+ "www",
1133
+ "com",
1134
+ "org",
1135
+ "net"
1136
+ ]);
1137
+ function nextSourceId(entries, now) {
1138
+ const { date } = sourceDateParts(now);
1139
+ const maxSequence = entries.reduce((max, entry) => {
1140
+ const match = new RegExp(`^src_${date}_(\\d{4})$`).exec(entry.id);
1141
+ if (match?.[1] === void 0) {
1142
+ return max;
1143
+ }
1144
+ return Math.max(max, Number(match[1]));
1145
+ }, 0);
1146
+ return `src_${date}_${String(maxSequence + 1).padStart(4, "0")}`;
1147
+ }
1148
+ function safeSlug(input) {
1149
+ return input.normalize("NFKD").toLowerCase().replace(/[\u0300-\u036f]/g, "").replace(/[^\p{Letter}\p{Number}]+/gu, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 80).replace(/-+$/g, "");
1150
+ }
1151
+ function titleFromFilename(filename) {
1152
+ const extension = path5.extname(filename);
1153
+ const stem = extension.length > 0 ? filename.slice(0, -extension.length) : filename;
1154
+ return stem.trim().length > 0 ? stem : filename;
1155
+ }
1156
+ function urlSlugSource(url) {
1157
+ const pathname = url.pathname.split("/").filter(Boolean).join("-");
1158
+ return pathname.length > 0 ? `${url.hostname}-${pathname}` : url.hostname;
1159
+ }
1160
+ function titleFromUrl(url) {
1161
+ const pathname = url.pathname.split("/").filter(Boolean).at(-1);
1162
+ return pathname !== void 0 && pathname.length > 0 ? pathname : url.hostname;
1163
+ }
1164
+ function urlMetadataContents(url, now) {
1165
+ return `# URL Source
1166
+
1167
+ URL: ${url}
1168
+ Registered at: ${now.toISOString()}
1169
+ `;
1170
+ }
1171
+ function sha256Hex(contents) {
1172
+ return createHash("sha256").update(contents).digest("hex");
1173
+ }
1174
+ function mimeFromFilename(filename) {
1175
+ switch (path5.extname(filename).toLowerCase()) {
1176
+ case ".md":
1177
+ case ".markdown":
1178
+ return "text/markdown";
1179
+ case ".txt":
1180
+ return "text/plain";
1181
+ case ".pdf":
1182
+ return "application/pdf";
1183
+ case ".html":
1184
+ case ".htm":
1185
+ return "text/html";
1186
+ case ".json":
1187
+ return "application/json";
1188
+ default:
1189
+ return "application/octet-stream";
1190
+ }
1191
+ }
1192
+ async function pathExists(input) {
1193
+ try {
1194
+ await access(input);
1195
+ return true;
1196
+ } catch (error) {
1197
+ if (errorCode2(error) === "ENOENT") {
1198
+ return false;
1199
+ }
1200
+ throw error;
1201
+ }
1202
+ }
1203
+ function errorCode2(error) {
1204
+ if (typeof error !== "object" || error === null || !("code" in error)) {
1205
+ return void 0;
1206
+ }
1207
+ const code = error.code;
1208
+ return typeof code === "string" ? code : void 0;
1209
+ }
1210
+
1211
+ // src/lint/index.ts
1212
+ var OKF_MISSING_FRONTMATTER = "OKF_MISSING_FRONTMATTER";
1213
+ var OKF_INVALID_FRONTMATTER = "OKF_INVALID_FRONTMATTER";
1214
+ var OKF_MISSING_TYPE = "OKF_MISSING_TYPE";
1215
+ var RESERVED_FILE_HAS_CONCEPT_FRONTMATTER = "RESERVED_FILE_HAS_CONCEPT_FRONTMATTER";
1216
+ var LOG_INVALID_DATE_HEADING = "LOG_INVALID_DATE_HEADING";
1217
+ var SOURCE_HASH_DRIFT = "SOURCE_HASH_DRIFT";
1218
+ var SOURCE_MISSING = "SOURCE_MISSING";
1219
+ var REFERENCE_SOURCE_MISSING = "REFERENCE_SOURCE_MISSING";
1220
+ var BROKEN_LINK = "BROKEN_LINK";
1221
+ var MISSING_INDEX_ENTRY = "MISSING_INDEX_ENTRY";
1222
+ var MISSING_CITATIONS_SECTION = "MISSING_CITATIONS_SECTION";
1223
+ async function lintWorkspace(workspaceRoot) {
1224
+ const configResult = await readWorkspaceConfig(workspaceRoot);
1225
+ if (!configResult.ok) {
1226
+ return {
1227
+ ok: false,
1228
+ issues: configResult.issues.map((issue) => ({
1229
+ code: CONFIG_INVALID,
1230
+ severity: "error",
1231
+ message: issue.message,
1232
+ path: issue.path
1233
+ }))
1234
+ };
1235
+ }
1236
+ try {
1237
+ const scanResult = await scanConcepts(workspaceRoot, configResult.config);
1238
+ const sourceManifest = await readSourceManifest(workspaceRoot, configResult.config);
1239
+ const sourceIssues = sourceManifest.issues.length === 0 ? [
1240
+ ...await lintRegisteredSources(workspaceRoot, sourceManifest.entries),
1241
+ ...lintReferenceSourceIds(scanResult.files, sourceManifest.entries),
1242
+ ...await lintUnregisteredRawSources(
1243
+ workspaceRoot,
1244
+ configResult.config.paths.raw_sources,
1245
+ sourceManifest.entries
1246
+ )
1247
+ ] : [];
1248
+ const issues = [
1249
+ ...scanResult.files.flatMap((file) => lintMarkdownFile(file)),
1250
+ ...lintWikiWarnings(scanResult.files),
1251
+ ...sourceManifest.issues.map(
1252
+ (issue) => ({
1253
+ code: issue.code,
1254
+ severity: "error",
1255
+ path: issue.path,
1256
+ line: issue.line,
1257
+ message: issue.message
1258
+ })
1259
+ ),
1260
+ ...sourceIssues
1261
+ ];
1262
+ return {
1263
+ ok: issues.every((issue) => issue.severity !== "error"),
1264
+ issues
1265
+ };
1266
+ } catch (error) {
1267
+ return {
1268
+ ok: false,
1269
+ issues: [
1270
+ {
1271
+ code: CONFIG_INVALID,
1272
+ severity: "error",
1273
+ path: configResult.config.okf.bundle_root,
1274
+ message: error instanceof Error ? error.message : "Could not scan OKF wiki."
1275
+ }
1276
+ ]
1277
+ };
1278
+ }
1279
+ }
1280
+ function lintReferenceSourceIds(files, entries) {
1281
+ const sourceIds = new Set(entries.map((entry) => entry.id));
1282
+ return files.flatMap((file) => {
1283
+ if (file.isReserved || !file.workspacePath.startsWith("wiki/references/")) {
1284
+ return [];
1285
+ }
1286
+ if (!file.frontmatter.ok) {
1287
+ return [];
1288
+ }
1289
+ const sourceId = frontmatterSourceId(file.frontmatter.data);
1290
+ if (sourceId === void 0 || sourceIds.has(sourceId)) {
1291
+ return [];
1292
+ }
1293
+ return [
1294
+ {
1295
+ code: REFERENCE_SOURCE_MISSING,
1296
+ severity: "error",
1297
+ path: file.workspacePath,
1298
+ message: `Reference document points to an unregistered source id: ${sourceId}`
1299
+ }
1300
+ ];
1301
+ });
1302
+ }
1303
+ async function lintUnregisteredRawSources(workspaceRoot, rawSourcesPath, entries) {
1304
+ const rawRoot = path6.join(workspaceRoot, rawSourcesPath);
1305
+ const registeredPaths = new Set(entries.map((entry) => entry.path));
1306
+ const files = await scanRawSourceFiles(rawRoot);
1307
+ return files.flatMap((filePath) => {
1308
+ const workspacePath = path6.relative(workspaceRoot, filePath).split(path6.sep).join(path6.posix.sep);
1309
+ if (registeredPaths.has(workspacePath) || isIgnoredRawSourceFile(workspacePath)) {
1310
+ return [];
1311
+ }
1312
+ return [
1313
+ {
1314
+ code: "UNREGISTERED_RAW_SOURCE",
1315
+ severity: "warning",
1316
+ path: workspacePath,
1317
+ message: `Raw source file is not registered in the manifest: ${workspacePath}`
1318
+ }
1319
+ ];
1320
+ });
1321
+ }
1322
+ async function scanRawSourceFiles(root) {
1323
+ let entries;
1324
+ try {
1325
+ entries = await readdir2(root, { withFileTypes: true });
1326
+ } catch (error) {
1327
+ if (errorCode3(error) === "ENOENT") {
1328
+ return [];
1329
+ }
1330
+ throw error;
1331
+ }
1332
+ const nested = await Promise.all(
1333
+ entries.map(async (entry) => {
1334
+ const entryPath = path6.join(root, entry.name);
1335
+ if (entry.isDirectory()) {
1336
+ return scanRawSourceFiles(entryPath);
1337
+ }
1338
+ return entry.isFile() ? [entryPath] : [];
1339
+ })
1340
+ );
1341
+ return nested.flat();
1342
+ }
1343
+ function isIgnoredRawSourceFile(workspacePath) {
1344
+ return workspacePath === "raw/sources/README.md" || workspacePath.endsWith("/.gitkeep");
1345
+ }
1346
+ function frontmatterSourceId(frontmatter) {
1347
+ const okfh = frontmatter.okfh;
1348
+ if (typeof okfh !== "object" || okfh === null || Array.isArray(okfh)) {
1349
+ return void 0;
1350
+ }
1351
+ const sourceId = okfh.source_id;
1352
+ return typeof sourceId === "string" && sourceId.trim().length > 0 ? sourceId : void 0;
1353
+ }
1354
+ async function lintRegisteredSources(workspaceRoot, entries) {
1355
+ const nested = await Promise.all(
1356
+ entries.map(async (entry) => {
1357
+ const absolutePath = path6.join(workspaceRoot, entry.path);
1358
+ let contents;
1359
+ try {
1360
+ contents = await readFile4(absolutePath);
1361
+ } catch (error) {
1362
+ if (errorCode3(error) === "ENOENT") {
1363
+ return [
1364
+ {
1365
+ code: SOURCE_MISSING,
1366
+ severity: "error",
1367
+ path: entry.path,
1368
+ message: `Registered source is missing: ${entry.path}`
1369
+ }
1370
+ ];
1371
+ }
1372
+ throw error;
1373
+ }
1374
+ const actual = createHash2("sha256").update(contents).digest("hex");
1375
+ if (actual === entry.sha256) {
1376
+ return [];
1377
+ }
1378
+ return [
1379
+ {
1380
+ code: SOURCE_HASH_DRIFT,
1381
+ severity: "error",
1382
+ path: entry.path,
1383
+ message: `Registered source hash changed: ${entry.path}`
1384
+ }
1385
+ ];
1386
+ })
1387
+ );
1388
+ return nested.flat();
1389
+ }
1390
+ function lintMarkdownFile(file) {
1391
+ const issues = [];
1392
+ if (file.frontmatter.ok && file.isReserved && hasFrontmatterData(file)) {
1393
+ issues.push({
1394
+ code: RESERVED_FILE_HAS_CONCEPT_FRONTMATTER,
1395
+ severity: "error",
1396
+ path: file.workspacePath,
1397
+ message: `${file.bundlePath} is reserved and must not define concept frontmatter.`
1398
+ });
1399
+ }
1400
+ if (!file.frontmatter.ok && file.frontmatter.hasFrontmatter) {
1401
+ issues.push({
1402
+ code: OKF_INVALID_FRONTMATTER,
1403
+ severity: "error",
1404
+ path: file.workspacePath,
1405
+ message: file.frontmatter.message
1406
+ });
1407
+ }
1408
+ if (!file.isReserved) {
1409
+ if (!file.frontmatter.ok && !file.frontmatter.hasFrontmatter) {
1410
+ issues.push({
1411
+ code: OKF_MISSING_FRONTMATTER,
1412
+ severity: "error",
1413
+ path: file.workspacePath,
1414
+ message: `${file.bundlePath} is missing YAML frontmatter.`
1415
+ });
1416
+ }
1417
+ if (file.frontmatter.ok && !hasNonEmptyType(file.frontmatter.data.type)) {
1418
+ issues.push({
1419
+ code: OKF_MISSING_TYPE,
1420
+ severity: "error",
1421
+ path: file.workspacePath,
1422
+ message: `${file.bundlePath} is missing a non-empty type field.`
1423
+ });
1424
+ }
1425
+ }
1426
+ if (file.bundlePath.split("/").at(-1) === "log.md") {
1427
+ issues.push(...lintLogDateHeadings(file));
1428
+ }
1429
+ return issues;
1430
+ }
1431
+ function lintWikiWarnings(files) {
1432
+ const existingConceptIds = new Set(files.map((file) => file.conceptId));
1433
+ const indexedConceptIds = indexMentionedConceptIds(files);
1434
+ return [
1435
+ ...lintBrokenLinks(files, existingConceptIds),
1436
+ ...lintMissingIndexEntries(files, indexedConceptIds),
1437
+ ...lintMissingCitationSections(files)
1438
+ ];
1439
+ }
1440
+ function lintBrokenLinks(files, existingConceptIds) {
1441
+ return files.flatMap(
1442
+ (file) => parseMarkdownLinks(file.markdown).flatMap((link) => {
1443
+ const conceptId = resolveOkfLinkTarget(link.target, file.bundlePath);
1444
+ if (conceptId === void 0 || existingConceptIds.has(conceptId)) {
1445
+ return [];
1446
+ }
1447
+ return [
1448
+ {
1449
+ code: BROKEN_LINK,
1450
+ severity: "warning",
1451
+ path: file.workspacePath,
1452
+ line: link.line,
1453
+ message: `Markdown link target does not exist: ${link.target}`
1454
+ }
1455
+ ];
1456
+ })
1457
+ );
1458
+ }
1459
+ function lintMissingIndexEntries(files, indexedConceptIds) {
1460
+ return files.flatMap((file) => {
1461
+ if (file.isReserved || indexedConceptIds.has(file.conceptId)) {
1462
+ return [];
1463
+ }
1464
+ return [
1465
+ {
1466
+ code: MISSING_INDEX_ENTRY,
1467
+ severity: "warning",
1468
+ path: file.workspacePath,
1469
+ message: `Concept is not linked from a root or directory index: ${file.workspacePath}`
1470
+ }
1471
+ ];
1472
+ });
1473
+ }
1474
+ function lintMissingCitationSections(files) {
1475
+ return files.flatMap((file) => {
1476
+ if (file.isReserved || !file.frontmatter.ok) {
1477
+ return [];
1478
+ }
1479
+ const type = stringValue3(file.frontmatter.data.type)?.toLocaleLowerCase();
1480
+ if (type === void 0 || !(/* @__PURE__ */ new Set(["topic", "entity", "project", "decision"])).has(type) || hasOkfhSources(file.frontmatter.data) || /^#\s+Citations\s*$/im.test(file.frontmatter.body)) {
1481
+ return [];
1482
+ }
1483
+ return [
1484
+ {
1485
+ code: MISSING_CITATIONS_SECTION,
1486
+ severity: "warning",
1487
+ path: file.workspacePath,
1488
+ message: `${file.bundlePath} should include # Citations or okfh.sources.`
1489
+ }
1490
+ ];
1491
+ });
1492
+ }
1493
+ function indexMentionedConceptIds(files) {
1494
+ const indexed = /* @__PURE__ */ new Set();
1495
+ for (const file of files) {
1496
+ if (path6.posix.basename(file.bundlePath) !== "index.md") {
1497
+ continue;
1498
+ }
1499
+ for (const link of parseMarkdownLinks(file.markdown)) {
1500
+ const conceptId = resolveOkfLinkTarget(link.target, file.bundlePath);
1501
+ if (conceptId !== void 0) {
1502
+ indexed.add(conceptId);
1503
+ }
1504
+ }
1505
+ }
1506
+ return indexed;
1507
+ }
1508
+ function lintLogDateHeadings(file) {
1509
+ return file.markdown.split(/\r?\n/).flatMap((line, index) => {
1510
+ const heading = /^(#{2,6})\s+(.+?)\s*$/.exec(line);
1511
+ if (heading === null) {
1512
+ return [];
1513
+ }
1514
+ const headingText = heading[2];
1515
+ if (headingText === void 0) {
1516
+ return [];
1517
+ }
1518
+ if (/^\d{4}-\d{2}-\d{2}$/.test(headingText)) {
1519
+ return [];
1520
+ }
1521
+ return [
1522
+ {
1523
+ code: LOG_INVALID_DATE_HEADING,
1524
+ severity: "error",
1525
+ path: file.workspacePath,
1526
+ line: index + 1,
1527
+ message: `Log heading must be YYYY-MM-DD: ${headingText}`
1528
+ }
1529
+ ];
1530
+ });
1531
+ }
1532
+ function hasFrontmatterData(file) {
1533
+ return file.frontmatter.ok && Object.keys(file.frontmatter.data).length > 0;
1534
+ }
1535
+ function hasNonEmptyType(value) {
1536
+ return typeof value === "string" && value.trim().length > 0;
1537
+ }
1538
+ function stringValue3(value) {
1539
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
1540
+ }
1541
+ function hasOkfhSources(frontmatter) {
1542
+ const okfh = frontmatter.okfh;
1543
+ if (typeof okfh !== "object" || okfh === null || Array.isArray(okfh)) {
1544
+ return false;
1545
+ }
1546
+ const sources = okfh.sources;
1547
+ return Array.isArray(sources) && sources.length > 0;
1548
+ }
1549
+ function errorCode3(error) {
1550
+ if (typeof error !== "object" || error === null || !("code" in error)) {
1551
+ return void 0;
1552
+ }
1553
+ const code = error.code;
1554
+ return typeof code === "string" ? code : void 0;
1555
+ }
1556
+
1557
+ // src/read/index.ts
1558
+ import { readFile as readFile5 } from "fs/promises";
1559
+ import path7 from "path";
1560
+ import { TextDecoder } from "util";
1561
+ var INVALID_TARGET = "INVALID_TARGET";
1562
+ var TARGET_NOT_FOUND = "TARGET_NOT_FOUND";
1563
+ var AMBIGUOUS_SECTION = "AMBIGUOUS_SECTION";
1564
+ var READ_LIMIT_EXCEEDED = "READ_LIMIT_EXCEEDED";
1565
+ var NON_MARKDOWN_TARGET = "NON_MARKDOWN_TARGET";
1566
+ var NON_UTF8_TARGET = "NON_UTF8_TARGET";
1567
+ var ReadWorkspaceError = class extends Error {
1568
+ constructor(message, code, details = {}) {
1569
+ super(message);
1570
+ this.code = code;
1571
+ this.details = details;
1572
+ this.name = "ReadWorkspaceError";
1573
+ }
1574
+ code;
1575
+ details;
1576
+ };
1577
+ var defaultReadPreviewChars = 12e3;
1578
+ var maxFullReadChars = 1e5;
1579
+ async function readWorkspaceDocument(options) {
1580
+ const workspaceRoot = path7.resolve(options.workspaceRoot);
1581
+ const config = await loadWorkspaceConfig(workspaceRoot);
1582
+ const [scanResult, sourceManifest] = await Promise.all([
1583
+ scanConcepts(workspaceRoot, config),
1584
+ readSourceManifest(workspaceRoot, config)
1585
+ ]);
1586
+ const file = resolveReadTarget(scanResult.files, options.target);
1587
+ await assertUtf8Target(file);
1588
+ const sourceEntries = new Map(sourceManifest.entries.map((entry) => [entry.id, entry]));
1589
+ const conceptIds = new Set(
1590
+ scanResult.files.filter((item) => !item.isReserved).map((item) => item.conceptId)
1591
+ );
1592
+ const body = markdownBody(file);
1593
+ const sections = parseSections(body);
1594
+ const citationRange = findCitationsRange(sections, body);
1595
+ const links = parseBodyLinks(file, body, conceptIds, citationRange);
1596
+ const { citations, citationIssues } = parseCitations(
1597
+ file,
1598
+ body,
1599
+ conceptIds,
1600
+ sourceEntries,
1601
+ citationRange
1602
+ );
1603
+ const source = file.frontmatter.ok && isReferenceDocument(file) ? sourceFromFrontmatter(file.frontmatter.data, sourceEntries) : void 0;
1604
+ const result = {
1605
+ workspaceRoot,
1606
+ target: {
1607
+ input: options.target,
1608
+ conceptId: file.conceptId,
1609
+ path: file.workspacePath,
1610
+ bundlePath: file.bundlePath,
1611
+ reserved: file.isReserved
1612
+ },
1613
+ frontmatter: renderFrontmatter(file),
1614
+ metadata: renderMetadata(file),
1615
+ outline: sections,
1616
+ availableSections: sections,
1617
+ links,
1618
+ citations,
1619
+ citationIssues,
1620
+ content: selectContent(body, sections, options),
1621
+ warnings: file.frontmatter.ok ? [] : [
1622
+ {
1623
+ code: "FRONTMATTER_DEGRADED",
1624
+ path: file.workspacePath,
1625
+ message: file.frontmatter.message
1626
+ }
1627
+ ]
1628
+ };
1629
+ if (source !== void 0) {
1630
+ result.source = source;
1631
+ }
1632
+ if (file.bundlePath === "index.md") {
1633
+ result.indexLinks = parseIndexLinks(file, body, conceptIds);
1634
+ }
1635
+ if (path7.posix.basename(file.bundlePath) === "log.md") {
1636
+ result.logEntries = parseLogEntries(body);
1637
+ }
1638
+ return result;
1639
+ }
1640
+ async function assertUtf8Target(file) {
1641
+ if (file.markdown.includes("\uFFFD") || file.frontmatter.ok && file.frontmatter.body.includes("\uFFFD")) {
1642
+ throw new ReadWorkspaceError("Read target is not valid UTF-8 markdown.", NON_UTF8_TARGET, {
1643
+ path: file.workspacePath
1644
+ });
1645
+ }
1646
+ const bytes = await readFile5(file.absolutePath);
1647
+ try {
1648
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1649
+ } catch {
1650
+ throw new ReadWorkspaceError("Read target is not valid UTF-8 markdown.", NON_UTF8_TARGET, {
1651
+ path: file.workspacePath
1652
+ });
1653
+ }
1654
+ }
1655
+ function resolveReadTarget(files, targetInput) {
1656
+ const target = targetInput.trim();
1657
+ if (target.length === 0 || target.includes("\\")) {
1658
+ throw new ReadWorkspaceError("Read target must be a non-empty OKF path.", INVALID_TARGET);
1659
+ }
1660
+ if (/\.[^./]+$/.test(target) && !target.endsWith(".md")) {
1661
+ throw new ReadWorkspaceError("Read target must be a markdown document.", NON_MARKDOWN_TARGET, {
1662
+ target
1663
+ });
1664
+ }
1665
+ const candidates = targetAliases(target);
1666
+ const file = files.find(
1667
+ (candidate) => candidates.some(
1668
+ (alias) => candidate.conceptId === alias || candidate.workspacePath === alias || candidate.bundlePath === alias || `/${candidate.bundlePath}` === alias
1669
+ )
1670
+ );
1671
+ if (file === void 0) {
1672
+ throw new ReadWorkspaceError(
1673
+ "No OKF concept document matched the read target.",
1674
+ TARGET_NOT_FOUND,
1675
+ {
1676
+ target
1677
+ }
1678
+ );
1679
+ }
1680
+ return file;
1681
+ }
1682
+ function targetAliases(target) {
1683
+ if (target === "index" || target === "wiki/index.md") {
1684
+ return ["index", "wiki/index.md", "index.md", "/index.md"];
1685
+ }
1686
+ if (target === "log" || target === "wiki/log.md") {
1687
+ return ["log", "wiki/log.md", "log.md", "/log.md"];
1688
+ }
1689
+ const withoutWiki = target.startsWith("wiki/") ? target.slice("wiki/".length) : target;
1690
+ const withoutSlash = withoutWiki.startsWith("/") ? withoutWiki.slice(1) : withoutWiki;
1691
+ const withExtension = withoutSlash.endsWith(".md") ? withoutSlash : `${withoutSlash}.md`;
1692
+ const conceptId = withExtension.slice(0, -".md".length);
1693
+ return [
1694
+ target,
1695
+ withoutSlash,
1696
+ withExtension,
1697
+ conceptId,
1698
+ `wiki/${withExtension}`,
1699
+ `/${withExtension}`
1700
+ ];
1701
+ }
1702
+ function selectContent(body, sections, options) {
1703
+ const contentLength = body.length;
1704
+ if (options.full === true) {
1705
+ if (contentLength > maxFullReadChars) {
1706
+ throw new ReadWorkspaceError(
1707
+ "Full read exceeds the current hard cap. Use section or range reads.",
1708
+ READ_LIMIT_EXCEEDED,
1709
+ { contentLength, maxFullReadChars }
1710
+ );
1711
+ }
1712
+ return contentForRange(body, 0, body.length, "full");
1713
+ }
1714
+ if (options.sectionId !== void 0 || options.section !== void 0) {
1715
+ const section = resolveSection(sections, options);
1716
+ return contentForRange(body, section.startOffset, section.endOffset, "section");
1717
+ }
1718
+ if (options.offset !== void 0 || options.limit !== void 0) {
1719
+ const startOffset = Math.max(0, Math.trunc(options.offset ?? 0));
1720
+ const length = Math.max(0, Math.trunc(options.limit ?? defaultReadPreviewChars));
1721
+ return contentForRange(body, startOffset, Math.min(body.length, startOffset + length), "range");
1722
+ }
1723
+ return contentForRange(body, 0, Math.min(body.length, defaultReadPreviewChars), "preview");
1724
+ }
1725
+ function resolveSection(sections, options) {
1726
+ if (options.sectionId !== void 0) {
1727
+ const section = sections.find((candidate) => candidate.sectionId === options.sectionId);
1728
+ if (section === void 0) {
1729
+ throw new ReadWorkspaceError(
1730
+ "No section matched the requested section id.",
1731
+ TARGET_NOT_FOUND,
1732
+ {
1733
+ sectionId: options.sectionId
1734
+ }
1735
+ );
1736
+ }
1737
+ return section;
1738
+ }
1739
+ const matches = sections.filter(
1740
+ (section) => section.heading.toLocaleLowerCase() === options.section?.toLocaleLowerCase()
1741
+ );
1742
+ if (matches.length === 1) {
1743
+ return matches[0];
1744
+ }
1745
+ if (matches.length > 1) {
1746
+ throw new ReadWorkspaceError(
1747
+ "Multiple sections matched the requested heading.",
1748
+ AMBIGUOUS_SECTION,
1749
+ {
1750
+ section: options.section,
1751
+ candidates: matches.map((section) => ({
1752
+ sectionId: section.sectionId,
1753
+ headingPath: section.headingPath
1754
+ }))
1755
+ }
1756
+ );
1757
+ }
1758
+ throw new ReadWorkspaceError("No section matched the requested heading.", TARGET_NOT_FOUND, {
1759
+ section: options.section
1760
+ });
1761
+ }
1762
+ function contentForRange(body, startOffset, endOffset, mode) {
1763
+ const text = body.slice(startOffset, endOffset);
1764
+ return {
1765
+ mode,
1766
+ text,
1767
+ startOffset,
1768
+ endOffset,
1769
+ contentLength: body.length,
1770
+ returnedChars: text.length,
1771
+ truncated: endOffset < body.length
1772
+ };
1773
+ }
1774
+ function parseSections(body) {
1775
+ const headings = [];
1776
+ const slugCounts = /* @__PURE__ */ new Map();
1777
+ const stack = [];
1778
+ const headingPattern = /^(#{1,6})\s+(.+?)\s*$/gm;
1779
+ let match = headingPattern.exec(body);
1780
+ while (match !== null) {
1781
+ const marker = match[1];
1782
+ const heading = match[2];
1783
+ if (marker === void 0 || heading === void 0) {
1784
+ continue;
1785
+ }
1786
+ const level = marker.length;
1787
+ while (stack.length > 0 && (stack.at(-1)?.level ?? 0) >= level) {
1788
+ stack.pop();
1789
+ }
1790
+ stack.push({ level, heading });
1791
+ const baseSlug = slugify(stack.map((item) => item.heading).join(" "));
1792
+ const count = slugCounts.get(baseSlug) ?? 0;
1793
+ slugCounts.set(baseSlug, count + 1);
1794
+ const sectionId = count === 0 ? baseSlug : `${baseSlug}-${count + 1}`;
1795
+ headings.push({
1796
+ sectionId,
1797
+ headingPath: stack.map((item) => item.heading),
1798
+ heading,
1799
+ level,
1800
+ startOffset: match.index,
1801
+ endOffset: body.length,
1802
+ line: lineNumberAtOffset(body, match.index)
1803
+ });
1804
+ match = headingPattern.exec(body);
1805
+ }
1806
+ return headings.map((heading, index) => {
1807
+ const next = headings.slice(index + 1).find((candidate) => candidate.level <= heading.level);
1808
+ return {
1809
+ sectionId: heading.sectionId,
1810
+ headingPath: heading.headingPath,
1811
+ heading: heading.heading,
1812
+ level: heading.level,
1813
+ startOffset: heading.startOffset,
1814
+ endOffset: next?.startOffset ?? body.length
1815
+ };
1816
+ });
1817
+ }
1818
+ function parseBodyLinks(file, body, conceptIds, citationRange) {
1819
+ return parseMarkdownLinks(body).filter((link) => !isLineInRange(link.line, citationRange)).flatMap((link) => {
1820
+ const conceptId = resolveOkfLinkTarget(link.target, file.bundlePath);
1821
+ if (conceptId === void 0) {
1822
+ return [];
1823
+ }
1824
+ const readLink = {
1825
+ text: link.text,
1826
+ target: link.target,
1827
+ exists: conceptIds.has(conceptId),
1828
+ line: link.line
1829
+ };
1830
+ if (conceptId !== void 0) {
1831
+ readLink.conceptId = conceptId;
1832
+ }
1833
+ return [readLink];
1834
+ });
1835
+ }
1836
+ function parseCitations(file, body, conceptIds, sourceEntries, citationRange) {
1837
+ if (citationRange === void 0) {
1838
+ return { citations: [], citationIssues: [] };
1839
+ }
1840
+ const citationMarkdown = body.slice(citationRange.startOffset, citationRange.endOffset);
1841
+ const links = parseMarkdownLinks(citationMarkdown);
1842
+ const citations = [];
1843
+ const citationIssues = [];
1844
+ const linkedTargets = /* @__PURE__ */ new Set();
1845
+ for (const link of links) {
1846
+ linkedTargets.add(link.target);
1847
+ const conceptId = resolveOkfLinkTarget(link.target, file.bundlePath);
1848
+ const exists = conceptId !== void 0 && conceptIds.has(conceptId);
1849
+ const citation = {
1850
+ kind: "reference",
1851
+ target: link.target,
1852
+ exists,
1853
+ line: citationRange.startLine + link.line - 1
1854
+ };
1855
+ if (conceptId !== void 0) {
1856
+ citation.conceptId = conceptId;
1857
+ }
1858
+ citations.push(citation);
1859
+ if (!exists) {
1860
+ citationIssues.push({
1861
+ code: "BROKEN_CITATION_REFERENCE",
1862
+ line: citation.line,
1863
+ message: `Citation reference does not resolve: ${link.target}`
1864
+ });
1865
+ }
1866
+ }
1867
+ const bareReferenceTargets2 = [
1868
+ ...citationMarkdown.matchAll(/(^|\s)(\/?(?:wiki\/)?references\/[^\s)]+\.md)\b/gm)
1869
+ ];
1870
+ for (const match of bareReferenceTargets2) {
1871
+ const target = match[2];
1872
+ if (target === void 0 || linkedTargets.has(target)) {
1873
+ continue;
1874
+ }
1875
+ const conceptId = resolveOkfLinkTarget(target, file.bundlePath);
1876
+ const exists = conceptId !== void 0 && conceptIds.has(conceptId);
1877
+ const citation = {
1878
+ kind: "reference",
1879
+ target,
1880
+ exists,
1881
+ line: citationRange.startLine + lineNumberAtOffset(citationMarkdown, match.index ?? 0) - 1
1882
+ };
1883
+ if (conceptId !== void 0) {
1884
+ citation.conceptId = conceptId;
1885
+ }
1886
+ citations.push(citation);
1887
+ if (!exists) {
1888
+ citationIssues.push({
1889
+ code: "BROKEN_CITATION_REFERENCE",
1890
+ line: citation.line,
1891
+ message: `Citation reference does not resolve: ${target}`
1892
+ });
1893
+ }
1894
+ }
1895
+ const citedSourceIds = [...citationMarkdown.matchAll(/\b(src_\d{8}_\d{4})\b/g)];
1896
+ for (const match of citedSourceIds) {
1897
+ const sourceId = match[1];
1898
+ if (sourceId === void 0) {
1899
+ continue;
1900
+ }
1901
+ const source = sourceEntries.get(sourceId);
1902
+ const citation = {
1903
+ kind: "source",
1904
+ sourceId,
1905
+ exists: source !== void 0,
1906
+ line: citationRange.startLine + lineNumberAtOffset(citationMarkdown, match.index ?? 0) - 1
1907
+ };
1908
+ if (source !== void 0) {
1909
+ citation.source = source;
1910
+ }
1911
+ citations.push(citation);
1912
+ if (source === void 0) {
1913
+ citationIssues.push({
1914
+ code: "BROKEN_CITATION_SOURCE",
1915
+ line: citation.line,
1916
+ message: `Citation source id is not registered: ${sourceId}`
1917
+ });
1918
+ }
1919
+ }
1920
+ return { citations, citationIssues };
1921
+ }
1922
+ function findCitationsRange(sections, body) {
1923
+ const section = sections.find(
1924
+ (candidate) => candidate.level === 1 && candidate.heading.trim().toLocaleLowerCase() === "citations"
1925
+ );
1926
+ if (section === void 0) {
1927
+ return void 0;
1928
+ }
1929
+ return {
1930
+ startOffset: section.startOffset,
1931
+ endOffset: section.endOffset,
1932
+ startLine: lineNumberAtOffset(body, section.startOffset),
1933
+ endLine: lineNumberAtOffset(body, section.endOffset)
1934
+ };
1935
+ }
1936
+ function isLineInRange(line, range) {
1937
+ return range !== void 0 && line >= range.startLine && line <= range.endLine;
1938
+ }
1939
+ function parseIndexLinks(file, body, conceptIds) {
1940
+ return parseMarkdownLinks(body).map((link) => {
1941
+ const conceptId = resolveOkfLinkTarget(link.target, file.bundlePath);
1942
+ const indexLink = {
1943
+ title: link.text,
1944
+ target: link.target,
1945
+ exists: conceptId !== void 0 && conceptIds.has(conceptId)
1946
+ };
1947
+ return conceptId === void 0 ? indexLink : { ...indexLink, conceptId };
1948
+ });
1949
+ }
1950
+ function parseLogEntries(body) {
1951
+ const lines = body.split(/\r?\n/);
1952
+ const entries = [];
1953
+ let currentDate;
1954
+ lines.forEach((line, index) => {
1955
+ const heading = /^##\s+(\d{4}-\d{2}-\d{2})\s*$/.exec(line);
1956
+ if (heading?.[1] !== void 0) {
1957
+ currentDate = heading[1];
1958
+ return;
1959
+ }
1960
+ if (currentDate !== void 0 && line.trim().startsWith("- ")) {
1961
+ entries.push({ date: currentDate, line: index + 1, text: line.trim().slice(2) });
1962
+ }
1963
+ });
1964
+ return entries;
1965
+ }
1966
+ function renderFrontmatter(file) {
1967
+ if (file.frontmatter.ok) {
1968
+ return {
1969
+ ok: true,
1970
+ data: file.frontmatter.data
1971
+ };
1972
+ }
1973
+ return {
1974
+ ok: false,
1975
+ error: file.frontmatter.error,
1976
+ message: file.frontmatter.message
1977
+ };
1978
+ }
1979
+ function renderMetadata(file) {
1980
+ const title = file.frontmatter.ok ? stringValue4(file.frontmatter.data.title) ?? firstHeading2(file.markdown) ?? file.conceptId : firstHeading2(file.markdown) ?? file.conceptId;
1981
+ const type = file.frontmatter.ok ? stringValue4(file.frontmatter.data.type) ?? "Reserved" : "Unknown";
1982
+ const metadata = {
1983
+ title,
1984
+ type,
1985
+ tags: file.frontmatter.ok ? stringArrayValue3(file.frontmatter.data.tags) : []
1986
+ };
1987
+ if (file.frontmatter.ok) {
1988
+ const description = stringValue4(file.frontmatter.data.description);
1989
+ const timestamp = stringValue4(file.frontmatter.data.timestamp);
1990
+ if (description !== void 0) {
1991
+ metadata.description = description;
1992
+ }
1993
+ if (timestamp !== void 0) {
1994
+ metadata.timestamp = timestamp;
1995
+ }
1996
+ }
1997
+ return metadata;
1998
+ }
1999
+ function sourceFromFrontmatter(frontmatter, sourceEntries) {
2000
+ const okfh = frontmatter.okfh;
2001
+ if (typeof okfh !== "object" || okfh === null || Array.isArray(okfh)) {
2002
+ return void 0;
2003
+ }
2004
+ const sourceId = okfh.source_id;
2005
+ return typeof sourceId === "string" ? sourceEntries.get(sourceId) : void 0;
2006
+ }
2007
+ function isReferenceDocument(file) {
2008
+ return file.workspacePath.startsWith("wiki/references/");
2009
+ }
2010
+ function markdownBody(file) {
2011
+ if (file.frontmatter.ok) {
2012
+ return file.frontmatter.body;
2013
+ }
2014
+ return stripFrontmatterFence2(file.markdown);
2015
+ }
2016
+ function stripFrontmatterFence2(markdown) {
2017
+ if (!markdown.startsWith("---")) {
2018
+ return markdown;
2019
+ }
2020
+ const end = markdown.indexOf("\n---", 3);
2021
+ return end === -1 ? markdown : markdown.slice(end + "\n---".length);
2022
+ }
2023
+ function firstHeading2(markdown) {
2024
+ return /^#\s+(.+?)\s*$/m.exec(markdown)?.[1];
2025
+ }
2026
+ function lineNumberAtOffset(input, offset) {
2027
+ return input.slice(0, offset).split(/\r?\n/).length;
2028
+ }
2029
+ function slugify(input) {
2030
+ const slug = input.trim().toLocaleLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-").replace(/^-+|-+$/g, "");
2031
+ return slug.length > 0 ? slug : "section";
2032
+ }
2033
+ function stringValue4(value) {
2034
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
2035
+ }
2036
+ function stringArrayValue3(value) {
2037
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
2038
+ }
2039
+
2040
+ // src/search/index.ts
2041
+ import { readFile as readFile6 } from "fs/promises";
2042
+ import path8 from "path";
2043
+ var defaultLimit = 10;
2044
+ var maxLimit = 50;
2045
+ var maxSearchBodyChars = 2e5;
2046
+ var stopWords = /* @__PURE__ */ new Set(["a", "an", "and", "are", "for", "in", "is", "of", "or", "the", "to"]);
2047
+ async function searchWorkspace(options) {
2048
+ const workspaceRoot = path8.resolve(options.workspaceRoot);
2049
+ const limit = clampLimit(options.limit);
2050
+ const config = await loadWorkspaceConfig(workspaceRoot);
2051
+ const scanResult = await scanConcepts(workspaceRoot, config);
2052
+ const indexMentioned = await readRootIndexMentions(workspaceRoot, config.okf.bundle_root);
2053
+ const parsedQuery = parseSearchQuery(options.query);
2054
+ const warnings = [];
2055
+ const scored = scanResult.files.filter((file) => !file.isReserved).map((file) => {
2056
+ const card = cardFromMarkdownFile(file, indexMentioned.has(file.conceptId));
2057
+ const body = markdownBody2(file);
2058
+ if (!card.frontmatterOk) {
2059
+ warnings.push({
2060
+ code: "FRONTMATTER_DEGRADED",
2061
+ path: file.workspacePath,
2062
+ message: `Search used fallback metadata because frontmatter is invalid: ${file.workspacePath}`
2063
+ });
2064
+ }
2065
+ if (body.length > maxSearchBodyChars) {
2066
+ warnings.push({
2067
+ code: "SEARCH_BODY_SKIPPED",
2068
+ path: file.workspacePath,
2069
+ message: `Search skipped body scoring for a large markdown file: ${file.workspacePath}`
2070
+ });
2071
+ }
2072
+ return scoreCard(card, body, parsedQuery);
2073
+ }).filter((candidate) => matchesFilters(candidate.card, parsedQuery.filters)).filter((candidate) => candidate.card.score > 0).sort(compareSearchCards).map((candidate) => candidate.card);
2074
+ return {
2075
+ workspaceRoot,
2076
+ query: options.query,
2077
+ filtersApplied: parsedQuery.filters,
2078
+ limit,
2079
+ totalMatches: scored.length,
2080
+ truncated: scored.length > limit,
2081
+ results: scored.slice(0, limit),
2082
+ warnings
2083
+ };
2084
+ }
2085
+ function cardFromMarkdownFile(file, indexMentioned) {
2086
+ const title = file.frontmatter.ok ? stringValue5(file.frontmatter.data.title) ?? firstHeading3(file.markdown) ?? file.conceptId : firstHeading3(file.markdown) ?? file.conceptId;
2087
+ const type = file.frontmatter.ok ? stringValue5(file.frontmatter.data.type) ?? "Unknown" : "Unknown";
2088
+ const description = file.frontmatter.ok ? stringValue5(file.frontmatter.data.description) : void 0;
2089
+ const card = {
2090
+ conceptId: file.conceptId,
2091
+ path: file.workspacePath,
2092
+ title,
2093
+ type,
2094
+ tags: file.frontmatter.ok ? stringArrayValue4(file.frontmatter.data.tags) : [],
2095
+ frontmatterOk: file.frontmatter.ok,
2096
+ indexMentioned,
2097
+ score: 0,
2098
+ scoreBreakdown: [],
2099
+ matchedFields: [],
2100
+ bodyHitCount: 0
2101
+ };
2102
+ if (description !== void 0) {
2103
+ card.description = description;
2104
+ }
2105
+ return card;
2106
+ }
2107
+ function scoreCard(card, body, query) {
2108
+ const phrase = query.phrase.toLocaleLowerCase();
2109
+ const title = card.title.toLocaleLowerCase();
2110
+ const conceptId = card.conceptId.toLocaleLowerCase();
2111
+ const pathValue = card.path.toLocaleLowerCase();
2112
+ const typeValue = card.type.toLocaleLowerCase();
2113
+ const description = (card.description ?? "").toLocaleLowerCase();
2114
+ const tags = card.tags.map((tag) => tag.toLocaleLowerCase());
2115
+ const matchedFields = /* @__PURE__ */ new Set();
2116
+ const scoreBreakdown = [];
2117
+ const addScore = (field, reason, score) => {
2118
+ if (score <= 0) {
2119
+ return;
2120
+ }
2121
+ matchedFields.add(field);
2122
+ scoreBreakdown.push({ field, reason, score });
2123
+ };
2124
+ const exactIdentityMatch = phrase.length > 0 && (title === phrase || conceptId === phrase || pathValue === phrase);
2125
+ if (exactIdentityMatch) {
2126
+ addScore("identity", "exact title/id/path match", 100);
2127
+ }
2128
+ const titlePhraseMatch = phrase.length > 0 && title.includes(phrase);
2129
+ if (titlePhraseMatch) {
2130
+ addScore("title", "title phrase match", 60);
2131
+ }
2132
+ if (phrase.length > 0 && (conceptId.includes(phrase) || pathValue.includes(phrase))) {
2133
+ addScore("path", "id/path phrase match", 50);
2134
+ }
2135
+ if (phrase.length > 0 && tags.includes(phrase)) {
2136
+ addScore("tags", "exact tag match", 40);
2137
+ }
2138
+ if (query.filters.type !== void 0 && typeValue === query.filters.type.toLocaleLowerCase()) {
2139
+ addScore("type", "type filter match", 25);
2140
+ }
2141
+ if (phrase.length > 0 && description.includes(phrase)) {
2142
+ addScore("description", "description phrase match", 20);
2143
+ }
2144
+ addTokenScores("title", query.tokens, tokenize2(title), 12, 5, addScore);
2145
+ addTokenScores("path", query.tokens, tokenize2(`${conceptId} ${pathValue}`), 10, 5, addScore);
2146
+ addTokenScores("tags", query.tokens, tokenize2(tags.join(" ")), 8, 5, addScore);
2147
+ addTokenScores("description", query.tokens, tokenize2(description), 4, 5, addScore);
2148
+ const bodyForSearch = body.length > maxSearchBodyChars ? "" : body.toLocaleLowerCase();
2149
+ const bodyPhraseHits = phrase.length > 0 ? countOccurrences(bodyForSearch, phrase) : 0;
2150
+ card.bodyHitCount = Math.min(bodyPhraseHits, 5);
2151
+ addScore("body", "body phrase hits", card.bodyHitCount * 4);
2152
+ const bodyTokenMatches = [...query.tokens].filter((token) => tokenize2(bodyForSearch).has(token));
2153
+ addScore("body", "body unique token hits", Math.min(bodyTokenMatches.length, 10) * 2);
2154
+ card.score = scoreBreakdown.reduce((total, item) => total + item.score, 0);
2155
+ card.scoreBreakdown = scoreBreakdown;
2156
+ card.matchedFields = [...matchedFields].sort();
2157
+ return { card, exactIdentityMatch, titlePhraseMatch };
2158
+ }
2159
+ function addTokenScores(field, queryTokens, fieldTokens, weight, cap, addScore) {
2160
+ const matches = [...queryTokens].filter((token) => fieldTokens.has(token));
2161
+ addScore(field, `${field} token hits`, Math.min(matches.length, cap) * weight);
2162
+ }
2163
+ function compareSearchCards(left, right) {
2164
+ return right.card.score - left.card.score || Number(right.exactIdentityMatch) - Number(left.exactIdentityMatch) || Number(right.titlePhraseMatch) - Number(left.titlePhraseMatch) || left.card.conceptId.localeCompare(right.card.conceptId);
2165
+ }
2166
+ function parseSearchQuery(query) {
2167
+ const filters = {};
2168
+ const terms = [];
2169
+ for (const rawPart of query.split(/\s+/)) {
2170
+ const part = rawPart.trim();
2171
+ if (part.length === 0) {
2172
+ continue;
2173
+ }
2174
+ const filter = /^(type|tag|path):(.+)$/i.exec(part);
2175
+ if (filter?.[1] !== void 0 && filter[2] !== void 0) {
2176
+ filters[filter[1].toLocaleLowerCase()] = filter[2];
2177
+ continue;
2178
+ }
2179
+ terms.push(part);
2180
+ }
2181
+ const phrase = terms.join(" ").trim();
2182
+ return {
2183
+ phrase,
2184
+ tokens: tokenize2(phrase),
2185
+ filters
2186
+ };
2187
+ }
2188
+ function tokenize2(input) {
2189
+ const tokens = /* @__PURE__ */ new Set();
2190
+ for (const token of input.toLocaleLowerCase().split(/[^\p{L}\p{N}]+/u)) {
2191
+ if (token.length > 0 && !stopWords.has(token)) {
2192
+ tokens.add(token);
2193
+ }
2194
+ }
2195
+ const cjkChars = [...input].filter((char) => /\p{Script=Han}/u.test(char));
2196
+ cjkChars.forEach((char) => {
2197
+ tokens.add(char);
2198
+ });
2199
+ for (let index = 0; index < cjkChars.length - 1; index += 1) {
2200
+ const first = cjkChars[index];
2201
+ const second = cjkChars[index + 1];
2202
+ if (first !== void 0 && second !== void 0) {
2203
+ tokens.add(`${first}${second}`);
2204
+ }
2205
+ }
2206
+ return tokens;
2207
+ }
2208
+ function matchesFilters(card, filters) {
2209
+ if (filters.type !== void 0 && card.type.toLocaleLowerCase() !== filters.type.toLocaleLowerCase()) {
2210
+ return false;
2211
+ }
2212
+ if (filters.tag !== void 0 && !card.tags.some((tag) => tag.toLocaleLowerCase() === filters.tag?.toLocaleLowerCase())) {
2213
+ return false;
2214
+ }
2215
+ if (filters.path !== void 0 && !card.path.startsWith(normalizePathFilter(filters.path))) {
2216
+ return false;
2217
+ }
2218
+ return true;
2219
+ }
2220
+ function normalizePathFilter(input) {
2221
+ return input.startsWith("wiki/") ? input : `wiki/${input.replace(/^\/+/, "")}`;
2222
+ }
2223
+ function markdownBody2(file) {
2224
+ if (file.frontmatter.ok) {
2225
+ return file.frontmatter.body;
2226
+ }
2227
+ return stripFrontmatterFence3(file.markdown);
2228
+ }
2229
+ function stripFrontmatterFence3(markdown) {
2230
+ if (!markdown.startsWith("---")) {
2231
+ return markdown;
2232
+ }
2233
+ const end = markdown.indexOf("\n---", 3);
2234
+ return end === -1 ? markdown : markdown.slice(end + "\n---".length);
2235
+ }
2236
+ function firstHeading3(markdown) {
2237
+ const heading = /^#\s+(.+?)\s*$/m.exec(markdown);
2238
+ return heading?.[1];
2239
+ }
2240
+ async function readRootIndexMentions(workspaceRoot, wikiRoot) {
2241
+ const indexPath = path8.join(workspaceRoot, wikiRoot, "index.md");
2242
+ try {
2243
+ const indexMarkdown = await readFile6(indexPath, "utf8");
2244
+ return new Set(
2245
+ parseMarkdownLinks(indexMarkdown).map((link) => resolveOkfLinkTarget(link.target, "index.md")).filter((conceptId) => conceptId !== void 0)
2246
+ );
2247
+ } catch (error) {
2248
+ if (errorCode4(error) === "ENOENT") {
2249
+ return /* @__PURE__ */ new Set();
2250
+ }
2251
+ throw error;
2252
+ }
2253
+ }
2254
+ function countOccurrences(input, needle) {
2255
+ if (needle.length === 0) {
2256
+ return 0;
2257
+ }
2258
+ let count = 0;
2259
+ let index = 0;
2260
+ while (true) {
2261
+ const nextIndex = input.indexOf(needle, index);
2262
+ if (nextIndex === -1) {
2263
+ return count;
2264
+ }
2265
+ count += 1;
2266
+ index = nextIndex + needle.length;
2267
+ }
2268
+ }
2269
+ function clampLimit(limit) {
2270
+ if (limit === void 0) {
2271
+ return defaultLimit;
2272
+ }
2273
+ return Math.max(1, Math.min(maxLimit, Math.trunc(limit)));
2274
+ }
2275
+ function stringValue5(value) {
2276
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
2277
+ }
2278
+ function stringArrayValue4(value) {
2279
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
2280
+ }
2281
+ function errorCode4(error) {
2282
+ if (typeof error !== "object" || error === null || !("code" in error)) {
2283
+ return void 0;
2284
+ }
2285
+ const code = error.code;
2286
+ return typeof code === "string" ? code : void 0;
2287
+ }
2288
+
2289
+ // src/workspace/index.ts
2290
+ import { execFile } from "child_process";
2291
+ import { access as access2, mkdir as mkdir3, readdir as readdir3, stat as stat2, writeFile as writeFile3 } from "fs/promises";
2292
+ import path9 from "path";
2293
+ import { promisify } from "util";
2294
+ import { stringify as stringifyYaml } from "yaml";
2295
+ var WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND";
2296
+ var WorkspaceResolutionError = class extends Error {
2297
+ constructor(message, startDir) {
2298
+ super(message);
2299
+ this.startDir = startDir;
2300
+ this.name = "WorkspaceResolutionError";
2301
+ }
2302
+ startDir;
2303
+ code = WORKSPACE_NOT_FOUND;
2304
+ };
2305
+ var WorkspaceInitError = class extends Error {
2306
+ constructor(message, code) {
2307
+ super(message);
2308
+ this.code = code;
2309
+ this.name = "WorkspaceInitError";
2310
+ }
2311
+ code;
2312
+ };
2313
+ var execFileAsync = promisify(execFile);
2314
+ async function initWorkspace(options) {
2315
+ const workspaceRoot = path9.resolve(options.workspaceRoot);
2316
+ const plan = options.now === void 0 ? createWorkspacePlan({ name: options.name }) : createWorkspacePlan({ name: options.name, now: options.now });
2317
+ await assertWorkspaceCanBeInitialized(workspaceRoot);
2318
+ if (options.dryRun === true) {
2319
+ return {
2320
+ workspaceRoot,
2321
+ name: plan.name,
2322
+ dryRun: true,
2323
+ git: {
2324
+ requested: options.git === true,
2325
+ initialized: false
2326
+ },
2327
+ files: plan.files.map((file) => file.path),
2328
+ directories: plan.directories,
2329
+ lint: {
2330
+ ok: true,
2331
+ issues: []
2332
+ },
2333
+ warnings: plan.warnings
2334
+ };
2335
+ }
2336
+ await mkdir3(workspaceRoot, { recursive: true });
2337
+ await Promise.all(
2338
+ plan.directories.map(
2339
+ (directory) => mkdir3(path9.join(workspaceRoot, directory), { recursive: true })
2340
+ )
2341
+ );
2342
+ await Promise.all(
2343
+ plan.files.map((file) => writeTextFile(path9.join(workspaceRoot, file.path), file.contents))
2344
+ );
2345
+ const gitInitialized = options.git === true ? await initializeGit(workspaceRoot) : false;
2346
+ const lint = await lintWorkspace(workspaceRoot);
2347
+ return {
2348
+ workspaceRoot,
2349
+ name: plan.name,
2350
+ dryRun: false,
2351
+ git: {
2352
+ requested: options.git === true,
2353
+ initialized: gitInitialized
2354
+ },
2355
+ files: plan.files.map((file) => file.path),
2356
+ directories: plan.directories,
2357
+ lint,
2358
+ warnings: plan.warnings
2359
+ };
2360
+ }
2361
+ async function initializeGit(workspaceRoot) {
2362
+ try {
2363
+ await execFileAsync("git", ["init"], { cwd: workspaceRoot });
2364
+ return true;
2365
+ } catch (error) {
2366
+ if (errorCode5(error) === "ENOENT") {
2367
+ throw new WorkspaceInitError("git executable was not found.", "DEPENDENCY_MISSING");
2368
+ }
2369
+ throw error;
2370
+ }
2371
+ }
2372
+ async function readWorkspaceStatus(workspaceRootInput) {
2373
+ const workspaceRoot = path9.resolve(workspaceRootInput);
2374
+ const configResult = await readWorkspaceConfig(workspaceRoot);
2375
+ if (!configResult.ok) {
2376
+ return {
2377
+ workspaceRoot,
2378
+ initialized: false,
2379
+ wikiFiles: 0,
2380
+ concepts: 0,
2381
+ lint: {
2382
+ ok: false,
2383
+ issues: configResult.issues.map((issue) => ({
2384
+ code: issue.code,
2385
+ severity: "error",
2386
+ message: issue.message,
2387
+ path: issue.path
2388
+ }))
2389
+ },
2390
+ warnings: []
2391
+ };
2392
+ }
2393
+ const [scanResult, lint] = await Promise.all([
2394
+ scanConcepts(workspaceRoot, configResult.config),
2395
+ lintWorkspace(workspaceRoot)
2396
+ ]);
2397
+ return {
2398
+ workspaceRoot,
2399
+ initialized: true,
2400
+ name: configResult.config.workspace.name,
2401
+ wikiFiles: scanResult.files.length,
2402
+ concepts: scanResult.concepts.length,
2403
+ lint,
2404
+ warnings: workspacePendingWarnings()
2405
+ };
2406
+ }
2407
+ async function resolveWorkspaceRoot(options) {
2408
+ if (options.workspaceRoot !== void 0 && options.workspaceRoot.trim().length > 0) {
2409
+ return path9.resolve(options.workspaceRoot);
2410
+ }
2411
+ const startDir = path9.resolve(options.startDir ?? process.cwd());
2412
+ const nearest = await findNearestWorkspaceRoot(startDir);
2413
+ if (nearest === void 0) {
2414
+ throw new WorkspaceResolutionError(
2415
+ "Could not find okfh.config.yaml in the current directory or its parents.",
2416
+ startDir
2417
+ );
2418
+ }
2419
+ return nearest;
2420
+ }
2421
+ async function findNearestWorkspaceRoot(startDir) {
2422
+ let current = startDir;
2423
+ while (true) {
2424
+ try {
2425
+ await access2(path9.join(current, "okfh.config.yaml"));
2426
+ return current;
2427
+ } catch (error) {
2428
+ if (errorCode5(error) !== "ENOENT") {
2429
+ throw error;
2430
+ }
2431
+ }
2432
+ const parent = path9.dirname(current);
2433
+ if (parent === current) {
2434
+ return void 0;
2435
+ }
2436
+ current = parent;
2437
+ }
2438
+ }
2439
+ function workspacePendingWarnings() {
2440
+ return [
2441
+ {
2442
+ code: "AGENT_PACK_PENDING",
2443
+ message: "Claude and Codex skill rendering is not included in the base workspace plan."
2444
+ }
2445
+ ];
2446
+ }
2447
+ function createWorkspacePlan(options) {
2448
+ const createdAt = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
2449
+ const logDate = createdAt.slice(0, "YYYY-MM-DD".length);
2450
+ const config = createWorkspaceConfig(options.name, createdAt);
2451
+ return {
2452
+ name: options.name,
2453
+ createdAt,
2454
+ directories: workspaceDirectories(),
2455
+ files: workspaceFiles(options.name, logDate, config),
2456
+ warnings: workspacePendingWarnings()
2457
+ };
2458
+ }
2459
+ function workspaceDirectories() {
2460
+ return [
2461
+ ".agents/skills",
2462
+ ".claude/skills",
2463
+ ".codex",
2464
+ ".okfh/cache",
2465
+ ".okfh/reports",
2466
+ "raw/assets",
2467
+ "raw/inbox",
2468
+ "raw/sources",
2469
+ "wiki/decisions",
2470
+ "wiki/entities",
2471
+ "wiki/projects",
2472
+ "wiki/questions",
2473
+ "wiki/references",
2474
+ "wiki/topics"
2475
+ ];
2476
+ }
2477
+ async function assertWorkspaceCanBeInitialized(workspaceRoot) {
2478
+ try {
2479
+ const workspaceStat = await stat2(workspaceRoot);
2480
+ if (!workspaceStat.isDirectory()) {
2481
+ throw new WorkspaceInitError(
2482
+ "Workspace path exists and is not a directory.",
2483
+ "INIT_NOT_DIRECTORY"
2484
+ );
2485
+ }
2486
+ const entries = await readdir3(workspaceRoot);
2487
+ if (entries.length > 0) {
2488
+ throw new WorkspaceInitError("Workspace path exists and is not empty.", "INIT_NOT_EMPTY");
2489
+ }
2490
+ } catch (error) {
2491
+ if (errorCode5(error) === "ENOENT") {
2492
+ return;
2493
+ }
2494
+ throw error;
2495
+ }
2496
+ }
2497
+ function createWorkspaceConfig(name, createdAt) {
2498
+ return {
2499
+ version: "0.1",
2500
+ workspace: {
2501
+ name,
2502
+ created_at: createdAt,
2503
+ platform: "macos"
2504
+ },
2505
+ okf: {
2506
+ bundle_root: "wiki",
2507
+ profile: "okf-harness-default"
2508
+ },
2509
+ agents: {
2510
+ tier1: {
2511
+ claude: true,
2512
+ codex: true
2513
+ },
2514
+ tier2: {
2515
+ pi: false,
2516
+ opencode: false
2517
+ }
2518
+ },
2519
+ paths: {
2520
+ raw_inbox: "raw/inbox",
2521
+ raw_sources: "raw/sources",
2522
+ wiki_root: "wiki",
2523
+ manifest: ".okfh/manifest.jsonl"
2524
+ },
2525
+ safety: {
2526
+ raw_sources_immutable: true,
2527
+ require_git_checkpoint_before_agent_write: true,
2528
+ max_files_changed_per_ingest: 20
2529
+ }
2530
+ };
2531
+ }
2532
+ function workspaceFiles(name, logDate, config) {
2533
+ return [
2534
+ {
2535
+ path: "README.md",
2536
+ contents: `# ${name}
2537
+
2538
+ This is an OKF Harness workspace.
2539
+ `
2540
+ },
2541
+ {
2542
+ path: "AGENTS.md",
2543
+ contents: "# OKF Harness workspace\n\nPlaceholder. Install agent guidance with okfh init or okfh agent.\n"
2544
+ },
2545
+ {
2546
+ path: "CLAUDE.md",
2547
+ contents: "@AGENTS.md\n"
2548
+ },
2549
+ {
2550
+ path: ".agents/skills/.gitkeep",
2551
+ contents: ""
2552
+ },
2553
+ {
2554
+ path: ".claude/skills/.gitkeep",
2555
+ contents: ""
2556
+ },
2557
+ {
2558
+ path: ".codex/.gitkeep",
2559
+ contents: ""
2560
+ },
2561
+ {
2562
+ path: ".gitignore",
2563
+ contents: "# OKF Harness generated caches\n.okfh/cache/\n.okfh/*.sqlite\n.okfh/backlinks.json\n.okfh/reports/graph.html\n.okfh/reports/*.tmp\n\n# OS\n.DS_Store\n\n# Secrets\n.env\n.env.*\n!.env.example\n"
2564
+ },
2565
+ {
2566
+ path: ".okfh/cache/.gitkeep",
2567
+ contents: ""
2568
+ },
2569
+ {
2570
+ path: ".okfh/manifest.jsonl",
2571
+ contents: ""
2572
+ },
2573
+ {
2574
+ path: ".okfh/reports/.gitkeep",
2575
+ contents: ""
2576
+ },
2577
+ {
2578
+ path: "okfh.config.yaml",
2579
+ contents: stringifyYaml(config)
2580
+ },
2581
+ {
2582
+ path: "raw/assets/README.md",
2583
+ contents: "# Assets\n\nStore local assets referenced by wiki pages here.\n"
2584
+ },
2585
+ {
2586
+ path: "raw/inbox/README.md",
2587
+ contents: "# Inbox\n\nDrop unregistered source material here before adding it to OKF Harness.\n"
2588
+ },
2589
+ {
2590
+ path: "raw/sources/README.md",
2591
+ contents: "# Sources\n\nRegistered raw sources live here and should not be edited in place.\n"
2592
+ },
2593
+ {
2594
+ path: "wiki/index.md",
2595
+ contents: `# ${name} Wiki
2596
+
2597
+ ## Concepts
2598
+
2599
+ - [Topics](/topics/index.md)
2600
+ - [References](/references/index.md)
2601
+ `
2602
+ },
2603
+ {
2604
+ path: "wiki/log.md",
2605
+ contents: `# Log
2606
+
2607
+ ## ${logDate}
2608
+
2609
+ - Initialized the OKF Harness workspace.
2610
+ `
2611
+ },
2612
+ ...workspaceIndexFiles([
2613
+ "decisions",
2614
+ "entities",
2615
+ "projects",
2616
+ "questions",
2617
+ "references",
2618
+ "topics"
2619
+ ])
2620
+ ].map((file) => ({
2621
+ path: toPosixRelativePath(".", file.path),
2622
+ contents: file.contents
2623
+ }));
2624
+ }
2625
+ function workspaceIndexFiles(directories) {
2626
+ return directories.map((directory) => ({
2627
+ path: `wiki/${directory}/index.md`,
2628
+ contents: `# ${titleCase(directory)}
2629
+
2630
+ No entries yet.
2631
+ `
2632
+ }));
2633
+ }
2634
+ async function writeTextFile(filePath, contents) {
2635
+ await mkdir3(path9.dirname(filePath), { recursive: true });
2636
+ await writeFile3(filePath, contents, "utf8");
2637
+ }
2638
+ function titleCase(input) {
2639
+ return input.split("-").map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`).join(" ");
2640
+ }
2641
+ function errorCode5(error) {
2642
+ if (typeof error !== "object" || error === null || !("code" in error)) {
2643
+ return void 0;
2644
+ }
2645
+ const code = error.code;
2646
+ return typeof code === "string" ? code : void 0;
2647
+ }
2648
+
2649
+ // src/index.ts
2650
+ var packageInfo = {
2651
+ name: "@okf-harness/core",
2652
+ role: "core"
2653
+ };
2654
+ export {
2655
+ AMBIGUOUS_SECTION,
2656
+ BROKEN_LINK,
2657
+ CONFIG_INVALID,
2658
+ ConceptScanError,
2659
+ GRAPH_WRITE_FAILED,
2660
+ GraphWorkspaceError,
2661
+ INVALID_TARGET,
2662
+ LOG_INVALID_DATE_HEADING,
2663
+ MANIFEST_INVALID,
2664
+ MISSING_CITATIONS_SECTION,
2665
+ MISSING_INDEX_ENTRY,
2666
+ NON_MARKDOWN_TARGET,
2667
+ NON_UTF8_TARGET,
2668
+ OKF_INVALID_FRONTMATTER,
2669
+ OKF_MISSING_FRONTMATTER,
2670
+ OKF_MISSING_TYPE,
2671
+ PATH_OUTSIDE_WORKSPACE,
2672
+ READ_LIMIT_EXCEEDED,
2673
+ REFERENCE_SOURCE_MISSING,
2674
+ RESERVED_FILE_HAS_CONCEPT_FRONTMATTER,
2675
+ RESERVED_OKF_FILENAMES,
2676
+ ReadWorkspaceError,
2677
+ SCAN_FAILED,
2678
+ SOURCE_HASH_DRIFT,
2679
+ SOURCE_INPUT_NOT_FOUND,
2680
+ SOURCE_INPUT_UNSUPPORTED,
2681
+ SOURCE_MISSING,
2682
+ SOURCE_NOT_REGISTERED,
2683
+ SOURCE_REGISTRATION_FAILED,
2684
+ SourceManagementError,
2685
+ TARGET_NOT_FOUND,
2686
+ WORKSPACE_NOT_FOUND,
2687
+ WorkspaceConfigError,
2688
+ WorkspaceInitError,
2689
+ WorkspacePathError,
2690
+ WorkspaceResolutionError,
2691
+ addSource,
2692
+ buildWorkspaceGraph,
2693
+ conceptIdFromPath,
2694
+ createIngestPlan,
2695
+ createWorkspacePlan,
2696
+ initWorkspace,
2697
+ isReservedOkfFile,
2698
+ lintWorkspace,
2699
+ listSources,
2700
+ loadWorkspaceConfig,
2701
+ packageInfo,
2702
+ parseMarkdownFrontmatter,
2703
+ parseMarkdownLinks,
2704
+ parseWorkspaceConfig,
2705
+ readSourceManifest,
2706
+ readWorkspaceConfig,
2707
+ readWorkspaceDocument,
2708
+ readWorkspaceStatus,
2709
+ resolveOkfLinkTarget,
2710
+ resolveWorkspaceRoot,
2711
+ safeResolveWorkspacePath,
2712
+ scanConcepts,
2713
+ searchWorkspace,
2714
+ toPosixPath,
2715
+ toPosixRelativePath,
2716
+ workspaceConfigSchema
2717
+ };