@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,55 @@
1
+ /**
2
+ * Fixture for chart-io.rollback.test.ts — runs in its OWN process because
3
+ * mock.module("node:fs") must not leak into the main test runner's module
4
+ * registry (it deadlocked the full-suite run). Applies the sabotaged
5
+ * renameSync, performs the failing write, and prints the resulting
6
+ * directory state as JSON on stdout.
7
+ *
8
+ * bun src/chart-io.rollback-fixture.ts <dir> <sabotageAt>
9
+ */
10
+ import { readdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ const [dir, sabotageArg] = process.argv.slice(2);
14
+ if (!dir || !sabotageArg) throw new Error("usage: rollback-fixture.ts <dir> <sabotageAt>");
15
+ const sabotageAt = Number(sabotageArg);
16
+
17
+ const real = await import("node:fs");
18
+ let renameCalls = 0;
19
+ const { mock } = await import("bun:test");
20
+ mock.module("node:fs", () => ({
21
+ ...real,
22
+ renameSync: (from: string, to: string) => {
23
+ renameCalls += 1;
24
+ if (renameCalls === sabotageAt) throw new Error("sabotaged rename (simulated ENOSPC)");
25
+ return real.renameSync(from, to);
26
+ },
27
+ }));
28
+
29
+ const { writeFilesAtomically } = await import("./chart-io");
30
+
31
+ // Two existing files (each replace costs two renames: final→bak,
32
+ // tmp→final) plus one new file.
33
+ writeFileSync(join(dir, "a.md"), "old a\n");
34
+ writeFileSync(join(dir, "b.md"), "old b\n");
35
+ const files = new Map<string, string>([
36
+ ["a.md", "new a\n"],
37
+ ["b.md", "new b\n"],
38
+ ["c.md", "new c\n"],
39
+ ]);
40
+
41
+ let threw: string | null = null;
42
+ try {
43
+ writeFilesAtomically(dir, files);
44
+ } catch (err) {
45
+ threw = (err as Error).message;
46
+ }
47
+
48
+ const ls = readdirSync(dir).sort();
49
+ const contents: Record<string, string> = {};
50
+ for (const name of ls) {
51
+ contents[name] = readFileSync(join(dir, name), "utf8");
52
+ }
53
+ process.stdout.write(
54
+ `${JSON.stringify({ threw, ls, contents }, null, 1)}\n`
55
+ );
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Low-level chart file IO shared by the store and the staleness refresher:
3
+ * paths, index (de)serialization, and the atomic stage-temp-then-rename
4
+ * writer. All writes stay under `<target>/.portolan/chart/`.
5
+ */
6
+ import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { randomBytes } from "node:crypto";
9
+ import type { IndexedEntry } from "./types";
10
+
11
+ export const INDEX_FILE = "index.jsonl";
12
+ export const NOTICES_FILE = "notices.txt";
13
+
14
+ /** Where the Chart lives for a given target root. */
15
+ export function chartDir(targetRoot: string): string {
16
+ return join(targetRoot, ".portolan", "chart");
17
+ }
18
+
19
+ export function entryKey(entry: { kind: string; id: string }): string {
20
+ return `${entry.kind}/${entry.id}`;
21
+ }
22
+
23
+ export function sortEntries<T extends { kind: string; id: string }>(entries: T[]): T[] {
24
+ return [...entries].sort((a, b) => {
25
+ const ka = entryKey(a);
26
+ const kb = entryKey(b);
27
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
28
+ });
29
+ }
30
+
31
+ /** Read the machine index. Throws with a remediation hint when absent. */
32
+ export function readChart(targetRoot: string): IndexedEntry[] {
33
+ const indexPath = join(chartDir(targetRoot), INDEX_FILE);
34
+ if (!existsSync(indexPath)) {
35
+ throw new Error(`no chart index at ${indexPath} — write a chart first`);
36
+ }
37
+ const lines = readFileSync(indexPath, "utf8")
38
+ .split("\n")
39
+ .filter((line) => line.trim().length > 0);
40
+ return lines.map((line, i) => {
41
+ let entry: IndexedEntry;
42
+ try {
43
+ entry = JSON.parse(line) as IndexedEntry;
44
+ } catch (err) {
45
+ // Same remediation shape as the check below: the corruption message
46
+ // names the file and the line, so index repair knows where to look.
47
+ throw new Error(
48
+ `corrupt chart index ${indexPath} line ${i + 1}: not valid JSON (${(err as Error).message})`,
49
+ );
50
+ }
51
+ if (typeof entry?.kind !== "string" || typeof entry?.id !== "string") {
52
+ throw new Error(`corrupt chart index ${indexPath} line ${i + 1}`);
53
+ }
54
+ return entry;
55
+ });
56
+ }
57
+
58
+ export function readChartOrNull(targetRoot: string): IndexedEntry[] | null {
59
+ try {
60
+ return readChart(targetRoot);
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ export function indexJsonl(entries: IndexedEntry[]): string {
67
+ return `${sortEntries(entries).map((e) => JSON.stringify(e)).join("\n")}\n`;
68
+ }
69
+
70
+ /**
71
+ * Stage every file to a temp name first, then rename them all into place.
72
+ * A failure before the renames (validation, full disk, ...) leaves the
73
+ * previous chart untouched; staged temps are removed on failure. A failure
74
+ * during the renames rolls back: every replaced file's previous content is
75
+ * held aside as a backup until all renames are through, so the previous
76
+ * chart stays byte-identical — not just the machine index.
77
+ */
78
+ export function writeFilesAtomically(dir: string, files: Map<string, string>): void {
79
+ const staged: Array<{ tmp: string; final: string }> = [];
80
+ try {
81
+ for (const [name, text] of files) {
82
+ const final = join(dir, name);
83
+ const tmp = `${final}.tmp-${randomBytes(6).toString("hex")}`;
84
+ writeFileSync(tmp, text);
85
+ staged.push({ tmp, final });
86
+ }
87
+ } catch (err) {
88
+ for (const { tmp } of staged) rmSync(tmp, { force: true });
89
+ throw err;
90
+ }
91
+ // Preflight: every occupied target must be a regular file a backup can
92
+ // restore. Anything else (a directory squatting on a sheet name) fails
93
+ // before a single original is touched.
94
+ for (const { final } of staged) {
95
+ if (existsSync(final) && !statSync(final).isFile()) {
96
+ for (const { tmp } of staged) rmSync(tmp, { force: true });
97
+ throw new Error(`writeFilesAtomically: ${final} exists and is not a regular file`);
98
+ }
99
+ }
100
+ const backups: Array<{ bak: string; final: string }> = [];
101
+ const placed = new Set<string>();
102
+ try {
103
+ for (const { tmp, final } of staged) {
104
+ if (existsSync(final)) {
105
+ const bak = `${final}.bak-${randomBytes(6).toString("hex")}`;
106
+ renameSync(final, bak);
107
+ backups.push({ bak, final });
108
+ }
109
+ renameSync(tmp, final);
110
+ placed.add(final);
111
+ }
112
+ } catch (err) {
113
+ for (const final of placed) rmSync(final, { force: true });
114
+ for (const { bak, final } of backups) renameSync(bak, final);
115
+ for (const { tmp, final } of staged) {
116
+ if (!placed.has(final)) rmSync(tmp, { force: true });
117
+ }
118
+ throw err;
119
+ }
120
+ for (const { bak } of backups) rmSync(bak, { force: true });
121
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The chart store: read/write of `<target>/.portolan/chart/` — one markdown
3
+ * sheet per vessel plus the machine index `index.jsonl` (authoritative for
4
+ * machines; sheets are rendered outputs). Writes are atomic by
5
+ * stage-to-temp + rename (design.md, decision 4): a write either persists
6
+ * completely or leaves the previous chart byte-identical. Each vessel entry
7
+ * is stamped with a source tree signature for staleness detection.
8
+ */
9
+ import { mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import type { ChartEntry, IndexedEntry, Notice } from "./types";
12
+ import { validateEntries } from "./validate";
13
+ import { renderSheets } from "./sheets";
14
+ import { treeSignature } from "./staleness";
15
+ import { diffNotices, renderNotices } from "./notices";
16
+ import {
17
+ INDEX_FILE,
18
+ NOTICES_FILE,
19
+ chartDir,
20
+ entryKey,
21
+ indexJsonl,
22
+ readChartOrNull,
23
+ sortEntries,
24
+ writeFilesAtomically,
25
+ } from "./chart-io";
26
+
27
+ export { INDEX_FILE, NOTICES_FILE, chartDir, readChart } from "./chart-io";
28
+
29
+ /** Result of a chart write: where the chart lives, the index, and the notices it produced. */
30
+ export interface WriteResult {
31
+ dir: string;
32
+ index: IndexedEntry[];
33
+ notices: Notice[];
34
+ noticesText: string;
35
+ /** Set when post-write cleanup failed: the write persisted, its cleanup did not. */
36
+ cleanupError?: string;
37
+ }
38
+
39
+ export interface WriteOptions {
40
+ /**
41
+ * Accept a full-replace that drops more than a quarter of the existing
42
+ * entries (chart-write-shrink-guard). Default false: silent mass shrink
43
+ * is how a partial rewrite clobbers a whole chart (live incident,
44
+ * receipt r43).
45
+ */
46
+ allowShrink?: boolean;
47
+ }
48
+
49
+ /**
50
+ * Write the whole chart (full-replace semantics: entries absent from the
51
+ * batch are retired). Validates the batch first — a rejection or a late
52
+ * duplicate-id failure persists nothing. Vessel sheets are re-rendered from
53
+ * the entries; sheets of retired vessels are removed.
54
+ */
55
+ export function writeChart(
56
+ targetRoot: string,
57
+ rawEntries: ChartEntry[],
58
+ options: WriteOptions = {},
59
+ ): WriteResult {
60
+ // Round-trip rule: entries read back from the chart carry `stale` and
61
+ // `signature` metadata the store owns and re-stamps on every write, so a
62
+ // read → modify → write repair cycle must not be rejected for them.
63
+ const entries = rawEntries.map((entry) => {
64
+ const { stale: _stale, signature: _signature, ...clean } = entry as ChartEntry & {
65
+ stale?: unknown;
66
+ signature?: unknown;
67
+ };
68
+ return clean as ChartEntry;
69
+ });
70
+ if (entries.length === 0) {
71
+ throw new Error("writeChart: refusing to write an empty chart");
72
+ }
73
+ // Shrink guard (chart-write-shrink-guard): a full-replace that drops more
74
+ // than a quarter of the existing entries is refused unless explicitly
75
+ // allowed — a partial rewrite must not clobber a whole chart silently.
76
+ // The threshold is compared as a float: flooring it would admit a
77
+ // 74.9% shrink as "not below 75%".
78
+ const previous = readChartOrNull(targetRoot);
79
+ if (previous && !options.allowShrink) {
80
+ if (entries.length < previous.length * 0.75) {
81
+ throw new Error(
82
+ `writeChart refused: ${entries.length} entries would shrink the chart from ${previous.length} ` +
83
+ `(allowShrink to override a deliberate retire-heavy correction)`,
84
+ );
85
+ }
86
+ }
87
+ validateEntries(entries);
88
+ // Late batch check, after per-entry validation: duplicate ids would
89
+ // corrupt the index. Reaching this throw proves nothing was persisted.
90
+ const seen = new Set<string>();
91
+ for (const entry of entries) {
92
+ const key = entryKey(entry);
93
+ if (seen.has(key)) {
94
+ throw new Error(`writeChart: duplicate chart entry id ${key}`);
95
+ }
96
+ seen.add(key);
97
+ }
98
+
99
+ const dir = chartDir(targetRoot);
100
+ const indexed = sortEntries(
101
+ entries.map((entry) => {
102
+ const base = { ...entry, stale: false } as IndexedEntry;
103
+ if (entry.kind === "vessel") {
104
+ base.signature = treeSignature(targetRoot, entry.paths);
105
+ }
106
+ return base;
107
+ })
108
+ );
109
+ const notices = diffNotices(previous ?? [], indexed);
110
+ const noticesText = renderNotices(notices);
111
+ const sheets = renderSheets(indexed);
112
+
113
+ mkdirSync(dir, { recursive: true });
114
+ const files = new Map<string, string>(sheets);
115
+ files.set(INDEX_FILE, indexJsonl(indexed));
116
+ if (notices.length > 0) files.set(NOTICES_FILE, noticesText);
117
+ writeFilesAtomically(dir, files);
118
+
119
+ // Cleanup runs after the atomic rename: the write has persisted, so a
120
+ // failing cleanup is reported in the result, never thrown — a throw here
121
+ // would surface a tool error for a write that in fact landed, and leave
122
+ // the caller assuming the previous chart.
123
+ let cleanupError: string | undefined;
124
+ try {
125
+ // Remove sheets the new chart no longer owns (retired vessels).
126
+ for (const name of readdirSync(dir)) {
127
+ if (name.endsWith(".md") && !sheets.has(name)) {
128
+ unlinkSync(join(dir, name));
129
+ }
130
+ }
131
+ // An empty report is removed: the notices file reflects the latest write.
132
+ if (notices.length === 0) rmSync(join(dir, NOTICES_FILE), { force: true });
133
+ } catch (err) {
134
+ cleanupError = err instanceof Error ? err.message : String(err);
135
+ }
136
+ return { dir, index: indexed, notices, noticesText, ...(cleanupError !== undefined ? { cleanupError } : {}) };
137
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The Chart Room CLI — human/scheduler entries (the MCP tool `chart.render`
4
+ * is the single-province one; both call the same core functions).
5
+ *
6
+ * bun core/src/chartroom/cli.ts render --target <province root>
7
+ * One-file Chart Room export for one province.
8
+ *
9
+ * bun core/src/chartroom/cli.ts review --target <t1> [--target <t2> ...]
10
+ * Fleet review: a multi-province index page, written into the FIRST
11
+ * named target's .portolan/fleet-review.html ("the reviewing
12
+ * harbor"). Targets are read-only; nothing is discovered by scanning.
13
+ *
14
+ * Both commands are deterministic over unchanged inputs. Any failure (no
15
+ * chart, bad arguments) exits 1 with the error on stderr.
16
+ */
17
+ import { parseArgs } from "node:util";
18
+ import { resolve } from "node:path";
19
+ import { renderChartRoom } from "./render";
20
+ import { buildFleetReview } from "./review";
21
+
22
+ const usage = `usage:
23
+ bun core/src/chartroom/cli.ts render --target <province root>
24
+ bun core/src/chartroom/cli.ts review --target <t1> [--target <t2> ...]`;
25
+
26
+ const { positionals, values } = parseArgs({
27
+ allowPositionals: true,
28
+ options: {
29
+ target: { type: "string", multiple: true, default: [] },
30
+ },
31
+ });
32
+
33
+ const command = positionals[0];
34
+ if (
35
+ !command ||
36
+ (command !== "render" && command !== "review") ||
37
+ positionals.length > 1 ||
38
+ values.target.length === 0
39
+ ) {
40
+ console.error(usage);
41
+ process.exit(1);
42
+ }
43
+ // render takes exactly one province; silently ignoring the rest would
44
+ // pretend the extra targets were served.
45
+ if (command === "render" && values.target.length > 1) {
46
+ console.error("render takes one --target; to assemble several provinces use review");
47
+ process.exit(1);
48
+ }
49
+
50
+ try {
51
+ if (command === "render") {
52
+ const result = renderChartRoom(resolve(values.target[0]!));
53
+ console.log(`chart-room.html written: ${result.path}`);
54
+ console.log(`entries: ${result.entries} — ${JSON.stringify(result.counts)}`);
55
+ } else {
56
+ const result = buildFleetReview(values.target.map((t) => resolve(t)));
57
+ console.log(`fleet-review.html written: ${result.path}`);
58
+ console.log(`provinces: ${result.provinces}`);
59
+ }
60
+ } catch (err) {
61
+ console.error(String(err instanceof Error ? err.message : err));
62
+ process.exit(1);
63
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The Chart Room renderer — the byproduct export of the Chart
3
+ * (openspec/changes/chart-room).
4
+ *
5
+ * Reads the machine index (and, when present, the Sailing Directions) and
6
+ * writes one self-contained `<target>/.portolan/chart-room.html`: nautical
7
+ * archipelago map + engineering layered graph over the same embedded data,
8
+ * deterministic (same chart in → same bytes out), zero runtime dependencies.
9
+ * The layout lives in the page; core owns only reading, inlining, and
10
+ * writing. Reads the Chart, writes exactly one file, never the storage.
11
+ */
12
+ import { readFileSync, existsSync } from "node:fs";
13
+ import { join, resolve, basename, dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { readChart, chartDir } from "../chart-store";
16
+ import { writeFilesAtomically } from "../chart-io";
17
+ import type { IndexedEntry, NoticeAction } from "../types";
18
+
19
+ /** Where the artifact lands — inside the write perimeter, never the chart dir. */
20
+ export function chartRoomPath(targetRoot: string): string {
21
+ return resolve(targetRoot, ".portolan/chart-room.html");
22
+ }
23
+
24
+ /** One parsed notice line group from notices.txt. */
25
+ export interface ParsedNotice {
26
+ action: NoticeAction;
27
+ key: string;
28
+ note?: string;
29
+ anchors: string[];
30
+ }
31
+
32
+ /**
33
+ * Dependency tangles: strongly connected components (size ≥ 2) of the
34
+ * fairway graph over vessel ids. Self-loops and singletons are not
35
+ * tangles. Deterministic — members sorted by id, groups by first member.
36
+ */
37
+ export function findTangles(entries: IndexedEntry[]): string[][] {
38
+ const vesselIds = new Set(
39
+ entries.filter((e) => e.kind === "vessel").map((e) => e.id),
40
+ );
41
+ const adj = new Map<string, string[]>();
42
+ for (const e of entries) {
43
+ if (e.kind !== "fairway") continue;
44
+ const f = e as IndexedEntry & { from: string; to: string };
45
+ if (!vesselIds.has(f.from) || !vesselIds.has(f.to)) continue;
46
+ if (!adj.has(f.from)) adj.set(f.from, []);
47
+ adj.get(f.from)!.push(f.to);
48
+ }
49
+ // Tarjan SCC
50
+ let index = 0;
51
+ const idx = new Map<string, number>();
52
+ const low = new Map<string, number>();
53
+ const stack: string[] = [];
54
+ const onStack = new Set<string>();
55
+ const sccs: string[][] = [];
56
+ const strong = (v: string): void => {
57
+ idx.set(v, index);
58
+ low.set(v, index);
59
+ index += 1;
60
+ stack.push(v);
61
+ onStack.add(v);
62
+ for (const w of adj.get(v) ?? []) {
63
+ if (!idx.has(w)) {
64
+ strong(w);
65
+ low.set(v, Math.min(low.get(v)!, low.get(w)!));
66
+ } else if (onStack.has(w)) {
67
+ low.set(v, Math.min(low.get(v)!, idx.get(w)!));
68
+ }
69
+ }
70
+ if (low.get(v) === idx.get(v)) {
71
+ const component: string[] = [];
72
+ let w: string;
73
+ do {
74
+ w = stack.pop()!;
75
+ onStack.delete(w);
76
+ component.push(w);
77
+ } while (w !== v);
78
+ sccs.push(component);
79
+ }
80
+ };
81
+ for (const v of vesselIds) if (!idx.has(v)) strong(v);
82
+ return sccs
83
+ .filter((c) => c.length > 1)
84
+ .map((c) => c.sort())
85
+ .sort((a, b) => (a[0]! < b[0]! ? -1 : 1));
86
+ }
87
+
88
+ /**
89
+ * Parse the plain-text grammar produced by `renderNotices` (notices.ts):
90
+ * header + per-notice entry lines (label padded to 14 columns) followed by
91
+ * indented `anchor:` continuation lines. Absent or empty file → [].
92
+ */
93
+ export function parseNotices(text: string): ParsedNotice[] {
94
+ const labels = new Set(["ADDED", "CORRECTED", "MARKED STALE", "RETIRED"]);
95
+ const actionOf = (word: string): NoticeAction | undefined => {
96
+ if (word === "ADDED") return "added";
97
+ if (word === "CORRECTED") return "corrected";
98
+ if (word === "MARKED STALE") return "markedStale";
99
+ if (word === "RETIRED") return "retired";
100
+ return undefined;
101
+ };
102
+ const notices: ParsedNotice[] = [];
103
+ for (const raw of text.split("\n")) {
104
+ if (!raw.trim()) continue;
105
+ const entryMatch = raw.match(/^(ADDED|CORRECTED|MARKED STALE|RETIRED)\s+(.*)$/);
106
+ if (entryMatch && labels.has(entryMatch[1]!)) {
107
+ const rest = entryMatch[2]!;
108
+ const dash = rest.indexOf(" — ");
109
+ const key = dash === -1 ? rest : rest.slice(0, dash);
110
+ const notice: ParsedNotice = { action: actionOf(entryMatch[1]!)!, key, anchors: [] };
111
+ if (dash !== -1) notice.note = rest.slice(dash + 3);
112
+ notices.push(notice);
113
+ continue;
114
+ }
115
+ const anchorMatch = raw.match(/^\s+anchor:\s*(.+)$/);
116
+ if (anchorMatch && notices.length > 0) {
117
+ notices[notices.length - 1]!.anchors.push(anchorMatch[1]!);
118
+ }
119
+ // anything else (e.g. the header line) is skipped
120
+ }
121
+ return notices;
122
+ }
123
+
124
+ /** Render the Sailing Directions (markdown) into the briefing panel HTML. */
125
+ export function inlineMd(md: string): string {
126
+ const esc = (s: string) =>
127
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
128
+ const out: string[] = [];
129
+ let inList = false;
130
+ for (const raw of md.split("\n")) {
131
+ const line = raw.trimEnd();
132
+ const inline = (s: string) =>
133
+ esc(s)
134
+ .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
135
+ .replace(/`([^`]+)`/g, "<code>$1</code>");
136
+ const h = line.match(/^(#{1,3})\s+(.*)$/);
137
+ const li = line.match(/^[-*]\s+(.*)$/);
138
+ if (li) {
139
+ if (!inList) { out.push("<ul>"); inList = true; }
140
+ out.push(`<li>${inline(li[1]!)}</li>`);
141
+ } else {
142
+ if (inList) { out.push("</ul>"); inList = false; }
143
+ if (h) out.push(`<h3>${inline(h[2]!)}</h3>`);
144
+ else if (line.trim()) out.push(`<p>${inline(line)}</p>`);
145
+ }
146
+ }
147
+ if (inList) out.push("</ul>");
148
+ return out.join("\n");
149
+ }
150
+
151
+ /**
152
+ * JSON for inlining into a <script> block: no `</script>` breakout, no
153
+ * U+2028/2029. The caller MUST substitute with a function replacement so
154
+ * `$`-sequences in chart notes (`$(hadoop classpath)`, `${...}`) reach the
155
+ * page verbatim — a plain string replace would interpret them.
156
+ */
157
+ export function safeInlineJson(value: unknown): string {
158
+ return JSON.stringify(value)
159
+ .replace(/</g, "\\u003c")
160
+ .replace(/\u2028/g, "\\u2028")
161
+ .replace(/\u2029/g, "\\u2029");
162
+ }
163
+
164
+ function loadTemplate(): string {
165
+ const here = fileURLToPath(import.meta.url);
166
+ return readFileSync(resolve(here, "..", "template.html"), "utf8");
167
+ }
168
+
169
+ export interface ChartRoomResult {
170
+ path: string;
171
+ entries: number;
172
+ counts: Record<string, number>;
173
+ bytes: number;
174
+ }
175
+
176
+ /** Render the Chart Room for one province. Throws (loudly) when no chart. */
177
+ export function renderChartRoom(targetRoot: string): ChartRoomResult {
178
+ const entries: IndexedEntry[] = readChart(targetRoot); // loud when absent
179
+ const sdPath = resolve(targetRoot, ".portolan/sailing-directions.md");
180
+ const sd = existsSync(sdPath) ? readFileSync(sdPath, "utf8") : "";
181
+ const expedition = (sd.match(/Expedition (\S+)/) || [])[1] ?? "—";
182
+
183
+ const noticesPath = join(chartDir(targetRoot), "notices.txt");
184
+ const notices = existsSync(noticesPath)
185
+ ? parseNotices(readFileSync(noticesPath, "utf8"))
186
+ : [];
187
+
188
+ const briefMd = sd.replace(/^# .*\n/, "");
189
+ const briefHtml = briefMd
190
+ ? inlineMd(briefMd)
191
+ : "<p>No sailing directions on this province.</p>";
192
+
193
+ const meta = {
194
+ province: basename(resolve(targetRoot)),
195
+ targetPath: resolve(targetRoot),
196
+ expedition,
197
+ };
198
+
199
+ const html = loadTemplate()
200
+ .replace("__CHART_DATA__", () =>
201
+ safeInlineJson({ entries, notices, tangles: findTangles(entries) }))
202
+ .replace("__BRIEF_HTML__", () => JSON.stringify(briefHtml))
203
+ .replace("__META__", () => safeInlineJson(meta));
204
+
205
+ const counts: Record<string, number> = {};
206
+ for (const e of entries) counts[e.kind] = (counts[e.kind] ?? 0) + 1;
207
+
208
+ const path = chartRoomPath(targetRoot);
209
+ // Same stage-temp-then-rename discipline as the chart itself: a crash
210
+ // mid-write leaves the previous room intact, never a truncated file.
211
+ writeFilesAtomically(dirname(path), new Map([[basename(path), html]]));
212
+ return { path, entries: entries.length, counts, bytes: html.length };
213
+ }