@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,232 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Fleet Review</title>
7
+ <style>
8
+ :root{
9
+ --paper:#efe6d0; --panel:#e9dec4; --ink:#33291c; --ink-soft:#6b5a3e;
10
+ --frame:#6b5a3e; --accent:#7a2f22; --water:#cfe0da;
11
+ --lane-measured:#2c5a68; --lane-charted:#5d8895;
12
+ --font:Georgia,'Iowan Old Style',serif;
13
+ }
14
+ *{box-sizing:border-box}
15
+ body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--font);
16
+ padding:34px 40px 60px;font-size:15px}
17
+ h1{font-variant:small-caps;letter-spacing:.16em;margin:0 0 4px;font-size:26px}
18
+ .sub{color:var(--ink-soft);margin:0 0 26px}
19
+ table{border-collapse:collapse;width:100%;max-width:1100px}
20
+ th{font-variant:small-caps;letter-spacing:.08em;text-align:left;font-weight:700;
21
+ border-bottom:2px solid var(--frame);padding:8px 12px 6px;font-size:14px}
22
+ td{padding:9px 12px;border-bottom:1px solid rgba(107,90,62,.35);vertical-align:top}
23
+ tr:hover td{background:rgba(107,90,62,.06)}
24
+ .pname{font-size:17px;font-style:italic}
25
+ a{color:#274a63;text-decoration-thickness:1px;text-underline-offset:3px}
26
+ .num{text-align:right;font-variant-numeric:tabular-nums}
27
+ .trustbar{display:inline-flex;width:92px;height:11px;border:1px solid var(--frame);
28
+ border-radius:2px;overflow:hidden;vertical-align:-1px;margin-right:8px}
29
+ .trustbar div{height:100%}
30
+ .tkey{font-size:12px;color:var(--ink-soft)}
31
+ code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;
32
+ background:rgba(127,127,127,.13);padding:1px 4px;border-radius:2px}
33
+ .muted{color:var(--ink-soft)}
34
+ footer{margin-top:26px;color:var(--ink-soft);font-size:13px}
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <h1>Fleet Review</h1>
39
+ <p class="sub">The assembled provinces of the Governor's fleet — every island group is drawn from that province's own Chart index; click a group to open its Chart Room. The table below carries the same facts as numbers.</p>
40
+ <svg id="fleetMap" viewBox="0 0 1880 560" role="img" aria-label="Archipelago of charted provinces" style="width:100%;height:auto;display:block;margin:6px 0 30px">
41
+ <rect x="0" y="0" width="1880" height="900" fill="#b9cfc6"/>
42
+ <g id="groups"></g>
43
+ </svg>
44
+ <table id="fleet" aria-label="Charted provinces">
45
+ <thead><tr>
46
+ <th>Province</th><th class="num">Vessels</th><th class="num">Fairways</th>
47
+ <th class="num">Dangers</th><th class="num">Stale</th><th>Trust</th><th>Top hub</th><th>Chart Room</th>
48
+ </tr></thead>
49
+ <tbody></tbody>
50
+ </table>
51
+ <footer>Rendered by Portolan — deterministic over the named targets. Nothing here was discovered by scanning; every province was named.</footer>
52
+ <script>
53
+ "use strict";
54
+ const ROWS = __FLEET_DATA__;
55
+
56
+ /* ---------- the fleet archipelago (fleet-review-map) ---------- */
57
+ function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);
58
+ t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
59
+ function hashStr(s){let h=2166136261;for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);
60
+ h=Math.imul(h,16777619)}return h>>>0}
61
+ function blobPath(cx,cy,r,jit,rot){
62
+ const n=jit.length,pts=[];
63
+ for(let i=0;i<n;i++){const a=rot+i/n*2*Math.PI;pts.push([cx+Math.cos(a)*r*jit[i],cy+Math.sin(a)*r*jit[i]])}
64
+ let d="";
65
+ for(let i=0;i<n;i++){
66
+ const p0=pts[(i-1+n)%n],p1=pts[i],p2=pts[(i+1)%n],p3=pts[(i+2)%n];
67
+ const c1=[p1[0]+(p2[0]-p0[0])/6,p1[1]+(p2[1]-p0[1])/6];
68
+ const c2=[p2[0]-(p3[0]-p1[0])/6,p2[1]-(p3[1]-p1[1])/6];
69
+ if(i===0)d+="M"+p1[0].toFixed(1)+","+p1[1].toFixed(1);
70
+ d+="C"+c1[0].toFixed(1)+","+c1[1].toFixed(1)+" "+c2[0].toFixed(1)+","+c2[1].toFixed(1)+" "+p2[0].toFixed(1)+","+p2[1].toFixed(1);
71
+ }
72
+ return d+"Z";
73
+ }
74
+ (function drawFleetMap(){
75
+ const NS="http://www.w3.org/2000/svg";
76
+ const g=document.getElementById("groups");
77
+ const GROUP_W=880,GROUP_H=380,PAD=60;
78
+ const TRUST_HALO={measured:"#7ba18e",charted:"#9ab39c",reported:"#b8c8ac",doubtful:"#cfd4b8",unsurveyed:"#eee4c5"};
79
+ ROWS.forEach((row,gi)=>{
80
+ const col=gi%2,rowI=Math.floor(gi/2);
81
+ const gx=PAD+col*(GROUP_W+PAD), gy=30+rowI*(GROUP_H+90);
82
+ const loc=document.createElementNS(NS,"g");
83
+ // sea plate + caption
84
+ const cap=document.createElementNS(NS,"text");
85
+ cap.setAttribute("x",gx);cap.setAttribute("y",gy+24);
86
+ cap.setAttribute("class","pname");cap.setAttribute("font-size","21");
87
+ cap.setAttribute("font-style","italic");cap.setAttribute("fill","#33291c");
88
+ cap.textContent=row.name;
89
+ loc.appendChild(cap);
90
+ const sub=document.createElementNS(NS,"text");
91
+ sub.setAttribute("x",gx);sub.setAttribute("y",gy+44);
92
+ sub.setAttribute("font-size","12.5");sub.setAttribute("fill","#6b5a3e");
93
+ sub.textContent=`${row.counts.vessel||0} vessels · ${row.counts.fairway||0} fairways · ${row.dangers||0} dangers — click to open its Chart Room`;
94
+ loc.appendChild(sub);
95
+ const vs=row.entries.filter(e=>e.kind==="vessel");
96
+ const labels=[];
97
+ const fws=row.entries.filter(e=>e.kind==="fairway");
98
+ const dgs=row.entries.filter(e=>e.kind==="danger");
99
+ const rnd=mulberry32(hashStr(row.path));
100
+ const ox=gx, oy=gy+70, W2=GROUP_W-140, H2=GROUP_H-100;
101
+ const rr=v=>6+7*Math.log10(Math.max(2,(v.signature&&v.signature.files)||2));
102
+ // seeded ellipse init + short relaxation
103
+ vs.forEach((v,i)=>{
104
+ const t=i/Math.max(1,vs.length)*2*Math.PI;
105
+ v._x=ox+W2/2+Math.cos(t)*(W2*0.34+(rnd()-0.5)*70);
106
+ v._y=oy+H2/2+Math.sin(t)*(H2*0.34+(rnd()-0.5)*50);
107
+ v._r=rr(v); v._jit=[]; const n=12,vh=hashStr(row.name+v.id);
108
+ for(let k=0;k<n;k++)v._jit.push(0.82+0.36*((vh>>>(k%28)&255)/255));
109
+ v._rot=rnd()*6.28;
110
+ v._trust=(v.trust in TRUST_HALO)?v.trust:"unsurveyed";
111
+ });
112
+ const byId=new Map(vs.map(v=>[v.id,v]));
113
+ const fan=new Map();
114
+ fws.forEach(f=>{if(byId.has(f.to)){fan.set(f.to,(fan.get(f.to)||0)+1)}});
115
+ for(let it=0;it<260;it++){
116
+ for(let i=0;i<vs.length;i++)for(let j=i+1;j<vs.length;j++){
117
+ const a=vs[i],b=vs[j];let dx=b._x-a._x,dy=b._y-a._y;
118
+ const d=Math.hypot(dx,dy)||0.01,min=a._r+b._r+26;
119
+ if(d<min){const push=(min-d)/d*0.16;dx*=push;dy*=push;
120
+ a._x-=dx;a._y-=dy;b._x+=dx;b._y+=dy}}
121
+ fws.forEach(f=>{const a=byId.get(f.from),b=byId.get(f.to);if(!a||!b)return;
122
+ let dx=b._x-a._x,dy=b._y-a._y;const d=Math.hypot(dx,dy)||0.01;
123
+ const target=a._r+b._r+96,s=(d-target)/d*0.06;
124
+ a._x+=dx*s;a._y+=dy*s;b._x-=dx*s;b._y-=dy*s});
125
+ vs.forEach(v=>{
126
+ v._x=Math.min(Math.max(v._x,ox+v._r+8),ox+W2-v._r-8);
127
+ v._y=Math.min(Math.max(v._y,oy+v._r+8),oy+H2-v._r-8)});
128
+ }
129
+ // lanes first
130
+ const seen=new Map();
131
+ fws.forEach(f=>{const a=byId.get(f.from),b=byId.get(f.to);if(!a||!b)return;
132
+ seen.set(f.to,(seen.get(f.to)||0)+1);
133
+ const h=hashStr(f.id);
134
+ const mx=(a._x+b._x)/2,my=(a._y+b._y)/2;
135
+ let dx=b._x-a._x,dy=b._y-a._y;const d=Math.hypot(dx,dy)||0.01;
136
+ const bend=(0.22+((h>>>4)%100)/100*0.26)*(((h>>>9)&1)?-1:1);
137
+ const cx=mx-dy/d*d*bend,cy=my+dx/d*d*bend;
138
+ const p=document.createElementNS(NS,"path");
139
+ p.setAttribute("d",`M${a._x+dx/d*a._r},${a._y+dy/d*a._r} Q${cx},${cy} ${b._x-dx/d*b._r},${b._y-dy/d*b._r}`);
140
+ p.setAttribute("fill","none");p.setAttribute("stroke",(f.trust==="measured")?"#25505e":"#4f7f8d");
141
+ p.setAttribute("stroke-width","1.4");p.setAttribute("opacity",".75");
142
+ loc.appendChild(p)});
143
+ // island blobs: trust halo band then sand blob, label under
144
+ vs.forEach(v=>{
145
+ const halo=document.createElementNS(NS,"path");
146
+ halo.setAttribute("d",blobPath(v._x,v._y,v._r+6,v._jit,v._rot));
147
+ halo.setAttribute("fill",TRUST_HALO[v._trust]);loc.appendChild(halo);
148
+ const sand=document.createElementNS(NS,"path");
149
+ sand.setAttribute("d",blobPath(v._x,v._y,v._r,v._jit,v._rot));
150
+ sand.setAttribute("fill","#ecddb0");sand.setAttribute("stroke","#8a744e");
151
+ sand.setAttribute("stroke-width","1.4");loc.appendChild(sand);
152
+ const t=document.createElementNS(NS,"text");
153
+ t.setAttribute("x",v._x);t.setAttribute("y",v._y+v._r+12);
154
+ t.setAttribute("text-anchor","middle");t.setAttribute("font-size","10.5");
155
+ t.setAttribute("fill","#33291c");
156
+ t.textContent=(v.name||v.id).replace(/^Apache /,"").toUpperCase();
157
+ loc.appendChild(t);
158
+ labels.push({t,x:v._x,y:v._y+v._r+12,w:(v.name||v.id).length*6.4+8,h:11});
159
+ });
160
+ // two passes of label de-collision, biggest first
161
+ labels.sort((a,b)=>b.w-a.w);
162
+ for(let pass=0;pass<2;pass++){
163
+ for(let i=0;i<labels.length;i++)for(let j=0;j<labels.length;j++){
164
+ if(i===j)continue;
165
+ const A=labels[i],B=labels[j];
166
+ if(Math.abs(A.x-B.x)<(A.w+B.w)/2&&Math.abs(A.y-B.y)<A.h){
167
+ A.y+=B.y<=A.y?7:-7;
168
+ A.t.setAttribute("y",A.y)}
169
+ }
170
+ }
171
+ // danger marks on owner coasts
172
+ dgs.forEach(dg=>{
173
+ const v=byId.get(dg.vessel);if(!v)return;
174
+ const h=hashStr(dg.id);const a=-2.3+((h%10)/10)*1.2;
175
+ const x=v._x+Math.cos(a)*(v._r+13),y=v._y+Math.sin(a)*(v._r+13);
176
+ const m=document.createElementNS(NS,"text");
177
+ m.setAttribute("x",x);m.setAttribute("y",y+4);m.setAttribute("text-anchor","middle");
178
+ m.setAttribute("font-size","13");m.setAttribute("font-weight","700");
179
+ m.setAttribute("fill","#8a3226");
180
+ m.textContent=dg.category==="rock"?"+":dg.category==="wreck"?"†":"*";
181
+ loc.appendChild(m)});
182
+ // hull = the link (only when the room exists)
183
+ const hit=document.createElementNS(NS,"rect");
184
+ hit.setAttribute("x",gx-14);hit.setAttribute("y",gy-6);
185
+ hit.setAttribute("width",GROUP_W);hit.setAttribute("height",GROUP_H+60);
186
+ hit.setAttribute("fill","transparent");
187
+ if(row.roomRendered){
188
+ const a=document.createElementNS(NS,"a");
189
+ a.setAttribute("href",row.roomUrl);a.appendChild(hit);loc.appendChild(a);
190
+ } else {
191
+ const t=document.createElementNS(NS,"text");
192
+ t.setAttribute("x",gx+GROUP_W-120);t.setAttribute("y",gy+64);
193
+ t.setAttribute("font-size","11");t.setAttribute("fill","#6b5a3e");
194
+ t.textContent="Chart Room not rendered yet";
195
+ loc.appendChild(t);
196
+ }
197
+ g.appendChild(loc);
198
+ });
199
+ })();
200
+
201
+ /* ---------- the table (data anchor) ---------- */
202
+ /* Chart-controlled strings are data, never markup: a member's hub id comes
203
+ from its chart, so every row interpolation passes through esc
204
+ (stored-XSS guard — the fleet review is published, not just local). */
205
+ const esc=s=>String(s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
206
+ const TRUSTS=["measured","charted","reported","doubtful","unsurveyed"];
207
+ const COLORS={measured:"#2c5a68",charted:"#5d8895",reported:"#6b8f8f",doubtful:"#a4b6b1",unsurveyed:"#d9d2b6"};
208
+ const tb=document.querySelector("#fleet tbody");
209
+ for(const r of ROWS){
210
+ const total=Object.values(r.trust).reduce((s,n)=>s+n,0)||1;
211
+ let bar='<span class="trustbar">';
212
+ for(const t of TRUSTS){const n=r.trust[t]||0;
213
+ if(n)bar+=`<div style="width:${n/total*100}%;background:${COLORS[t]}" title="${t}: ${n}"></div>`}
214
+ bar+='</span>';
215
+ bar+=`<span class="tkey">${TRUSTS.filter(t=>r.trust[t]).map(t=>`${r.trust[t]} ${t}`).join(" · ")}</span>`;
216
+ const tr=document.createElement("tr");
217
+ tr.innerHTML=`
218
+ <td><div class="pname"><a href="${esc(r.url)}">${esc(r.name)}</a></div><code>${esc(r.path)}</code></td>
219
+ <td class="num">${r.counts.vessel||0}</td>
220
+ <td class="num">${r.counts.fairway||0}</td>
221
+ <td class="num">${r.dangers||0}</td>
222
+ <td class="num">${r.stale||0}</td>
223
+ <td>${bar}</td>
224
+ <td>${r.topHub?`<code>${esc(r.topHub.id)}</code> <span class="muted">×${r.topHub.fanIn}</span>`:'<span class="muted">—</span>'}</td>
225
+ <td>${r.roomRendered
226
+ ?`<a href="${esc(r.roomUrl)}">open Chart Room</a>`
227
+ :'<span class="muted">not rendered yet</span>'}</td>`;
228
+ tb.appendChild(tr);
229
+ }
230
+ </script>
231
+ </body>
232
+ </html>
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The fleet review — one-file multi-province index page
3
+ * (openspec/changes/fleet-review). The Governor names provinces explicitly;
4
+ * the export lists them in order with arithmetic over each machine index
5
+ * and a file:// link to every province's Chart Room. Deterministic,
6
+ * read-only inputs, one written artifact inside the first target's
7
+ * perimeter. No MCP surface: a server is bound to one province by contract.
8
+ */
9
+ import { readFileSync, existsSync } from "node:fs";
10
+ import { join, resolve, basename, dirname } from "node:path";
11
+ import { pathToFileURL, fileURLToPath } from "node:url";
12
+ import { readChart } from "../chart-store";
13
+ import { writeFilesAtomically } from "../chart-io";
14
+ import type { IndexedEntry } from "../types";
15
+ import { safeInlineJson } from "./render";
16
+
17
+ /** Where the review lands: the first named target's perimeter. */
18
+ export function fleetReviewPath(firstTarget: string): string {
19
+ return resolve(firstTarget, ".portolan/fleet-review.html");
20
+ }
21
+
22
+ export interface FleetRow {
23
+ name: string;
24
+ path: string;
25
+ url: string;
26
+ counts: Record<string, number>;
27
+ trust: Record<string, number>;
28
+ stale: number;
29
+ dangers: number;
30
+ topHub: { id: string; fanIn: number } | null;
31
+ roomUrl: string;
32
+ roomRendered: boolean;
33
+ /** Vessels + fairways + dangers of this province — the fleet sheet's data. */
34
+ entries: IndexedEntry[];
35
+ }
36
+
37
+ function rowFor(targetRoot: string): FleetRow {
38
+ const entries: IndexedEntry[] = readChart(targetRoot); // loud when absent
39
+ const counts: Record<string, number> = {};
40
+ const trust: Record<string, number> = {};
41
+ let stale = 0;
42
+ let dangers = 0;
43
+ const vessels: IndexedEntry[] = [];
44
+ const fairways: IndexedEntry[] = [];
45
+ for (const e of entries) {
46
+ counts[e.kind] = (counts[e.kind] ?? 0) + 1;
47
+ trust[e.trust] = (trust[e.trust] ?? 0) + 1;
48
+ if (e.stale) stale += 1;
49
+ if (e.kind === "danger") dangers += 1;
50
+ if (e.kind === "vessel") vessels.push(e);
51
+ if (e.kind === "fairway") fairways.push(e);
52
+ }
53
+ const fanIn = new Map<string, number>();
54
+ for (const f of fairways) {
55
+ const to = (f as { to?: string }).to;
56
+ if (typeof to === "string" && vessels.some((v) => v.id === to)) {
57
+ fanIn.set(to, (fanIn.get(to) ?? 0) + 1);
58
+ }
59
+ }
60
+ let topHub: FleetRow["topHub"] = null;
61
+ for (const [id, n] of fanIn) {
62
+ if (!topHub || n > topHub.fanIn || (n === topHub.fanIn && id < topHub.id)) {
63
+ topHub = { id, fanIn: n };
64
+ }
65
+ }
66
+ const root = resolve(targetRoot);
67
+ const roomPath = join(root, ".portolan/chart-room.html");
68
+ return {
69
+ name: basename(root),
70
+ path: root,
71
+ url: pathToFileURL(root).href,
72
+ counts,
73
+ trust,
74
+ stale,
75
+ dangers,
76
+ topHub,
77
+ roomUrl: pathToFileURL(roomPath).href,
78
+ roomRendered: existsSync(roomPath),
79
+ entries: entries.filter((e) =>
80
+ e.kind === "vessel" || e.kind === "fairway" || e.kind === "danger"),
81
+ };
82
+ }
83
+
84
+ const TEMPLATE_ROW = "__FLEET_DATA__";
85
+
86
+ function loadTemplate(): string {
87
+ return readFileSync(
88
+ resolve(fileURLToPath(import.meta.url), "..", "review-template.html"),
89
+ "utf8",
90
+ );
91
+ }
92
+
93
+ export interface FleetReviewResult {
94
+ path: string;
95
+ provinces: number;
96
+ bytes: number;
97
+ }
98
+
99
+ /** Render the fleet review. Throws loudly when any target lacks a chart. */
100
+ export function buildFleetReview(targets: string[]): FleetReviewResult {
101
+ if (targets.length === 0) {
102
+ throw new Error("fleet review needs at least one --target");
103
+ }
104
+ const rows = targets.map(rowFor); // loud per-target on missing charts
105
+ const html = loadTemplate().replace(TEMPLATE_ROW, () => safeInlineJson(rows));
106
+ const path = fleetReviewPath(targets[0]!);
107
+ writeFilesAtomically(dirname(path), new Map([[basename(path), html]]));
108
+ return { path, provinces: rows.length, bytes: html.length };
109
+ }