@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 +5 -2
- package/adapters/README.md +2 -2
- package/core/schema/chart.schema.json +30 -9
- package/core/schema/graph-export.schema.json +203 -0
- package/core/schema/receipt.schema.json +37 -0
- package/core/schema/trust-vocabulary.schema.json +8 -0
- package/core/src/bin/portolan.ts +39 -2
- package/core/src/index.ts +1 -0
- package/core/src/server/registry.ts +35 -1
- package/core/src/tools/export.ts +275 -0
- package/core/src/tools/log.ts +8 -14
- package/core/src/types.generated.ts +126 -0
- package/core/src/types.ts +11 -83
- package/core/src/validate.ts +9 -1
- package/package.json +1 -1
- package/skill/SKILL.md +3 -1
- package/skill/verify/checks.ts +1 -1
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
|
|
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
|
|
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 |
|
package/adapters/README.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
"
|
|
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
|
+
}
|
package/core/src/bin/portolan.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/core/src/tools/log.ts
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
*
|
|
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;
|
package/core/src/validate.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
```
|
package/skill/verify/checks.ts
CHANGED
|
@@ -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(/
|
|
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"),
|