@fcon-tech/portolan 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/adapters/README.md +226 -0
  4. package/adapters/omp/portolan-mcp +19 -0
  5. package/adapters/opencode/expedition-launcher +70 -0
  6. package/adapters/opencode/install.test.ts +105 -0
  7. package/adapters/opencode/install.ts +357 -0
  8. package/adapters/pi/portolan-mcp +19 -0
  9. package/adapters/scheduling/night-watch.cron +23 -0
  10. package/core/schema/chart.schema.json +154 -0
  11. package/core/src/bin/portolan.ts +84 -0
  12. package/core/src/chart-io.rollback-fixture.ts +55 -0
  13. package/core/src/chart-io.ts +121 -0
  14. package/core/src/chart-store.ts +137 -0
  15. package/core/src/chartroom/cli.ts +63 -0
  16. package/core/src/chartroom/render.ts +213 -0
  17. package/core/src/chartroom/review-template.html +232 -0
  18. package/core/src/chartroom/review.ts +109 -0
  19. package/core/src/chartroom/template.html +1090 -0
  20. package/core/src/fan-in.ts +84 -0
  21. package/core/src/harbor/chat-format.ts +154 -0
  22. package/core/src/harbor/cli.ts +178 -0
  23. package/core/src/harbor/errors.ts +22 -0
  24. package/core/src/harbor/fingerprint.ts +29 -0
  25. package/core/src/harbor/history.ts +178 -0
  26. package/core/src/harbor/launcher.ts +155 -0
  27. package/core/src/harbor/night-policy.ts +64 -0
  28. package/core/src/harbor/proposals.ts +324 -0
  29. package/core/src/harbor/run.ts +72 -0
  30. package/core/src/harbor/settings.ts +108 -0
  31. package/core/src/harbor/snapshot.ts +187 -0
  32. package/core/src/harbor/watch.ts +103 -0
  33. package/core/src/index.ts +28 -0
  34. package/core/src/notices.ts +117 -0
  35. package/core/src/perimeter.ts +44 -0
  36. package/core/src/server/adapter-boundary.ts +66 -0
  37. package/core/src/server/main.ts +27 -0
  38. package/core/src/server/registry.ts +609 -0
  39. package/core/src/server/server.ts +123 -0
  40. package/core/src/server/test-harness.ts +161 -0
  41. package/core/src/sheets.ts +151 -0
  42. package/core/src/staleness.ts +203 -0
  43. package/core/src/tools/log.ts +215 -0
  44. package/core/src/tools/manifests.ts +912 -0
  45. package/core/src/tools/neighborhood.ts +423 -0
  46. package/core/src/tools/shared.ts +72 -0
  47. package/core/src/tools/sound.ts +634 -0
  48. package/core/src/tools/sweep.ts +198 -0
  49. package/core/src/tools/symbols.ts +176 -0
  50. package/core/src/tools/trust-report.ts +193 -0
  51. package/core/src/types.ts +162 -0
  52. package/core/src/validate.ts +106 -0
  53. package/package.json +34 -0
  54. package/skill/SKILL.md +279 -0
  55. package/skill/examples/sailing-directions-example.md +35 -0
  56. package/skill/sailing-directions.template.md +59 -0
  57. package/skill/verify/checks.ts +476 -0
  58. package/skill/verify/dry-run.ts +738 -0
  59. package/skill/verify/fixture.ts +128 -0
@@ -0,0 +1,609 @@
1
+ /**
2
+ * The tool registry: Portolan name → handler + input schema. This table is
3
+ * the single wiring point for every served tool (design.md, decision 3) —
4
+ * the server loop, the error boundary, and the adapters never change per
5
+ * tool, and future tools (`smells.scan`, an MCP `run`) are new table entries,
6
+ * not redesigns. Handlers receive the bound target root implicitly, call the
7
+ * real tool implementations, and return their results verbatim; the server
8
+ * envelopes but never reinterprets them. A thrown rejection (the tool's own
9
+ * error) becomes an MCP tool error at the handler boundary in server.ts.
10
+ * specs/harness/spec.md
11
+ */
12
+ import type { Anchor, ChartEntry, FairwayEntry, VesselEntry } from "../types";
13
+ import { readChart, writeChart } from "../chart-store";
14
+ import { refreshStaleness } from "../staleness";
15
+ import { sweep } from "../tools/sweep";
16
+ import { symbols } from "../tools/symbols";
17
+ import { readManifest } from "../tools/manifests";
18
+ import { appendReceipt, readReceipt, readReceipts } from "../tools/log";
19
+ import { neighborhood, NEIGHBORHOOD_CAPS, NEIGHBORHOOD_DEFAULTS, type NeighborhoodParams } from "../tools/neighborhood";
20
+ import { soundAnchor, soundEdge } from "../tools/sound";
21
+ import { trustReport } from "../tools/trust-report";
22
+ import { computeProposals, decide } from "../harbor/proposals";
23
+ import { renderChartRoom } from "../chartroom/render";
24
+
25
+ /** Everything a handler knows about its world: one province, bound at launch. */
26
+ export interface ToolContext {
27
+ /** The absolute target root this server was launched with (--target). */
28
+ targetRoot: string;
29
+ }
30
+
31
+ /** A JSON Schema describing one tool's arguments. */
32
+ export type JsonSchema = Record<string, unknown>;
33
+
34
+ /**
35
+ * One registry entry. `handler` receives the (already JSON-decoded) tool
36
+ * arguments and returns the tool's structured result.
37
+ */
38
+ export interface ToolSpec {
39
+ name: string;
40
+ description: string;
41
+ inputSchema: JsonSchema;
42
+ handler: (args: Record<string, unknown>, ctx: ToolContext) => unknown;
43
+ }
44
+
45
+ /** A malformed tool call (missing/ill-typed argument), reported as a tool error. */
46
+ export class ToolInputError extends Error {
47
+ constructor(tool: string, message: string) {
48
+ super(`${tool}: ${message}`);
49
+ this.name = "ToolInputError";
50
+ }
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Argument readers: strict, tool-named, and loud. They never coerce.
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function reqString(tool: string, args: Record<string, unknown>, key: string): string {
58
+ const value = args[key];
59
+ if (typeof value !== "string" || value.length === 0) {
60
+ throw new ToolInputError(tool, `argument "${key}" must be a non-empty string`);
61
+ }
62
+ return value;
63
+ }
64
+
65
+ function optString(
66
+ tool: string,
67
+ args: Record<string, unknown>,
68
+ key: string,
69
+ ): string | undefined {
70
+ return args[key] === undefined ? undefined : reqString(tool, args, key);
71
+ }
72
+
73
+ function optInt(tool: string, args: Record<string, unknown>, key: string): number | undefined {
74
+ const value = args[key];
75
+ if (value === undefined) return undefined;
76
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
77
+ throw new ToolInputError(tool, `argument "${key}" must be a non-negative integer`);
78
+ }
79
+ return value;
80
+ }
81
+
82
+ function optBool(tool: string, args: Record<string, unknown>, key: string): boolean | undefined {
83
+ const value = args[key];
84
+ if (value === undefined) return undefined;
85
+ if (typeof value !== "boolean") {
86
+ throw new ToolInputError(tool, `argument "${key}" must be a boolean`);
87
+ }
88
+ return value;
89
+ }
90
+
91
+ function reqObject(
92
+ tool: string,
93
+ args: Record<string, unknown>,
94
+ key: string,
95
+ ): Record<string, unknown> {
96
+ const value = args[key];
97
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
98
+ throw new ToolInputError(tool, `argument "${key}" must be an object`);
99
+ }
100
+ return value as Record<string, unknown>;
101
+ }
102
+
103
+ function optObject(
104
+ tool: string,
105
+ args: Record<string, unknown>,
106
+ key: string,
107
+ ): Record<string, unknown> | undefined {
108
+ return args[key] === undefined ? undefined : reqObject(tool, args, key);
109
+ }
110
+
111
+ function reqArray(tool: string, args: Record<string, unknown>, key: string): unknown[] {
112
+ const value = args[key];
113
+ if (!Array.isArray(value)) {
114
+ throw new ToolInputError(tool, `argument "${key}" must be an array`);
115
+ }
116
+ return value;
117
+ }
118
+
119
+ function reqDecision(tool: string, args: Record<string, unknown>, key: string): "accepted" | "declined" {
120
+ const value = args[key];
121
+ if (value !== "accepted" && value !== "declined") {
122
+ throw new ToolInputError(tool, `argument "${key}" must be "accepted" or "declined"`);
123
+ }
124
+ return value;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Input schemas. Plain JSON Schema on purpose: the table stays readable and
129
+ // harness-agnostic, and the server sends it to the client verbatim.
130
+ // ---------------------------------------------------------------------------
131
+
132
+ const anchorSchema = {
133
+ type: "object",
134
+ description: "A core-foundation anchor: file (path, optional line), manifest (path + key), or receipt (id).",
135
+ oneOf: [
136
+ {
137
+ type: "object",
138
+ properties: {
139
+ type: { const: "file" },
140
+ path: { type: "string" },
141
+ line: { type: "integer", minimum: 1 },
142
+ },
143
+ required: ["type", "path"],
144
+ additionalProperties: false,
145
+ },
146
+ {
147
+ type: "object",
148
+ properties: {
149
+ type: { const: "manifest" },
150
+ path: { type: "string" },
151
+ key: { type: "string" },
152
+ },
153
+ required: ["type", "path", "key"],
154
+ additionalProperties: false,
155
+ },
156
+ {
157
+ type: "object",
158
+ properties: {
159
+ type: { const: "receipt" },
160
+ id: { type: "string" },
161
+ },
162
+ required: ["type", "id"],
163
+ additionalProperties: false,
164
+ },
165
+ ],
166
+ } as const;
167
+
168
+ const trustLabelSchema = {
169
+ type: "string",
170
+ enum: ["measured", "charted", "reported", "doubtful", "unsurveyed"],
171
+ description: "Exactly one trust label per entry (closed vocabulary).",
172
+ } as const;
173
+
174
+ const entryBaseSchema = {
175
+ type: "object",
176
+ properties: {
177
+ id: { type: "string", description: "Stable identifier, unique across the chart." },
178
+ anchors: {
179
+ type: "array",
180
+ minItems: 1,
181
+ items: anchorSchema,
182
+ description: "At least one anchor is mandatory; the store rejects otherwise.",
183
+ },
184
+ trust: trustLabelSchema,
185
+ note: { type: "string", description: "Free-form qualification; never a substitute for evidence." },
186
+ },
187
+ required: ["id", "anchors", "trust"],
188
+ } as const;
189
+
190
+ const vesselEntrySchema = {
191
+ ...entryBaseSchema,
192
+ properties: {
193
+ ...entryBaseSchema.properties,
194
+ kind: { const: "vessel" },
195
+ name: { type: "string" },
196
+ behavior: { type: "string", description: "What the vessel does at runtime; absent renders as unsurveyed." },
197
+ paths: { type: "array", items: { type: "string" }, description: "Source paths covered by the tree signature." },
198
+ },
199
+ required: [...entryBaseSchema.required, "kind", "name", "paths"],
200
+ } as unknown as JsonSchema;
201
+
202
+ const fairwayEntrySchema = {
203
+ ...entryBaseSchema,
204
+ properties: {
205
+ ...entryBaseSchema.properties,
206
+ kind: { const: "fairway" },
207
+ from: { type: "string", description: "Departing vessel id." },
208
+ to: { type: "string", description: "Arriving vessel id." },
209
+ },
210
+ required: [...entryBaseSchema.required, "kind", "from", "to"],
211
+ } as unknown as JsonSchema;
212
+
213
+ const chartEntrySchema = {
214
+ type: "object",
215
+ description:
216
+ "A chart entry (vessel, fairway, portOfEntry, beacon, light, or danger) as the chart capability defines it. " +
217
+ "Every entry carries at least one anchor and exactly one trust label; the chart store validates the full " +
218
+ "ontology and its rejection — naming the offending entry — is the product surface.",
219
+ required: ["kind", "id", "anchors", "trust"],
220
+ } as const;
221
+
222
+ const receiptFilterSchema = {
223
+ type: "object",
224
+ properties: {
225
+ command: { type: "string" },
226
+ scope: { type: "string" },
227
+ outcome: { type: "string" },
228
+ },
229
+ additionalProperties: false,
230
+ description: "Every provided field must match exactly.",
231
+ } as const;
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // The table: the complete served toolset under its Portolan names.
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /** The served toolset, in the order the harness capability lists it. */
238
+ export const TOOL_TABLE: ToolSpec[] = [
239
+ {
240
+ name: "chart.read",
241
+ description:
242
+ "Read the Chart (Padrón) of the province: the machine index entries as stored under <target>/.portolan/chart/index.jsonl. " +
243
+ "Refreshes staleness first — vessels whose sources changed since the last write come back marked pending correction.",
244
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
245
+ handler: (_args, ctx) => {
246
+ refreshStaleness(ctx.targetRoot);
247
+ return { entries: readChart(ctx.targetRoot) };
248
+ },
249
+ },
250
+ {
251
+ name: "chart.write",
252
+ description:
253
+ "Write the Chart (full-replace semantics): validates every entry — anchors and a trust label are mandatory, " +
254
+ "else the store rejects — then persists sheets + index atomically and returns the write result with Notices to Mariners. " +
255
+ "A write that would drop more than a quarter of the existing entries is refused unless allowShrink is true.",
256
+ inputSchema: {
257
+ type: "object",
258
+ properties: {
259
+ entries: { type: "array", minItems: 1, items: chartEntrySchema },
260
+ allowShrink: {
261
+ type: "boolean",
262
+ description: "Explicitly allow a retire-heavy write that drops more than a quarter of the existing entries.",
263
+ },
264
+ },
265
+ required: ["entries"],
266
+ additionalProperties: false,
267
+ },
268
+ handler: (args, ctx) => {
269
+ const options =
270
+ args.allowShrink === undefined ? {} : { allowShrink: optBool("chart.write", args, "allowShrink") as boolean };
271
+ return writeChart(
272
+ ctx.targetRoot,
273
+ reqArray("chart.write", args, "entries") as ChartEntry[],
274
+ options,
275
+ );
276
+ },
277
+ },
278
+ {
279
+ name: "sweep",
280
+ description:
281
+ "ripgrep-backed pattern search over the province. Returns one anchored chunk per match (path, line, matched text, " +
282
+ "optional context), trust-labeled `measured`. No match is an honest empty list.",
283
+ inputSchema: {
284
+ type: "object",
285
+ properties: {
286
+ pattern: { type: "string", description: "ripgrep regular expression." },
287
+ context: { type: "integer", minimum: 0, description: "Surrounding context lines per match." },
288
+ glob: { type: "string", description: "Glob filter, e.g. '*.ts'." },
289
+ },
290
+ required: ["pattern"],
291
+ additionalProperties: false,
292
+ },
293
+ handler: (args, ctx) => {
294
+ const context = optInt("sweep", args, "context");
295
+ const glob = optString("sweep", args, "glob");
296
+ return sweep(ctx.targetRoot, reqString("sweep", args, "pattern"), {
297
+ ...(context !== undefined ? { context } : {}),
298
+ ...(glob !== undefined ? { glob } : {}),
299
+ });
300
+ },
301
+ },
302
+ {
303
+ name: "symbols",
304
+ description:
305
+ "ctags-backed symbol lookup: definitions (name, kind, path, line) and, when requested, references corroborated " +
306
+ "by sweep — never guessed. Trust-labeled `measured`; an unknown symbol is an empty result.",
307
+ inputSchema: {
308
+ type: "object",
309
+ properties: {
310
+ name: { type: "string", description: "Symbol name to look up." },
311
+ references: { type: "boolean", description: "Also resolve references (corroborating sweep)." },
312
+ },
313
+ required: ["name"],
314
+ additionalProperties: false,
315
+ },
316
+ handler: (args, ctx) => {
317
+ const references = optBool("symbols", args, "references");
318
+ return symbols(ctx.targetRoot, reqString("symbols", args, "name"), {
319
+ ...(references !== undefined ? { references } : {}),
320
+ });
321
+ },
322
+ },
323
+ {
324
+ name: "manifests",
325
+ description:
326
+ "Cheap deterministic facts from one manifest file (go.mod, pom.xml, package.json, Cargo.toml, pubspec.yaml): " +
327
+ "name, version, declared dependencies — each anchored to its manifest key, trust-labeled `charted`.",
328
+ inputSchema: {
329
+ type: "object",
330
+ properties: {
331
+ path: { type: "string", description: "Manifest path, relative to the province root." },
332
+ },
333
+ required: ["path"],
334
+ additionalProperties: false,
335
+ },
336
+ handler: (args, ctx) => readManifest(ctx.targetRoot, reqString("manifests", args, "path")),
337
+ },
338
+ {
339
+ name: "sound.edge",
340
+ description:
341
+ "Deterministic verification of an asserted fairway between two charted vessels: a manifest-declared dependency " +
342
+ "and/or name-based references in the source's files. Returns `confirmed` (with evidence) or `unconfirmed` — never a refutation.",
343
+ inputSchema: {
344
+ type: "object",
345
+ properties: {
346
+ fairway: fairwayEntrySchema,
347
+ source: { ...vesselEntrySchema, description: "The vessel the fairway departs from." },
348
+ target: { ...vesselEntrySchema, description: "The vessel the fairway arrives at." },
349
+ },
350
+ required: ["fairway", "source", "target"],
351
+ additionalProperties: false,
352
+ },
353
+ handler: (args, ctx) =>
354
+ soundEdge(ctx.targetRoot, {
355
+ fairway: reqObject("sound.edge", args, "fairway") as unknown as FairwayEntry,
356
+ source: reqObject("sound.edge", args, "source") as unknown as VesselEntry,
357
+ target: reqObject("sound.edge", args, "target") as unknown as VesselEntry,
358
+ }),
359
+ },
360
+ {
361
+ name: "sound.anchor",
362
+ description:
363
+ "Deterministic verification that an anchor cited by a chart entry resolves: file anchors (existence, range, " +
364
+ "cited content), manifest keys, receipt ids. Returns `confirmed` or `refuted` with what was actually found.",
365
+ inputSchema: {
366
+ type: "object",
367
+ properties: {
368
+ anchor: anchorSchema,
369
+ content: { type: "string", description: "For file anchors: the content the entry claims sits at the cited range." },
370
+ endLine: { type: "integer", minimum: 1, description: "For file anchors: last line of the cited range." },
371
+ },
372
+ required: ["anchor"],
373
+ additionalProperties: false,
374
+ },
375
+ handler: (args, ctx) => {
376
+ const content = optString("sound.anchor", args, "content");
377
+ const endLine = optInt("sound.anchor", args, "endLine");
378
+ return soundAnchor(ctx.targetRoot, {
379
+ anchor: reqObject("sound.anchor", args, "anchor") as unknown as Anchor,
380
+ ...(content !== undefined ? { content } : {}),
381
+ ...(endLine !== undefined ? { endLine } : {}),
382
+ });
383
+ },
384
+ },
385
+ {
386
+ name: "log.append",
387
+ description:
388
+ "Append one receipt to the ship's log (<target>/.portolan/log.jsonl): command identity, scope, outcome. " +
389
+ "Returns the receipt with its stable, citable id.",
390
+ inputSchema: {
391
+ type: "object",
392
+ properties: {
393
+ command: { type: "string", description: "Command identity, e.g. 'sweep pattern=UserService'." },
394
+ scope: { type: "string", description: "What was surveyed." },
395
+ outcome: { type: "string", description: "Outcome, e.g. 'ok: 3 chunks' or 'error: missing binary ctags'." },
396
+ meta: { type: "object", description: "Free-form metadata.", additionalProperties: true },
397
+ },
398
+ required: ["command", "outcome"],
399
+ additionalProperties: false,
400
+ },
401
+ handler: (args, ctx) => {
402
+ const scope = optString("log.append", args, "scope");
403
+ const meta = optObject("log.append", args, "meta");
404
+ return appendReceipt(ctx.targetRoot, {
405
+ command: reqString("log.append", args, "command"),
406
+ outcome: reqString("log.append", args, "outcome"),
407
+ ...(scope !== undefined ? { scope } : {}),
408
+ ...(meta !== undefined ? { meta } : {}),
409
+ });
410
+ },
411
+ },
412
+ {
413
+ name: "log.read",
414
+ description:
415
+ "Read the ship's log: one receipt by id, or all receipts matching a filter. Receipt ids are chart-anchorable.",
416
+ inputSchema: {
417
+ type: "object",
418
+ properties: {
419
+ id: { type: "string", description: "Resolve exactly this receipt." },
420
+ filter: receiptFilterSchema,
421
+ },
422
+ additionalProperties: false,
423
+ },
424
+ handler: (args, ctx) => {
425
+ const id = optString("log.read", args, "id");
426
+ if (id !== undefined) {
427
+ const receipt = readReceipt(ctx.targetRoot, id);
428
+ return { receipts: receipt === undefined ? [] : [receipt] };
429
+ }
430
+ const filter = optObject("log.read", args, "filter") ?? {};
431
+ const command = optString("log.read", filter, "command");
432
+ const scope = optString("log.read", filter, "scope");
433
+ const outcome = optString("log.read", filter, "outcome");
434
+ return {
435
+ receipts: readReceipts(ctx.targetRoot, {
436
+ ...(command !== undefined ? { command } : {}),
437
+ ...(scope !== undefined ? { scope } : {}),
438
+ ...(outcome !== undefined ? { outcome } : {}),
439
+ }),
440
+ };
441
+ },
442
+ },
443
+ {
444
+ name: "expeditions.propose",
445
+ description:
446
+ "The Harbor Master: compute the expedition-proposal queue from deterministic chart state — vessels marked " +
447
+ "pending correction (repair), charted vessels with no recorded behavior or no charted light (gap), and " +
448
+ "landscape present since the last survey snapshot (new-land). Every proposal carries its kind, evidence " +
449
+ "anchors, a scope estimate, and a stable fingerprint; fingerprints declined by the Governor are not " +
450
+ "re-proposed while their evidence is unchanged. No input; refreshes staleness first; a still province " +
451
+ "yields an empty queue. Proposals are computed, never imagined.",
452
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
453
+ handler: (_args, ctx) => computeProposals(ctx.targetRoot),
454
+ },
455
+ {
456
+ name: "expeditions.decide",
457
+ description:
458
+ "Record the Governor's decision on a proposal from expeditions.propose — accepted or declined. Appends to " +
459
+ "the append-only decision history under <target>/.portolan/harbor/; the last decision per fingerprint " +
460
+ "wins, so a refusal can be overturned while the evidence is unchanged and reopens when it changes. An " +
461
+ "unknown fingerprint is rejected.",
462
+ inputSchema: {
463
+ type: "object",
464
+ properties: {
465
+ fingerprint: {
466
+ type: "string",
467
+ description: "The proposal's fingerprint, exactly as expeditions.propose returned it.",
468
+ },
469
+ decision: {
470
+ type: "string",
471
+ enum: ["accepted", "declined"],
472
+ description: "The Governor's decision.",
473
+ },
474
+ },
475
+ required: ["fingerprint", "decision"],
476
+ additionalProperties: false,
477
+ },
478
+ handler: (args, ctx) =>
479
+ decide(
480
+ ctx.targetRoot,
481
+ reqString("expeditions.decide", args, "fingerprint"),
482
+ reqDecision("expeditions.decide", args, "decision"),
483
+ ),
484
+ },
485
+ {
486
+ name: "chart.render",
487
+ description:
488
+ "Render the Chart Room — the one-file byproduct export of the Chart at <target>/.portolan/chart-room.html: " +
489
+ "nautical archipelago map + engineering dependency graph, dossier and impact set per vessel, trust legend " +
490
+ "always visible. Reads the Chart and the Sailing Directions, writes exactly that one file, changes no " +
491
+ "storage. Deterministic: the same chart renders the same bytes.",
492
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
493
+ handler: (_args, ctx) => renderChartRoom(ctx.targetRoot),
494
+ },
495
+ {
496
+ name: "trust.report",
497
+ description:
498
+ "One-call verification summary of the province: the trust-label distribution, the per-kind counts, the " +
499
+ "pending-correction vessels with the entries each drags (staleness refreshed first, same semantics as " +
500
+ "chart.read), every chart anchor re-sounded live through the deterministic sound.anchor machinery with every " +
501
+ "refuted anchor named — entry id, cited anchor, what was actually found — and the ship's-log summary. " +
502
+ "Read-only toward the sources and the Chart beyond that staleness refresh: a refuted verdict informs, it " +
503
+ "never rewrites an entry or its trust label.",
504
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
505
+ handler: (_args, ctx) => trustReport(ctx.targetRoot),
506
+ },
507
+ {
508
+ name: "chart.neighborhood",
509
+ description:
510
+ "One vessel's fairway neighborhood in one deterministic call: the charted fairways touching it out to the " +
511
+ "requested depth (default 1, at most 3) in the requested direction (in, out, or both; default both), each edge " +
512
+ "with its id, endpoints, trust label, optional relation, staleness, and anchors with line numbers; the touched " +
513
+ "vessels (trust, staleness, direct fan-in) with their ports of entry, ordered by fan-in, highest first. " +
514
+ "Budgeted — maxEdges (default 40, cap 200) and maxBytes (default 32768, cap 131072) — and a budget cut is " +
515
+ "stated loudly, never a silent prefix. An unknown vessel is an honest unsurveyed error, never an empty " +
516
+ "neighborhood. With verify: true every returned edge's anchors are re-sounded and each edge is marked " +
517
+ "confirmed or refuted by name; the verdict informs, the Chart is never written. Read-only toward the Chart: " +
518
+ "each call appends exactly one ship's-log receipt.",
519
+ inputSchema: {
520
+ type: "object",
521
+ properties: {
522
+ vessel: { type: "string", description: "The charted vessel whose neighborhood is asked for." },
523
+ direction: {
524
+ type: "string",
525
+ enum: ["in", "out", "both"],
526
+ default: "both",
527
+ description: "Which charted fairways count as touching the vessel.",
528
+ },
529
+ depth: {
530
+ // The advertised limits quote the engine's own constants — one
531
+ // owner per cap, so the schema cannot silently desync from
532
+ // enforcement.
533
+ type: "integer",
534
+ minimum: 1,
535
+ maximum: NEIGHBORHOOD_CAPS.depth,
536
+ default: NEIGHBORHOOD_DEFAULTS.depth,
537
+ description: "Hops to traverse.",
538
+ },
539
+ verify: {
540
+ type: "boolean",
541
+ default: false,
542
+ description:
543
+ "Re-sound every returned edge's anchors and mark each edge confirmed or refuted, naming the refuted " +
544
+ "anchors. Informs; never modifies the Chart.",
545
+ },
546
+ maxEdges: {
547
+ type: "integer",
548
+ minimum: 1,
549
+ maximum: NEIGHBORHOOD_CAPS.maxEdges,
550
+ default: NEIGHBORHOOD_DEFAULTS.maxEdges,
551
+ description: "Edge budget.",
552
+ },
553
+ maxBytes: {
554
+ type: "integer",
555
+ minimum: 1,
556
+ maximum: NEIGHBORHOOD_CAPS.maxBytes,
557
+ default: NEIGHBORHOOD_DEFAULTS.maxBytes,
558
+ description: "Serialized-response budget in bytes.",
559
+ },
560
+ },
561
+ required: ["vessel"],
562
+ additionalProperties: false,
563
+ },
564
+ handler: (args, ctx) => {
565
+ // The engine validates every parameter itself and rejects naming the
566
+ // violated parameter and its allowed values; the rejection surfaces as
567
+ // a tool error at the server boundary, unmodified.
568
+ const response = neighborhood(ctx.targetRoot, args as unknown as NeighborhoodParams);
569
+ // The one write this call makes: exactly one ship's-log receipt, through
570
+ // the same append path log.append serves. A rejected call never reaches
571
+ // this line, so a rejection leaves no receipt.
572
+ appendReceipt(ctx.targetRoot, {
573
+ command: "chart.neighborhood",
574
+ scope: response.vessel,
575
+ outcome:
576
+ `ok: ${response.edges.length} edge${response.edges.length === 1 ? "" : "s"}, ` +
577
+ `${response.vessels.length} vessel${response.vessels.length === 1 ? "" : "s"}` +
578
+ (response.truncated
579
+ ? `, truncated: ${response.droppedEdges} edge${response.droppedEdges === 1 ? "" : "s"} and ` +
580
+ `${response.droppedVessels} vessel${response.droppedVessels === 1 ? "" : "s"} dropped`
581
+ : ""),
582
+ });
583
+ return response;
584
+ },
585
+ },
586
+ ];
587
+
588
+ /** The served Portolan tool names, in table order (the harness capability's fourteen). */
589
+ export const TOOL_NAMES = TOOL_TABLE.map((spec) => spec.name);
590
+
591
+ /**
592
+ * Every tool accepts an optional `targetRoot` — an echo of the province root
593
+ * the server was launched with, never a redirect. The server refuses any
594
+ * value that is not the launched root (see server.ts); the property is
595
+ * declared so clients see the binding instead of discovering it by
596
+ * rejection. (Named `targetRoot`, not `target`: sound.edge's `target` is a
597
+ * vessel, not a province.)
598
+ */
599
+ const BOUND_TARGET_PROPERTY: JsonSchema = {
600
+ type: "string",
601
+ description:
602
+ "The province root this server was launched with (--target). The server is bound to it: " +
603
+ "a different value is refused — changing provinces means launching a new server.",
604
+ };
605
+
606
+ for (const spec of TOOL_TABLE) {
607
+ const schema = spec.inputSchema as { properties?: Record<string, unknown> };
608
+ schema.properties = { ...(schema.properties ?? {}), targetRoot: BOUND_TARGET_PROPERTY };
609
+ }