@nutteen/conductor-ui 0.1.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.
package/README.md ADDED
@@ -0,0 +1,383 @@
1
+ # Conductor Control Room
2
+
3
+ A desktop viewer for Conductor: per ticket, **the graph**, **live state on that graph**, and
4
+ **the audit log**.
5
+
6
+ `conductor control-tower` prints a one-shot table and `conductor logs -f` prints raw JSONL.
7
+ This is the same data, drawn.
8
+
9
+ ```bash
10
+ npm run build --workspace apps/conductor-ui
11
+ npm run dev --workspace apps/conductor-ui # electron-vite dev server
12
+
13
+ # Options (all optional)
14
+ electron apps/conductor-ui \
15
+ --config conductor-runner/flows.yaml \ # which flows to start/stop; remembered between launches
16
+ --runtime-dir ~/.conductor \ # defaults to CONDUCTOR_RUNTIME_DIR, then ~/.conductor
17
+ --ticket IST-128 \ # open straight to a ticket instead of the config view
18
+ --tab graph|state|audit|config \ # which panel that deep link lands on
19
+ --capture shot.png # render, save a PNG, exit
20
+ ```
21
+
22
+ ## The CLI does not depend on this
23
+
24
+ The app **reads** files the daemon writes, and **starts and stops the daemon by running the
25
+ same CLI you would run yourself** — `conductor start <flow>` / `conductor stop <flow>`, as a
26
+ detached subprocess. It never reaches *into* a running daemon: no socket, no control file, no
27
+ new flag on `run`. The only signal it sends is the `SIGTERM` that `conductor stop` already
28
+ sends, and a daemon started from the app keeps running after you quit it.
29
+
30
+ That distinction is the whole of it. Spawning `conductor start` is exactly what a terminal
31
+ does and the daemon cannot tell the difference; a socket or a signal protocol would be a new
32
+ process contract, and *that* is what these guarantees protect.
33
+
34
+ | Guarantee | How it is held |
35
+ |---|---|
36
+ | `apps/conductor` gains no runtime deps | still `js-yaml` + `liquidjs`; `src/lib.ts` is re-export only |
37
+ | The published CLI package is unchanged in shape | `files: ["dist/src", "README.md"]` and `bin` untouched |
38
+ | No IPC *into* a running daemon | no socket, no control file, no hooks, no new flags on `run`; `lib.ts` exports no lifecycle symbol and `tests/lib.test.ts` fails the build if one appears |
39
+ | Every UI action has a CLI equivalent | Start/Stop/Restart shell out to `conductor start\|stop`; the app adds no capability the terminal lacks — notably no `SIGKILL` escalation |
40
+ | The app never writes to the runtime dir | records and pid files are still written by the CLI; the app's own remembered config path lives in Electron `userData` |
41
+ | The daemon outlives the app | it is spawned detached and `unref`'d, so `tail.ts`'s "stopping the app stops a reader, nothing else" stays literally true |
42
+ | The CLI's own output is unchanged | `buildRunSnapshotsFromLogLines` and `control-tower` still emit one row per `(ticket, attempt)`; nothing in the CLI's runtime path imports `replay.ts` |
43
+ | Deleting this workspace breaks nothing | nothing in `apps/conductor` imports it |
44
+ | It works with **no daemon running** | it reads historical JSONL; liveness is `isProcessRunning`, never a requirement |
45
+
46
+ `scripts/check.sh architecture` machine-checks the read/write split: `src/main/sources.ts` may
47
+ not import `child_process`, signal a pid or write a file — all of that lives in
48
+ `src/main/daemon.ts`, which is also why `sources.ts` can keep its header rule literally true.
49
+
50
+ `scripts/check.sh conductor` is deliberately a separate scope from
51
+ `scripts/check.sh conductor-ui`, so the first keeps passing with this directory deleted.
52
+
53
+ **Where the app and the CLI differ, precisely.** Both read the same log through the same
54
+ library, and every number still comes from the CLI's own functions. What differs is grouping:
55
+ `conductor control-tower` prints one row per `(ticket, attempt)`, whereas the sidebar here is
56
+ **ticket-level** — one row, badged `N runs` — and the detail panels are **dispatch-level**,
57
+ with earlier dispatches behind the run switcher. That is a presentation choice on top of
58
+ shared data, not a second parser.
59
+
60
+ The one real risk was the `exports` map: `apps/conductor/package.json` had no `main` or
61
+ `exports`, so deep paths resolved freely, and adding `exports` *restricts* resolution. It
62
+ therefore ships a `"./dist/src/*"` wildcard alongside `"."` and `"./lib"`, and
63
+ `apps/conductor/tests/lib.test.ts` asserts that.
64
+
65
+ ## Starting and stopping a flow
66
+
67
+ Start, Stop and Restart sit in the topbar. Each is a shell-out:
68
+
69
+ | Button | What it runs |
70
+ |---|---|
71
+ | `Start…` | `conductor --config <flows.yaml> start <flow>` |
72
+ | `Stop` | `conductor --config <flows.yaml> stop <flow>` |
73
+ | `Restart…` | `stop`, then wait for the pid to be gone, then `start` |
74
+
75
+ **Start and Restart confirm first, and the preflight runs inside the dialog.** Starting a flow
76
+ is not free: it sweeps workspaces, then autonomously dispatches tickets, spends tokens and
77
+ opens pull requests. The dialog names the resolved workspace root, how many directories are
78
+ under it, and the owner-scoped rule the sweep uses — Conductor reads each directory's
79
+ `.conductor-owner` marker and only removes ones owned by that flow or unmarked. (It is
80
+ *Symphony's* cleanup that is unscoped; that is what `flows.yaml`'s longest comment is about.)
81
+ It does not say which directories will go: that needs a tracker query, and opening a dialog
82
+ should not make one.
83
+
84
+ The checks are split by who can actually see the answer. **The CLI owns config and graph
85
+ correctness** — `preflightFlows` in `apps/conductor` is the same function `conductor start`
86
+ gates its spawn on, so a second copy cannot drift from it. **The app owns the host
87
+ environment**, which the CLI cannot see because it inherits a terminal's.
88
+
89
+ The session probe is `ist auth whoami`, not `ist auth status`. `status` is a **local** check:
90
+ it reads the token file and reports `expired` from its own `expiresAt`, so a token the server
91
+ has since rejected still passes it. Observed on a real machine: `status` printed
92
+ `"authenticated": true, "expired": false` while `whoami` returned *"Your session has
93
+ expired"* — and `startOrchestrator` calls `whoami` and refuses to start without it. A
94
+ preflight that passes where the daemon fails is worse than no preflight.
95
+
96
+ ### The host environment is the whole risk
97
+
98
+ A GUI-launched Electron app never sourced `~/.zprofile`. Its `PATH` is the bare
99
+ `/usr/bin:/bin:/usr/sbin:/sbin` macOS hands a double-clicked app, and none of `ist`, `claude`,
100
+ `git`, `gh` or `uv` is on it. That matters more than it sounds, because **an `ist auth` failure
101
+ is not fatal in the daemon**: `orchestrator.ts` logs `Dispatch skipped: ist auth check failed`
102
+ and keeps polling forever, doing nothing. A daemon spawned with a stripped PATH looks perfectly
103
+ healthy and accomplishes nothing at all.
104
+
105
+ So `host-env.ts` asks the login shell for its PATH once — `$SHELL -lc` with the value wrapped
106
+ in sentinel markers, because login shells emit MOTDs, nvm banners and `direnv` chatter, and
107
+ `-lc` rather than `-ilc` because interactive mode can block on a prompt. PATH only, not the
108
+ whole environment: every version manager works by putting real directories on PATH, and
109
+ importing a stale login env over Electron's own is a far larger surface than the problem.
110
+ Every failure path — no `$SHELL`, non-zero exit, timeout, no markers — falls back to the
111
+ inherited PATH and says so in the dialog; none of them throws.
112
+
113
+ The same capture answers the other hazard for free. `process.execPath` inside Electron is the
114
+ **Electron binary**, so spawning the CLI with it would fork Electron rather than node; the
115
+ `node` on the login PATH is both simpler and more faithful than `ELECTRON_RUN_AS_NODE=1`,
116
+ which is kept only as the fallback. When a real `node` is used, every `ELECTRON_*` key is
117
+ stripped from the daemon's environment — it lives for days and spreads its env into every
118
+ `claude`, `git`, `gh` and `npm` it spawns, and a stray `ELECTRON_RUN_AS_NODE=1` reaching a
119
+ subprocess that happens to be an Electron app makes it start in node mode and silently do
120
+ nothing.
121
+
122
+ ### Why the dot is never driven by `stopped_at`
123
+
124
+ `conductor stop` sends one `SIGTERM`, writes `stopped_at` and removes the pid file
125
+ **synchronously and unconditionally** — while the daemon's `shutdown()` aborts in-flight graphs
126
+ and *awaits* them, which can take minutes. So a record that says stopped is routinely a record
127
+ whose process is still alive and still holding a workspace.
128
+
129
+ Liveness is therefore `isProcessRunning(pid)` and nothing else. `stopped_at` is used for one
130
+ thing: telling a clean stop apart from a crash, which is the **`stale`** state — a pid, no
131
+ `stopped_at`, and no process. The topbar used to hide that behind "no daemon running".
132
+
133
+ `renderer/lifecycle.ts` holds the rule as a pure function of `(flow, pending, now, draining)`,
134
+ so it unit-tests with a fake clock. Transitional states are overlays with a ~15 s deadline that
135
+ clear the moment a `FlowInfo` disagrees; the deadline is what stops a spinner spinning forever
136
+ when a spawn dies before writing a record, and when it lapses it says where to look — the
137
+ daemon detaches and logs to JSONL, so its own startup failure never reaches the spawn's stderr.
138
+
139
+ **A restart that times out starts nothing.** This is the subtlest trap in the feature: calling
140
+ `start` while the old daemon is still alive makes `startManaged` print `IST already running
141
+ (PID …)` and exit **0**, so a naive restart reports success having done nothing but SIGTERM a
142
+ daemon. Instead the flow holds a distinct **draining** state — a `SIGTERM` has been sent to a
143
+ pid that is still alive — which a plain Stop also enters, and which clears itself the moment
144
+ that pid is gone.
145
+
146
+ **Draining offers the log and nothing else, and that is a safety property.** `runForeground`
147
+ registers `process.once("SIGTERM", …)`, so after the first signal the handler is removed and
148
+ Node restores default termination: a **second** `conductor stop` during a drain does not hurry
149
+ the daemon, it hard-kills it, orphaning an agent mid-`git push`. So Stop and Restart are both
150
+ off the screen while draining, and the button opens the log — which is where the wait explains
151
+ itself (`Shutdown: waiting for N in-flight run(s) to complete`). There is no `SIGKILL`
152
+ escalation anywhere; if force-stop is ever wanted it belongs in the CLI as
153
+ `conductor stop --force`, where it can shut down cleanly rather than from outside.
154
+
155
+ `pid: 0` gets its own guard, because `isProcessRunning(0)` returns **true** —
156
+ `process.kill(0, sig)` signals the entire process group, which from this app includes the app
157
+ itself. Every liveness question goes through `isLivePid`, which requires a positive integer
158
+ first.
159
+
160
+ ## Editing `flows.yaml`
161
+
162
+ The Config tab shows every declared flow, what it declares and what that resolves to, and can
163
+ edit it. **Edits are line-level splices with a diff preview and an explicit confirmation, never
164
+ `yaml.dump`.**
165
+
166
+ Nothing in this repo writes YAML — every use is `yaml.load` — and `conductor-runner/flows.yaml`
167
+ is 18 lines of which **10 are comments**, one of them the reason a Symphony restart does not
168
+ `rm -rf` Conductor's working trees. A `load` → mutate → `dump` round trip deletes all ten on
169
+ the first save.
170
+
171
+ So `flows-file.ts` is pure string → string: it parses the file into line spans, plans splices,
172
+ and copies **every line the plan does not name by reference**. "Untouched bytes are identical"
173
+ holds by construction. The diff is derived from the same splices that perform the write, which
174
+ makes a preview that differs from the write structurally impossible — which is why it is
175
+ hand-rolled rather than a re-diff of two texts.
176
+
177
+ Comments are attributed by walking *up* from a field while the line is a comment at the same
178
+ indent, stopping at a blank line. That is what binds the Symphony warning to `workspace_root`:
179
+ removing that field removes its comment, visibly, in the diff, and editing a different field
180
+ leaves it alone.
181
+
182
+ It **refuses** rather than guesses, naming the offending lines, for block sequences, block
183
+ scalars, multi-document files, mixed line endings, and especially anchors / aliases / merge
184
+ keys — with a merge key the effective config is not the lines you can see, so a line edit would
185
+ be a *silent* lie rather than a visible one.
186
+
187
+ `flows-config.ts` supplies the filesystem half. Validation writes the candidate to
188
+ `<configPath>.preview.<pid>` — a **sibling**, not `tmpdir()`, because `loadManagedConfig`
189
+ resolves `./workflows/IST.md` against the config file's own directory and a copy elsewhere
190
+ reports a bogus "workflow not found". The write is tmp + rename with a `.bak`, preserving the
191
+ file mode, then re-reads and asserts it still loads, restoring from the backup if not. Preview
192
+ and commit are two round trips, so the commit token pins the *before* text and is re-checked
193
+ against disk: an edit made in vim in between is refused, not clobbered.
194
+
195
+ **One thing the checker cannot see.** `flowCollisions` compares flows *within one config file*.
196
+ Symphony's `workspace_root` lives in `symphony-runner/`, which this loader never reads — so the
197
+ editor validates Conductor-vs-Conductor collisions and is blind to the Conductor-vs-Symphony
198
+ one. That comment in `flows.yaml` is not made redundant by this panel.
199
+
200
+ `runtime_dir` is deliberately read-only here: changing it orphans every record, log, pid file
201
+ and checkpoint under the old directory, with no migration.
202
+
203
+ ## Why the JSONL is the source, not the checkpoint
204
+
205
+ **Live graph state is deleted the moment a run ends.** `~/.conductor/graph/<flow>/<TICKET>.json`
206
+ is the only place holding `cursor`, `state`, `node_history` and `traversals`, and
207
+ `orchestrator.ts` removes it at `__end__` — keeping it would make the next dispatch resume
208
+ into a completed run.
209
+
210
+ So a per-ticket view built on the checkpoint alone is **blank for every finished ticket** and
211
+ only works mid-run. It looks fine right up until the run ends.
212
+
213
+ The log is the durable record. `node_started`, `node_completed`, `node_failed` and
214
+ `edge_taken` are enough to reconstruct the walk, which is what
215
+ `@nutteen/conductor/lib`'s `replayWalks` does — using the same bookkeeping rules
216
+ `graph/executor.ts` uses live.
217
+
218
+ They do *not* produce identical numbers, and the difference matters: a **checkpoint**
219
+ belongs to a ticket and its `node_history` spans every resume, while a **replayed run**
220
+ covers one dispatch. The checkpoint is also live state, so it is attached to the ticket's
221
+ most recent run only — hanging it off an older one would draw a run's graph with another
222
+ run's values on it.
223
+
224
+ That also collapses two of the four asks into one pipeline: **the audit log and the graph
225
+ state are the same events, rendered differently.**
226
+
227
+ The checkpoint is still read, for one thing the log does not have: **state values**.
228
+ `node_completed.writes` is a list of key *names*, so the log can say the reviewer set
229
+ `review_verdict` and can never say what it set it to. The State panel says so rather than
230
+ rendering a blank that reads as "unset".
231
+
232
+ ### What a "run" is
233
+
234
+ **One `graph run starting` line — one per `dispatchIssue` — begins exactly one graph walk.**
235
+ Not one attempt. `state.retry_attempts` lives in memory in the daemon and is rebuilt on every
236
+ start, so the counter resets on restart and is not a run identity: in the real IST log,
237
+ IST-116 was **dispatched 15 times under 3 distinct attempt numbers**, 10 of those dispatches
238
+ sharing attempt 1 — and the numbers are not even monotonic in time, since attempt 1 was still
239
+ logging at 02:41 after attempts 2 and 3 ended at 02:35 and 02:28.
240
+
241
+ Two things followed from keying on attempt, and both are fixed here: "the highest attempt is
242
+ the current run" returned a run that had finished *before* two others, and ten dispatches
243
+ concatenated into one walk produced a history with 24 node entries but only 16 edges — with
244
+ `checks -> checks -> checks` runs that no edge ever connected.
245
+
246
+ | | |
247
+ |---|---|
248
+ | Detection | `run_started: true`, or `started_at` as the structural fallback. Never `phase` prose. |
249
+ | Run id | `` `${identifier}#${startedAtIso}` `` — stable across re-reads, because the log window slides and a positional index would not. |
250
+ | Resumed dispatch | Its own run, *labelled* `resumed_at`, so each run's `node_history` lines up with its own edges. |
251
+ | Window opened mid-run | Kept as `partial: true` under `` `${identifier}#~${firstEventTs}` ``, rather than dropped. |
252
+
253
+ Ordinals — "run 12 of 15" — are display only.
254
+
255
+ ### `run_event`
256
+
257
+ Every graph event line now carries `run_event: "<kind>"`, so a reader recovers the event kind
258
+ by reading a field instead of pattern-matching `phase` prose. Logs written before it are read
259
+ by `inferRunEventKind`, which keys off field shape — with one exact-match exclusion list,
260
+ because several whole-run lifecycle lines carry `nodes_visited` exactly as
261
+ `worker_exited_normal` does, and field shape alone reports a budget halt as a clean exit.
262
+
263
+ ## Two modes, one renderer
264
+
265
+ `GraphView` is a pure function of `GraphSpec` plus an optional overlay, so ticket state stays
266
+ out of the component and it is testable against a fixture with no daemon and no runtime data.
267
+
268
+ **Config mode** (`overlay === null`) — a workflow's declared graph, no ticket. Shows what is
269
+ otherwise only readable as YAML: node kinds, which `agents:` profile *and model* each node
270
+ binds to, edge predicates, loop budgets, back edges, `on_error` behaviour, and
271
+ `on_enter.tracker_status` transitions. Works with no daemon and no ticket ever dispatched.
272
+
273
+ It also **surfaces validation inline**. A workflow that fails to load is a rendered
274
+ diagnostic, not a crash — and where the message names a node or an edge, it is drawn there.
275
+ This is the view that would have made `unknown builtin 'record_validation'` obvious
276
+ immediately, instead of leaving a daemon walking an older graph for three hours behind one
277
+ warning line.
278
+
279
+ **Ticket mode** — the same drawing, restyled: current node, visited path, and loop pressure
280
+ (traversals against each edge's `max_traversals`).
281
+
282
+ Layout is computed once per `GraphSpec` and only CSS classes change as events stream in.
283
+ Not mermaid: it re-renders the whole SVG from a text source on every change, which flickers
284
+ and drops pan position on a live graph. Not dagre either — the graph is small and strictly
285
+ layered apart from its back edges, which want to be drawn as arcs rather than reversed, and
286
+ keeping the dependency tree small matters for something that must never become load-bearing.
287
+
288
+ ### Reading the drawing
289
+
290
+ **It runs left to right.** Layers advance along x; nodes sharing a layer stack along y.
291
+ Top-down, IST's nine-stage pipeline was a 460×1448 ribbon inside a ~1130px panel — 40% of
292
+ the width used, vertical scrolling, and `__end__` off-screen. Sideways it is ~2850×392,
293
+ because the widest layer is two nodes (`check_plan`/`land`, `plan`/`merge_pr`). The layering
294
+ passes are unchanged; only the axis the result lands on is.
295
+
296
+ **An edge into `__end__` may be drawn as a stub** — a short drop out of the bottom of its
297
+ source ending in a bar, rather than a line across the whole picture. That happens when the
298
+ source *also* has a way to carry on, which is every `on_error` edge plus `__start__ -> __end__`
299
+ (the ticket is already handled) and `checks -> __end__` (the gate gave up): seven of IST's
300
+ edges, and most of its former crossing clutter. `handoff -> __end__` and `merge_pr -> __end__`
301
+ stay real lines, because ending is all those nodes have left to do — both pipelines still
302
+ visibly terminate somewhere. A stub carries the same label, the same tooltip and the same
303
+ `id`/`from`/`to`, so `traversals` still keys on it and a failed run reads *better* than it
304
+ did: the taken edge is next to the node that failed. Stubs are excluded from the layering
305
+ pass exactly as back edges are, so a failure edge cannot stretch the layer of a sink it no
306
+ longer reaches.
307
+
308
+ **Anything that would cut across the row is routed under it**: back-edge loops, and forward
309
+ edges spanning more than two layers (`merge_pr -> __end__`). Each gets its own depth lane,
310
+ and they label themselves on a row below the deepest of them.
311
+
312
+ **The gap between two columns is label space.** A forward edge's predicate is anchored in
313
+ the corridor immediately right of its source — the one place with no node boxes in it — and
314
+ the corridor is widened to fit the widest label anchored there. Predicates truncate at 40
315
+ characters on the canvas and are complete in the `<title>`.
316
+
317
+ **It has its own contrast rules.** The shared palette — mirrored from `apps/web` so
318
+ the two read as one product — draws borders at about 2:1 against their background,
319
+ which is right where text carries the meaning and a loud rule would only shout. A
320
+ node-link diagram is the one place in this app where the boxes and the lines *are*
321
+ the content, and at those values a node box measured 1.05:1 against the canvas and an
322
+ edge 1.79:1. The `--graph-*` variables in `styles.css` are the exception: 3.5:1 and
323
+ 5.3:1 in dark, 5.2:1 and 8.6:1 in light, against a canvas dropped to `--color-shell`.
324
+ `tests/theme.test.ts` parses the stylesheet and asserts the ratios, because the
325
+ failure mode here is a number being wrong, not anything throwing.
326
+
327
+ **It fits the panel, with a floor.** The canvas scales down to 75% and then the panel
328
+ scrolls horizontally, because scaling a nine-stage pipeline into ~1130px puts node text at
329
+ about 5px. Selecting a ticket scrolls its current node to the middle, which is the "where is
330
+ my run" question the panel exists to answer.
331
+
332
+ ## Dark and light
333
+
334
+ Dark by default, light by `data-theme="light"` on `<html>` — the same mechanism and
335
+ the same token values as `apps/web`, minus its plumbing, which round-trips the choice
336
+ to the tracker API against an authenticated user. With no explicit choice the viewer
337
+ follows the OS, which is the fix for the original complaint: it used to force dark on
338
+ a light desktop.
339
+
340
+ An explicit choice is persisted **by main**, in `preferences.json` under
341
+ `app.getPath("userData")`. Not `localStorage`, which is the obvious answer and does
342
+ not work: the renderer is loaded with `loadFile`, so it is a `file://` document, and
343
+ Chromium gives one of those an opaque storage bucket — writing succeeds, reading back
344
+ in the same window succeeds, and the value is gone on the next launch. Verified by
345
+ launching the app twice, not assumed.
346
+
347
+ It comes back to the renderer through the preload's `additionalArguments` as
348
+ `window.conductor.storedTheme`, a plain value rather than a promise, because it has
349
+ to be known *before* the first paint; an awaited answer is one frame of the other
350
+ theme on every launch. `BrowserWindow`'s `backgroundColor` is chosen from the same
351
+ value, since that is painted before any HTML exists at all.
352
+
353
+ ## Architecture
354
+
355
+ ```
356
+ Electron main (Node) Electron renderer (React, sandboxed)
357
+ ──────────────────── ────────────────────────────────────
358
+ imports @nutteen/conductor/lib Tickets ← RunSnapshot[]
359
+ buildControlTowerReport() ─── IPC ──► Graph ← SerializableGraphSpec + overlay
360
+ loadWorkflow() → GraphSpec State ← checkpoint.state / replayed writes
361
+ readGraphCheckpoint() Audit ← RunEventRecord[]
362
+ replayWalks()
363
+ tail on IST.jsonl ───── IPC push ──────► (live updates)
364
+ watchWorkflow() ─────── IPC push ──────► (redraw on save)
365
+ ```
366
+
367
+ No HTTP server and no SSE: in Electron the main process is already Node, so it tails the
368
+ JSONL and pushes over IPC. The renderer runs with `contextIsolation` and no Node, which is
369
+ also why it cannot read `~/.conductor` itself — everything it shows crosses the typed surface
370
+ in `src/shared/contract.ts`.
371
+
372
+ Everything crossing that boundary must survive `structuredClone`: no `Map`, no `Set`, no
373
+ compiled predicate. `EdgeSpec.when_expr` is stripped by `serializableGraphSpec`; the `when`
374
+ source text is what gets rendered.
375
+
376
+ Every rendered string passes through `redactSensitive` in the main process first.
377
+
378
+ ## Known limitation
379
+
380
+ The graph drawn is the workflow **as it is now**, with a historical walk overlaid. A ticket
381
+ that ran under an earlier revision can therefore reference nodes the current graph no longer
382
+ declares — they simply do not appear. The audit log still shows them, because it is the raw
383
+ event stream.
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-undef */
3
+ import { createRequire } from "node:module";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const electron = require("electron");
10
+ const appDir = join(dirname(fileURLToPath(import.meta.url)), "..");
11
+
12
+ const child = spawn(String(electron), [appDir, ...process.argv.slice(2)], {
13
+ stdio: "inherit",
14
+ windowsHide: false,
15
+ });
16
+
17
+ child.on("close", (code) => process.exit(code ?? 0));