@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,84 @@
1
+ /**
2
+ * The two per-vessel facts the harbor queue and trust.report both quote, in
3
+ * one leaf so one definition serves both.
4
+ *
5
+ * The rank (openspec/changes/resurvey-queue/specs/harbor/spec.md, "The
6
+ * repair queue is fan-in ranked"): direct cross-vessel charted fan-in, ties
7
+ * broken by vessel id. The harbor queue orders its repair rows by it and
8
+ * trust.report orders its pending-vessel list by the same rank — one rank,
9
+ * one order (the lists themselves differ by settled design).
10
+ *
11
+ * The charge (same delta, "charged by the same attribution the staleness
12
+ * report uses"): per-vessel stale-entry counts, so the queue's evidence and
13
+ * scope name the same number the report's staleness section does.
14
+ *
15
+ * The rank is deliberately a second definition beside chart.neighborhood's,
16
+ * not a unification of it: the neighborhood counts every charted incoming
17
+ * fairway per entry, while this rank counts per vessel and excludes a
18
+ * vessel's fairways to itself — internal traffic says nothing about how much
19
+ * of the rest of the chart hangs from the vessel. The divergence is pinned
20
+ * by the spec; the leaf keeps exactly those two importers.
21
+ *
22
+ * Arithmetic over charted bytes only: no timestamps and no judgment
23
+ * participate, so two computations over an unchanged chart return the same
24
+ * counts and, sorted by the compare below, the same order.
25
+ */
26
+ import type { ChartEntry, IndexedEntry } from "./types";
27
+
28
+ /**
29
+ * Per-vessel direct cross-vessel charted fan-in: the count of charted
30
+ * fairways whose target vessel is that vessel and whose source vessel is a
31
+ * different one. Fairway endpoints are vessel ids — a fairway from an id
32
+ * with no charted vessel entry still counts, the chart is the truth, not
33
+ * the vessel list. A vessel with no incoming cross-vessel fairway is absent
34
+ * from the map and ranks zero wherever it is read.
35
+ */
36
+ export function vesselFanIn(entries: ReadonlyArray<ChartEntry>): Map<string, number> {
37
+ const fanIn = new Map<string, number>();
38
+ for (const entry of entries) {
39
+ if (entry.kind !== "fairway") continue;
40
+ if (entry.from === entry.to) continue; // intra-vessel: no cross-vessel fan-in
41
+ fanIn.set(entry.to, (fanIn.get(entry.to) ?? 0) + 1);
42
+ }
43
+ return fanIn;
44
+ }
45
+
46
+ /**
47
+ * The rank order over vessel ids, as the compare the two importers sort
48
+ * with: fan-in descending, ties broken by vessel id ascending — the queue's
49
+ * voice. A count missing from the map ranks zero, so a detached vessel is
50
+ * ordered by the tie-break alone instead of dropping out.
51
+ */
52
+ export function compareVesselRank(a: string, b: string, fanIn: Map<string, number>): number {
53
+ const fa = fanIn.get(a) ?? 0;
54
+ const fb = fanIn.get(b) ?? 0;
55
+ if (fa !== fb) return fb - fa;
56
+ return a < b ? -1 : a > b ? 1 : 0;
57
+ }
58
+
59
+ /**
60
+ * Charge every stale entry to the pending-correction vessel(s) it hangs
61
+ * from, read from the index's own stale flags: a vessel entry charges its
62
+ * vessel, a stale fairway charges BOTH the vessels it runs between, and
63
+ * every other entry charges its vessel. The refresh recomputes those flags
64
+ * — a drifted vessel stays pending, a reverted one clears — so attribution
65
+ * must come from the chart as it stands now, never from a refresh delta.
66
+ * A pending fairway drags on both its endpoints: once drift is reverted
67
+ * there is no telling which endpoint moved, and over-attribution is the
68
+ * honest direction, so an endpoint that is itself fresh is charged too.
69
+ */
70
+ export function chargeStaleEntries(entries: ReadonlyArray<IndexedEntry>): Map<string, number> {
71
+ const charged = new Map<string, number>();
72
+ for (const entry of entries) {
73
+ if (!entry.stale) continue;
74
+ const bump = (vesselId: string): void => {
75
+ charged.set(vesselId, (charged.get(vesselId) ?? 0) + 1);
76
+ };
77
+ if (entry.kind === "vessel") bump(entry.id);
78
+ else if (entry.kind === "fairway") {
79
+ bump(entry.from);
80
+ bump(entry.to);
81
+ } else bump(entry.vessel);
82
+ }
83
+ return charged;
84
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The chat rendering of the proposal queue and of the night-watch report —
3
+ * the one format the headless CLI posts and the skill's session-start
4
+ * message mirrors (design.md risk: the wording lives here, with a golden
5
+ * test; the skill references it, not copies). Deterministic by
6
+ * construction: the queue in, the same text out, no timestamps. An empty
7
+ * queue renders to the empty string — silence on a still province. The
8
+ * watch report always renders: an invoked watch says what it did, even
9
+ * when what it did was nothing.
10
+ * openspec/changes/harbor-master + openspec/changes/night-watch (harbor
11
+ * capability: the watch report is chat-formatted and deterministic)
12
+ */
13
+ import { formatAnchor } from "../types";
14
+ import type { Proposal, ProposeResult } from "./proposals";
15
+ import type { WatchAction, WatchReport } from "./watch";
16
+ import type { RunReport } from "./run";
17
+
18
+ function scopeLine(proposal: Proposal): string {
19
+ if (proposal.kind === "new-land") {
20
+ // Proposals carry their display path; the evidence-key parse stays only
21
+ // for records stored before `subject` existed (history is append-only).
22
+ const key = proposal.evidence[0] ?? "";
23
+ const path = proposal.subject ?? key.slice(key.indexOf(":") + 1);
24
+ return `full survey of ${path}; no charted vessels there yet`;
25
+ }
26
+ return (
27
+ `vessels ${proposal.scope.vessels.join(", ")} · ` +
28
+ `${proposal.scope.entries} entries · ${proposal.scope.soundings} soundings`
29
+ );
30
+ }
31
+
32
+ /** Render the queue as one postable chat message; "" when the queue is empty. */
33
+ export function renderQueueChat(result: ProposeResult): string {
34
+ const { proposals } = result;
35
+ if (proposals.length === 0) return "";
36
+ const lines: string[] = [];
37
+ lines.push(
38
+ `Portolan harbor — ${proposals.length} expedition ${proposals.length === 1 ? "proposal" : "proposals"} for this province.`,
39
+ "",
40
+ );
41
+ for (const [index, proposal] of proposals.entries()) {
42
+ lines.push(`${index + 1}. ${proposal.kind} — ${proposal.summary}`);
43
+ lines.push(` evidence: ${proposal.anchors.map(formatAnchor).join("; ")}`);
44
+ lines.push(` scope: ${scopeLine(proposal)}`);
45
+ }
46
+ lines.push(
47
+ "",
48
+ "Accept or decline by number — one phrase is enough; the decision is recorded with expeditions.decide.",
49
+ );
50
+ return `${lines.join("\n")}\n`;
51
+ }
52
+
53
+ /** One proposal as the report's pending entries render it: summary, evidence, scope. */
54
+ function proposalLines(proposal: Proposal): string[] {
55
+ return [
56
+ `${proposal.kind} — ${proposal.summary}`,
57
+ ` evidence: ${proposal.anchors.map(formatAnchor).join("; ")}`,
58
+ ` scope: ${scopeLine(proposal)}`,
59
+ ];
60
+ }
61
+
62
+ /** The policy line: the bound, and whether anything could launch at all. */
63
+ function watchPolicyLine(report: WatchReport): string {
64
+ const vessels = report.bound === 1 ? "vessel" : "vessels";
65
+ if (report.bound <= 0) {
66
+ return `policy: auto-repair bound 0 ${vessels} — report-only (harbor.auto_repair_max_vessels unset or zero)`;
67
+ }
68
+ if (report.reportOnly) {
69
+ return `policy: auto-repair bound ${report.bound} ${vessels} — report-only (no --launcher configured)`;
70
+ }
71
+ const command = (report.launcherCommand ?? "").trim().split(/\s+/)[0];
72
+ return `policy: auto-repair bound ${report.bound} ${vessels}; launcher ${command}`;
73
+ }
74
+
75
+ function ranLine(action: WatchAction): string {
76
+ return action.outcome === "completed"
77
+ ? " outcome: completed"
78
+ : ` outcome: launch-failed (${action.reason})`;
79
+ }
80
+
81
+ /**
82
+ * Render the manual run report as one postable chat message: the proposal
83
+ * (kind, summary, evidence, scope), the launcher, and the outcome. Always
84
+ * renders; deterministic — no timestamps, no ambient state. A failure says
85
+ * it stays queued, like the watch report does.
86
+ * openspec/changes/harbor-run (harbor capability: the run report is
87
+ * chat-formatted and deterministic)
88
+ */
89
+ export function renderRunChat(report: RunReport): string {
90
+ const lines: string[] = [];
91
+ lines.push(
92
+ "Portolan harbor run — one expedition by the Governor's hand.",
93
+ "",
94
+ ...proposalLines(report.proposal),
95
+ `launcher: ${(report.launcherCommand ?? "").trim().split(/\s+/)[0]}`,
96
+ );
97
+ if (report.outcome === "completed") {
98
+ lines.push("outcome: completed");
99
+ } else {
100
+ lines.push(
101
+ `outcome: launch-failed (${report.reason})`,
102
+ "note: recorded in history; the proposal stays queued for the Governor",
103
+ );
104
+ }
105
+ return `${lines.join("\n")}\n`;
106
+ }
107
+
108
+ /** The night-watch report in chat form: what ran (with outcomes), what was
109
+ * left pending (with evidence summaries), and any launcher failures. Always
110
+ * renders — even a watch that did nothing says so. Deterministic: no
111
+ * timestamps, no ambient state.
112
+ */
113
+ export function renderWatchChat(report: WatchReport): string {
114
+ const completed = report.ran.filter((a) => a.outcome === "completed");
115
+ const failed = report.ran.filter((a) => a.outcome === "launch-failed");
116
+
117
+ const lines: string[] = [];
118
+ lines.push(
119
+ `Portolan night watch — ${completed.length} launched, ${report.pending.length} pending, ${failed.length} failed.`,
120
+ "",
121
+ watchPolicyLine(report),
122
+ );
123
+
124
+ lines.push("ran:");
125
+ if (completed.length === 0) {
126
+ lines.push(
127
+ failed.length > 0
128
+ ? "none — every attempted launch failed (see launch failures)"
129
+ : "none"
130
+ );
131
+ }
132
+ for (const [index, action] of completed.entries()) {
133
+ lines.push(`${index + 1}. ${action.proposal.kind} — ${action.proposal.summary}`);
134
+ lines.push(ranLine(action));
135
+ }
136
+
137
+ lines.push("pending:");
138
+ if (report.pending.length === 0) lines.push("none");
139
+ for (const [index, proposal] of report.pending.entries()) {
140
+ for (const [lineNo, line] of proposalLines(proposal).entries()) {
141
+ lines.push(lineNo === 0 ? `${index + 1}. ${line}` : line);
142
+ }
143
+ }
144
+
145
+ lines.push("launch failures:");
146
+ if (failed.length === 0) lines.push("none");
147
+ for (const [index, action] of failed.entries()) {
148
+ lines.push(`${index + 1}. ${action.proposal.kind} — ${action.proposal.summary}`);
149
+ lines.push(` failure: ${action.reason}`);
150
+ lines.push(" note: recorded in history; the proposal stays queued for the Governor");
151
+ }
152
+
153
+ return `${lines.join("\n")}\n`;
154
+ }
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The headless Harbor Master CLI — the scheduler's entry (Portolan ships no
4
+ * daemon; the harbor schedule setting only documents an external cadence).
5
+ *
6
+ * bun core/src/harbor/cli.ts propose [--target <province root>] [--format chat|json]
7
+ * bun core/src/harbor/cli.ts watch [--target <province root>] [--format chat|json]
8
+ * [--launcher "<command>"] [--launcher-timeout <duration>]
9
+ * bun core/src/harbor/cli.ts run --fingerprint <fp> --launcher "<command>"
10
+ * [--target <province root>] [--format chat|json]
11
+ * [--launcher-timeout <duration>]
12
+ *
13
+ * `propose` computes the deterministic queue and prints it: `--format chat`
14
+ * is the postable chat rendering (nothing at all on an empty queue),
15
+ * `--format json` (the default) is the machine queue.
16
+ *
17
+ * `watch` (openspec/changes/night-watch) applies the night policy to the
18
+ * queue, launches what qualifies through the external launcher, records the
19
+ * auto-accepts (`by night-watch`) and any launch failures in the harbor
20
+ * history, and prints one chat-formatted watch report (`--format chat` is
21
+ * the default; `--format json` is the machine report). A launch failure is
22
+ * receipted, not fatal: the exit stays 0 so the scheduler always gets the
23
+ * report; the failure is named in it and appended to the history.
24
+ *
25
+ * Both commands are deterministic — two runs over an unchanged province
26
+ * emit identical output. Settings warnings print to stderr so stdout stays
27
+ * postable; any failure (no chart, corrupt settings, bad arguments) exits 1
28
+ * with the error on stderr.
29
+ * openspec/changes/harbor-master + openspec/changes/night-watch (harbor
30
+ * capability: scheduling is an explicit setting, off by default / the
31
+ * night watch acts only on invocation)
32
+ */
33
+ import { parseArgs } from "node:util";
34
+ import { resolve } from "node:path";
35
+ import { renderQueueChat, renderWatchChat, renderRunChat } from "./chat-format";
36
+ import { computeProposals } from "./proposals";
37
+ import { readSettings } from "./settings";
38
+ import { runWatch } from "./watch";
39
+ import { runProposal } from "./run";
40
+ import { DEFAULT_LAUNCHER_TIMEOUT_MS, formatDuration, parseDurationMs } from "./launcher";
41
+
42
+ function fail(message: string): never {
43
+ console.error(message);
44
+ process.exit(1);
45
+ }
46
+
47
+ const USAGE = "usage: bun core/src/harbor/cli.ts <propose|watch|run> [flags] — try --help";
48
+
49
+ const HELP = `Portolan harbor CLI — the scheduler's entry (no daemon).
50
+
51
+ usage:
52
+ bun core/src/harbor/cli.ts propose [--target <province root>] [--format chat|json]
53
+ bun core/src/harbor/cli.ts watch [--target <province root>] [--format chat|json] \\
54
+ [--launcher "<command>"] [--launcher-timeout <duration>]
55
+ bun core/src/harbor/cli.ts run --fingerprint <fp> --launcher "<command>" \\
56
+ [--target <province root>] [--format chat|json] \\
57
+ [--launcher-timeout <duration>]
58
+
59
+ commands:
60
+ propose compute the deterministic expedition queue and print it
61
+ watch apply the night policy (harbor.auto_repair_max_vessels), launch
62
+ what qualifies through the external launcher, record the history,
63
+ and print the chat-formatted watch report
64
+ run launch ONE named proposal by the Governor's explicit choice —
65
+ any kind (repair, gap, new-land); records the acceptance
66
+ (by: governor) and any launch failure in the history
67
+
68
+ flags:
69
+ --target <province root> the province to operate on (default: working directory)
70
+ --format <chat|json> output format; propose defaults to json, watch and run to chat
71
+ --fingerprint <fp> run only: the proposal's fingerprint, exactly as propose returned
72
+ --launcher "<command>" watch/run: the external launcher to spawn; the
73
+ proposal brief arrives as JSON on stdin; absent means
74
+ report-only for the watch (nothing is launched) and is a
75
+ usage error for run
76
+ --launcher-timeout <duration>
77
+ watch/run: how long one launch may run
78
+ (default: ${formatDuration(DEFAULT_LAUNCHER_TIMEOUT_MS)}); e.g. 45s, 30m, 1h
79
+ --help print this help`;
80
+
81
+ let parsed;
82
+ try {
83
+ parsed = parseArgs({
84
+ allowPositionals: true,
85
+ options: {
86
+ target: { type: "string", default: process.cwd() },
87
+ format: { type: "string" },
88
+ fingerprint: { type: "string" },
89
+ launcher: { type: "string" },
90
+ "launcher-timeout": { type: "string" },
91
+ help: { type: "boolean", default: false },
92
+ },
93
+ });
94
+ } catch (err) {
95
+ fail((err as Error).message);
96
+ }
97
+ const { values, positionals } = parsed;
98
+
99
+ if (values.help) {
100
+ console.log(HELP);
101
+ process.exit(0);
102
+ }
103
+
104
+ const COMMANDS = ["propose", "watch", "run"] as const;
105
+ if (positionals.length !== 1 || !(COMMANDS as readonly string[]).includes(positionals[0]!)) {
106
+ fail(USAGE);
107
+ }
108
+ const command = positionals[0] as (typeof COMMANDS)[number];
109
+
110
+ // The launcher flags belong to the launching commands alone; accepting them
111
+ // silently elsewhere would be a false promise.
112
+ if (command === "propose" && (values.launcher !== undefined || values["launcher-timeout"] !== undefined)) {
113
+ fail("--launcher and --launcher-timeout belong to the watch and run commands");
114
+ }
115
+
116
+ // A manual run launches — the fingerprint is its whole identity (a missing
117
+ // launcher is run.ts's own loud input error, the one message for the rule).
118
+ if (command === "run") {
119
+ if (values.fingerprint === undefined) fail("run: --fingerprint is required — copy it from propose");
120
+ }
121
+
122
+ const format = values.format ?? (command === "propose" ? "json" : "chat");
123
+ if (format !== "chat" && format !== "json") {
124
+ fail(`--format must be "chat" or "json", got ${JSON.stringify(format)}`);
125
+ }
126
+
127
+ const targetRoot = resolve(values.target as string);
128
+
129
+ // The settings file is read for its warnings and the watch's auto-repair
130
+ // bound; nothing is interpreted from harbor.schedule — the scheduler owns
131
+ // timing. (runWatch reads the settings again for the bound itself.)
132
+ const { warnings } = readSettings(targetRoot);
133
+ for (const warning of warnings) console.error(warning);
134
+
135
+ function parseTimeout(): number {
136
+ if (values["launcher-timeout"] === undefined) return DEFAULT_LAUNCHER_TIMEOUT_MS;
137
+ try {
138
+ return parseDurationMs(values["launcher-timeout"]);
139
+ } catch (err) {
140
+ return fail((err as Error).message);
141
+ }
142
+ }
143
+
144
+ if (command === "propose") {
145
+ try {
146
+ const result = computeProposals(targetRoot);
147
+ process.stdout.write(
148
+ format === "chat" ? renderQueueChat(result) : `${JSON.stringify(result, null, 2)}\n`,
149
+ );
150
+ } catch (err) {
151
+ fail(String((err as Error).message));
152
+ }
153
+ } else if (command === "watch") {
154
+ try {
155
+ const report = await runWatch(targetRoot, {
156
+ launcher: values.launcher,
157
+ launcherTimeoutMs: parseTimeout(),
158
+ });
159
+ process.stdout.write(
160
+ format === "chat" ? renderWatchChat(report) : `${JSON.stringify(report, null, 2)}\n`,
161
+ );
162
+ } catch (err) {
163
+ fail(String((err as Error).message));
164
+ }
165
+ } else {
166
+ try {
167
+ const report = await runProposal(targetRoot, {
168
+ fingerprint: values.fingerprint as string,
169
+ launcher: values.launcher as string,
170
+ launcherTimeoutMs: parseTimeout(),
171
+ });
172
+ process.stdout.write(
173
+ format === "chat" ? renderRunChat(report) : `${JSON.stringify(report, null, 2)}\n`,
174
+ );
175
+ } catch (err) {
176
+ fail(String((err as Error).message));
177
+ }
178
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The Harbor Master's error type. One shape for every harbor rejection —
3
+ * corrupt snapshot/history files, unknown decision vocabulary, deciding on
4
+ * a fingerprint the queue does not compute — so the registry boundary can
5
+ * surface any of them verbatim as a tool error (same discipline as
6
+ * LogError / SoundingError in the tool layer).
7
+ * openspec/changes/harbor-master
8
+ */
9
+ export class HarborError extends Error {
10
+ constructor(message: string) {
11
+ super(message);
12
+ this.name = "HarborError";
13
+ }
14
+ }
15
+
16
+ /** A settings file that cannot be honored as written. */
17
+ export class SettingsError extends HarborError {
18
+ constructor(message: string) {
19
+ super(`settings: ${message}`);
20
+ this.name = "SettingsError";
21
+ }
22
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The proposal fingerprint: a stable identity for one expedition proposal,
3
+ * computed from exactly its kind and its evidence keys (design.md,
4
+ * decision 3). Timestamps are excluded on purpose — unchanged evidence
5
+ * keeps the fingerprint stable, which is what makes refusal-respect
6
+ * possible; drift growth or new land changes the evidence set and therefore
7
+ * the fingerprint, which is what reopens a declined proposal.
8
+ *
9
+ * Evidence keys are plain strings owned by the proposal engine
10
+ * (`vessel/<id>#<stale-entry-count>` for drift — the drift-sensitive count
11
+ * reopens a declined vessel when its drift changes, `vessel/<id>#<pass>`
12
+ * for gaps, `<kind>:<path>` for landscape entries).
13
+ */
14
+ import { createHash } from "node:crypto";
15
+
16
+ /** The three proposal kinds, in the order the harbor capability names them. */
17
+ export const PROPOSAL_KINDS = ["repair", "gap", "new-land"] as const;
18
+
19
+ export type ProposalKind = (typeof PROPOSAL_KINDS)[number];
20
+
21
+ /**
22
+ * sha256 over `kind` + the sorted, deduplicated evidence keys. Order of the
23
+ * input keys is irrelevant — the same evidence always yields the same
24
+ * fingerprint; any change to the evidence set yields a different one.
25
+ */
26
+ export function proposalFingerprint(kind: ProposalKind, evidenceKeys: string[]): string {
27
+ const keys = [...new Set(evidenceKeys)].sort();
28
+ return createHash("sha256").update(`${kind}\n${keys.join("\n")}`).digest("hex");
29
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The decision history: the Governor's accepted/declined verdicts on
3
+ * expedition proposals — and the night watch's records — stored append-only
4
+ * as JSONL under `<target>/.portolan/harbor/history.jsonl`: one line per
5
+ * record, same shape discipline as the ship's log (design.md, decision 4).
6
+ * Dedupe reads the LAST record per fingerprint, so an overturned refusal is
7
+ * the Governor's latest will, and nothing already written is ever altered.
8
+ *
9
+ * Night-watch records (openspec/changes/night-watch, design decision 3):
10
+ * an auto-executed launch appends `accepted` with `by: "night-watch"`
11
+ * BEFORE the launcher runs; if the launch then fails, a `launch-failed`
12
+ * outcome is appended after it — accept-then-append-failure keeps the audit
13
+ * trail honest, and because the failure is the last word on that
14
+ * fingerprint, a failed launch leaves the proposal not-accepted and queued.
15
+ */
16
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { HarborError } from "./errors";
19
+ import { harborDir } from "./snapshot";
20
+
21
+ export const HISTORY_FILE = "history.jsonl";
22
+
23
+ /** The closed decision vocabulary. */
24
+ export const DECISIONS = ["accepted", "declined"] as const;
25
+
26
+ export type GovernorDecision = (typeof DECISIONS)[number];
27
+
28
+ /** Attribution the night watch writes; session decisions carry no `by`. */
29
+ export const NIGHT_WATCH = "night-watch";
30
+
31
+ /** Attribution the manual `run` command writes: the Governor's own launch. */
32
+ export const GOVERNOR = "governor";
33
+
34
+ /** Who may append a launch outcome (the closed attribution vocabulary). */
35
+ const LAUNCH_ATTRIBUTIONS = new Set([NIGHT_WATCH, GOVERNOR]);
36
+
37
+ /** One recorded decision; `decidedAt` is ISO, like a receipt's `recordedAt`. */
38
+ export interface DecisionRecord {
39
+ fingerprint: string;
40
+ decision: GovernorDecision;
41
+ decidedAt: string;
42
+ /** Who decided; absent = the Governor in session, `night-watch` = the night watch. */
43
+ by?: string;
44
+ }
45
+
46
+ /**
47
+ * A launch outcome: appended after a night-watch acceptance when the
48
+ * external launcher failed (non-zero exit, timeout, or spawn failure). The
49
+ * failed proposal stays queued — the queue filters on `declined` only.
50
+ */
51
+ export interface LaunchOutcomeRecord {
52
+ fingerprint: string;
53
+ outcome: "launch-failed";
54
+ recordedAt: string;
55
+ /** Who launched: the night watch or the Governor's manual run. */
56
+ by: string;
57
+ /** Deterministic reason: exit status, timeout, or spawn failure. */
58
+ reason: string;
59
+ }
60
+
61
+ /** Any record the history file may hold. */
62
+ export type HistoryRecord = DecisionRecord | LaunchOutcomeRecord;
63
+
64
+ /** Where the decision history lives. */
65
+ export function historyFile(targetRoot: string): string {
66
+ return join(harborDir(targetRoot), HISTORY_FILE);
67
+ }
68
+
69
+ function isDecision(record: HistoryRecord): record is DecisionRecord {
70
+ return (record as DecisionRecord).decision !== undefined;
71
+ }
72
+
73
+ function parseLine(line: string, file: string, lineNo: number): HistoryRecord {
74
+ let record: HistoryRecord;
75
+ try {
76
+ record = JSON.parse(line) as HistoryRecord;
77
+ if (isDecision(record)) {
78
+ if (
79
+ typeof record?.fingerprint !== "string" ||
80
+ (record.decision !== "accepted" && record.decision !== "declined") ||
81
+ typeof record?.decidedAt !== "string" ||
82
+ (record.by !== undefined && typeof record.by !== "string")
83
+ ) {
84
+ throw new Error("not a decision");
85
+ }
86
+ } else if (
87
+ record?.outcome !== "launch-failed" ||
88
+ typeof record?.fingerprint !== "string" ||
89
+ typeof record?.recordedAt !== "string" ||
90
+ typeof record?.by !== "string" ||
91
+ typeof record?.reason !== "string" ||
92
+ record.reason.length === 0
93
+ ) {
94
+ throw new Error("not a launch outcome");
95
+ }
96
+ return record;
97
+ } catch (err) {
98
+ const why = err instanceof Error ? err.message : "not a decision";
99
+ throw new HarborError(`history: corrupt decision history ${file} line ${lineNo}: ${why}`);
100
+ }
101
+ }
102
+
103
+ /** Every recorded history row, oldest first. Empty when no history exists. */
104
+ export function readHistory(targetRoot: string): HistoryRecord[] {
105
+ const file = historyFile(targetRoot);
106
+ if (!existsSync(file)) return [];
107
+ return readFileSync(file, "utf8")
108
+ .split("\n")
109
+ .filter((line) => line.trim().length > 0)
110
+ .map((line, index) => parseLine(line, file, index + 1));
111
+ }
112
+
113
+ /** Every recorded Governor/night-watch decision (launch outcomes excluded), oldest first. */
114
+ export function readDecisions(targetRoot: string): DecisionRecord[] {
115
+ return readHistory(targetRoot).filter(isDecision);
116
+ }
117
+
118
+ /** The last decision per fingerprint — a map where the latest will wins. */
119
+ export function lastDecisionPerFingerprint(records: DecisionRecord[]): Map<string, DecisionRecord> {
120
+ const last = new Map<string, DecisionRecord>();
121
+ for (const record of records) last.set(record.fingerprint, record);
122
+ return last;
123
+ }
124
+
125
+ /** The last record of any kind per fingerprint — the latest word wins. */
126
+ export function lastRecordPerFingerprint(records: HistoryRecord[]): Map<string, HistoryRecord> {
127
+ const last = new Map<string, HistoryRecord>();
128
+ for (const record of records) last.set(record.fingerprint, record);
129
+ return last;
130
+ }
131
+
132
+ /** Append one decision; returns the record as stored. Never rewrites a line. */
133
+ export function appendDecision(
134
+ targetRoot: string,
135
+ fingerprint: string,
136
+ decision: GovernorDecision,
137
+ options: { by?: string } = {},
138
+ ): DecisionRecord {
139
+ if ((DECISIONS as readonly string[]).includes(decision) === false) {
140
+ throw new HarborError(
141
+ `history: unknown decision ${JSON.stringify(decision)}; the vocabulary is ${DECISIONS.join(", ")}`,
142
+ );
143
+ }
144
+ const record: DecisionRecord = { fingerprint, decision, decidedAt: new Date().toISOString() };
145
+ if (options.by !== undefined) record.by = options.by;
146
+ mkdirSync(harborDir(targetRoot), { recursive: true });
147
+ appendFileSync(historyFile(targetRoot), `${JSON.stringify(record)}\n`);
148
+ return record;
149
+ }
150
+
151
+ /**
152
+ * Append a launch failure, attributed to the night watch by default or to
153
+ * the Governor's manual `run` when `{ by: "governor" }`; the deterministic
154
+ * `reason` names the failure for the history reader and the report alike.
155
+ */
156
+ export function appendLaunchFailure(
157
+ targetRoot: string,
158
+ fingerprint: string,
159
+ reason: string,
160
+ options: { by?: string } = {},
161
+ ): LaunchOutcomeRecord {
162
+ const by = options.by ?? NIGHT_WATCH;
163
+ if (!LAUNCH_ATTRIBUTIONS.has(by)) {
164
+ throw new HarborError(
165
+ `history: launch outcome attribution must be ${NIGHT_WATCH} or ${GOVERNOR}, got ${JSON.stringify(by)}`,
166
+ );
167
+ }
168
+ const record: LaunchOutcomeRecord = {
169
+ fingerprint,
170
+ outcome: "launch-failed",
171
+ recordedAt: new Date().toISOString(),
172
+ by,
173
+ reason,
174
+ };
175
+ mkdirSync(harborDir(targetRoot), { recursive: true });
176
+ appendFileSync(historyFile(targetRoot), `${JSON.stringify(record)}\n`);
177
+ return record;
178
+ }