@fcon-tech/portolan 0.4.5
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/LICENSE +21 -0
- package/README.md +110 -0
- package/adapters/README.md +226 -0
- package/adapters/omp/portolan-mcp +19 -0
- package/adapters/opencode/expedition-launcher +70 -0
- package/adapters/opencode/install.test.ts +105 -0
- package/adapters/opencode/install.ts +357 -0
- package/adapters/pi/portolan-mcp +19 -0
- package/adapters/scheduling/night-watch.cron +23 -0
- package/core/schema/chart.schema.json +154 -0
- package/core/src/bin/portolan.ts +84 -0
- package/core/src/chart-io.rollback-fixture.ts +55 -0
- package/core/src/chart-io.ts +121 -0
- package/core/src/chart-store.ts +137 -0
- package/core/src/chartroom/cli.ts +63 -0
- package/core/src/chartroom/render.ts +213 -0
- package/core/src/chartroom/review-template.html +232 -0
- package/core/src/chartroom/review.ts +109 -0
- package/core/src/chartroom/template.html +1090 -0
- package/core/src/fan-in.ts +84 -0
- package/core/src/harbor/chat-format.ts +154 -0
- package/core/src/harbor/cli.ts +178 -0
- package/core/src/harbor/errors.ts +22 -0
- package/core/src/harbor/fingerprint.ts +29 -0
- package/core/src/harbor/history.ts +178 -0
- package/core/src/harbor/launcher.ts +155 -0
- package/core/src/harbor/night-policy.ts +64 -0
- package/core/src/harbor/proposals.ts +324 -0
- package/core/src/harbor/run.ts +72 -0
- package/core/src/harbor/settings.ts +108 -0
- package/core/src/harbor/snapshot.ts +187 -0
- package/core/src/harbor/watch.ts +103 -0
- package/core/src/index.ts +28 -0
- package/core/src/notices.ts +117 -0
- package/core/src/perimeter.ts +44 -0
- package/core/src/server/adapter-boundary.ts +66 -0
- package/core/src/server/main.ts +27 -0
- package/core/src/server/registry.ts +609 -0
- package/core/src/server/server.ts +123 -0
- package/core/src/server/test-harness.ts +161 -0
- package/core/src/sheets.ts +151 -0
- package/core/src/staleness.ts +203 -0
- package/core/src/tools/log.ts +215 -0
- package/core/src/tools/manifests.ts +912 -0
- package/core/src/tools/neighborhood.ts +423 -0
- package/core/src/tools/shared.ts +72 -0
- package/core/src/tools/sound.ts +634 -0
- package/core/src/tools/sweep.ts +198 -0
- package/core/src/tools/symbols.ts +176 -0
- package/core/src/tools/trust-report.ts +193 -0
- package/core/src/types.ts +162 -0
- package/core/src/validate.ts +106 -0
- package/package.json +34 -0
- package/skill/SKILL.md +279 -0
- package/skill/examples/sailing-directions-example.md +35 -0
- package/skill/sailing-directions.template.md +59 -0
- package/skill/verify/checks.ts +476 -0
- package/skill/verify/dry-run.ts +738 -0
- package/skill/verify/fixture.ts +128 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* The opencode adapter — launch configuration only (design.md, decision 5).
|
|
4
|
+
*
|
|
5
|
+
* Registers the Portolan MCP server in an opencode config by writing the
|
|
6
|
+
* `mcp.portolan` block opencode expects:
|
|
7
|
+
*
|
|
8
|
+
* {
|
|
9
|
+
* "$schema": "https://opencode.ai/config.json",
|
|
10
|
+
* "mcp": {
|
|
11
|
+
* "portolan": {
|
|
12
|
+
* "type": "local",
|
|
13
|
+
* "command": ["bunx", "--package", "@fcon-tech/portolan", "portolan",
|
|
14
|
+
* "serve", "--target", "<province>"]
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* The launch line resolves the published npm package (`bunx`), so the
|
|
20
|
+
* installer works without a clone of this repository — no repo path is
|
|
21
|
+
* written into the config.
|
|
22
|
+
*
|
|
23
|
+
* The skill ships inside the same package and is COPIED into the harness's
|
|
24
|
+
* fixed skills directory (`~/.config/opencode/skills/<frontmatter name>/`;
|
|
25
|
+
* opencode resolves skills only from fixed locations — project
|
|
26
|
+
* `.opencode/skills/` or that global dir — so a reference into the package
|
|
27
|
+
* would never load). The copy is idempotent: a re-install overwrites.
|
|
28
|
+
* (pi and omp likewise resolve only their own global dirs —
|
|
29
|
+
* `~/.pi/agent/skills/`, `~/.omp/agent/skills/`, plus `~/.agents/skills/` —
|
|
30
|
+
* their installers belong in their adapters, not here.)
|
|
31
|
+
*
|
|
32
|
+
* (Shape verified against opencode 1.18.21's own `opencode mcp add`.)
|
|
33
|
+
* opencode config files are JSONC (comments and trailing commas allowed), so
|
|
34
|
+
* the merge is text surgery through a small JSONC scanner: the user's
|
|
35
|
+
* comments, formatting, and every other key survive verbatim. Only the
|
|
36
|
+
* `mcp.portolan` block is inserted or replaced. Adapters must not import
|
|
37
|
+
* tool logic; the boundary is checked by core/src/server/adapter-boundary.ts.
|
|
38
|
+
*
|
|
39
|
+
* Usage:
|
|
40
|
+
* bun adapters/opencode/install.ts --target /path/to/province
|
|
41
|
+
* bun adapters/opencode/install.ts --target . --config ~/proj/opencode.jsonc
|
|
42
|
+
*/
|
|
43
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
44
|
+
import { dirname, join, resolve } from "node:path";
|
|
45
|
+
import { parseArgs } from "node:util";
|
|
46
|
+
|
|
47
|
+
// The skill source is resolved relative to THIS file, so the same installer
|
|
48
|
+
// works from a clone (repo skill/) and from the published package
|
|
49
|
+
// (node_modules/@fcon-tech/portolan/skill/ — the files allowlist ships it).
|
|
50
|
+
const SKILL_SOURCE = join(import.meta.dir, "..", "..", "skill");
|
|
51
|
+
|
|
52
|
+
const { values } = parseArgs({
|
|
53
|
+
allowPositionals: false,
|
|
54
|
+
options: {
|
|
55
|
+
target: { type: "string" },
|
|
56
|
+
config: { type: "string" },
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (values.target === undefined) {
|
|
61
|
+
console.error("usage: bun adapters/opencode/install.ts --target <province root> [--config <opencode.jsonc>]");
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const province = resolve(values.target);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The user-level config root. Neither variable set is an environment error:
|
|
69
|
+
* silently falling back to "." would scatter configs into the cwd.
|
|
70
|
+
*/
|
|
71
|
+
function configHome(): string {
|
|
72
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
73
|
+
if (xdg !== undefined && xdg !== "") return xdg;
|
|
74
|
+
const home = process.env.HOME;
|
|
75
|
+
if (home === undefined || home === "") {
|
|
76
|
+
console.error(
|
|
77
|
+
"error: neither XDG_CONFIG_HOME nor HOME is set — cannot locate the opencode config directory. Set one and retry.",
|
|
78
|
+
);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
return join(home, ".config");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const configPath =
|
|
85
|
+
values.config !== undefined
|
|
86
|
+
? resolve(values.config)
|
|
87
|
+
: join(configHome(), "opencode", "opencode.jsonc");
|
|
88
|
+
|
|
89
|
+
// The launch line resolves the published package via bunx: opencode spawns
|
|
90
|
+
// it verbatim; the only path is the absolute province target.
|
|
91
|
+
const launchCommand = [
|
|
92
|
+
"bunx",
|
|
93
|
+
"--package",
|
|
94
|
+
"@fcon-tech/portolan",
|
|
95
|
+
"portolan",
|
|
96
|
+
"serve",
|
|
97
|
+
"--target",
|
|
98
|
+
province,
|
|
99
|
+
];
|
|
100
|
+
const portolanBlock = `{ "type": "local", "command": ${JSON.stringify(launchCommand)}, "enabled": true }`;
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// A minimal JSONC scanner. It walks the text once, tracking strings,
|
|
104
|
+
// escapes, and both comment kinds, and produces two things:
|
|
105
|
+
// - a cleaned copy (comments blanked) for JSON.parse validation with
|
|
106
|
+
// trailing commas handled;
|
|
107
|
+
// - structural spans ({ ... } pairs) so the merge can do exact text
|
|
108
|
+
// surgery instead of a rewrite that would destroy user comments.
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
interface Span {
|
|
112
|
+
open: number; // index of '{'
|
|
113
|
+
close: number; // index of matching '}'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Blank out comments (keeping offsets stable) and return structural spans in order. */
|
|
117
|
+
function scanJsonc(text: string): { cleaned: string; spans: Span[] } {
|
|
118
|
+
const chars = text.split("");
|
|
119
|
+
const spans: Span[] = [];
|
|
120
|
+
const openStack: number[] = [];
|
|
121
|
+
let i = 0;
|
|
122
|
+
let inString = false;
|
|
123
|
+
while (i < text.length) {
|
|
124
|
+
const c = text[i];
|
|
125
|
+
if (inString) {
|
|
126
|
+
if (c === "\\") {
|
|
127
|
+
i += 2;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (c === '"') inString = false;
|
|
131
|
+
i++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (c === '"') {
|
|
135
|
+
inString = true;
|
|
136
|
+
i++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
140
|
+
while (i < text.length && text[i] !== "\n") {
|
|
141
|
+
chars[i] = " ";
|
|
142
|
+
i++;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
147
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) {
|
|
148
|
+
chars[i] = text[i] === "\n" ? "\n" : " ";
|
|
149
|
+
i++;
|
|
150
|
+
}
|
|
151
|
+
if (i < text.length) {
|
|
152
|
+
chars[i] = " ";
|
|
153
|
+
chars[i + 1] = " ";
|
|
154
|
+
i += 2;
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (c === "{") {
|
|
159
|
+
openStack.push(i);
|
|
160
|
+
i++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (c === "}") {
|
|
164
|
+
const open = openStack.pop();
|
|
165
|
+
if (open !== undefined) spans.push({ open, close: i });
|
|
166
|
+
i++;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
i++;
|
|
170
|
+
}
|
|
171
|
+
if (openStack.length > 0) throw new Error("unbalanced braces in config");
|
|
172
|
+
if (inString) throw new Error("unterminated string in config");
|
|
173
|
+
return { cleaned: chars.join(""), spans };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Parse JSONC for validation: comments blanked, trailing commas dropped. */
|
|
177
|
+
function parseJsonc(text: string): unknown {
|
|
178
|
+
const { cleaned } = scanJsonc(text);
|
|
179
|
+
const noTrailing = cleaned.replace(/,(\s*[}\]])/g, "$1");
|
|
180
|
+
return JSON.parse(noTrailing);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The innermost span whose '{' sits at cleaned[start]; expects text[start] === '{'. */
|
|
184
|
+
function spanStartingAt(spans: Span[], start: number): Span {
|
|
185
|
+
const span = spans.find((s) => s.open === start);
|
|
186
|
+
if (span === undefined) throw new Error("no object found where one was expected");
|
|
187
|
+
return span;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Find a direct property key of the object `obj` whose name matches; return the index of its ':' or -1. */
|
|
191
|
+
function findPropertyColon(cleaned: string, obj: Span, spans: Span[], key: string): number {
|
|
192
|
+
const search = `"${key}"`;
|
|
193
|
+
for (let i = obj.open + 1; i < obj.close; i++) {
|
|
194
|
+
if (!cleaned.startsWith(search, i)) continue;
|
|
195
|
+
// The key must sit directly inside this object: its innermost enclosing
|
|
196
|
+
// span (ignoring strings/comments via the cleaned copy) is obj itself.
|
|
197
|
+
const enclosing = spans
|
|
198
|
+
.filter((s) => s.open < i && s.close > i && s.open >= obj.open && s.close <= obj.close)
|
|
199
|
+
.sort((a, b) => b.open - a.open)[0];
|
|
200
|
+
if (enclosing === undefined || enclosing.open === obj.open) {
|
|
201
|
+
const colon = cleaned.indexOf(":", i + search.length);
|
|
202
|
+
if (colon !== -1 && colon < obj.close) return colon;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return -1;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Upsert `mcp.portolan` into JSONC text, preserving everything else verbatim.
|
|
210
|
+
* Returns the merged text.
|
|
211
|
+
*/
|
|
212
|
+
function upsertPortolan(text: string): string {
|
|
213
|
+
const { cleaned, spans } = scanJsonc(text);
|
|
214
|
+
const root = spanStartingAt(spans, cleaned.indexOf("{"));
|
|
215
|
+
const mcpColon = findPropertyColon(cleaned, root, spans, "mcp");
|
|
216
|
+
const block = `"portolan": ${portolanBlock}`;
|
|
217
|
+
|
|
218
|
+
if (mcpColon !== -1) {
|
|
219
|
+
// The mcp object starts at the first '{' after its colon.
|
|
220
|
+
const mcpOpen = cleaned.indexOf("{", mcpColon);
|
|
221
|
+
const mcpSpan = spanStartingAt(spans, mcpOpen);
|
|
222
|
+
const portolanColon = findPropertyColon(cleaned, mcpSpan, spans, "portolan");
|
|
223
|
+
if (portolanColon !== -1) {
|
|
224
|
+
// Replace the existing value: from the value start to its end (the
|
|
225
|
+
// innermost span starting after the colon, else up to ',' or '}').
|
|
226
|
+
const nextBrace = cleaned.indexOf("{", portolanColon);
|
|
227
|
+
let valueEnd: number;
|
|
228
|
+
if (nextBrace !== -1 && nextBrace < mcpSpan.close && spans.some((s) => s.open === nextBrace)) {
|
|
229
|
+
valueEnd = spanStartingAt(spans, nextBrace).close + 1;
|
|
230
|
+
} else {
|
|
231
|
+
// Non-object value: up to the next ',' or the mcp closing brace.
|
|
232
|
+
const comma = cleaned.indexOf(",", portolanColon);
|
|
233
|
+
valueEnd = comma !== -1 && comma < mcpSpan.close ? comma : mcpSpan.close;
|
|
234
|
+
}
|
|
235
|
+
return text.slice(0, portolanColon + 1) + " " + portolanBlock + text.slice(valueEnd);
|
|
236
|
+
}
|
|
237
|
+
// Insert as the first property of mcp, right after its opening brace.
|
|
238
|
+
return text.slice(0, mcpSpan.open + 1) + `\n ${block},` + text.slice(mcpSpan.open + 1);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// No mcp key: insert one before the root's closing brace.
|
|
242
|
+
const before = text.slice(0, root.close);
|
|
243
|
+
const hasKeys = cleaned.slice(root.open + 1, root.close).trim().length > 0;
|
|
244
|
+
const insert = `${hasKeys ? "," : ""}\n "mcp": { ${block} }`;
|
|
245
|
+
return before + insert + text.slice(root.close);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let finalText: string;
|
|
249
|
+
if (existsSync(configPath)) {
|
|
250
|
+
const text = readFileSync(configPath, "utf8");
|
|
251
|
+
let parsed: unknown;
|
|
252
|
+
try {
|
|
253
|
+
parsed = parseJsonc(text);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
console.error(
|
|
256
|
+
`cannot merge into ${configPath}: not a JSON/JSONC document (${err instanceof Error ? err.message : String(err)}).\n` +
|
|
257
|
+
`Add this block by hand under "mcp":\n\n` +
|
|
258
|
+
` "portolan": {\n` +
|
|
259
|
+
` "type": "local",\n` +
|
|
260
|
+
` "command": ${JSON.stringify(launchCommand)}\n` +
|
|
261
|
+
` }\n`,
|
|
262
|
+
);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
266
|
+
console.error(`cannot merge into ${configPath}: top level is not an object.`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
finalText = upsertPortolan(text);
|
|
270
|
+
} else {
|
|
271
|
+
finalText =
|
|
272
|
+
`{\n` +
|
|
273
|
+
` "$schema": "https://opencode.ai/config.json",\n` +
|
|
274
|
+
` "mcp": { "portolan": ${portolanBlock} }\n` +
|
|
275
|
+
`}\n`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
279
|
+
// Stage-and-rename: the file we promised to merge into verbatim is replaced
|
|
280
|
+
// atomically, so a crash mid-write truncates the temp file, never the
|
|
281
|
+
// operator's config.
|
|
282
|
+
const configTmp = `${configPath}.tmp-${Date.now()}`;
|
|
283
|
+
writeFileSync(configTmp, finalText);
|
|
284
|
+
renameSync(configTmp, configPath);
|
|
285
|
+
|
|
286
|
+
// The skill's destination directory name must equal the SKILL.md frontmatter
|
|
287
|
+
// name (opencode's rule), so it is read from the shipped file itself rather
|
|
288
|
+
// than hardcoded.
|
|
289
|
+
function skillName(): string {
|
|
290
|
+
const text = readFileSync(join(SKILL_SOURCE, "SKILL.md"), "utf8");
|
|
291
|
+
const match = /^name:\s*(\S+)\s*$/m.exec(text);
|
|
292
|
+
if (match === null) throw new Error(`no frontmatter name in ${join(SKILL_SOURCE, "SKILL.md")}`);
|
|
293
|
+
return match[1];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const skillDirName = skillName();
|
|
297
|
+
// The name becomes a directory under the skills root and the rmSync target:
|
|
298
|
+
// a hostile or malformed frontmatter must never steer the wipe outside it.
|
|
299
|
+
// (A lone ".." passes the character class, so it is rejected explicitly.)
|
|
300
|
+
if (!/^[A-Za-z0-9._-]+$/.test(skillDirName) || skillDirName === "..") {
|
|
301
|
+
console.error(
|
|
302
|
+
`error: skill frontmatter name ${JSON.stringify(skillDirName)} is not a safe directory name ` +
|
|
303
|
+
`(expected [A-Za-z0-9._-], not "..") — refusing to install the skill.`,
|
|
304
|
+
);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
const skillsRoot = join(configHome(), "opencode", "skills", skillDirName);
|
|
308
|
+
// Idempotent overwrite: a re-install replaces the previous copy wholesale so
|
|
309
|
+
// no stale file from an older package version survives.
|
|
310
|
+
rmSync(skillsRoot, { recursive: true, force: true });
|
|
311
|
+
cpSync(SKILL_SOURCE, skillsRoot, { recursive: true });
|
|
312
|
+
|
|
313
|
+
// The province's AGENTS.md carries the harbor protocol so every harness
|
|
314
|
+
// that reads agent instructions (opencode first) knows to propose
|
|
315
|
+
// expeditions at session start — no prompt injection needed. The skill is
|
|
316
|
+
// named, not pathed: the harness loads it from its own skills directory, so
|
|
317
|
+
// the block carries no machine path and works identically from a clone or
|
|
318
|
+
// the published package.
|
|
319
|
+
const agentsPath = join(province, "AGENTS.md");
|
|
320
|
+
const BLOCK_BEGIN = "<!-- portolan:harbor:begin -->";
|
|
321
|
+
const BLOCK_END = "<!-- portolan:harbor:end -->";
|
|
322
|
+
const block =
|
|
323
|
+
`${BLOCK_BEGIN}\n` +
|
|
324
|
+
`## Portolan province\n\n` +
|
|
325
|
+
`This target is a charted Portolan province (Chart at \`.portolan/chart/\`, ship's log at \`.portolan/log.jsonl\`).\n\n` +
|
|
326
|
+
`At session start, before other work: call the \`expeditions.propose\` tool from the \`portolan\` MCP server; ` +
|
|
327
|
+
`if the queue is non-empty, present the top proposals in ONE chat message (kind, evidence summary, scope) ` +
|
|
328
|
+
`and ask for a one-phrase decision; record it with \`expeditions.decide\`. ` +
|
|
329
|
+
`Answer landscape questions from the Chart, citing anchors and trust labels. ` +
|
|
330
|
+
`The full Cartographer's method: the \`${skillDirName}\` skill (your harness loads it from its skills directory). ` +
|
|
331
|
+
`Never modify anything outside \`.portolan/\`.\n` +
|
|
332
|
+
BLOCK_END;
|
|
333
|
+
let agentsText = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : "";
|
|
334
|
+
const beginIdx = agentsText.indexOf(BLOCK_BEGIN);
|
|
335
|
+
const endIdx = agentsText.indexOf(BLOCK_END);
|
|
336
|
+
if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
|
|
337
|
+
agentsText = agentsText.slice(0, beginIdx) + block + agentsText.slice(endIdx + BLOCK_END.length);
|
|
338
|
+
} else {
|
|
339
|
+
// No block, or orphan/misordered markers: strip any stray markers (and an
|
|
340
|
+
// orphaned body between a begin and a later-end pairing is already covered
|
|
341
|
+
// above; here only unmatched leftovers can remain) and append one fresh block.
|
|
342
|
+
const cleaned = agentsText
|
|
343
|
+
.replace(/<!-- portolan:harbor:begin -->[\s\S]*?<!-- portolan:harbor:end -->/g, "")
|
|
344
|
+
.split(BLOCK_BEGIN)
|
|
345
|
+
.join("")
|
|
346
|
+
.split(BLOCK_END)
|
|
347
|
+
.join("");
|
|
348
|
+
agentsText = cleaned.trim().length > 0 ? `${cleaned.replace(/\s*$/, "\n")}\n${block}\n` : `${block}\n`;
|
|
349
|
+
}
|
|
350
|
+
mkdirSync(province, { recursive: true });
|
|
351
|
+
writeFileSync(agentsPath, agentsText);
|
|
352
|
+
|
|
353
|
+
console.log(`portolan MCP server registered in ${configPath}`);
|
|
354
|
+
console.log(` province: ${province}`);
|
|
355
|
+
console.log(` launch: ${launchCommand.join(" ")}`);
|
|
356
|
+
console.log(` skill copied to: ${skillsRoot}`);
|
|
357
|
+
console.log(` harbor protocol: ${agentsPath}`);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# pi launch shim for the Portolan MCP server (design.md, decision 6).
|
|
4
|
+
#
|
|
5
|
+
# A shim adds no behavior: it execs the same server a direct launch gives
|
|
6
|
+
# you, with the arguments you pass through. Point any MCP client extension
|
|
7
|
+
# (pi/omp MCP tooling) at this script:
|
|
8
|
+
#
|
|
9
|
+
# adapters/pi/portolan-mcp --target /path/to/province
|
|
10
|
+
#
|
|
11
|
+
# Adapters must not import tool logic (checked by
|
|
12
|
+
# core/src/server/adapter-boundary.ts) and must not parse tool traffic.
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
# Bun lives at ~/.bun/bin by default; find it without copying commands.
|
|
16
|
+
command -v bun >/dev/null 2>&1 || export PATH="$HOME/.bun/bin:$PATH"
|
|
17
|
+
|
|
18
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
19
|
+
exec bun "$ROOT/core/src/server/main.ts" "$@"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Portolan night watch — drop-in crontab lines.
|
|
2
|
+
#
|
|
3
|
+
# Portolan ships no daemon: these lines are the external scheduler. Adjust
|
|
4
|
+
# PROVINCE and PORTOLAN to this machine, then install with:
|
|
5
|
+
#
|
|
6
|
+
# crontab -e # paste the lines below
|
|
7
|
+
#
|
|
8
|
+
# Both runs are deterministic over an unchanged province — a quiet run
|
|
9
|
+
# prints nothing extra to post. See adapters/README.md ("The night watch")
|
|
10
|
+
# for the policy bound (harbor.auto_repair_max_vessels) and launcher
|
|
11
|
+
# contract.
|
|
12
|
+
|
|
13
|
+
PROVINCE=/path/to/province
|
|
14
|
+
PORTOLAN=/path/to/portolan-checkout
|
|
15
|
+
|
|
16
|
+
# Report-only nightly watch: the morning list, nothing auto-launched.
|
|
17
|
+
15 2 * * * cd $PORTOLAN && bun core/src/harbor/cli.ts watch --target $PROVINCE --format chat
|
|
18
|
+
|
|
19
|
+
# Acting variant (auto-repair within the configured bound through the
|
|
20
|
+
# opencode launcher) — replace the line above with:
|
|
21
|
+
#15 2 * * * cd $PORTOLAN && bun core/src/harbor/cli.ts watch --target $PROVINCE \
|
|
22
|
+
# --launcher adapters/opencode/expedition-launcher \
|
|
23
|
+
# --launcher-timeout 45m --format chat
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://portolan.dev/core/chart.schema.json",
|
|
4
|
+
"title": "Portolan Chart entry",
|
|
5
|
+
"description": "One entry of the Chart (the Padrón): vessel, fairway, port of entry, beacon, light, or danger. Every entry carries at least one anchor and exactly one trust label from the closed vocabulary. This schema is the published contract for Cartographer agents; core/src/types.ts mirrors it.",
|
|
6
|
+
"$ref": "#/$defs/chartEntry",
|
|
7
|
+
"$defs": {
|
|
8
|
+
"trustLabel": {
|
|
9
|
+
"description": "Closed trust vocabulary (chart notation): measured, charted, reported, doubtful, unsurveyed.",
|
|
10
|
+
"enum": ["measured", "charted", "reported", "doubtful", "unsurveyed"]
|
|
11
|
+
},
|
|
12
|
+
"fairwayRelation": {
|
|
13
|
+
"description": "Optional closed relation vocabulary on a fairway: build, runtime, config. A fairway without a relation stays valid and reads as untyped.",
|
|
14
|
+
"enum": ["build", "runtime", "config"]
|
|
15
|
+
},
|
|
16
|
+
"entryId": { "type": "string", "minLength": 1 },
|
|
17
|
+
"anchor": {
|
|
18
|
+
"oneOf": [
|
|
19
|
+
{
|
|
20
|
+
"type": "object",
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"required": ["type", "path"],
|
|
23
|
+
"properties": {
|
|
24
|
+
"type": { "const": "file" },
|
|
25
|
+
"path": { "type": "string", "minLength": 1 },
|
|
26
|
+
"line": { "type": "integer", "minimum": 1 }
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"type": "object",
|
|
31
|
+
"additionalProperties": false,
|
|
32
|
+
"required": ["type", "path", "key"],
|
|
33
|
+
"properties": {
|
|
34
|
+
"type": { "const": "manifest" },
|
|
35
|
+
"path": { "type": "string", "minLength": 1 },
|
|
36
|
+
"key": { "type": "string", "minLength": 1 }
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"type": "object",
|
|
41
|
+
"additionalProperties": false,
|
|
42
|
+
"required": ["type", "id"],
|
|
43
|
+
"properties": {
|
|
44
|
+
"type": { "const": "receipt" },
|
|
45
|
+
"id": { "type": "string", "minLength": 1 }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"anchors": {
|
|
51
|
+
"description": "An entry without at least one anchor does not ship.",
|
|
52
|
+
"type": "array",
|
|
53
|
+
"minItems": 1,
|
|
54
|
+
"items": { "$ref": "#/$defs/anchor" }
|
|
55
|
+
},
|
|
56
|
+
"vessel": {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"additionalProperties": false,
|
|
59
|
+
"required": ["kind", "id", "name", "paths", "anchors", "trust"],
|
|
60
|
+
"properties": {
|
|
61
|
+
"kind": { "const": "vessel" },
|
|
62
|
+
"id": { "$ref": "#/$defs/entryId" },
|
|
63
|
+
"name": { "type": "string", "minLength": 1 },
|
|
64
|
+
"behavior": { "type": "string" },
|
|
65
|
+
"paths": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
|
66
|
+
"note": { "type": "string" },
|
|
67
|
+
"anchors": { "$ref": "#/$defs/anchors" },
|
|
68
|
+
"trust": { "$ref": "#/$defs/trustLabel" }
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"fairway": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"additionalProperties": false,
|
|
74
|
+
"required": ["kind", "id", "from", "to", "anchors", "trust"],
|
|
75
|
+
"properties": {
|
|
76
|
+
"kind": { "const": "fairway" },
|
|
77
|
+
"id": { "$ref": "#/$defs/entryId" },
|
|
78
|
+
"from": { "$ref": "#/$defs/entryId" },
|
|
79
|
+
"to": { "$ref": "#/$defs/entryId" },
|
|
80
|
+
"relation": { "$ref": "#/$defs/fairwayRelation" },
|
|
81
|
+
"note": { "type": "string" },
|
|
82
|
+
"anchors": { "$ref": "#/$defs/anchors" },
|
|
83
|
+
"trust": { "$ref": "#/$defs/trustLabel" }
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"portOfEntry": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"additionalProperties": false,
|
|
89
|
+
"required": ["kind", "id", "vessel", "protocol", "anchors", "trust"],
|
|
90
|
+
"properties": {
|
|
91
|
+
"kind": { "const": "portOfEntry" },
|
|
92
|
+
"id": { "$ref": "#/$defs/entryId" },
|
|
93
|
+
"vessel": { "$ref": "#/$defs/entryId" },
|
|
94
|
+
"protocol": { "type": "string", "minLength": 1 },
|
|
95
|
+
"note": { "type": "string" },
|
|
96
|
+
"anchors": { "$ref": "#/$defs/anchors" },
|
|
97
|
+
"trust": { "$ref": "#/$defs/trustLabel" }
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"beacon": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"additionalProperties": false,
|
|
103
|
+
"required": ["kind", "id", "vessel", "surface", "key", "anchors", "trust"],
|
|
104
|
+
"properties": {
|
|
105
|
+
"kind": { "const": "beacon" },
|
|
106
|
+
"id": { "$ref": "#/$defs/entryId" },
|
|
107
|
+
"vessel": { "$ref": "#/$defs/entryId" },
|
|
108
|
+
"surface": { "enum": ["env", "flag", "port"] },
|
|
109
|
+
"key": { "type": "string", "minLength": 1 },
|
|
110
|
+
"note": { "type": "string" },
|
|
111
|
+
"anchors": { "$ref": "#/$defs/anchors" },
|
|
112
|
+
"trust": { "$ref": "#/$defs/trustLabel" }
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"light": {
|
|
116
|
+
"type": "object",
|
|
117
|
+
"additionalProperties": false,
|
|
118
|
+
"required": ["kind", "id", "vessel", "name", "anchors", "trust"],
|
|
119
|
+
"properties": {
|
|
120
|
+
"kind": { "const": "light" },
|
|
121
|
+
"id": { "$ref": "#/$defs/entryId" },
|
|
122
|
+
"vessel": { "$ref": "#/$defs/entryId" },
|
|
123
|
+
"name": { "type": "string", "minLength": 1 },
|
|
124
|
+
"note": { "type": "string" },
|
|
125
|
+
"anchors": { "$ref": "#/$defs/anchors" },
|
|
126
|
+
"trust": { "$ref": "#/$defs/trustLabel" }
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"danger": {
|
|
130
|
+
"type": "object",
|
|
131
|
+
"additionalProperties": false,
|
|
132
|
+
"required": ["kind", "id", "vessel", "category", "note", "anchors", "trust"],
|
|
133
|
+
"properties": {
|
|
134
|
+
"kind": { "const": "danger" },
|
|
135
|
+
"id": { "$ref": "#/$defs/entryId" },
|
|
136
|
+
"vessel": { "$ref": "#/$defs/entryId" },
|
|
137
|
+
"category": { "enum": ["rock", "shallow", "wreck"] },
|
|
138
|
+
"note": { "type": "string", "minLength": 1 },
|
|
139
|
+
"anchors": { "$ref": "#/$defs/anchors" },
|
|
140
|
+
"trust": { "$ref": "#/$defs/trustLabel" }
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
"chartEntry": {
|
|
144
|
+
"oneOf": [
|
|
145
|
+
{ "$ref": "#/$defs/vessel" },
|
|
146
|
+
{ "$ref": "#/$defs/fairway" },
|
|
147
|
+
{ "$ref": "#/$defs/portOfEntry" },
|
|
148
|
+
{ "$ref": "#/$defs/beacon" },
|
|
149
|
+
{ "$ref": "#/$defs/light" },
|
|
150
|
+
{ "$ref": "#/$defs/danger" }
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* The `portolan` dispatcher — the single bin the published package exposes.
|
|
4
|
+
* It routes to the existing entry points; it implements nothing itself:
|
|
5
|
+
*
|
|
6
|
+
* portolan serve --target <province root> → server/main.ts (MCP over stdio)
|
|
7
|
+
* portolan chartroom <render|review> … → chartroom/cli.ts
|
|
8
|
+
* portolan harbor <propose|watch|run> … → harbor/cli.ts
|
|
9
|
+
*
|
|
10
|
+
* `serve` runs in-process (same parse, same server wiring as main.ts);
|
|
11
|
+
* the CLIs are spawned with inherited stdio so their behavior — output,
|
|
12
|
+
* exit codes — is indistinguishable from running them directly.
|
|
13
|
+
*/
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const SUBCOMMANDS = ["serve", "chartroom", "harbor"] as const;
|
|
19
|
+
|
|
20
|
+
const usage = `usage: portolan <command> [args]
|
|
21
|
+
|
|
22
|
+
commands:
|
|
23
|
+
serve run the Portolan MCP server (stdio)
|
|
24
|
+
chartroom Chart Room CLI (render | review)
|
|
25
|
+
harbor harbor CLI (propose | watch | run)`;
|
|
26
|
+
|
|
27
|
+
function failUsage(): never {
|
|
28
|
+
console.error(`${usage}\n\nvalid commands: ${SUBCOMMANDS.join(", ")}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Path to a sibling source file of this dispatcher. */
|
|
33
|
+
function srcPath(relative: string): string {
|
|
34
|
+
return fileURLToPath(new URL(relative, import.meta.url));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function serve(rest: readonly string[]): Promise<void> {
|
|
38
|
+
// Same parse and wiring as core/src/server/main.ts — behavior
|
|
39
|
+
// indistinguishable from launching that file directly.
|
|
40
|
+
const { parseArgs } = await import("node:util");
|
|
41
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
42
|
+
const { createPortolanServer } = await import("../server/server");
|
|
43
|
+
|
|
44
|
+
const { values } = parseArgs({
|
|
45
|
+
// Same parse as main.ts, over the args after the `serve` subcommand.
|
|
46
|
+
args: rest,
|
|
47
|
+
allowPositionals: false,
|
|
48
|
+
options: {
|
|
49
|
+
target: { type: "string", default: process.cwd() },
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const targetRoot = resolve(values.target as string);
|
|
53
|
+
const server = createPortolanServer({ targetRoot });
|
|
54
|
+
await server.connect(new StdioServerTransport());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Run one of the existing CLI scripts with the remaining args, verbatim. */
|
|
58
|
+
function runCli(script: string, args: string[]): never {
|
|
59
|
+
const child = spawn(process.execPath, [script, ...args], { stdio: "inherit" });
|
|
60
|
+
child.on("exit", (code, signal) => {
|
|
61
|
+
if (signal !== null) process.kill(process.pid, signal);
|
|
62
|
+
else process.exit(code ?? 1);
|
|
63
|
+
});
|
|
64
|
+
// Never resolves in practice; keeps the dispatcher alive for the child.
|
|
65
|
+
return new Promise<never>(() => {}) as never;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function dispatch(argv: readonly string[]): Promise<void> {
|
|
69
|
+
const [command, ...rest] = argv;
|
|
70
|
+
switch (command) {
|
|
71
|
+
case "serve":
|
|
72
|
+
return serve(rest);
|
|
73
|
+
case "chartroom":
|
|
74
|
+
return runCli(srcPath("../chartroom/cli.ts"), rest);
|
|
75
|
+
case "harbor":
|
|
76
|
+
return runCli(srcPath("../harbor/cli.ts"), rest);
|
|
77
|
+
default:
|
|
78
|
+
failUsage();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (import.meta.main) {
|
|
83
|
+
await dispatch(Bun.argv.slice(2));
|
|
84
|
+
}
|