@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,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
|
+
}
|