@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,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());
|