@fcon-tech/portolan 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/adapters/README.md +226 -0
- package/adapters/omp/portolan-mcp +19 -0
- package/adapters/opencode/expedition-launcher +70 -0
- package/adapters/opencode/install.test.ts +105 -0
- package/adapters/opencode/install.ts +357 -0
- package/adapters/pi/portolan-mcp +19 -0
- package/adapters/scheduling/night-watch.cron +23 -0
- package/core/schema/chart.schema.json +154 -0
- package/core/src/bin/portolan.ts +84 -0
- package/core/src/chart-io.rollback-fixture.ts +55 -0
- package/core/src/chart-io.ts +121 -0
- package/core/src/chart-store.ts +137 -0
- package/core/src/chartroom/cli.ts +63 -0
- package/core/src/chartroom/render.ts +213 -0
- package/core/src/chartroom/review-template.html +232 -0
- package/core/src/chartroom/review.ts +109 -0
- package/core/src/chartroom/template.html +1090 -0
- package/core/src/fan-in.ts +84 -0
- package/core/src/harbor/chat-format.ts +154 -0
- package/core/src/harbor/cli.ts +178 -0
- package/core/src/harbor/errors.ts +22 -0
- package/core/src/harbor/fingerprint.ts +29 -0
- package/core/src/harbor/history.ts +178 -0
- package/core/src/harbor/launcher.ts +155 -0
- package/core/src/harbor/night-policy.ts +64 -0
- package/core/src/harbor/proposals.ts +324 -0
- package/core/src/harbor/run.ts +72 -0
- package/core/src/harbor/settings.ts +108 -0
- package/core/src/harbor/snapshot.ts +187 -0
- package/core/src/harbor/watch.ts +103 -0
- package/core/src/index.ts +28 -0
- package/core/src/notices.ts +117 -0
- package/core/src/perimeter.ts +44 -0
- package/core/src/server/adapter-boundary.ts +66 -0
- package/core/src/server/main.ts +27 -0
- package/core/src/server/registry.ts +609 -0
- package/core/src/server/server.ts +123 -0
- package/core/src/server/test-harness.ts +161 -0
- package/core/src/sheets.ts +151 -0
- package/core/src/staleness.ts +203 -0
- package/core/src/tools/log.ts +215 -0
- package/core/src/tools/manifests.ts +912 -0
- package/core/src/tools/neighborhood.ts +423 -0
- package/core/src/tools/shared.ts +72 -0
- package/core/src/tools/sound.ts +634 -0
- package/core/src/tools/sweep.ts +198 -0
- package/core/src/tools/symbols.ts +176 -0
- package/core/src/tools/trust-report.ts +193 -0
- package/core/src/types.ts +162 -0
- package/core/src/validate.ts +106 -0
- package/package.json +34 -0
- package/skill/SKILL.md +279 -0
- package/skill/examples/sailing-directions-example.md +35 -0
- package/skill/sailing-directions.template.md +59 -0
- package/skill/verify/checks.ts +476 -0
- package/skill/verify/dry-run.ts +738 -0
- package/skill/verify/fixture.ts +128 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A mechanical dry run of the taught method (skill/SKILL.md) against a small
|
|
3
|
+
* fixture province, for the expedition-skill change's verifications.
|
|
4
|
+
*
|
|
5
|
+
* It follows the skill's sections in order — lift-off, the one approval, the
|
|
6
|
+
* perimeter, the five passes in the fixed order with the assert → sound →
|
|
7
|
+
* write-with-verdict loop, honesty, interruption, later-expedition repair,
|
|
8
|
+
* Sailing Directions — and writes the Chart through the real store from
|
|
9
|
+
* core/. The harness is a stub that records everything the Governor would
|
|
10
|
+
* see; the sweep, symbols, manifests, sounding, and log operations are
|
|
11
|
+
* deterministic stand-ins for the MCP tools the mcp-delivery change will
|
|
12
|
+
* serve.
|
|
13
|
+
*
|
|
14
|
+
* Usage: runExpedition(targetRoot, { stamp, abortAfter? }) — see checks.ts.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
readChart,
|
|
20
|
+
refreshStaleness,
|
|
21
|
+
writeChart,
|
|
22
|
+
type Anchor,
|
|
23
|
+
type ChartEntry,
|
|
24
|
+
type IndexedEntry,
|
|
25
|
+
type Notice,
|
|
26
|
+
} from "../../core/src/index";
|
|
27
|
+
import { trustReport } from "../../core/src/tools/trust-report";
|
|
28
|
+
import { TRUST_LABELS } from "../../core/src/types";
|
|
29
|
+
|
|
30
|
+
const SKILL_PATH = join(import.meta.dir, "..", "SKILL.md");
|
|
31
|
+
|
|
32
|
+
/** The five pass headings, in the fixed order the skill teaches. */
|
|
33
|
+
const PASS_HEADINGS = [
|
|
34
|
+
"### Pass 1 — Vessels",
|
|
35
|
+
"### Pass 2 — Fairways",
|
|
36
|
+
"### Pass 3 — Ports of entry and beacons",
|
|
37
|
+
"### Pass 4 — Lights",
|
|
38
|
+
"### Pass 5 — Dangers",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const PASS_NAMES = ["vessels", "fairways", "portsAndBeacons", "lights", "dangers"] as const;
|
|
42
|
+
type PassName = (typeof PASS_NAMES)[number];
|
|
43
|
+
|
|
44
|
+
export interface JournalEvent {
|
|
45
|
+
type: "approval" | "install" | "receipt" | "pass" | "sounding" | "refutation" | "write" | "brief" | "staleness";
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Receipt {
|
|
50
|
+
id: string;
|
|
51
|
+
command: string;
|
|
52
|
+
scope: string;
|
|
53
|
+
outcome: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DryRunResult {
|
|
57
|
+
/** Everything the harness showed the Governor, in order. */
|
|
58
|
+
governorMessages: string[];
|
|
59
|
+
approvalsAsked: number;
|
|
60
|
+
receipts: Receipt[];
|
|
61
|
+
journal: JournalEvent[];
|
|
62
|
+
/** Notices from the expedition's final chart write. */
|
|
63
|
+
notices: Notice[];
|
|
64
|
+
/** The Sailing Directions (null when the expedition was interrupted). */
|
|
65
|
+
brief: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// The harness stub: what a harness would show the Governor, plus the ship's log.
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
class Harness {
|
|
73
|
+
governorMessages: string[] = [];
|
|
74
|
+
approvalsAsked = 0;
|
|
75
|
+
receipts: Receipt[] = [];
|
|
76
|
+
journal: JournalEvent[] = [];
|
|
77
|
+
|
|
78
|
+
say(message: string): void {
|
|
79
|
+
this.governorMessages.push(message);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
askApproval(message: string): void {
|
|
83
|
+
this.approvalsAsked += 1;
|
|
84
|
+
this.journal.push({ type: "approval", count: this.approvalsAsked });
|
|
85
|
+
this.say(message);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
appendReceipt(command: string, scope: string, outcome: string): string {
|
|
89
|
+
const id = `r${this.receipts.length + 1}`;
|
|
90
|
+
this.receipts.push({ id, command, scope, outcome });
|
|
91
|
+
this.journal.push({ type: "receipt", id, command, scope, outcome });
|
|
92
|
+
return id;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// The skill text: the dry run refuses to run without it.
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function loadSkill(): { text: string; approval: string } {
|
|
101
|
+
const text = readFileSync(SKILL_PATH, "utf8");
|
|
102
|
+
const positions = PASS_HEADINGS.map((heading) => text.indexOf(heading));
|
|
103
|
+
if (positions.some((p) => p < 0)) {
|
|
104
|
+
throw new Error("SKILL.md is missing one of the five pass headings");
|
|
105
|
+
}
|
|
106
|
+
for (let i = 1; i < positions.length; i++) {
|
|
107
|
+
if (positions[i] <= positions[i - 1]) {
|
|
108
|
+
throw new Error("SKILL.md does not teach the five passes in the fixed order");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const marker = "Ask it in these words:";
|
|
112
|
+
const markerAt = text.indexOf(marker);
|
|
113
|
+
if (markerAt < 0) throw new Error("SKILL.md does not pin the approval message");
|
|
114
|
+
const quoteLines: string[] = [];
|
|
115
|
+
for (const raw of text.slice(markerAt + marker.length).split("\n")) {
|
|
116
|
+
if (raw.startsWith(">")) quoteLines.push(raw.replace(/^>\s?/, "").trim());
|
|
117
|
+
else if (quoteLines.length > 0) break;
|
|
118
|
+
}
|
|
119
|
+
const approval = quoteLines.join(" ").replace(/\s+/g, " ").trim();
|
|
120
|
+
if (!approval.toLowerCase().includes("network") || !approval.toLowerCase().includes("install")) {
|
|
121
|
+
throw new Error("the pinned approval message does not cover network and installation");
|
|
122
|
+
}
|
|
123
|
+
return { text, approval };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Deterministic probe stand-ins (sweep / symbols / manifests).
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function walk(root: string, rel = "", out: string[] = []): string[] {
|
|
131
|
+
const entries = readdirSync(join(root, rel), { withFileTypes: true }).sort((a, b) =>
|
|
132
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
133
|
+
);
|
|
134
|
+
for (const de of entries) {
|
|
135
|
+
if (de.name === ".portolan" || de.name === "node_modules" || de.name === ".git") continue;
|
|
136
|
+
const r = rel ? `${rel}/${de.name}` : de.name;
|
|
137
|
+
if (de.isDirectory()) walk(root, r, out);
|
|
138
|
+
else out.push(r);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sweep(root: string, needle: string, dir = ""): Array<{ path: string; line: number; text: string }> {
|
|
144
|
+
const hits: Array<{ path: string; line: number; text: string }> = [];
|
|
145
|
+
for (const rel of walk(root).filter((p) => p.startsWith(dir))) {
|
|
146
|
+
const lines = readFileSync(join(root, rel), "utf8").split("\n");
|
|
147
|
+
lines.forEach((text, i) => {
|
|
148
|
+
if (text.includes(needle)) hits.push({ path: rel, line: i + 1, text: text.trim() });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return hits;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function findLine(root: string, rel: string, needle: string): number {
|
|
155
|
+
const lines = readFileSync(join(root, rel), "utf8").split("\n");
|
|
156
|
+
const at = lines.findIndex((text) => text.includes(needle));
|
|
157
|
+
if (at < 0) throw new Error(`${needle} not found in ${rel}`);
|
|
158
|
+
return at + 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readJson(root: string, rel: string): Record<string, unknown> {
|
|
162
|
+
return JSON.parse(readFileSync(join(root, rel), "utf8")) as Record<string, unknown>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Sounding stand-ins (sound.anchor / sound.edge), per the soundings contract.
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
function soundAnchor(
|
|
170
|
+
root: string,
|
|
171
|
+
anchor: Anchor,
|
|
172
|
+
receipts: Receipt[]
|
|
173
|
+
): { verdict: "confirmed" | "refuted"; found?: string } {
|
|
174
|
+
if (anchor.type === "file") {
|
|
175
|
+
const path = join(root, anchor.path);
|
|
176
|
+
if (!existsSync(path)) return { verdict: "refuted", found: `no such file: ${anchor.path}` };
|
|
177
|
+
const lines = readFileSync(path, "utf8").split("\n");
|
|
178
|
+
if (anchor.line !== undefined && (anchor.line < 1 || anchor.line > lines.length)) {
|
|
179
|
+
return { verdict: "refuted", found: `${anchor.path} has ${lines.length} lines` };
|
|
180
|
+
}
|
|
181
|
+
return { verdict: "confirmed" };
|
|
182
|
+
}
|
|
183
|
+
if (anchor.type === "manifest") {
|
|
184
|
+
try {
|
|
185
|
+
const json = readJson(root, anchor.path);
|
|
186
|
+
const value = anchor.key.split(".").reduce<unknown>((acc, key) => {
|
|
187
|
+
return acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[key] : undefined;
|
|
188
|
+
}, json);
|
|
189
|
+
return value === undefined
|
|
190
|
+
? { verdict: "refuted", found: `no key ${anchor.key} in ${anchor.path}` }
|
|
191
|
+
: { verdict: "confirmed" };
|
|
192
|
+
} catch {
|
|
193
|
+
return { verdict: "refuted", found: `${anchor.path} is not readable` };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return receipts.some((r) => r.id === anchor.id)
|
|
197
|
+
? { verdict: "confirmed" }
|
|
198
|
+
: { verdict: "refuted", found: `no receipt ${anchor.id}` };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function soundEdge(
|
|
202
|
+
root: string,
|
|
203
|
+
fromDir: string,
|
|
204
|
+
toPackage: string
|
|
205
|
+
): { verdict: "confirmed" | "unconfirmed"; evidence: Anchor[] } {
|
|
206
|
+
const evidence: Anchor[] = [];
|
|
207
|
+
const manifestRel = `${fromDir}/package.json`;
|
|
208
|
+
if (existsSync(join(root, manifestRel))) {
|
|
209
|
+
const json = readJson(root, manifestRel);
|
|
210
|
+
const deps = json.dependencies as Record<string, string> | undefined;
|
|
211
|
+
if (deps && deps[toPackage] !== undefined) {
|
|
212
|
+
evidence.push({ type: "manifest", path: manifestRel, key: `dependencies.${toPackage}` });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const hit of sweep(root, toPackage, fromDir)) {
|
|
216
|
+
evidence.push({ type: "file", path: hit.path, line: hit.line });
|
|
217
|
+
}
|
|
218
|
+
return evidence.length > 0
|
|
219
|
+
? { verdict: "confirmed", evidence }
|
|
220
|
+
: { verdict: "unconfirmed", evidence: [] };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Anchor rendering for the brief.
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
function anchorText(anchor: Anchor): string {
|
|
228
|
+
switch (anchor.type) {
|
|
229
|
+
case "file":
|
|
230
|
+
return anchor.line === undefined ? anchor.path : `${anchor.path}:${anchor.line}`;
|
|
231
|
+
case "manifest":
|
|
232
|
+
return `${anchor.path}#${anchor.key}`;
|
|
233
|
+
case "receipt":
|
|
234
|
+
return `receipt ${anchor.id}`;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// The expedition.
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
interface VesselDesc {
|
|
243
|
+
id: string;
|
|
244
|
+
dir: string;
|
|
245
|
+
pkg: string;
|
|
246
|
+
manifestRel: string;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function descriptors(root: string): VesselDesc[] {
|
|
250
|
+
return walk(root)
|
|
251
|
+
.filter((rel) => rel.endsWith("/package.json"))
|
|
252
|
+
.map((manifestRel) => {
|
|
253
|
+
const dir = manifestRel.slice(0, manifestRel.length - "/package.json".length);
|
|
254
|
+
const json = readJson(root, manifestRel);
|
|
255
|
+
return {
|
|
256
|
+
id: dir.split("/").pop() ?? dir,
|
|
257
|
+
dir,
|
|
258
|
+
pkg: String(json.name),
|
|
259
|
+
manifestRel,
|
|
260
|
+
};
|
|
261
|
+
})
|
|
262
|
+
.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function runExpedition(
|
|
266
|
+
targetRoot: string,
|
|
267
|
+
opts: { stamp: string; abortAfter?: "fairways" }
|
|
268
|
+
): DryRunResult {
|
|
269
|
+
const skill = loadSkill();
|
|
270
|
+
const harness = new Harness();
|
|
271
|
+
const provinceName = String(readJson(targetRoot, "package.json").name);
|
|
272
|
+
|
|
273
|
+
// -- skill §1, lift-off: acknowledge, then ask the one approval (§2). -----
|
|
274
|
+
harness.say(
|
|
275
|
+
`Expedition started on ${provinceName}. One approval follows; then I survey and return Sailing Directions.`
|
|
276
|
+
);
|
|
277
|
+
harness.askApproval(skill.approval);
|
|
278
|
+
|
|
279
|
+
// -- skill §1 step 3-4: install the toolset yourself; §2: receipt it. -----
|
|
280
|
+
harness.appendReceipt(
|
|
281
|
+
"install: portolan mcp server + expedition skill (harness adapter)",
|
|
282
|
+
"harness",
|
|
283
|
+
"installed"
|
|
284
|
+
);
|
|
285
|
+
harness.journal.push({ type: "install", tools: 14 });
|
|
286
|
+
|
|
287
|
+
const descs = descriptors(targetRoot);
|
|
288
|
+
const byId = new Map(descs.map((d) => [d.id, d]));
|
|
289
|
+
|
|
290
|
+
// -- skill §8: a later expedition begins from the existing Chart. ---------
|
|
291
|
+
const repairing = existsSync(join(targetRoot, ".portolan", "chart", "index.jsonl"));
|
|
292
|
+
let scope = descs;
|
|
293
|
+
let passthrough: ChartEntry[] = [];
|
|
294
|
+
if (repairing) {
|
|
295
|
+
const staleness = refreshStaleness(targetRoot);
|
|
296
|
+
harness.journal.push({ type: "staleness", changedVessels: staleness.changedVessels });
|
|
297
|
+
const changed = new Set(staleness.changedVessels);
|
|
298
|
+
scope = descs.filter((d) => changed.has(d.id));
|
|
299
|
+
passthrough = readChart(targetRoot)
|
|
300
|
+
.filter((entry) => {
|
|
301
|
+
if (entry.kind === "fairway") return !changed.has(entry.from) && !changed.has(entry.to);
|
|
302
|
+
if (entry.kind === "vessel") return !changed.has(entry.id);
|
|
303
|
+
return !changed.has(entry.vessel);
|
|
304
|
+
})
|
|
305
|
+
// Strip store metadata: the store validates and re-stamps it on write.
|
|
306
|
+
.map((entry) => {
|
|
307
|
+
const { stale: _stale, signature: _signature, ...rest } = entry;
|
|
308
|
+
return rest as ChartEntry;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Fresh expeditions chart as they go (skill §4); a repair gathers and
|
|
313
|
+
// writes once so only the stale entries are touched (skill §8).
|
|
314
|
+
let entries: ChartEntry[] = [];
|
|
315
|
+
const writeAsYouGo = !repairing;
|
|
316
|
+
|
|
317
|
+
const commit = (pass: PassName | "close-out" | "repair") => {
|
|
318
|
+
if (!writeAsYouGo) return;
|
|
319
|
+
writeChart(targetRoot, entries);
|
|
320
|
+
harness.journal.push({ type: "write", pass, entries: entries.length });
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// -- skill §4, pass 1: vessels from manifests and entry points. -----------
|
|
324
|
+
const vesselEntries: ChartEntry[] = scope.map((d) => {
|
|
325
|
+
const anchors: Anchor[] = [{ type: "manifest", path: d.manifestRel, key: "name" }];
|
|
326
|
+
const json = readJson(targetRoot, d.manifestRel);
|
|
327
|
+
if (json.bin !== undefined) anchors.push({ type: "manifest", path: d.manifestRel, key: "bin" });
|
|
328
|
+
if (json.main !== undefined) anchors.push({ type: "manifest", path: d.manifestRel, key: "main" });
|
|
329
|
+
return {
|
|
330
|
+
kind: "vessel" as const,
|
|
331
|
+
id: d.id,
|
|
332
|
+
name: d.dir,
|
|
333
|
+
paths: [d.dir],
|
|
334
|
+
anchors,
|
|
335
|
+
trust: "charted" as const,
|
|
336
|
+
};
|
|
337
|
+
});
|
|
338
|
+
harness.journal.push({ type: "pass", name: "vessels" });
|
|
339
|
+
|
|
340
|
+
// Builds and tests need no second approval (skill §2); receipt them.
|
|
341
|
+
const libDesc = scope.find((d) => d.id === "lib");
|
|
342
|
+
if (libDesc) {
|
|
343
|
+
const proc = Bun.spawnSync(["bun", "check.ts"], {
|
|
344
|
+
cwd: join(targetRoot, libDesc.dir),
|
|
345
|
+
stdout: "pipe",
|
|
346
|
+
stderr: "pipe",
|
|
347
|
+
});
|
|
348
|
+
const outcome = proc.exitCode === 0 ? "pass" : "fail";
|
|
349
|
+
const receiptId = harness.appendReceipt("bun check.ts", libDesc.dir, outcome);
|
|
350
|
+
const libVessel = vesselEntries.find((e) => e.kind === "vessel" && e.id === "lib");
|
|
351
|
+
if (libVessel && libVessel.kind === "vessel") {
|
|
352
|
+
libVessel.behavior = `exports parse(); the target's own check script executes it (receipt ${receiptId}: ${outcome})`;
|
|
353
|
+
libVessel.anchors.push({ type: "receipt", id: receiptId });
|
|
354
|
+
libVessel.trust = "measured";
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
entries = [...vesselEntries];
|
|
358
|
+
commit("vessels");
|
|
359
|
+
|
|
360
|
+
// -- skill §4, pass 2 + §5: fairways, asserted then sounded. --------------
|
|
361
|
+
harness.journal.push({ type: "pass", name: "fairways" });
|
|
362
|
+
const fairwayEntries: ChartEntry[] = [];
|
|
363
|
+
for (const from of scope) {
|
|
364
|
+
const deps = (readJson(targetRoot, from.manifestRel).dependencies ?? {}) as Record<string, string>;
|
|
365
|
+
for (const toPkg of Object.keys(deps)) {
|
|
366
|
+
const to = descs.find((d) => d.pkg === toPkg && d.id !== from.id);
|
|
367
|
+
if (!to) continue;
|
|
368
|
+
const sounding = soundEdge(targetRoot, from.dir, to.pkg);
|
|
369
|
+
harness.journal.push({
|
|
370
|
+
type: "sounding",
|
|
371
|
+
tool: "sound.edge",
|
|
372
|
+
subject: `${from.id}->${to.id}`,
|
|
373
|
+
verdict: sounding.verdict,
|
|
374
|
+
});
|
|
375
|
+
fairwayEntries.push({
|
|
376
|
+
kind: "fairway",
|
|
377
|
+
id: `${from.id}-${to.id}`,
|
|
378
|
+
from: from.id,
|
|
379
|
+
to: to.id,
|
|
380
|
+
anchors: sounding.evidence,
|
|
381
|
+
trust: sounding.verdict === "confirmed" ? "measured" : "doubtful",
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// The README claims a cli→api fairway: sound it, then downgrade (§5).
|
|
386
|
+
const claimLine = findLine(targetRoot, "README.md", "calls the API");
|
|
387
|
+
const cliToApi = soundEdge(targetRoot, byId.get("cli")!.dir, byId.get("api")!.pkg);
|
|
388
|
+
harness.journal.push({
|
|
389
|
+
type: "sounding",
|
|
390
|
+
tool: "sound.edge",
|
|
391
|
+
subject: "cli->api",
|
|
392
|
+
verdict: cliToApi.verdict,
|
|
393
|
+
});
|
|
394
|
+
if (scope.some((d) => d.id === "cli") || scope.some((d) => d.id === "api")) {
|
|
395
|
+
fairwayEntries.push({
|
|
396
|
+
kind: "fairway",
|
|
397
|
+
id: "cli-api",
|
|
398
|
+
from: "cli",
|
|
399
|
+
to: "api",
|
|
400
|
+
anchors: [{ type: "file", path: "README.md", line: claimLine }],
|
|
401
|
+
trust: "doubtful",
|
|
402
|
+
note: "claimed in docs; neither manifest nor source references confirm",
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
entries = [...entries, ...fairwayEntries];
|
|
406
|
+
commit("fairways");
|
|
407
|
+
|
|
408
|
+
// -- skill §7: an interrupted expedition leaves a valid partial Chart. ----
|
|
409
|
+
if (opts.abortAfter === "fairways") {
|
|
410
|
+
const closed = entries.map((entry) =>
|
|
411
|
+
entry.kind === "vessel"
|
|
412
|
+
? {
|
|
413
|
+
...entry,
|
|
414
|
+
note:
|
|
415
|
+
"ports of entry, beacons, lights, dangers: unsurveyed (Expedition stopped after the fairways pass)",
|
|
416
|
+
}
|
|
417
|
+
: entry
|
|
418
|
+
);
|
|
419
|
+
writeChart(targetRoot, closed);
|
|
420
|
+
harness.journal.push({ type: "write", pass: "close-out", entries: closed.length });
|
|
421
|
+
harness.say(
|
|
422
|
+
"Expedition stopped after the fairways pass. Vessels and fairways stand on the Chart; ports of entry, beacons, lights, and dangers remain unsurveyed."
|
|
423
|
+
);
|
|
424
|
+
return finish(targetRoot, harness, { notices: [], brief: null });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// -- skill §4, pass 3: ports of entry and beacons. ------------------------
|
|
428
|
+
harness.journal.push({ type: "pass", name: "portsAndBeacons" });
|
|
429
|
+
const surfaceEntries: ChartEntry[] = [];
|
|
430
|
+
for (const d of scope) {
|
|
431
|
+
if (d.id === "cli") {
|
|
432
|
+
const binLine = findLine(targetRoot, `${d.dir}/bin/cli.ts`, "#!/usr/bin/env bun");
|
|
433
|
+
surfaceEntries.push({
|
|
434
|
+
kind: "portOfEntry",
|
|
435
|
+
id: "cli-bin",
|
|
436
|
+
vessel: "cli",
|
|
437
|
+
protocol: "cli",
|
|
438
|
+
anchors: [{ type: "file", path: `${d.dir}/bin/cli.ts`, line: binLine }],
|
|
439
|
+
trust: "measured",
|
|
440
|
+
});
|
|
441
|
+
const envLine = findLine(targetRoot, `${d.dir}/bin/cli.ts`, "process.env[");
|
|
442
|
+
surfaceEntries.push({
|
|
443
|
+
kind: "beacon",
|
|
444
|
+
id: "cli-env-dynamic",
|
|
445
|
+
vessel: "cli",
|
|
446
|
+
surface: "env",
|
|
447
|
+
key: "unknown — built at run time",
|
|
448
|
+
anchors: [{ type: "file", path: `${d.dir}/bin/cli.ts`, line: envLine }],
|
|
449
|
+
trust: "unsurveyed",
|
|
450
|
+
note: "key constructed dynamically; not determinable statically",
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
if (d.id === "api") {
|
|
454
|
+
const serveLine = findLine(targetRoot, `${d.dir}/server.ts`, "Bun.serve({");
|
|
455
|
+
const portLine = findLine(targetRoot, `${d.dir}/server.ts`, "process.env.PORT");
|
|
456
|
+
surfaceEntries.push(
|
|
457
|
+
{
|
|
458
|
+
kind: "portOfEntry",
|
|
459
|
+
id: "api-http",
|
|
460
|
+
vessel: "api",
|
|
461
|
+
protocol: "http",
|
|
462
|
+
anchors: [{ type: "file", path: `${d.dir}/server.ts`, line: serveLine }],
|
|
463
|
+
trust: "measured",
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
kind: "beacon",
|
|
467
|
+
id: "api-port-env",
|
|
468
|
+
vessel: "api",
|
|
469
|
+
surface: "env",
|
|
470
|
+
key: "PORT",
|
|
471
|
+
anchors: [{ type: "file", path: `${d.dir}/server.ts`, line: portLine }],
|
|
472
|
+
trust: "measured",
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
kind: "beacon",
|
|
476
|
+
id: "api-port-8080",
|
|
477
|
+
vessel: "api",
|
|
478
|
+
surface: "port",
|
|
479
|
+
key: "8080",
|
|
480
|
+
anchors: [{ type: "file", path: `${d.dir}/server.ts`, line: portLine }],
|
|
481
|
+
trust: "measured",
|
|
482
|
+
}
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
entries = [...entries, ...surfaceEntries];
|
|
487
|
+
commit("portsAndBeacons");
|
|
488
|
+
|
|
489
|
+
// -- skill §4, pass 4 + §5: lights; a refuted doc claim is corrected. -----
|
|
490
|
+
harness.journal.push({ type: "pass", name: "lights" });
|
|
491
|
+
const lightEntries: ChartEntry[] = [];
|
|
492
|
+
for (const d of scope) {
|
|
493
|
+
if (d.id === "lib") {
|
|
494
|
+
// The README claims an export the source does not have: sound the
|
|
495
|
+
// claimed anchor, record the refutation, correct to the truth.
|
|
496
|
+
const claimed: Anchor = { type: "file", path: "packages/lib/src/validate.ts", line: 1 };
|
|
497
|
+
const sounded = soundAnchor(targetRoot, claimed, harness.receipts);
|
|
498
|
+
harness.journal.push({
|
|
499
|
+
type: "sounding",
|
|
500
|
+
tool: "sound.anchor",
|
|
501
|
+
subject: "packages/lib/src/validate.ts:1 (claimed export validate())",
|
|
502
|
+
verdict: sounded.verdict,
|
|
503
|
+
});
|
|
504
|
+
if (sounded.verdict === "refuted") {
|
|
505
|
+
const exportLine = findLine(targetRoot, `${d.dir}/src/parse.ts`, "export function parse");
|
|
506
|
+
harness.journal.push({
|
|
507
|
+
type: "refutation",
|
|
508
|
+
assertion: "light: lib exports validate() from src/validate.ts (README claim)",
|
|
509
|
+
action: `corrected to export function parse() at packages/lib/src/parse.ts:${exportLine}`,
|
|
510
|
+
});
|
|
511
|
+
lightEntries.push({
|
|
512
|
+
kind: "light",
|
|
513
|
+
id: "lib-parse",
|
|
514
|
+
vessel: "lib",
|
|
515
|
+
name: "export function parse()",
|
|
516
|
+
anchors: [{ type: "file", path: `${d.dir}/src/parse.ts`, line: exportLine }],
|
|
517
|
+
trust: "measured",
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (d.id === "api") {
|
|
522
|
+
const healthLine = findLine(targetRoot, `${d.dir}/server.ts`, '"/health"');
|
|
523
|
+
lightEntries.push({
|
|
524
|
+
kind: "light",
|
|
525
|
+
id: "api-health",
|
|
526
|
+
vessel: "api",
|
|
527
|
+
name: "GET /health",
|
|
528
|
+
anchors: [{ type: "file", path: `${d.dir}/server.ts`, line: healthLine }],
|
|
529
|
+
trust: "measured",
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
if (d.id === "cli") {
|
|
533
|
+
const flagLine = findLine(targetRoot, `${d.dir}/bin/cli.ts`, "--json");
|
|
534
|
+
lightEntries.push({
|
|
535
|
+
kind: "light",
|
|
536
|
+
id: "cli-json",
|
|
537
|
+
vessel: "cli",
|
|
538
|
+
name: "flag --json",
|
|
539
|
+
anchors: [{ type: "file", path: `${d.dir}/bin/cli.ts`, line: flagLine }],
|
|
540
|
+
trust: "measured",
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
entries = [...entries, ...lightEntries];
|
|
545
|
+
commit("lights");
|
|
546
|
+
|
|
547
|
+
// -- skill §4, pass 5: dangers, anchored to the exact lines. -------------
|
|
548
|
+
harness.journal.push({ type: "pass", name: "dangers" });
|
|
549
|
+
const dangerEntries: ChartEntry[] = [];
|
|
550
|
+
for (const d of scope) {
|
|
551
|
+
if (d.id === "lib") {
|
|
552
|
+
const readmeClaim = findLine(targetRoot, "README.md", "exports validate()");
|
|
553
|
+
const exportLine = findLine(targetRoot, `${d.dir}/src/parse.ts`, "export function parse");
|
|
554
|
+
dangerEntries.push({
|
|
555
|
+
kind: "danger",
|
|
556
|
+
id: "docs-drift",
|
|
557
|
+
vessel: "lib",
|
|
558
|
+
category: "shallow",
|
|
559
|
+
note: "README claims lib exports validate() from src/validate.ts; source exports parse() from src/parse.ts",
|
|
560
|
+
anchors: [
|
|
561
|
+
{ type: "file", path: "README.md", line: readmeClaim },
|
|
562
|
+
{ type: "file", path: `${d.dir}/src/parse.ts`, line: exportLine },
|
|
563
|
+
],
|
|
564
|
+
trust: "measured",
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
if (d.id === "api") {
|
|
568
|
+
const catchLine = findLine(targetRoot, `${d.dir}/server.ts`, "errors swallowed");
|
|
569
|
+
dangerEntries.push({
|
|
570
|
+
kind: "danger",
|
|
571
|
+
id: "api-swallow",
|
|
572
|
+
vessel: "api",
|
|
573
|
+
category: "rock",
|
|
574
|
+
note: "request handler catches errors and answers ok without an error signal",
|
|
575
|
+
anchors: [{ type: "file", path: `${d.dir}/server.ts`, line: catchLine }],
|
|
576
|
+
trust: "measured",
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
entries = [...entries, ...dangerEntries];
|
|
581
|
+
commit("dangers");
|
|
582
|
+
|
|
583
|
+
// -- skill §8: the repair merges into the standing Chart, one write. ------
|
|
584
|
+
let notices: Notice[] = [];
|
|
585
|
+
if (repairing) {
|
|
586
|
+
const merged = [...passthrough, ...entries];
|
|
587
|
+
const result = writeChart(targetRoot, merged);
|
|
588
|
+
harness.journal.push({ type: "write", pass: "repair", entries: merged.length });
|
|
589
|
+
notices = result.notices;
|
|
590
|
+
entries = merged;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// -- skill §9: deliver Sailing Directions, in conversation and archived. --
|
|
594
|
+
const finalEntries = readChart(targetRoot);
|
|
595
|
+
const summary = trustReport(targetRoot);
|
|
596
|
+
const brief = composeBrief(provinceName, finalEntries, notices, opts.stamp, !repairing, summary);
|
|
597
|
+
writeFileSync(join(targetRoot, ".portolan", "sailing-directions.md"), brief);
|
|
598
|
+
harness.say(brief);
|
|
599
|
+
harness.journal.push({ type: "brief", archived: ".portolan/sailing-directions.md" });
|
|
600
|
+
|
|
601
|
+
return finish(targetRoot, harness, { notices, brief });
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function composeBrief(
|
|
605
|
+
provinceName: string,
|
|
606
|
+
entries: IndexedEntry[],
|
|
607
|
+
notices: Notice[],
|
|
608
|
+
stamp: string,
|
|
609
|
+
first: boolean,
|
|
610
|
+
summary: ReturnType<typeof trustReport>
|
|
611
|
+
): string {
|
|
612
|
+
const vessels = entries.filter((e) => e.kind === "vessel");
|
|
613
|
+
const fairways = entries.filter((e) => e.kind === "fairway");
|
|
614
|
+
const measuredFairways = fairways.filter((e) => e.trust === "measured");
|
|
615
|
+
const doubtfulFairways = fairways.filter((e) => e.trust === "doubtful");
|
|
616
|
+
const dangers = entries.filter((e) => e.kind === "danger").sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
617
|
+
|
|
618
|
+
const lines: string[] = [];
|
|
619
|
+
lines.push(`# Sailing Directions — ${provinceName}`, "");
|
|
620
|
+
lines.push(
|
|
621
|
+
`Expedition ${stamp} · Cartographer: portolan dry-run · Chart: <target>/.portolan/chart/`,
|
|
622
|
+
""
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
lines.push("## The waters", "");
|
|
626
|
+
lines.push(
|
|
627
|
+
`${provinceName} is a ${vessels.length}-vessel province (${vessels
|
|
628
|
+
.map((v) => (v as { name: string }).name)
|
|
629
|
+
.join(", ")}). ` +
|
|
630
|
+
`${measuredFairways.length} measured fairways connect them; ` +
|
|
631
|
+
`${doubtfulFairways.length} claimed fairway is doubtful.`,
|
|
632
|
+
""
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
lines.push("## Top findings", "");
|
|
636
|
+
const findings: Array<{ text: string; entry: IndexedEntry }> = [];
|
|
637
|
+
for (const danger of dangers) {
|
|
638
|
+
findings.push({
|
|
639
|
+
text:
|
|
640
|
+
danger.category === "shallow"
|
|
641
|
+
? "Docs name an export the source does not have"
|
|
642
|
+
: "Request handler swallows errors",
|
|
643
|
+
entry: danger,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
for (const fairway of doubtfulFairways) {
|
|
647
|
+
findings.push({ text: "A claimed fairway has no deterministic support", entry: fairway });
|
|
648
|
+
}
|
|
649
|
+
if (measuredFairways.length > 0) {
|
|
650
|
+
findings.push({ text: "Declared fairways converge on packages/lib", entry: measuredFairways[0] });
|
|
651
|
+
}
|
|
652
|
+
for (const finding of findings) {
|
|
653
|
+
const anchors = finding.entry.anchors.map(anchorText).join("; ");
|
|
654
|
+
lines.push(
|
|
655
|
+
`- **${finding.text}** — trust: ${finding.entry.trust} — anchor: ${anchors} — chart: ${finding.entry.kind}/${finding.entry.id}`
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
lines.push("");
|
|
659
|
+
|
|
660
|
+
lines.push("## Verification summary", "");
|
|
661
|
+
lines.push(`- trust labels: ${TRUST_LABELS.map((l) => `${l} ${summary.trust[l]}`).join(" · ")}`);
|
|
662
|
+
const pending = summary.staleness.pendingVessels;
|
|
663
|
+
lines.push(
|
|
664
|
+
`- pending correction: ${pending.length === 0 ? "none" : pending.map((v) => `${v.id} (${v.entries})`).join(", ")}`
|
|
665
|
+
);
|
|
666
|
+
const refuted = summary.anchors.refutedList;
|
|
667
|
+
lines.push(
|
|
668
|
+
`- anchor re-sounding: ${summary.anchors.sounded}/${summary.anchors.total} anchors sounded, ` +
|
|
669
|
+
`${summary.anchors.confirmed} confirmed — ` +
|
|
670
|
+
(refuted.length === 0
|
|
671
|
+
? "none refuted"
|
|
672
|
+
: `refuted: ${refuted.map((x) => `\`${x.entryId}\``).join(", ")}`),
|
|
673
|
+
""
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
lines.push("## The Chart", "");
|
|
677
|
+
lines.push(
|
|
678
|
+
`The Chart lives at \`<target>/.portolan/chart/\` — ${vessels.length} sheets (one per vessel) ` +
|
|
679
|
+
"plus the machine index `index.jsonl`. Read the trust labels before trusting anything: " +
|
|
680
|
+
"`measured` taken from source, `charted` from manifests, `reported` a claim from docs, " +
|
|
681
|
+
"`doubtful` unvalidated, `unsurveyed` not determined.",
|
|
682
|
+
""
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
lines.push("## Unsurveyed waters", "");
|
|
686
|
+
const unobserved = vessels.filter((v) => v.behavior === undefined).map((v) => (v as { name: string }).name);
|
|
687
|
+
lines.push("- runtime topology — where each vessel actually runs is not determinable statically");
|
|
688
|
+
lines.push("- deployed versions — what is actually deployed is not determinable statically");
|
|
689
|
+
lines.push(
|
|
690
|
+
`- run-time behavior of ${unobserved.join(" and ")} — no observation; receipts cover the rest`
|
|
691
|
+
);
|
|
692
|
+
lines.push(
|
|
693
|
+
"- the apps/cli configuration key — built at run time — chart: beacon/cli-env-dynamic",
|
|
694
|
+
""
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
lines.push("## Notices to Mariners", "");
|
|
698
|
+
if (first) {
|
|
699
|
+
lines.push("- First Expedition: the Chart is new; every entry is an addition (chart/notices.txt).");
|
|
700
|
+
} else {
|
|
701
|
+
const corrected = notices.filter((n) => n.action === "corrected").sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
702
|
+
for (const notice of corrected) {
|
|
703
|
+
lines.push(`- corrected: ${notice.kind}/${notice.id} — repaired (was pending correction)`);
|
|
704
|
+
}
|
|
705
|
+
if (corrected.length === 0) lines.push("- Nothing changed since the last expedition.");
|
|
706
|
+
}
|
|
707
|
+
lines.push("");
|
|
708
|
+
return lines.join("\n");
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function finish(
|
|
712
|
+
targetRoot: string,
|
|
713
|
+
harness: Harness,
|
|
714
|
+
extra: { notices: Notice[]; brief: string | null }
|
|
715
|
+
): DryRunResult {
|
|
716
|
+
const dir = join(targetRoot, ".portolan");
|
|
717
|
+
mkdirSync(dir, { recursive: true });
|
|
718
|
+
writeFileSync(
|
|
719
|
+
join(dir, "ship-log.jsonl"),
|
|
720
|
+
harness.receipts.map((r) => JSON.stringify(r)).join("\n") + "\n"
|
|
721
|
+
);
|
|
722
|
+
writeFileSync(
|
|
723
|
+
join(dir, "expedition-journal.jsonl"),
|
|
724
|
+
harness.journal.map((e) => JSON.stringify(e)).join("\n") + "\n"
|
|
725
|
+
);
|
|
726
|
+
writeFileSync(
|
|
727
|
+
join(dir, "dry-run-transcript.md"),
|
|
728
|
+
harness.governorMessages.join("\n\n---\n\n") + "\n"
|
|
729
|
+
);
|
|
730
|
+
return {
|
|
731
|
+
governorMessages: harness.governorMessages,
|
|
732
|
+
approvalsAsked: harness.approvalsAsked,
|
|
733
|
+
receipts: harness.receipts,
|
|
734
|
+
journal: harness.journal,
|
|
735
|
+
notices: extra.notices,
|
|
736
|
+
brief: extra.brief,
|
|
737
|
+
};
|
|
738
|
+
}
|