@bragi-gmbh/codebus 1.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 (133) hide show
  1. package/bin/codebus.js +2 -0
  2. package/dist/binding-guard.js +97 -0
  3. package/dist/binding-guard.js.map +1 -0
  4. package/dist/client.js +94 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/commands/ack.js +47 -0
  7. package/dist/commands/ack.js.map +1 -0
  8. package/dist/commands/archive.js +21 -0
  9. package/dist/commands/archive.js.map +1 -0
  10. package/dist/commands/as.js +299 -0
  11. package/dist/commands/as.js.map +1 -0
  12. package/dist/commands/ask.js +189 -0
  13. package/dist/commands/ask.js.map +1 -0
  14. package/dist/commands/auth.js +33 -0
  15. package/dist/commands/auth.js.map +1 -0
  16. package/dist/commands/checkin.js +235 -0
  17. package/dist/commands/checkin.js.map +1 -0
  18. package/dist/commands/decision.js +52 -0
  19. package/dist/commands/decision.js.map +1 -0
  20. package/dist/commands/digest.js +117 -0
  21. package/dist/commands/digest.js.map +1 -0
  22. package/dist/commands/doctor.js +106 -0
  23. package/dist/commands/doctor.js.map +1 -0
  24. package/dist/commands/factory.js +191 -0
  25. package/dist/commands/factory.js.map +1 -0
  26. package/dist/commands/github-login.js +205 -0
  27. package/dist/commands/github-login.js.map +1 -0
  28. package/dist/commands/graph.js +88 -0
  29. package/dist/commands/graph.js.map +1 -0
  30. package/dist/commands/health.js +34 -0
  31. package/dist/commands/health.js.map +1 -0
  32. package/dist/commands/inbox.js +83 -0
  33. package/dist/commands/inbox.js.map +1 -0
  34. package/dist/commands/ingest.js +553 -0
  35. package/dist/commands/ingest.js.map +1 -0
  36. package/dist/commands/init.js +97 -0
  37. package/dist/commands/init.js.map +1 -0
  38. package/dist/commands/install.js +520 -0
  39. package/dist/commands/install.js.map +1 -0
  40. package/dist/commands/invite.js +152 -0
  41. package/dist/commands/invite.js.map +1 -0
  42. package/dist/commands/jira.js +237 -0
  43. package/dist/commands/jira.js.map +1 -0
  44. package/dist/commands/join.js +96 -0
  45. package/dist/commands/join.js.map +1 -0
  46. package/dist/commands/links.js +66 -0
  47. package/dist/commands/links.js.map +1 -0
  48. package/dist/commands/login.js +84 -0
  49. package/dist/commands/login.js.map +1 -0
  50. package/dist/commands/morning.js +121 -0
  51. package/dist/commands/morning.js.map +1 -0
  52. package/dist/commands/next.js +66 -0
  53. package/dist/commands/next.js.map +1 -0
  54. package/dist/commands/onboarding.js +109 -0
  55. package/dist/commands/onboarding.js.map +1 -0
  56. package/dist/commands/pack.js +276 -0
  57. package/dist/commands/pack.js.map +1 -0
  58. package/dist/commands/plan-gh.js +425 -0
  59. package/dist/commands/plan-gh.js.map +1 -0
  60. package/dist/commands/plan.js +465 -0
  61. package/dist/commands/plan.js.map +1 -0
  62. package/dist/commands/pr.js +88 -0
  63. package/dist/commands/pr.js.map +1 -0
  64. package/dist/commands/question.js +142 -0
  65. package/dist/commands/question.js.map +1 -0
  66. package/dist/commands/read.js +84 -0
  67. package/dist/commands/read.js.map +1 -0
  68. package/dist/commands/refs.js +357 -0
  69. package/dist/commands/refs.js.map +1 -0
  70. package/dist/commands/reply.js +38 -0
  71. package/dist/commands/reply.js.map +1 -0
  72. package/dist/commands/repo.js +1198 -0
  73. package/dist/commands/repo.js.map +1 -0
  74. package/dist/commands/report.js +41 -0
  75. package/dist/commands/report.js.map +1 -0
  76. package/dist/commands/resolve-refs.js +21 -0
  77. package/dist/commands/resolve-refs.js.map +1 -0
  78. package/dist/commands/resolve.js +93 -0
  79. package/dist/commands/resolve.js.map +1 -0
  80. package/dist/commands/send.js +168 -0
  81. package/dist/commands/send.js.map +1 -0
  82. package/dist/commands/sent.js +22 -0
  83. package/dist/commands/sent.js.map +1 -0
  84. package/dist/commands/session.js +426 -0
  85. package/dist/commands/session.js.map +1 -0
  86. package/dist/commands/statusline.js +43 -0
  87. package/dist/commands/statusline.js.map +1 -0
  88. package/dist/commands/task.js +140 -0
  89. package/dist/commands/task.js.map +1 -0
  90. package/dist/commands/tick.js +233 -0
  91. package/dist/commands/tick.js.map +1 -0
  92. package/dist/commands/upgrade.js +76 -0
  93. package/dist/commands/upgrade.js.map +1 -0
  94. package/dist/commands/watch.js +169 -0
  95. package/dist/commands/watch.js.map +1 -0
  96. package/dist/commands/whoami.js +27 -0
  97. package/dist/commands/whoami.js.map +1 -0
  98. package/dist/config.js +103 -0
  99. package/dist/config.js.map +1 -0
  100. package/dist/directives.js +102 -0
  101. package/dist/directives.js.map +1 -0
  102. package/dist/errors.js +110 -0
  103. package/dist/errors.js.map +1 -0
  104. package/dist/git-hooks.js +116 -0
  105. package/dist/git-hooks.js.map +1 -0
  106. package/dist/graph-bootstrap.js +95 -0
  107. package/dist/graph-bootstrap.js.map +1 -0
  108. package/dist/graph-remote.js +889 -0
  109. package/dist/graph-remote.js.map +1 -0
  110. package/dist/graph.js +335 -0
  111. package/dist/graph.js.map +1 -0
  112. package/dist/index.js +154 -0
  113. package/dist/index.js.map +1 -0
  114. package/dist/kit/graph-publish.mjs +400 -0
  115. package/dist/mail-latency.js +120 -0
  116. package/dist/mail-latency.js.map +1 -0
  117. package/dist/mcp-registry.js +859 -0
  118. package/dist/mcp-registry.js.map +1 -0
  119. package/dist/mcp.js +193 -0
  120. package/dist/mcp.js.map +1 -0
  121. package/dist/output.js +12 -0
  122. package/dist/output.js.map +1 -0
  123. package/dist/positioning.js +21 -0
  124. package/dist/positioning.js.map +1 -0
  125. package/dist/rolestore.js +89 -0
  126. package/dist/rolestore.js.map +1 -0
  127. package/dist/session.js +49 -0
  128. package/dist/session.js.map +1 -0
  129. package/dist/sessionstore.js +736 -0
  130. package/dist/sessionstore.js.map +1 -0
  131. package/dist/version-check.js +97 -0
  132. package/dist/version-check.js.map +1 -0
  133. package/package.json +47 -0
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * graph-publish.mjs — SP-B build-ticket "Lane 2" CI-caller rollout kit.
4
+ *
5
+ * Runs in the reusable workflow (.github/workflows/graphify-publish.yml) on
6
+ * every push-to-main. Implements the spec's two non-negotiables:
7
+ * - SHA-identity: graph_head_report is ALWAYS called first, before the
8
+ * build even starts, so the hub learns about a new tip even if the
9
+ * build subsequently fails.
10
+ * - Honest failure: a failed build or failed validation EXITS NON-ZERO.
11
+ * This deliberately does NOT follow the older scripts/graphify-ci.mjs
12
+ * pattern of "exit 0 to not block CI" — that pattern predates the 0050
13
+ * graph-knowledge freshness contract, where a build failure is supposed
14
+ * to leave the repo honestly STALE (via the already-reported head)
15
+ * rather than pretend nothing happened. A red CI check is the correct,
16
+ * visible signal.
17
+ *
18
+ * Round-1 review (PR #33, CB-MSG-005318) fixes applied here:
19
+ * 1 BLOCKER — no more SUPABASE_SERVICE_ROLE_KEY / GRAPH_REPO_ID. This
20
+ * script now authenticates with a per-repo, per-scope bearer token
21
+ * (GRAPH_INGEST_TOKEN) via the anon-executable *_with_token RPCs
22
+ * (0059_graph_ingest_tokens.sql) — repo_id is resolved server-side FROM
23
+ * the token, never supplied by this script. A compromised token can
24
+ * only ever act as the one repo_id/scope it was minted for.
25
+ * 3 MAJOR — every subprocess call uses execFileSync with an argument
26
+ * ARRAY (never string interpolation into a shell command), and
27
+ * GRAPHIFY_REPO_ROOT is resolved + verified to stay under
28
+ * GITHUB_WORKSPACE (or cwd, for local runs) before anything touches it
29
+ * — an absolute path or a `..`-escaping value is rejected outright.
30
+ * 9 MAJOR — no residual service-role-shaped credential lives in this
31
+ * script's env at all now; SUPABASE_ANON_KEY is not sensitive (it is
32
+ * the public client key).
33
+ *
34
+ * Round-15 review finding 1 (CONFIRMED, MAJOR): round-13's fix
35
+ * (.github/workflows/graphify-publish.yml checking the caller out into a
36
+ * dedicated `repo/` subdirectory, a SIBLING of `.codebus-kit/`, specifically
37
+ * so graphify could never traverse into the kit) was bypassable through
38
+ * GRAPHIFY_REPO_ROOT itself — this script validated the resolved root
39
+ * against GITHUB_WORKSPACE (the shared ANCESTOR of both `repo/` and
40
+ * `.codebus-kit/`), not against `repo/` itself, so `GRAPHIFY_REPO_ROOT=..`
41
+ * resolved right back to the workspace root (still "inside" that wider
42
+ * boundary) and `../.codebus-kit` resolved directly onto the kit —
43
+ * silently recreating the exact traversal the round-13 fix believed it had
44
+ * closed. Fixed with a SECOND, narrower boundary — GRAPHIFY_ROOT_BOUNDARY
45
+ * — that the workflow now sets to the caller's own `repo/` checkout
46
+ * directory; containment is checked against THAT, not GITHUB_WORKSPACE,
47
+ * and (a caller's own checkout is fully attacker-controlled content, so a
48
+ * planted symlink cannot be ruled out) using the REAL, symlink-resolved
49
+ * filesystem paths on both sides of the comparison, not merely the
50
+ * lexically-normalized ones `resolve()`/`relative()` alone produce.
51
+ *
52
+ * Usage:
53
+ * node scripts/graph-publish.mjs
54
+ *
55
+ * Required env:
56
+ * SUPABASE_URL, SUPABASE_ANON_KEY — the public API endpoint + anon key
57
+ * (not sensitive; matches every other Supabase client in this repo).
58
+ * GRAPH_INGEST_TOKEN — this repo's 'ingest'-scoped token, minted via
59
+ * graph_ingest_token_mint by a workspace admin/curator and stored as a
60
+ * GitHub Environment secret (see graphify-publish-codebus.yml).
61
+ *
62
+ * Optional env / flags:
63
+ * GITHUB_SHA, GITHUB_RUN_ID, GITHUB_WORKFLOW_REF, GITHUB_REPOSITORY,
64
+ * GITHUB_WORKSPACE — supplied automatically by GitHub Actions; falls back
65
+ * to `git rev-parse HEAD` / cwd / synthetic values for local runs.
66
+ * GRAPHIFY_REPO_ROOT — repo subdirectory to build, RELATIVE to
67
+ * GITHUB_WORKSPACE (default: "."). Resolved and verified to stay inside
68
+ * GRAPHIFY_ROOT_BOUNDARY (round-15 review finding 1) — never accept
69
+ * this as an unvalidated absolute path.
70
+ * GRAPHIFY_ROOT_BOUNDARY — the directory GRAPHIFY_REPO_ROOT must resolve
71
+ * inside of (round-15 review finding 1); set by the reusable workflow
72
+ * to the caller's own checkout directory (`repo/`), which is narrower
73
+ * than — and a sibling of, never an ancestor shared with — `.codebus-
74
+ * kit/`. Falls back to GITHUB_WORKSPACE (this script's original, wider
75
+ * boundary) when unset, for backward compatibility with any caller or
76
+ * local run that hasn't set it.
77
+ * --artifact-path <file> — skip running `graphify`, load this JSON file
78
+ * as the artifact instead (script-level testing
79
+ * against a fixture, per the build ticket).
80
+ * --skip-build — same effect, reads graphify-out/graph.json
81
+ * as-is without re-running graphify.
82
+ * --dry-run — do everything except the final
83
+ * graph_ingest_with_token call (still reports
84
+ * the head and still validates).
85
+ */
86
+
87
+ import { execFileSync } from "node:child_process";
88
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
89
+ import { join, resolve, relative, isAbsolute } from "node:path";
90
+ import { pathToFileURL } from "node:url";
91
+ import { createClient } from "@supabase/supabase-js";
92
+
93
+ const args = process.argv.slice(2);
94
+ const flag = (name) => args.includes(name);
95
+ const opt = (name) => {
96
+ const i = args.indexOf(name);
97
+ return i >= 0 ? args[i + 1] : undefined;
98
+ };
99
+
100
+ const DRY_RUN = flag("--dry-run");
101
+ const SKIP_BUILD = flag("--skip-build") || !!opt("--artifact-path");
102
+ const ARTIFACT_PATH_OVERRIDE = opt("--artifact-path");
103
+
104
+ function die(msg) {
105
+ console.error(`FATAL: ${msg}`);
106
+ process.exit(1);
107
+ }
108
+
109
+ function section(title) {
110
+ console.log("");
111
+ console.log(`=== ${title} ===`);
112
+ }
113
+
114
+ // Round-1 review finding 3: GRAPHIFY_REPO_ROOT is a workflow_call INPUT —
115
+ // resolve it against the checkout root and verify the result stays INSIDE
116
+ // the allowed boundary before it ever reaches a subprocess argument or a
117
+ // filesystem call. An absolute path or a `..`-escaping relative path is
118
+ // rejected outright, not sanitized/best-effort-fixed.
119
+ //
120
+ // Round-15 review finding 1 (CONFIRMED, MAJOR): the boundary this function
121
+ // checks against is DELIBERATELY narrower than GITHUB_WORKSPACE whenever
122
+ // GRAPHIFY_ROOT_BOUNDARY is set (see the file header) — checking against
123
+ // GITHUB_WORKSPACE alone let `GRAPHIFY_REPO_ROOT=..` resolve right back to
124
+ // the workspace root (still "inside" that wider boundary) and
125
+ // `../.codebus-kit` resolve directly onto the sibling kit checkout,
126
+ // silently recreating the traversal round-13's directory-separation fix
127
+ // believed it had closed. Exported for scripts/test/graph-publish.validate.
128
+ // test.mjs (guarded below by the same import.meta.url check
129
+ // scripts/graph-trust-sample.mjs already established, so importing this
130
+ // module for that test does not itself run the CLI).
131
+ // Throws a plain Error (never calls die()/process.exit itself) — this
132
+ // keeps the function a pure, in-process-testable unit, matching the same
133
+ // convention scripts/graph-trust-sample.mjs's own validateMinGradable()
134
+ // already established (that one throws too, for the identical reason:
135
+ // scripts/test/*.validate.test.mjs needs to assert a rejection without the
136
+ // test runner's own process getting killed by a process.exit() inside the
137
+ // function under test). main()'s own caller below converts any thrown
138
+ // error from this function into the SAME "FATAL: <message>" die() output
139
+ // as before this refactor — nothing about the CLI's actual observable
140
+ // behavior (message text, exit code 1) changed, only that the failure is
141
+ // now surfaced via a normal thrown Error rather than an in-function
142
+ // process.exit().
143
+ export function resolveRepoRoot() {
144
+ const workspaceRoot = process.env.GITHUB_WORKSPACE || process.cwd();
145
+ const boundaryRaw = process.env.GRAPHIFY_ROOT_BOUNDARY || workspaceRoot;
146
+ const raw = process.env.GRAPHIFY_REPO_ROOT || ".";
147
+ if (isAbsolute(raw)) {
148
+ throw new Error(`GRAPHIFY_REPO_ROOT must be a path relative to the checkout root, got an absolute path: ${raw}`);
149
+ }
150
+ const resolved = resolve(workspaceRoot, raw);
151
+
152
+ // Round-15 review finding 1: containment is checked against the REAL
153
+ // (symlink-resolved) filesystem paths on BOTH sides, not merely the
154
+ // lexically-normalized ones `resolve()`/`relative()` alone would compare
155
+ // — the caller's own checkout is fully attacker-controlled content, so a
156
+ // planted symlink that is lexically "inside" the boundary but actually
157
+ // points elsewhere on disk must not be trusted at face value.
158
+ let realResolved;
159
+ try {
160
+ realResolved = realpathSync(resolved);
161
+ } catch {
162
+ throw new Error(`GRAPHIFY_REPO_ROOT ("${raw}") resolves to a path that does not exist: ${resolved}`);
163
+ }
164
+ let realBoundary;
165
+ try {
166
+ realBoundary = realpathSync(boundaryRaw);
167
+ } catch {
168
+ throw new Error(`GRAPHIFY_ROOT_BOUNDARY does not exist: ${boundaryRaw}`);
169
+ }
170
+
171
+ const rel = relative(realBoundary, realResolved);
172
+ if (rel === "") return realResolved; // the boundary itself
173
+ if (rel.startsWith("..") || isAbsolute(rel)) {
174
+ throw new Error(`GRAPHIFY_REPO_ROOT ("${raw}") resolves outside the allowed checkout boundary (${realBoundary}): ${realResolved}`);
175
+ }
176
+ return realResolved;
177
+ }
178
+
179
+ async function main() {
180
+ section("graph-publish: SP-B rollout-kit CI caller");
181
+
182
+ let repoRoot;
183
+ try {
184
+ repoRoot = resolveRepoRoot();
185
+ } catch (e) {
186
+ die(e.message);
187
+ }
188
+ const supabaseUrl = process.env.SUPABASE_URL;
189
+ const anonKey = process.env.SUPABASE_ANON_KEY;
190
+ const ingestToken = process.env.GRAPH_INGEST_TOKEN;
191
+
192
+ // Fail closed: every one of these is load-bearing. Missing config is
193
+ // refused, never silently skipped (house rule, sp-b-knowledge-layer.md
194
+ // §B.2 preamble).
195
+ if (!supabaseUrl) die("SUPABASE_URL is required");
196
+ if (!anonKey) die("SUPABASE_ANON_KEY is required");
197
+ if (!ingestToken) die("GRAPH_INGEST_TOKEN is required (this repo's ingest-scoped token — mint via graph_ingest_token_mint)");
198
+
199
+ let commitSha = process.env.GITHUB_SHA;
200
+ if (!commitSha) {
201
+ try {
202
+ commitSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf-8" }).trim();
203
+ } catch {
204
+ die("could not resolve commit sha (no GITHUB_SHA and `git rev-parse HEAD` failed)");
205
+ }
206
+ }
207
+ const runId = process.env.GITHUB_RUN_ID || `local-${Date.now()}`;
208
+ const workflowRef = process.env.GITHUB_WORKFLOW_REF || process.env.GITHUB_REPOSITORY || "local";
209
+
210
+ console.log(`repo root: ${repoRoot}`);
211
+ console.log(`commit sha: ${commitSha}`);
212
+ console.log(`run_id: ${runId}`);
213
+ console.log(`workflow_ref: ${workflowRef}`);
214
+ console.log(`dry-run: ${DRY_RUN}`);
215
+ console.log(`skip-build: ${SKIP_BUILD}`);
216
+
217
+ const supabase = createClient(supabaseUrl, anonKey, { auth: { persistSession: false } });
218
+
219
+ // ── Step 1: report the head FIRST, unconditionally, before any build
220
+ // work happens. This is what makes freshness honest even when step 2
221
+ // fails below — the hub already knows main advanced. Token-scoped: no
222
+ // repo_id is sent, it's resolved server-side from GRAPH_INGEST_TOKEN.
223
+ section("Step 1: graph_head_report_with_token (authoritative tip, before build)");
224
+ {
225
+ const { error } = await supabase.rpc("graph_head_report_with_token", {
226
+ p_token: ingestToken,
227
+ p_sha: commitSha,
228
+ p_run_id: String(runId),
229
+ });
230
+ if (error) die(`graph_head_report_with_token failed: ${error.message} (nothing downstream can be trusted if the tip itself was never recorded)`);
231
+ console.log(`✓ head reported: sha=${commitSha}`);
232
+ }
233
+
234
+ // ── Step 2: build (unless testing against a fixture/existing artifact).
235
+ section("Step 2: graphify build");
236
+ const graphJsonPath = join(repoRoot, "graphify-out", "graph.json");
237
+ if (!SKIP_BUILD) {
238
+ try {
239
+ execFileSync("which", ["graphify"], { stdio: "ignore" });
240
+ } catch {
241
+ die("graphify command not found (install via: pip install graphifyy)");
242
+ }
243
+ try {
244
+ execFileSync("graphify", ["update", repoRoot, "--force"], { stdio: "inherit", cwd: repoRoot });
245
+ console.log("✓ graphify build succeeded");
246
+ } catch (err) {
247
+ // HONEST FAILURE: exit non-zero. The head was already reported in
248
+ // Step 1, so graph_status will correctly go STALE after grace
249
+ // instead of silently keeping last-good presented as current.
250
+ die(`graphify build failed (${err.message}). Head sha ${commitSha} was already reported — the repo will honestly go STALE after the configured grace period rather than silently serve a stale artifact as current.`);
251
+ }
252
+ } else {
253
+ console.log(`(--skip-build/--artifact-path given: not invoking graphify)`);
254
+ }
255
+
256
+ const artifactPath = ARTIFACT_PATH_OVERRIDE || graphJsonPath;
257
+ if (!existsSync(artifactPath)) {
258
+ die(`artifact not found at ${artifactPath}`);
259
+ }
260
+ let artifact;
261
+ try {
262
+ artifact = JSON.parse(readFileSync(artifactPath, "utf-8"));
263
+ } catch (err) {
264
+ die(`failed to parse artifact JSON at ${artifactPath}: ${err.message}`);
265
+ }
266
+ const nodeCount = artifact.nodes?.length ?? 0;
267
+ const edgeCount = (artifact.links ?? artifact.edges ?? []).length;
268
+ const hyperedgeCount = (artifact.hyperedges ?? artifact.graph?.hyperedges ?? []).length;
269
+ console.log(`artifact: ${artifactPath}`);
270
+ console.log(` nodes=${nodeCount} edges=${edgeCount} hyperedges=${hyperedgeCount}`);
271
+
272
+ // ── Step 3: resolve the current vocab version (never hardcode — the
273
+ // vocabulary is the versioned artifact, per B.2.3). graph_vocab_current()
274
+ // is anon-executable (vocab is not repo-scoped/sensitive; SECURITY
275
+ // DEFINER bypasses the graph_vocab_read RLS policy's "any AUTHENTICATED
276
+ // agent" bar, which anon does not clear).
277
+ section("Step 3: resolve current vocab_version");
278
+ let vocabVersion;
279
+ {
280
+ const { data, error } = await supabase.rpc("graph_vocab_current");
281
+ if (error) die(`failed to resolve current vocab_version: ${error.message}`);
282
+ if (!data) die("no graph_vocab row has is_current=true — cannot publish without a current vocabulary version");
283
+ vocabVersion = data;
284
+ console.log(`✓ current vocab_version: ${vocabVersion}`);
285
+ }
286
+
287
+ // ── Step 4: pre-validate LOCALLY (same validation shape the ingest RPC
288
+ // itself uses — graph_ingest_validate_anon nests a call straight through
289
+ // to 0050's graph_ingest_validate, the exact function graph_ingest_artifact
290
+ // calls internally) so a schema problem is reported as a clearly-labeled
291
+ // VALIDATION failure, distinct from an INGEST failure, before we ever
292
+ // attempt to publish. Calling graph_ingest_validate DIRECTLY as anon does
293
+ // not work — it is a plain (non-definer) function, so its own internal
294
+ // graph_vocab read still runs under anon's insufficient privileges;
295
+ // graph_ingest_validate_anon is a thin SECURITY DEFINER wrapper added
296
+ // specifically so this pre-check needs no broader credential either.
297
+ section("Step 4: validate artifact against vocab (graph_ingest_validate_anon)");
298
+ {
299
+ const { error } = await supabase.rpc("graph_ingest_validate_anon", {
300
+ p_vocab_version: vocabVersion,
301
+ p_artifact: artifact,
302
+ });
303
+ if (error) die(`VALIDATION FAILED: ${error.message}`);
304
+ console.log("✓ artifact validates against the current vocabulary");
305
+ }
306
+
307
+ if (DRY_RUN) {
308
+ console.log("");
309
+ console.log("=== DRY-RUN: skipping graph_ingest_with_token ===");
310
+ process.exit(0);
311
+ }
312
+
313
+ // ── Step 5: publish. Atomic on the DB side: a rejection here leaves
314
+ // last-good serving completely untouched (0050's own guarantee), and
315
+ // (finding 2) the exact validated artifact is persisted content-addressed
316
+ // in the SAME transaction as promotion.
317
+ section("Step 5: graph_ingest_with_token (publish)");
318
+ {
319
+ const { data, error } = await supabase.rpc("graph_ingest_with_token", {
320
+ p_token: ingestToken,
321
+ p_source_sha: commitSha,
322
+ p_vocab_version: vocabVersion,
323
+ p_run_id: String(runId),
324
+ p_workflow_ref: workflowRef,
325
+ p_artifact: artifact,
326
+ });
327
+ if (error) die(`INGEST FAILED: ${error.message}`);
328
+
329
+ console.log(`status: ${data.status}`);
330
+ console.log(`promoted: ${data.promoted}`);
331
+ console.log(`version: ${data.version_id}`);
332
+ console.log(`nodes/edges: ${data.node_count}/${data.edge_count}`);
333
+
334
+ if (data.status === "quarantined") {
335
+ console.log("");
336
+ console.log("⚠ QUARANTINED: this version was NOT promoted (poisoning-guard sanity delta tripped).");
337
+ console.log(" Last-good stays serving. A curator/owner must review before this can be promoted.");
338
+ console.log(" (This is a successful, honest CI run — the guard did its job — not a build failure.)");
339
+ } else if (data.status === "noop_already_ingested") {
340
+ console.log("");
341
+ console.log("(idempotent no-op: this sha was already ingested)");
342
+ } else if (data.promoted) {
343
+ console.log("");
344
+ console.log("✓ promoted to serving (artifact persisted content-addressed, finding 2)");
345
+ }
346
+ }
347
+
348
+ console.log("");
349
+ console.log("=== SUCCESS ===");
350
+ }
351
+
352
+ // Round-11 review finding (CONFIRMED, MAJOR): the previous check compared
353
+ // `import.meta.url` (a properly percent-ENCODED URL — Node encodes spaces
354
+ // as %20, '#' as %23, '%' as %25, and on Windows produces a
355
+ // `file:///C:/...`-shaped URL) against `file://${process.argv[1]}`, a raw
356
+ // filesystem path concatenated onto a `file://` prefix with NO encoding at
357
+ // all. For any invocation path containing a space, '#', '%', or run on
358
+ // Windows, the two strings can never be equal even when this IS the
359
+ // directly-executed entry script — the guard silently evaluates false,
360
+ // `main()` never runs, and the process exits 0 having done nothing. Since
361
+ // `.github/workflows/graphify-publish.yml` invokes this exact script (via
362
+ // the CLI's shipped `dist/kit/graph-publish.mjs` copy, or the checked-out
363
+ // `.codebus-kit/scripts/graph-publish.mjs` copy) with `node <path>`, a
364
+ // silent no-op here means `graph_head_report_with_token` is never called,
365
+ // the build never runs, and validate/ingest never happen — while `codebus
366
+ // repo connect --build` (cli/src/commands/repo.ts), which already minted
367
+ // the token and transitioned pending -> ci_wired before spawning this
368
+ // process, sees only the exit code (0) and falsely reports success. Fixed
369
+ // by comparing two properly normalized URLs instead of a URL against a raw
370
+ // path: `pathToFileURL(...).href` applies the SAME percent-encoding rules
371
+ // `import.meta.url` itself was produced with (and produces the correct
372
+ // `file:///C:/...` shape on Windows), so both sides of the comparison are
373
+ // now in the same representation. `realpathSync` on `process.argv[1]`
374
+ // first (symlink-resolved, matching this file's own
375
+ // GRAPHIFY_ROOT_BOUNDARY containment check above, and
376
+ // resolveTrustedGraphPublishScript's identical convention in
377
+ // cli/src/commands/repo.ts) handles the case where the invoked path is a
378
+ // symlink — Node's own ESM loader resolves `import.meta.url` to the
379
+ // REAL underlying file, so comparing against the un-resolved argv[1] would
380
+ // reintroduce an equivalent false-negative for a symlinked invocation.
381
+ // Wrapped in a try/catch since `process.argv[1]` is not guaranteed to name
382
+ // an existing path in every embedding context (e.g. `node -e`); any
383
+ // failure there simply means "this is not a direct file invocation",
384
+ // exactly like the string comparison it replaces.
385
+ //
386
+ // scripts/graph-trust-sample.mjs established the ORIGINAL (buggy) form of
387
+ // this guard; fixed there too, identically, in the same round.
388
+ let isMainModule = false;
389
+ try {
390
+ isMainModule = pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
391
+ } catch {
392
+ // process.argv[1] does not resolve to a real path — not a direct
393
+ // file invocation; isMainModule stays false, matching the old guard's
394
+ // behavior for the same case.
395
+ }
396
+ if (isMainModule) {
397
+ main().catch((err) => {
398
+ die(err.stack || err.message || String(err));
399
+ });
400
+ }
@@ -0,0 +1,120 @@
1
+ // Shared by doctor.ts and checkin.ts (delivery gap #8): under Claude Code,
2
+ // checkin deliberately skips auto-arming a watch window because the Stop hook
3
+ // already surfaces mail at turn boundaries — but that hook is opt-in
4
+ // (`codebus install claude`), identity-gated, and only fires at turn
5
+ // boundaries. If NEITHER the Stop hook nor a live/recent watch window is
6
+ // present, mail latency for that agent is genuinely unbounded: nothing pulls
7
+ // new mail until a human happens to poll. Both doctor and checkin must flag
8
+ // this loudly rather than silently assuming "checkin ran, so we're fine".
9
+ import { readFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { workspaceId } from "./client.js";
13
+ export function stopHookInstalled() {
14
+ try {
15
+ const s = JSON.parse(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8"));
16
+ return JSON.stringify(s?.hooks?.Stop ?? []).includes("codebus tick");
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ // RC3: Codex's native lifecycle-hooks engine (codex-rs/hooks; Feature::CodexHooks,
23
+ // stage Stable, default_enabled true) is a genuine turn-boundary hook, not the
24
+ // older fire-and-forget `notify` shim most blog posts describe. Its Stop event
25
+ // carries the same `stop_hook_active` loop-guard field Claude Code's Stop hook
26
+ // does, and a handler can return `{"decision":"block","reason":"..."}` on
27
+ // stdout to feed `reason` back as the NEXT user prompt — verified against
28
+ // codex-rs/hooks/src/events/stop.rs, which is functionally identical to Claude
29
+ // Code's Stop-hook contract. `codebus install codex` wires exactly the same
30
+ // `codebus tick --hook` command Claude's Stop hook runs into
31
+ // ~/.codex/hooks.json (Stop event) — this checks whether that wiring is present.
32
+ // A hook event is genuinely wired only when one of its groups carries a command
33
+ // handler whose `command` is EXACTLY `cmd`. A substring match over the serialized
34
+ // JSON (the earlier approach) is fooled by any unrelated hook whose description
35
+ // or command merely CONTAINS the text — that both reports false coverage (checkin
36
+ // skips its watch fallback, doctor claims bounded delivery) and makes the
37
+ // installer refuse to add the real hook (ai-review finding). Shape of a hook
38
+ // event: [ { hooks: [ { type: "command", command: "..." } ] } ].
39
+ export function hookEventHasCommand(hookEvent, cmd) {
40
+ if (!Array.isArray(hookEvent))
41
+ return false;
42
+ return hookEvent.some((group) => !!group && typeof group === "object" && Array.isArray(group.hooks) &&
43
+ group.hooks.some((h) => !!h && typeof h === "object" &&
44
+ h.type === "command" &&
45
+ h.command === cmd));
46
+ }
47
+ export function codexHooksInstalled() {
48
+ try {
49
+ const s = JSON.parse(readFileSync(join(homedir(), ".codex", "hooks.json"), "utf8"));
50
+ return hookEventHasCommand(s?.hooks?.Stop, "codebus tick --hook");
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ // Review MAJOR finding #5: a Claude Stop hook being INSTALLED (present in
57
+ // ~/.claude/settings.json) is a machine-wide fact — it says nothing about
58
+ // whether THIS process is actually running inside a Claude Code session. The
59
+ // hook only fires for turns Claude Code itself drives; a Codex session or a
60
+ // plain shell in the same directory never triggers it no matter how it's
61
+ // configured. `CLAUDECODE` is set by Claude Code itself for every session it
62
+ // runs (already relied on by checkin.ts's auto-arm-watch branch) — the one
63
+ // reliable signal that the hook could possibly apply to the current process.
64
+ export function inClaudeCodeSession() {
65
+ return !!process.env.CLAUDECODE;
66
+ }
67
+ // Codex's analogue of CLAUDECODE: CODEX_THREAD_ID is injected by the Codex CLI
68
+ // into every subprocess it spawns for the active thread — shell-tool calls and
69
+ // hook commands alike (codex-rs/protocol/src/shell_environment.rs:
70
+ // CODEX_THREAD_ID_ENV_VAR; codex-rs/core/src/exec_env.rs — "injected when a
71
+ // thread id is provided, even when ..."). It is the one reliable signal that a
72
+ // Codex Stop hook installed in ~/.codex/hooks.json could possibly be firing
73
+ // FOR this process, exactly as CLAUDECODE is for Claude Code above.
74
+ export function inCodexSession() {
75
+ return !!process.env.CODEX_THREAD_ID;
76
+ }
77
+ // Generous relative to the standing loop's own re-arm cadence (directives.ts
78
+ // REARM: 280s) — a watch window arms itself well inside this before going stale.
79
+ export const WATCH_ARM_FRESHNESS_MS = 10 * 60 * 1000;
80
+ // Best-effort: `codebus watch` logs a session_log(kind='watch') row on arm
81
+ // (0048). There is no durable "a process is still blocked in watch" truth
82
+ // available from SQL, so freshness of the last arm is the closest signal.
83
+ // Review MAJOR finding #5: scoped to the SELECTED workspace — an agent that
84
+ // belongs to multiple workspaces previously had a watch armed in workspace A
85
+ // count as coverage for workspace B, which it never watched.
86
+ export async function watchRecentlyArmed(ctx, freshnessMs = WATCH_ARM_FRESHNESS_MS) {
87
+ try {
88
+ const ws = await workspaceId(ctx);
89
+ const { data } = await ctx.supabase
90
+ .from("session_log")
91
+ .select("created_at, agents!inner(slug)")
92
+ .eq("kind", "watch")
93
+ .eq("workspace_id", ws)
94
+ .eq("agents.slug", ctx.agentSlug)
95
+ .order("created_at", { ascending: false })
96
+ .limit(1);
97
+ const row = (data ?? [])[0];
98
+ if (!row)
99
+ return false;
100
+ return Date.now() - new Date(row.created_at).getTime() < freshnessMs;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ // True delivery coverage for THIS process: either it's actually running
107
+ // inside a Claude Code session with the Stop hook installed (the hook WILL
108
+ // fire for it), or a watch window is live/recently armed for the selected
109
+ // workspace. A hook merely being installed somewhere on the machine does not
110
+ // count when the current process is Codex/a plain shell — that hook can
111
+ // never fire for a process Claude Code isn't driving (review MAJOR finding #5).
112
+ export async function hasDeliveryCoverage(ctx) {
113
+ const viaHook = (inClaudeCodeSession() && stopHookInstalled()) || (inCodexSession() && codexHooksInstalled());
114
+ const viaWatch = ctx ? await watchRecentlyArmed(ctx) : false;
115
+ return { ok: viaHook || viaWatch, viaHook, viaWatch };
116
+ }
117
+ export const MAIL_LATENCY_WARNING = "mail latency unbounded: no Stop hook installed (codebus install claude, or codebus install codex) and no watch " +
118
+ "armed (codebus watch --mine --first --timeout 280000). Without one of these, new mail sits until " +
119
+ "someone happens to poll — install the hook for your tool, or keep a watch window armed.";
120
+ //# sourceMappingURL=mail-latency.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mail-latency.js","sourceRoot":"","sources":["../src/mail-latency.ts"],"names":[],"mappings":"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"}