tgo-wiki 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +255 -0
  3. package/docs/mcp-usage.md +631 -0
  4. package/docs/v0-acceptance.md +105 -0
  5. package/docs/v0-delivery-checklist.md +57 -0
  6. package/docs/v1-acceptance.md +39 -0
  7. package/docs/v2-acceptance.md +165 -0
  8. package/package.json +69 -0
  9. package/packages/core/src/config/config-loader.ts +109 -0
  10. package/packages/core/src/config/defaults.ts +74 -0
  11. package/packages/core/src/config/workspace-resolver.ts +40 -0
  12. package/packages/core/src/documents/command-document-parser.ts +206 -0
  13. package/packages/core/src/documents/document-id.ts +26 -0
  14. package/packages/core/src/documents/document-parser-registry.ts +126 -0
  15. package/packages/core/src/documents/document-service.ts +656 -0
  16. package/packages/core/src/documents/document-store.ts +132 -0
  17. package/packages/core/src/documents/document-types.ts +33 -0
  18. package/packages/core/src/documents/pdf-text-parser.ts +35 -0
  19. package/packages/core/src/documents/text-markdown-parser.ts +50 -0
  20. package/packages/core/src/errors.ts +46 -0
  21. package/packages/core/src/git/git-service.ts +68 -0
  22. package/packages/core/src/index.ts +38 -0
  23. package/packages/core/src/markdown/markdown-scanner.ts +90 -0
  24. package/packages/core/src/permissions/permission-service.ts +50 -0
  25. package/packages/core/src/publish/publish-service.ts +142 -0
  26. package/packages/core/src/result.ts +13 -0
  27. package/packages/core/src/services/session-workflow-service.ts +493 -0
  28. package/packages/core/src/services/wiki-service.ts +119 -0
  29. package/packages/core/src/services/workspace-service.ts +223 -0
  30. package/packages/core/src/session/session-id.ts +14 -0
  31. package/packages/core/src/session/session-service.ts +77 -0
  32. package/packages/core/src/session/session-store.ts +91 -0
  33. package/packages/core/src/session/session-types.ts +17 -0
  34. package/packages/core/src/sources/source-id.ts +19 -0
  35. package/packages/core/src/sources/source-paths.ts +15 -0
  36. package/packages/core/src/sources/source-service.ts +416 -0
  37. package/packages/core/src/sources/source-types.ts +77 -0
  38. package/packages/core/src/sources/source-validator.ts +132 -0
  39. package/packages/core/src/sources/source-writer.ts +419 -0
  40. package/packages/core/src/validation/frontmatter-validator.ts +128 -0
  41. package/packages/core/src/validation/link-validator.ts +55 -0
  42. package/packages/core/src/validation/path-validator.ts +65 -0
  43. package/packages/core/src/validation/source-reference-validator.ts +191 -0
  44. package/packages/core/src/validation/validation-service.ts +106 -0
  45. package/packages/core/src/vfs/vfs-command-parser.ts +69 -0
  46. package/packages/core/src/vfs/vfs-service.ts +498 -0
  47. package/packages/core/src/web/html-to-markdown.ts +144 -0
  48. package/packages/core/src/web/static-web-fetcher.ts +537 -0
  49. package/packages/core/src/web/web-id.ts +26 -0
  50. package/packages/core/src/web/web-ingestion-service.ts +335 -0
  51. package/packages/core/src/web/web-paths.ts +6 -0
  52. package/packages/core/src/web/web-types.ts +33 -0
  53. package/packages/server/src/cli.ts +56 -0
  54. package/packages/server/src/context.ts +7 -0
  55. package/packages/server/src/index.ts +2 -0
  56. package/packages/server/src/mcp-server.ts +111 -0
  57. package/packages/server/src/schemas/documents.ts +17 -0
  58. package/packages/server/src/schemas/read.ts +16 -0
  59. package/packages/server/src/schemas/session.ts +31 -0
  60. package/packages/server/src/schemas/sources.ts +12 -0
  61. package/packages/server/src/schemas/web.ts +23 -0
  62. package/packages/server/src/tools/document-tools.ts +46 -0
  63. package/packages/server/src/tools/publish-tools.ts +33 -0
  64. package/packages/server/src/tools/read-tools.ts +52 -0
  65. package/packages/server/src/tools/response.ts +24 -0
  66. package/packages/server/src/tools/session-tools.ts +100 -0
  67. package/packages/server/src/tools/source-tools.ts +32 -0
  68. package/packages/server/src/tools/web-tools.ts +26 -0
@@ -0,0 +1,132 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { link, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { WorkspacePaths } from "../config/workspace-resolver.js";
5
+ import { WikiError } from "../errors.js";
6
+ import { assertValidDocumentId } from "./document-id.js";
7
+ import type { PendingDocumentMetadata } from "./document-types.js";
8
+
9
+ export class DocumentStore {
10
+ constructor(private readonly paths: WorkspacePaths) {}
11
+
12
+ async writeBlobIfAbsent(blobSha256: string, content: Buffer): Promise<void> {
13
+ assertValidSha256(blobSha256);
14
+ assertContentMatchesSha256(content, blobSha256);
15
+ await mkdir(this.paths.documentBlobsPath, { recursive: true });
16
+ const blobPath = this.blobPath(blobSha256);
17
+
18
+ try {
19
+ await writeAtomicallyNoOverwrite(this.paths.documentBlobsPath, blobPath, content);
20
+ } catch (error) {
21
+ if (isFileExists(error)) {
22
+ await assertExistingBlobMatches(blobPath, blobSha256);
23
+ return;
24
+ }
25
+
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ async writePending(metadata: PendingDocumentMetadata): Promise<void> {
31
+ assertValidDocumentId(metadata.documentId);
32
+ await mkdir(this.paths.pendingDocumentsPath, { recursive: true });
33
+ const pendingPath = this.pendingPath(metadata.documentId);
34
+ const content = Buffer.from(`${JSON.stringify(metadata, null, 2)}\n`, "utf8");
35
+
36
+ try {
37
+ await writeAtomicallyNoOverwrite(this.paths.pendingDocumentsPath, pendingPath, content);
38
+ } catch (error) {
39
+ if (isFileExists(error)) {
40
+ throw new WikiError("source_exists", `Pending document metadata already exists: ${metadata.documentId}`);
41
+ }
42
+
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ blobPath(blobSha256: string): string {
48
+ assertValidSha256(blobSha256);
49
+ return path.join(this.paths.documentBlobsPath, blobSha256);
50
+ }
51
+
52
+ pendingPath(documentId: string): string {
53
+ assertValidDocumentId(documentId);
54
+ return path.join(this.paths.pendingDocumentsPath, `${documentId}.json`);
55
+ }
56
+ }
57
+
58
+ function assertValidSha256(value: string): void {
59
+ if (!/^[0-9a-f]{64}$/.test(value)) {
60
+ throw new WikiError("validation_failed", `Invalid sha256: ${value}`);
61
+ }
62
+ }
63
+
64
+ function assertContentMatchesSha256(content: Buffer, expected: string): void {
65
+ const actual = createHash("sha256").update(content).digest("hex");
66
+
67
+ if (actual !== expected) {
68
+ throw new WikiError("validation_failed", `Blob content does not match sha256: ${expected}`, {
69
+ expected,
70
+ actual
71
+ });
72
+ }
73
+ }
74
+
75
+ async function writeAtomicallyNoOverwrite(directory: string, finalPath: string, content: Buffer): Promise<void> {
76
+ const tempPath = await writeUniqueTempFile(directory, path.basename(finalPath), content);
77
+
78
+ try {
79
+ await link(tempPath, finalPath);
80
+ } finally {
81
+ await unlinkIfExists(tempPath);
82
+ }
83
+ }
84
+
85
+ async function writeUniqueTempFile(directory: string, finalBaseName: string, content: Buffer): Promise<string> {
86
+ for (let attempt = 0; attempt < 10; attempt += 1) {
87
+ const tempPath = path.join(directory, `.${finalBaseName}.${randomBytes(8).toString("hex")}.tmp`);
88
+
89
+ try {
90
+ await writeFile(tempPath, content, { flag: "wx" });
91
+ return tempPath;
92
+ } catch (error) {
93
+ if (isFileExists(error)) {
94
+ continue;
95
+ }
96
+
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ throw new WikiError("validation_failed", `Could not create unique temp file for: ${finalBaseName}`);
102
+ }
103
+
104
+ async function assertExistingBlobMatches(blobPath: string, blobSha256: string): Promise<void> {
105
+ const existing = await readFile(blobPath);
106
+ const actual = createHash("sha256").update(existing).digest("hex");
107
+
108
+ if (actual !== blobSha256) {
109
+ throw new WikiError("validation_failed", `Existing blob content does not match sha256 file name: ${blobSha256}`, {
110
+ expected: blobSha256,
111
+ actual
112
+ });
113
+ }
114
+ }
115
+
116
+ async function unlinkIfExists(filePath: string): Promise<void> {
117
+ try {
118
+ await unlink(filePath);
119
+ } catch (error) {
120
+ if (!isEnoent(error)) {
121
+ throw error;
122
+ }
123
+ }
124
+ }
125
+
126
+ function isFileExists(error: unknown): boolean {
127
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "EEXIST");
128
+ }
129
+
130
+ function isEnoent(error: unknown): boolean {
131
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
132
+ }
@@ -0,0 +1,33 @@
1
+ export type DocumentIdGenerator = () => string;
2
+
3
+ export type DocumentSourceType = "pdf" | "markdown" | "text";
4
+
5
+ export type PendingDocumentStatus = "uploaded";
6
+
7
+ export type DocumentUploadInput = {
8
+ sessionId: string;
9
+ fileName: string;
10
+ mimeType: string;
11
+ contentBase64: string;
12
+ createdBy?: string;
13
+ };
14
+
15
+ export type DocumentUploadResult = {
16
+ document_id: string;
17
+ blob_sha256: string;
18
+ size: number;
19
+ status: PendingDocumentStatus;
20
+ };
21
+
22
+ export type PendingDocumentMetadata = {
23
+ documentId: string;
24
+ sessionId: string;
25
+ sourceType: DocumentSourceType;
26
+ originalFileName: string;
27
+ mimeType: string;
28
+ blobSha256: string;
29
+ createdAt: string;
30
+ updatedAt: string;
31
+ createdBy?: string;
32
+ status: PendingDocumentStatus;
33
+ };
@@ -0,0 +1,35 @@
1
+ import { PDFParse } from "pdf-parse";
2
+ import { WikiError, toWikiError } from "../errors.js";
3
+ import type { DocumentParseInput, DocumentParseOutput, DocumentParser } from "./document-parser-registry.js";
4
+
5
+ export class PdfTextParser implements DocumentParser {
6
+ readonly name = "pdf-text";
7
+ readonly version = "1.0.0";
8
+ readonly supportedMimeTypes = ["application/pdf"];
9
+
10
+ async parse(input: DocumentParseInput): Promise<DocumentParseOutput> {
11
+ try {
12
+ const pdf = new PDFParse({ data: input.content });
13
+ try {
14
+ const extracted = await pdf.getText();
15
+ const text = extracted.text.trim();
16
+
17
+ return {
18
+ markdown: `# ${input.fileName}\n\n> Source document parsed from PDF.\n> Parser: pdf-text\n> Parsed at: ${input.parsedAt}\n\n${text}\n`,
19
+ assets: [],
20
+ warnings: [],
21
+ metadata: { pages: extracted.total }
22
+ };
23
+ } finally {
24
+ await pdf.destroy();
25
+ }
26
+ } catch (error) {
27
+ const wikiError = toWikiError(error, "parse_failed");
28
+ if (wikiError.code === "parse_failed") {
29
+ throw wikiError;
30
+ }
31
+
32
+ throw new WikiError("parse_failed", wikiError.message, wikiError.details);
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,50 @@
1
+ import type { DocumentParseInput, DocumentParseOutput, DocumentParser } from "./document-parser-registry.js";
2
+
3
+ export class TextMarkdownParser implements DocumentParser {
4
+ readonly name = "text-markdown";
5
+ readonly version = "1.0.0";
6
+ readonly supportedMimeTypes = ["text/markdown", "text/x-markdown", "text/plain"];
7
+
8
+ async parse(input: DocumentParseInput): Promise<DocumentParseOutput> {
9
+ const text = stripUtf8Bom(input.content.toString("utf8"));
10
+ const markdown = input.mimeType === "text/plain" ? plainTextMarkdown(input.fileName, text, input.parsedAt) : ensureFinalNewline(text);
11
+
12
+ return {
13
+ markdown,
14
+ assets: [],
15
+ warnings: [],
16
+ metadata: {
17
+ characters: text.length,
18
+ lines: countLines(text)
19
+ }
20
+ };
21
+ }
22
+ }
23
+
24
+ function plainTextMarkdown(fileName: string, text: string, parsedAt: string): string {
25
+ return [
26
+ `# ${fileName}`,
27
+ "",
28
+ "> Source document parsed from plain text.",
29
+ "> Parser: text-markdown",
30
+ `> Parsed at: ${parsedAt}`,
31
+ "",
32
+ ensureFinalNewline(text).trimEnd()
33
+ ].join("\n") + "\n";
34
+ }
35
+
36
+ function ensureFinalNewline(value: string): string {
37
+ return value.endsWith("\n") ? value : `${value}\n`;
38
+ }
39
+
40
+ function stripUtf8Bom(value: string): string {
41
+ return value.startsWith("\uFEFF") ? value.slice(1) : value;
42
+ }
43
+
44
+ function countLines(value: string): number {
45
+ if (value.length === 0) {
46
+ return 0;
47
+ }
48
+
49
+ return value.endsWith("\n") ? value.slice(0, -1).split("\n").length : value.split("\n").length;
50
+ }
@@ -0,0 +1,46 @@
1
+ export type WikiErrorCode =
2
+ | "permission_denied"
3
+ | "invalid_path"
4
+ | "invalid_frontmatter"
5
+ | "validation_failed"
6
+ | "session_not_found"
7
+ | "session_metadata_invalid"
8
+ | "session_dirty"
9
+ | "needs_rebase"
10
+ | "unsupported_channel"
11
+ | "git_error"
12
+ | "vfs_command_denied"
13
+ | "document_not_found"
14
+ | "blob_not_found"
15
+ | "parser_not_supported"
16
+ | "parse_failed"
17
+ | "invalid_url"
18
+ | "fetch_failed"
19
+ | "fetch_timeout"
20
+ | "response_too_large"
21
+ | "source_exists"
22
+ | "source_write_denied";
23
+
24
+ export class WikiError extends Error {
25
+ readonly code: WikiErrorCode;
26
+ readonly details: Record<string, unknown>;
27
+
28
+ constructor(code: WikiErrorCode, message: string, details: Record<string, unknown> = {}) {
29
+ super(message);
30
+ this.name = "WikiError";
31
+ this.code = code;
32
+ this.details = details;
33
+ }
34
+ }
35
+
36
+ export function toWikiError(error: unknown, fallbackCode: WikiErrorCode): WikiError {
37
+ if (error instanceof WikiError) {
38
+ return error;
39
+ }
40
+
41
+ if (error instanceof Error) {
42
+ return new WikiError(fallbackCode, error.message);
43
+ }
44
+
45
+ return new WikiError(fallbackCode, String(error));
46
+ }
@@ -0,0 +1,68 @@
1
+ import { spawn } from "node:child_process";
2
+ import { WikiError } from "../errors.js";
3
+
4
+ export type GitRunResult = {
5
+ stdout: string;
6
+ stderr: string;
7
+ exitCode: number;
8
+ };
9
+
10
+ export class GitService {
11
+ async run(args: string[], options: { cwd: string }): Promise<GitRunResult> {
12
+ return new Promise((resolve, reject) => {
13
+ const child = spawn("git", args, {
14
+ cwd: options.cwd,
15
+ stdio: ["ignore", "pipe", "pipe"]
16
+ });
17
+
18
+ let stdout = "";
19
+ let stderr = "";
20
+
21
+ child.stdout.setEncoding("utf8");
22
+ child.stderr.setEncoding("utf8");
23
+ child.stdout.on("data", chunk => {
24
+ stdout += chunk;
25
+ });
26
+ child.stderr.on("data", chunk => {
27
+ stderr += chunk;
28
+ });
29
+ child.on("error", error => {
30
+ reject(new WikiError("git_error", error.message, { args }));
31
+ });
32
+ child.on("close", exitCode => {
33
+ const code = exitCode ?? 1;
34
+ const result = { stdout, stderr, exitCode: code };
35
+
36
+ if (code !== 0) {
37
+ reject(
38
+ new WikiError("git_error", stderr.trim() || stdout.trim() || "git command failed", {
39
+ args,
40
+ exitCode: code
41
+ })
42
+ );
43
+ return;
44
+ }
45
+
46
+ resolve(result);
47
+ });
48
+ });
49
+ }
50
+
51
+ async commit(cwd: string, message: string, pathspecs = ["wiki"]): Promise<string> {
52
+ await this.run(["add", "--", ...stagingPathspecs(pathspecs)], { cwd });
53
+ await this.run(
54
+ ["-c", "user.name=TGO Wiki", "-c", "user.email=tgo-wiki@example.invalid", "commit", "-m", message],
55
+ { cwd }
56
+ );
57
+ const rev = await this.run(["rev-parse", "HEAD"], { cwd });
58
+ return rev.stdout.trim();
59
+ }
60
+ }
61
+
62
+ function stagingPathspecs(pathspecs: string[]): string[] {
63
+ if (pathspecs.length === 1 && pathspecs[0] === "wiki") {
64
+ return pathspecs;
65
+ }
66
+
67
+ return pathspecs.map(pathspec => `:(literal)${pathspec}`);
68
+ }
@@ -0,0 +1,38 @@
1
+ export * from "./errors.js";
2
+ export * from "./result.js";
3
+ export * from "./config/defaults.js";
4
+ export * from "./config/workspace-resolver.js";
5
+ export * from "./config/config-loader.js";
6
+ export * from "./git/git-service.js";
7
+ export * from "./services/workspace-service.js";
8
+ export * from "./services/wiki-service.js";
9
+ export * from "./services/session-workflow-service.js";
10
+ export * from "./validation/path-validator.js";
11
+ export * from "./validation/frontmatter-validator.js";
12
+ export * from "./validation/validation-service.js";
13
+ export * from "./session/session-types.js";
14
+ export * from "./session/session-id.js";
15
+ export * from "./session/session-store.js";
16
+ export * from "./session/session-service.js";
17
+ export * from "./vfs/vfs-command-parser.js";
18
+ export * from "./vfs/vfs-service.js";
19
+ export * from "./permissions/permission-service.js";
20
+ export * from "./publish/publish-service.js";
21
+ export * from "./sources/source-id.js";
22
+ export * from "./sources/source-paths.js";
23
+ export * from "./sources/source-types.js";
24
+ export * from "./sources/source-validator.js";
25
+ export * from "./sources/source-service.js";
26
+ export * from "./documents/document-id.js";
27
+ export * from "./documents/document-types.js";
28
+ export * from "./documents/document-store.js";
29
+ export * from "./documents/document-service.js";
30
+ export * from "./documents/command-document-parser.js";
31
+ export * from "./documents/pdf-text-parser.js";
32
+ export * from "./documents/text-markdown-parser.js";
33
+ export * from "./web/web-types.js";
34
+ export * from "./web/web-id.js";
35
+ export * from "./web/web-paths.js";
36
+ export * from "./web/html-to-markdown.js";
37
+ export * from "./web/static-web-fetcher.js";
38
+ export * from "./web/web-ingestion-service.js";
@@ -0,0 +1,90 @@
1
+ export type MarkdownLink = {
2
+ kind: "markdown" | "wiki";
3
+ target: string;
4
+ raw: string;
5
+ };
6
+
7
+ export function firstH1(content: string): string | undefined {
8
+ for (const line of nonFencedLines(content)) {
9
+ const match = /^#\s+(.+?)\s*$/.exec(line);
10
+
11
+ if (match) {
12
+ return match[1]?.replace(/\s+#+$/, "").trim();
13
+ }
14
+ }
15
+
16
+ return undefined;
17
+ }
18
+
19
+ export function findMarkdownLinks(content: string): MarkdownLink[] {
20
+ const links: MarkdownLink[] = [];
21
+ const markdownLinkPattern = /(?<!!)\[[^\]]+\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
22
+ const wikiLinkPattern = /\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]/g;
23
+ const searchableContent = nonFencedLines(content).join("\n");
24
+
25
+ for (const match of searchableContent.matchAll(markdownLinkPattern)) {
26
+ const raw = match[0];
27
+ const target = match[1]?.trim();
28
+
29
+ if (!target || isIgnoredMarkdownTarget(target) || isEscaped(searchableContent, match.index ?? 0)) {
30
+ continue;
31
+ }
32
+
33
+ links.push({ kind: "markdown", target, raw });
34
+ }
35
+
36
+ for (const match of searchableContent.matchAll(wikiLinkPattern)) {
37
+ const raw = match[0];
38
+ const target = normalizeWikiLinkTarget(match[1] ?? "");
39
+
40
+ if (!target || isEscaped(searchableContent, match.index ?? 0)) {
41
+ continue;
42
+ }
43
+
44
+ links.push({ kind: "wiki", target, raw });
45
+ }
46
+
47
+ return links;
48
+ }
49
+
50
+ function isIgnoredMarkdownTarget(target: string): boolean {
51
+ return target.startsWith("#") || /^https?:\/\//i.test(target);
52
+ }
53
+
54
+ function normalizeWikiLinkTarget(target: string): string | undefined {
55
+ const normalized = target.trim().replaceAll("\\", "/").replace(/^\/+/, "");
56
+
57
+ if (!normalized) {
58
+ return undefined;
59
+ }
60
+
61
+ return normalized.endsWith(".md") ? `wiki/${normalized}` : `wiki/${normalized}.md`;
62
+ }
63
+
64
+ function nonFencedLines(content: string): string[] {
65
+ const lines: string[] = [];
66
+ let inFence = false;
67
+
68
+ for (const line of content.split(/\r?\n/)) {
69
+ if (/^\s*(```|~~~)/.test(line)) {
70
+ inFence = !inFence;
71
+ continue;
72
+ }
73
+
74
+ if (!inFence) {
75
+ lines.push(line);
76
+ }
77
+ }
78
+
79
+ return lines;
80
+ }
81
+
82
+ function isEscaped(content: string, index: number): boolean {
83
+ let slashCount = 0;
84
+
85
+ for (let cursor = index - 1; cursor >= 0 && content[cursor] === "\\"; cursor -= 1) {
86
+ slashCount += 1;
87
+ }
88
+
89
+ return slashCount % 2 === 1;
90
+ }
@@ -0,0 +1,50 @@
1
+ import { WikiError } from "../errors.js";
2
+
3
+ export type Role = "reader" | "writer" | "publisher" | "admin";
4
+
5
+ export type ToolName =
6
+ | "vfs_exec"
7
+ | "wiki_read"
8
+ | "wiki_channel_status"
9
+ | "wiki_session_start"
10
+ | "wiki_session_patch"
11
+ | "wiki_session_validate"
12
+ | "wiki_session_diff"
13
+ | "wiki_session_commit"
14
+ | "document_upload"
15
+ | "document_parse"
16
+ | "web_fetch"
17
+ | "source_list"
18
+ | "source_read"
19
+ | "wiki_publish_session";
20
+
21
+ const readerTools = new Set<ToolName>(["vfs_exec", "wiki_read", "wiki_channel_status", "source_list", "source_read"]);
22
+ const writerTools = new Set<ToolName>([
23
+ ...readerTools,
24
+ "wiki_session_start",
25
+ "wiki_session_patch",
26
+ "wiki_session_validate",
27
+ "wiki_session_diff",
28
+ "wiki_session_commit",
29
+ "document_upload",
30
+ "document_parse"
31
+ ]);
32
+ const publisherTools = new Set<ToolName>([...writerTools, "web_fetch", "wiki_publish_session"]);
33
+
34
+ export function assertCanUseTool(role: Role, toolName: ToolName): void {
35
+ const allowed =
36
+ role === "reader"
37
+ ? readerTools
38
+ : role === "writer"
39
+ ? writerTools
40
+ : role === "publisher" || role === "admin"
41
+ ? publisherTools
42
+ : null;
43
+ if (allowed === null) {
44
+ throw new WikiError("permission_denied", `${role} cannot use ${toolName}`);
45
+ }
46
+
47
+ if (!allowed.has(toolName)) {
48
+ throw new WikiError("permission_denied", `${role} cannot use ${toolName}`);
49
+ }
50
+ }
@@ -0,0 +1,142 @@
1
+ import path from "node:path";
2
+ import { resolveWorkspacePaths, type WorkspacePaths } from "../config/workspace-resolver.js";
3
+ import { WikiError, toWikiError } from "../errors.js";
4
+ import { GitService } from "../git/git-service.js";
5
+ import { err, ok, type Result } from "../result.js";
6
+ import { SessionWorkflowService } from "../services/session-workflow-service.js";
7
+ import { SessionStore } from "../session/session-store.js";
8
+ import type { SessionMetadata } from "../session/session-types.js";
9
+
10
+ export type PublishInput = {
11
+ sessionId: string;
12
+ channel?: "stable" | string;
13
+ mode?: "ff-only" | string;
14
+ };
15
+
16
+ export type PublishResult = {
17
+ ok: true;
18
+ channel: "stable";
19
+ previous_commit: string;
20
+ published_commit: string;
21
+ };
22
+
23
+ export class PublishService {
24
+ private readonly paths: WorkspacePaths;
25
+ private readonly store: SessionStore;
26
+ private readonly workflow: SessionWorkflowService;
27
+
28
+ constructor(
29
+ workspaceRoot: string,
30
+ private readonly git = new GitService()
31
+ ) {
32
+ this.paths = resolveWorkspacePaths(workspaceRoot);
33
+ this.store = new SessionStore(this.paths);
34
+ this.workflow = new SessionWorkflowService(workspaceRoot, undefined, git);
35
+ }
36
+
37
+ async publish(input: PublishInput): Promise<Result<PublishResult>> {
38
+ try {
39
+ if ((input.channel ?? "stable") !== "stable") {
40
+ return err(new WikiError("unsupported_channel", `Unsupported publish channel: ${input.channel}`));
41
+ }
42
+
43
+ if ((input.mode ?? "ff-only") !== "ff-only") {
44
+ return err(new WikiError("unsupported_channel", `Unsupported publish mode: ${input.mode}`));
45
+ }
46
+
47
+ const metadata = await this.store.read(input.sessionId);
48
+
49
+ if (metadata.status !== "open") {
50
+ return err(new WikiError("session_dirty", `Session is not open: ${input.sessionId}`, { status: metadata.status }));
51
+ }
52
+
53
+ if (!metadata.lastCommit) {
54
+ return err(new WikiError("session_dirty", `Session has no committed changes: ${input.sessionId}`));
55
+ }
56
+
57
+ const sessionWorktree = this.sessionWorktreeRoot(metadata);
58
+ const status = await this.git.run(["status", "--porcelain"], { cwd: sessionWorktree });
59
+
60
+ if (status.stdout.trim().length > 0) {
61
+ return err(new WikiError("session_dirty", "Session has uncommitted changes"));
62
+ }
63
+
64
+ const branchHead = (await this.git.run(["rev-parse", metadata.branch], { cwd: this.paths.repoPath })).stdout.trim();
65
+
66
+ if (branchHead !== metadata.lastCommit) {
67
+ return err(
68
+ new WikiError("session_dirty", "Session branch head differs from last committed session metadata", {
69
+ branch: metadata.branch,
70
+ branchHead,
71
+ lastCommit: metadata.lastCommit
72
+ })
73
+ );
74
+ }
75
+
76
+ const validation = await this.workflow.validate({ sessionId: input.sessionId });
77
+
78
+ if (!validation.ok) {
79
+ return validation;
80
+ }
81
+
82
+ if (!validation.value.ok) {
83
+ return err(new WikiError("validation_failed", "Session validation failed", { validation: validation.value }));
84
+ }
85
+
86
+ const stableStatus = await this.git.run(["status", "--porcelain"], { cwd: this.paths.stableWorktreePath });
87
+
88
+ if (stableStatus.stdout.trim().length > 0) {
89
+ return err(new WikiError("session_dirty", "Stable worktree has uncommitted changes"));
90
+ }
91
+
92
+ const previousCommit = (await this.git.run(["rev-parse", "HEAD"], { cwd: this.paths.stableWorktreePath })).stdout.trim();
93
+
94
+ try {
95
+ await this.git.run(["merge", "--ff-only", metadata.lastCommit], { cwd: this.paths.stableWorktreePath });
96
+ } catch (error) {
97
+ if (isFastForwardFailure(error)) {
98
+ return err(new WikiError("needs_rebase", "Stable has advanced; rebase the session before publishing"));
99
+ }
100
+
101
+ throw error;
102
+ }
103
+
104
+ const publishedCommit = (await this.git.run(["rev-parse", "HEAD"], { cwd: this.paths.stableWorktreePath })).stdout.trim();
105
+
106
+ await this.git.run(["worktree", "remove", sessionWorktree], { cwd: this.paths.repoPath });
107
+
108
+ const now = new Date().toISOString();
109
+ await this.store.update(input.sessionId, current => ({
110
+ ...current,
111
+ status: "published",
112
+ publishedAt: now,
113
+ publishedCommit,
114
+ updatedAt: now
115
+ }));
116
+
117
+ return ok({
118
+ ok: true,
119
+ channel: "stable",
120
+ previous_commit: previousCommit,
121
+ published_commit: publishedCommit
122
+ });
123
+ } catch (error) {
124
+ return err(toWikiError(error, "git_error"));
125
+ }
126
+ }
127
+
128
+ private sessionWorktreeRoot(metadata: SessionMetadata): string {
129
+ const absolute = path.resolve(this.paths.workspaceRoot, metadata.worktree);
130
+ const sessionsRoot = path.resolve(this.paths.sessionsWorktreePath);
131
+
132
+ if (!absolute.startsWith(`${sessionsRoot}${path.sep}`) && absolute !== sessionsRoot) {
133
+ throw new WikiError("session_metadata_invalid", `Session worktree escapes sessions root: ${metadata.sessionId}`);
134
+ }
135
+
136
+ return absolute;
137
+ }
138
+ }
139
+
140
+ function isFastForwardFailure(error: unknown): boolean {
141
+ return error instanceof WikiError && /fast-forward|Not possible to fast-forward/i.test(error.message);
142
+ }