@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,108 @@
1
+ /**
2
+ * The settings file: `<target>/.portolan/settings.json`, one recognized
3
+ * namespace (`harbor`) with two recognized keys — `harbor.schedule` and
4
+ * `harbor.auto_repair_max_vessels` — unset by default (design.md, decision
5
+ * 5). The schedule is documentation for external wiring — Portolan
6
+ * interprets nothing from it and runs nothing on its own; the headless
7
+ * propose CLI is the scheduler's entry. The auto-repair bound is the night
8
+ * watch's whole policy (night-watch design.md, decision 1): absent or zero
9
+ * means report-only, and a malformed value fails LOUDLY — a typo'd bound
10
+ * must never degrade silently into "launch everything" or "launch nothing".
11
+ * Unknown keys are tolerated with a warning so a newer or hand-edited
12
+ * settings file never breaks an older Portolan; a file that is not a JSON
13
+ * object fails loudly.
14
+ * openspec/changes/harbor-master + openspec/changes/night-watch (harbor
15
+ * capability: scheduling is an explicit setting, off by default / night
16
+ * policy bound)
17
+ */
18
+ import { existsSync, readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { SettingsError } from "./errors";
21
+
22
+ export const SETTINGS_FILE = "settings.json";
23
+
24
+ /** The recognized `harbor` namespace. */
25
+ export interface HarborSettings {
26
+ /** A cron-ish descriptor, documentation for external scheduling; absent by default. */
27
+ schedule?: string;
28
+ /**
29
+ * The night watch's auto-repair bound: the largest number of affected
30
+ * vessels a repair proposal may carry and still be auto-executed.
31
+ * Absent (and zero) means report-only; a non-negative integer or the
32
+ * settings file fails loudly.
33
+ */
34
+ autoRepairMaxVessels?: number;
35
+ }
36
+
37
+ /** What reading the settings file produced, plus its warnings. */
38
+ export interface SettingsResult {
39
+ harbor: HarborSettings;
40
+ warnings: string[];
41
+ }
42
+
43
+ /** Where the settings file lives. */
44
+ export function settingsFile(targetRoot: string): string {
45
+ return join(targetRoot, ".portolan", SETTINGS_FILE);
46
+ }
47
+
48
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
51
+
52
+ /**
53
+ * Read the settings file. Absent file: `{ harbor: {}, warnings: [] }` — no
54
+ * schedule, nothing configured, nothing runs on its own. Unknown keys (at
55
+ * either level) and ill-typed known keys are tolerated with a warning and
56
+ * ignored — except `harbor.auto_repair_max_vessels`, which must be a
57
+ * non-negative integer when present and fails loudly otherwise (the bound is
58
+ * the night watch's safety story; ambiguity about it is never tolerable).
59
+ * Malformed JSON or a non-object root is a loud error.
60
+ */
61
+ export function readSettings(targetRoot: string): SettingsResult {
62
+ const file = settingsFile(targetRoot);
63
+ if (!existsSync(file)) return { harbor: {}, warnings: [] };
64
+
65
+ let parsed: unknown;
66
+ try {
67
+ parsed = JSON.parse(readFileSync(file, "utf8"));
68
+ } catch (err) {
69
+ throw new SettingsError(`cannot parse ${file}: ${(err as Error).message}`);
70
+ }
71
+ if (!isPlainObject(parsed)) {
72
+ throw new SettingsError(`${file} is not a JSON object`);
73
+ }
74
+
75
+ const warnings: string[] = [];
76
+ const harbor: HarborSettings = {};
77
+ for (const [key, value] of Object.entries(parsed)) {
78
+ if (key !== "harbor") {
79
+ warnings.push(`settings: unknown key "${key}" ignored (known: harbor)`);
80
+ continue;
81
+ }
82
+ if (!isPlainObject(value)) {
83
+ warnings.push(`settings: "harbor" must be an object; ignored`);
84
+ continue;
85
+ }
86
+ for (const [innerKey, innerValue] of Object.entries(value)) {
87
+ if (innerKey === "schedule") {
88
+ if (typeof innerValue !== "string" || innerValue.length === 0) {
89
+ warnings.push(`settings: harbor.schedule must be a non-empty string; ignored`);
90
+ } else {
91
+ harbor.schedule = innerValue;
92
+ }
93
+ } else if (innerKey === "auto_repair_max_vessels") {
94
+ if (typeof innerValue !== "number" || !Number.isInteger(innerValue) || innerValue < 0) {
95
+ throw new SettingsError(
96
+ `harbor.auto_repair_max_vessels must be a non-negative integer, got ${JSON.stringify(innerValue)}`,
97
+ );
98
+ }
99
+ harbor.autoRepairMaxVessels = innerValue;
100
+ } else {
101
+ warnings.push(
102
+ `settings: unknown key "harbor.${innerKey}" ignored (known: harbor.schedule, harbor.auto_repair_max_vessels)`,
103
+ );
104
+ }
105
+ }
106
+ }
107
+ return { harbor, warnings };
108
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The landscape snapshot: what the province looked like at the last survey,
3
+ * stored under `<target>/.portolan/harbor/snapshot.json` as
4
+ * `{ indexHash, landscape[] }` (design.md, decision 2).
5
+ *
6
+ * Refresh rule — the chart index hash. When the current index hash differs
7
+ * from the stored one, a survey stood against the current landscape and the
8
+ * snapshot refreshes; when it matches, the landscape is compared against the
9
+ * snapshot and entries absent from it yield new-land proposals. This needs
10
+ * no hooks into the chart store. A first propose over a chart with no
11
+ * snapshot establishes the baseline (and proposes no new-land): there is no
12
+ * earlier survey to differ from.
13
+ *
14
+ * The landscape is a bounded walk collecting two entry kinds: repository
15
+ * directories (a child `.git`, file or directory — worktrees and submodules
16
+ * included) and manifest files (the five kinds the manifests tool parses).
17
+ * The walk skips `node_modules`, `.git`, and `.portolan` exactly like the
18
+ * probe tools' walks, and never records the province root itself.
19
+ */
20
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
21
+ import { createHash } from "node:crypto";
22
+ import { join } from "node:path";
23
+ import { MANIFEST_KINDS } from "../tools/manifests";
24
+ import { readChart, sortEntries, writeFilesAtomically } from "../chart-io";
25
+ import { HarborError } from "./errors";
26
+
27
+ export const HARBOR_DIRNAME = "harbor";
28
+ export const SNAPSHOT_FILE = "snapshot.json";
29
+
30
+ /** Directories the landscape walk never descends into (probe-tool convention). */
31
+ const SKIPPED_DIRS = new Set(["node_modules", ".git", ".portolan"]);
32
+
33
+ /** Bound on walk depth: deep trees terminate; six levels cover monorepo layouts. */
34
+ const MAX_DEPTH = 6;
35
+
36
+ /** One landscape fact: a repository directory or a manifest file, by relative path. */
37
+ export interface LandscapeEntry {
38
+ kind: "repo" | "manifest";
39
+ /** Relative to the province root; `apps/api`, `vendor/lib/go.mod`, ... */
40
+ path: string;
41
+ }
42
+
43
+ /** The stored snapshot: the landscape together with the chart index hash it was taken against. */
44
+ export interface LandscapeSnapshot {
45
+ indexHash: string;
46
+ landscape: LandscapeEntry[];
47
+ }
48
+
49
+ /** Where the Harbor Master's files live for a given target root. */
50
+ export function harborDir(targetRoot: string): string {
51
+ return join(targetRoot, ".portolan", HARBOR_DIRNAME);
52
+ }
53
+
54
+ /** Where the landscape snapshot lives. */
55
+ export function snapshotFile(targetRoot: string): string {
56
+ return join(harborDir(targetRoot), SNAPSHOT_FILE);
57
+ }
58
+
59
+ /**
60
+ * sha256 over the chart's canonical entry content — the snapshot's refresh
61
+ * key. Store-owned `stale` flags are stripped before hashing: a staleness
62
+ * refresh marks pending correction but is not "a survey stood", so it must
63
+ * not refresh the snapshot (design.md, decision 2's parenthetical). A real
64
+ * survey write always changes content — vessel signatures are re-stamped
65
+ * against the repaired sources — and a write that changes nothing is, by
66
+ * the design's accepted-risk note, not a new survey.
67
+ */
68
+ export function chartIndexHash(targetRoot: string): string {
69
+ const content = readChart(targetRoot).map(({ stale: _stale, ...entry }) => entry);
70
+ const canonical = sortEntries(content)
71
+ .map((entry) => JSON.stringify(entry))
72
+ .join("\n");
73
+ return createHash("sha256").update(canonical).digest("hex");
74
+ }
75
+
76
+ function entryKey(entry: LandscapeEntry): string {
77
+ return `${entry.kind}:${entry.path}`;
78
+ }
79
+
80
+ /** Stable landscape order: kind, then path. */
81
+ export function sortLandscape(landscape: LandscapeEntry[]): LandscapeEntry[] {
82
+ return [...landscape].sort((a, b) =>
83
+ a.kind === b.kind ? (a.path < b.path ? -1 : a.path > b.path ? 1 : 0) : a.kind < b.kind ? -1 : 1,
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Walk the province and list the landscape: repository directories (a
89
+ * `.git` child) and manifest files, skipping `node_modules`/`.git`/
90
+ * `.portolan`, bounded by depth. Read-only toward everything it walks.
91
+ */
92
+ export function scanLandscape(targetRoot: string): LandscapeEntry[] {
93
+ const found: LandscapeEntry[] = [];
94
+ const seen = new Set<string>();
95
+ const push = (entry: LandscapeEntry): void => {
96
+ const key = entryKey(entry);
97
+ if (!seen.has(key)) {
98
+ seen.add(key);
99
+ found.push(entry);
100
+ }
101
+ };
102
+
103
+ const visit = (rel: string, depth: number): void => {
104
+ if (depth > MAX_DEPTH) return;
105
+ let children;
106
+ try {
107
+ children = readdirSync(join(targetRoot, rel), { withFileTypes: true });
108
+ } catch {
109
+ return; // an unreadable directory contributes nothing
110
+ }
111
+ const sorted = [...children].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
112
+ // A `.git` child marks a repository — noticed, never descended into.
113
+ if (rel !== "" && sorted.some((de) => de.name === ".git")) {
114
+ push({ kind: "repo", path: rel });
115
+ }
116
+ for (const de of sorted) {
117
+ if (de.isDirectory()) {
118
+ if (SKIPPED_DIRS.has(de.name)) continue;
119
+ visit(rel === "" ? de.name : `${rel}/${de.name}`, depth + 1);
120
+ } else if (de.isFile() && (MANIFEST_KINDS as readonly string[]).includes(de.name)) {
121
+ push({ kind: "manifest", path: rel === "" ? de.name : `${rel}/${de.name}` });
122
+ }
123
+ }
124
+ };
125
+ visit("", 0);
126
+ return sortLandscape(found);
127
+ }
128
+
129
+ /**
130
+ * The anchor a new-land proposal cites for a landscape entry: the manifest
131
+ * file itself, or the repository's `.git` marker (a regular file in both
132
+ * cases, so `sound.anchor` can confirm it: `.git/HEAD` for a plain
133
+ * repository, the gitdir pointer file for a worktree/submodule).
134
+ */
135
+ export function landscapeAnchor(targetRoot: string, entry: LandscapeEntry): { type: "file"; path: string } {
136
+ if (entry.kind === "manifest") return { type: "file", path: entry.path };
137
+ const git = join(targetRoot, entry.path, ".git");
138
+ try {
139
+ if (statSync(git).isFile()) return { type: "file", path: `${entry.path}/.git` };
140
+ } catch {
141
+ // fall through to the plain-repository form
142
+ }
143
+ return { type: "file", path: `${entry.path}/.git/HEAD` };
144
+ }
145
+
146
+ /** Read the stored snapshot; null when none exists yet. */
147
+ export function readSnapshot(targetRoot: string): LandscapeSnapshot | null {
148
+ const file = snapshotFile(targetRoot);
149
+ if (!existsSync(file)) return null;
150
+ let parsed: unknown;
151
+ try {
152
+ parsed = JSON.parse(readFileSync(file, "utf8"));
153
+ } catch (err) {
154
+ throw new HarborError(`snapshot: corrupt landscape snapshot ${file}: ${(err as Error).message}`);
155
+ }
156
+ const snap = parsed as LandscapeSnapshot;
157
+ if (
158
+ typeof snap?.indexHash !== "string" ||
159
+ !Array.isArray(snap.landscape) ||
160
+ snap.landscape.some(
161
+ (e) =>
162
+ (e?.kind !== "repo" && e?.kind !== "manifest") || typeof e?.path !== "string" || e.path.length === 0,
163
+ )
164
+ ) {
165
+ throw new HarborError(
166
+ `snapshot: corrupt landscape snapshot ${file}: expected { indexHash, landscape[] } with repo/manifest entries`,
167
+ );
168
+ }
169
+ return snap;
170
+ }
171
+
172
+ /** Persist the snapshot (plain pretty JSON, diff-friendly). */
173
+ export function writeSnapshot(targetRoot: string, snapshot: LandscapeSnapshot): void {
174
+ // Same stage-temp-then-rename discipline as the chart itself: a torn
175
+ // write on the unattended cron path would brick every later propose and
176
+ // watch with a "corrupt landscape snapshot" until a human deletes the file.
177
+ mkdirSync(harborDir(targetRoot), { recursive: true });
178
+ writeFilesAtomically(
179
+ harborDir(targetRoot),
180
+ new Map([
181
+ [
182
+ SNAPSHOT_FILE,
183
+ `${JSON.stringify({ indexHash: snapshot.indexHash, landscape: sortLandscape(snapshot.landscape) }, null, 2)}\n`,
184
+ ],
185
+ ]),
186
+ );
187
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The night watch (openspec/changes/night-watch): queue → night policy →
3
+ * external launcher → harbor history → chat report. Designed for external
4
+ * schedulers (cron/CI); Portolan still ships no daemon — nothing here runs
5
+ * on a timer, only when invoked.
6
+ *
7
+ * Semantics pinned by design.md:
8
+ * - decision 1: the whole policy is `harbor.auto_repair_max_vessels`
9
+ * (absent/0 = report-only); repairs within the bound launch, new-land and
10
+ * gap never do.
11
+ * - decision 2: the launcher is external and swappable (argv from
12
+ * `--launcher`, the `{ target, proposal }` brief as JSON on stdin, capped
13
+ * by a timeout); with no launcher configured the watch is report-only
14
+ * even with a bound set — the core names no harness.
15
+ * - decision 3: accept-then-append-failure. The auto-accept (`by:
16
+ * night-watch`) is written before launch; a failed launch appends a
17
+ * `launch-failed` outcome, which is the latest word on the fingerprint,
18
+ * so a failed proposal is effectively not-accepted and stays queued (the
19
+ * queue filters on `declined` only).
20
+ *
21
+ * Determinism: the report carries no timestamps — two watch runs over an
22
+ * unchanged province (same launcher behavior) emit byte-identical reports.
23
+ */
24
+ import { computeProposals, type Proposal } from "./proposals";
25
+ import { readSettings } from "./settings";
26
+ import { nightPolicy } from "./night-policy";
27
+ import { briefFor, launchExpedition, DEFAULT_LAUNCHER_TIMEOUT_MS } from "./launcher";
28
+ import { appendDecision, appendLaunchFailure, NIGHT_WATCH } from "./history";
29
+
30
+ /** What the watch was told at invocation. */
31
+ export interface WatchOptions {
32
+ /** The external launcher command (argv template); absent = report-only. */
33
+ launcher?: string;
34
+ /** Per-launch timeout in milliseconds; default 30m (DEFAULT_LAUNCHER_TIMEOUT_MS). */
35
+ launcherTimeoutMs?: number;
36
+ }
37
+
38
+ /** One launch the watch attempted, with its outcome. */
39
+ export interface WatchAction {
40
+ proposal: Proposal;
41
+ outcome: "completed" | "launch-failed";
42
+ /** Deterministic failure reason; present iff the outcome is launch-failed. */
43
+ reason?: string;
44
+ }
45
+
46
+ /** The watch report's data: what ran, what stayed pending, and the policy that decided. */
47
+ export interface WatchReport {
48
+ /** The effective `harbor.auto_repair_max_vessels` (absent = 0). */
49
+ bound: number;
50
+ /** True when nothing could launch at all (no launcher configured, or bound 0). */
51
+ reportOnly: boolean;
52
+ /** The launcher command the run used, when one was configured. */
53
+ launcherCommand?: string;
54
+ /** Every launch attempted this run, in queue order, with outcomes. */
55
+ ran: WatchAction[];
56
+ /** Proposals left for the Governor, in queue order, with their evidence. */
57
+ pending: Proposal[];
58
+ }
59
+
60
+ /**
61
+ * Run the night watch against a province: compute the queue, apply the
62
+ * night policy, launch what qualifies through the external launcher
63
+ * (recording each auto-accept `by night-watch` first, appending
64
+ * `launch-failed` on failure), and return the report data. Never launches
65
+ * anything and writes no history when report-only.
66
+ */
67
+ export async function runWatch(targetRoot: string, options: WatchOptions = {}): Promise<WatchReport> {
68
+ const { harbor } = readSettings(targetRoot);
69
+ const bound = harbor.autoRepairMaxVessels ?? 0;
70
+
71
+ const { proposals } = computeProposals(targetRoot);
72
+ const { launch, pending } = nightPolicy(proposals, bound);
73
+
74
+ // Report-only (no launcher, or bound 0): nothing can launch, so what the
75
+ // policy would have launched folds back into pending — the report always
76
+ // shows the Governor every outstanding proposal.
77
+ const reportOnly = options.launcher === undefined || bound <= 0;
78
+ const ran: WatchAction[] = [];
79
+ if (!reportOnly) {
80
+ for (const proposal of launch) {
81
+ appendDecision(targetRoot, proposal.fingerprint, "accepted", { by: NIGHT_WATCH });
82
+ const result = await launchExpedition({
83
+ launcher: options.launcher as string,
84
+ brief: briefFor(targetRoot, proposal),
85
+ timeoutMs: options.launcherTimeoutMs ?? DEFAULT_LAUNCHER_TIMEOUT_MS,
86
+ });
87
+ if (result.ok) {
88
+ ran.push({ proposal, outcome: "completed" });
89
+ } else {
90
+ appendLaunchFailure(targetRoot, proposal.fingerprint, result.reason as string);
91
+ ran.push({ proposal, outcome: "launch-failed", reason: result.reason });
92
+ }
93
+ }
94
+ }
95
+
96
+ return {
97
+ bound,
98
+ reportOnly,
99
+ ...(options.launcher !== undefined ? { launcherCommand: options.launcher } : {}),
100
+ ran,
101
+ pending: reportOnly ? proposals : pending,
102
+ };
103
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @portolan/core — public surface: the chart ontology, validation, the
3
+ * chart store, staleness, Notices to Mariners, and the Harbor Master's
4
+ * expedition-proposal engine (including the night watch and its external
5
+ * launcher contract). Everything a Cartographer harness needs; nothing
6
+ * else.
7
+ */
8
+ export * from "./types";
9
+ export * from "./validate";
10
+ export * from "./chart-store";
11
+ export * from "./staleness";
12
+ export * from "./notices";
13
+ export * from "./tools/sweep";
14
+ export * from "./tools/symbols";
15
+ export * from "./tools/manifests";
16
+ export * from "./tools/log";
17
+ export * from "./tools/sound";
18
+ export * from "./tools/neighborhood";
19
+ export * from "./harbor/errors";
20
+ export * from "./harbor/fingerprint";
21
+ export * from "./harbor/snapshot";
22
+ export * from "./harbor/history";
23
+ export * from "./harbor/settings";
24
+ export * from "./harbor/proposals";
25
+ export * from "./harbor/chat-format";
26
+ export * from "./harbor/night-policy";
27
+ export * from "./harbor/launcher";
28
+ export * from "./harbor/watch";
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Notices to Mariners: the plain-text change report an expedition leaves
3
+ * behind. Derived by diffing the previous index against the new one —
4
+ * added / corrected / marked stale / retired — each notice carrying the
5
+ * entry's anchors. Deterministic output, suitable for git diff review.
6
+ */
7
+ import { formatAnchor, type IndexedEntry, type Notice, type NoticeAction } from "./types";
8
+
9
+ /** Fields that carry chart state, not surveyed content. */
10
+ const META_FIELDS = new Set(["stale", "signature"]);
11
+
12
+ function contentChangedFields(before: IndexedEntry, after: IndexedEntry): string[] {
13
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
14
+ const changed: string[] = [];
15
+ for (const key of keys) {
16
+ if (META_FIELDS.has(key)) continue;
17
+ if (JSON.stringify(before[key as keyof IndexedEntry]) !== JSON.stringify(after[key as keyof IndexedEntry])) {
18
+ changed.push(key);
19
+ }
20
+ }
21
+ return changed.sort();
22
+ }
23
+
24
+ /**
25
+ * Diff two chart states into notices.
26
+ *
27
+ * - present after only → added
28
+ * - present before only → retired
29
+ * - fresh → stale → marked stale (pending correction)
30
+ * - content changed, or stale → fresh (a repair) → corrected
31
+ */
32
+ export function diffNotices(before: IndexedEntry[], after: IndexedEntry[]): Notice[] {
33
+ const beforeByKey = new Map(before.map((e) => [`${e.kind}/${e.id}`, e]));
34
+ const afterByKey = new Map(after.map((e) => [`${e.kind}/${e.id}`, e]));
35
+ const notices: Notice[] = [];
36
+
37
+ for (const [key, entry] of afterByKey) {
38
+ const prior = beforeByKey.get(key);
39
+ if (!prior) {
40
+ notices.push({ action: "added", kind: entry.kind, id: entry.id, anchors: entry.anchors });
41
+ continue;
42
+ }
43
+ const changedFields = contentChangedFields(prior, entry);
44
+ const staleCleared = prior.stale && !entry.stale;
45
+ const staleSet = !prior.stale && entry.stale;
46
+ if (staleSet) {
47
+ notices.push({
48
+ action: "markedStale",
49
+ kind: entry.kind,
50
+ id: entry.id,
51
+ note: "sources changed since the last survey",
52
+ anchors: entry.anchors,
53
+ });
54
+ } else if (changedFields.length > 0 || staleCleared) {
55
+ const parts: string[] = [];
56
+ if (changedFields.length > 0) parts.push(`changed: ${changedFields.join(", ")}`);
57
+ if (staleCleared) parts.push("repaired (was pending correction)");
58
+ notices.push({
59
+ action: "corrected",
60
+ kind: entry.kind,
61
+ id: entry.id,
62
+ note: parts.join("; "),
63
+ anchors: entry.anchors,
64
+ });
65
+ }
66
+ }
67
+
68
+ for (const [key, entry] of beforeByKey) {
69
+ if (!afterByKey.has(key)) {
70
+ notices.push({ action: "retired", kind: entry.kind, id: entry.id, anchors: entry.anchors });
71
+ }
72
+ }
73
+
74
+ return sortNotices(notices);
75
+ }
76
+
77
+ const ACTION_ORDER: NoticeAction[] = ["added", "corrected", "markedStale", "retired"];
78
+
79
+ function sortNotices(notices: Notice[]): Notice[] {
80
+ return [...notices].sort((a, b) => {
81
+ const rank = ACTION_ORDER.indexOf(a.action) - ACTION_ORDER.indexOf(b.action);
82
+ if (rank !== 0) return rank;
83
+ const ka = `${a.kind}/${a.id}`;
84
+ const kb = `${b.kind}/${b.id}`;
85
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
86
+ });
87
+ }
88
+
89
+ const LABELS: Record<NoticeAction, string> = {
90
+ added: "ADDED",
91
+ corrected: "CORRECTED",
92
+ markedStale: "MARKED STALE",
93
+ retired: "RETIRED",
94
+ };
95
+
96
+ /**
97
+ * Render notices as plain text:
98
+ *
99
+ * NOTICES TO MARINERS
100
+ *
101
+ * MARKED STALE vessel/web — sources changed since the last survey
102
+ * anchor: services/web/main.ts:1
103
+ */
104
+ export function renderNotices(notices: Notice[]): string {
105
+ if (notices.length === 0) return "";
106
+ const pad = " ".repeat(14);
107
+ const lines: string[] = ["NOTICES TO MARINERS", ""];
108
+ for (const notice of notices) {
109
+ const label = LABELS[notice.action].padEnd(14);
110
+ const head = `${notice.kind}/${notice.id}${notice.note ? ` — ${notice.note}` : ""}`;
111
+ lines.push(`${label}${head}`);
112
+ for (const anchor of notice.anchors) {
113
+ lines.push(`${pad}anchor: ${formatAnchor(anchor)}`);
114
+ }
115
+ }
116
+ return `${lines.join("\n")}\n`;
117
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The target perimeter: the one containment rule every province read obeys.
3
+ * A cited path counts as inside only when it resolves both lexically and
4
+ * through symlinks inside the target root — no reader crosses the perimeter
5
+ * whatever the citation claims. A wholly fabricated path has no existing
6
+ * parent, so containment is checked against the nearest existing ancestor:
7
+ * nothing on such a path can be read, and the honest outcome is "escapes",
8
+ * not a crash.
9
+ * specs/permissions/spec.md; security hardening cfa1869, 23d012b.
10
+ */
11
+ import { realpathSync } from "node:fs";
12
+ import { dirname, resolve, sep } from "node:path";
13
+
14
+ /**
15
+ * Resolve a cited path inside the target root; undefined when it escapes
16
+ * (a `..` segment, an in-target symlink pointing outside, or an absolute
17
+ * path elsewhere on the machine).
18
+ */
19
+ export function resolveInsideTarget(targetRoot: string, rel: string): string | undefined {
20
+ const root = resolve(targetRoot);
21
+ const abs = resolve(root, rel);
22
+ if (abs !== root && !abs.startsWith(root + sep)) return undefined;
23
+ // A symlink inside the target may point outside it — including the cited
24
+ // file itself: compare real paths, so an anchor can never read through an
25
+ // in-target link past the perimeter.
26
+ const realRoot = realpathSync(root);
27
+ let probe = abs;
28
+ let realPath: string;
29
+ for (;;) {
30
+ try {
31
+ realPath = realpathSync(probe);
32
+ break;
33
+ } catch {
34
+ const parent = dirname(probe);
35
+ if (parent === probe) {
36
+ realPath = probe; // walked to the filesystem root unrealized: escape
37
+ break;
38
+ }
39
+ probe = parent;
40
+ }
41
+ }
42
+ if (realPath !== realRoot && !realPath.startsWith(realRoot + sep)) return undefined;
43
+ return abs;
44
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The adapter-boundary check (tasks.md 4.3): a static scan proving
3
+ * `adapters/` imports no tool logic. Adapters are launch configuration
4
+ * (design.md, decision 5) — an adapter that imports a tool module would be
5
+ * a code path the harness-parity scenario cannot test by construction.
6
+ *
7
+ * The scan is deliberately blunt: it flags any module import (static,
8
+ * dynamic, or require) of the core package or of anything under core/src.
9
+ * Launching the server (exec lines in shims, the command array in the
10
+ * opencode config) is exactly what adapters are FOR and is never flagged.
11
+ * Markdown is skipped — prose shows examples, code does not import.
12
+ */
13
+ import { readdirSync, readFileSync, statSync } from "node:fs";
14
+ import { join, relative } from "node:path";
15
+
16
+ /** One boundary violation: where it is, and the offending line. */
17
+ export interface BoundaryViolation {
18
+ file: string;
19
+ line: number;
20
+ text: string;
21
+ }
22
+
23
+ /** Import/requires that reach tool or core logic from adapter code. */
24
+ const TOOL_IMPORT_PATTERNS: RegExp[] = [
25
+ /from\s+["']@portolan\/core[^"']*["']/, // static import of the package
26
+ /import\s*\(\s*["']@portolan\/core[^"']*["']\s*\)/, // dynamic import
27
+ /require\s*\(\s*["']@portolan\/core[^"']*["']\s*\)/, // CJS require
28
+ /from\s+["'][^"']*core\/src\//, // relative path into the core tree
29
+ /import\s*\(\s*["'][^"']*core\/src\//,
30
+ /require\s*\(\s*["'][^"']*core\/src\//,
31
+ ];
32
+
33
+ const SCANNED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs", ".sh", ""]);
34
+
35
+ /**
36
+ * Scan an adapter tree for tool-logic imports. Returns every violation;
37
+ * an empty list means the boundary holds.
38
+ */
39
+ export function scanAdapterTree(root: string): BoundaryViolation[] {
40
+ const violations: BoundaryViolation[] = [];
41
+ const visit = (dir: string): void => {
42
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
43
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
44
+ )) {
45
+ const abs = join(dir, entry.name);
46
+ if (entry.isDirectory()) {
47
+ visit(abs);
48
+ continue;
49
+ }
50
+ if (!entry.isFile()) continue;
51
+ if (entry.name.endsWith(".md") || entry.name.endsWith(".json") || entry.name.endsWith(".jsonc")) {
52
+ continue; // prose and config; launch lines live here on purpose
53
+ }
54
+ const ext = entry.name.includes(".") ? entry.name.slice(entry.name.lastIndexOf(".")) : "";
55
+ if (!SCANNED_EXTENSIONS.has(ext)) continue;
56
+ const lines = readFileSync(abs, "utf8").split("\n");
57
+ lines.forEach((text, index) => {
58
+ if (TOOL_IMPORT_PATTERNS.some((pattern) => pattern.test(text))) {
59
+ violations.push({ file: relative(root, abs), line: index + 1, text: text.trim() });
60
+ }
61
+ });
62
+ }
63
+ };
64
+ visit(root);
65
+ return violations;
66
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The Portolan MCP server entry point. Launch with:
4
+ *
5
+ * bun core/src/server/main.ts --target <province root>
6
+ *
7
+ * `--target` defaults to the working directory and is resolved once here;
8
+ * every tool call is scoped to that root, and no call can redirect it —
9
+ * changing provinces means launching a new server (design.md, decision 2).
10
+ * The server speaks MCP over stdio and nothing else: no network, no daemon.
11
+ * specs/harness/spec.md
12
+ */
13
+ import { parseArgs } from "node:util";
14
+ import { resolve } from "node:path";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { createPortolanServer } from "./server";
17
+
18
+ const { values } = parseArgs({
19
+ allowPositionals: false,
20
+ options: {
21
+ target: { type: "string", default: process.cwd() },
22
+ },
23
+ });
24
+
25
+ const targetRoot = resolve(values.target as string);
26
+ const server = createPortolanServer({ targetRoot });
27
+ await server.connect(new StdioServerTransport());