@ferrule-io/ok-fine 0.3.11 → 0.3.13
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/auth/verifier.js +14 -8
- package/dist/config.js +104 -1
- package/dist/http/rest.js +30 -13
- package/dist/local-host.js +1 -0
- package/dist/mcp/server.js +38 -18
- package/dist/okf/concept.js +5 -1
- package/dist/okf/lint.js +15 -1
- package/dist/okf/log-file.js +3 -0
- package/dist/okf/markdown.js +83 -1
- package/dist/okf/semantics.js +10 -0
- package/dist/service/knowledge-service.js +435 -65
- package/dist/store/bundle.js +2 -1
- package/dist/store/catalog.js +81 -24
- package/dist/ui/assets/{highlight-DkgVr4Tn.js → highlight-CpEbRnLL.js} +1 -1
- package/dist/ui/assets/index-BjnLNjEO.css +1 -0
- package/dist/ui/assets/{index-C2IYVK_7.js → index-DElMMN0L.js} +33 -33
- package/dist/ui/index.html +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/index-CFuTAZQM.css +0 -1
package/dist/auth/verifier.js
CHANGED
|
@@ -97,14 +97,7 @@ export class JwtTokenVerifier {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
if (requiredGroups.length > 0) {
|
|
100
|
-
const
|
|
101
|
-
let tokenGroups = [];
|
|
102
|
-
if (typeof groupsVal === "string") {
|
|
103
|
-
tokenGroups = [groupsVal];
|
|
104
|
-
}
|
|
105
|
-
else if (Array.isArray(groupsVal)) {
|
|
106
|
-
tokenGroups = groupsVal.filter((g) => typeof g === "string");
|
|
107
|
-
}
|
|
100
|
+
const tokenGroups = readGroups(payload[groupsClaim]);
|
|
108
101
|
const hasRequiredGroup = tokenGroups.some((g) => requiredGroups.includes(g));
|
|
109
102
|
if (!hasRequiredGroup) {
|
|
110
103
|
throw new OAuthError(OAuthErrorCode.InsufficientScope, "token not permitted by this server's access policy");
|
|
@@ -133,6 +126,7 @@ export class JwtTokenVerifier {
|
|
|
133
126
|
break;
|
|
134
127
|
}
|
|
135
128
|
}
|
|
129
|
+
const groups = readGroups(payload[this.access?.groupsClaim ?? "groups"]);
|
|
136
130
|
return {
|
|
137
131
|
token,
|
|
138
132
|
clientId,
|
|
@@ -141,10 +135,19 @@ export class JwtTokenVerifier {
|
|
|
141
135
|
extra: {
|
|
142
136
|
sub: typeof payload.sub === "string" ? payload.sub : "unknown",
|
|
143
137
|
identity,
|
|
138
|
+
groups,
|
|
144
139
|
},
|
|
145
140
|
};
|
|
146
141
|
}
|
|
147
142
|
}
|
|
143
|
+
/** Normalizes a groups claim, accepted as a string or an array of strings. */
|
|
144
|
+
function readGroups(value) {
|
|
145
|
+
if (typeof value === "string")
|
|
146
|
+
return [value];
|
|
147
|
+
if (Array.isArray(value))
|
|
148
|
+
return value.filter((g) => typeof g === "string");
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
148
151
|
/**
|
|
149
152
|
* Computes a Principal from an AuthInfo and configured scope names.
|
|
150
153
|
* Hierarchy:
|
|
@@ -161,12 +164,15 @@ export function principalFromAuthInfo(info, scopeNames) {
|
|
|
161
164
|
const canRead = hasRead || hasWrite || hasAdmin;
|
|
162
165
|
const extraSub = info.extra?.sub;
|
|
163
166
|
const extraIdentity = info.extra?.identity;
|
|
167
|
+
const extraGroups = info.extra?.groups;
|
|
164
168
|
const sub = typeof extraSub === "string" ? extraSub : "unknown";
|
|
165
169
|
const identity = typeof extraIdentity === "string" ? extraIdentity : null;
|
|
170
|
+
const groups = Array.isArray(extraGroups) ? extraGroups.filter((g) => typeof g === "string") : [];
|
|
166
171
|
return {
|
|
167
172
|
subject: sub,
|
|
168
173
|
clientId: info.clientId,
|
|
169
174
|
identity,
|
|
175
|
+
groups,
|
|
170
176
|
scopes: info.scopes,
|
|
171
177
|
canRead,
|
|
172
178
|
canWrite,
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { isIPv4 } from "node:net";
|
|
2
3
|
import { z } from "zod";
|
|
4
|
+
import { PROJECT_RE } from "./okf/paths.js";
|
|
3
5
|
/** A whole-string decimal integer env var (rejects `8080junk`, `1x`, `1.5`, and blanks). */
|
|
4
6
|
function intEnv(name, fallback, min, max = Number.MAX_SAFE_INTEGER) {
|
|
5
7
|
return z
|
|
@@ -73,6 +75,10 @@ const storageEnvShape = {
|
|
|
73
75
|
.default("/data")
|
|
74
76
|
.refine((v) => v.startsWith("/"), "DATA_DIR must be an absolute path"),
|
|
75
77
|
GIT_BRANCH: z.string().default("main"),
|
|
78
|
+
HUB_PROJECT: z
|
|
79
|
+
.string()
|
|
80
|
+
.default("org")
|
|
81
|
+
.transform((v) => (v.trim() === "" ? "org" : v.trim())),
|
|
76
82
|
GIT_REMOTE_URL: z.string().optional(),
|
|
77
83
|
GIT_SYNC_INTERVAL_SECONDS: intEnv("GIT_SYNC_INTERVAL_SECONDS", "60", 0),
|
|
78
84
|
GIT_SSH_KEY_PATH: z.string().optional(),
|
|
@@ -82,7 +88,98 @@ const storageEnvShape = {
|
|
|
82
88
|
MAX_FILE_BYTES: intEnv("MAX_FILE_BYTES", "1048576", 1),
|
|
83
89
|
MAX_ARCHIVE_BYTES: intEnv("MAX_ARCHIVE_BYTES", "52428800", 1),
|
|
84
90
|
DEFAULT_STALE_AFTER_DAYS: optionalIntEnv("DEFAULT_STALE_AFTER_DAYS", 1, 36500),
|
|
91
|
+
PROJECT_ACCESS: z.string().optional(),
|
|
92
|
+
PROJECT_ACCESS_FILE: z.string().optional(),
|
|
85
93
|
};
|
|
94
|
+
const projectAccessRuleSchema = z.object({
|
|
95
|
+
readGroups: z.array(z.string()).default([]),
|
|
96
|
+
writeGroups: z.array(z.string()).default([]),
|
|
97
|
+
});
|
|
98
|
+
const projectAccessMapSchema = z.record(z.string(), projectAccessRuleSchema);
|
|
99
|
+
function refineProjectAccess(data, ctx) {
|
|
100
|
+
if (data.PROJECT_ACCESS && data.PROJECT_ACCESS_FILE) {
|
|
101
|
+
ctx.addIssue({
|
|
102
|
+
code: "custom",
|
|
103
|
+
path: ["PROJECT_ACCESS"],
|
|
104
|
+
message: "PROJECT_ACCESS and PROJECT_ACCESS_FILE cannot both be set",
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
let jsonStr;
|
|
109
|
+
let targetPath = "PROJECT_ACCESS";
|
|
110
|
+
if (data.PROJECT_ACCESS !== undefined && data.PROJECT_ACCESS.trim() !== "") {
|
|
111
|
+
jsonStr = data.PROJECT_ACCESS;
|
|
112
|
+
targetPath = "PROJECT_ACCESS";
|
|
113
|
+
}
|
|
114
|
+
else if (data.PROJECT_ACCESS_FILE !== undefined && data.PROJECT_ACCESS_FILE.trim() !== "") {
|
|
115
|
+
targetPath = "PROJECT_ACCESS_FILE";
|
|
116
|
+
try {
|
|
117
|
+
jsonStr = readFileSync(data.PROJECT_ACCESS_FILE, "utf-8");
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
ctx.addIssue({
|
|
121
|
+
code: "custom",
|
|
122
|
+
path: ["PROJECT_ACCESS_FILE"],
|
|
123
|
+
message: `could not read PROJECT_ACCESS_FILE: ${err.message}`,
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (jsonStr === undefined)
|
|
129
|
+
return;
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = JSON.parse(jsonStr);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
ctx.addIssue({
|
|
136
|
+
code: "custom",
|
|
137
|
+
path: [targetPath],
|
|
138
|
+
message: `${targetPath} must be valid JSON`,
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
143
|
+
ctx.addIssue({
|
|
144
|
+
code: "custom",
|
|
145
|
+
path: [targetPath],
|
|
146
|
+
message: `${targetPath} must be a JSON object`,
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const key of Object.keys(parsed)) {
|
|
151
|
+
if (!PROJECT_RE.test(key)) {
|
|
152
|
+
ctx.addIssue({
|
|
153
|
+
code: "custom",
|
|
154
|
+
path: [targetPath, key],
|
|
155
|
+
message: `invalid project name "${key}": must match ${PROJECT_RE}`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const result = projectAccessMapSchema.safeParse(parsed);
|
|
160
|
+
if (!result.success) {
|
|
161
|
+
for (const issue of result.error.issues) {
|
|
162
|
+
ctx.addIssue({
|
|
163
|
+
code: "custom",
|
|
164
|
+
path: [targetPath, ...issue.path],
|
|
165
|
+
message: issue.message,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function parseProjectAccess(projectAccess, projectAccessFile) {
|
|
171
|
+
let jsonStr;
|
|
172
|
+
if (projectAccess !== undefined && projectAccess.trim() !== "") {
|
|
173
|
+
jsonStr = projectAccess;
|
|
174
|
+
}
|
|
175
|
+
else if (projectAccessFile !== undefined && projectAccessFile.trim() !== "") {
|
|
176
|
+
jsonStr = readFileSync(projectAccessFile, "utf-8");
|
|
177
|
+
}
|
|
178
|
+
if (!jsonStr)
|
|
179
|
+
return {};
|
|
180
|
+
const parsed = JSON.parse(jsonStr);
|
|
181
|
+
return projectAccessMapSchema.parse(parsed);
|
|
182
|
+
}
|
|
86
183
|
const httpEnvShape = {
|
|
87
184
|
AUTH_MODE: z.enum(["oidc", "none"], "AUTH_MODE must be oidc or none").default("oidc"),
|
|
88
185
|
PORT: intEnv("PORT", "8080", 0, 65535),
|
|
@@ -150,6 +247,7 @@ const rawEnvSchema = z.object({ ...httpEnvShape, ...storageEnvShape }).superRefi
|
|
|
150
247
|
if (!data || typeof data !== "object")
|
|
151
248
|
return;
|
|
152
249
|
refineGitCredentials(data, ctx);
|
|
250
|
+
refineProjectAccess(data, ctx);
|
|
153
251
|
if (data.AUTH_MODE === "none") {
|
|
154
252
|
const isLoopback = isLoopbackHost(data.HOST ?? "0.0.0.0");
|
|
155
253
|
const allowUnauthenticated = data.ALLOW_UNAUTHENTICATED_NETWORK === "true";
|
|
@@ -220,7 +318,10 @@ const rawEnvSchema = z.object({ ...httpEnvShape, ...storageEnvShape }).superRefi
|
|
|
220
318
|
}
|
|
221
319
|
}
|
|
222
320
|
}, { when: () => true });
|
|
223
|
-
const storageEnvSchema = z
|
|
321
|
+
const storageEnvSchema = z
|
|
322
|
+
.object(storageEnvShape)
|
|
323
|
+
.superRefine(refineGitCredentials, { when: () => true })
|
|
324
|
+
.superRefine(refineProjectAccess, { when: () => true });
|
|
224
325
|
function formatConfigError(error) {
|
|
225
326
|
const errorMessages = error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`);
|
|
226
327
|
return new Error(`Configuration errors:\n ${errorMessages.join("\n ")}`);
|
|
@@ -230,6 +331,7 @@ function storageFromRaw(raw, env, options) {
|
|
|
230
331
|
logLevel: raw.LOG_LEVEL,
|
|
231
332
|
dataDir: raw.DATA_DIR,
|
|
232
333
|
gitBranch: raw.GIT_BRANCH,
|
|
334
|
+
hubProject: raw.HUB_PROJECT,
|
|
233
335
|
gitRemoteUrl: raw.GIT_REMOTE_URL,
|
|
234
336
|
gitSyncIntervalSeconds: raw.GIT_SYNC_INTERVAL_SECONDS,
|
|
235
337
|
gitSshKeyPath: raw.GIT_SSH_KEY_PATH,
|
|
@@ -244,6 +346,7 @@ function storageFromRaw(raw, env, options) {
|
|
|
244
346
|
maxFileBytes: raw.MAX_FILE_BYTES,
|
|
245
347
|
maxArchiveBytes: raw.MAX_ARCHIVE_BYTES,
|
|
246
348
|
defaultStaleAfterDays: raw.DEFAULT_STALE_AFTER_DAYS,
|
|
349
|
+
projectAccess: parseProjectAccess(raw.PROJECT_ACCESS, raw.PROJECT_ACCESS_FILE),
|
|
247
350
|
};
|
|
248
351
|
}
|
|
249
352
|
/** Storage-only settings for the stdio transport: needs neither PUBLIC_BASE_URL nor OAUTH_*. */
|
package/dist/http/rest.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { OkfError } from "../errors.js";
|
|
3
3
|
import { parseFrontmatter, splitFrontmatter } from "../okf/frontmatter.js";
|
|
4
|
+
import { TRUST_TIERS } from "../okf/semantics.js";
|
|
4
5
|
const projectParams = z.object({ project: z.string() });
|
|
5
6
|
const wildcardParams = z.object({ project: z.string(), "*": z.string() });
|
|
6
7
|
function principalOf(req) {
|
|
@@ -95,7 +96,7 @@ const searchQuery = z.object({
|
|
|
95
96
|
type: z.string().optional(),
|
|
96
97
|
tag: z.union([z.string(), z.array(z.string())]).optional(),
|
|
97
98
|
status: z.enum(["draft", "stable", "deprecated"]).optional(),
|
|
98
|
-
trustTier: z.enum(
|
|
99
|
+
trustTier: z.enum(TRUST_TIERS).optional(),
|
|
99
100
|
stale: z.enum(["true", "false"]).optional(),
|
|
100
101
|
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
101
102
|
});
|
|
@@ -104,8 +105,24 @@ export function registerRestRoutes(app, service) {
|
|
|
104
105
|
const write = { permission: "write" };
|
|
105
106
|
const admin = { permission: "admin" };
|
|
106
107
|
app.get("/api/v1/projects", { config: read }, async (req) => {
|
|
107
|
-
const { repository } = z
|
|
108
|
-
|
|
108
|
+
const { repository, team, query } = z
|
|
109
|
+
.object({
|
|
110
|
+
repository: z.string().optional(),
|
|
111
|
+
team: z.string().optional(),
|
|
112
|
+
query: z.string().optional(),
|
|
113
|
+
})
|
|
114
|
+
.parse(req.query);
|
|
115
|
+
return service.listProjects(principalOf(req), { repository, team, query });
|
|
116
|
+
});
|
|
117
|
+
app.get("/api/v1/orient", { config: read }, async (req) => {
|
|
118
|
+
const { question, project, limit } = z
|
|
119
|
+
.object({
|
|
120
|
+
question: z.string().min(1).max(512),
|
|
121
|
+
project: z.string().optional(),
|
|
122
|
+
limit: z.coerce.number().int().min(1).max(50).optional(),
|
|
123
|
+
})
|
|
124
|
+
.parse(req.query);
|
|
125
|
+
return service.orient(principalOf(req), { question, project, limit });
|
|
109
126
|
});
|
|
110
127
|
app.post("/api/v1/projects", { config: write }, async (req, reply) => {
|
|
111
128
|
const body = z
|
|
@@ -116,7 +133,7 @@ export function registerRestRoutes(app, service) {
|
|
|
116
133
|
});
|
|
117
134
|
app.get("/api/v1/projects/:project", { config: read }, async (req) => {
|
|
118
135
|
const { project } = projectParams.parse(req.params);
|
|
119
|
-
return service.getProject(project);
|
|
136
|
+
return service.getProject(principalOf(req), project);
|
|
120
137
|
});
|
|
121
138
|
app.delete("/api/v1/projects/:project", { config: admin }, async (req) => {
|
|
122
139
|
const { project } = projectParams.parse(req.params);
|
|
@@ -125,11 +142,11 @@ export function registerRestRoutes(app, service) {
|
|
|
125
142
|
app.get("/api/v1/projects/:project/index", { config: read }, async (req) => {
|
|
126
143
|
const { project } = projectParams.parse(req.params);
|
|
127
144
|
const { path } = z.object({ path: z.string().optional() }).parse(req.query);
|
|
128
|
-
return service.getIndex(project, path ?? "");
|
|
145
|
+
return service.getIndex(principalOf(req), project, path ?? "");
|
|
129
146
|
});
|
|
130
147
|
app.get("/api/v1/projects/:project/concepts/*", { config: read }, async (req, reply) => {
|
|
131
148
|
const { project, "*": id } = wildcardParams.parse(req.params);
|
|
132
|
-
const concept = withRevision(reply, await service.readConcept(project, id));
|
|
149
|
+
const concept = withRevision(reply, await service.readConcept(principalOf(req), project, id));
|
|
133
150
|
if ((req.headers.accept ?? "").includes("text/markdown")) {
|
|
134
151
|
return reply.type("text/markdown; charset=utf-8").send(concept.markdown);
|
|
135
152
|
}
|
|
@@ -172,7 +189,7 @@ export function registerRestRoutes(app, service) {
|
|
|
172
189
|
});
|
|
173
190
|
app.get("/api/v1/projects/:project/files/*", { config: read }, async (req, reply) => {
|
|
174
191
|
const { project, "*": path } = wildcardParams.parse(req.params);
|
|
175
|
-
const file = withRevision(reply, await service.readFile(project, path));
|
|
192
|
+
const file = withRevision(reply, await service.readFile(principalOf(req), project, path));
|
|
176
193
|
const extension = /\.[^./]+$/.exec(path)?.[0].toLowerCase() ?? "";
|
|
177
194
|
return reply.type(CONTENT_TYPE_BY_EXTENSION[extension] ?? "text/plain; charset=utf-8").send(file.content);
|
|
178
195
|
});
|
|
@@ -203,19 +220,19 @@ export function registerRestRoutes(app, service) {
|
|
|
203
220
|
const { id, limit } = z
|
|
204
221
|
.object({ id: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).optional() })
|
|
205
222
|
.parse(req.query);
|
|
206
|
-
return service.history(project, id, limit);
|
|
223
|
+
return service.history(principalOf(req), project, id, limit);
|
|
207
224
|
});
|
|
208
225
|
app.get("/api/v1/projects/:project/lint", { config: read }, async (req) => {
|
|
209
226
|
const { project } = projectParams.parse(req.params);
|
|
210
|
-
return service.lint(project);
|
|
227
|
+
return service.lint(principalOf(req), project);
|
|
211
228
|
});
|
|
212
229
|
app.get("/api/v1/projects/:project/conflicts", { config: read }, async (req) => {
|
|
213
230
|
const { project } = projectParams.parse(req.params);
|
|
214
|
-
return service.listConflicts(project);
|
|
231
|
+
return service.listConflicts(principalOf(req), project);
|
|
215
232
|
});
|
|
216
233
|
app.get("/api/v1/projects/:project/conflicts/:id/files/*", { config: read }, async (req) => {
|
|
217
234
|
const { project, id, "*": path, } = z.object({ project: z.string(), id: z.string(), "*": z.string() }).parse(req.params);
|
|
218
|
-
return service.readConflict(project, id, path);
|
|
235
|
+
return service.readConflict(principalOf(req), project, id, path);
|
|
219
236
|
});
|
|
220
237
|
app.post("/api/v1/projects/:project/conflicts/:id/resolution", { config: write }, async (req, reply) => {
|
|
221
238
|
const { project, id } = z.object({ project: z.string(), id: z.string() }).parse(req.params);
|
|
@@ -225,7 +242,7 @@ export function registerRestRoutes(app, service) {
|
|
|
225
242
|
});
|
|
226
243
|
app.get("/api/v1/projects/:project/archive", { config: read }, async (req, reply) => {
|
|
227
244
|
const { project } = projectParams.parse(req.params);
|
|
228
|
-
const stream =
|
|
245
|
+
const stream = service.exportArchive(principalOf(req), project);
|
|
229
246
|
return reply
|
|
230
247
|
.type("application/gzip")
|
|
231
248
|
.header("content-disposition", `attachment; filename="${project}.tar.gz"`)
|
|
@@ -240,7 +257,7 @@ export function registerRestRoutes(app, service) {
|
|
|
240
257
|
});
|
|
241
258
|
app.get("/api/v1/search", { config: read }, async (req) => {
|
|
242
259
|
const q = searchQuery.parse(req.query);
|
|
243
|
-
return service.search({
|
|
260
|
+
return service.search(principalOf(req), {
|
|
244
261
|
...(q.q === undefined ? {} : { query: q.q }),
|
|
245
262
|
...(q.project === undefined ? {} : { project: q.project }),
|
|
246
263
|
...(q.type === undefined ? {} : { type: q.type }),
|
package/dist/local-host.js
CHANGED
package/dist/mcp/server.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { OkfError } from "../errors.js";
|
|
4
|
+
import { TRUST_TIERS } from "../okf/semantics.js";
|
|
4
5
|
import { FEEDBACK_TYPES, feedbackLink } from "../service/feedback.js";
|
|
5
6
|
import { VERSION } from "../version.js";
|
|
6
|
-
export const INSTRUCTIONS = `ok-fine holds shared project knowledge outside the codebase, as OKF v0.2 markdown concepts grouped into projects. In a git repository
|
|
7
|
-
1. Discover: get_index (progressive disclosure) or search_concepts with \`project
|
|
8
|
-
2. Read: read_concept returns frontmatter, body, trust tier (unverified | machine-confirmed | human-reviewed), staleness, and links. Freshness comes first: a concept that is stale
|
|
9
|
-
3. Write: write_concept with frontmatter containing \`type\` (e.g. Decision, Convention, Architecture, Component, Playbook, Interface, Reference) plus \`title\`, \`description\`, \`tags\`, and \`stale_after\` (ISO 8601, e.g. 180 days ahead). Record provenance in \`sources\` (each with \`resource\` and a stable \`id\`; code sources carry \`commit\`) and cite claims with footnotes [^id]. Link concepts with bundle-absolute links such as [orders](/tables/orders.md). After re-checking a concept against the code, refresh \`sources[].commit\` and \`stale_after\` with write_concept, then call verify_concept.
|
|
10
|
-
4. Pass \`actor\` as <harness>/<model> (e.g. claude-code/claude-opus-4-5,
|
|
7
|
+
export const INSTRUCTIONS = `ok-fine holds shared project knowledge outside the codebase, as OKF v0.2 markdown concepts grouped into projects. Project resolution order: 1) A project named explicitly by the user or by harness project/workspace instructions (e.g. Claude Project instructions, a custom GPT's instructions): use it. 2) In a git repository with a remote: call list_projects with \`repository\` set to the remote URL (\`git remote get-url origin\`, falling back to the first remote); use the returned project(s) for every read and write. If none match, say the repository is not onboarded and offer to onboard it (ok-fine-onboard skill). 3) Otherwise (no repository, no shell, or no remote): call orient with your question to rank relevant projects and concepts across the organization, or call list_projects with \`team\` and/or \`query\` (or no arguments). Never run git commands and never say "not onboarded" or offer onboarding on this path. If several fit, read from all; ask before writing only when the write target is ambiguous. Search before planning or editing; record durable decisions, conventions, and runbooks afterwards.
|
|
8
|
+
1. Discover: orient (cross-org ranked discovery with working rules), get_index (progressive disclosure) or search_concepts with \`project\` (omitting \`project\` searches all projects).
|
|
9
|
+
2. Read: read_concept returns frontmatter, body, trust tier (proposed | unverified | machine-confirmed | human-reviewed), staleness, and links. Freshness comes first: a concept that is stale is a lead to re-check whatever its tier. For concepts with code sources, that means when code changed since \`sources[].commit\` or that commit is not an ancestor of HEAD (unmerged or rebased away); for concepts without code sources, fresh means \`stale_after\` is set and not past (additionally, when a tool available in the session can fetch a source's \`resource\` URL and report its last-modified time, a source modified after \`generated.at\` counts as drifted). Among fresh concepts prefer higher trust tiers. Knowledge about unmerged work is written at its usual id (e.g. \`decisions/<slug>\`, never over an existing concept) with a \`proposal: { ref: <URI> }\` frontmatter key; such concepts have trust tier \`proposed\` and are not current truth. When one you read has landed in the mainline, rewrite it as current truth without the \`proposal\` key and verify it; when its work was abandoned, set \`status: deprecated\`. Deprecated concepts are history; when code contradicts a concept, trust the code and update the concept.
|
|
10
|
+
3. Write: write_concept with frontmatter containing \`type\` (e.g. Decision, Convention, Architecture, Component, Playbook, Interface, Reference) plus \`title\`, \`description\`, \`tags\`, and \`stale_after\` (ISO 8601, e.g. 180 days ahead). Record provenance in \`sources\` (each with \`resource\` and a stable \`id\`; code sources carry \`commit\`; non-code sources use a URL or stable URI \`resource\` without \`commit\`) and cite claims with footnotes [^id]. Link concepts with bundle-absolute links such as [orders](/tables/orders.md). After re-checking a concept against the code or sources, refresh \`sources[].commit\` and \`stale_after\` with write_concept, then call verify_concept.
|
|
11
|
+
4. Pass \`actor\` as <harness>/<model> (e.g. claude-code/claude-opus-4-5, claude-desktop/<model>, claude-ai/<model>, chatgpt/<model>, gemini-cli/gemini-2.5-pro). Use human:<email>, with the email from \`git config user.email\` in a repository or the email confirmed by the user outside one, only when the user personally reviewed the concept; on forbidden_actor, report both identities instead of retrying as another. The server stamps \`generated\`; \`verified\` changes only through verify_concept.
|
|
11
12
|
5. When updating, pass expectedRevision from read_concept (null to create only).
|
|
12
13
|
6. An \`unresolved_conflict\` issue (lint_project, read_concept) is a write ok-fine accepted but could not merge with a concurrent edit from another ok-fine instance. Before editing the affected file: read_conflict, merge \`preserved\` into \`current\` (use \`base\` to see what each side changed), write the result with expectedRevision set to \`current.revision\`, then call resolve_conflict with every file path of the conflict. Resolve only conflicts of the project you are working in.
|
|
13
14
|
7. Prefer \`status: deprecated\` over delete_concept. index.md and log.md are maintained by the server; do not write them. A project is bound to repositories through the \`repositories\` list in its overview frontmatter.
|
|
@@ -61,67 +62,86 @@ export function createMcpServer(service, principal, log) {
|
|
|
61
62
|
const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
62
63
|
tool("list_projects", "read", {
|
|
63
64
|
title: "List projects",
|
|
64
|
-
description: "
|
|
65
|
+
description: "Call first to pick the project. In a git repository pass `repository` (the remote URL); outside a git repository (e.g. desktop chat), call with `team` and/or `query` (or no arguments) and choose by title, description, teams, domains, keywords; a project named by the user or project instructions wins. Lists projects (OKF bundles) with concept and staleness counts, bound git repositories, and routing metadata (teams, domains, audience, keywords, owners).",
|
|
65
66
|
inputSchema: z.object({
|
|
66
67
|
repository: z
|
|
67
68
|
.string()
|
|
68
69
|
.optional()
|
|
69
|
-
.describe("Git remote URL of the current repository, e.g. the output of `git remote get-url origin`; returns only the projects bound to it"),
|
|
70
|
+
.describe("Git remote URL of the current repository, e.g. the output of `git remote get-url origin`; returns only the projects bound to it. Omit outside a git repository."),
|
|
71
|
+
team: z.string().optional().describe("Case-insensitive match on the overview's `teams`"),
|
|
72
|
+
query: z
|
|
73
|
+
.string()
|
|
74
|
+
.optional()
|
|
75
|
+
.describe("Keywords matched against title, description, `domains`, and `keywords`; any term matches by word prefix"),
|
|
76
|
+
}),
|
|
77
|
+
annotations: readOnly,
|
|
78
|
+
}, (a) => service.listProjects(principal, { repository: a.repository, team: a.team, query: a.query }));
|
|
79
|
+
tool("orient", "read", {
|
|
80
|
+
title: "Orient across organization knowledge",
|
|
81
|
+
description: "Call before answering any question about how the organization works: processes, policies, customers, products, campaigns. Returns ranked projects, each with its top concepts and scores, plus working rules (freshness first, trust tier, citing sources, and rephrasing queries).",
|
|
82
|
+
inputSchema: z.object({
|
|
83
|
+
question: z
|
|
84
|
+
.string()
|
|
85
|
+
.min(1)
|
|
86
|
+
.max(512)
|
|
87
|
+
.describe("The user's question, task, or search terms to orient against (max 512 chars)"),
|
|
88
|
+
project: z.string().optional().describe("Restrict orientation to a single project"),
|
|
89
|
+
limit: z.number().int().min(1).max(50).optional().describe("Maximum projects to return (default: 5)"),
|
|
70
90
|
}),
|
|
71
91
|
annotations: readOnly,
|
|
72
|
-
}, (a) => service.
|
|
92
|
+
}, (a) => service.orient(principal, { question: a.question, project: a.project, limit: a.limit }));
|
|
73
93
|
tool("get_index", "read", {
|
|
74
94
|
title: "Get directory index",
|
|
75
95
|
description: "Progressive-disclosure listing of one bundle directory: its concepts grouped by type, files, and subdirectories.",
|
|
76
96
|
inputSchema: z.object({ project, path: z.string().optional().describe("directory, default bundle root") }),
|
|
77
97
|
annotations: readOnly,
|
|
78
|
-
}, (a) => service.getIndex(a.project, a.path ?? ""));
|
|
98
|
+
}, (a) => service.getIndex(principal, a.project, a.path ?? ""));
|
|
79
99
|
tool("read_concept", "read", {
|
|
80
100
|
title: "Read concept",
|
|
81
101
|
description: "Read one concept with frontmatter, body, derived trust/staleness, links, and lint issues; returns `revision` to pass back as expectedRevision when updating.",
|
|
82
102
|
inputSchema: z.object({ project, id }),
|
|
83
103
|
annotations: readOnly,
|
|
84
|
-
}, (a) => service.readConcept(a.project, a.id));
|
|
104
|
+
}, (a) => service.readConcept(principal, a.project, a.id));
|
|
85
105
|
tool("search_concepts", "read", {
|
|
86
106
|
title: "Search concepts",
|
|
87
|
-
description: "Keyword full-text search over concepts, optionally filtered by project, type, tags, status, trust tier, and staleness.",
|
|
107
|
+
description: "Keyword full-text search over concepts, optionally filtered by project, type, tags, status, trust tier, and staleness. Omitting project searches every project (use when no project is resolved yet or outside a repository); results carry project to use for follow-up read_concept calls. Cite concepts read.",
|
|
88
108
|
inputSchema: z.object({
|
|
89
109
|
query: z.string().max(512).optional(),
|
|
90
110
|
project: z.string().optional(),
|
|
91
111
|
type: z.string().optional(),
|
|
92
112
|
tags: z.array(z.string()).optional(),
|
|
93
113
|
status: z.enum(["draft", "stable", "deprecated"]).optional().describe("omitted hides deprecated"),
|
|
94
|
-
trustTier: z.enum(
|
|
114
|
+
trustTier: z.enum(TRUST_TIERS).optional(),
|
|
95
115
|
stale: z.boolean().optional(),
|
|
96
116
|
limit,
|
|
97
117
|
}),
|
|
98
118
|
annotations: readOnly,
|
|
99
|
-
}, (a) => service.search(a));
|
|
119
|
+
}, (a) => service.search(principal, a));
|
|
100
120
|
tool("get_history", "read", {
|
|
101
121
|
title: "Get history",
|
|
102
122
|
description: "List git commits that changed a concept, or the whole project when id is omitted.",
|
|
103
123
|
inputSchema: z.object({ project, id: id.optional(), limit }),
|
|
104
124
|
annotations: readOnly,
|
|
105
|
-
}, (a) => service.history(a.project, a.id, a.limit));
|
|
125
|
+
}, (a) => service.history(principal, a.project, a.id, a.limit));
|
|
106
126
|
tool("read_file", "read", {
|
|
107
127
|
title: "Read file",
|
|
108
128
|
description: "Read any text file in a bundle verbatim (including index.md, log.md, and non-markdown assets).",
|
|
109
129
|
inputSchema: z.object({ project, path: z.string().describe("bundle-relative file path") }),
|
|
110
130
|
annotations: readOnly,
|
|
111
|
-
}, (a) => service.readFile(a.project, a.path));
|
|
131
|
+
}, (a) => service.readFile(principal, a.project, a.path));
|
|
112
132
|
tool("lint_project", "read", {
|
|
113
133
|
title: "Lint project",
|
|
114
134
|
description: "Check a bundle for OKF v0.2 conformance and return errors, warnings, and info issues.",
|
|
115
135
|
inputSchema: z.object({ project }),
|
|
116
136
|
annotations: readOnly,
|
|
117
|
-
}, (a) => service.lint(a.project));
|
|
137
|
+
}, (a) => service.lint(principal, a.project));
|
|
118
138
|
const conflictId = z.string().describe("Conflict id from list_conflicts or an unresolved_conflict lint issue");
|
|
119
139
|
tool("list_conflicts", "read", {
|
|
120
140
|
title: "List conflicts",
|
|
121
141
|
description: "List the project's unresolved conflicts: writes the server accepted but could not reconcile with a concurrent edit from another ok-fine instance. Each lists changed files (divergent = the current version also changed) and the commits that made them.",
|
|
122
142
|
inputSchema: z.object({ project }),
|
|
123
143
|
annotations: readOnly,
|
|
124
|
-
}, (a) => service.listConflicts(a.project));
|
|
144
|
+
}, (a) => service.listConflicts(principal, a.project));
|
|
125
145
|
tool("read_conflict", "read", {
|
|
126
146
|
title: "Read conflict file",
|
|
127
147
|
description: "Read one file of a conflict: `preserved` (the unapplied write), `base` (common ancestor), and `current` (with the revision to pass as expectedRevision when writing the merge). null = absent on that side.",
|
|
@@ -131,7 +151,7 @@ export function createMcpServer(service, principal, log) {
|
|
|
131
151
|
path: z.string().describe("bundle-relative file path, e.g. tables/orders.md"),
|
|
132
152
|
}),
|
|
133
153
|
annotations: readOnly,
|
|
134
|
-
}, (a) => service.readConflict(a.project, a.id, a.path));
|
|
154
|
+
}, (a) => service.readConflict(principal, a.project, a.id, a.path));
|
|
135
155
|
tool("resolve_conflict", "write", {
|
|
136
156
|
title: "Resolve conflict",
|
|
137
157
|
description: "Discard a conflict's preserved copy after merging what should survive with write_concept/write_file. `paths` must list every file of the conflict, confirming each was reconciled; only this project's part of the conflict is resolved.",
|
package/dist/okf/concept.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseFrontmatter, splitFrontmatter } from "./frontmatter.js";
|
|
2
|
-
import { extractLinks } from "./markdown.js";
|
|
2
|
+
import { extractCrossProjectLinks, extractLinks } from "./markdown.js";
|
|
3
3
|
import { blobRevision } from "./paths.js";
|
|
4
4
|
import { displayTitle, effectiveStatus, generatedAt, generatedBy, lastVerifiedAt, staleAfter, trustTier, } from "./semantics.js";
|
|
5
5
|
export function parseConcept(project, id, file, buf) {
|
|
@@ -26,6 +26,7 @@ export function parseConcept(project, id, file, buf) {
|
|
|
26
26
|
generatedBy: null,
|
|
27
27
|
lastVerifiedAt: null,
|
|
28
28
|
outbound: [],
|
|
29
|
+
crossProjectOutbound: [],
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
const parsed = parseFrontmatter(split.yaml);
|
|
@@ -49,6 +50,7 @@ export function parseConcept(project, id, file, buf) {
|
|
|
49
50
|
generatedBy: null,
|
|
50
51
|
lastVerifiedAt: null,
|
|
51
52
|
outbound: [],
|
|
53
|
+
crossProjectOutbound: [],
|
|
52
54
|
};
|
|
53
55
|
}
|
|
54
56
|
const { data } = parsed;
|
|
@@ -72,6 +74,7 @@ export function parseConcept(project, id, file, buf) {
|
|
|
72
74
|
generatedBy: generatedBy(data),
|
|
73
75
|
lastVerifiedAt: lastVerifiedAt(data),
|
|
74
76
|
outbound: extractLinks(split.body, id),
|
|
77
|
+
crossProjectOutbound: extractCrossProjectLinks(split.body),
|
|
75
78
|
};
|
|
76
79
|
}
|
|
77
80
|
const tags = Array.isArray(data.tags) ? data.tags.filter((t) => typeof t === "string") : [];
|
|
@@ -94,6 +97,7 @@ export function parseConcept(project, id, file, buf) {
|
|
|
94
97
|
generatedBy: generatedBy(data),
|
|
95
98
|
lastVerifiedAt: lastVerifiedAt(data),
|
|
96
99
|
outbound: extractLinks(split.body, id),
|
|
100
|
+
crossProjectOutbound: extractCrossProjectLinks(split.body),
|
|
97
101
|
};
|
|
98
102
|
}
|
|
99
103
|
//# sourceMappingURL=concept.js.map
|
package/dist/okf/lint.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { parseFrontmatter, splitFrontmatter } from "./frontmatter.js";
|
|
3
|
-
import { computationBlocks, extractFootnoteLabels, extractLinks, hasLegacyCitations } from "./markdown.js";
|
|
3
|
+
import { computationBlocks, extractCrossProjectLinks, extractFootnoteLabels, extractLinks, hasLegacyCitations, } from "./markdown.js";
|
|
4
4
|
import { ISO_DATETIME, isStale, parseActor } from "./semantics.js";
|
|
5
5
|
const COMMIT_RE = /^[0-9a-f]{7,64}$/i;
|
|
6
6
|
const SHELL_META_OR_WHITESPACE = /[\s`$;|&<>()\\"'`]/;
|
|
@@ -371,6 +371,20 @@ export function lintConceptFile(path, text, ctx) {
|
|
|
371
371
|
});
|
|
372
372
|
}
|
|
373
373
|
}
|
|
374
|
+
// Warning: cross-project outbound links
|
|
375
|
+
if (ctx.crossProjectConceptExists) {
|
|
376
|
+
const crossLinks = extractCrossProjectLinks(body);
|
|
377
|
+
for (const target of crossLinks) {
|
|
378
|
+
if (!ctx.crossProjectConceptExists(target.project, target.id)) {
|
|
379
|
+
issues.push({
|
|
380
|
+
severity: "warning",
|
|
381
|
+
code: "broken_cross_link",
|
|
382
|
+
path,
|
|
383
|
+
message: `cross-project link to "okf://${target.project}/${target.id}" does not exist`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
374
388
|
// Info: stale
|
|
375
389
|
if (isStale(data, ctx.now)) {
|
|
376
390
|
issues.push({
|
package/dist/okf/log-file.js
CHANGED
|
@@ -26,6 +26,9 @@ export function logVerificationEntry(title, id, actor, message) {
|
|
|
26
26
|
export function logDeletionEntry(id, actor, message) {
|
|
27
27
|
return appendMessage(`**Deletion**: Removed \`${id}\` (by ${actor}).`, message);
|
|
28
28
|
}
|
|
29
|
+
export function logMoveEntry(title, fromId, toId, actor) {
|
|
30
|
+
return `**Move**: Moved \`${fromId}\` to [${title}](/${toId}.md) (by ${actor}).`;
|
|
31
|
+
}
|
|
29
32
|
export function logFileUpdateEntry(path, actor, message) {
|
|
30
33
|
return appendMessage(`**Update**: Wrote file \`${path}\` (by ${actor}).`, message);
|
|
31
34
|
}
|
package/dist/okf/markdown.js
CHANGED
|
@@ -1,8 +1,90 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { marked } from "marked";
|
|
3
|
-
import { isReservedName } from "./paths.js";
|
|
3
|
+
import { isReservedName, PROJECT_RE, SEGMENT_RE } from "./paths.js";
|
|
4
4
|
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
5
5
|
const FOOTNOTE_RE = /\[\^([^\]\s]+)\](?!:)/g;
|
|
6
|
+
export function parseCrossProjectTarget(rawHref) {
|
|
7
|
+
if (!rawHref) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
let href = rawHref;
|
|
11
|
+
try {
|
|
12
|
+
href = decodeURI(href);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// ignore URI decode failures
|
|
16
|
+
}
|
|
17
|
+
if (href.includes("\\")) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const trimmed = href.trim();
|
|
21
|
+
if (!trimmed.toLowerCase().startsWith("okf://")) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
let withoutHash = trimmed;
|
|
25
|
+
const hashIdx = withoutHash.indexOf("#");
|
|
26
|
+
if (hashIdx !== -1) {
|
|
27
|
+
withoutHash = withoutHash.slice(0, hashIdx);
|
|
28
|
+
}
|
|
29
|
+
const queryIdx = withoutHash.indexOf("?");
|
|
30
|
+
if (queryIdx !== -1) {
|
|
31
|
+
withoutHash = withoutHash.slice(0, queryIdx);
|
|
32
|
+
}
|
|
33
|
+
const rest = withoutHash.slice(6);
|
|
34
|
+
const slashIdx = rest.indexOf("/");
|
|
35
|
+
if (slashIdx === -1) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const project = rest.slice(0, slashIdx);
|
|
39
|
+
if (!PROJECT_RE.test(project)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
let target = rest.slice(slashIdx + 1);
|
|
43
|
+
if (target.endsWith(".md")) {
|
|
44
|
+
target = target.slice(0, -3);
|
|
45
|
+
}
|
|
46
|
+
if (target.length === 0 || target.length > 512) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const segments = target.split("/");
|
|
50
|
+
if (segments.length > 12) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
for (const seg of segments) {
|
|
54
|
+
if (!SEGMENT_RE.test(seg)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const lastSeg = segments[segments.length - 1];
|
|
59
|
+
if (!lastSeg) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const lastLower = lastSeg.toLowerCase();
|
|
63
|
+
if (lastLower === "index" || lastLower === "log") {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return { project, id: segments.join("/") };
|
|
67
|
+
}
|
|
68
|
+
export function extractCrossProjectLinks(body) {
|
|
69
|
+
const tokens = marked.lexer(body);
|
|
70
|
+
const found = [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
marked.walkTokens(tokens, (token) => {
|
|
73
|
+
if (token.type !== "link" || token.raw.startsWith("[^")) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const target = parseCrossProjectTarget(token.href);
|
|
77
|
+
if (!target) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const key = `${target.project}/${target.id}`;
|
|
81
|
+
if (!seen.has(key)) {
|
|
82
|
+
seen.add(key);
|
|
83
|
+
found.push(target);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
6
88
|
export function extractLinks(body, conceptId) {
|
|
7
89
|
const tokens = marked.lexer(body);
|
|
8
90
|
const found = [];
|