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