@ferrule-io/ok-fine 0.3.12 → 0.3.14
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 +106 -1
- package/dist/http/metrics.js +11 -0
- package/dist/http/rest.js +28 -12
- package/dist/local-host.js +1 -0
- package/dist/mcp/server.js +36 -17
- package/dist/metrics/histogram.js +36 -0
- package/dist/metrics/instrument.js +146 -0
- package/dist/metrics/store.js +298 -0
- package/dist/metrics/types.js +4 -0
- package/dist/okf/concept.js +5 -1
- package/dist/okf/lint.js +15 -1
- package/dist/okf/markdown.js +83 -1
- package/dist/server.js +44 -11
- package/dist/service/knowledge-service.js +379 -61
- package/dist/store/bundle.js +2 -1
- package/dist/store/catalog.js +78 -23
- package/dist/ui/assets/{highlight-D8LQtOZ_.js → highlight-X-D4YRi7.js} +1 -1
- package/dist/ui/assets/{index-CWFStnfZ.js → index-Bs2N743P.js} +33 -33
- package/dist/ui/assets/index-CMUhze4o.css +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/index-BjnLNjEO.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,99 @@ 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
|
+
METRICS_RETENTION_DAYS: intEnv("METRICS_RETENTION_DAYS", "30", 1, 3650),
|
|
92
|
+
PROJECT_ACCESS: z.string().optional(),
|
|
93
|
+
PROJECT_ACCESS_FILE: z.string().optional(),
|
|
85
94
|
};
|
|
95
|
+
const projectAccessRuleSchema = z.object({
|
|
96
|
+
readGroups: z.array(z.string()).default([]),
|
|
97
|
+
writeGroups: z.array(z.string()).default([]),
|
|
98
|
+
});
|
|
99
|
+
const projectAccessMapSchema = z.record(z.string(), projectAccessRuleSchema);
|
|
100
|
+
function refineProjectAccess(data, ctx) {
|
|
101
|
+
if (data.PROJECT_ACCESS && data.PROJECT_ACCESS_FILE) {
|
|
102
|
+
ctx.addIssue({
|
|
103
|
+
code: "custom",
|
|
104
|
+
path: ["PROJECT_ACCESS"],
|
|
105
|
+
message: "PROJECT_ACCESS and PROJECT_ACCESS_FILE cannot both be set",
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
let jsonStr;
|
|
110
|
+
let targetPath = "PROJECT_ACCESS";
|
|
111
|
+
if (data.PROJECT_ACCESS !== undefined && data.PROJECT_ACCESS.trim() !== "") {
|
|
112
|
+
jsonStr = data.PROJECT_ACCESS;
|
|
113
|
+
targetPath = "PROJECT_ACCESS";
|
|
114
|
+
}
|
|
115
|
+
else if (data.PROJECT_ACCESS_FILE !== undefined && data.PROJECT_ACCESS_FILE.trim() !== "") {
|
|
116
|
+
targetPath = "PROJECT_ACCESS_FILE";
|
|
117
|
+
try {
|
|
118
|
+
jsonStr = readFileSync(data.PROJECT_ACCESS_FILE, "utf-8");
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
ctx.addIssue({
|
|
122
|
+
code: "custom",
|
|
123
|
+
path: ["PROJECT_ACCESS_FILE"],
|
|
124
|
+
message: `could not read PROJECT_ACCESS_FILE: ${err.message}`,
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (jsonStr === undefined)
|
|
130
|
+
return;
|
|
131
|
+
let parsed;
|
|
132
|
+
try {
|
|
133
|
+
parsed = JSON.parse(jsonStr);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
ctx.addIssue({
|
|
137
|
+
code: "custom",
|
|
138
|
+
path: [targetPath],
|
|
139
|
+
message: `${targetPath} must be valid JSON`,
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
144
|
+
ctx.addIssue({
|
|
145
|
+
code: "custom",
|
|
146
|
+
path: [targetPath],
|
|
147
|
+
message: `${targetPath} must be a JSON object`,
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
for (const key of Object.keys(parsed)) {
|
|
152
|
+
if (!PROJECT_RE.test(key)) {
|
|
153
|
+
ctx.addIssue({
|
|
154
|
+
code: "custom",
|
|
155
|
+
path: [targetPath, key],
|
|
156
|
+
message: `invalid project name "${key}": must match ${PROJECT_RE}`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const result = projectAccessMapSchema.safeParse(parsed);
|
|
161
|
+
if (!result.success) {
|
|
162
|
+
for (const issue of result.error.issues) {
|
|
163
|
+
ctx.addIssue({
|
|
164
|
+
code: "custom",
|
|
165
|
+
path: [targetPath, ...issue.path],
|
|
166
|
+
message: issue.message,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function parseProjectAccess(projectAccess, projectAccessFile) {
|
|
172
|
+
let jsonStr;
|
|
173
|
+
if (projectAccess !== undefined && projectAccess.trim() !== "") {
|
|
174
|
+
jsonStr = projectAccess;
|
|
175
|
+
}
|
|
176
|
+
else if (projectAccessFile !== undefined && projectAccessFile.trim() !== "") {
|
|
177
|
+
jsonStr = readFileSync(projectAccessFile, "utf-8");
|
|
178
|
+
}
|
|
179
|
+
if (!jsonStr)
|
|
180
|
+
return {};
|
|
181
|
+
const parsed = JSON.parse(jsonStr);
|
|
182
|
+
return projectAccessMapSchema.parse(parsed);
|
|
183
|
+
}
|
|
86
184
|
const httpEnvShape = {
|
|
87
185
|
AUTH_MODE: z.enum(["oidc", "none"], "AUTH_MODE must be oidc or none").default("oidc"),
|
|
88
186
|
PORT: intEnv("PORT", "8080", 0, 65535),
|
|
@@ -150,6 +248,7 @@ const rawEnvSchema = z.object({ ...httpEnvShape, ...storageEnvShape }).superRefi
|
|
|
150
248
|
if (!data || typeof data !== "object")
|
|
151
249
|
return;
|
|
152
250
|
refineGitCredentials(data, ctx);
|
|
251
|
+
refineProjectAccess(data, ctx);
|
|
153
252
|
if (data.AUTH_MODE === "none") {
|
|
154
253
|
const isLoopback = isLoopbackHost(data.HOST ?? "0.0.0.0");
|
|
155
254
|
const allowUnauthenticated = data.ALLOW_UNAUTHENTICATED_NETWORK === "true";
|
|
@@ -220,7 +319,10 @@ const rawEnvSchema = z.object({ ...httpEnvShape, ...storageEnvShape }).superRefi
|
|
|
220
319
|
}
|
|
221
320
|
}
|
|
222
321
|
}, { when: () => true });
|
|
223
|
-
const storageEnvSchema = z
|
|
322
|
+
const storageEnvSchema = z
|
|
323
|
+
.object(storageEnvShape)
|
|
324
|
+
.superRefine(refineGitCredentials, { when: () => true })
|
|
325
|
+
.superRefine(refineProjectAccess, { when: () => true });
|
|
224
326
|
function formatConfigError(error) {
|
|
225
327
|
const errorMessages = error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`);
|
|
226
328
|
return new Error(`Configuration errors:\n ${errorMessages.join("\n ")}`);
|
|
@@ -230,6 +332,7 @@ function storageFromRaw(raw, env, options) {
|
|
|
230
332
|
logLevel: raw.LOG_LEVEL,
|
|
231
333
|
dataDir: raw.DATA_DIR,
|
|
232
334
|
gitBranch: raw.GIT_BRANCH,
|
|
335
|
+
hubProject: raw.HUB_PROJECT,
|
|
233
336
|
gitRemoteUrl: raw.GIT_REMOTE_URL,
|
|
234
337
|
gitSyncIntervalSeconds: raw.GIT_SYNC_INTERVAL_SECONDS,
|
|
235
338
|
gitSshKeyPath: raw.GIT_SSH_KEY_PATH,
|
|
@@ -244,6 +347,8 @@ function storageFromRaw(raw, env, options) {
|
|
|
244
347
|
maxFileBytes: raw.MAX_FILE_BYTES,
|
|
245
348
|
maxArchiveBytes: raw.MAX_ARCHIVE_BYTES,
|
|
246
349
|
defaultStaleAfterDays: raw.DEFAULT_STALE_AFTER_DAYS,
|
|
350
|
+
metricsRetentionDays: raw.METRICS_RETENTION_DAYS,
|
|
351
|
+
projectAccess: parseProjectAccess(raw.PROJECT_ACCESS, raw.PROJECT_ACCESS_FILE),
|
|
247
352
|
};
|
|
248
353
|
}
|
|
249
354
|
/** Storage-only settings for the stdio transport: needs neither PUBLIC_BASE_URL nor OAUTH_*. */
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { METRICS_WINDOWS } from "../metrics/types.js";
|
|
3
|
+
const metricsQuery = z.object({ window: z.enum(METRICS_WINDOWS).default("24h") });
|
|
4
|
+
/** Reads MetricsStore directly: metrics are not knowledge, and the service stays free of database access. */
|
|
5
|
+
export function registerMetricsRoutes(app, metrics) {
|
|
6
|
+
app.get("/api/v1/metrics", { config: { permission: "read" } }, async (req) => {
|
|
7
|
+
const { window } = metricsQuery.parse(req.query);
|
|
8
|
+
return metrics.report(window);
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=metrics.js.map
|
package/dist/http/rest.js
CHANGED
|
@@ -105,8 +105,24 @@ export function registerRestRoutes(app, service) {
|
|
|
105
105
|
const write = { permission: "write" };
|
|
106
106
|
const admin = { permission: "admin" };
|
|
107
107
|
app.get("/api/v1/projects", { config: read }, async (req) => {
|
|
108
|
-
const { repository } = z
|
|
109
|
-
|
|
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 });
|
|
110
126
|
});
|
|
111
127
|
app.post("/api/v1/projects", { config: write }, async (req, reply) => {
|
|
112
128
|
const body = z
|
|
@@ -117,7 +133,7 @@ export function registerRestRoutes(app, service) {
|
|
|
117
133
|
});
|
|
118
134
|
app.get("/api/v1/projects/:project", { config: read }, async (req) => {
|
|
119
135
|
const { project } = projectParams.parse(req.params);
|
|
120
|
-
return service.getProject(project);
|
|
136
|
+
return service.getProject(principalOf(req), project);
|
|
121
137
|
});
|
|
122
138
|
app.delete("/api/v1/projects/:project", { config: admin }, async (req) => {
|
|
123
139
|
const { project } = projectParams.parse(req.params);
|
|
@@ -126,11 +142,11 @@ export function registerRestRoutes(app, service) {
|
|
|
126
142
|
app.get("/api/v1/projects/:project/index", { config: read }, async (req) => {
|
|
127
143
|
const { project } = projectParams.parse(req.params);
|
|
128
144
|
const { path } = z.object({ path: z.string().optional() }).parse(req.query);
|
|
129
|
-
return service.getIndex(project, path ?? "");
|
|
145
|
+
return service.getIndex(principalOf(req), project, path ?? "");
|
|
130
146
|
});
|
|
131
147
|
app.get("/api/v1/projects/:project/concepts/*", { config: read }, async (req, reply) => {
|
|
132
148
|
const { project, "*": id } = wildcardParams.parse(req.params);
|
|
133
|
-
const concept = withRevision(reply, await service.readConcept(project, id));
|
|
149
|
+
const concept = withRevision(reply, await service.readConcept(principalOf(req), project, id));
|
|
134
150
|
if ((req.headers.accept ?? "").includes("text/markdown")) {
|
|
135
151
|
return reply.type("text/markdown; charset=utf-8").send(concept.markdown);
|
|
136
152
|
}
|
|
@@ -173,7 +189,7 @@ export function registerRestRoutes(app, service) {
|
|
|
173
189
|
});
|
|
174
190
|
app.get("/api/v1/projects/:project/files/*", { config: read }, async (req, reply) => {
|
|
175
191
|
const { project, "*": path } = wildcardParams.parse(req.params);
|
|
176
|
-
const file = withRevision(reply, await service.readFile(project, path));
|
|
192
|
+
const file = withRevision(reply, await service.readFile(principalOf(req), project, path));
|
|
177
193
|
const extension = /\.[^./]+$/.exec(path)?.[0].toLowerCase() ?? "";
|
|
178
194
|
return reply.type(CONTENT_TYPE_BY_EXTENSION[extension] ?? "text/plain; charset=utf-8").send(file.content);
|
|
179
195
|
});
|
|
@@ -204,19 +220,19 @@ export function registerRestRoutes(app, service) {
|
|
|
204
220
|
const { id, limit } = z
|
|
205
221
|
.object({ id: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).optional() })
|
|
206
222
|
.parse(req.query);
|
|
207
|
-
return service.history(project, id, limit);
|
|
223
|
+
return service.history(principalOf(req), project, id, limit);
|
|
208
224
|
});
|
|
209
225
|
app.get("/api/v1/projects/:project/lint", { config: read }, async (req) => {
|
|
210
226
|
const { project } = projectParams.parse(req.params);
|
|
211
|
-
return service.lint(project);
|
|
227
|
+
return service.lint(principalOf(req), project);
|
|
212
228
|
});
|
|
213
229
|
app.get("/api/v1/projects/:project/conflicts", { config: read }, async (req) => {
|
|
214
230
|
const { project } = projectParams.parse(req.params);
|
|
215
|
-
return service.listConflicts(project);
|
|
231
|
+
return service.listConflicts(principalOf(req), project);
|
|
216
232
|
});
|
|
217
233
|
app.get("/api/v1/projects/:project/conflicts/:id/files/*", { config: read }, async (req) => {
|
|
218
234
|
const { project, id, "*": path, } = z.object({ project: z.string(), id: z.string(), "*": z.string() }).parse(req.params);
|
|
219
|
-
return service.readConflict(project, id, path);
|
|
235
|
+
return service.readConflict(principalOf(req), project, id, path);
|
|
220
236
|
});
|
|
221
237
|
app.post("/api/v1/projects/:project/conflicts/:id/resolution", { config: write }, async (req, reply) => {
|
|
222
238
|
const { project, id } = z.object({ project: z.string(), id: z.string() }).parse(req.params);
|
|
@@ -226,7 +242,7 @@ export function registerRestRoutes(app, service) {
|
|
|
226
242
|
});
|
|
227
243
|
app.get("/api/v1/projects/:project/archive", { config: read }, async (req, reply) => {
|
|
228
244
|
const { project } = projectParams.parse(req.params);
|
|
229
|
-
const stream =
|
|
245
|
+
const stream = service.exportArchive(principalOf(req), project);
|
|
230
246
|
return reply
|
|
231
247
|
.type("application/gzip")
|
|
232
248
|
.header("content-disposition", `attachment; filename="${project}.tar.gz"`)
|
|
@@ -241,7 +257,7 @@ export function registerRestRoutes(app, service) {
|
|
|
241
257
|
});
|
|
242
258
|
app.get("/api/v1/search", { config: read }, async (req) => {
|
|
243
259
|
const q = searchQuery.parse(req.query);
|
|
244
|
-
return service.search({
|
|
260
|
+
return service.search(principalOf(req), {
|
|
245
261
|
...(q.q === undefined ? {} : { query: q.q }),
|
|
246
262
|
...(q.project === undefined ? {} : { project: q.project }),
|
|
247
263
|
...(q.type === undefined ? {} : { type: q.type }),
|
package/dist/local-host.js
CHANGED
package/dist/mcp/server.js
CHANGED
|
@@ -4,11 +4,11 @@ import { OkfError } from "../errors.js";
|
|
|
4
4
|
import { TRUST_TIERS } from "../okf/semantics.js";
|
|
5
5
|
import { FEEDBACK_TYPES, feedbackLink } from "../service/feedback.js";
|
|
6
6
|
import { VERSION } from "../version.js";
|
|
7
|
-
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
|
|
8
|
-
1. Discover: get_index (progressive disclosure) or search_concepts with \`project
|
|
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
|
|
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\`) 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.
|
|
11
|
-
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.
|
|
12
12
|
5. When updating, pass expectedRevision from read_concept (null to create only).
|
|
13
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.
|
|
14
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.
|
|
@@ -62,30 +62,49 @@ export function createMcpServer(service, principal, log) {
|
|
|
62
62
|
const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
63
63
|
tool("list_projects", "read", {
|
|
64
64
|
title: "List projects",
|
|
65
|
-
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).",
|
|
66
66
|
inputSchema: z.object({
|
|
67
67
|
repository: z
|
|
68
68
|
.string()
|
|
69
69
|
.optional()
|
|
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"),
|
|
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)"),
|
|
71
90
|
}),
|
|
72
91
|
annotations: readOnly,
|
|
73
|
-
}, (a) => service.
|
|
92
|
+
}, (a) => service.orient(principal, { question: a.question, project: a.project, limit: a.limit }));
|
|
74
93
|
tool("get_index", "read", {
|
|
75
94
|
title: "Get directory index",
|
|
76
95
|
description: "Progressive-disclosure listing of one bundle directory: its concepts grouped by type, files, and subdirectories.",
|
|
77
96
|
inputSchema: z.object({ project, path: z.string().optional().describe("directory, default bundle root") }),
|
|
78
97
|
annotations: readOnly,
|
|
79
|
-
}, (a) => service.getIndex(a.project, a.path ?? ""));
|
|
98
|
+
}, (a) => service.getIndex(principal, a.project, a.path ?? ""));
|
|
80
99
|
tool("read_concept", "read", {
|
|
81
100
|
title: "Read concept",
|
|
82
101
|
description: "Read one concept with frontmatter, body, derived trust/staleness, links, and lint issues; returns `revision` to pass back as expectedRevision when updating.",
|
|
83
102
|
inputSchema: z.object({ project, id }),
|
|
84
103
|
annotations: readOnly,
|
|
85
|
-
}, (a) => service.readConcept(a.project, a.id));
|
|
104
|
+
}, (a) => service.readConcept(principal, a.project, a.id));
|
|
86
105
|
tool("search_concepts", "read", {
|
|
87
106
|
title: "Search concepts",
|
|
88
|
-
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.",
|
|
89
108
|
inputSchema: z.object({
|
|
90
109
|
query: z.string().max(512).optional(),
|
|
91
110
|
project: z.string().optional(),
|
|
@@ -97,32 +116,32 @@ export function createMcpServer(service, principal, log) {
|
|
|
97
116
|
limit,
|
|
98
117
|
}),
|
|
99
118
|
annotations: readOnly,
|
|
100
|
-
}, (a) => service.search(a));
|
|
119
|
+
}, (a) => service.search(principal, a));
|
|
101
120
|
tool("get_history", "read", {
|
|
102
121
|
title: "Get history",
|
|
103
122
|
description: "List git commits that changed a concept, or the whole project when id is omitted.",
|
|
104
123
|
inputSchema: z.object({ project, id: id.optional(), limit }),
|
|
105
124
|
annotations: readOnly,
|
|
106
|
-
}, (a) => service.history(a.project, a.id, a.limit));
|
|
125
|
+
}, (a) => service.history(principal, a.project, a.id, a.limit));
|
|
107
126
|
tool("read_file", "read", {
|
|
108
127
|
title: "Read file",
|
|
109
128
|
description: "Read any text file in a bundle verbatim (including index.md, log.md, and non-markdown assets).",
|
|
110
129
|
inputSchema: z.object({ project, path: z.string().describe("bundle-relative file path") }),
|
|
111
130
|
annotations: readOnly,
|
|
112
|
-
}, (a) => service.readFile(a.project, a.path));
|
|
131
|
+
}, (a) => service.readFile(principal, a.project, a.path));
|
|
113
132
|
tool("lint_project", "read", {
|
|
114
133
|
title: "Lint project",
|
|
115
134
|
description: "Check a bundle for OKF v0.2 conformance and return errors, warnings, and info issues.",
|
|
116
135
|
inputSchema: z.object({ project }),
|
|
117
136
|
annotations: readOnly,
|
|
118
|
-
}, (a) => service.lint(a.project));
|
|
137
|
+
}, (a) => service.lint(principal, a.project));
|
|
119
138
|
const conflictId = z.string().describe("Conflict id from list_conflicts or an unresolved_conflict lint issue");
|
|
120
139
|
tool("list_conflicts", "read", {
|
|
121
140
|
title: "List conflicts",
|
|
122
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.",
|
|
123
142
|
inputSchema: z.object({ project }),
|
|
124
143
|
annotations: readOnly,
|
|
125
|
-
}, (a) => service.listConflicts(a.project));
|
|
144
|
+
}, (a) => service.listConflicts(principal, a.project));
|
|
126
145
|
tool("read_conflict", "read", {
|
|
127
146
|
title: "Read conflict file",
|
|
128
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.",
|
|
@@ -132,7 +151,7 @@ export function createMcpServer(service, principal, log) {
|
|
|
132
151
|
path: z.string().describe("bundle-relative file path, e.g. tables/orders.md"),
|
|
133
152
|
}),
|
|
134
153
|
annotations: readOnly,
|
|
135
|
-
}, (a) => service.readConflict(a.project, a.id, a.path));
|
|
154
|
+
}, (a) => service.readConflict(principal, a.project, a.id, a.path));
|
|
136
155
|
tool("resolve_conflict", "write", {
|
|
137
156
|
title: "Resolve conflict",
|
|
138
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.",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inclusive upper bounds (ms) of the latency histogram buckets; one overflow bucket follows the last.
|
|
3
|
+
* Changing them changes the database columns: bump SCHEMA_VERSION in store.ts.
|
|
4
|
+
*/
|
|
5
|
+
export const LATENCY_BUCKETS_MS = [
|
|
6
|
+
0.1, 0.25, 0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000,
|
|
7
|
+
];
|
|
8
|
+
export const BUCKET_COUNT = LATENCY_BUCKETS_MS.length + 1;
|
|
9
|
+
export function bucketIndex(ms) {
|
|
10
|
+
const i = LATENCY_BUCKETS_MS.findIndex((upper) => ms <= upper);
|
|
11
|
+
return i === -1 ? BUCKET_COUNT - 1 : i;
|
|
12
|
+
}
|
|
13
|
+
/** Estimates quantile q (0..1) by linear interpolation inside the bucket holding the rank, clamped to [minMs, maxMs]. */
|
|
14
|
+
export function quantile(buckets, q, minMs, maxMs) {
|
|
15
|
+
let total = 0;
|
|
16
|
+
for (const c of buckets)
|
|
17
|
+
total += c;
|
|
18
|
+
if (total === 0)
|
|
19
|
+
return 0;
|
|
20
|
+
const rank = q * total;
|
|
21
|
+
let cum = 0;
|
|
22
|
+
for (let i = 0; i < buckets.length; i++) {
|
|
23
|
+
const c = buckets[i] ?? 0;
|
|
24
|
+
if (c === 0)
|
|
25
|
+
continue;
|
|
26
|
+
if (cum + c >= rank) {
|
|
27
|
+
const lower = i === 0 ? 0 : (LATENCY_BUCKETS_MS[i - 1] ?? 0);
|
|
28
|
+
const upper = i < LATENCY_BUCKETS_MS.length ? (LATENCY_BUCKETS_MS[i] ?? maxMs) : maxMs;
|
|
29
|
+
const value = lower + (upper - lower) * ((rank - cum) / c);
|
|
30
|
+
return Math.min(Math.max(value, minMs), maxMs);
|
|
31
|
+
}
|
|
32
|
+
cum += c;
|
|
33
|
+
}
|
|
34
|
+
return maxMs;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=histogram.js.map
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { finished, Readable } from "node:stream";
|
|
3
|
+
import { OkfError } from "../errors.js";
|
|
4
|
+
/** The service reports client errors only as OkfError with a 4xx status; anything else is a server error. */
|
|
5
|
+
export function outcomeOf(err) {
|
|
6
|
+
return err instanceof OkfError && err.status < 500 ? "client_error" : "server_error";
|
|
7
|
+
}
|
|
8
|
+
async function time(sink, layer, op, fn) {
|
|
9
|
+
const startedAt = performance.now();
|
|
10
|
+
try {
|
|
11
|
+
const value = await fn();
|
|
12
|
+
sink.record(layer, op, startedAt, "ok");
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
sink.record(layer, op, startedAt, outcomeOf(err));
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Records once the stream ends, errors, or is destroyed. */
|
|
21
|
+
function timeStream(sink, layer, op, startedAt, stream) {
|
|
22
|
+
finished(stream, (err) => sink.record(layer, op, startedAt, err ? outcomeOf(err) : "ok"));
|
|
23
|
+
return stream;
|
|
24
|
+
}
|
|
25
|
+
class InstrumentedTx {
|
|
26
|
+
inner;
|
|
27
|
+
sink;
|
|
28
|
+
constructor(inner, sink) {
|
|
29
|
+
this.inner = inner;
|
|
30
|
+
this.sink = sink;
|
|
31
|
+
}
|
|
32
|
+
writeFile(project, path, content) {
|
|
33
|
+
return time(this.sink, "storage", "tx.writeFile", () => this.inner.writeFile(project, path, content));
|
|
34
|
+
}
|
|
35
|
+
deleteFile(project, path) {
|
|
36
|
+
return time(this.sink, "storage", "tx.deleteFile", () => this.inner.deleteFile(project, path));
|
|
37
|
+
}
|
|
38
|
+
deleteProject(project) {
|
|
39
|
+
return time(this.sink, "storage", "tx.deleteProject", () => this.inner.deleteProject(project));
|
|
40
|
+
}
|
|
41
|
+
replaceProject(project, source) {
|
|
42
|
+
return time(this.sink, "storage", "tx.replaceProject", () => this.inner.replaceProject(project, source));
|
|
43
|
+
}
|
|
44
|
+
resolveConflict(project, id) {
|
|
45
|
+
return time(this.sink, "storage", "tx.resolveConflict", () => this.inner.resolveConflict(project, id));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Times every StorageBackend and StorageTx call under layer `storage`, op = method name. */
|
|
49
|
+
export class InstrumentedStorage {
|
|
50
|
+
inner;
|
|
51
|
+
sink;
|
|
52
|
+
constructor(inner, sink) {
|
|
53
|
+
this.inner = inner;
|
|
54
|
+
this.sink = sink;
|
|
55
|
+
}
|
|
56
|
+
projects() {
|
|
57
|
+
return time(this.sink, "storage", "projects", () => this.inner.projects());
|
|
58
|
+
}
|
|
59
|
+
tree(project) {
|
|
60
|
+
return time(this.sink, "storage", "tree", () => this.inner.tree(project));
|
|
61
|
+
}
|
|
62
|
+
readFile(project, path) {
|
|
63
|
+
return time(this.sink, "storage", "readFile", () => this.inner.readFile(project, path));
|
|
64
|
+
}
|
|
65
|
+
/** Duration includes lock wait, the work, commit, and push. */
|
|
66
|
+
transaction(spec, work) {
|
|
67
|
+
return time(this.sink, "storage", "transaction", () => this.inner.transaction(spec, (tx) => work(new InstrumentedTx(tx, this.sink))));
|
|
68
|
+
}
|
|
69
|
+
setResyncHandler(handler) {
|
|
70
|
+
this.inner.setResyncHandler((project, tx) => handler(project, new InstrumentedTx(tx, this.sink)));
|
|
71
|
+
}
|
|
72
|
+
history(project, path, limit) {
|
|
73
|
+
return time(this.sink, "storage", "history", () => this.inner.history(project, path, limit));
|
|
74
|
+
}
|
|
75
|
+
archive(project) {
|
|
76
|
+
const startedAt = performance.now();
|
|
77
|
+
let stream;
|
|
78
|
+
try {
|
|
79
|
+
stream = this.inner.archive(project);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
this.sink.record("storage", "archive", startedAt, outcomeOf(err));
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
return timeStream(this.sink, "storage", "archive", startedAt, stream);
|
|
86
|
+
}
|
|
87
|
+
sync() {
|
|
88
|
+
return time(this.sink, "storage", "sync", () => this.inner.sync());
|
|
89
|
+
}
|
|
90
|
+
syncStatus() {
|
|
91
|
+
return time(this.sink, "storage", "syncStatus", () => this.inner.syncStatus());
|
|
92
|
+
}
|
|
93
|
+
conflicts(project) {
|
|
94
|
+
return time(this.sink, "storage", "conflicts", () => this.inner.conflicts(project));
|
|
95
|
+
}
|
|
96
|
+
readConflictFile(project, id, side, path) {
|
|
97
|
+
return time(this.sink, "storage", "readConflictFile", () => this.inner.readConflictFile(project, id, side, path));
|
|
98
|
+
}
|
|
99
|
+
close() {
|
|
100
|
+
return this.inner.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Times every KnowledgeService method call under layer `service`, op = method name. A Proxy covers methods added
|
|
105
|
+
* later; methods run bound to the raw service, so its internal `this.*` calls are not counted twice.
|
|
106
|
+
*/
|
|
107
|
+
export function instrumentService(service, sink) {
|
|
108
|
+
const wrappers = new Map();
|
|
109
|
+
return new Proxy(service, {
|
|
110
|
+
get(target, prop) {
|
|
111
|
+
const value = Reflect.get(target, prop);
|
|
112
|
+
if (typeof prop !== "string" || prop === "constructor" || typeof value !== "function")
|
|
113
|
+
return value;
|
|
114
|
+
let wrapper = wrappers.get(prop);
|
|
115
|
+
if (!wrapper) {
|
|
116
|
+
wrapper = (...args) => {
|
|
117
|
+
const startedAt = performance.now();
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
result = Reflect.apply(value, target, args);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
sink.record("service", prop, startedAt, outcomeOf(err));
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
if (result instanceof Promise) {
|
|
127
|
+
return result.then((v) => {
|
|
128
|
+
sink.record("service", prop, startedAt, "ok");
|
|
129
|
+
return v;
|
|
130
|
+
}, (err) => {
|
|
131
|
+
sink.record("service", prop, startedAt, outcomeOf(err));
|
|
132
|
+
throw err;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (result instanceof Readable)
|
|
136
|
+
return timeStream(sink, "service", prop, startedAt, result);
|
|
137
|
+
sink.record("service", prop, startedAt, "ok");
|
|
138
|
+
return result;
|
|
139
|
+
};
|
|
140
|
+
wrappers.set(prop, wrapper);
|
|
141
|
+
}
|
|
142
|
+
return wrapper;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=instrument.js.map
|