@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,155 @@
1
+ /**
2
+ * The external expedition launcher (night-watch design.md, decision 2):
3
+ * the watch never names a harness — it spawns whatever command the operator
4
+ * passed as `--launcher "<cmd>"`, feeds the expedition brief
5
+ * (`{ target, proposal }`) as JSON on stdin, and caps the run with
6
+ * `--launcher-timeout` (default 30m).
7
+ *
8
+ * Contract: exit 0 = the expedition completed; non-zero exit, timeout, or
9
+ * spawn failure = the failure path (the caller records it in the harbor
10
+ * history and names it in the report). The launcher's stdout is consumed
11
+ * and discarded (it must never pollute the watch's postable report); its
12
+ * stderr is forwarded to the watch's stderr so a stuck launcher is visible
13
+ * in the scheduler's mail without breaking stdout determinism.
14
+ *
15
+ * No core module is named here and none is imported by launchers — the
16
+ * adapter boundary (core/src/server/adapter-boundary.ts) holds both ways.
17
+ * openspec/changes/night-watch (harbor capability: the launcher is
18
+ * external and swappable)
19
+ */
20
+ import { spawn } from "node:child_process";
21
+ import type { Proposal } from "./proposals";
22
+
23
+ /** What one launch is told: the province and the proposal to execute. */
24
+ export interface LaunchBrief {
25
+ /** The province root the expedition runs against. */
26
+ target: string;
27
+ /** The proposal the night watch auto-accepted, exactly as computed. */
28
+ proposal: Proposal;
29
+ }
30
+
31
+ /** The brief one launch receives on stdin: exactly the province and the proposal. */
32
+ export function briefFor(targetRoot: string, proposal: Proposal): LaunchBrief {
33
+ return { target: targetRoot, proposal };
34
+ }
35
+
36
+ /** How one launch ended. `reason` is deterministic (status, timeout, spawn error). */
37
+ export interface LaunchResult {
38
+ ok: boolean;
39
+ reason?: string;
40
+ }
41
+
42
+ /** The default per-launch timeout: 30 minutes (design.md, decision 2). */
43
+ export const DEFAULT_LAUNCHER_TIMEOUT_MS = 30 * 60 * 1000;
44
+
45
+ /**
46
+ * Split a launcher command string into argv, honoring double and single
47
+ * quotes so a path with spaces survives: `bash "/path/with spaces/x.sh"`
48
+ * → `["bash", "/path/with spaces/x.sh"]`.
49
+ */
50
+ export function splitCommand(command: string): string[] {
51
+ const argv: string[] = [];
52
+ for (const match of command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
53
+ argv.push(match[1] ?? match[2] ?? match[3] ?? "");
54
+ }
55
+ return argv;
56
+ }
57
+
58
+ /**
59
+ * Parse a `--launcher-timeout` duration: `<n>ms`, `<n>s`, `<n>m`, `<n>h`,
60
+ * or a bare number (seconds). Throws on anything else — a silently wrong
61
+ * timeout is a silently unbounded launcher.
62
+ */
63
+ export function parseDurationMs(value: string): number {
64
+ const match = /^(.+?)(ms|s|m|h)?$/.exec(value.trim());
65
+ const amount = match === null ? NaN : Number(match[1]);
66
+ if (match === null || !Number.isFinite(amount) || amount <= 0) {
67
+ throw new Error(`--launcher-timeout must be a positive duration like 45s, 30m or 1h, got ${JSON.stringify(value)}`);
68
+ }
69
+ const unit = match[2] ?? "s";
70
+ const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : 3_600_000;
71
+ return Math.round(amount * factor);
72
+ }
73
+
74
+ /** Render a millisecond duration compactly and deterministically: 250ms, 30m, 1h. */
75
+ export function formatDuration(ms: number): string {
76
+ if (ms % 3_600_000 === 0) return `${ms / 3_600_000}h`;
77
+ if (ms % 60_000 === 0) return `${ms / 60_000}m`;
78
+ if (ms % 1000 === 0) return `${ms / 1000}s`;
79
+ return `${ms}ms`;
80
+ }
81
+
82
+ /**
83
+ * Launch one expedition through the external launcher: spawn the command,
84
+ * write the brief as JSON on stdin, wait for exit, and kill the launcher
85
+ * (SIGKILL — a stuck launcher forfeits cleanup) once the timeout burns.
86
+ * Never throws: every failure is a `{ ok: false, reason }` return.
87
+ */
88
+ export function launchExpedition(options: {
89
+ launcher: string;
90
+ brief: LaunchBrief;
91
+ timeoutMs: number;
92
+ }): Promise<LaunchResult> {
93
+ const argv = splitCommand(options.launcher);
94
+ if (argv.length === 0 || argv[0].length === 0) {
95
+ return Promise.resolve({ ok: false, reason: "no launcher command given" });
96
+ }
97
+
98
+ return new Promise<LaunchResult>((resolve) => {
99
+ let child;
100
+ try {
101
+ // detached: the launcher leads its own process group, so a timeout can
102
+ // kill the whole tree — a grandchild holding the stdio pipes would
103
+ // otherwise outlive the cap and hang the watch.
104
+ child = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "pipe", "pipe"], detached: true });
105
+ } catch (err) {
106
+ resolve({ ok: false, reason: `launcher could not be spawned: ${(err as Error).message}` });
107
+ return;
108
+ }
109
+
110
+ let settled = false;
111
+ let timedOut = false;
112
+ const killTree = (): void => {
113
+ try {
114
+ process.kill(-child.pid!, "SIGKILL");
115
+ } catch {
116
+ child.kill("SIGKILL"); // the group is gone or unsupported; the child alone then
117
+ }
118
+ };
119
+ const settle = (result: LaunchResult): void => {
120
+ if (settled) return;
121
+ settled = true;
122
+ clearTimeout(timer);
123
+ resolve(result);
124
+ };
125
+ const timer = setTimeout(() => {
126
+ timedOut = true;
127
+ killTree();
128
+ }, options.timeoutMs);
129
+
130
+ child.on("error", (err) => {
131
+ settle({ ok: false, reason: `launcher could not be spawned: ${err.message}` });
132
+ });
133
+ child.on("close", (code, signal) => {
134
+ if (code === 0) {
135
+ settle({ ok: true });
136
+ } else if (code !== null) {
137
+ settle({ ok: false, reason: `launcher exited with status ${code}` });
138
+ } else if (timedOut) {
139
+ settle({ ok: false, reason: `launcher timed out after ${formatDuration(options.timeoutMs)} and was killed` });
140
+ } else {
141
+ settle({ ok: false, reason: `launcher was killed by signal ${signal ?? "unknown"}` });
142
+ }
143
+ });
144
+
145
+ // The launcher's stdout must be consumed (a full pipe would deadlock the
146
+ // child) but is never part of the watch report.
147
+ child.stdout?.on("data", () => {});
148
+ // Launcher stderr is forwarded so schedulers see it; it never touches stdout.
149
+ child.stderr?.on("data", (chunk: Buffer) => process.stderr.write(chunk));
150
+
151
+ child.stdin.on("error", () => {}); // a launcher that exits before reading stdin is not a spawn bug
152
+ child.stdin.write(`${JSON.stringify(options.brief)}\n`);
153
+ child.stdin.end();
154
+ });
155
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The night policy (night-watch design.md, decision 1; the bound is
3
+ * cumulative since openspec/changes/resurvey-queue, design decision "The
4
+ * night bound becomes cumulative"): which of the standing queue's proposals
5
+ * the night watch may auto-execute. The entire policy is one bound —
6
+ * `harbor.auto_repair_max_vessels` — and one rule with no exceptions:
7
+ *
8
+ * walking the queue in order, a `repair` row launches iff the bound is
9
+ * present and positive and its scope's vessels still fit in what remains
10
+ * of the bound; each launch spends its scope's vessel count, and
11
+ * everything past the bound stays pending.
12
+ *
13
+ * The bound is spent cumulatively, not per row: after the per-vessel split
14
+ * every repair row holds exactly one vessel, so any positive bound would
15
+ * pass every row and "bounded" would bound nothing. Whether a launch
16
+ * attempt refunds its share on failure is the watch's question
17
+ * (watch.ts, accept-then-append-failure) — not answered here: this policy
18
+ * is pure and knows nothing of outcomes.
19
+ *
20
+ * `new-land` and `gap` proposals are NEVER auto-executed, regardless of the
21
+ * bound — the night watch repairs known coast, it does not explore. Absent,
22
+ * zero, or negative bound means report-only: everything is pending.
23
+ *
24
+ * Pure by construction: proposals and a number in, two lists out, queue
25
+ * order preserved in both. No filesystem, no clock, no harness.
26
+ * openspec/changes/night-watch (harbor capability: auto-repair is bounded
27
+ * and never curious)
28
+ */
29
+ import type { Proposal } from "./proposals";
30
+
31
+ /** What the night policy decided: what may launch, what stays with the Governor. */
32
+ export interface NightPolicyResult {
33
+ /** Repair proposals within the bound, in queue order — the watch may launch these. */
34
+ launch: Proposal[];
35
+ /** Everything else, in queue order — left pending for the Governor's decision. */
36
+ pending: Proposal[];
37
+ }
38
+
39
+ /**
40
+ * Apply the night policy to a computed queue. `bound` is the effective
41
+ * `harbor.auto_repair_max_vessels` (absent = 0): 0 or negative means
42
+ * report-only.
43
+ */
44
+ export function nightPolicy(proposals: Proposal[], bound: number): NightPolicyResult {
45
+ const launch: Proposal[] = [];
46
+ const pending: Proposal[] = [];
47
+ // A row's cost is its scope's vessel count: one per served per-vessel
48
+ // repair row — no grouped shape is served today, and the sum keeps the
49
+ // policy honest if one ever returns (night-policy.test.ts pins the cost
50
+ // model). A misfit row does not stop the walk: a later smaller row may
51
+ // still fit, and the misfit stays pending for the Governor either way.
52
+ let spent = 0;
53
+ for (const proposal of proposals) {
54
+ const cost = proposal.scope.vessels.length;
55
+ const launches = proposal.kind === "repair" && bound > 0 && spent + cost <= bound;
56
+ if (launches) {
57
+ launch.push(proposal);
58
+ spent += cost;
59
+ } else {
60
+ pending.push(proposal);
61
+ }
62
+ }
63
+ return { launch, pending };
64
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * The proposal engine: the deterministic Harbor Master. The queue is
3
+ * computed from exactly three inputs — never imagined (the trust spine
4
+ * forbids model-invented proposals):
5
+ *
6
+ * 1. repair — one proposal per vessel marked `pending correction`; the
7
+ * row names that vessel alone (staleness refresh runs first,
8
+ * exactly like `chart.read`);
9
+ * 2. gap — per charted vessel with no recorded behavior and/or no
10
+ * charted light (both signals read from the index, never from
11
+ * parsed sheets — design.md, decision 1);
12
+ * 3. new-land — landscape entries absent from the last-survey snapshot,
13
+ * compared only while the chart index hash is unchanged.
14
+ *
15
+ * Ranking: repair rows order among themselves by the shared rank — direct
16
+ * cross-vessel charted fan-in, ties by vessel id (../fan-in.ts) — before
17
+ * the kind rank resolves against new-land and gap: repair > new-land > gap,
18
+ * then evidence size, then evidence key (design.md, decision 6). Every
19
+ * proposal carries its kind, evidence keys, anchors, a scope estimate, and
20
+ * a stable fingerprint; fingerprints whose LAST recorded decision is
21
+ * declined are filtered — a refusal holds while that vessel's drift is
22
+ * unchanged and reopens when its stale-entry count changes.
23
+ *
24
+ * Anchor honesty on repair: the per-vessel tree signature hashes the file
25
+ * list, sizes, and mtimes, so individual changed files are not recoverable
26
+ * without storing per-file state. Repair anchors therefore cite a soundable
27
+ * regular file under each drifted vessel's charted paths — `sound.anchor`
28
+ * refutes any non-regular file, so citing the directory itself would refute
29
+ * true drift at the very first sounding of the brief the Cartographer was
30
+ * handed (the new-land precedent: landscapeAnchor cites the manifest or
31
+ * `.git` marker for the same reason).
32
+ */
33
+ import { readdirSync, statSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ import type { Anchor, IndexedEntry, VesselEntry } from "../types";
36
+ import { resolveInsideTarget } from "../perimeter";
37
+ import { readChart } from "../chart-store";
38
+ import { refreshStaleness } from "../staleness";
39
+ import { chargeStaleEntries, compareVesselRank, vesselFanIn } from "../fan-in";
40
+ import { HarborError } from "./errors";
41
+ import { PROPOSAL_KINDS, proposalFingerprint, type ProposalKind } from "./fingerprint";
42
+ import {
43
+ DECISIONS,
44
+ appendDecision,
45
+ lastRecordPerFingerprint,
46
+ readHistory,
47
+ type DecisionRecord,
48
+ type GovernorDecision,
49
+ } from "./history";
50
+ import {
51
+ chartIndexHash,
52
+ landscapeAnchor,
53
+ readSnapshot,
54
+ scanLandscape,
55
+ writeSnapshot,
56
+ type LandscapeEntry,
57
+ } from "./snapshot";
58
+
59
+ /** The scope estimate every proposal carries: who and what an expedition touches. */
60
+ export interface ProposalScope {
61
+ /** Charted vessel ids affected; empty for new land (nothing charted there yet). */
62
+ vessels: string[];
63
+ /** Estimated chart entries the expedition would touch. */
64
+ entries: number;
65
+ /** Estimated soundings (one per entry the verify loop re-sounds). */
66
+ soundings: number;
67
+ }
68
+
69
+ /** One expedition proposal, evidence-complete and fingerprinted. */
70
+ export interface Proposal {
71
+ kind: ProposalKind;
72
+ fingerprint: string;
73
+ /** One deterministic sentence: what justifies the proposal. */
74
+ summary: string;
75
+ /** The fingerprint's evidence keys (`vessel/api`, `repo:vendor/lib`, ...). */
76
+ evidence: string[];
77
+ /** The display path the proposal is about, when it has one (new-land). */
78
+ subject?: string;
79
+ /** Anchors justifying the proposal, citable and soundable. */
80
+ anchors: Anchor[];
81
+ scope: ProposalScope;
82
+ }
83
+
84
+ /** What `expeditions.propose` returns: the ranked, refusal-filtered queue. */
85
+ export interface ProposeResult {
86
+ proposals: Proposal[];
87
+ }
88
+
89
+ const KIND_RANK: Record<ProposalKind, number> = { repair: 0, "new-land": 1, gap: 2 };
90
+
91
+ /** A vessel as read from the index: store metadata included. */
92
+ type IndexedVessel = VesselEntry & { stale: boolean };
93
+
94
+ function sortById<T extends { id: string }>(entries: T[]): T[] {
95
+ return [...entries].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
96
+ }
97
+
98
+ function uniqueAnchors(anchors: Anchor[]): Anchor[] {
99
+ const seen = new Set<string>();
100
+ const out: Anchor[] = [];
101
+ for (const anchor of anchors) {
102
+ const key = JSON.stringify(anchor);
103
+ if (!seen.has(key)) {
104
+ seen.add(key);
105
+ out.push(anchor);
106
+ }
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * A soundable regular file under the given charted path, for repair anchors:
113
+ * the first file in sorted, hidden/node_modules-skipping walk order, so the
114
+ * anchor is deterministic. Undefined when the path escapes the province or
115
+ * holds no regular file — an unsoundable citation is dropped, never faked.
116
+ */
117
+ function soundableAnchorUnder(targetRoot: string, rel: string): Anchor | undefined {
118
+ const stack = [rel.replace(/\/+$/, "")];
119
+ while (stack.length > 0) {
120
+ const current = stack.shift()!;
121
+ if (current.length === 0) continue;
122
+ if (resolveInsideTarget(targetRoot, current) === undefined) continue;
123
+ let stats;
124
+ try {
125
+ stats = statSync(join(targetRoot, current));
126
+ } catch {
127
+ continue; // a path that no longer exists contributes nothing
128
+ }
129
+ if (stats.isFile()) return { type: "file", path: current };
130
+ if (!stats.isDirectory()) continue;
131
+ let names: string[];
132
+ try {
133
+ names = readdirSync(join(targetRoot, current), { withFileTypes: true })
134
+ .filter((de) => de.isFile() || (de.isDirectory() && !de.name.startsWith(".") && de.name !== "node_modules"))
135
+ .map((de) => de.name)
136
+ .sort();
137
+ } catch {
138
+ continue;
139
+ }
140
+ for (const name of names) stack.push(current === "." ? name : `${current}/${name}`);
141
+ }
142
+ return undefined;
143
+ }
144
+
145
+ /**
146
+ * Repair proposals: one per pending-correction vessel, in vessel-id order
147
+ * (the queue sort below applies the shared fan-in rank). The evidence key
148
+ * carries the stale-entry count charged to that vessel (../fan-in.ts, the
149
+ * report's own attribution rule), so a refusal holds while the drift is
150
+ * unchanged and reopens when the count changes.
151
+ */
152
+ function repairProposals(targetRoot: string, entries: IndexedEntry[]): Proposal[] {
153
+ const charged = chargeStaleEntries(entries);
154
+ return sortById(
155
+ entries.filter((e): e is IndexedVessel => e.kind === "vessel" && e.stale === true),
156
+ ).map((vessel) => {
157
+ const staleEntries = charged.get(vessel.id) ?? 0;
158
+ const evidence = [`vessel/${vessel.id}#${staleEntries}`];
159
+ return {
160
+ kind: "repair" as const,
161
+ fingerprint: proposalFingerprint("repair", evidence),
162
+ summary:
163
+ `vessel ${vessel.id} marked pending correction ` +
164
+ `(sources changed under ${vessel.paths.join(", ")})`,
165
+ evidence,
166
+ // A vessel whose charted paths hold no soundable regular file is
167
+ // proposed with its anchor omitted, never faked (the new-land precedent).
168
+ anchors: uniqueAnchors(
169
+ vessel.paths
170
+ .map((path) => soundableAnchorUnder(targetRoot, path))
171
+ .filter((anchor): anchor is Anchor => anchor !== undefined),
172
+ ),
173
+ scope: { vessels: [vessel.id], entries: staleEntries, soundings: staleEntries },
174
+ };
175
+ });
176
+ }
177
+
178
+ /** Gap proposals: one per charted vessel missing its behavior and/or its lights. */
179
+ function gapProposals(entries: IndexedEntry[]): Proposal[] {
180
+ const lightsPerVessel = new Map<string, number>();
181
+ for (const entry of entries) {
182
+ if (entry.kind === "light") {
183
+ lightsPerVessel.set(entry.vessel, (lightsPerVessel.get(entry.vessel) ?? 0) + 1);
184
+ }
185
+ }
186
+ const proposals: Proposal[] = [];
187
+ for (const vessel of sortById(entries.filter((e): e is IndexedVessel => e.kind === "vessel"))) {
188
+ const missing: string[] = [];
189
+ if (vessel.behavior === undefined || vessel.behavior.trim().length === 0) missing.push("behavior");
190
+ if ((lightsPerVessel.get(vessel.id) ?? 0) === 0) missing.push("lights");
191
+ if (missing.length === 0) continue;
192
+ const phrases = missing.map((pass) =>
193
+ pass === "behavior" ? "no recorded behavior" : "no charted light",
194
+ );
195
+ proposals.push({
196
+ kind: "gap",
197
+ fingerprint: proposalFingerprint("gap", missing.map((pass) => `vessel/${vessel.id}#${pass}`)),
198
+ summary: `vessel ${vessel.id} (${vessel.paths.join(", ")}) has ${phrases.join(" and ")}`,
199
+ evidence: missing.map((pass) => `vessel/${vessel.id}#${pass}`),
200
+ anchors: vessel.anchors,
201
+ scope: { vessels: [vessel.id], entries: missing.length, soundings: missing.length },
202
+ });
203
+ }
204
+ return proposals;
205
+ }
206
+
207
+ /** New-land proposals: landscape present now but absent from the last-survey snapshot. */
208
+ function newLandProposals(targetRoot: string, absent: LandscapeEntry[]): Proposal[] {
209
+ return absent.map((entry) => {
210
+ const evidence = [`${entry.kind}:${entry.path}`];
211
+ return {
212
+ kind: "new-land" as const,
213
+ fingerprint: proposalFingerprint("new-land", evidence),
214
+ summary:
215
+ `${entry.kind === "repo" ? "repository" : "manifest"} ${entry.path} is present in the province ` +
216
+ "but absent from the last-survey snapshot",
217
+ evidence,
218
+ subject: entry.path,
219
+ anchors: [landscapeAnchor(targetRoot, entry)],
220
+ scope: { vessels: [], entries: 0, soundings: 0 },
221
+ };
222
+ });
223
+ }
224
+
225
+ /**
226
+ * `expeditions.propose`: compute the ranked queue. Refreshes staleness
227
+ * first (chart.read semantics), lazily establishes or refreshes the
228
+ * landscape snapshot, and filters fingerprints whose last decision is
229
+ * declined. Purely deterministic: no timestamps participate, so two runs
230
+ * over an unchanged province return the same queue.
231
+ */
232
+ export function computeProposals(
233
+ targetRoot: string,
234
+ options: { includeDeclined?: boolean } = {},
235
+ ): ProposeResult {
236
+ refreshStaleness(targetRoot);
237
+ const entries = readChart(targetRoot);
238
+
239
+ // Landscape vs snapshot. Snapshot first established on a chart with none:
240
+ // the baseline, and no new-land — there is no earlier survey to differ
241
+ // from. Index hash changed since the snapshot: a survey stood, refresh to
242
+ // the current landscape. Hash unchanged: compare, and propose the absent.
243
+ const stored = readSnapshot(targetRoot);
244
+ const currentHash = chartIndexHash(targetRoot);
245
+ let newLand: LandscapeEntry[] = [];
246
+ if (stored === null) {
247
+ writeSnapshot(targetRoot, { indexHash: currentHash, landscape: scanLandscape(targetRoot) });
248
+ } else if (stored.indexHash === currentHash) {
249
+ const known = new Set(stored.landscape.map((e) => `${e.kind}:${e.path}`));
250
+ newLand = scanLandscape(targetRoot).filter((e) => !known.has(`${e.kind}:${e.path}`));
251
+ } else {
252
+ writeSnapshot(targetRoot, { indexHash: currentHash, landscape: scanLandscape(targetRoot) });
253
+ }
254
+
255
+ const fanIn = vesselFanIn(entries);
256
+ const proposals: Proposal[] = [
257
+ ...repairProposals(targetRoot, entries),
258
+ ...newLandProposals(targetRoot, newLand),
259
+ ...gapProposals(entries),
260
+ ];
261
+ proposals.sort((a, b) => {
262
+ // Repair rows order among themselves by the shared rank — fan-in
263
+ // descending, vessel id ascending, the row's single vessel compared —
264
+ // before the kind rank resolves against new-land and gap.
265
+ if (a.kind === "repair" && b.kind === "repair") {
266
+ return compareVesselRank(a.scope.vessels[0], b.scope.vessels[0], fanIn);
267
+ }
268
+ return (
269
+ KIND_RANK[a.kind] - KIND_RANK[b.kind] ||
270
+ b.evidence.length - a.evidence.length ||
271
+ (a.evidence[0] < b.evidence[0] ? -1 : a.evidence[0] > b.evidence[0] ? 1 : 0)
272
+ );
273
+ });
274
+
275
+ if (options.includeDeclined === true) return { proposals };
276
+ // Refusal filtering keys on `declined` only (the standing rule): an
277
+ // acceptance — Governor's or night-watch's — never filters, and neither
278
+ // does a night-watch `launch-failed` outcome, so a failed launch leaves
279
+ // the proposal queued for retry or the Governor's decision. The LAST
280
+ // record of any kind is the latest word on the fingerprint.
281
+ const declined = new Set(
282
+ [...lastRecordPerFingerprint(readHistory(targetRoot)).values()]
283
+ .filter(
284
+ (record): record is DecisionRecord =>
285
+ "decision" in record && record.decision === "declined",
286
+ )
287
+ .map((record) => record.fingerprint),
288
+ );
289
+ return { proposals: proposals.filter((p) => !declined.has(p.fingerprint)) };
290
+ }
291
+
292
+ /**
293
+ * `expeditions.decide`: record the Governor's decision on a proposal the
294
+ * queue currently computes (declined proposals stay computable — the
295
+ * Governor may overturn a refusal while the evidence is unchanged). An
296
+ * unknown fingerprint is rejected: deciding on a proposal that does not
297
+ * exist would write an unverifiable row into the history.
298
+ */
299
+ export function decide(
300
+ targetRoot: string,
301
+ fingerprint: string,
302
+ decision: GovernorDecision,
303
+ ): DecisionRecord {
304
+ if ((DECISIONS as readonly string[]).includes(decision) === false) {
305
+ throw new HarborError(
306
+ `unknown decision ${JSON.stringify(decision)}; the vocabulary is ${DECISIONS.join(", ")}`,
307
+ );
308
+ }
309
+ if (fingerprint.length === 0) {
310
+ throw new HarborError(
311
+ "a decision needs the proposal's fingerprint, exactly as expeditions.propose returned it",
312
+ );
313
+ }
314
+ const computable = computeProposals(targetRoot, { includeDeclined: true });
315
+ if (!computable.proposals.some((p) => p.fingerprint === fingerprint)) {
316
+ throw new HarborError(
317
+ `unknown proposal fingerprint ${fingerprint}; decide on a proposal the queue currently computes ` +
318
+ "(call expeditions.propose first)",
319
+ );
320
+ }
321
+ return appendDecision(targetRoot, fingerprint, decision);
322
+ }
323
+
324
+ export { PROPOSAL_KINDS };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The manual single-proposal run (openspec/changes/harbor-run): the
3
+ * Governor names one fingerprint from the computed queue and launches
4
+ * exactly that expedition through the external launcher — any kind
5
+ * (repair, gap, new-land), because the Governor's explicit choice
6
+ * overrides the night policy bounds while the policy itself stands.
7
+ *
8
+ * History semantics mirror the watch (accept-then-append-failure): the
9
+ * launch is accepted `by: governor` before spawning; a launcher failure
10
+ * appends `launch-failed` — the latest word — leaving the proposal
11
+ * effectively not-accepted and queued. A launcher is required (a
12
+ * report-only run is a contradiction); an unknown fingerprint is a loud
13
+ * input error that writes nothing.
14
+ */
15
+ import { computeProposals, type Proposal } from "./proposals";
16
+ import { briefFor, launchExpedition, DEFAULT_LAUNCHER_TIMEOUT_MS } from "./launcher";
17
+ import { appendDecision, appendLaunchFailure, GOVERNOR } from "./history";
18
+ import { HarborError } from "./errors";
19
+
20
+ export interface RunOptions {
21
+ /** The fingerprint to launch, exactly as expeditions.propose returned it. */
22
+ fingerprint: string;
23
+ /** The external launcher command (argv template); required — no report-only run. */
24
+ launcher: string;
25
+ /** Per-launch timeout in milliseconds; default 30m. */
26
+ launcherTimeoutMs?: number;
27
+ }
28
+
29
+ /** The run report: the one proposal, and how its launch ended. */
30
+ export interface RunReport {
31
+ proposal: Proposal;
32
+ outcome: "completed" | "launch-failed";
33
+ /** Deterministic failure reason; present iff launch-failed. */
34
+ reason?: string;
35
+ /** The launcher command the run used. */
36
+ launcherCommand: string;
37
+ }
38
+
39
+ /**
40
+ * Launch one named proposal. Throws HarborError for input faults (unknown
41
+ * fingerprint, missing launcher) BEFORE writing any history; a launcher
42
+ * failure is an outcome (recorded, named in the report), never a throw.
43
+ */
44
+ export async function runProposal(targetRoot: string, options: RunOptions): Promise<RunReport> {
45
+ if (typeof options.launcher !== "string" || options.launcher.length === 0) {
46
+ throw new HarborError("run: --launcher is required — a manual run launches; use propose to list");
47
+ }
48
+ const { proposals } = computeProposals(targetRoot);
49
+ const proposal = proposals.find((p) => p.fingerprint === options.fingerprint);
50
+ if (proposal === undefined) {
51
+ throw new HarborError(
52
+ `run: fingerprint ${options.fingerprint} names no proposal in the current queue — run propose and copy the fingerprint exactly`,
53
+ );
54
+ }
55
+
56
+ appendDecision(targetRoot, proposal.fingerprint, "accepted", { by: GOVERNOR });
57
+ const result = await launchExpedition({
58
+ launcher: options.launcher,
59
+ brief: briefFor(targetRoot, proposal),
60
+ timeoutMs: options.launcherTimeoutMs ?? DEFAULT_LAUNCHER_TIMEOUT_MS,
61
+ });
62
+ if (result.ok) {
63
+ return { proposal, outcome: "completed", launcherCommand: options.launcher };
64
+ }
65
+ appendLaunchFailure(targetRoot, proposal.fingerprint, result.reason as string, { by: GOVERNOR });
66
+ return {
67
+ proposal,
68
+ outcome: "launch-failed",
69
+ reason: result.reason,
70
+ launcherCommand: options.launcher,
71
+ };
72
+ }