@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,215 @@
1
+ /**
2
+ * The ship's log: an append-only receipt for every command an expedition
3
+ * runs, stored as JSONL under `<target>/.portolan/log.jsonl`. Receipt ids
4
+ * are monotonic (`r1`, `r2`, ...) and citable as chart anchors. Appends are
5
+ * serialized by a `.portolan/log.lock` exclusive-create lock — the MCP
6
+ * server and the harbor CLI are separate processes over one log, and two
7
+ * readers computing `max+1` at once would mint duplicate ids. Existing
8
+ * receipts are never altered or removed — the only write any probe tool
9
+ * performs lands here, inside the `.portolan` perimeter.
10
+ * specs/tools/spec.md
11
+ */
12
+ import {
13
+ appendFileSync,
14
+ closeSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ openSync,
18
+ readFileSync,
19
+ rmSync,
20
+ statSync,
21
+ unlinkSync,
22
+ } from "node:fs";
23
+ import { join } from "node:path";
24
+ import type { Anchor } from "../types";
25
+
26
+ export const SHIPS_LOG_FILE = "log.jsonl";
27
+ export const LOG_LOCK_FILE = "log.lock";
28
+
29
+ /** How long an append waits out a live lock before naming the contention. */
30
+ const LOCK_DEADLINE_MS = 2_000;
31
+ /** A lock older than this belongs to a crashed process and is stolen. */
32
+ const LOCK_STALE_MS = 10_000;
33
+
34
+ /** Where the ship's log lives for a given target root. */
35
+ export function logFile(targetRoot: string): string {
36
+ return join(targetRoot, ".portolan", SHIPS_LOG_FILE);
37
+ }
38
+
39
+ export interface Receipt {
40
+ /** Stable, monotonic, citable as an anchor: r1, r2, ... */
41
+ id: string;
42
+ /** Command identity, e.g. `sweep pattern=UserService`. */
43
+ command: string;
44
+ /** What was surveyed, e.g. the module or path scope. */
45
+ scope?: string;
46
+ /** Outcome, e.g. `ok: 3 chunks` or `error: missing binary ctags`. */
47
+ outcome: string;
48
+ /** ISO timestamp of the append. */
49
+ recordedAt: string;
50
+ meta?: Record<string, unknown>;
51
+ }
52
+
53
+ export type ReceiptInput = Omit<Receipt, "id" | "recordedAt"> & {
54
+ /** Callers normally let the log assign ids; a replayed id is checked. */
55
+ id?: string;
56
+ };
57
+
58
+ export interface ReceiptFilter {
59
+ command?: string;
60
+ scope?: string;
61
+ outcome?: string;
62
+ }
63
+
64
+ export class LogError extends Error {
65
+ constructor(message: string) {
66
+ super(`log: ${message}`);
67
+ this.name = "LogError";
68
+ }
69
+ }
70
+
71
+ function parseLine(line: string, file: string, lineNo: number): Receipt {
72
+ try {
73
+ const receipt = JSON.parse(line) as Receipt;
74
+ if (
75
+ typeof receipt?.id !== "string" ||
76
+ typeof receipt.command !== "string" ||
77
+ typeof receipt.outcome !== "string" ||
78
+ typeof receipt.recordedAt !== "string"
79
+ ) {
80
+ throw new Error("not a receipt");
81
+ }
82
+ return receipt;
83
+ } catch {
84
+ throw new LogError(`corrupt ship's log ${file} line ${lineNo}: not a receipt`);
85
+ }
86
+ }
87
+
88
+ function readAll(targetRoot: string): Receipt[] {
89
+ const file = logFile(targetRoot);
90
+ if (!existsSync(file)) return [];
91
+ return readFileSync(file, "utf8")
92
+ .split("\n")
93
+ .filter((line) => line.trim().length > 0)
94
+ .map((line, index) => parseLine(line, file, index + 1));
95
+ }
96
+
97
+ function maxSequence(receipts: Receipt[]): number {
98
+ let max = 0;
99
+ for (const receipt of receipts) {
100
+ const match = /^r(\d+)$/.exec(receipt.id);
101
+ if (match !== null) max = Math.max(max, Number(match[1]));
102
+ }
103
+ return max;
104
+ }
105
+
106
+ /**
107
+ * Hold the log's exclusive-create lock across one read-compute-append
108
+ * cycle, so concurrent processes (MCP server + harbor CLI) cannot mint the
109
+ * same receipt id. A lock left by a crashed process is stolen once it is
110
+ * older than LOCK_STALE_MS; a live contention is waited out briefly and
111
+ * then named loudly — an append never proceeds unserialized.
112
+ */
113
+ function withLogLock<T>(targetRoot: string, fn: () => T): T {
114
+ const portDir = join(targetRoot, ".portolan");
115
+ const lockPath = join(portDir, LOG_LOCK_FILE);
116
+ mkdirSync(portDir, { recursive: true });
117
+ const deadline = Date.now() + LOCK_DEADLINE_MS;
118
+ let fd: number;
119
+ for (;;) {
120
+ try {
121
+ fd = openSync(lockPath, "wx");
122
+ break;
123
+ } catch (err) {
124
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
125
+ try {
126
+ if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
127
+ rmSync(lockPath, { force: true });
128
+ continue; // stolen — try to take it immediately
129
+ }
130
+ } catch {
131
+ continue; // the lock vanished between stat and steal — retry
132
+ }
133
+ if (Date.now() > deadline) {
134
+ throw new LogError(
135
+ `the ship's log is locked: ${lockPath} is held by another append (or is stuck)` +
136
+ ` — delete the stale lock to proceed`,
137
+ );
138
+ }
139
+ Bun.sleepSync(25);
140
+ }
141
+ }
142
+ try {
143
+ return fn();
144
+ } finally {
145
+ closeSync(fd);
146
+ unlinkSync(lockPath);
147
+ }
148
+ }
149
+
150
+ /** `log.append`: append one receipt per executed command; returns it. */
151
+ export function appendReceipt(targetRoot: string, input: ReceiptInput): Receipt {
152
+ return withLogLock(targetRoot, () => {
153
+ const file = logFile(targetRoot);
154
+ const existing = readAll(targetRoot);
155
+ const next = maxSequence(existing) + 1;
156
+
157
+ let id = `r${next}`;
158
+ if (input.id !== undefined) {
159
+ if (existing.some((receipt) => receipt.id === input.id)) {
160
+ // Append-only: re-appending an existing id is an attempted alteration.
161
+ throw new LogError(
162
+ `the ship's log is append-only: receipt ${input.id} already exists and cannot be altered or replaced`,
163
+ );
164
+ }
165
+ const match = /^r(\d+)$/.exec(input.id);
166
+ if (match === null || Number(match[1]) !== next) {
167
+ throw new LogError(
168
+ `receipt ids are assigned by the log: expected r${next}, got ${input.id}`,
169
+ );
170
+ }
171
+ id = input.id;
172
+ }
173
+
174
+ const receipt: Receipt = {
175
+ id,
176
+ command: input.command,
177
+ ...(input.scope !== undefined ? { scope: input.scope } : {}),
178
+ outcome: input.outcome,
179
+ recordedAt: new Date().toISOString(),
180
+ ...(input.meta !== undefined ? { meta: input.meta } : {}),
181
+ };
182
+ mkdirSync(join(targetRoot, ".portolan"), { recursive: true });
183
+ // One receipt per line, written with a single atomic append.
184
+ appendFileSync(file, `${JSON.stringify(receipt)}\n`);
185
+ return receipt;
186
+ });
187
+ }
188
+
189
+ /** `log.read`: resolve one receipt by id; undefined when absent. */
190
+ export function readReceipt(targetRoot: string, id: string): Receipt | undefined {
191
+ return readAll(targetRoot).find((receipt) => receipt.id === id);
192
+ }
193
+
194
+ /** `log.read`: receipts matching every provided filter field exactly. */
195
+ export function readReceipts(targetRoot: string, filter: ReceiptFilter = {}): Receipt[] {
196
+ return readAll(targetRoot).filter(
197
+ (receipt) =>
198
+ (filter.command === undefined || receipt.command === filter.command) &&
199
+ (filter.scope === undefined || receipt.scope === filter.scope) &&
200
+ (filter.outcome === undefined || receipt.outcome === filter.outcome),
201
+ );
202
+ }
203
+
204
+ /** The anchor form a chart entry cites when it references a receipt. */
205
+ export function receiptAnchor(id: string): Anchor {
206
+ return { type: "receipt", id };
207
+ }
208
+
209
+ /** Resolve a chart anchor that cites a receipt id back to the receipt. */
210
+ export function resolveReceiptAnchor(targetRoot: string, anchor: Anchor): Receipt | undefined {
211
+ if (anchor.type !== "receipt") {
212
+ throw new LogError(`not a receipt anchor: ${JSON.stringify(anchor)}`);
213
+ }
214
+ return readReceipt(targetRoot, anchor.id);
215
+ }