mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 13:17:07 +00:00
* OpenFleet reference implementation: @logicsrc/openfleet 0.1.0 and logicsrc fleet Ship what docs/openfleet.md describes. The new workspace package holds the record (write once, never overwrite, 0600), the ledger (append-only JSON Lines, merged across ledger*.jsonl by at), the ceiling rules (whole fleet ceiling, narrowed swarm keys, a merge that never widens, refusals by key), claiming and deriving exactly as the spec's "Claiming and deriving" and rule 13, and fold(), which turns any $OPENFLEET_HOME plus the engine rosters into the tree the landing page shows. logicsrc fleet open|cap|tree|stop|log are the sysop's verbs, every one with --json. open and cap exit 4 when OPENFLEET_MEMBER is set; stop exits 4 outside the caller's subtree, ends nested swarms first, goes through each member's own engine (claude stop, moshcode herd kill, tmux kill-pane, a signal for claude-p) and writes one swarm.end per swarm. tree reads claude agents --json --all and ~/.moshcode/herd/sessions.json when it can, draws recordless sessions as roster roots of the implicit fleet, and writes member.end lost for a recorded member its engine no longer lists. Claude Code takes part through hooks: logicsrc fleet hooks install merges SessionStart, UserPromptSubmit, PreToolUse, Stop and SessionEnd into ~/.claude/settings.json without clobbering it, and logicsrc fleet hook <Event> runs each one. SessionStart claims, derives or writes a root record and hands the member its variables through CLAUDE_ENV_FILE; UserPromptSubmit checks the ceiling with the permission mode the engine reports and writes member.start, or refuses the first prompt with exit 2 and ceiling.refuse; PreToolUse denies an edit outside piece.owns; Stop and SessionEnd write member.end. A hand-started root takes the engine's reported approvals before member.start, since the command line only guesses them. Hooks never fail the engine: everything is caught and logged to hooks.log. The spec and the landing page now say what ships, keep Status 0.1, and record the two verified Claude Code limits: a background job dispatched from claude agents gets no launcher environment, and OPENFLEET_* exported at SessionStart reach the member's tools but not later hooks, so hooks key on session_id through $OPENFLEET_HOME/sessions/<session_id>.json. PRD 0008 covers the work. CLI 0.2.1 -> 0.3.0; build and build:cli chains build the package before the CLI; README and docs/cli.md list the group. Tests: 95 in the package (record, ledger merge, every narrower case, the worked example's claim and derive, the folded tree, hook install idempotence, each hook handler including the exit-2 refusal and the PreToolUse deny, every verb with fake deps) and 4 in the CLI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV * OpenFleet fix round: rebuild the ceiling from the ledger, once-markers, rule 6 in tree, lost only for what a roster can hold The review of the reference implementation against moshcode found the two readers disagreeing on the same files. This round applies the shared rulings so both sides read a ledger the same way. Ceiling (R-A, R-B, R-C, R1, R6, R10, R15, R17): memberCeiling rebuilds the effective ceiling from the ledger on every read. The latest fleet-target fleet.cap (else fleet.open, else the implicit fleet's) replaces the copy in a record, so a sysop's widening cap reaches running members; then each swarm.spawn narrowing down the path, then swarm caps last. In the implicit fleet a parentless record's own approvals enters at the root; a ceiling a writer left without the key is never read as native, and startMember fills it with the engine's word while the record is unclaimed. A fleet.open or cap with no hosts means the host it was written on (R23). Once-markers (R-G, R28): member.start, member.end and swarm.end each take an exclusive create under fleets/<fleet>/marks/<event>.<id> before the append; a lost end takes <id>.lost so a real end can still supersede it. The hooks let a real end follow a lost line (R9). tree (R-F, R20): run by the sysop it enforces rule 6, stopping a member past its effective until with state timeout and the members of a swarm or fleet at its budget with state budget, then writes swarm.end for each swarm touched once it is complete. An agent's tree stops nothing. lost is written only for a member its engine's roster can hold: a claude-code background job (8-hex member or session) or a moshcode pane, never an interactive session claude agents does not list (R-E, R3, R14). A nested swarm is drawn under the member that spawned it and its row shows the effective ceiling (R25). stop and cap (R-D, R-H, R22, R27): swarm.end is written only once every member and every nested swarm has an end line that counts; an engine that will not end a member leaves it without an end line and the verb exits non-zero. claude stop takes the job id: the member of a background job, else the first eight characters of a session UUID; an interactive session with no job id cannot be stopped and the tool says so. cap on a swarm refuses a key that would widen. A derived claude-code job is named by its job id and carries no pid. Also: R-I (endMember ends only the engine-minted swarm of one), R35 (a derived record's guessed approvals corrected at UserPromptSubmit), R32 (the UserPromptSubmit hook passes only exit 2 through), R31 (package README), R36 (rule 13 says the launcher test is unimplemented in 0.1), docs and PRD 0008 updated for lost, rule 6 and the markers. 113 openfleet tests, 93 CLI tests, contract green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV * openfleet hooks: no member.end for a member that never started A first prompt refused by the ceiling still lets the session wind down through Stop and SessionEnd; those handlers now write nothing when the ledger holds no member.start for the member, so a refused member is never drawn as done. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV * logicsrc-mcp test: the next free PRD id is 0009 now that PRD 0008 exists Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
227 lines
9.1 KiB
TypeScript
227 lines
9.1 KiB
TypeScript
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
import { initOntologyPackage } from "@logicsrc/openontology";
|
|
import { createLogicSrcMcpServer } from "./server.js";
|
|
|
|
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
const dirs: string[] = [];
|
|
|
|
let ontologyDir: string;
|
|
|
|
beforeAll(() => {
|
|
ontologyDir = mkdtempSync(join(tmpdir(), "mcp-ontology-"));
|
|
dirs.push(ontologyDir);
|
|
initOntologyPackage(ontologyDir, { id: "test-ecosystem", now: "2026-07-26T00:00:00Z" });
|
|
process.env.OPENONTOLOGY_PACKAGE = ontologyDir;
|
|
process.env.OPENPRD_DIR = join(REPO, "prd");
|
|
});
|
|
|
|
afterAll(() => {
|
|
delete process.env.OPENONTOLOGY_PACKAGE;
|
|
delete process.env.OPENPRD_DIR;
|
|
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function connect() {
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
const server = createLogicSrcMcpServer();
|
|
const client = new Client({ name: "test-client", version: "0.1.0" });
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
return client;
|
|
}
|
|
|
|
function toolText(result: unknown): string {
|
|
const content = (result as { content?: Array<{ type: string; text?: string }> }).content ?? [];
|
|
return content.map((entry) => entry.text ?? "").join("\n");
|
|
}
|
|
|
|
/** Resource contents are text-or-blob in the SDK types; these fixtures are text. */
|
|
function resourceText(contents: unknown): string {
|
|
const entry = (contents as Array<{ text?: string }>)[0];
|
|
return entry?.text ?? "";
|
|
}
|
|
|
|
function isError(result: unknown): boolean {
|
|
return (result as { isError?: boolean }).isError === true;
|
|
}
|
|
|
|
describe("MCP: OpenOntology", () => {
|
|
it("exposes the spec, manifest, schema, and saved queries as resources", async () => {
|
|
const client = await connect();
|
|
const uris = (await client.listResources()).resources.map((resource) => resource.uri);
|
|
expect(uris).toContain("logicsrc://openontology/spec");
|
|
expect(uris).toContain("ontology://test-ecosystem/manifest");
|
|
expect(uris).toContain("ontology://test-ecosystem/schema");
|
|
expect(uris).toContain("ontology://test-ecosystem/queries");
|
|
});
|
|
|
|
it("serves the ontology schema resource", async () => {
|
|
const client = await connect();
|
|
const resource = await client.readResource({ uri: "ontology://test-ecosystem/schema" });
|
|
expect(resourceText(resource.contents)).toContain("worksOn");
|
|
});
|
|
|
|
it("runs a query and returns claim ids with the ontology version (R160)", async () => {
|
|
const client = await connect();
|
|
const result = await client.callTool({ name: "ontology_query", arguments: { savedQuery: "contributors" } });
|
|
const payload = JSON.parse(toolText(result)) as {
|
|
ontology: string;
|
|
rows: Array<{ claims: string[] }>;
|
|
resultId: string;
|
|
};
|
|
expect(payload.ontology).toBe("test-ecosystem@0.1.0");
|
|
expect(payload.rows.length).toBeGreaterThan(0);
|
|
expect(payload.rows[0]!.claims.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("explains a result down to sources", async () => {
|
|
const client = await connect();
|
|
const query = await client.callTool({ name: "ontology_query", arguments: { savedQuery: "contributors" } });
|
|
const { resultId } = JSON.parse(toolText(query)) as { resultId: string };
|
|
|
|
const explained = await client.callTool({
|
|
name: "ontology_explain",
|
|
arguments: { resultId, row: 0 }
|
|
});
|
|
const payload = JSON.parse(toolText(explained)) as { claims: Array<{ sources: unknown[] }> };
|
|
expect(payload.claims[0]!.sources.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("validates the loaded package", async () => {
|
|
const client = await connect();
|
|
const result = await client.callTool({ name: "ontology_validate", arguments: { strict: true } });
|
|
expect(toolText(result)).toContain("Validation passed");
|
|
});
|
|
|
|
it("finds entities with ranked evidence rather than a silent match", async () => {
|
|
const client = await connect();
|
|
const result = await client.callTool({ name: "ontology_find_entities", arguments: { text: "Alice" } });
|
|
const matches = JSON.parse(toolText(result)) as Array<{ id: string; matchedOn: string; score: number }>;
|
|
expect(matches[0]!.id).toBe("test:person:alice");
|
|
expect(matches[0]!.matchedOn).toBeTruthy();
|
|
});
|
|
|
|
it("refuses to propose when the server is read-only (the default)", async () => {
|
|
const client = await connect();
|
|
const denied = await client.callTool({
|
|
name: "ontology_propose_claim",
|
|
arguments: {
|
|
subject: "test:person:alice",
|
|
predicate: "worksOn",
|
|
objectEntity: "test:project:docs-portal",
|
|
source: "test:source:repo"
|
|
}
|
|
});
|
|
expect(isError(denied)).toBe(true);
|
|
expect(toolText(denied)).toContain("ontology:claim:propose");
|
|
});
|
|
|
|
it("proposes a change set when writes are enabled — and still cannot apply it", async () => {
|
|
process.env.OPENONTOLOGY_MCP_WRITABLE = "1";
|
|
try {
|
|
const client = await connect();
|
|
const proposed = await client.callTool({
|
|
name: "ontology_propose_claim",
|
|
arguments: {
|
|
subject: "test:person:alice",
|
|
predicate: "worksOn",
|
|
objectEntity: "test:project:docs-portal",
|
|
source: "test:source:repo",
|
|
runId: "run_mcp_1"
|
|
}
|
|
});
|
|
const payload = JSON.parse(toolText(proposed)) as { changeSet: string; status: string };
|
|
expect(payload.status).toBe("proposed");
|
|
|
|
// Enabling writes buys proposals, not applies: the actor is an agent.
|
|
const applied = await client.callTool({
|
|
name: "ontology_apply_changeset",
|
|
arguments: { changeSet: payload.changeSet }
|
|
});
|
|
expect(isError(applied)).toBe(true);
|
|
expect(toolText(applied)).toMatch(/never apply directly/);
|
|
} finally {
|
|
delete process.env.OPENONTOLOGY_MCP_WRITABLE;
|
|
}
|
|
});
|
|
|
|
it("exports Turtle and SHACL", async () => {
|
|
const client = await connect();
|
|
const turtle = await client.callTool({ name: "ontology_export", arguments: { format: "turtle" } });
|
|
expect(toolText(turtle)).toContain("@prefix oo:");
|
|
|
|
const shacl = await client.callTool({ name: "ontology_export", arguments: { format: "shacl" } });
|
|
expect(toolText(shacl)).toContain("sh:NodeShape");
|
|
});
|
|
|
|
it("registers the ontology prompts", async () => {
|
|
const client = await connect();
|
|
const names = (await client.listPrompts()).prompts.map((prompt) => prompt.name);
|
|
expect(names).toEqual(
|
|
expect.arrayContaining([
|
|
"design_ontology",
|
|
"map_sources_to_claims",
|
|
"resolve_entities",
|
|
"review_ontology_changeset",
|
|
"explain_ontology_answer"
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("MCP: OpenPRD", () => {
|
|
it("exposes the spec and the generated index", async () => {
|
|
const client = await connect();
|
|
const uris = (await client.listResources()).resources.map((resource) => resource.uri);
|
|
expect(uris).toContain("logicsrc://openprd/spec");
|
|
expect(uris).toContain("prd://index");
|
|
|
|
const index = await client.readResource({ uri: "prd://index" });
|
|
expect(resourceText(index.contents)).toContain("| ID | Title | Status | Tags |");
|
|
});
|
|
|
|
it("lists this repo's PRDs", async () => {
|
|
const client = await connect();
|
|
const result = await client.callTool({ name: "prd_list", arguments: {} });
|
|
const rows = JSON.parse(toolText(result)) as Array<{ id: string; title: string; requirements: number }>;
|
|
expect(rows[0]!.id).toBe("0001");
|
|
expect(rows[0]!.requirements).toBe(210);
|
|
});
|
|
|
|
it("validates the collection", async () => {
|
|
const client = await connect();
|
|
const result = await client.callTool({ name: "prd_validate", arguments: {} });
|
|
expect(toolText(result)).toContain("validation passed");
|
|
});
|
|
|
|
it("reports the next free id and the allowed lifecycle moves", async () => {
|
|
const client = await connect();
|
|
// Asserted against the live prd/ directory, so this advances with every PRD added.
|
|
expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0009");
|
|
|
|
const moves = await client.callTool({ name: "prd_next_statuses", arguments: { ref: "0001" } });
|
|
const payload = JSON.parse(toolText(moves)) as { status: string; allowedNext: string[] };
|
|
expect(payload.status).toBe("Draft");
|
|
expect(payload.allowedNext).toEqual(["Review", "Withdrawn"]);
|
|
});
|
|
|
|
it("maps requirements to validated tasks", async () => {
|
|
const client = await connect();
|
|
const result = await client.callTool({ name: "prd_tasks", arguments: { ref: "0001", priority: "P0" } });
|
|
const payload = JSON.parse(toolText(result)) as { tasks: Array<{ type: string }> };
|
|
expect(payload.tasks.length).toBeGreaterThan(100);
|
|
expect(payload.tasks[0]!.type).toBe("logicsrc.task");
|
|
});
|
|
|
|
it("registers the PRD prompts", async () => {
|
|
const client = await connect();
|
|
const names = (await client.listPrompts()).prompts.map((prompt) => prompt.name);
|
|
expect(names).toEqual(expect.arrayContaining(["write_prd", "review_prd"]));
|
|
});
|
|
});
|