@sleepy_panda_srl/portulan 0.1.2 → 0.2.0

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 (81) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +142 -291
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1443 -1210
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +225 -1071
  10. package/cli/drills.mjs +1107 -0
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +595 -0
  15. package/cli/gate.mjs +7 -198
  16. package/cli/goldens.mjs +311 -0
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +244 -564
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +764 -0
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +6 -68
  27. package/cli/pack-version.mjs +13 -248
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +16 -240
  31. package/cli/release-eval.mjs +748 -0
  32. package/cli/review-meter.mjs +500 -0
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +424 -0
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +126 -454
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +582 -0
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +9 -74
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/gate-policy.md +27 -8
  54. package/core/templates/handoff.md +28 -15
  55. package/core/templates/memory-entry.md +1 -0
  56. package/evals/README.md +320 -0
  57. package/evals/releases/0.1.3.json +134 -0
  58. package/evals/releases/0.1.3.md +64 -0
  59. package/evals/releases/0.2.0.json +146 -0
  60. package/evals/releases/0.2.0.md +67 -0
  61. package/evals/releases/README.md +32 -0
  62. package/package.json +9 -2
  63. package/packs/rituals/checkpoints/README.md +9 -2
  64. package/packs/rituals/checkpoints/pack.json +1 -1
  65. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
  66. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  67. package/spec/README.md +112 -6
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  70. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  71. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  72. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  73. package/spec/migrations/0006-boot-card.mjs +77 -0
  74. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  75. package/spec/migrations/0008-card-reading.mjs +55 -0
  76. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  77. package/spec/migrations/0010-comment-rail.mjs +57 -0
  78. package/spec/migrations/README.md +45 -10
  79. package/spec/pack.schema.json +1 -1
  80. package/spec/slots.md +359 -23
  81. package/spec/workspace.schema.json +145 -4
@@ -0,0 +1,582 @@
1
+ #!/usr/bin/env node
2
+ // The OTel emitter: the review-loop figures as OTLP/HTTP JSON, sent only when a committed config opts in.
3
+ //
4
+ // node cli/telemetry.mjs --config <file> [--repo-root <dir>] [--render | --check <file> | --write <file> | --export]
5
+ // node cli/telemetry.mjs --audit-recipes [--workspace <dir>] [--repo-root <dir>] [--pack-root <dir>]...
6
+ //
7
+ // Exit 0 done · 1 a verdict (golden drift, export while opted out, a recipe reaching the network) · 2 could not run.
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { spawnSync } from "node:child_process";
12
+ import { pathToFileURL } from "node:url";
13
+
14
+ import { meter, validateSnapshot } from "./review-meter.mjs";
15
+ import { VERSION } from "./manifest.mjs";
16
+ import { recipeSet, resolverFor } from "./recipe-set.mjs";
17
+ import { isInside } from "./inside.mjs";
18
+
19
+ // ---------------------------------------------------------------------------------------------
20
+ // The offline audit: no verify recipe may reach the network
21
+ // ---------------------------------------------------------------------------------------------
22
+
23
+ /** Every mode in cli/ that can reach the network; a new one is unrailed until it has a row here. */
24
+ export const NETWORK_MODES = Object.freeze([
25
+ { module: "cli/review-meter.mjs", flag: "--fetch", what: "fetches review data from GitHub" },
26
+ { module: "cli/telemetry.mjs", flag: "--export", what: "posts an OTLP payload to a collector" },
27
+ { module: "cli/feedback.mjs", flag: "--approve", what: "files a GitHub issue with `gh issue create`" },
28
+ ]);
29
+
30
+ /** Whole-line comments only: a trailing `#` may sit inside a quote. */
31
+ export const stripShellComments = (source) =>
32
+ source
33
+ .split("\n")
34
+ .filter((line) => !/^\s*#/.test(line))
35
+ .join("\n");
36
+
37
+ const shellTokens = (body) => body.split(/\s+/).map((t) => t.replace(/^[("'`]+/, "").replace(/[)"'`;]+$/, "")).filter(Boolean);
38
+
39
+ const tokenNamesModule = (token, module) => token === module || token.endsWith(`/${module}`);
40
+
41
+ const tokenIsFlag = (token, flag) => token === flag || token.startsWith(`${flag}=`);
42
+
43
+ export function auditRecipeSource(source) {
44
+ const tokens = shellTokens(stripShellComments(source));
45
+ return NETWORK_MODES.filter((m) => tokens.some((t) => tokenNamesModule(t, m.module)) && tokens.some((t) => tokenIsFlag(t, m.flag)));
46
+ }
47
+
48
+ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
49
+ const workspace = path.resolve(repoRoot, workspaceDir);
50
+ if (!isInside(path.resolve(repoRoot), workspace)) {
51
+ return { ok: false, why: `the workspace at ${workspace} resolves outside the repository at ${repoRoot}; this audit grades the pinned tree and will not read past it` };
52
+ }
53
+ const roots = packRoots.map((r) => path.resolve(repoRoot, r));
54
+ let manifest;
55
+ try {
56
+ manifest = JSON.parse(fs.readFileSync(path.join(workspace, "workspace.json"), "utf8"));
57
+ } catch (cause) {
58
+ return { ok: false, why: `the workspace manifest at ${workspace} could not be read — ${cause.message}` };
59
+ }
60
+ let set;
61
+ try {
62
+ set = recipeSet(manifest, { resolve: resolverFor({ workspaceDir: workspace, manifest, repoRoot, named: roots, discovery: null, forced: false }) });
63
+ } catch (cause) {
64
+ return { ok: false, why: cause.message };
65
+ }
66
+ if (!set.ok) return { ok: false, why: set.reason };
67
+ const recipes = set.recipes ?? [];
68
+ if (recipes.length === 0) {
69
+ return { ok: false, why: "the workspace yielded NO recipes — an empty set makes this audit vacuously green, which is the one answer it may never give" };
70
+ }
71
+
72
+ const findings = [];
73
+ const read = [];
74
+ for (const r of recipes) {
75
+ const script = String(r.run ?? "")
76
+ .split(/\s+/)
77
+ .find((t) => t.endsWith(".sh"));
78
+ if (!script) {
79
+ return { ok: false, why: `recipe ${JSON.stringify(r.id)} has no readable script in its run line (${JSON.stringify(r.run ?? null)}); this audit cannot grade what it cannot read` };
80
+ }
81
+ const file = path.resolve(repoRoot, script);
82
+ if (!isInside(path.resolve(repoRoot), file)) {
83
+ return { ok: false, why: `recipe ${JSON.stringify(r.id)} names ${script}, which resolves outside the repository at ${repoRoot}; this audit grades the tree and will not read past it` };
84
+ }
85
+ let source;
86
+ try {
87
+ source = fs.readFileSync(file, "utf8");
88
+ } catch (cause) {
89
+ return { ok: false, why: `recipe ${JSON.stringify(r.id)} names ${script}, which could not be read — ${cause.message}` };
90
+ }
91
+ read.push(r.id);
92
+ for (const hit of auditRecipeSource(source)) findings.push({ recipe: r.id, script, ...hit });
93
+ }
94
+ return { ok: true, examined: read, findings };
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------------------------
98
+ // The config — read, validated, and never guessed at
99
+ // ---------------------------------------------------------------------------------------------
100
+
101
+ /** The config document's own version, so a future shape change is a refusal rather than a misread. */
102
+ export const CONFIG_SPEC = "1";
103
+
104
+ /** The config's problems; empty means usable. */
105
+ export function validateConfig(config) {
106
+ const problems = [];
107
+ if (config === null || typeof config !== "object" || Array.isArray(config)) {
108
+ return ["the config is not a JSON object"];
109
+ }
110
+ const spec = config.portulan?.telemetry;
111
+ if (spec !== CONFIG_SPEC) {
112
+ problems.push(`portulan.telemetry must be ${JSON.stringify(CONFIG_SPEC)}, not ${JSON.stringify(spec ?? null)}`);
113
+ }
114
+ if (typeof config.enabled !== "boolean") {
115
+ problems.push(`enabled must be a boolean — absent is not false, because a gate you can reach by omission is not a gate (got ${JSON.stringify(config.enabled ?? null)})`);
116
+ }
117
+ if (typeof config.service?.name !== "string" || config.service.name.length === 0) {
118
+ problems.push("service.name must be a non-empty string — it becomes the `service.name` resource attribute");
119
+ }
120
+ if (config.service?.namespace !== undefined && typeof config.service.namespace !== "string") {
121
+ problems.push("service.namespace, where present, must be a string");
122
+ }
123
+ if (!Array.isArray(config.signals) || config.signals.length === 0) {
124
+ problems.push("signals must be a non-empty array — an emitter with no signal is a tool that exits 0 having done nothing");
125
+ } else {
126
+ for (const s of config.signals) {
127
+ if (!Object.prototype.hasOwnProperty.call(PRODUCERS, s)) {
128
+ problems.push(`signals names ${JSON.stringify(s)}, which no producer answers to — known: ${Object.keys(PRODUCERS).join(", ")}`);
129
+ }
130
+ }
131
+ }
132
+ for (const banned of ["headers", "endpoint", "token", "apiKey", "api_key"]) {
133
+ if (config[banned] !== undefined || config.exporter?.[banned] !== undefined) {
134
+ problems.push(
135
+ `${banned} may not be set in a committed config — transport and secrets come from the ` +
136
+ "OTEL_EXPORTER_OTLP_* environment, so a token never enters this repository",
137
+ );
138
+ }
139
+ }
140
+ return problems;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------------------------
144
+ // The producer registry — the closed payload, as code
145
+ // ---------------------------------------------------------------------------------------------
146
+
147
+ /** The payload's allow-list: a producer builds its rows from named figures and never spreads a snapshot into them. */
148
+ export const PRODUCERS = {
149
+ "review-loop": {
150
+ input: "evals/review-loop/snapshot.json",
151
+ scope: "portulan/review-loop",
152
+ /** OTLP's timeUnixNano is when a figure applies, so the capture stamp, which also keeps the golden byte-stable. */
153
+ capturedAt: (snapshot) => snapshot.captured ?? null,
154
+ validate: (snapshot) => validateSnapshot(snapshot),
155
+ rows(snapshot) {
156
+ const m = meter(snapshot);
157
+ return [
158
+ { name: "portulan.review.pull_requests", unit: "{pull_request}", description: "Merged pull requests in the metered window.", value: m.pullRequests },
159
+ { name: "portulan.review.submissions", unit: "{submission}", description: "Reviews the reviewer submitted across the window. Submission units, never fix-rounds.", value: m.submissions },
160
+ { name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure a-review-loop-needs-a-bound.md bounds the loop on.", value: m.submissionsPerPullRequest },
161
+ { name: "portulan.review.submissions_no_inline", unit: "{submission}", description: "Submissions raising no inline comment.", value: m.noInline },
162
+ { name: "portulan.review.no_inline_rate", unit: "1", description: "An UPPER BOUND on the found-nothing rate; a submission carrying only suppressed notes counts in it.", value: m.noInlineRate },
163
+ { name: "portulan.review.pushes", unit: "{push}", description: "Distinct heads the reviewer saw — a floor on pushes, not a count of them.", value: m.pushes },
164
+ { name: "portulan.review.pushes_per_submission", unit: "1", description: "The criterion's literal pushes-per-round figure, in submission units.", value: m.pushesPerSubmission },
165
+ ];
166
+ },
167
+ attributes: (snapshot) => ({
168
+ "portulan.window.merged": snapshot.window?.merged ?? 0,
169
+ "portulan.window.captured": snapshot.captured ?? "",
170
+ "portulan.units": "submission",
171
+ }),
172
+ /** The payload's one identifier, the repository's; nothing naming a person, such as a login, may join it. */
173
+ resource: (snapshot) => ({ "portulan.repository": snapshot.repository ?? "" }),
174
+ },
175
+ };
176
+
177
+ /** Every attribute key a payload may carry; the suite fails a rendered key outside it. */
178
+ export const EMITTED_ATTRIBUTE_KEYS = Object.freeze([
179
+ "service.name",
180
+ "service.namespace",
181
+ "telemetry.sdk.name",
182
+ "telemetry.sdk.language",
183
+ "telemetry.sdk.version",
184
+ "portulan.repository",
185
+ "portulan.window.merged",
186
+ "portulan.window.captured",
187
+ "portulan.units",
188
+ ]);
189
+
190
+ export const REQUIRED_ATTRIBUTE_KEYS = Object.freeze(EMITTED_ATTRIBUTE_KEYS.filter((k) => k !== "service.namespace"));
191
+
192
+ // ---------------------------------------------------------------------------------------------
193
+ // OTLP/HTTP JSON — the wire shape, written by hand
194
+ // ---------------------------------------------------------------------------------------------
195
+
196
+ export function anyValue(v) {
197
+ if (typeof v === "string") return { stringValue: v };
198
+ if (typeof v === "boolean") return { boolValue: v };
199
+ // Past 2^53 a number may not be the integer meant, so it goes out as a double rather than as a wrong int64.
200
+ if (Number.isSafeInteger(v)) return { intValue: String(v) };
201
+ if (typeof v === "number" && Number.isFinite(v)) return { doubleValue: v };
202
+ throw new Error(`no OTLP encoding for ${JSON.stringify(v)}`);
203
+ }
204
+
205
+ const kv = (attrs) => Object.entries(attrs).map(([key, value]) => ({ key, value: anyValue(value) }));
206
+
207
+ /** Gauges, not Sums: a Sum owes a start time, and the snapshot records no window start. */
208
+ export function renderPayload({ config, signals, version = VERSION }) {
209
+ const resource = { "service.name": config.service.name, "telemetry.sdk.name": "portulan", "telemetry.sdk.language": "nodejs", "telemetry.sdk.version": version };
210
+ if (config.service.namespace !== undefined) resource["service.namespace"] = config.service.namespace;
211
+ for (const s of signals) Object.assign(resource, s.resource ?? {});
212
+
213
+ const scopeMetrics = [];
214
+ for (const s of signals) {
215
+ const timeUnixNano = String(BigInt(Date.parse(s.capturedAt)) * 1000000n);
216
+ const attributes = kv(s.attributes);
217
+ const metrics = s.rows
218
+ .filter((r) => r.value !== null && Number.isFinite(r.value))
219
+ .map((r) => ({
220
+ name: r.name,
221
+ unit: r.unit,
222
+ description: r.description,
223
+ gauge: { dataPoints: [{ timeUnixNano, attributes, ...(Number.isSafeInteger(r.value) ? { asInt: String(r.value) } : { asDouble: r.value }) }] },
224
+ }));
225
+ scopeMetrics.push({ scope: { name: s.scope, version }, metrics });
226
+ }
227
+ return { resourceMetrics: [{ resource: { attributes: kv(resource) }, scopeMetrics }] };
228
+ }
229
+
230
+ /** The bytes on the wire and in the golden are the same bytes, produced here and nowhere else. */
231
+ export const serialize = (payload) => `${JSON.stringify(payload, null, 2)}\n`;
232
+
233
+ // ---------------------------------------------------------------------------------------------
234
+ // Transport — the ONE mode that reaches the network
235
+ // ---------------------------------------------------------------------------------------------
236
+
237
+ /** A URL safe to print: origin and path, since userinfo and a query may carry credentials. */
238
+ export function safeEndpoint(href) {
239
+ try {
240
+ const u = new URL(href);
241
+ u.username = "";
242
+ u.password = "";
243
+ u.search = "";
244
+ u.hash = "";
245
+ return u.href;
246
+ } catch {
247
+ return "<withheld: not a parsable URL>";
248
+ }
249
+ }
250
+
251
+ /** Endpoint and headers only: the OTLP protocol, timeout, compression, certificate and client-key variables go unread. */
252
+ export function transportFromEnv(env) {
253
+ const specific = env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT;
254
+ const base = env.OTEL_EXPORTER_OTLP_ENDPOINT;
255
+ const named = typeof specific === "string" && specific.length > 0 ? "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" : "OTEL_EXPORTER_OTLP_ENDPOINT";
256
+ const raw = named === "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" ? specific : base;
257
+ if (typeof raw !== "string" || raw.length === 0) {
258
+ return { ok: false, why: "neither OTEL_EXPORTER_OTLP_METRICS_ENDPOINT nor OTEL_EXPORTER_OTLP_ENDPOINT is set" };
259
+ }
260
+ let url;
261
+ try {
262
+ url = new URL(named === "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" ? raw : raw.endsWith("/") ? `${raw}v1/metrics` : `${raw}/v1/metrics`);
263
+ } catch {
264
+ return { ok: false, why: `${named} is not a parsable URL — its value is withheld, since an endpoint may carry credentials` };
265
+ }
266
+ const headerSource = env.OTEL_EXPORTER_OTLP_METRICS_HEADERS ?? env.OTEL_EXPORTER_OTLP_HEADERS ?? "";
267
+ const headers = { "content-type": "application/json" };
268
+ for (const pair of headerSource.split(",")) {
269
+ const at = pair.indexOf("=");
270
+ if (at > 0) headers[pair.slice(0, at).trim().toLowerCase()] = pair.slice(at + 1).trim();
271
+ }
272
+ return { ok: true, url: url.href, headers, from: named };
273
+ }
274
+
275
+ /** The real send; the suite injects its own, since it runs inside a verify recipe, which may not reach the network. */
276
+ export async function postJson(url, headers, body) {
277
+ const res = await fetch(url, { method: "POST", headers, body });
278
+ return { status: res.status, ok: res.ok, text: await res.text().catch(() => "") };
279
+ }
280
+
281
+ /** Refuses a config that is untracked, staged and never committed, or changed since HEAD. */
282
+ export function consentIsCommitted(configPath, repoRoot, spawn = spawnSync) {
283
+ const parent = path.resolve(repoRoot);
284
+ const child = path.resolve(parent, configPath);
285
+ if (!isInside(parent, child)) {
286
+ return { ok: false, why: `${configPath} is outside the repository at ${repoRoot}, so nothing can establish that it is committed` };
287
+ }
288
+ // Git pathspecs and HEAD:<path> want forward slashes, whatever separator path.relative gives.
289
+ const rel = path.relative(parent, child).split(path.sep).join("/");
290
+ const git = (args) => spawn("git", ["-C", parent, ...args], { encoding: "utf8" });
291
+
292
+ // Asked first, since `git show HEAD:<path>` also exits 128 for a path staged but never committed.
293
+ const repo = git(["rev-parse", "--git-dir"]);
294
+ if (repo.error) return { ok: false, why: `git could not be run to establish whether the consent is committed — ${repo.error.message}` };
295
+ if (repo.status !== 0) {
296
+ return { ok: false, why: `${parent} is not a git repository, so nothing there can be committed — ${(repo.stderr || "").trim() || "git rev-parse --git-dir failed"}` };
297
+ }
298
+
299
+ const tracked = git(["ls-files", "--error-unmatch", "--", rel]);
300
+ if (tracked.error) return { ok: false, why: `git could not be run to establish whether the consent is committed — ${tracked.error.message}` };
301
+ if (tracked.status !== 0) {
302
+ return { ok: false, why: `${rel} is not tracked by git, so it is one working copy's opinion rather than the team's committed consent` };
303
+ }
304
+
305
+ const head = git(["show", `HEAD:${rel}`]);
306
+ if (head.error) return { ok: false, why: `git could not read ${rel} at HEAD — ${head.error.message}` };
307
+ if (head.status !== 0) {
308
+ return { ok: false, why: `${rel} is tracked but does not exist at HEAD, so the consent it states has been staged and never committed` };
309
+ }
310
+
311
+ // `git diff`, not a byte compare: under core.autocrlf a committed file checks out as CRLF over an LF blob.
312
+ const diff = git(["diff", "--quiet", "HEAD", "--", rel]);
313
+ if (diff.error) return { ok: false, why: `git could not compare ${rel} against HEAD — ${diff.error.message}` };
314
+ if (diff.status === 1) {
315
+ return { ok: false, why: `${rel} differs from HEAD — an edited consent is not a committed one. Commit it, or run --render to see what WOULD be sent` };
316
+ }
317
+ if (diff.status !== 0) {
318
+ return { ok: false, why: `git could not compare ${rel} against HEAD — it exited ${diff.status}: ${(diff.stderr || "").trim()}` };
319
+ }
320
+ return { ok: true };
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------------------------
324
+ // The command line
325
+ // ---------------------------------------------------------------------------------------------
326
+
327
+ const USAGE = [
328
+ "usage: node cli/telemetry.mjs --config <file> [--repo-root <dir>] [--render | --check <file> | --write <file> | --export]",
329
+ "",
330
+ " --config <file> the committed opt-in config; the ONLY gate on emission",
331
+ " --render print the OTLP/HTTP JSON payload and open no socket (the default)",
332
+ " --check <file> byte-compare the payload against a committed golden",
333
+ " --write <file> rewrite that golden",
334
+ " --export the ONE mode that reaches the network; refuses unless the config opts in",
335
+ " AND is committed. Transport comes from the OTLP environment, signal-specific",
336
+ " first: OTEL_EXPORTER_OTLP_METRICS_ENDPOINT (used as given) else",
337
+ " OTEL_EXPORTER_OTLP_ENDPOINT (a base, +/v1/metrics); and",
338
+ " OTEL_EXPORTER_OTLP_METRICS_HEADERS replacing OTEL_EXPORTER_OTLP_HEADERS",
339
+ " --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode;",
340
+ " takes --workspace and --pack-root, and needs no --config",
341
+ "",
342
+ "exit 0 the thing asked for happened · 1 a verdict (golden drift, or export while opted out)",
343
+ " · 2 could not run (config or snapshot missing, unreadable, or malformed)",
344
+ ].join("\n");
345
+
346
+ export const PATH_OPTIONS = Object.freeze(["config", "check", "write", "workspace"]);
347
+
348
+ /** Resolves each path option against the repository root; an absolute one stays as given. */
349
+ export function pinPaths(opts) {
350
+ const root = path.resolve(opts.repoRoot);
351
+ const pinned = { ...opts, repoRoot: root, packRoots: opts.packRoots.map((r) => path.resolve(root, r)) };
352
+ for (const key of PATH_OPTIONS) {
353
+ if (typeof pinned[key] === "string" && pinned[key].length > 0) pinned[key] = path.resolve(root, pinned[key]);
354
+ }
355
+ return pinned;
356
+ }
357
+
358
+ function parseArgs(argv) {
359
+ const opts = { config: null, repoRoot: ".", render: false, check: null, write: null, export: false, audit: false, workspace: ".portulan", packRoots: [], help: false };
360
+ for (let i = 0; i < argv.length; i += 1) {
361
+ const a = argv[i];
362
+ const next = () => {
363
+ const v = argv[i + 1];
364
+ if (v === undefined) throw new Error(`${a} needs a value`);
365
+ i += 1;
366
+ return v;
367
+ };
368
+ if (a === "--config") opts.config = next();
369
+ else if (a === "--repo-root") opts.repoRoot = next();
370
+ else if (a === "--render") opts.render = true;
371
+ else if (a === "--check") opts.check = next();
372
+ else if (a === "--write") opts.write = next();
373
+ else if (a === "--export") opts.export = true;
374
+ else if (a === "--audit-recipes") opts.audit = true;
375
+ else if (a === "--workspace") opts.workspace = next();
376
+ else if (a === "--pack-root") opts.packRoots.push(next());
377
+ else if (a === "--help" || a === "-h") opts.help = true;
378
+ else throw new Error(`unrecognised argument ${JSON.stringify(a)}`);
379
+ }
380
+ return opts;
381
+ }
382
+
383
+ const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
384
+
385
+ export async function run(argv = process.argv.slice(2), io = console, { env = process.env, post = postJson } = {}) {
386
+ let opts;
387
+ try {
388
+ opts = parseArgs(argv);
389
+ } catch (e) {
390
+ io.error(`telemetry: ${e.message}`);
391
+ io.error(USAGE);
392
+ return 2;
393
+ }
394
+ if (opts.help) {
395
+ io.log(USAGE);
396
+ return 0;
397
+ }
398
+ opts = pinPaths(opts);
399
+ if (opts.audit) {
400
+ const res = auditRecipes({ workspaceDir: opts.workspace, repoRoot: opts.repoRoot, packRoots: opts.packRoots });
401
+ if (!res.ok) {
402
+ io.error(`telemetry: the offline audit could not run — ${res.why}`);
403
+ return 2;
404
+ }
405
+ if (res.findings.length > 0) {
406
+ io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network:`);
407
+ for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${f.module} ${f.flag}, which ${f.what}`);
408
+ io.error("A verify recipe may not make a network call: spec/slots.md and .portulan/verify/README.md");
409
+ io.error("both prohibit it, because a rail that moves with the network goes red about the world");
410
+ io.error("rather than about the tree.");
411
+ return 1;
412
+ }
413
+ io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode`);
414
+ io.log(` Modes railed: ${NETWORK_MODES.map((m) => `${m.module} ${m.flag}`).join(", ")}.`);
415
+ io.log(" A network mode with no row here is UNRAILED, and nothing can audit that a row was added.");
416
+ return 0;
417
+ }
418
+
419
+ if (!opts.config) {
420
+ io.error("telemetry: --config <file> is required — the committed config is the only gate on emission,");
421
+ io.error("and a run that inferred one would be an emitter deciding its own consent.");
422
+ return 2;
423
+ }
424
+ const modes = [opts.render, opts.check !== null, opts.write !== null, opts.export].filter(Boolean).length;
425
+ if (modes > 1) {
426
+ io.error("telemetry: --render, --check, --write and --export ask for different things; pick one");
427
+ return 2;
428
+ }
429
+
430
+ // ---- the config
431
+ const configPath = opts.config;
432
+ let config;
433
+ try {
434
+ config = readJson(configPath);
435
+ } catch (e) {
436
+ io.error(`telemetry: cannot read ${configPath} — ${e.message}`);
437
+ io.error("This is could-not-run, NOT opted out: a config that will not parse may well say `enabled: true`.");
438
+ return 2;
439
+ }
440
+ const problems = validateConfig(config);
441
+ if (problems.length > 0) {
442
+ io.error(`telemetry: ${configPath} is not a usable opt-in config:`);
443
+ for (const p of problems) io.error(` - ${p}`);
444
+ io.error("This is could-not-run, NOT opted out — a malformed config states no decision either way.");
445
+ return 2;
446
+ }
447
+
448
+ // ---- the signals
449
+ const signals = [];
450
+ for (const name of config.signals) {
451
+ const producer = PRODUCERS[name];
452
+ const input = path.resolve(opts.repoRoot, producer.input);
453
+ let snapshot;
454
+ try {
455
+ snapshot = readJson(input);
456
+ } catch (e) {
457
+ io.error(`telemetry: signal ${JSON.stringify(name)} cannot read its input ${input} — ${e.message}`);
458
+ return 2;
459
+ }
460
+ const problems = producer.validate?.(snapshot) ?? [];
461
+ if (problems.length > 0) {
462
+ io.error(`telemetry: signal ${JSON.stringify(name)} cannot be metered from ${input}:`);
463
+ for (const p of problems) io.error(` - ${p}`);
464
+ io.error("A malformed input would render a payload with defaulted metadata rather than none,");
465
+ io.error("and the payload is the thing that leaves the machine.");
466
+ return 2;
467
+ }
468
+ const capturedAt = producer.capturedAt(snapshot);
469
+ if (typeof capturedAt !== "string" || Number.isNaN(Date.parse(capturedAt))) {
470
+ io.error(`telemetry: signal ${JSON.stringify(name)} has no parsable capture stamp (${JSON.stringify(capturedAt)});`);
471
+ io.error("OTLP needs the instant the measurement is ABOUT, and a clock read here would be a different figure.");
472
+ return 2;
473
+ }
474
+ signals.push({ name, scope: producer.scope, capturedAt, rows: producer.rows(snapshot), attributes: producer.attributes(snapshot), resource: producer.resource?.(snapshot) ?? {} });
475
+ }
476
+
477
+ let body;
478
+ try {
479
+ body = serialize(renderPayload({ config, signals }));
480
+ } catch (e) {
481
+ io.error(`telemetry: the payload could not be encoded — ${e.message}`);
482
+ return 2;
483
+ }
484
+
485
+ const emitted = signals.reduce((n, s) => n + s.rows.filter((r) => r.value !== null && Number.isFinite(r.value)).length, 0);
486
+
487
+ // ---- --write
488
+ if (opts.write !== null) {
489
+ fs.mkdirSync(path.dirname(opts.write), { recursive: true });
490
+ fs.writeFileSync(opts.write, body);
491
+ io.log(`telemetry: wrote ${opts.write} — ${emitted} metric(s) over ${signals.length} signal(s)`);
492
+ return 0;
493
+ }
494
+
495
+ // ---- --check
496
+ if (opts.check !== null) {
497
+ let onDisk;
498
+ try {
499
+ onDisk = fs.readFileSync(opts.check, "utf8");
500
+ } catch (e) {
501
+ io.error(`telemetry: cannot read ${opts.check} — ${e.message}`);
502
+ io.error("The golden is generated; run with --write to create it.");
503
+ return 2;
504
+ }
505
+ if (onDisk !== body) {
506
+ io.error(`telemetry: ${opts.check} is out of date against the payload this config renders`);
507
+ io.error("It is generated and byte-compared. Regenerate it with --write; do not edit it by hand.");
508
+ return 1;
509
+ }
510
+ io.log(`telemetry: ${opts.check} is byte-identical to the rendered payload (${emitted} metric(s))`);
511
+ return 0;
512
+ }
513
+
514
+ // ---- --export, the one mode that reaches the network
515
+ if (opts.export) {
516
+ // Never silent: an opted-out emitter and one that never started would look alike.
517
+ if (config.enabled !== true) {
518
+ io.error(`telemetry: ${configPath} says enabled: false, so nothing was sent.`);
519
+ io.error("This is a verdict, not a failure: the config was read and it opts out. Emission is");
520
+ io.error("the maintainer's Gated act — the committed config is the standing consent, and this");
521
+ io.error("workspace has not given it. Use --render to see exactly what WOULD be sent.");
522
+ return 1;
523
+ }
524
+ const committed = consentIsCommitted(configPath, opts.repoRoot);
525
+ if (!committed.ok) {
526
+ io.error(`telemetry: --export cannot run — ${committed.why}.`);
527
+ io.error("The ruling of 2026-08-28 is that the COMMITTED config is the standing consent, so a");
528
+ io.error("config that is not committed states nobody's decision. This is could-not-run, not a refusal.");
529
+ return 2;
530
+ }
531
+ const t = transportFromEnv(env);
532
+ if (!t.ok) {
533
+ io.error(`telemetry: --export cannot run — ${t.why}.`);
534
+ io.error("Transport comes from the OpenTelemetry standard environment; nothing is committed here.");
535
+ return 2;
536
+ }
537
+ let res;
538
+ try {
539
+ res = await post(t.url, t.headers, body);
540
+ } catch (e) {
541
+ io.error(`telemetry: the export failed — ${e.message}`);
542
+ io.error("Nothing is queued and nothing is retried: a queue that flushes itself later is a send");
543
+ io.error("nobody attended, which is what the Gated tier exists to prevent.");
544
+ return 2;
545
+ }
546
+ if (!res.ok) {
547
+ io.error(`telemetry: the collector answered ${res.status}${res.text ? ` — ${res.text.slice(0, 200)}` : ""}`);
548
+ return 1;
549
+ }
550
+ io.log(`telemetry: exported ${emitted} metric(s) to ${safeEndpoint(t.url)} — ${res.status}`);
551
+ return 0;
552
+ }
553
+
554
+ // ---- --render, the default
555
+ io.log(body);
556
+ io.log(`telemetry: rendered ${emitted} metric(s) over ${signals.length} signal(s); nothing was sent.`);
557
+ io.log(` This workspace's config says enabled: ${config.enabled}. --export is the only mode that`);
558
+ io.log(" reaches the network, and it refuses unless the committed config opts in.");
559
+ io.log(" Every figure is a Gauge stamped with the instant it is ABOUT, not the instant it was sent;");
560
+ io.log(" a stale snapshot therefore exports a stale timestamp, labelled as such rather than hidden.");
561
+ return 0;
562
+ }
563
+
564
+ // As file URLs, since `import.meta.url` percent-encodes a space; through realpath too, for an npm `bin` symlink.
565
+ function isMain() {
566
+ const invoked = process.argv[1];
567
+ if (!invoked) return false;
568
+ if (import.meta.url === pathToFileURL(invoked).href) return true;
569
+ try {
570
+ return import.meta.url === pathToFileURL(fs.realpathSync(invoked)).href;
571
+ } catch {
572
+ return false;
573
+ }
574
+ }
575
+
576
+ // `process.exitCode` rather than `process.exit`, so a pipe that has not drained is not cut short.
577
+ if (isMain()) {
578
+ run(process.argv.slice(2)).then((code) => {
579
+ process.exitCode = code;
580
+ });
581
+ }
582
+