@pleri/olam-cli 0.1.175 → 0.1.182

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 (130) hide show
  1. package/README.md +19 -0
  2. package/bin/olam.cjs +22 -0
  3. package/dist/commands/auth.d.ts.map +1 -1
  4. package/dist/commands/auth.js +67 -19
  5. package/dist/commands/auth.js.map +1 -1
  6. package/dist/commands/config.d.ts.map +1 -1
  7. package/dist/commands/config.js +93 -0
  8. package/dist/commands/config.js.map +1 -1
  9. package/dist/commands/destroy.d.ts +41 -0
  10. package/dist/commands/destroy.d.ts.map +1 -1
  11. package/dist/commands/destroy.js +81 -33
  12. package/dist/commands/destroy.js.map +1 -1
  13. package/dist/commands/dispatch-resolve.d.ts +54 -0
  14. package/dist/commands/dispatch-resolve.d.ts.map +1 -0
  15. package/dist/commands/dispatch-resolve.js +105 -0
  16. package/dist/commands/dispatch-resolve.js.map +1 -0
  17. package/dist/commands/dispatch.d.ts.map +1 -1
  18. package/dist/commands/dispatch.js +40 -9
  19. package/dist/commands/dispatch.js.map +1 -1
  20. package/dist/commands/flywheel/index.d.ts.map +1 -1
  21. package/dist/commands/flywheel/index.js +4 -0
  22. package/dist/commands/flywheel/index.js.map +1 -1
  23. package/dist/commands/flywheel/install-sessionstart-hook.d.ts +64 -0
  24. package/dist/commands/flywheel/install-sessionstart-hook.d.ts.map +1 -0
  25. package/dist/commands/flywheel/install-sessionstart-hook.js +197 -0
  26. package/dist/commands/flywheel/install-sessionstart-hook.js.map +1 -0
  27. package/dist/commands/flywheel/k5-validate.d.ts +31 -0
  28. package/dist/commands/flywheel/k5-validate.d.ts.map +1 -1
  29. package/dist/commands/flywheel/k5-validate.js +80 -19
  30. package/dist/commands/flywheel/k5-validate.js.map +1 -1
  31. package/dist/commands/flywheel/session-start.d.ts +26 -0
  32. package/dist/commands/flywheel/session-start.d.ts.map +1 -0
  33. package/dist/commands/flywheel/session-start.js +119 -0
  34. package/dist/commands/flywheel/session-start.js.map +1 -0
  35. package/dist/commands/host-cp.d.ts +0 -3
  36. package/dist/commands/host-cp.d.ts.map +1 -1
  37. package/dist/commands/host-cp.js +27 -2
  38. package/dist/commands/host-cp.js.map +1 -1
  39. package/dist/commands/kg-classify.d.ts.map +1 -1
  40. package/dist/commands/kg-classify.js +20 -0
  41. package/dist/commands/kg-classify.js.map +1 -1
  42. package/dist/commands/kg-doctor.d.ts +67 -6
  43. package/dist/commands/kg-doctor.d.ts.map +1 -1
  44. package/dist/commands/kg-doctor.js +126 -46
  45. package/dist/commands/kg-doctor.js.map +1 -1
  46. package/dist/commands/list.d.ts +27 -0
  47. package/dist/commands/list.d.ts.map +1 -1
  48. package/dist/commands/list.js +67 -19
  49. package/dist/commands/list.js.map +1 -1
  50. package/dist/commands/memory/status.d.ts +18 -0
  51. package/dist/commands/memory/status.d.ts.map +1 -1
  52. package/dist/commands/memory/status.js +38 -2
  53. package/dist/commands/memory/status.js.map +1 -1
  54. package/dist/commands/memory-service-container.d.ts +44 -0
  55. package/dist/commands/memory-service-container.d.ts.map +1 -1
  56. package/dist/commands/memory-service-container.js +49 -0
  57. package/dist/commands/memory-service-container.js.map +1 -1
  58. package/dist/commands/ps.d.ts +32 -0
  59. package/dist/commands/ps.d.ts.map +1 -1
  60. package/dist/commands/ps.js +34 -0
  61. package/dist/commands/ps.js.map +1 -1
  62. package/dist/commands/runbooks.d.ts +32 -0
  63. package/dist/commands/runbooks.d.ts.map +1 -1
  64. package/dist/commands/runbooks.js +79 -22
  65. package/dist/commands/runbooks.js.map +1 -1
  66. package/dist/commands/skills-source.d.ts.map +1 -1
  67. package/dist/commands/skills-source.js +77 -2
  68. package/dist/commands/skills-source.js.map +1 -1
  69. package/dist/commands/upgrade-history.d.ts +0 -2
  70. package/dist/commands/upgrade-history.d.ts.map +1 -1
  71. package/dist/commands/upgrade-history.js +0 -6
  72. package/dist/commands/upgrade-history.js.map +1 -1
  73. package/dist/commands/upgrade-lock.d.ts +0 -9
  74. package/dist/commands/upgrade-lock.d.ts.map +1 -1
  75. package/dist/commands/upgrade-lock.js +1 -1
  76. package/dist/commands/upgrade-lock.js.map +1 -1
  77. package/dist/commands/world-snapshot.d.ts +13 -0
  78. package/dist/commands/world-snapshot.d.ts.map +1 -1
  79. package/dist/commands/world-snapshot.js +81 -1
  80. package/dist/commands/world-snapshot.js.map +1 -1
  81. package/dist/commands/yolo.d.ts +95 -0
  82. package/dist/commands/yolo.d.ts.map +1 -0
  83. package/dist/commands/yolo.js +377 -0
  84. package/dist/commands/yolo.js.map +1 -0
  85. package/dist/image-digests.json +8 -8
  86. package/dist/index.js +3990 -2445
  87. package/dist/index.js.map +1 -1
  88. package/dist/lib/anthropic-base-url-file.d.ts +37 -0
  89. package/dist/lib/anthropic-base-url-file.d.ts.map +1 -0
  90. package/dist/lib/anthropic-base-url-file.js +46 -0
  91. package/dist/lib/anthropic-base-url-file.js.map +1 -0
  92. package/dist/lib/auth-remote.d.ts +9 -17
  93. package/dist/lib/auth-remote.d.ts.map +1 -1
  94. package/dist/lib/auth-remote.js +25 -20
  95. package/dist/lib/auth-remote.js.map +1 -1
  96. package/dist/lib/cf-access-token.d.ts +32 -0
  97. package/dist/lib/cf-access-token.d.ts.map +1 -0
  98. package/dist/lib/cf-access-token.js +52 -0
  99. package/dist/lib/cf-access-token.js.map +1 -0
  100. package/dist/lib/config.d.ts +17 -3
  101. package/dist/lib/config.d.ts.map +1 -1
  102. package/dist/lib/config.js +28 -4
  103. package/dist/lib/config.js.map +1 -1
  104. package/dist/lib/kubectl-context.d.ts +49 -0
  105. package/dist/lib/kubectl-context.d.ts.map +1 -1
  106. package/dist/lib/kubectl-context.js +64 -2
  107. package/dist/lib/kubectl-context.js.map +1 -1
  108. package/dist/lib/upgrade-kubernetes.d.ts +7 -0
  109. package/dist/lib/upgrade-kubernetes.d.ts.map +1 -1
  110. package/dist/lib/upgrade-kubernetes.js +35 -8
  111. package/dist/lib/upgrade-kubernetes.js.map +1 -1
  112. package/dist/mcp-server.js +1470 -991
  113. package/hermes-bundle/version.json +1 -1
  114. package/host-cp/k8s/manifests/45-pvc.yaml +6 -2
  115. package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
  116. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  117. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  118. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  119. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  120. package/host-cp/observability/trace-summary.mjs +267 -0
  121. package/host-cp/src/bootstrap-selective.mjs +58 -0
  122. package/host-cp/src/host-stream.mjs +52 -0
  123. package/host-cp/src/plan-chat-service.mjs +51 -0
  124. package/host-cp/src/redirect.mjs +159 -0
  125. package/host-cp/src/resolver.mjs +121 -0
  126. package/host-cp/src/router.mjs +168 -0
  127. package/host-cp/src/serve-only-config.mjs +85 -0
  128. package/host-cp/src/server.mjs +375 -205
  129. package/host-cp/src/world-services.mjs +136 -0
  130. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
1
  {
2
- "bundledAt": "2026-05-24T13:03:21.307Z",
2
+ "bundledAt": "2026-05-26T05:28:12.384Z",
3
3
  "kgFirstSha": "29a9ccce1b115d049e375c4a90eb5cf7c123e610e2d0590270a4db2cdbc64a28"
4
4
  }
@@ -1,4 +1,4 @@
1
- # PersistentVolumeClaim for olam-host-cp /data volume.
1
+ # PersistentVolumeClaim for olam-host-cp /data volume — k3d substrate default.
2
2
  #
3
3
  # Why PVC instead of hostPath:
4
4
  # hostPath volumes on k3d nodes resolve to paths INSIDE the k3d node
@@ -9,7 +9,11 @@
9
9
  # root-owned hostPath mounts even when fsGroup: 1000 is set.
10
10
  #
11
11
  # local-path StorageClass ships with k3d by default (rancher/local-path-provisioner).
12
- # On non-k3d clusters, substitute with the appropriate StorageClass name.
12
+ # On non-k3d clusters, substitute with the appropriate StorageClass name (D24,
13
+ # operator-editable). For managed clusters (GKE, EKS, AKS) use the GKE-variant
14
+ # manifest instead: packages/host-cp/k8s/manifests/gke/45-pvc.yaml (storageClassName:
15
+ # standard-rwo). See docs/architecture/peripheral-services-on-k3s.md Decision #3
16
+ # for the full per-cluster storageclass table.
13
17
  apiVersion: v1
14
18
  kind: PersistentVolumeClaim
15
19
  metadata:
@@ -118,7 +118,7 @@ spec:
118
118
  # k3d), started by `olam upgrade` Step 0.7 — not inside this Pod.
119
119
  containers:
120
120
  - name: olam-host-cp
121
- image: ghcr.io/pleri/olam-host-cp@sha256:71e376df97d498e76c51f35698464b26413bcfe4efe926b85e12248422ae1a54
121
+ image: ghcr.io/pleri/olam-host-cp@sha256:20d84b6d490c633bc5a158b0f7f849152aba3cf1d2d45657360f627d8d41ec3f
122
122
  imagePullPolicy: IfNotPresent
123
123
  securityContext:
124
124
  runAsNonRoot: true
@@ -70,7 +70,7 @@ spec:
70
70
  mountPath: /data
71
71
  containers:
72
72
  - name: olam-auth-service
73
- image: ghcr.io/pleri/olam-auth@sha256:fe6e1ff20ee99aa2ce74e144d33ebf07411855fc2080ec046ee8a82a6144fb54
73
+ image: ghcr.io/pleri/olam-auth@sha256:57d5f69395f9d4199035bb1e8156ffa1a4b815e50f6ecef28c2fc082f4d40e23
74
74
  imagePullPolicy: IfNotPresent
75
75
  securityContext:
76
76
  runAsNonRoot: true
@@ -61,7 +61,7 @@ spec:
61
61
  mountPath: /data
62
62
  containers:
63
63
  - name: olam-kg-service
64
- image: ghcr.io/pleri/olam-kg-service@sha256:0300a3140a25510703df52ebeae8a1783e620b93aae7115ef0b3577208eb9b1f
64
+ image: ghcr.io/pleri/olam-kg-service@sha256:7b48bc20dead674fb01f5e01f1fc43fee2b589c05156a7b5384504f42e96a18a
65
65
  imagePullPolicy: IfNotPresent
66
66
  securityContext:
67
67
  runAsNonRoot: true
@@ -68,7 +68,7 @@ spec:
68
68
  mountPath: /data
69
69
  containers:
70
70
  - name: olam-mcp-auth-service
71
- image: ghcr.io/pleri/olam-mcp-auth@sha256:2a442b17f372eb60b2e1444bb16092157df10ad76b4e87cf6cc8975d386e5be4
71
+ image: ghcr.io/pleri/olam-mcp-auth@sha256:9cbf2fb4c79a6b447282da5f1a190c730eb9c85e5df1c0c16dc238c34352c583
72
72
  imagePullPolicy: IfNotPresent
73
73
  securityContext:
74
74
  runAsNonRoot: true
@@ -70,7 +70,7 @@ spec:
70
70
  # bootstrap-placeholder comment + run `npm run refresh:manifest-digests`
71
71
  # once ghcr.io/pleri/olam-memory-service has a real published digest.
72
72
  # bootstrap-placeholder: pre-publish; refresh after first release
73
- image: ghcr.io/pleri/olam-memory-service@sha256:320298284431afbeebc947a55701f486bc1df0d5642817f68a61147056da9056
73
+ image: ghcr.io/pleri/olam-memory-service@sha256:d94c6699ca3937f67a873b2b6bb28a1d40317f9c0a780f780f45b41c5d103f2d
74
74
  imagePullPolicy: IfNotPresent
75
75
  securityContext:
76
76
  runAsNonRoot: true
@@ -0,0 +1,267 @@
1
+ // Trace summary — operator triage digest over the NDJSON span trace.
2
+ //
3
+ // The NDJSON span sink (see `ndjson-span-sink.mjs`) writes one JSON line
4
+ // per span to ~/.olam/logs/host.trace.ndjson. Operators triage it today
5
+ // with hand-typed `jq` one-liners (README § Observability): "longest 5
6
+ // spans", "all failed spans", "failure-kind tally". This module codifies
7
+ // those recipes into ONE digest so the common questions get one answer
8
+ // without remembering jq incantations.
9
+ //
10
+ // Design:
11
+ // - `summarizeSpans(spans, opts)` is PURE — no I/O. Given an array of
12
+ // parsed span records (the exact shape the sink writes) it returns a
13
+ // digest object. This is the unit-testable core.
14
+ // - `parseTrace(ndjsonText)` turns raw file bytes into { spans, skipped }.
15
+ // Malformed lines (truncated tail line, partial write mid-rotation)
16
+ // are COUNTED, never thrown — triage tooling must survive a corrupt
17
+ // line, not die on it.
18
+ // - `summarizeTraceFile(path, opts)` is the thin file-reading wrapper.
19
+ // - `formatDigest(digest)` renders a human-readable report for the CLI.
20
+ //
21
+ // Read-only + additive: this module never writes the trace, never changes
22
+ // the line schema. It only READS fields the sink already emits
23
+ // (durationMs, exit._tag, exit.reason, name, attributes.failureKind).
24
+
25
+ import { readFile } from 'node:fs/promises';
26
+
27
+ const DEFAULT_TOP_N = 5;
28
+
29
+ /**
30
+ * Parse NDJSON trace text into spans, tolerating malformed lines.
31
+ *
32
+ * @param {string} text raw file contents
33
+ * @returns {{ spans: object[], skipped: number }}
34
+ */
35
+ export function parseTrace(text) {
36
+ const spans = [];
37
+ let skipped = 0;
38
+ for (const line of String(text).split('\n')) {
39
+ const trimmed = line.trim();
40
+ if (trimmed === '') continue;
41
+ try {
42
+ spans.push(JSON.parse(trimmed));
43
+ } catch {
44
+ // Truncated tail line or a partial write straddling rotation — the
45
+ // append-only log can leave one half-line. Triage must not crash on
46
+ // it; count and move on.
47
+ skipped += 1;
48
+ }
49
+ }
50
+ return { spans, skipped };
51
+ }
52
+
53
+ function isFailure(span) {
54
+ return span?.exit?._tag === 'Failure';
55
+ }
56
+
57
+ /**
58
+ * Compute a triage digest over parsed spans. Pure.
59
+ *
60
+ * @param {object[]} spans
61
+ * @param {{ topN?: number }} [opts]
62
+ * @returns {{
63
+ * totalSpans: number,
64
+ * failures: number,
65
+ * successes: number,
66
+ * failureRate: number,
67
+ * slowest: object[],
68
+ * recentFailures: object[],
69
+ * failureReasons: { reason: string, count: number }[],
70
+ * failureKinds: { kind: string, count: number }[],
71
+ * byName: { name: string, count: number, failures: number, meanMs: number|null, maxMs: number|null }[],
72
+ * }}
73
+ */
74
+ export function summarizeSpans(spans, { topN = DEFAULT_TOP_N } = {}) {
75
+ const list = Array.isArray(spans) ? spans : [];
76
+ const totalSpans = list.length;
77
+ const failingSpans = list.filter(isFailure);
78
+ const failures = failingSpans.length;
79
+ const successes = totalSpans - failures;
80
+ const failureRate = totalSpans === 0 ? 0 : failures / totalSpans;
81
+
82
+ // Slowest spans by durationMs. Spans with a null duration (in-flight or
83
+ // missing endedAt) are excluded — they carry no comparable cost signal.
84
+ const timed = list.filter((s) => typeof s?.durationMs === 'number');
85
+ const slowest = [...timed]
86
+ .sort((a, b) => b.durationMs - a.durationMs)
87
+ .slice(0, topN)
88
+ .map(projectSpan);
89
+
90
+ // Recent failures — the trace is append-only, so the last failures in
91
+ // file order are the most recent. Take the tail.
92
+ const recentFailures = failingSpans.slice(-topN).reverse().map(projectSpan);
93
+
94
+ const failureReasons = tally(
95
+ failingSpans,
96
+ (s) => (s?.exit?.reason != null ? String(s.exit.reason) : '(no reason)'),
97
+ 'reason',
98
+ );
99
+
100
+ // failureKind is the world.lifecycle attribute the README already greps
101
+ // for; surface it as a first-class tally regardless of span name so
102
+ // recovery-relevant failures aggregate even when span names differ.
103
+ const failureKinds = tally(
104
+ list.filter((s) => s?.attributes?.failureKind != null),
105
+ (s) => String(s.attributes.failureKind),
106
+ 'kind',
107
+ );
108
+
109
+ const byName = aggregateByName(list);
110
+
111
+ return {
112
+ totalSpans,
113
+ failures,
114
+ successes,
115
+ failureRate,
116
+ slowest,
117
+ recentFailures,
118
+ failureReasons,
119
+ failureKinds,
120
+ byName,
121
+ };
122
+ }
123
+
124
+ function projectSpan(s) {
125
+ return {
126
+ name: s?.name ?? null,
127
+ traceId: s?.traceId ?? null,
128
+ spanId: s?.spanId ?? null,
129
+ durationMs: typeof s?.durationMs === 'number' ? s.durationMs : null,
130
+ startedAt: typeof s?.startedAt === 'number' ? s.startedAt : null,
131
+ reason: s?.exit?.reason != null ? String(s.exit.reason) : null,
132
+ };
133
+ }
134
+
135
+ // Group spans by a string key and count occurrences, labelling the key
136
+ // field per the caller (`reason` for failure reasons, `kind` for failure
137
+ // kinds). Sorted by count descending so the dominant cause leads.
138
+ function tally(spans, keyFn, label) {
139
+ const counts = new Map();
140
+ for (const s of spans) {
141
+ const key = keyFn(s);
142
+ counts.set(key, (counts.get(key) ?? 0) + 1);
143
+ }
144
+ const out = [];
145
+ for (const [k, count] of counts) out.push({ count, [label]: k });
146
+ return out.sort((a, b) => b.count - a.count);
147
+ }
148
+
149
+ /**
150
+ * Per-span-name aggregate: count, failure count, mean + max duration.
151
+ * Sorted by count descending so the busiest spans surface first.
152
+ */
153
+ function aggregateByName(spans) {
154
+ const groups = new Map();
155
+ for (const s of spans) {
156
+ const name = s?.name != null ? String(s.name) : '(unnamed)';
157
+ let g = groups.get(name);
158
+ if (!g) {
159
+ g = { name, count: 0, failures: 0, durSum: 0, durCount: 0, maxMs: null };
160
+ groups.set(name, g);
161
+ }
162
+ g.count += 1;
163
+ if (isFailure(s)) g.failures += 1;
164
+ if (typeof s?.durationMs === 'number') {
165
+ g.durSum += s.durationMs;
166
+ g.durCount += 1;
167
+ g.maxMs = g.maxMs === null ? s.durationMs : Math.max(g.maxMs, s.durationMs);
168
+ }
169
+ }
170
+ return [...groups.values()]
171
+ .map((g) => ({
172
+ name: g.name,
173
+ count: g.count,
174
+ failures: g.failures,
175
+ meanMs: g.durCount === 0 ? null : g.durSum / g.durCount,
176
+ maxMs: g.maxMs,
177
+ }))
178
+ .sort((a, b) => b.count - a.count);
179
+ }
180
+
181
+ /**
182
+ * Read + summarize a trace file. Missing file → empty digest (an operator
183
+ * who hasn't generated any spans yet sees a clean zero-state, not a crash).
184
+ *
185
+ * @param {string} path
186
+ * @param {{ topN?: number }} [opts]
187
+ */
188
+ export async function summarizeTraceFile(path, opts = {}) {
189
+ let text;
190
+ try {
191
+ text = await readFile(path, 'utf8');
192
+ } catch (err) {
193
+ if (err && err.code === 'ENOENT') {
194
+ return { ...summarizeSpans([], opts), skipped: 0, missing: true };
195
+ }
196
+ throw err;
197
+ }
198
+ const { spans, skipped } = parseTrace(text);
199
+ return { ...summarizeSpans(spans, opts), skipped, missing: false };
200
+ }
201
+
202
+ function fmtMs(ms) {
203
+ if (ms == null) return '—';
204
+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
205
+ return `${Math.round(ms)}ms`;
206
+ }
207
+
208
+ /**
209
+ * Render a digest as a human-readable, plain-text report for the CLI.
210
+ *
211
+ * @param {ReturnType<typeof summarizeSpans> & { skipped?: number, missing?: boolean, path?: string }} digest
212
+ * @returns {string}
213
+ */
214
+ export function formatDigest(digest) {
215
+ const lines = [];
216
+ const path = digest.path ? ` (${digest.path})` : '';
217
+ lines.push(`Trace summary${path}`);
218
+ if (digest.missing) {
219
+ lines.push(' no trace file yet — nothing recorded.');
220
+ return lines.join('\n');
221
+ }
222
+ const pct = (digest.failureRate * 100).toFixed(1);
223
+ lines.push(
224
+ ` ${digest.totalSpans} spans · ${digest.failures} failed (${pct}%) · ${digest.successes} ok` +
225
+ (digest.skipped ? ` · ${digest.skipped} malformed line(s) skipped` : ''),
226
+ );
227
+
228
+ if (digest.slowest.length) {
229
+ lines.push('');
230
+ lines.push(`Top ${digest.slowest.length} slowest:`);
231
+ for (const s of digest.slowest) {
232
+ lines.push(` ${fmtMs(s.durationMs).padStart(7)} ${s.name ?? '(unnamed)'}${s.traceId ? ` [${s.traceId}]` : ''}`);
233
+ }
234
+ }
235
+
236
+ if (digest.recentFailures.length) {
237
+ lines.push('');
238
+ lines.push(`Recent failures (${digest.recentFailures.length}):`);
239
+ for (const f of digest.recentFailures) {
240
+ lines.push(` ${f.name ?? '(unnamed)'}: ${f.reason ?? '(no reason)'}${f.traceId ? ` [${f.traceId}]` : ''}`);
241
+ }
242
+ }
243
+
244
+ if (digest.failureKinds.length) {
245
+ lines.push('');
246
+ lines.push('Failure kinds:');
247
+ for (const k of digest.failureKinds) lines.push(` ${String(k.count).padStart(4)} ${k.kind}`);
248
+ }
249
+
250
+ if (digest.failureReasons.length) {
251
+ lines.push('');
252
+ lines.push('Failure reasons:');
253
+ for (const r of digest.failureReasons) lines.push(` ${String(r.count).padStart(4)} ${r.reason}`);
254
+ }
255
+
256
+ if (digest.byName.length) {
257
+ lines.push('');
258
+ lines.push('By span name (count · failures · mean · max):');
259
+ for (const n of digest.byName) {
260
+ lines.push(
261
+ ` ${String(n.count).padStart(5)} · ${String(n.failures).padStart(4)}f · ${fmtMs(n.meanMs).padStart(7)} · ${fmtMs(n.maxMs).padStart(7)} ${n.name}`,
262
+ );
263
+ }
264
+ }
265
+
266
+ return lines.join('\n');
267
+ }
@@ -0,0 +1,58 @@
1
+ // bootstrap-selective.mjs — Phase D1 helper, collapsed to a wildcard in
2
+ // Phase E5 (ATOMIC SERVING CUTOVER).
3
+ //
4
+ // Determines whether a SPA shell render path should SKIP the host-cp
5
+ // BOOTSTRAP_SCRIPT injection (cookie-bootstrap + fetch/EventSource
6
+ // rewrite shim) and instead let the served SPA's own auth resolver +
7
+ // world-fetch shim handle auth.
8
+ //
9
+ // Phase E5: plan-chat-spa is now host-cp's SOLE served SPA. Its bundle
10
+ // re-homes the cookie-bootstrap + world-fetch-rewrite + 401-recover shim
11
+ // (packages/plan-chat-spa/src/lib/worldFetch.ts, installed at the top of
12
+ // src/main.tsx — Phase C). Therefore host-cp NEVER needs to inject
13
+ // BOOTSTRAP_SCRIPT anymore: every path is a "planning" (== SPA-owned)
14
+ // path. isPlanningPath() is collapsed to a wildcard accordingly.
15
+ //
16
+ // Reversal: set isPlanningPath to consult BOOTSTRAP_NOOP_PLANNING_PATHS
17
+ // again (restore the prefix-match body below) to re-narrow the no-op to
18
+ // the explicit planning prefixes; or, for full pre-D behaviour, also set
19
+ // BOOTSTRAP_NOOP_PLANNING_PATHS to []. The const is retained as the
20
+ // documented revert seam.
21
+ //
22
+ // Per K1 SCP-3 + phase-d-tasks.md D1 + phase-e-tasks.md E2.
23
+
24
+ /**
25
+ * Path prefixes that WERE owned by plan-chat-spa under the Phase D
26
+ * selective no-op. Retained as the documented single-line revert seam:
27
+ * to re-narrow the bootstrap no-op back to only the planning surfaces,
28
+ * restore the prefix-match body in isPlanningPath() (see git history of
29
+ * this file at the Phase E5 commit) so it consults this array again.
30
+ *
31
+ * Format: include both the bare segment ("/plan") and the trailing-slash
32
+ * variant ("/plan/"). The trailing-slash form is the prefix-match
33
+ * generator for "/plan/<rest>".
34
+ *
35
+ * @type {readonly string[]}
36
+ */
37
+ export const BOOTSTRAP_NOOP_PLANNING_PATHS = Object.freeze([
38
+ '/plan',
39
+ '/plan/',
40
+ ]);
41
+
42
+ /**
43
+ * Phase E5 wildcard: TRUE for every string path.
44
+ *
45
+ * host-cp now serves plan-chat-spa exclusively, whose bundle re-homes the
46
+ * cookie-bootstrap + world-fetch-rewrite shim (worldFetch.ts). No served
47
+ * path needs host-cp's BOOTSTRAP_SCRIPT injection anymore, so every path
48
+ * is treated as an SPA-owned ("planning") path and skips bootstrap.
49
+ *
50
+ * Returns false only for non-string input (defensive — a non-string
51
+ * pathname is never a real served path).
52
+ *
53
+ * @param {unknown} pathname
54
+ * @returns {boolean}
55
+ */
56
+ export function isPlanningPath(pathname) {
57
+ return typeof pathname === 'string';
58
+ }
@@ -23,6 +23,13 @@
23
23
  // in-memory buffer; overflow drops oldest events with an
24
24
  // `:overflow` comment so consumers know they missed updates.
25
25
  // - E4: per-event-type broadcast counter + sink count metric line.
26
+ // - E5: the metrics tick ALSO broadcasts a `stream.health` typed event
27
+ // carrying the same counters it logs, so any SPA tab can observe
28
+ // live stream health (sink count, per-event broadcast rates,
29
+ // overflow drops) without polling. Snapshot-cached like every
30
+ // other state event — reconnecting clients replay the last
31
+ // health payload immediately (first-paint parity). Opt out via
32
+ // `deps.healthEvents = false`.
26
33
  //
27
34
  // Pure module: no docker, no DB, no global clock except `setInterval`
28
35
  // for the heartbeat/metrics timers (injectable in tests). Wiring those
@@ -43,7 +50,9 @@ import crypto from 'node:crypto';
43
50
  * @property {number} [debounceMs.default] default trailing-edge ms (Phase E1)
44
51
  * @property {number} [heartbeatMs] per-sink heartbeat interval (Phase E2)
45
52
  * @property {number} [metricsMs] per-broadcaster metrics tick (Phase E4)
53
+ * @property {boolean} [healthEvents] broadcast `stream.health` on each metrics tick (Phase E5; default true)
46
54
  * @property {number} [maxQueuedPerSink] bounded queue size (Phase E3)
55
+ * @property {() => number} [now] injectable clock for `stream.health.at` (tests)
47
56
  * @property {(cb: () => void, ms: number) => any} [setTimer] injectable setInterval (tests)
48
57
  * @property {(handle: any) => void} [clearTimer] injectable clearInterval (tests)
49
58
  */
@@ -66,6 +75,26 @@ import crypto from 'node:crypto';
66
75
  * @property {number} overflows total `:overflow` drops since last reset
67
76
  */
68
77
 
78
+ /**
79
+ * Payload wire-shape for the `stream.health` event (Phase E5). A
80
+ * point-in-time projection of the broadcaster's own observability
81
+ * counters, emitted on each metrics tick. `events` carries the
82
+ * per-event-type broadcast counts accrued during the just-elapsed
83
+ * interval (reset afterward), so consumers see a per-interval RATE
84
+ * rather than a monotonic total. `at` is the wall-clock emit time so a
85
+ * reconnecting client can tell how stale the replayed snapshot is.
86
+ *
87
+ * @typedef {object} StreamHealthPayload
88
+ * @property {Record<string, number>} events per-event broadcasts during the interval
89
+ * @property {number} sinks active-sink count at emit time
90
+ * @property {number} overflows `:overflow` drops during the interval
91
+ * @property {number} intervalMs the metrics-tick cadence that produced this payload
92
+ * @property {number} at Date.now() at emit time
93
+ */
94
+
95
+ /** Event type emitted by the metrics tick (Phase E5). */
96
+ export const STREAM_HEALTH_EVENT = 'stream.health';
97
+
69
98
  const DEFAULT_DEBOUNCE_MS = 100;
70
99
  const DEFAULT_HEARTBEAT_MS = 25_000;
71
100
  const DEFAULT_METRICS_MS = 60_000;
@@ -104,6 +133,8 @@ export function createHostStream(deps = {}) {
104
133
  const defaultDebounceMs = deps.debounceMs?.default ?? DEFAULT_DEBOUNCE_MS;
105
134
  const heartbeatMs = deps.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
106
135
  const metricsMs = deps.metricsMs ?? DEFAULT_METRICS_MS;
136
+ const healthEvents = deps.healthEvents ?? true;
137
+ const now = deps.now ?? (() => Date.now());
107
138
  const maxQueuedPerSink = deps.maxQueuedPerSink ?? DEFAULT_MAX_QUEUED;
108
139
  const setTimer = deps.setTimer ?? ((cb, ms) => setInterval(cb, ms));
109
140
  const clearTimer = deps.clearTimer ?? ((h) => clearInterval(h));
@@ -273,6 +304,27 @@ export function createHostStream(deps = {}) {
273
304
  const events = {};
274
305
  for (const [type, count] of eventCounters) events[type] = count;
275
306
  log(`events=${JSON.stringify(events)} sinks=${sinks.size}${overflowCounter > 0 ? ` overflows=${overflowCounter}` : ''}`);
307
+
308
+ // Phase E5: broadcast the same counters as a typed `stream.health`
309
+ // event so SPA tabs can observe live stream health without polling.
310
+ // Built from the interval's counters BEFORE the reset below, so the
311
+ // payload is a per-interval rate. The broadcast itself bumps the
312
+ // `stream.health` counter, but the immediately-following reset wipes
313
+ // it — the next interval never double-counts this tick's own emit.
314
+ // Bypasses debounce (immediate path) since each tick is already
315
+ // rate-limited to the metrics cadence.
316
+ if (healthEvents) {
317
+ /** @type {StreamHealthPayload} */
318
+ const payload = {
319
+ events,
320
+ sinks: sinks.size,
321
+ overflows: overflowCounter,
322
+ intervalMs: metricsMs,
323
+ at: now(),
324
+ };
325
+ doBroadcast(STREAM_HEALTH_EVENT, payload);
326
+ }
327
+
276
328
  eventCounters.clear();
277
329
  overflowCounter = 0;
278
330
  }
@@ -37,6 +37,7 @@ import pg from 'pg';
37
37
  import { ensureSecret, timingSafeEqual, SECRET_PATH } from './plan-chat-secret.mjs';
38
38
  import { listPlanningSessions } from './planning-sessions.mjs';
39
39
  import { crystallizePlanningSession } from './crystallize-planning.mjs';
40
+ import { resolveId, RESOLVE_ID_RE, createRateLimiter } from './resolver.mjs';
40
41
 
41
42
  const DEFAULT_PORT = 3200;
42
43
  const DEFAULT_DB_URL = 'postgres://postgres:spike@localhost:54321/chunks';
@@ -243,6 +244,10 @@ export function createHandler({
243
244
  * so the test harness can inject a hardcoded server-resolved actor_id and verify
244
245
  * the mismatch guard. Production callers omit this. */
245
246
  resolveActor,
247
+ /** Phase A A1 — optional override for tests. When supplied, replaces the
248
+ * default per-bearer rate limiter (60 req/min). Tests inject a stub with
249
+ * lower capacity to exercise the 429 path quickly. Production callers omit. */
250
+ rateLimiter,
246
251
  }) {
247
252
  if (!pool) throw new Error('createHandler: { pool } required');
248
253
  if (typeof bearer !== 'string' || bearer.length === 0) {
@@ -258,6 +263,13 @@ export function createHandler({
258
263
  ? shapeDebugLog
259
264
  : (msg, details) => console.error(msg, details);
260
265
 
266
+ // Phase A A1 — per-bearer rate limiter for /v1/resolve. Single token
267
+ // bucket keyed by the bearer string; capacity 60, refilled at 60/min.
268
+ // Closes the brute-force enumeration vector (bearer auth alone keeps
269
+ // unauthenticated probes out, but an authenticated caller could grind
270
+ // through ids at line-rate without the bucket).
271
+ const resolveLimiter = rateLimiter ?? createRateLimiter({ capacity: 60, windowMs: 60_000 });
272
+
261
273
  function checkAuth(req) {
262
274
  const header = req.headers.authorization;
263
275
  if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
@@ -707,6 +719,37 @@ export function createHandler({
707
719
  }
708
720
  }
709
721
 
722
+ // Phase A A1 (plan-chat-spa-supersedes-control-plane) — resolver
723
+ // endpoint. Disambiguates :id between planning_sessions.session_id
724
+ // and planning_artifacts.crystallized_world_id via a single UNION ALL
725
+ // SQL query (see resolver.mjs). Bearer required (closes K1 SEC-1
726
+ // enumeration oracle); per-bearer 60 req/min token bucket closes the
727
+ // authenticated brute-force vector.
728
+ async function handleGetResolve(req, res, id) {
729
+ if (!checkAuth(req)) return unauthorized(res);
730
+
731
+ // Rate-limit BEFORE shape validation so a flood of invalid ids
732
+ // still consumes the bucket. Key on the bearer; the bucket is
733
+ // global to a single-secret service. When A4 adds multi-secret
734
+ // resolution, this key becomes principal.actorId.
735
+ const { allowed, retryAfterMs } = resolveLimiter.take(bearer);
736
+ if (!allowed) {
737
+ res.setHeader('retry-after', Math.ceil(retryAfterMs / 1000));
738
+ return send(res, 429, { error: 'rate-limited', message: 'too many requests' });
739
+ }
740
+
741
+ if (!RESOLVE_ID_RE.test(id)) {
742
+ return badRequest(res, 'id must match [A-Za-z0-9._-]{6,80}');
743
+ }
744
+
745
+ try {
746
+ const result = await resolveId(pool, id);
747
+ return send(res, 200, result);
748
+ } catch (err) {
749
+ return send(res, 500, { error: 'resolve-failed', message: String(err?.message ?? err) });
750
+ }
751
+ }
752
+
710
753
  // H4 (Phase G) — GET + PATCH /v1/artifacts/:id endpoint pair backing
711
754
  // the SPA's editor-view round-trip. GET returns the artifact row;
712
755
  // PATCH updates body (the JSON payload) + bumps updated_at via trigger.
@@ -776,6 +819,13 @@ export function createHandler({
776
819
  if (req.method === 'GET' && url.pathname === '/v1/shape') return handleGetShape(req, res, url);
777
820
  if (req.method === 'GET' && url.pathname === '/v1/planning-sessions') return handleGetPlanningSessions(req, res, url);
778
821
  if (req.method === 'POST' && url.pathname === '/v1/crystallize') return handlePostCrystallize(req, res);
822
+ // Phase A A1 — /v1/resolve/:id (plan-chat-spa-supersedes-control-plane).
823
+ const resolveMatch = /^\/v1\/resolve\/([^/]+)$/.exec(url.pathname);
824
+ if (resolveMatch && req.method === 'GET') {
825
+ const id = decodeURIComponent(resolveMatch[1]);
826
+ return handleGetResolve(req, res, id);
827
+ }
828
+
779
829
  // H4 — /v1/artifacts/:id pair
780
830
  const artifactMatch = /^\/v1\/artifacts\/([^/]+)$/.exec(url.pathname);
781
831
  if (artifactMatch) {
@@ -815,6 +865,7 @@ export async function startService(opts = {}) {
815
865
  shapeDebug: opts.shapeDebug,
816
866
  shapeDebugLog: opts.shapeDebugLog,
817
867
  resolveActor: opts.resolveActor,
868
+ rateLimiter: opts.rateLimiter,
818
869
  });
819
870
  const server = http.createServer((req, res) => {
820
871
  handler(req, res).catch((err) => {