@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,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifications for the expedition-skill change (archived at
|
|
3
|
+
* openspec/changes/archive/2026-08-23-expedition-skill/tasks.md), plus the
|
|
4
|
+
* harbor-master change's skill task (openspec/changes/harbor-master). Each
|
|
5
|
+
* check is labeled with the task it proves.
|
|
6
|
+
*
|
|
7
|
+
* Run from the repo root: bun run skill/verify/checks.ts
|
|
8
|
+
* Regenerate the checked-in example brief: bun run skill/verify/checks.ts --write-example
|
|
9
|
+
*
|
|
10
|
+
* Exit code 0 = every check passed.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync, appendFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { readChart } from "../../core/src/index";
|
|
17
|
+
import { createFixture } from "./fixture";
|
|
18
|
+
import { runExpedition, type DryRunResult } from "./dry-run";
|
|
19
|
+
|
|
20
|
+
const REPO = join(import.meta.dir, "..", "..");
|
|
21
|
+
const SKILL_PATH = join(REPO, "skill", "SKILL.md");
|
|
22
|
+
const TEMPLATE_PATH = join(REPO, "skill", "sailing-directions.template.md");
|
|
23
|
+
const EXAMPLE_PATH = join(REPO, "skill", "examples", "sailing-directions-example.md");
|
|
24
|
+
const STAMP = "2026-08-23";
|
|
25
|
+
|
|
26
|
+
let failures = 0;
|
|
27
|
+
|
|
28
|
+
function check(task: string, name: string, fn: () => string | void): void {
|
|
29
|
+
try {
|
|
30
|
+
const note = fn();
|
|
31
|
+
console.log(`[task ${task}] ${name}: PASS${note ? ` (${note})` : ""}`);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
failures += 1;
|
|
34
|
+
console.log(`[task ${task}] ${name}: FAIL — ${(err as Error).message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function assert(cond: unknown, msg: string): void {
|
|
39
|
+
if (!cond) throw new Error(msg);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Local walkers + snapshots.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function walk(root: string, rel = "", out: string[] = []): string[] {
|
|
47
|
+
for (const de of readdirSync(join(root, rel), { withFileTypes: true }).sort((a, b) =>
|
|
48
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
49
|
+
)) {
|
|
50
|
+
if (de.name === "node_modules" || de.name === ".git") continue;
|
|
51
|
+
const r = rel ? `${rel}/${de.name}` : de.name;
|
|
52
|
+
if (de.isDirectory()) walk(root, r, out);
|
|
53
|
+
else out.push(r);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sourceSnapshot(root: string): Map<string, string> {
|
|
59
|
+
const map = new Map<string, string>();
|
|
60
|
+
for (const rel of walk(root).filter((p) => !p.startsWith(".portolan/"))) {
|
|
61
|
+
map.set(rel, createHash("sha256").update(readFileSync(join(root, rel))).digest("hex"));
|
|
62
|
+
}
|
|
63
|
+
return map;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scenario(name: string): string {
|
|
67
|
+
const dir = mkdtempSync(join(tmpdir(), `portolan-${name}-`));
|
|
68
|
+
createFixture(dir);
|
|
69
|
+
return dir;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function runFull(root: string): DryRunResult {
|
|
73
|
+
return runExpedition(root, { stamp: STAMP });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// 1.1 — the method document, reviewed against docs/MANIFEST.md.
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
const BANNED_TERMS: Array<{ re: RegExp; why: string }> = [
|
|
81
|
+
{ re: /\bcaptain\b/i, why: "locked term is Governor" },
|
|
82
|
+
{ re: /\badmiral\b/i, why: "locked term is Governor" },
|
|
83
|
+
{ re: /\bcomponent\b/i, why: "locked term is vessel" },
|
|
84
|
+
{ re: /\bmodule\b/i, why: "locked term is vessel" },
|
|
85
|
+
{ re: /\bmicroservice\b/i, why: "locked term is vessel" },
|
|
86
|
+
{ re: /\bsubsystem\b/i, why: "locked term is vessel" },
|
|
87
|
+
{ re: /\bservice\b/i, why: "locked term is vessel" },
|
|
88
|
+
{ re: /\bendpoint\b/i, why: "locked terms are port of entry / light" },
|
|
89
|
+
{ re: /\busers?\b/i, why: "locked term is Governor" },
|
|
90
|
+
{ re: /\bcopy\b/i, why: "the Governor is handed no command text" },
|
|
91
|
+
{ re: /\bpaste\b/i, why: "the Governor is handed no command text" },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
function skillMarkdown(): Array<{ rel: string; text: string }> {
|
|
95
|
+
const dir = join(REPO, "skill");
|
|
96
|
+
return walk(dir)
|
|
97
|
+
.filter((rel) => rel.endsWith(".md") && !rel.startsWith("verify/"))
|
|
98
|
+
.map((rel) => ({ rel, text: readFileSync(join(dir, rel), "utf8") }));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
check("1.1", "no synonym terms in skill markdown (docs/MANIFEST.md review)", () => {
|
|
102
|
+
const offenses: string[] = [];
|
|
103
|
+
for (const { rel, text } of skillMarkdown()) {
|
|
104
|
+
for (const { re, why } of BANNED_TERMS) {
|
|
105
|
+
const match = text.match(re);
|
|
106
|
+
if (match) offenses.push(`${rel}: "${match[0]}" (${why})`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
assert(offenses.length === 0, offenses.join("; "));
|
|
110
|
+
return `${skillMarkdown().length} files reviewed`;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
check("1.1", "locked glossary and trust vocabulary present in SKILL.md", () => {
|
|
114
|
+
const text = readFileSync(SKILL_PATH, "utf8");
|
|
115
|
+
for (const term of [
|
|
116
|
+
"Governor", "Cartographer", "Expedition", "Chart", "vessel", "fairway",
|
|
117
|
+
"port of entry", "beacon", "light", "danger", "unsurveyed",
|
|
118
|
+
"pending correction", "Notices to Mariners", "Sailing Directions",
|
|
119
|
+
"measured", "charted", "reported", "doubtful",
|
|
120
|
+
]) {
|
|
121
|
+
assert(text.includes(term), `SKILL.md never uses the locked term "${term}"`);
|
|
122
|
+
}
|
|
123
|
+
for (const tool of [
|
|
124
|
+
"chart.read", "chart.write", "chart.neighborhood", "sweep", "symbols", "manifests",
|
|
125
|
+
"sound.edge", "sound.anchor", "log.append", "log.read",
|
|
126
|
+
"expeditions.propose", "expeditions.decide", "chart.render",
|
|
127
|
+
]) {
|
|
128
|
+
assert(text.includes(tool), `SKILL.md never teaches the tool ${tool}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// harbor-master task 5.1 — the harbor watch: queue surfacing at session
|
|
134
|
+
// start (openspec/changes/harbor-master/specs/harbor: "The queue surfaces
|
|
135
|
+
// in chat at session start").
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
check("harbor 5.1", "SKILL.md teaches the harbor watch at session start", () => {
|
|
139
|
+
const text = readFileSync(SKILL_PATH, "utf8");
|
|
140
|
+
const watchAt = text.indexOf("## 0. The harbor watch");
|
|
141
|
+
const liftOffAt = text.indexOf("## 1. Lift-off");
|
|
142
|
+
assert(watchAt >= 0, "the harbor-watch section is missing");
|
|
143
|
+
assert(liftOffAt > watchAt, "the harbor watch must be taught before lift-off");
|
|
144
|
+
for (const phrase of [
|
|
145
|
+
"expeditions.propose",
|
|
146
|
+
"expeditions.decide",
|
|
147
|
+
"computed, never imagined",
|
|
148
|
+
"one chat message",
|
|
149
|
+
"one-phrase decision",
|
|
150
|
+
"say nothing about proposals",
|
|
151
|
+
"refusal holds while the evidence is unchanged",
|
|
152
|
+
]) {
|
|
153
|
+
assert(text.includes(phrase), `the harbor teaching omits "${phrase}"`);
|
|
154
|
+
}
|
|
155
|
+
assert(/Fourteen tools:/.test(text), "the tool desk does not count fourteen tools");
|
|
156
|
+
assert(text.includes('"tool": "trust.report", "input": {}'), "no call shape for trust.report");
|
|
157
|
+
assert(
|
|
158
|
+
text.includes("call `trust.report` (no input) when composing"),
|
|
159
|
+
"Sailing Directions teaching does not mandate trust.report"
|
|
160
|
+
);
|
|
161
|
+
assert(
|
|
162
|
+
text.includes("A refuted anchor is reported as it stands"),
|
|
163
|
+
"the mandate does not demand refuted anchors verbatim"
|
|
164
|
+
);
|
|
165
|
+
// The desk's call shapes teach the expedition tools' exact argument names.
|
|
166
|
+
assert(text.includes('"tool": "expeditions.propose", "input": {}'), "no call shape for expeditions.propose");
|
|
167
|
+
assert(text.includes('"fingerprint"'), "no call shape citing a fingerprint");
|
|
168
|
+
assert(text.includes('"decision"'), "no call shape citing a decision");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// chart-neighborhood task 5.2 — the invocation contract in the skill
|
|
173
|
+
// (openspec/changes/chart-neighborhood/specs/invocation: a mandated query
|
|
174
|
+
// tool ships with a session-start mandate and a tool-desk row).
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
check("neighborhood 5.2", "SKILL.md mandates chart.neighborhood at session start, before any edit", () => {
|
|
178
|
+
const text = readFileSync(SKILL_PATH, "utf8");
|
|
179
|
+
const startAt = text.indexOf("## 0. The harbor watch");
|
|
180
|
+
const liftOffAt = text.indexOf("## 1. Lift-off");
|
|
181
|
+
assert(startAt >= 0 && liftOffAt > startAt, "the harbor-watch section moved");
|
|
182
|
+
const sessionStart = text.slice(startAt, liftOffAt);
|
|
183
|
+
assert(
|
|
184
|
+
/a task touching more than one file or vessel[^.]*`chart\.neighborhood`[^.]*before the first edit/i.test(
|
|
185
|
+
sessionStart
|
|
186
|
+
),
|
|
187
|
+
"the session-start region lacks the mandate (trigger: a task touching more than one file or vessel; ordering: before the first edit)"
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
check("neighborhood 5.2", "the tool desk lists chart.neighborhood with a purpose row and a call shape", () => {
|
|
192
|
+
const text = readFileSync(SKILL_PATH, "utf8");
|
|
193
|
+
const deskAt = text.indexOf("## 10. Tool desk");
|
|
194
|
+
assert(deskAt >= 0, "the tool desk section is missing");
|
|
195
|
+
const desk = text.slice(deskAt, text.indexOf("Call shapes", deskAt));
|
|
196
|
+
assert(/^\| `chart\.neighborhood` \| .+\|$/m.test(desk), "no tool-desk row for chart.neighborhood");
|
|
197
|
+
assert(
|
|
198
|
+
text.includes('"tool": "chart.neighborhood", "input": { "vessel"'),
|
|
199
|
+
"no call shape for chart.neighborhood"
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
check("1.1", "no command text addressed to the Governor", () => {
|
|
204
|
+
for (const { rel, text } of skillMarkdown()) {
|
|
205
|
+
assert(!/```(sh|bash|shell|console|zsh)/.test(text), `${rel}: shell fence`);
|
|
206
|
+
for (const fence of text.matchAll(/```[a-z]*\n([^`]*)```/g)) {
|
|
207
|
+
assert(!/^\s*\$ /m.test(fence[1]), `${rel}: shell prompt inside a fence`);
|
|
208
|
+
}
|
|
209
|
+
assert(
|
|
210
|
+
!/Governor[^\n]{0,50}\b(run|execute|type)\b/i.test(text) &&
|
|
211
|
+
!/\b(run|execute|type)\b[^\n]{0,50}Governor/i.test(text),
|
|
212
|
+
`${rel}: a sentence hands a command to the Governor`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// 1.2 / 5.2 — template and example shape.
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
const FINDING_RE =
|
|
222
|
+
/^- \*\*[^*]+\*\* — trust: (measured|charted|reported|doubtful|unsurveyed) — anchor: .+ — chart: \w+\/[\w-]+$/;
|
|
223
|
+
|
|
224
|
+
function sectionLines(text: string, heading: string): string[] {
|
|
225
|
+
const at = text.indexOf(heading);
|
|
226
|
+
assert(at >= 0, `missing section "${heading}"`);
|
|
227
|
+
const rest = text.slice(at + heading.length);
|
|
228
|
+
const next = rest.indexOf("\n## ");
|
|
229
|
+
return rest.slice(0, next < 0 ? undefined : next).split("\n").filter((l) => l.startsWith("-"));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function briefChecks(label: string, brief: string): number {
|
|
233
|
+
for (const heading of ["# Sailing Directions", "## The waters", "## Top findings", "## Verification summary", "## The Chart", "## Unsurveyed waters", "## Notices to Mariners"]) {
|
|
234
|
+
assert(brief.includes(heading), `${label}: missing "${heading}"`);
|
|
235
|
+
}
|
|
236
|
+
const findings = sectionLines(brief, "## Top findings");
|
|
237
|
+
assert(findings.length >= 3, `${label}: fewer than 3 findings`);
|
|
238
|
+
for (const line of findings) {
|
|
239
|
+
assert(FINDING_RE.test(line), `${label}: finding lacks anchor/trust/chart location: ${line}`);
|
|
240
|
+
}
|
|
241
|
+
const unsurveyed = sectionLines(brief, "## Unsurveyed waters");
|
|
242
|
+
assert(unsurveyed.some((l) => l.includes("runtime topology")), `${label}: unsurveyed list omits runtime topology`);
|
|
243
|
+
assert(unsurveyed.some((l) => l.includes("deployed versions")), `${label}: unsurveyed list omits deployed versions`);
|
|
244
|
+
return findings.length;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
check("1.2", "Sailing Directions template carries every required section + rules", () => {
|
|
248
|
+
const text = readFileSync(TEMPLATE_PATH, "utf8").replace(/\\</g, "<").replace(/\\>/g, ">");
|
|
249
|
+
for (const heading of [
|
|
250
|
+
"# Sailing Directions", "## The waters", "## Top findings", "## The Chart",
|
|
251
|
+
"## Unsurveyed waters", "## Notices to Mariners",
|
|
252
|
+
]) {
|
|
253
|
+
assert(text.includes(heading), `template missing "${heading}"`);
|
|
254
|
+
}
|
|
255
|
+
const form = sectionLines(text, "## Top findings")[0];
|
|
256
|
+
assert(
|
|
257
|
+
!!form && form.includes("trust:") && form.includes("anchor:") && form.includes("chart:"),
|
|
258
|
+
"template finding form lacks anchor/trust/chart location"
|
|
259
|
+
);
|
|
260
|
+
assert(text.includes("never presented as an established fact"), "template omits the unanchored-claim rule");
|
|
261
|
+
const unsurveyed = sectionLines(text, "## Unsurveyed waters");
|
|
262
|
+
assert(
|
|
263
|
+
unsurveyed.some((l) => l.includes("runtime topology")) && unsurveyed.some((l) => l.includes("deployed versions")),
|
|
264
|
+
"template unsurveyed placeholders missing"
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Scenario S1 — full dry run on a fixture target.
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
const S1 = scenario("full");
|
|
273
|
+
const S1_BEFORE = sourceSnapshot(S1);
|
|
274
|
+
const R1 = runFull(S1);
|
|
275
|
+
const S1_CHART = readChart(S1);
|
|
276
|
+
|
|
277
|
+
check("2.1", "dry run: install completes with zero Governor-copied commands", () => {
|
|
278
|
+
assert(R1.receipts.some((r) => r.scope === "harness" && r.outcome === "installed"), "no install receipt");
|
|
279
|
+
for (const message of R1.governorMessages) {
|
|
280
|
+
assert(!/```/.test(message), "fenced text shown to the Governor");
|
|
281
|
+
assert(
|
|
282
|
+
!/(^|\n)\s*(\$ |npm |npx |yarn |pnpm |pip |go |cargo |git |curl |wget )/i.test(message),
|
|
283
|
+
`command text shown to the Governor: ${message.slice(0, 60)}...`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const approvalAt = R1.journal.findIndex((e) => e.type === "approval");
|
|
287
|
+
const installAt = R1.journal.findIndex((e) => e.type === "receipt");
|
|
288
|
+
assert(approvalAt >= 0 && installAt > approvalAt, "install preceded the approval");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
check("2.2", "dry run: exactly one approval; builds/tests receipted, unasked", () => {
|
|
292
|
+
assert(R1.approvalsAsked === 1, `approvals asked: ${R1.approvalsAsked}`);
|
|
293
|
+
const approval = R1.governorMessages[1];
|
|
294
|
+
assert(/network/i.test(approval) && /install/i.test(approval), "approval does not cover network + installation");
|
|
295
|
+
const build = R1.receipts.find((r) => r.command.startsWith("bun"));
|
|
296
|
+
assert(build && build.outcome === "pass" && build.scope === "packages/lib", "no receipt for the executed target check");
|
|
297
|
+
assert(R1.approvalsAsked === 1, "a second approval was asked after builds ran");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
check("2.3", "dry run: source byte-identical; writes only under .portolan/", () => {
|
|
301
|
+
const after = sourceSnapshot(S1);
|
|
302
|
+
const before = S1_BEFORE;
|
|
303
|
+
assert(before.size === after.size, `source file count changed: ${before.size} -> ${after.size}`);
|
|
304
|
+
for (const [rel, hash] of before) {
|
|
305
|
+
assert(after.get(rel) === hash, `source file changed: ${rel}`);
|
|
306
|
+
}
|
|
307
|
+
const added = walk(S1).filter((rel) => !before.has(rel));
|
|
308
|
+
assert(added.length > 0, "no chart was written");
|
|
309
|
+
for (const rel of added) {
|
|
310
|
+
assert(rel.startsWith(".portolan/"), `file written outside the perimeter: ${rel}`);
|
|
311
|
+
}
|
|
312
|
+
return `${added.length} files, all under .portolan/`;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
check("3.1", "dry run: passes run vessels → fairways → ports/beacons → lights → dangers", () => {
|
|
316
|
+
const passes = R1.journal.filter((e) => e.type === "pass").map((e) => e.name);
|
|
317
|
+
assert(
|
|
318
|
+
JSON.stringify(passes) === JSON.stringify(["vessels", "fairways", "portsAndBeacons", "lights", "dangers"]),
|
|
319
|
+
`pass order: ${passes.join(" -> ")}`
|
|
320
|
+
);
|
|
321
|
+
const writes = R1.journal.filter((e) => e.type === "write").map((e) => e.pass);
|
|
322
|
+
assert(
|
|
323
|
+
JSON.stringify(writes) === JSON.stringify(["vessels", "fairways", "portsAndBeacons", "lights", "dangers"]),
|
|
324
|
+
`chart writes per pass: ${writes.join(", ")}`
|
|
325
|
+
);
|
|
326
|
+
const count = (kind: string) => S1_CHART.filter((e) => e.kind === kind).length;
|
|
327
|
+
assert(count("vessel") === 3 && count("fairway") === 3 && count("portOfEntry") === 2, "vessels/fairways/ports missing");
|
|
328
|
+
assert(count("beacon") === 3 && count("light") === 3 && count("danger") === 2, "beacons/lights/dangers missing");
|
|
329
|
+
for (const entry of S1_CHART) {
|
|
330
|
+
assert(entry.anchors.length >= 1, `entry ${entry.kind}/${entry.id} has no anchor`);
|
|
331
|
+
assert(["measured", "charted", "reported", "doubtful", "unsurveyed"].includes(entry.trust), `bad trust on ${entry.id}`);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
check("3.3", "dry run: runtime topology and deployed versions stay unsurveyed", () => {
|
|
336
|
+
for (const entry of S1_CHART) {
|
|
337
|
+
const text = JSON.stringify(entry).toLowerCase();
|
|
338
|
+
if (/(runtime topology|deployed version)/.test(text)) {
|
|
339
|
+
assert(entry.trust === "unsurveyed", `${entry.kind}/${entry.id} claims a runtime fact under ${entry.trust}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const beacon = S1_CHART.find((e) => e.kind === "beacon" && e.id === "cli-env-dynamic");
|
|
343
|
+
assert(beacon?.trust === "unsurveyed", "the dynamic beacon is not unsurveyed");
|
|
344
|
+
const lib = S1_CHART.find((e) => e.kind === "vessel" && e.id === "lib");
|
|
345
|
+
assert(lib?.trust === "measured" && /receipt/.test(JSON.stringify(lib.anchors)), "lib behavior is not receipt-anchored");
|
|
346
|
+
const api = S1_CHART.find((e) => e.kind === "vessel" && e.id === "api");
|
|
347
|
+
assert(api?.behavior === undefined, "api behavior was guessed");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
check("4.1", "dry run: planted drift corrected or downgraded in the same run", () => {
|
|
351
|
+
const soundingAt = R1.journal.findIndex(
|
|
352
|
+
(e) => e.type === "sounding" && e.subject === "cli->api" && e.verdict === "unconfirmed"
|
|
353
|
+
);
|
|
354
|
+
const fairwayWriteAt = R1.journal.findIndex((e) => e.type === "write" && e.pass === "fairways");
|
|
355
|
+
assert(soundingAt >= 0 && soundingAt < fairwayWriteAt, "the claimed fairway was not sounded before its write");
|
|
356
|
+
const claimed = S1_CHART.find((e) => e.kind === "fairway" && e.id === "cli-api");
|
|
357
|
+
assert(claimed?.trust === "doubtful", `claimed fairway stands as ${claimed?.trust}`);
|
|
358
|
+
assert(R1.journal.some((e) => e.type === "sounding" && e.verdict === "refuted"), "the fabricated export anchor was never sounded");
|
|
359
|
+
assert(R1.journal.some((e) => e.type === "refutation"), "no refutation was recorded");
|
|
360
|
+
assert(!S1_CHART.some((e) => e.kind === "light" && /validate/.test(e.id + e.name)), "a light for the refuted export exists");
|
|
361
|
+
const light = S1_CHART.find((e) => e.kind === "light" && e.id === "lib-parse");
|
|
362
|
+
assert(light?.trust === "measured", "the corrected light is missing");
|
|
363
|
+
const drift = S1_CHART.find((e) => e.kind === "danger" && e.id === "docs-drift");
|
|
364
|
+
assert(!!drift, "the doc drift is not charted as a danger");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
check("5.1", "dry run: Sailing Directions in conversation and archived, findings anchored", () => {
|
|
368
|
+
assert(R1.brief !== null, "no brief was delivered");
|
|
369
|
+
const archived = readFileSync(join(S1, ".portolan", "sailing-directions.md"), "utf8");
|
|
370
|
+
assert(archived === R1.brief, "archived brief differs from the conversation brief");
|
|
371
|
+
assert(R1.governorMessages[R1.governorMessages.length - 1] === R1.brief, "the brief is not the last word to the Governor");
|
|
372
|
+
const n = briefChecks("fixture brief", R1.brief);
|
|
373
|
+
return `${n} findings, all anchored and labeled`;
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
check("5.2", "fixture brief review: zero claims lacking anchor + trust label", () => {
|
|
377
|
+
const findings = sectionLines(R1.brief ?? "", "## Top findings");
|
|
378
|
+
const bad = findings.filter((l) => !FINDING_RE.test(l));
|
|
379
|
+
assert(bad.length === 0, bad.join("; "));
|
|
380
|
+
return `${findings.length} findings checked`;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
check("1.2", "checked-in example is the fixture brief, byte-for-byte", () => {
|
|
384
|
+
assert(existsSync(EXAMPLE_PATH), "skill/examples/sailing-directions-example.md is missing");
|
|
385
|
+
const example = readFileSync(EXAMPLE_PATH, "utf8");
|
|
386
|
+
briefChecks("example", example);
|
|
387
|
+
assert(example === R1.brief, "the example does not match a fresh dry run of the fixture");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Scenario S2 — expedition aborted after the fairways pass.
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
const S2 = scenario("abort");
|
|
395
|
+
const R2 = runExpedition(S2, { stamp: STAMP, abortAfter: "fairways" });
|
|
396
|
+
const S2_CHART = readChart(S2);
|
|
397
|
+
|
|
398
|
+
check("3.2", "dry run aborted after fairways leaves a valid partial Chart", () => {
|
|
399
|
+
const count = (kind: string) => S2_CHART.filter((e) => e.kind === kind).length;
|
|
400
|
+
assert(count("vessel") === 3 && count("fairway") === 3, "completed passes are missing");
|
|
401
|
+
assert(count("portOfEntry") + count("beacon") + count("light") + count("danger") === 0, "unrun passes left claims");
|
|
402
|
+
for (const entry of S2_CHART) {
|
|
403
|
+
assert(entry.anchors.length >= 1 && entry.trust, `entry ${entry.id} lacks anchor or trust`);
|
|
404
|
+
if (entry.kind === "vessel") {
|
|
405
|
+
assert(/unsurveyed/.test(entry.note ?? ""), `vessel ${entry.id} does not mark unrun passes unsurveyed`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
for (const sheet of readdirSync(join(S2, ".portolan", "chart")).filter((f) => f.endsWith(".md"))) {
|
|
409
|
+
const text = readFileSync(join(S2, ".portolan", "chart", sheet), "utf8");
|
|
410
|
+
assert(/unsurveyed/i.test(text), `sheet ${sheet} hides its unsurveyed waters`);
|
|
411
|
+
}
|
|
412
|
+
const last = R2.governorMessages[R2.governorMessages.length - 1];
|
|
413
|
+
assert(/stopped after the fairways pass/.test(last) && /unsurveyed/.test(last), "the Governor was not told what remains unsurveyed");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Scenario S3 — a second expedition repairs pending correction.
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
const S3 = scenario("repair");
|
|
421
|
+
runFull(S3);
|
|
422
|
+
const S3_BEFORE = new Map(
|
|
423
|
+
readChart(S3).map((e) => {
|
|
424
|
+
const { stale: _s, signature: _g, ...rest } = e;
|
|
425
|
+
return [`${e.kind}/${e.id}`, JSON.stringify(rest)] as const;
|
|
426
|
+
})
|
|
427
|
+
);
|
|
428
|
+
appendFileSync(join(S3, "apps", "api", "server.ts"), "\n// a later edit\n");
|
|
429
|
+
const R3 = runFull(S3);
|
|
430
|
+
const S3_CHART = readChart(S3);
|
|
431
|
+
|
|
432
|
+
check("4.2", "second dry run repairs only stale entries and emits notices", () => {
|
|
433
|
+
assert(R3.approvalsAsked === 1, "the second session asked more than its one approval");
|
|
434
|
+
const staleness = R3.journal.find((e) => e.type === "staleness");
|
|
435
|
+
assert(JSON.stringify(staleness?.changedVessels) === JSON.stringify(["api"]), `changed vessels: ${JSON.stringify(staleness?.changedVessels)}`);
|
|
436
|
+
assert(S3_CHART.every((e) => !e.stale), "entries remain marked pending correction");
|
|
437
|
+
const corrected = new Set(
|
|
438
|
+
R3.notices.filter((n) => n.action === "corrected").map((n) => `${n.kind}/${n.id}`)
|
|
439
|
+
);
|
|
440
|
+
for (const expected of [
|
|
441
|
+
"vessel/api", "fairway/api-lib", "fairway/cli-api", "portOfEntry/api-http",
|
|
442
|
+
"beacon/api-port-env", "beacon/api-port-8080", "light/api-health", "danger/api-swallow",
|
|
443
|
+
]) {
|
|
444
|
+
assert(corrected.has(expected), `no corrected notice for ${expected}`);
|
|
445
|
+
}
|
|
446
|
+
for (const untouched of [
|
|
447
|
+
"vessel/cli", "vessel/lib", "fairway/cli-lib", "portOfEntry/cli-bin",
|
|
448
|
+
"beacon/cli-env-dynamic", "light/lib-parse", "light/cli-json",
|
|
449
|
+
]) {
|
|
450
|
+
assert(!corrected.has(untouched), `untouched entry ${untouched} was redrawn`);
|
|
451
|
+
}
|
|
452
|
+
assert(S3_CHART.length === S3_BEFORE.size, "the chart was redrawn rather than repaired");
|
|
453
|
+
for (const entry of S3_CHART) {
|
|
454
|
+
const key = `${entry.kind}/${entry.id}`;
|
|
455
|
+
if (key.startsWith("vessel/cli") || key.startsWith("vessel/lib") || key.includes("cli-lib") ||
|
|
456
|
+
key.includes("cli-bin") || key.includes("cli-env-dynamic") || key.includes("lib-parse") || key.includes("cli-json")) {
|
|
457
|
+
const { stale: _s, signature: _g, ...rest } = entry;
|
|
458
|
+
assert(S3_BEFORE.get(key) === JSON.stringify(rest), `pass-through entry ${key} changed content`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
assert(/## Notices to Mariners/.test(R3.brief ?? "") && /corrected:/.test(R3.brief ?? ""), "the repair brief omits the notices");
|
|
462
|
+
return `${corrected.size} corrected notices`;
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// Optional: regenerate the checked-in example from scenario S1.
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
if (process.argv.includes("--write-example")) {
|
|
470
|
+
mkdirSync(join(REPO, "skill", "examples"), { recursive: true });
|
|
471
|
+
writeFileSync(EXAMPLE_PATH, R1.brief ?? "");
|
|
472
|
+
console.log(`\nwrote ${EXAMPLE_PATH} from the fixture dry run`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
console.log(failures === 0 ? "\nAll expedition-skill checks passed." : `\n${failures} check(s) failed.`);
|
|
476
|
+
process.exit(failures === 0 ? 0 : 1);
|