@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/adapters/README.md +226 -0
  4. package/adapters/omp/portolan-mcp +19 -0
  5. package/adapters/opencode/expedition-launcher +70 -0
  6. package/adapters/opencode/install.test.ts +105 -0
  7. package/adapters/opencode/install.ts +357 -0
  8. package/adapters/pi/portolan-mcp +19 -0
  9. package/adapters/scheduling/night-watch.cron +23 -0
  10. package/core/schema/chart.schema.json +154 -0
  11. package/core/src/bin/portolan.ts +84 -0
  12. package/core/src/chart-io.rollback-fixture.ts +55 -0
  13. package/core/src/chart-io.ts +121 -0
  14. package/core/src/chart-store.ts +137 -0
  15. package/core/src/chartroom/cli.ts +63 -0
  16. package/core/src/chartroom/render.ts +213 -0
  17. package/core/src/chartroom/review-template.html +232 -0
  18. package/core/src/chartroom/review.ts +109 -0
  19. package/core/src/chartroom/template.html +1090 -0
  20. package/core/src/fan-in.ts +84 -0
  21. package/core/src/harbor/chat-format.ts +154 -0
  22. package/core/src/harbor/cli.ts +178 -0
  23. package/core/src/harbor/errors.ts +22 -0
  24. package/core/src/harbor/fingerprint.ts +29 -0
  25. package/core/src/harbor/history.ts +178 -0
  26. package/core/src/harbor/launcher.ts +155 -0
  27. package/core/src/harbor/night-policy.ts +64 -0
  28. package/core/src/harbor/proposals.ts +324 -0
  29. package/core/src/harbor/run.ts +72 -0
  30. package/core/src/harbor/settings.ts +108 -0
  31. package/core/src/harbor/snapshot.ts +187 -0
  32. package/core/src/harbor/watch.ts +103 -0
  33. package/core/src/index.ts +28 -0
  34. package/core/src/notices.ts +117 -0
  35. package/core/src/perimeter.ts +44 -0
  36. package/core/src/server/adapter-boundary.ts +66 -0
  37. package/core/src/server/main.ts +27 -0
  38. package/core/src/server/registry.ts +609 -0
  39. package/core/src/server/server.ts +123 -0
  40. package/core/src/server/test-harness.ts +161 -0
  41. package/core/src/sheets.ts +151 -0
  42. package/core/src/staleness.ts +203 -0
  43. package/core/src/tools/log.ts +215 -0
  44. package/core/src/tools/manifests.ts +912 -0
  45. package/core/src/tools/neighborhood.ts +423 -0
  46. package/core/src/tools/shared.ts +72 -0
  47. package/core/src/tools/sound.ts +634 -0
  48. package/core/src/tools/sweep.ts +198 -0
  49. package/core/src/tools/symbols.ts +176 -0
  50. package/core/src/tools/trust-report.ts +193 -0
  51. package/core/src/types.ts +162 -0
  52. package/core/src/validate.ts +106 -0
  53. package/package.json +34 -0
  54. package/skill/SKILL.md +279 -0
  55. package/skill/examples/sailing-directions-example.md +35 -0
  56. package/skill/sailing-directions.template.md +59 -0
  57. package/skill/verify/checks.ts +476 -0
  58. package/skill/verify/dry-run.ts +738 -0
  59. 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
+ }