@fcon-tech/portolan 0.4.5 → 0.5.0

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/README.md CHANGED
@@ -65,9 +65,12 @@ bun install -g @fcon-tech/portolan # or: npm i -g @fcon-tech/portolan
65
65
  # your agent installs Portolan itself from one phrase:
66
66
  survey <target> with Portolan
67
67
 
68
- # serve the fourteen MCP tools to your harness:
68
+ # serve the fifteen MCP tools to your harness:
69
69
  portolan serve --target /path/to/province
70
70
 
71
+ # the Chart's machine layer as one JSON document (docs/formats.md):
72
+ portolan export --target /path/to/province
73
+
71
74
  # the atlas for a surveyed province (map + graph + dossier + ledger):
72
75
  portolan chartroom render --target /path/to/province
73
76
 
@@ -94,7 +97,7 @@ bun core/src/server/main.ts --target /path/to/province # dev path
94
97
 
95
98
  | Path | What lives there |
96
99
  | --- | --- |
97
- | `core/` | the Chart store, the fourteen MCP tools (stdio server), the Harbor, the Chart Room renderer |
100
+ | `core/` | the Chart store, the fifteen MCP tools (stdio server), the Harbor, the Chart Room renderer |
98
101
  | `skill/` | the Cartographer's expedition method, as a harness-loadable skill |
99
102
  | `adapters/` | opencode installer + expedition launcher, pi/omp shims, drop-in night-watch crontab |
100
103
  | `acceptance/` | the sea-trial gate: the whole loop graded against a real corpus |
@@ -4,7 +4,7 @@ How the one Portolan MCP server reaches a harness. Adapters are launch
4
4
  configuration only: they configure how the server is launched and add no
5
5
  behavior of their own — no tool filtering, no traffic parsing, no
6
6
  per-harness code paths. Two harnesses connecting through different adapters
7
- see the same fourteen tools, the same results, and the same errors as a
7
+ see the same fifteen tools, the same results, and the same errors as a
8
8
  direct launch (`openspec/specs/harness/spec.md`: "The served tools are
9
9
  harness-agnostic").
10
10
 
@@ -39,7 +39,7 @@ writes (shape verified against opencode's own `opencode mcp add`):
39
39
  ```
40
40
 
41
41
  After installing, `opencode mcp list` shows `portolan connected`, and every
42
- session can call all fourteen served tools.
42
+ session can call all fifteen served tools.
43
43
 
44
44
  The opencode adapter also ships the night watch's expedition launcher
45
45
  (`adapters/opencode/expedition-launcher`) — see "The night watch" below.
@@ -1,19 +1,33 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://portolan.dev/core/chart.schema.json",
4
+ "version": "0.1.0",
4
5
  "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
+ "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 (a standalone $ref to trust-vocabulary.schema.json — register both files). Entries as stored in index.jsonl additionally carry the store's `stale` marker, and vessels its `signature`; a hand-supplied write carries neither, the store stamps them. This schema is the published contract for Cartographer agents; core/src/types.ts mirrors it.",
6
7
  "$ref": "#/$defs/chartEntry",
7
8
  "$defs": {
8
9
  "trustLabel": {
9
- "description": "Closed trust vocabulary (chart notation): measured, charted, reported, doubtful, unsurveyed.",
10
- "enum": ["measured", "charted", "reported", "doubtful", "unsurveyed"]
10
+ "$ref": "https://portolan.dev/core/trust-vocabulary.schema.json"
11
11
  },
12
12
  "fairwayRelation": {
13
13
  "description": "Optional closed relation vocabulary on a fairway: build, runtime, config. A fairway without a relation stays valid and reads as untyped.",
14
14
  "enum": ["build", "runtime", "config"]
15
15
  },
16
16
  "entryId": { "type": "string", "minLength": 1 },
17
+ "stale": {
18
+ "description": "Store metadata on entries as stored: true when the anchored sources drifted since the last survey — the entry is pending correction (Notices to Mariners). Never hand-supplied; the store stamps it.",
19
+ "type": "boolean"
20
+ },
21
+ "signature": {
22
+ "description": "Store metadata on vessels as stored: cheap tree signature over the vessel's paths, re-stamped by the store on every write. Never hand-supplied.",
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "required": ["hash", "files"],
26
+ "properties": {
27
+ "hash": { "type": "string", "minLength": 1 },
28
+ "files": { "type": "integer", "minimum": 0 }
29
+ }
30
+ },
17
31
  "anchor": {
18
32
  "oneOf": [
19
33
  {
@@ -63,9 +77,11 @@
63
77
  "name": { "type": "string", "minLength": 1 },
64
78
  "behavior": { "type": "string" },
65
79
  "paths": { "type": "array", "items": { "type": "string", "minLength": 1 } },
80
+ "signature": { "$ref": "#/$defs/signature" },
66
81
  "note": { "type": "string" },
67
82
  "anchors": { "$ref": "#/$defs/anchors" },
68
- "trust": { "$ref": "#/$defs/trustLabel" }
83
+ "trust": { "$ref": "#/$defs/trustLabel" },
84
+ "stale": { "$ref": "#/$defs/stale" }
69
85
  }
70
86
  },
71
87
  "fairway": {
@@ -80,7 +96,8 @@
80
96
  "relation": { "$ref": "#/$defs/fairwayRelation" },
81
97
  "note": { "type": "string" },
82
98
  "anchors": { "$ref": "#/$defs/anchors" },
83
- "trust": { "$ref": "#/$defs/trustLabel" }
99
+ "trust": { "$ref": "#/$defs/trustLabel" },
100
+ "stale": { "$ref": "#/$defs/stale" }
84
101
  }
85
102
  },
86
103
  "portOfEntry": {
@@ -94,7 +111,8 @@
94
111
  "protocol": { "type": "string", "minLength": 1 },
95
112
  "note": { "type": "string" },
96
113
  "anchors": { "$ref": "#/$defs/anchors" },
97
- "trust": { "$ref": "#/$defs/trustLabel" }
114
+ "trust": { "$ref": "#/$defs/trustLabel" },
115
+ "stale": { "$ref": "#/$defs/stale" }
98
116
  }
99
117
  },
100
118
  "beacon": {
@@ -109,7 +127,8 @@
109
127
  "key": { "type": "string", "minLength": 1 },
110
128
  "note": { "type": "string" },
111
129
  "anchors": { "$ref": "#/$defs/anchors" },
112
- "trust": { "$ref": "#/$defs/trustLabel" }
130
+ "trust": { "$ref": "#/$defs/trustLabel" },
131
+ "stale": { "$ref": "#/$defs/stale" }
113
132
  }
114
133
  },
115
134
  "light": {
@@ -123,7 +142,8 @@
123
142
  "name": { "type": "string", "minLength": 1 },
124
143
  "note": { "type": "string" },
125
144
  "anchors": { "$ref": "#/$defs/anchors" },
126
- "trust": { "$ref": "#/$defs/trustLabel" }
145
+ "trust": { "$ref": "#/$defs/trustLabel" },
146
+ "stale": { "$ref": "#/$defs/stale" }
127
147
  }
128
148
  },
129
149
  "danger": {
@@ -137,7 +157,8 @@
137
157
  "category": { "enum": ["rock", "shallow", "wreck"] },
138
158
  "note": { "type": "string", "minLength": 1 },
139
159
  "anchors": { "$ref": "#/$defs/anchors" },
140
- "trust": { "$ref": "#/$defs/trustLabel" }
160
+ "trust": { "$ref": "#/$defs/trustLabel" },
161
+ "stale": { "$ref": "#/$defs/stale" }
141
162
  }
142
163
  },
143
164
  "chartEntry": {
@@ -0,0 +1,203 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://portolan.dev/core/graph-export.schema.json",
4
+ "title": "Portolan adjacency graph export",
5
+ "version": "0.1.0",
6
+ "description": "The chart.export document: the Chart's machine layer rendered as nodes and edges, consumable without the MCP server. Arithmetic over charted bytes only — no timestamps anywhere (the ship's log carries the when), no derived rollups (no top-level `vessels` list — every node carries its vessel; no dangling-endpoint `issues[]` — a raw edge id already states the fact), no invented nodes. Nodes mirror the charted entries field-for-field plus `stale`; the store's `signature` stays in the Chart. Trust labels $ref trust-vocabulary.schema.json — register both files.",
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "required": ["format", "version", "nodes", "edges", "truncated", "omitted"],
10
+ "properties": {
11
+ "format": {
12
+ "description": "The format name; a consumer dispatches on it.",
13
+ "const": "portolan-adjacency"
14
+ },
15
+ "version": {
16
+ "description": "The graph-export schema version this document was produced against.",
17
+ "type": "string",
18
+ "minLength": 1
19
+ },
20
+ "nodes": {
21
+ "description": "Every charted entry that is not a fairway, rendered whole: the entry's charted fields as-is plus staleness.",
22
+ "type": "array",
23
+ "items": { "$ref": "#/$defs/node" }
24
+ },
25
+ "edges": {
26
+ "description": "Every charted fairway. The arrays are disjoint: nodes and edges together draw exactly the charted entries.",
27
+ "type": "array",
28
+ "items": { "$ref": "#/$defs/edge" }
29
+ },
30
+ "truncated": {
31
+ "description": "True when the export byte budget cut entries; `omitted` then names every cut vessel.",
32
+ "type": "boolean"
33
+ },
34
+ "omitted": {
35
+ "description": "The loud truncation report: one record per vessel with entries cut, empty when nothing was cut.",
36
+ "type": "array",
37
+ "items": {
38
+ "type": "object",
39
+ "additionalProperties": false,
40
+ "required": ["vessel", "entries"],
41
+ "properties": {
42
+ "vessel": { "type": "string", "minLength": 1 },
43
+ "entries": {
44
+ "description": "How many of that vessel's entries were cut.",
45
+ "type": "integer",
46
+ "minimum": 1
47
+ }
48
+ }
49
+ }
50
+ }
51
+ },
52
+ "$defs": {
53
+ "entryId": { "type": "string", "minLength": 1 },
54
+ "trustLabel": {
55
+ "$ref": "https://portolan.dev/core/trust-vocabulary.schema.json"
56
+ },
57
+ "stale": {
58
+ "description": "Pending correction: true when the anchored sources drifted since the last survey. Carried as-is, never hidden.",
59
+ "type": "boolean"
60
+ },
61
+ "anchor": {
62
+ "oneOf": [
63
+ {
64
+ "type": "object",
65
+ "additionalProperties": false,
66
+ "required": ["type", "path"],
67
+ "properties": {
68
+ "type": { "const": "file" },
69
+ "path": { "type": "string", "minLength": 1 },
70
+ "line": { "type": "integer", "minimum": 1 }
71
+ }
72
+ },
73
+ {
74
+ "type": "object",
75
+ "additionalProperties": false,
76
+ "required": ["type", "path", "key"],
77
+ "properties": {
78
+ "type": { "const": "manifest" },
79
+ "path": { "type": "string", "minLength": 1 },
80
+ "key": { "type": "string", "minLength": 1 }
81
+ }
82
+ },
83
+ {
84
+ "type": "object",
85
+ "additionalProperties": false,
86
+ "required": ["type", "id"],
87
+ "properties": {
88
+ "type": { "const": "receipt" },
89
+ "id": { "type": "string", "minLength": 1 }
90
+ }
91
+ }
92
+ ]
93
+ },
94
+ "anchors": {
95
+ "description": "An entry without at least one anchor does not ship.",
96
+ "type": "array",
97
+ "minItems": 1,
98
+ "items": { "$ref": "#/$defs/anchor" }
99
+ },
100
+ "node": {
101
+ "oneOf": [
102
+ { "$ref": "#/$defs/vessel" },
103
+ { "$ref": "#/$defs/portOfEntry" },
104
+ { "$ref": "#/$defs/beacon" },
105
+ { "$ref": "#/$defs/light" },
106
+ { "$ref": "#/$defs/danger" }
107
+ ]
108
+ },
109
+ "vessel": {
110
+ "description": "A charted vessel, rendered whole: the charted fields as-is plus `stale`. The store's `signature` is not exported.",
111
+ "type": "object",
112
+ "additionalProperties": false,
113
+ "required": ["kind", "id", "name", "paths", "anchors", "trust", "stale"],
114
+ "properties": {
115
+ "kind": { "const": "vessel" },
116
+ "id": { "$ref": "#/$defs/entryId" },
117
+ "name": { "type": "string", "minLength": 1 },
118
+ "behavior": { "type": "string" },
119
+ "paths": { "type": "array", "items": { "type": "string", "minLength": 1 } },
120
+ "note": { "type": "string" },
121
+ "anchors": { "$ref": "#/$defs/anchors" },
122
+ "trust": { "$ref": "#/$defs/trustLabel" },
123
+ "stale": { "$ref": "#/$defs/stale" }
124
+ }
125
+ },
126
+ "portOfEntry": {
127
+ "type": "object",
128
+ "additionalProperties": false,
129
+ "required": ["kind", "id", "vessel", "protocol", "anchors", "trust", "stale"],
130
+ "properties": {
131
+ "kind": { "const": "portOfEntry" },
132
+ "id": { "$ref": "#/$defs/entryId" },
133
+ "vessel": { "$ref": "#/$defs/entryId" },
134
+ "protocol": { "type": "string", "minLength": 1 },
135
+ "note": { "type": "string" },
136
+ "anchors": { "$ref": "#/$defs/anchors" },
137
+ "trust": { "$ref": "#/$defs/trustLabel" },
138
+ "stale": { "$ref": "#/$defs/stale" }
139
+ }
140
+ },
141
+ "beacon": {
142
+ "type": "object",
143
+ "additionalProperties": false,
144
+ "required": ["kind", "id", "vessel", "surface", "key", "anchors", "trust", "stale"],
145
+ "properties": {
146
+ "kind": { "const": "beacon" },
147
+ "id": { "$ref": "#/$defs/entryId" },
148
+ "vessel": { "$ref": "#/$defs/entryId" },
149
+ "surface": { "enum": ["env", "flag", "port"] },
150
+ "key": { "type": "string", "minLength": 1 },
151
+ "note": { "type": "string" },
152
+ "anchors": { "$ref": "#/$defs/anchors" },
153
+ "trust": { "$ref": "#/$defs/trustLabel" },
154
+ "stale": { "$ref": "#/$defs/stale" }
155
+ }
156
+ },
157
+ "light": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "required": ["kind", "id", "vessel", "name", "anchors", "trust", "stale"],
161
+ "properties": {
162
+ "kind": { "const": "light" },
163
+ "id": { "$ref": "#/$defs/entryId" },
164
+ "vessel": { "$ref": "#/$defs/entryId" },
165
+ "name": { "type": "string", "minLength": 1 },
166
+ "note": { "type": "string" },
167
+ "anchors": { "$ref": "#/$defs/anchors" },
168
+ "trust": { "$ref": "#/$defs/trustLabel" },
169
+ "stale": { "$ref": "#/$defs/stale" }
170
+ }
171
+ },
172
+ "danger": {
173
+ "type": "object",
174
+ "additionalProperties": false,
175
+ "required": ["kind", "id", "vessel", "category", "note", "anchors", "trust", "stale"],
176
+ "properties": {
177
+ "kind": { "const": "danger" },
178
+ "id": { "$ref": "#/$defs/entryId" },
179
+ "vessel": { "$ref": "#/$defs/entryId" },
180
+ "category": { "enum": ["rock", "shallow", "wreck"] },
181
+ "note": { "type": "string", "minLength": 1 },
182
+ "anchors": { "$ref": "#/$defs/anchors" },
183
+ "trust": { "$ref": "#/$defs/trustLabel" },
184
+ "stale": { "$ref": "#/$defs/stale" }
185
+ }
186
+ },
187
+ "edge": {
188
+ "description": "A charted fairway as an edge. No `kind` tag — nodes and edges are disjoint by construction; `relation` stays absent when the fairway is untyped.",
189
+ "type": "object",
190
+ "additionalProperties": false,
191
+ "required": ["id", "from", "to", "anchors", "trust", "stale"],
192
+ "properties": {
193
+ "id": { "$ref": "#/$defs/entryId" },
194
+ "from": { "$ref": "#/$defs/entryId" },
195
+ "to": { "$ref": "#/$defs/entryId" },
196
+ "relation": { "enum": ["build", "runtime", "config"] },
197
+ "anchors": { "$ref": "#/$defs/anchors" },
198
+ "trust": { "$ref": "#/$defs/trustLabel" },
199
+ "stale": { "$ref": "#/$defs/stale" }
200
+ }
201
+ }
202
+ }
203
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://portolan.dev/core/receipt.schema.json",
4
+ "title": "Portolan ship's-log receipt",
5
+ "version": "0.1.0",
6
+ "description": "One line of <target>/.portolan/log.jsonl — the append-only receipt the log writes per executed command (core/src/tools/log.ts). The writer is authoritative (design D3): this schema documents what log.ts writes, ids are monotonic and citable as chart anchors, and no field outside the six named ones may appear.",
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "required": ["id", "command", "outcome", "recordedAt"],
10
+ "properties": {
11
+ "id": {
12
+ "description": "Monotonic, assigned by the log: r1, r2, ... — citable as a receipt anchor.",
13
+ "type": "string",
14
+ "pattern": "^r\\d+$"
15
+ },
16
+ "command": {
17
+ "description": "Command identity, e.g. `sweep pattern=UserService`.",
18
+ "type": "string"
19
+ },
20
+ "scope": {
21
+ "description": "What was surveyed, e.g. the module or path scope. Optional — short probes may carry none.",
22
+ "type": "string"
23
+ },
24
+ "outcome": {
25
+ "description": "Outcome, e.g. `ok: 3 chunks` or `error: missing binary ctags`.",
26
+ "type": "string"
27
+ },
28
+ "recordedAt": {
29
+ "description": "ISO timestamp of the append (the log writes Date.prototype.toISOString).",
30
+ "type": "string"
31
+ },
32
+ "meta": {
33
+ "description": "Free-form command metadata (counts, notes, cited receipts). Open object: the log does not constrain keys or value shapes.",
34
+ "type": "object"
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://portolan.dev/core/trust-vocabulary.schema.json",
4
+ "title": "Portolan trust vocabulary",
5
+ "version": "0.1.0",
6
+ "description": "The closed trust vocabulary (chart notation): exactly five labels, one per claim. measured: taken from source directly; charted: from manifests/metadata; reported: from docs/commits/tickets — claims, not facts; doubtful: evidence present, could not be validated; unsurveyed: no usable evidence, never faked. Single source of the enum (design D2): chart.schema.json references this file by $id, and a validator needs both files registered.",
7
+ "enum": ["measured", "charted", "reported", "doubtful", "unsurveyed"]
8
+ }
@@ -4,23 +4,30 @@
4
4
  * It routes to the existing entry points; it implements nothing itself:
5
5
  *
6
6
  * portolan serve --target <province root> → server/main.ts (MCP over stdio)
7
+ * portolan export [--target <province root>] → tools/export.ts (chartExport)
7
8
  * portolan chartroom <render|review> … → chartroom/cli.ts
8
9
  * portolan harbor <propose|watch|run> … → harbor/cli.ts
9
10
  *
10
11
  * `serve` runs in-process (same parse, same server wiring as main.ts);
11
12
  * the CLIs are spawned with inherited stdio so their behavior — output,
12
- * exit codes — is indistinguishable from running them directly.
13
+ * exit codes — is indistinguishable from running them directly. `export`
14
+ * also runs in-process, over the same core function the served chart.export
15
+ * tool calls (design D6: MCP and CLI cannot diverge); it prints the
16
+ * document pretty-printed (two spaces, trailing newline), the same shape
17
+ * the harbor CLI prints its JSON in, and it appends no ship's-log receipt —
18
+ * receipts are the served tools' discipline, never a CLI side effect.
13
19
  */
14
20
  import { resolve } from "node:path";
15
21
  import { spawn } from "node:child_process";
16
22
  import { fileURLToPath } from "node:url";
17
23
 
18
- const SUBCOMMANDS = ["serve", "chartroom", "harbor"] as const;
24
+ const SUBCOMMANDS = ["serve", "export", "chartroom", "harbor"] as const;
19
25
 
20
26
  const usage = `usage: portolan <command> [args]
21
27
 
22
28
  commands:
23
29
  serve run the Portolan MCP server (stdio)
30
+ export adjacency graph export of the Chart (JSON to stdout)
24
31
  chartroom Chart Room CLI (render | review)
25
32
  harbor harbor CLI (propose | watch | run)`;
26
33
 
@@ -54,6 +61,34 @@ async function serve(rest: readonly string[]): Promise<void> {
54
61
  await server.connect(new StdioServerTransport());
55
62
  }
56
63
 
64
+ async function exportJson(rest: readonly string[]): Promise<void> {
65
+ // Same parse shape as `serve`, over the args after the `export` subcommand;
66
+ // the same chartExport the served chart.export tool calls (design D6).
67
+ const { parseArgs } = await import("node:util");
68
+ const { chartExport, ExportError } = await import("../tools/export");
69
+
70
+ const { values } = parseArgs({
71
+ args: rest,
72
+ allowPositionals: false,
73
+ options: {
74
+ target: { type: "string", default: process.cwd() },
75
+ },
76
+ });
77
+ try {
78
+ const doc = chartExport(resolve(values.target as string));
79
+ console.log(JSON.stringify(doc, null, 2));
80
+ } catch (err) {
81
+ // The honest errors (no Chart, fairways alone over budget): name them on
82
+ // stderr, exit nonzero, never print a document. No receipt is appended —
83
+ // this path never touches the ship's log, on success or rejection.
84
+ if (err instanceof ExportError) {
85
+ console.error(err.message);
86
+ process.exit(1);
87
+ }
88
+ throw err;
89
+ }
90
+ }
91
+
57
92
  /** Run one of the existing CLI scripts with the remaining args, verbatim. */
58
93
  function runCli(script: string, args: string[]): never {
59
94
  const child = spawn(process.execPath, [script, ...args], { stdio: "inherit" });
@@ -70,6 +105,8 @@ async function dispatch(argv: readonly string[]): Promise<void> {
70
105
  switch (command) {
71
106
  case "serve":
72
107
  return serve(rest);
108
+ case "export":
109
+ return exportJson(rest);
73
110
  case "chartroom":
74
111
  return runCli(srcPath("../chartroom/cli.ts"), rest);
75
112
  case "harbor":
package/core/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export * from "./tools/manifests";
16
16
  export * from "./tools/log";
17
17
  export * from "./tools/sound";
18
18
  export * from "./tools/neighborhood";
19
+ export * from "./tools/export";
19
20
  export * from "./harbor/errors";
20
21
  export * from "./harbor/fingerprint";
21
22
  export * from "./harbor/snapshot";
@@ -17,6 +17,7 @@ import { symbols } from "../tools/symbols";
17
17
  import { readManifest } from "../tools/manifests";
18
18
  import { appendReceipt, readReceipt, readReceipts } from "../tools/log";
19
19
  import { neighborhood, NEIGHBORHOOD_CAPS, NEIGHBORHOOD_DEFAULTS, type NeighborhoodParams } from "../tools/neighborhood";
20
+ import { chartExport, EXPORT_MAX_BYTES } from "../tools/export";
20
21
  import { soundAnchor, soundEdge } from "../tools/sound";
21
22
  import { trustReport } from "../tools/trust-report";
22
23
  import { computeProposals, decide } from "../harbor/proposals";
@@ -583,9 +584,42 @@ export const TOOL_TABLE: ToolSpec[] = [
583
584
  return response;
584
585
  },
585
586
  },
587
+ {
588
+ name: "chart.export",
589
+ description:
590
+ "Export the province's whole machine layer as one self-describing adjacency document (format " +
591
+ "portolan-adjacency, schema-versioned): every charted entry drawn as a node — the charted fields " +
592
+ "as-is plus staleness — or an edge (fairways: from/to, relation when charted), each carrying its " +
593
+ "anchors and trust label un-upgraded; no timestamps, no derived rollups, nothing a charted entry " +
594
+ `does not state. Byte-budgeted (${EXPORT_MAX_BYTES} bytes): an oversized chart truncates loudly, ` +
595
+ "naming every omitted vessel with its cut entry count. Staleness is refreshed before serving " +
596
+ "exactly as chart.read refreshes it; read-only toward the sources and the Chart beyond that " +
597
+ "staleness refresh — a rejected call writes nothing, not even the refresh — and each successful " +
598
+ "call appends exactly one ship's-log receipt. " +
599
+ "A province with no Chart is an honest error naming the absence, never a fabricated document.",
600
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
601
+ handler: (_args, ctx) => {
602
+ // The one write this call makes: exactly one ship's-log receipt, through
603
+ // the same append path log.append serves. A rejected call (no Chart) is
604
+ // thrown by the builder and never reaches this line, so a rejection
605
+ // leaves no receipt.
606
+ const doc = chartExport(ctx.targetRoot);
607
+ appendReceipt(ctx.targetRoot, {
608
+ command: "chart.export",
609
+ scope: "adjacency graph",
610
+ outcome:
611
+ `ok: ${doc.nodes.length} node${doc.nodes.length === 1 ? "" : "s"}, ` +
612
+ `${doc.edges.length} edge${doc.edges.length === 1 ? "" : "s"}` +
613
+ (doc.truncated
614
+ ? `, truncated: ${doc.omitted.length} vessel${doc.omitted.length === 1 ? "" : "s"} omitted`
615
+ : ""),
616
+ });
617
+ return doc;
618
+ },
619
+ },
586
620
  ];
587
621
 
588
- /** The served Portolan tool names, in table order (the harness capability's fourteen). */
622
+ /** The served Portolan tool names, in table order (the harness capability's fifteen). */
589
623
  export const TOOL_NAMES = TOOL_TABLE.map((spec) => spec.name);
590
624
 
591
625
  /**
@@ -0,0 +1,275 @@
1
+ /**
2
+ * `chart.export`: the province's adjacency graph export in one
3
+ * deterministic document (openspec/changes/formats-pass, design D4 + D5;
4
+ * specs/tools/spec.md + specs/formats/spec.md).
5
+ *
6
+ * The document is arithmetic over the Chart's machine layer and nothing
7
+ * else: every non-fairway entry is drawn as a node — the charted fields
8
+ * as-is plus `stale`, the store's `signature` aside — and every fairway as
9
+ * an edge (from/to vessel ids, the relation when charted, anchors, trust,
10
+ * stale). Nothing is invented, decorated, or re-graded: `doubtful` and
11
+ * `unsurveyed` pass through un-upgraded, a fairway endpoint without a
12
+ * charted vessel is stated by the raw edge id alone, and there are no
13
+ * timestamps and no derived rollups (no top-level `vessels` list, no
14
+ * dangling-endpoint `issues[]`) — the ship's log carries the when. The
15
+ * document names its format (`portolan-adjacency`) and the graph-export
16
+ * schema version it was produced against, read from the schema file itself
17
+ * (design D1: the version lives in the schema file).
18
+ *
19
+ * Staleness follows chart.read semantics: refreshed before answering, so
20
+ * pending correction is visible, never hidden. That refresh is the only
21
+ * write the builder may cause (nothing on unchanged signatures); the
22
+ * builder itself appends nothing and mutates nothing else — the served
23
+ * handler owns the single ship's-log receipt per call. A rejected call
24
+ * writes nothing, not even the refresh: the byte-budget feasibility
25
+ * precheck runs before the refresh, so an over-budget refusal never moves
26
+ * the Chart.
27
+ *
28
+ * The export is byte-budgeted (EXPORT_MAX_BYTES; bytes alone — the export
29
+ * takes no query parameters). An oversized chart is cut whole-vessel: from
30
+ * the tail of the id-ascending vessel order, one vessel at a time, until
31
+ * the serialized document fits, removing each cut vessel's own node and
32
+ * every node it owns, while the fairways stay drawn. The cut is loud:
33
+ * `truncated` plus `omitted`, naming every cut vessel with its cut entry
34
+ * count — never a silent prefix — and it is measured over a running total
35
+ * (each vessel's serialized contribution computed once), not by
36
+ * re-serializing the document per cut. If the fairways alone exceed the
37
+ * budget there is no honest cut left, and the builder refuses by name
38
+ * instead of serving over budget. A province with no Chart is an honest
39
+ * ExportError naming the absence, never a fabricated document.
40
+ */
41
+ import { existsSync } from "node:fs";
42
+ import { join } from "node:path";
43
+ import graphExportSchema from "../../schema/graph-export.schema.json";
44
+ import type {
45
+ Anchor,
46
+ BeaconEntry,
47
+ ChartEntry,
48
+ DangerEntry,
49
+ FairwayRelation,
50
+ IndexedEntry,
51
+ LightEntry,
52
+ PortOfEntryEntry,
53
+ TrustLabel,
54
+ VesselEntry,
55
+ } from "../types";
56
+ import { INDEX_FILE, chartDir, readChart } from "../chart-store";
57
+ import { refreshStaleness } from "../staleness";
58
+
59
+ /** The document's self-named format (graph-export.schema.json's const). */
60
+ const FORMAT = "portolan-adjacency" as const;
61
+
62
+ /** The graph-export schema version this document is produced against. */
63
+ const GRAPH_EXPORT_VERSION: string = graphExportSchema.version;
64
+
65
+ /**
66
+ * The export's byte budget over the compact serialized document — the bytes
67
+ * a consumer receives. Fixed: the export takes no query parameters to
68
+ * inflate or shrink it (design D5 dropped the records half in favor of
69
+ * bytes alone).
70
+ */
71
+ export const EXPORT_MAX_BYTES = 262_144;
72
+
73
+ /** Raised for every rejection of this tool: no Chart, no honest cut. */
74
+ export class ExportError extends Error {
75
+ constructor(message: string) {
76
+ super(`export: ${message}`);
77
+ this.name = "ExportError";
78
+ }
79
+ }
80
+
81
+ /** A charted fairway as an edge: no `kind` tag, `relation` absent when untyped. */
82
+ export interface GraphExportEdge {
83
+ id: string;
84
+ from: string;
85
+ to: string;
86
+ relation?: FairwayRelation;
87
+ anchors: Anchor[];
88
+ trust: TrustLabel;
89
+ stale: boolean;
90
+ }
91
+
92
+ /**
93
+ * A charted non-fairway entry, rendered whole: the charted fields as-is
94
+ * plus `stale`, the store's `signature` omitted — typed exactly as the
95
+ * charted entry minus its store signature; the per-kind shapes the document
96
+ * must satisfy are pinned by graph-export.schema.json, not by this alias.
97
+ * The union stays distributed: `Omit` over the entry union as a whole would
98
+ * collapse it to the common keys alone (a vessel's `paths`, a beacon's
99
+ * `surface` and `key`, a danger's `category` — all erased).
100
+ */
101
+ type NodeOf<T extends ChartEntry> = Omit<T & { stale: boolean }, "signature">;
102
+ export type GraphExportNode =
103
+ | NodeOf<VesselEntry>
104
+ | NodeOf<PortOfEntryEntry>
105
+ | NodeOf<BeaconEntry>
106
+ | NodeOf<LightEntry>
107
+ | NodeOf<DangerEntry>;
108
+
109
+ /** What `chart.export` returns: the self-describing adjacency document. */
110
+ export interface GraphExport {
111
+ format: typeof FORMAT;
112
+ version: string;
113
+ nodes: GraphExportNode[];
114
+ edges: GraphExportEdge[];
115
+ truncated: boolean;
116
+ /** The loud cut report: one record per vessel with entries cut; empty when whole. */
117
+ omitted: Array<{ vessel: string; entries: number }>;
118
+ }
119
+
120
+ /** The vessel an entry hangs from: a vessel is its own owner. */
121
+ type DrawnNode = { owner: string; node: GraphExportNode };
122
+
123
+ /** The charted fairways as edges, in entry order: no `kind` tag, `relation` absent when untyped. */
124
+ function edgesOf(entries: IndexedEntry[]): GraphExportEdge[] {
125
+ const edges: GraphExportEdge[] = [];
126
+ for (const entry of entries) {
127
+ if (entry.kind !== "fairway") continue;
128
+ edges.push({
129
+ id: entry.id,
130
+ from: entry.from,
131
+ to: entry.to,
132
+ ...(entry.relation !== undefined ? { relation: entry.relation } : {}),
133
+ anchors: entry.anchors,
134
+ trust: entry.trust,
135
+ stale: entry.stale,
136
+ });
137
+ }
138
+ return edges;
139
+ }
140
+
141
+ /**
142
+ * `chart.export`: the province's adjacency document. Deterministic — no
143
+ * timestamps, no map-order leakage: two runs over an unchanged province
144
+ * return the same document in the same order.
145
+ */
146
+ export function chartExport(targetRoot: string): GraphExport {
147
+ const indexPath = join(chartDir(targetRoot), INDEX_FILE);
148
+ if (!existsSync(indexPath)) {
149
+ throw new ExportError(
150
+ `no chart at ${indexPath} — nothing to export; survey the province and write the Chart first`,
151
+ );
152
+ }
153
+
154
+ // Feasibility precheck, before any write: the fairways alone bound the
155
+ // document from below (every cut removes only vessels' nodes). If they
156
+ // already exceed the budget there is no honest cut left, and the export
157
+ // refuses before the staleness refresh can move the Chart — a rejected
158
+ // call writes nothing at all.
159
+ const precheck: GraphExport = {
160
+ format: FORMAT,
161
+ version: GRAPH_EXPORT_VERSION,
162
+ nodes: [],
163
+ edges: edgesOf(readChart(targetRoot)),
164
+ truncated: false,
165
+ omitted: [],
166
+ };
167
+ if (Buffer.byteLength(JSON.stringify(precheck), "utf8") > EXPORT_MAX_BYTES) {
168
+ throw new ExportError(
169
+ `the chart's fairways alone exceed the export budget of ${EXPORT_MAX_BYTES} bytes — ` +
170
+ `no vessel left to cut, and the export refuses to serve over budget`,
171
+ );
172
+ }
173
+
174
+ // chart.read semantics: staleness is refreshed before answering. On
175
+ // unchanged signatures the refresh writes nothing at all; when it writes,
176
+ // that is the builder's only possible write — and it runs only after the
177
+ // precheck above has accepted the call.
178
+ refreshStaleness(targetRoot);
179
+ const entries = readChart(targetRoot);
180
+
181
+ const nodes: DrawnNode[] = [];
182
+ const edges = edgesOf(entries);
183
+ /** Owner vessel id -> how many of its entries are nodes (itself included). */
184
+ const owned = new Map<string, number>();
185
+ for (const entry of entries) {
186
+ if (entry.kind === "fairway") continue;
187
+ // The charted fields as-is: the entry, its store signature aside.
188
+ const { signature: _signature, ...node } = entry;
189
+ const owner = entry.kind === "vessel" ? entry.id : entry.vessel;
190
+ nodes.push({ owner, node });
191
+ owned.set(owner, (owned.get(owner) ?? 0) + 1);
192
+ }
193
+
194
+ const owners = [...owned.keys()].sort();
195
+ const cut = new Set<string>();
196
+ const assemble = (): GraphExport => ({
197
+ format: FORMAT,
198
+ version: GRAPH_EXPORT_VERSION,
199
+ nodes: nodes.filter(({ owner }) => !cut.has(owner)).map(({ node }) => node),
200
+ edges,
201
+ truncated: cut.size > 0,
202
+ omitted: owners
203
+ .filter((id) => cut.has(id))
204
+ .map((id) => ({ vessel: id, entries: owned.get(id)! })),
205
+ });
206
+
207
+ // The budget is measured on the compact serialization — the bytes a
208
+ // consumer receives for the document itself. Each owner's serialized
209
+ // contribution is computed once; the document total is then a running
210
+ // sum over the kept/cut sets — the skeleton already carries the two
211
+ // `[`…`]` array pairs, and a filled array keeps its own pair — plus the
212
+ // arrays' commas — so the cut is O(vessels), not O(vessels × bytes)
213
+ // re-serializations.
214
+ const nodeBytes = new Map<string, number>();
215
+ for (const { owner, node } of nodes) {
216
+ nodeBytes.set(owner, (nodeBytes.get(owner) ?? 0) + Buffer.byteLength(JSON.stringify(node), "utf8"));
217
+ }
218
+ const omittedBytes = new Map<string, number>(
219
+ owners.map((id) => [id, Buffer.byteLength(JSON.stringify({ vessel: id, entries: owned.get(id)! }), "utf8")]),
220
+ );
221
+ const skeletonBytes = (truncated: boolean): number =>
222
+ Buffer.byteLength(
223
+ JSON.stringify({ format: FORMAT, version: GRAPH_EXPORT_VERSION, nodes: [], edges, truncated, omitted: [] }),
224
+ "utf8",
225
+ );
226
+ const totalBytes = (
227
+ keptBytes: number,
228
+ keptCount: number,
229
+ cutBytes: number,
230
+ cutCount: number,
231
+ truncated: boolean,
232
+ ): number => {
233
+ const commas = (count: number): number => (count > 0 ? count - 1 : 0);
234
+ return skeletonBytes(truncated) + keptBytes + cutBytes + commas(keptCount) + commas(cutCount);
235
+ };
236
+
237
+ let keptBytes = 0;
238
+ for (const bytes of nodeBytes.values()) keptBytes += bytes;
239
+ let keptCount = nodes.length;
240
+ let cutBytes = 0;
241
+ let cutCount = 0;
242
+ let overBudget = totalBytes(keptBytes, keptCount, cutBytes, cutCount, false) > EXPORT_MAX_BYTES;
243
+ if (overBudget) {
244
+ // Whole-vessel cut from the tail of the id-ascending order — never a
245
+ // partial vessel, never a silent prefix. Fairways stay drawn: a raw
246
+ // edge id states its endpoints, so an edge whose far vessel was cut is
247
+ // still charted truth (design D4).
248
+ for (let i = owners.length - 1; i >= 0 && overBudget; i--) {
249
+ const id = owners[i]!;
250
+ cut.add(id);
251
+ keptBytes -= nodeBytes.get(id) ?? 0;
252
+ keptCount -= owned.get(id)!;
253
+ cutBytes += omittedBytes.get(id)!;
254
+ cutCount += 1;
255
+ overBudget = totalBytes(keptBytes, keptCount, cutBytes, cutCount, true) > EXPORT_MAX_BYTES;
256
+ }
257
+ if (overBudget) {
258
+ throw new ExportError(
259
+ `the chart's fairways alone exceed the export budget of ${EXPORT_MAX_BYTES} bytes — ` +
260
+ `no vessel left to cut, and the export refuses to serve over budget`,
261
+ );
262
+ }
263
+ }
264
+ // The served bytes are the real serialization, not the running estimate:
265
+ // one full assembly pins the cut to the honest count (and is the paranoid
266
+ // re-check after the refresh, whose flag flips can move edge bytes).
267
+ const doc = assemble();
268
+ if (Buffer.byteLength(JSON.stringify(doc), "utf8") > EXPORT_MAX_BYTES) {
269
+ throw new ExportError(
270
+ `the serialized export exceeds the export budget of ${EXPORT_MAX_BYTES} bytes — ` +
271
+ `the export refuses to serve over budget`,
272
+ );
273
+ }
274
+ return doc;
275
+ }
@@ -21,7 +21,7 @@ import {
21
21
  unlinkSync,
22
22
  } from "node:fs";
23
23
  import { join } from "node:path";
24
- import type { Anchor } from "../types";
24
+ import type { Anchor, Receipt } from "../types";
25
25
 
26
26
  export const SHIPS_LOG_FILE = "log.jsonl";
27
27
  export const LOG_LOCK_FILE = "log.lock";
@@ -36,19 +36,13 @@ export function logFile(targetRoot: string): string {
36
36
  return join(targetRoot, ".portolan", SHIPS_LOG_FILE);
37
37
  }
38
38
 
39
- export interface Receipt {
40
- /** Stable, monotonic, citable as an anchor: r1, r2, ... */
41
- id: string;
42
- /** Command identity, e.g. `sweep pattern=UserService`. */
43
- command: string;
44
- /** What was surveyed, e.g. the module or path scope. */
45
- scope?: string;
46
- /** Outcome, e.g. `ok: 3 chunks` or `error: missing binary ctags`. */
47
- outcome: string;
48
- /** ISO timestamp of the append. */
49
- recordedAt: string;
50
- meta?: Record<string, unknown>;
51
- }
39
+ /**
40
+ * The receipt type is generated from core/schema/receipt.schema.json
41
+ * (formats-pass, design D7) and lives in ../types; the writer stays
42
+ * authoritative (D3) if a field the writer needs is missing there, the
43
+ * schema follows the writer, never this file.
44
+ */
45
+ export type { Receipt } from "../types";
52
46
 
53
47
  export type ReceiptInput = Omit<Receipt, "id" | "recordedAt"> & {
54
48
  /** Callers normally let the log assign ids; a replayed id is checked. */
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Generated from the chart, trust-vocabulary, and receipt schemas under
3
+ * core/schema/ by scripts/gen-types.ts — do not edit by hand; schema wins
4
+ * (formats-pass, design D7). Regenerate with `bun run scripts/gen-types.ts`;
5
+ * a committed copy that differs from today's schemas fails the drift guard
6
+ * in core/src/formats.test.ts.
7
+ */
8
+
9
+ /** The closed trust vocabulary (chart notation): exactly five labels, one per claim. measured: taken from source directly; charted: from manifests/metadata; reported: from docs/commits/tickets — claims, not facts; doubtful: evidence present, could not be validated; unsurveyed: no usable evidence, never faked. Single source of the enum (design D2): chart.schema.json references this file by $id, and a validator needs both files registered. */
10
+ type TrustLabel = "measured" | "charted" | "reported" | "doubtful" | "unsurveyed";
11
+
12
+ /** Optional closed relation vocabulary on a fairway: build, runtime, config. A fairway without a relation stays valid and reads as untyped. */
13
+ type FairwayRelation = "build" | "runtime" | "config";
14
+
15
+ export type Anchor =
16
+ | { type: "file"; path: string; line?: number }
17
+ | { type: "manifest"; path: string; key: string }
18
+ | { type: "receipt"; id: string };
19
+
20
+ export interface VesselEntry {
21
+ kind: "vessel";
22
+ id: string;
23
+ name: string;
24
+ behavior?: string;
25
+ paths: string[];
26
+ /** Store metadata on vessels as stored: cheap tree signature over the vessel's paths, re-stamped by the store on every write. Never hand-supplied. */
27
+ signature?: { hash: string; files: number };
28
+ note?: string;
29
+ /** An entry without at least one anchor does not ship. */
30
+ anchors: Anchor[];
31
+ trust: TrustLabel;
32
+ /** Store metadata on entries as stored: true when the anchored sources drifted since the last survey — the entry is pending correction (Notices to Mariners). Never hand-supplied; the store stamps it. */
33
+ stale?: boolean;
34
+ }
35
+
36
+ export interface FairwayEntry {
37
+ kind: "fairway";
38
+ id: string;
39
+ from: string;
40
+ to: string;
41
+ /** Optional closed relation vocabulary on a fairway: build, runtime, config. A fairway without a relation stays valid and reads as untyped. */
42
+ relation?: FairwayRelation;
43
+ note?: string;
44
+ /** An entry without at least one anchor does not ship. */
45
+ anchors: Anchor[];
46
+ trust: TrustLabel;
47
+ /** Store metadata on entries as stored: true when the anchored sources drifted since the last survey — the entry is pending correction (Notices to Mariners). Never hand-supplied; the store stamps it. */
48
+ stale?: boolean;
49
+ }
50
+
51
+ export interface PortOfEntryEntry {
52
+ kind: "portOfEntry";
53
+ id: string;
54
+ vessel: string;
55
+ protocol: string;
56
+ note?: string;
57
+ /** An entry without at least one anchor does not ship. */
58
+ anchors: Anchor[];
59
+ trust: TrustLabel;
60
+ /** Store metadata on entries as stored: true when the anchored sources drifted since the last survey — the entry is pending correction (Notices to Mariners). Never hand-supplied; the store stamps it. */
61
+ stale?: boolean;
62
+ }
63
+
64
+ export interface BeaconEntry {
65
+ kind: "beacon";
66
+ id: string;
67
+ vessel: string;
68
+ surface: "env" | "flag" | "port";
69
+ key: string;
70
+ note?: string;
71
+ /** An entry without at least one anchor does not ship. */
72
+ anchors: Anchor[];
73
+ trust: TrustLabel;
74
+ /** Store metadata on entries as stored: true when the anchored sources drifted since the last survey — the entry is pending correction (Notices to Mariners). Never hand-supplied; the store stamps it. */
75
+ stale?: boolean;
76
+ }
77
+
78
+ export interface LightEntry {
79
+ kind: "light";
80
+ id: string;
81
+ vessel: string;
82
+ name: string;
83
+ note?: string;
84
+ /** An entry without at least one anchor does not ship. */
85
+ anchors: Anchor[];
86
+ trust: TrustLabel;
87
+ /** Store metadata on entries as stored: true when the anchored sources drifted since the last survey — the entry is pending correction (Notices to Mariners). Never hand-supplied; the store stamps it. */
88
+ stale?: boolean;
89
+ }
90
+
91
+ export interface DangerEntry {
92
+ kind: "danger";
93
+ id: string;
94
+ vessel: string;
95
+ category: "rock" | "shallow" | "wreck";
96
+ note: string;
97
+ /** An entry without at least one anchor does not ship. */
98
+ anchors: Anchor[];
99
+ trust: TrustLabel;
100
+ /** Store metadata on entries as stored: true when the anchored sources drifted since the last survey — the entry is pending correction (Notices to Mariners). Never hand-supplied; the store stamps it. */
101
+ stale?: boolean;
102
+ }
103
+
104
+ export type ChartEntry =
105
+ | VesselEntry
106
+ | FairwayEntry
107
+ | PortOfEntryEntry
108
+ | BeaconEntry
109
+ | LightEntry
110
+ | DangerEntry;
111
+
112
+ /** One line of <target>/.portolan/log.jsonl — the append-only receipt the log writes per executed command (core/src/tools/log.ts). The writer is authoritative (design D3): this schema documents what log.ts writes, ids are monotonic and citable as chart anchors, and no field outside the six named ones may appear. */
113
+ export interface Receipt {
114
+ /** Monotonic, assigned by the log: r1, r2, ... — citable as a receipt anchor. */
115
+ id: string;
116
+ /** Command identity, e.g. `sweep pattern=UserService`. */
117
+ command: string;
118
+ /** What was surveyed, e.g. the module or path scope. Optional — short probes may carry none. */
119
+ scope?: string;
120
+ /** Outcome, e.g. `ok: 3 chunks` or `error: missing binary ctags`. */
121
+ outcome: string;
122
+ /** ISO timestamp of the append (the log writes Date.prototype.toISOString). */
123
+ recordedAt: string;
124
+ /** Free-form command metadata (counts, notes, cited receipts). Open object: the log does not constrain keys or value shapes. */
125
+ meta?: { [key: string]: unknown };
126
+ }
package/core/src/types.ts CHANGED
@@ -5,8 +5,18 @@
5
5
  * beacon, light, danger, anchor, trust label, pending correction, Notices to
6
6
  * Mariners. Every entry carries at least one anchor and exactly one trust
7
7
  * label; the store rejects writes that omit either.
8
+ *
9
+ * Split (formats-pass, design D7): the entry, anchor, and receipt structural
10
+ * types are generated whole from core/schema/*.schema.json into
11
+ * ./types.generated and re-exported here — schema wins, the mirror cannot
12
+ * drift. This file keeps the hand-written runtime constants and the types
13
+ * the schemas do not own (the store's indexed view, Notices to Mariners).
8
14
  */
9
15
 
16
+ import type { Anchor, ChartEntry } from "./types.generated";
17
+
18
+ export * from "./types.generated";
19
+
10
20
  /** The closed trust vocabulary (chart notation). */
11
21
  export const TRUST_LABELS = [
12
22
  "measured",
@@ -31,15 +41,8 @@ export const ENTRY_KINDS = [
31
41
  export type EntryKind = (typeof ENTRY_KINDS)[number];
32
42
 
33
43
  /**
34
- * An anchor ties a claim to evidence: a file path (with optional line), a
35
- * manifest key, or a receipt id from the ship's log.
44
+ * Render an anchor as a compact, human-readable string.
36
45
  */
37
- export type Anchor =
38
- | { type: "file"; path: string; line?: number }
39
- | { type: "manifest"; path: string; key: string }
40
- | { type: "receipt"; id: string };
41
-
42
- /** Render an anchor as a compact, human-readable string. */
43
46
  export function formatAnchor(anchor: Anchor): string {
44
47
  switch (anchor.type) {
45
48
  case "file":
@@ -51,30 +54,6 @@ export function formatAnchor(anchor: Anchor): string {
51
54
  }
52
55
  }
53
56
 
54
- interface EntryBase {
55
- /** Stable identifier, unique across the chart. */
56
- id: string;
57
- /** At least one anchor is mandatory. */
58
- anchors: Anchor[];
59
- /** Exactly one trust label is mandatory. */
60
- trust: TrustLabel;
61
- /** Free-form qualification; never a substitute for evidence. */
62
- note?: string;
63
- }
64
-
65
- /** A deployable unit. */
66
- export interface VesselEntry extends EntryBase {
67
- kind: "vessel";
68
- name: string;
69
- /**
70
- * What the vessel does at runtime. Absent behavior is rendered as
71
- * `unsurveyed` on the sheet — absence stays visible, never omitted.
72
- */
73
- behavior?: string;
74
- /** Source paths (relative to the target root) covered by the tree signature. */
75
- paths: string[];
76
- }
77
-
78
57
  /**
79
58
  * The closed relation vocabulary on a fairway — the senses the anchors can
80
59
  * actually support. Optional: a fairway without a relation stays valid and
@@ -84,57 +63,6 @@ export const FAIRWAY_RELATIONS = ["build", "runtime", "config"] as const;
84
63
 
85
64
  export type FairwayRelation = (typeof FAIRWAY_RELATIONS)[number];
86
65
 
87
- /** A typed dependency edge between two vessels. */
88
- export interface FairwayEntry extends EntryBase {
89
- kind: "fairway";
90
- from: string;
91
- to: string;
92
- /** When known: what kind of dependence the edge is. */
93
- relation?: FairwayRelation;
94
- }
95
-
96
- /** An entry point into a vessel (http endpoint, cli, event, job, ...). */
97
- export interface PortOfEntryEntry extends EntryBase {
98
- kind: "portOfEntry";
99
- vessel: string;
100
- /** Short protocol family, e.g. "http", "cli", "gradle task". */
101
- protocol: string;
102
- }
103
-
104
- /** A configuration surface: env var, flag, or port. */
105
- export interface BeaconEntry extends EntryBase {
106
- kind: "beacon";
107
- vessel: string;
108
- surface: "env" | "flag" | "port";
109
- /** The configured key, e.g. "PORT", "--verbose", "8080". */
110
- key: string;
111
- }
112
-
113
- /** An API contract surface: endpoint, exported symbol, CLI flag, event. */
114
- export interface LightEntry extends EntryBase {
115
- kind: "light";
116
- vessel: string;
117
- /** The contract's name, e.g. "GET /api/users" or "export function parse()". */
118
- name: string;
119
- }
120
-
121
- /** A smell or risk. Categories per the locked glossary: rock / shallow / wreck. */
122
- export interface DangerEntry extends EntryBase {
123
- kind: "danger";
124
- vessel: string;
125
- category: "rock" | "shallow" | "wreck";
126
- /** What the danger is. */
127
- note: string;
128
- }
129
-
130
- export type ChartEntry =
131
- | VesselEntry
132
- | FairwayEntry
133
- | PortOfEntryEntry
134
- | BeaconEntry
135
- | LightEntry
136
- | DangerEntry;
137
-
138
66
  /** Cheap tree signature over a vessel's paths (see design.md, decision 3). */
139
67
  export interface VesselSignature {
140
68
  hash: string;
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * Chart entry validation — ajv (draft 2020-12) against
3
3
  * core/schema/chart.schema.json, with entry-locating errors: every problem
4
- * names the offending entry's kind and id.
4
+ * names the offending entry's kind and id. The chart schema's trust label
5
+ * $refs trust-vocabulary.schema.json by $id (design D2), so both files are
6
+ * registered here; any standalone compile of the chart schema needs the
7
+ * vocabulary registered too. `version` is the format-version annotation on
8
+ * each schema file (design D1) — ajv strict mode rejects unknown keywords,
9
+ * so it is declared as an annotation keyword instead of dropping strictness.
5
10
  */
6
11
  import Ajv2020 from "ajv/dist/2020";
7
12
  import schema from "../schema/chart.schema.json";
13
+ import trustVocabulary from "../schema/trust-vocabulary.schema.json";
8
14
  import { ENTRY_KINDS, type ChartEntry, type EntryKind } from "./types";
9
15
 
10
16
  interface AjvErrorLike {
@@ -20,6 +26,8 @@ type SubschemaValidator = ((data: unknown) => boolean) & {
20
26
  const SCHEMA_ID = schema.$id;
21
27
 
22
28
  const ajv = new Ajv2020({ allErrors: true });
29
+ ajv.addKeyword("version");
30
+ ajv.addSchema(trustVocabulary);
23
31
  ajv.addSchema(schema);
24
32
 
25
33
  const validators = new Map<string, SubschemaValidator>();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcon-tech/portolan",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "description": "Portolan: an MCP server that charts codebases - Chart store, Harbor expeditions, Chart Room. Runs on Bun; requires ripgrep and ctags on PATH (wrapped, not bundled).",
5
5
  "type": "module",
6
6
  "mcpName": "io.github.fcon-tech/portolan",
package/skill/SKILL.md CHANGED
@@ -243,7 +243,7 @@ labeled `unsurveyed`, never presented as an established fact.
243
243
 
244
244
  ## 10. Tool desk
245
245
 
246
- One MCP server over stdio, bound to the target root at launch. Fourteen tools:
246
+ One MCP server over stdio, bound to the target root at launch. Fifteen tools:
247
247
 
248
248
  | Tool | Use |
249
249
  | --- | --- |
@@ -261,6 +261,7 @@ One MCP server over stdio, bound to the target root at launch. Fourteen tools:
261
261
  | `chart.render` | no input; renders the Chart Room — the one-file visual export of this province's waters (archipelago map + dependency graph, every trust label visible) at `<target>/.portolan/chart-room.html`. When the Governor asks to *see* the landscape ("show me the province" or similar, in any language), call it and point to the file; say plainly that the picture renders only what the Chart holds, and nothing more |
262
262
  | `trust.report` | no input; the verification summary — trust-label distribution, per-kind counts, staleness refreshed first, every chart anchor re-sounded deterministically with refuted ones named, ship's-log tail; feeds the Sailing Directions |
263
263
  | `chart.neighborhood` | one vessel's neighborhood in one call: the charted fairways touching it (direction `in`/`out`/`both`, depth 1–3) with trust labels, anchors, and staleness, plus the touched vessels ranked by fan-in with their ports of entry; budgeted (`maxEdges`, `maxBytes`) and a budget cut is stated loudly; `verify: true` re-sounds every edge and names the refuted ones; read-only toward the Chart, and each call receipts itself in the ship's log |
264
+ | `chart.export` | no input; the whole Chart as one self-describing adjacency document (format `portolan-adjacency`): every charted entry as a node or edge carrying its anchors, trust label, and staleness; byte-budgeted, and a cut is reported loudly naming the omitted vessels with their counts; staleness refreshed first, as chart.read; read-only toward the Chart, and each call receipts itself in the ship's log |
264
265
 
265
266
  Call shapes (fields abbreviated to the ones that matter):
266
267
 
@@ -276,4 +277,5 @@ Call shapes (fields abbreviated to the ones that matter):
276
277
  { "tool": "expeditions.decide", "input": { "fingerprint": "64-hex from expeditions.propose", "decision": "accepted" } }
277
278
  { "tool": "trust.report", "input": {} }
278
279
  { "tool": "chart.neighborhood", "input": { "vessel": "api", "direction": "both", "depth": 1 } }
280
+ { "tool": "chart.export", "input": {} }
279
281
  ```
@@ -152,7 +152,7 @@ check("harbor 5.1", "SKILL.md teaches the harbor watch at session start", () =>
152
152
  ]) {
153
153
  assert(text.includes(phrase), `the harbor teaching omits "${phrase}"`);
154
154
  }
155
- assert(/Fourteen tools:/.test(text), "the tool desk does not count fourteen tools");
155
+ assert(/Fifteen tools:/.test(text), "the tool desk does not count fifteen tools");
156
156
  assert(text.includes('"tool": "trust.report", "input": {}'), "no call shape for trust.report");
157
157
  assert(
158
158
  text.includes("call `trust.report` (no input) when composing"),