@pmelab/gtd 7.2.0 → 7.4.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 CHANGED
@@ -99,7 +99,13 @@ handful of commands drive it:
99
99
  back. Refuses on a dirty working tree, when there is nothing retained to
100
100
  restore, or when HEAD has since moved past it with commits that would be lost.
101
101
  - **`gtd visualize`** — serve an interactive diagram of the active workflow on a
102
- local web server (the main flow, its sub-machines, and per-state details).
102
+ local web server: the main flow (each sub-machine collapsed to one box, with
103
+ its own diagram below — pan/zoom with scroll, drag, or the corner controls),
104
+ per-state details including the state's own raw prompt/message/ script text,
105
+ and a "Current state" panel showing where the active process rests, its
106
+ pending changes, and which action leads where. The panel and diagram highlight
107
+ refresh live (~every 3s) while the page is open, so advancing the process
108
+ elsewhere shows up without a manual refresh.
103
109
 
104
110
  `gtd version` (or `gtd --version`/`-v`) prints the installed version and exits;
105
111
  `gtd help` (or `gtd --help`/`-h`) prints the command list. Both short-circuit
@@ -161,11 +167,11 @@ through two `vars` tiers — `plannerModel` (heavier planning and review) and
161
167
  place (a `vars:` edit or a `GTD_VAR_plannerModel` override) instead of per
162
168
  state.
163
169
 
164
- `gtd-loop`, installed alongside `gtd`, is a ready-to-run driver for the whole
165
- protocol — point it at a repo and it runs the loop until it's your turn. It is
166
- the only command you run: at a gate you edit files (answer a plan question, tick
167
- a review box, fix code) and re-launch it, and it captures your edit as its
168
- opening move before driving on — so you never run `gtd step human` by hand. See
170
+ Bare `gtd` (or `gtd loop`) is a ready-to-run driver for the whole protocol —
171
+ point it at a repo and it runs the loop until it's your turn. It is the only
172
+ command you run: at a gate you edit files (answer a plan question, tick a review
173
+ box, fix code) and re-launch it, and it captures your edit as its opening move
174
+ before driving on — so you never run `gtd step human` by hand. See
169
175
  [Driving the loop](docs/loop.md).
170
176
 
171
177
  Before wiring gtd into a repo, note the
@@ -204,8 +210,8 @@ reporting contract.
204
210
  sub-machines, and the bundled workflow template
205
211
  - [CLI reference](docs/cli.md) — every command, exit codes, JSON schemas,
206
212
  repository requirements
207
- - [Driving the loop](docs/loop.md) — the reference loop driver, `gtd-loop`,
208
- custom agents
213
+ - [Driving the loop](docs/loop.md) — the reference loop driver,
214
+ `gtd`/`gtd loop`, custom agents
209
215
  - [Configuration](docs/configuration.md) — `gtd init`, the `.gtdrc` `workflow:`
210
216
  schema, lookup
211
217
  - [Upgrading](docs/upgrading.md) — breaking changes and migration
@@ -1,6 +1,41 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
+ # This file is the single published `bin` entry: `bin/gtd`. Before anything
5
+ # else, it dispatches on $1:
6
+ # - no arguments, or exactly `loop` with no further arguments -> fall through
7
+ # into the loop body below (the loop protocol comment further down).
8
+ # - `loop` with additional arguments -> usage error, exit 2.
9
+ # - anything else -> hand off to the bundle via `run_gtd`, forwarding all
10
+ # arguments unchanged (e.g. `bin/gtd status`, `bin/gtd step human`).
11
+ # `run_gtd` is the ONE place that knows how to invoke "the bundle": by default
12
+ # it resolves `dist/gtd.bundle.mjs` relative to this script's own location
13
+ # (not cwd), but `GTD_BIN` (a dev/test override for the whole invocation
14
+ # command, e.g. `GTD_BIN="node $PWD/dev/run.mjs"`) takes precedence when set.
15
+ # Every call the loop body makes below goes through `run_gtd` too, so the loop
16
+ # always drives the exact same build/source it was launched from — no
17
+ # dependence on a `gtd` binary being on PATH.
18
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
19
+
20
+ run_gtd() {
21
+ if [[ -n "${GTD_BIN:-}" ]]; then
22
+ eval "$GTD_BIN" '"$@"'
23
+ else
24
+ node "$SCRIPT_DIR/../dist/gtd.bundle.mjs" "$@"
25
+ fi
26
+ }
27
+
28
+ if [[ $# -gt 0 && "$1" != "loop" ]]; then
29
+ run_gtd "$@"
30
+ exit $?
31
+ fi
32
+ if [[ "${1:-}" == "loop" && $# -gt 1 ]]; then
33
+ echo "gtd: 'loop' takes no arguments" >&2
34
+ exit 2
35
+ fi
36
+ # otherwise ($# -eq 0, or exactly "loop" with no further args): fall through
37
+ # into the existing loop body below, unchanged in behavior.
38
+
4
39
  # Drives the v3 pattern-machine loop (see docs/loop.md): each iteration asks
5
40
  # `gtd next --json` who's up and what they should do, dispatching on `kind`:
6
41
  # - "message" (a human rest) -> halt, tell the user what gtd is waiting on.
@@ -101,14 +136,14 @@ run_agent_turn() {
101
136
  # a harmless no-op; a genuine refusal there (a malformed steering file, or an
102
137
  # edit no `on` pattern accepts) is surfaced and stops the loop rather than being
103
138
  # driven past.
104
- if ! peek_json="$(gtd next --json 2>&1)"; then
139
+ if ! peek_json="$(run_gtd next --json 2>&1)"; then
105
140
  echo "gtd-loop: could not determine the next step:" >&2
106
141
  echo "$peek_json" >&2
107
142
  echo "Run \`gtd status\` to inspect the repo." >&2
108
143
  exit 1
109
144
  fi
110
145
  if [[ "$(jq -r .kind <<<"$peek_json")" == "message" ]]; then
111
- if ! step_out="$(gtd step human 2>&1)"; then
146
+ if ! step_out="$(run_gtd step human 2>&1)"; then
112
147
  echo "gtd-loop: could not capture your changes at the human gate:" >&2
113
148
  echo "$step_out" >&2
114
149
  echo "Fix the reported issue and re-run gtd-loop." >&2
@@ -117,7 +152,7 @@ if [[ "$(jq -r .kind <<<"$peek_json")" == "message" ]]; then
117
152
  fi
118
153
 
119
154
  while true; do
120
- if ! next_json="$(gtd next --json 2>&1)"; then
155
+ if ! next_json="$(run_gtd next --json 2>&1)"; then
121
156
  echo "gtd-loop: could not determine the next step:" >&2
122
157
  echo "$next_json" >&2
123
158
  echo "Run \`gtd status\` to inspect the repo." >&2
@@ -141,7 +176,7 @@ while true; do
141
176
  herdr_report blocked "$label"
142
177
  herdr_notify "gtd needs you — $(basename "$PWD")" "$label"
143
178
  echo "--- Your turn ($state) ---"
144
- gtd next
179
+ run_gtd next
145
180
  exit 0
146
181
  fi
147
182
 
@@ -152,7 +187,7 @@ while true; do
152
187
  # the tree, and `gtd step` captures whatever it left there via the state's
153
188
  # own `on` patterns.
154
189
  bash -c "$content" || true
155
- gtd step "$actor" >/dev/null
190
+ run_gtd step "$actor" >/dev/null
156
191
  head_after="$(git rev-parse HEAD 2>/dev/null || echo none)"
157
192
  if [[ "$head_before" == "$head_after" ]]; then
158
193
  herdr_report idle "$label"
@@ -170,7 +205,7 @@ while true; do
170
205
  # progress; stop rather than spin on it.
171
206
  if [[ "$state" == "$prev_state" && "$content" == "$prev_content" ]]; then
172
207
  echo "gtd-loop: no progress at '$state' — the agent's last turn changed nothing. Stopping to avoid spinning." >&2
173
- gtd next >&2
208
+ run_gtd next >&2
174
209
  exit 1
175
210
  fi
176
211
  prev_state="$state"
@@ -213,7 +248,7 @@ while true; do
213
248
  run_agent_turn "$content" "$resume"
214
249
 
215
250
  fix_attempt=0
216
- while ! validate_out="$(gtd validate 2>&1)"; do
251
+ while ! validate_out="$(run_gtd validate 2>&1)"; do
217
252
  fix_attempt=$((fix_attempt + 1))
218
253
  if ((fix_attempt > 3)); then
219
254
  echo "gtd-loop: '$state' still fails \`gtd validate\` after 3 fix attempts:" >&2
@@ -228,5 +263,5 @@ while true; do
228
263
  run_agent_turn "$content"$'\n\n'"Your last turn does not pass \`gtd validate\`. Fix these format violations, then finish:"$'\n'"$validate_out" "$fix_resume"
229
264
  done
230
265
 
231
- gtd step "$actor" >/dev/null
266
+ run_gtd step "$actor" >/dev/null
232
267
  done
@@ -243,6 +243,20 @@ const constTrue = /*#__PURE__*/ constant(true);
243
243
  */
244
244
  const constFalse = /*#__PURE__*/ constant(false);
245
245
  /**
246
+ * A thunk that returns always `null`.
247
+ *
248
+ * @example
249
+ * ```ts
250
+ * import * as assert from "node:assert"
251
+ * import { constNull } from "effect/Function"
252
+ *
253
+ * assert.deepStrictEqual(constNull(), null)
254
+ * ```
255
+ *
256
+ * @since 2.0.0
257
+ */
258
+ const constNull = /*#__PURE__*/ constant(null);
259
+ /**
246
260
  * A thunk that returns always `undefined`.
247
261
  *
248
262
  * @example
@@ -1821,6 +1835,20 @@ const merge$5 = /*#__PURE__*/ match$9({
1821
1835
  onRight: identity
1822
1836
  });
1823
1837
  /**
1838
+ * @example
1839
+ * ```ts
1840
+ * import * as assert from "node:assert"
1841
+ * import { Either } from "effect"
1842
+ *
1843
+ * assert.deepStrictEqual(Either.getOrNull(Either.right(1)), 1)
1844
+ * assert.deepStrictEqual(Either.getOrNull(Either.left("a")), null)
1845
+ * ```
1846
+ *
1847
+ * @category getters
1848
+ * @since 2.0.0
1849
+ */
1850
+ const getOrNull = /*#__PURE__*/ (/* @__PURE__ */ dual(2, (self, onLeft) => isLeft(self) ? onLeft(self.left) : self.right))(constNull);
1851
+ /**
1824
1852
  * Extracts the value of an `Either` or throws if the `Either` is `Left`.
1825
1853
  *
1826
1854
  * If a default error is sufficient for your use case and you don't need to configure the thrown error, see {@link getOrThrow}.
@@ -19779,7 +19807,7 @@ const runFork$1 = unsafeForkEffect;
19779
19807
  * @since 2.0.0
19780
19808
  * @category Running Effects
19781
19809
  */
19782
- const runPromise = unsafeRunPromiseEffect;
19810
+ const runPromise$1 = unsafeRunPromiseEffect;
19783
19811
  /**
19784
19812
  * Executes an effect synchronously, running it immediately and returning the
19785
19813
  * result.
@@ -22310,6 +22338,18 @@ const get = get$1;
22310
22338
  const runFork = unsafeFork;
22311
22339
  /**
22312
22340
  * Runs the `Effect`, returning a JavaScript `Promise` that will be resolved
22341
+ * with the value of the effect once the effect has been executed, or will be
22342
+ * rejected with the first error or exception throw by the effect.
22343
+ *
22344
+ * This method is effectful and should only be used at the edges of your
22345
+ * program.
22346
+ *
22347
+ * @since 2.0.0
22348
+ * @category execution
22349
+ */
22350
+ const runPromise = unsafeRunPromise;
22351
+ /**
22352
+ * Runs the `Effect`, returning a JavaScript `Promise` that will be resolved
22313
22353
  * with the `Exit` state of the effect once the effect has been executed.
22314
22354
  *
22315
22355
  * This method is effectful and should only be used at the edges of your
@@ -29364,9 +29404,9 @@ const makePlatform$1 = () => (options) => PlatformWorker$1.of({
29364
29404
  yield* addFinalizer$1(scope$2, sync$1(() => {
29365
29405
  currentPort = void 0;
29366
29406
  }));
29367
- const runtime$4 = (yield* runtime()).pipe(updateContext(omit$1(Scope)));
29407
+ const runtime$5 = (yield* runtime()).pipe(updateContext(omit$1(Scope)));
29368
29408
  const fiberSet = yield* make$3();
29369
- const runFork$3 = runFork(runtime$4);
29409
+ const runFork$3 = runFork(runtime$5);
29370
29410
  yield* options.listen({
29371
29411
  port,
29372
29412
  scope: scope$2,
@@ -189119,6 +189159,8 @@ const contentKindOf = (state) => {
189119
189159
  };
189120
189160
  /** True when a state is a commit (final, squash) state. */
189121
189161
  const isCommitState = (state) => state.commit !== void 0;
189162
+ /** The raw template source a state's own content kind carries — `script`/`prompt`/`message`, or `undefined` for a commit state (never at rest, no template a viewer could show). */
189163
+ const contentOf = (state) => state.script ?? state.prompt ?? state.message;
189122
189164
  /** True when a rest at `state` should open the review checkout window (see `StateDef.reviewWindow`). Safe for an unknown state name (returns `false`). */
189123
189165
  const isReviewWindowState = (def, state) => def.states[state]?.reviewWindow === true;
189124
189166
  /** True when `state` anchors the review window's diff base (see `StateDef.reviewBase`). Safe for an unknown state name (returns `false`). */
@@ -191941,7 +191983,7 @@ const makeGitImpl = (executor, root) => {
191941
191983
  };
191942
191984
  })), { concurrency: "unbounded" }));
191943
191985
  }),
191944
- lastCommitSubject: () => exec("git", "log", "-1", "--pretty=%s").pipe(map$4((s) => s.trim())),
191986
+ lastCommitSubject: (ref = "HEAD") => exec("git", "log", "-1", "--pretty=%s", ref).pipe(map$4((s) => s.trim())),
191945
191987
  lastCommitMessage: () => exec("git", "log", "-1", "--pretty=%B").pipe(map$4((s) => s.trim())),
191946
191988
  hasCommits: () => exec("git", "rev-parse", "--verify", "HEAD").pipe(map$4(() => true), catchAll(() => succeed$3(false))),
191947
191989
  diffRef: (ref, exclude = []) => gen(function* () {
@@ -192728,11 +192770,18 @@ const costByModel = (entries) => {
192728
192770
  };
192729
192771
  /** Collapse an arbitrary git status letter to the pattern grammar's closed `A|M|D` set (mirrors the plan's decision 5 — only those three are meaningful statuses). */
192730
192772
  const normalizeStatus = (raw) => raw === "A" ? "A" : raw === "D" ? "D" : "M";
192731
- /** Resolve HEAD's subject against the active workflow definition (the bundled default, or a compiled `.gtdrc` `workflow:` key). */
192732
- const resolveRest = () => gen(function* () {
192773
+ /**
192774
+ * Resolve a commit's subject against the active workflow definition (the
192775
+ * bundled default, or a compiled `.gtdrc` `workflow:` key). Resolves HEAD by
192776
+ * default; `atRef` resolves an arbitrary ref/hash instead — used by `gtd
192777
+ * visualize`'s best-effort current-state read, which prefers the review
192778
+ * checkout window's saved head over a HEAD that may be mid-window-rewind (see
192779
+ * `src/ReviewWindow.ts`'s `REVIEW_HEAD_REF`).
192780
+ */
192781
+ const resolveRest = (atRef) => gen(function* () {
192733
192782
  const git = yield* GitService;
192734
192783
  const def = (yield* (yield* ConfigService).load).workflow;
192735
- const state = resolveState(def, (yield* git.hasCommits()) ? yield* git.lastCommitSubject() : "");
192784
+ const state = resolveState(def, (yield* git.hasCommits()) ? yield* git.lastCommitSubject(atRef) : "");
192736
192785
  const stateDef = def.states[state];
192737
192786
  if (stateDef.actor === void 0) return yield* fail$2(/* @__PURE__ */ new Error(`gtd: resolved at commit state "${state}" — a process never rests there`));
192738
192787
  return {
@@ -204868,7 +204917,7 @@ const worktreeLayerForRoot = (root) => WorktreeReader.Live.pipe(provide(Cwd.laye
204868
204917
  */
204869
204918
  const loadModeMap = async (root, onWarn) => {
204870
204919
  try {
204871
- const config = await runPromise(ConfigService.pipe(flatMap$4((c) => c.load), provide$1(configLayerForRoot(root))));
204920
+ const config = await runPromise$1(ConfigService.pipe(flatMap$4((c) => c.load), provide$1(configLayerForRoot(root))));
204872
204921
  const vars = resolveVars(config.workflowVars, config.rcVars, process.env);
204873
204922
  const { map, warnings } = buildFileModeMap(config.workflow, vars, root);
204874
204923
  for (const warning of warnings) onWarn(warning);
@@ -204888,7 +204937,7 @@ const loadModeMap = async (root, onWarn) => {
204888
204937
  */
204889
204938
  const tryGitTopLevel = async (dir) => {
204890
204939
  try {
204891
- return await runPromise(GitService.pipe(flatMap$4((git) => git.topLevel()), provide$1(gitLayerForRoot(dir))));
204940
+ return await runPromise$1(GitService.pipe(flatMap$4((git) => git.topLevel()), provide$1(gitLayerForRoot(dir))));
204892
204941
  } catch {
204893
204942
  return;
204894
204943
  }
@@ -204972,7 +205021,7 @@ const startLspServer = () => gen(function* () {
204972
205021
  if (params.command !== OPEN_STEERING_FILE_COMMAND) return null;
204973
205022
  const root = workspaceRoot ?? process.cwd();
204974
205023
  try {
204975
- const { state, file } = await runPromise(resolveSteeringFile(root));
205024
+ const { state, file } = await runPromise$1(resolveSteeringFile(root));
204976
205025
  const outcome = steeringFileOutcome(state, file, root);
204977
205026
  if (outcome.kind === "inform") connection.window.showInformationMessage(`gtd: state "${outcome.state}" has no associated steering file.`);
204978
205027
  else await connection.window.showDocument({ uri: outcome.uri });
@@ -205002,7 +205051,7 @@ const startLspServer = () => gen(function* () {
205002
205051
  });
205003
205052
  //#endregion
205004
205053
  //#region src/visualize.html
205005
- var visualize_default = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>gtd · workflow visualizer</title>\n <style>\n :root {\n --bg: #f4f6fa;\n --surface: #ffffff;\n --surface-2: #eef1f7;\n --surface-3: #e6ebf3;\n --border: #d9dee8;\n --border-strong: #c2ccdb;\n --text: #1a2233;\n --text-dim: #55607a;\n --text-faint: #8a93a6;\n --accent: #2d6cdf;\n --accent-soft: #e4edfd;\n --human-bg: #fbeccd;\n --human-fg: #7a5310;\n --human-dot: #d99413;\n --agent-bg: #e5e2fb;\n --agent-fg: #463693;\n --agent-dot: #6d5ce0;\n --check-bg: #d3f0ef;\n --check-fg: #0c524f;\n --check-dot: #16a39d;\n --engine-bg: #fbdfe4;\n --engine-fg: #8a1f38;\n --engine-dot: #d84a68;\n --unknown-bg: #eef1f7;\n --unknown-fg: #55607a;\n --unknown-dot: #8a93a6;\n --shadow: 0 1px 2px rgba(20, 30, 50, 0.06), 0 8px 28px rgba(20, 30, 50, 0.1);\n --shadow-sm: 0 1px 2px rgba(20, 30, 50, 0.08);\n --radius: 12px;\n --mono:\n ui-monospace, \"SF Mono\", \"SFMono-Regular\", Menlo, Consolas, \"Liberation Mono\", monospace;\n --sans:\n ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n --dia-bg: #f7f9fc;\n }\n @media (prefers-color-scheme: dark) {\n :root {\n --bg: #0e1420;\n --surface: #161d2b;\n --surface-2: #1c2434;\n --surface-3: #232c3e;\n --border: #2b3547;\n --border-strong: #3a465c;\n --text: #e6ebf4;\n --text-dim: #9aa6bd;\n --text-faint: #6b7690;\n --accent: #5b8def;\n --accent-soft: #1b2740;\n --human-bg: #3a2e12;\n --human-fg: #f0c674;\n --human-dot: #e0a83e;\n --agent-bg: #26224a;\n --agent-fg: #b6abf7;\n --agent-dot: #8b7cf0;\n --check-bg: #123433;\n --check-fg: #68dcd4;\n --check-dot: #2dc4bc;\n --engine-bg: #3a1b24;\n --engine-fg: #f2a3b3;\n --engine-dot: #e05f7c;\n --unknown-bg: #1c2434;\n --unknown-fg: #9aa6bd;\n --unknown-dot: #6b7690;\n --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 34px rgba(0, 0, 0, 0.45);\n --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);\n --dia-bg: #0b1018;\n }\n }\n :root[data-theme=\"light\"] {\n --bg: #f4f6fa;\n --surface: #ffffff;\n --surface-2: #eef1f7;\n --surface-3: #e6ebf3;\n --border: #d9dee8;\n --border-strong: #c2ccdb;\n --text: #1a2233;\n --text-dim: #55607a;\n --text-faint: #8a93a6;\n --accent: #2d6cdf;\n --accent-soft: #e4edfd;\n --human-bg: #fbeccd;\n --human-fg: #7a5310;\n --human-dot: #d99413;\n --agent-bg: #e5e2fb;\n --agent-fg: #463693;\n --agent-dot: #6d5ce0;\n --check-bg: #d3f0ef;\n --check-fg: #0c524f;\n --check-dot: #16a39d;\n --engine-bg: #fbdfe4;\n --engine-fg: #8a1f38;\n --engine-dot: #d84a68;\n --unknown-bg: #eef1f7;\n --unknown-fg: #55607a;\n --unknown-dot: #8a93a6;\n --shadow: 0 1px 2px rgba(20, 30, 50, 0.06), 0 8px 28px rgba(20, 30, 50, 0.1);\n --shadow-sm: 0 1px 2px rgba(20, 30, 50, 0.08);\n --dia-bg: #f7f9fc;\n }\n :root[data-theme=\"dark\"] {\n --bg: #0e1420;\n --surface: #161d2b;\n --surface-2: #1c2434;\n --surface-3: #232c3e;\n --border: #2b3547;\n --border-strong: #3a465c;\n --text: #e6ebf4;\n --text-dim: #9aa6bd;\n --text-faint: #6b7690;\n --accent: #5b8def;\n --accent-soft: #1b2740;\n --human-bg: #3a2e12;\n --human-fg: #f0c674;\n --human-dot: #e0a83e;\n --agent-bg: #26224a;\n --agent-fg: #b6abf7;\n --agent-dot: #8b7cf0;\n --check-bg: #123433;\n --check-fg: #68dcd4;\n --check-dot: #2dc4bc;\n --engine-bg: #3a1b24;\n --engine-fg: #f2a3b3;\n --engine-dot: #e05f7c;\n --unknown-bg: #1c2434;\n --unknown-fg: #9aa6bd;\n --unknown-dot: #6b7690;\n --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 34px rgba(0, 0, 0, 0.45);\n --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);\n --dia-bg: #0b1018;\n }\n\n * {\n box-sizing: border-box;\n }\n html,\n body {\n max-width: 100%;\n overflow-x: hidden;\n }\n body {\n margin: 0;\n background: var(--bg);\n color: var(--text);\n font-family: var(--sans);\n font-size: 15px;\n line-height: 1.55;\n -webkit-font-smoothing: antialiased;\n }\n ::selection {\n background: var(--accent);\n color: #fff;\n }\n .wrap {\n max-width: 1180px;\n margin: 0 auto;\n padding: 0 20px;\n }\n\n /* header */\n header.top {\n position: sticky;\n top: 0;\n z-index: 40;\n background: color-mix(in srgb, var(--bg) 88%, transparent);\n backdrop-filter: blur(10px);\n border-bottom: 1px solid var(--border);\n }\n .top-inner {\n display: flex;\n align-items: center;\n gap: 18px;\n padding: 12px 20px;\n max-width: 1180px;\n margin: 0 auto;\n flex-wrap: wrap;\n }\n .brand {\n display: flex;\n align-items: baseline;\n gap: 10px;\n margin-right: auto;\n }\n .brand .cmd {\n font-family: var(--mono);\n font-weight: 700;\n font-size: 17px;\n letter-spacing: -0.02em;\n }\n .brand .cmd b {\n color: var(--accent);\n }\n .brand .sub {\n font-size: 12px;\n color: var(--text-faint);\n letter-spacing: 0.04em;\n text-transform: uppercase;\n }\n .summary {\n font-size: 12.5px;\n color: var(--text-dim);\n display: flex;\n gap: 10px;\n flex-wrap: wrap;\n align-items: center;\n }\n .summary .s-num {\n font-family: var(--mono);\n font-weight: 700;\n color: var(--text);\n }\n .summary .sep {\n color: var(--border-strong);\n }\n .theme-btn {\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--text-dim);\n border-radius: 8px;\n width: 32px;\n height: 32px;\n cursor: pointer;\n font-size: 14px;\n display: grid;\n place-items: center;\n }\n .theme-btn:hover {\n color: var(--text);\n border-color: var(--border-strong);\n }\n .theme-btn:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n\n /* hero */\n .hero {\n padding: 40px 0 4px;\n }\n h1 {\n font-size: clamp(26px, 4vw, 36px);\n line-height: 1.08;\n letter-spacing: -0.025em;\n margin: 0 0 12px;\n font-weight: 780;\n }\n h1 .mono {\n font-family: var(--mono);\n font-weight: 700;\n }\n .lede {\n font-size: 16px;\n color: var(--text-dim);\n max-width: 70ch;\n margin: 0;\n }\n .lede code {\n font-family: var(--mono);\n font-size: 0.88em;\n background: var(--surface-2);\n padding: 1px 6px;\n border-radius: 5px;\n color: var(--text);\n }\n\n /* section headers */\n .sec-head {\n display: flex;\n align-items: baseline;\n gap: 12px;\n margin: 46px 0 4px;\n }\n .sec-head h2 {\n font-size: 13px;\n text-transform: uppercase;\n letter-spacing: 0.12em;\n color: var(--text-faint);\n margin: 0;\n font-weight: 700;\n }\n .sec-head .rule {\n flex: 1;\n height: 1px;\n background: var(--border);\n }\n\n /* diagram */\n .diagram-note {\n font-size: 13px;\n color: var(--text-dim);\n margin: 16px 0 14px;\n max-width: 78ch;\n }\n .diagram-note .mono {\n font-family: var(--mono);\n }\n .dia-card {\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n box-shadow: var(--shadow-sm);\n overflow: hidden;\n }\n .dia-scroll {\n overflow-x: auto;\n background: var(--dia-bg);\n padding: 18px 16px;\n }\n .dia-scroll .mflow {\n min-width: min-content;\n display: flex;\n justify-content: center;\n }\n .dia-scroll .mflow svg {\n max-width: none;\n height: auto;\n }\n .dia-msg {\n padding: 22px 18px;\n font-size: 13.5px;\n color: var(--text-dim);\n }\n .dia-msg code {\n font-family: var(--mono);\n font-size: 12px;\n background: var(--surface-2);\n padding: 1px 6px;\n border-radius: 5px;\n }\n .dia-src {\n margin: 10px 0 0;\n padding: 12px 14px;\n background: var(--surface-2);\n border-radius: 9px;\n font-family: var(--mono);\n font-size: 11.5px;\n white-space: pre;\n overflow-x: auto;\n color: var(--text-dim);\n max-height: 260px;\n }\n\n /* sub-machines */\n .sm-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));\n gap: 14px;\n margin-top: 18px;\n }\n .sm-def {\n border: 1.5px dashed var(--border-strong);\n border-radius: var(--radius);\n background: var(--surface);\n box-shadow: var(--shadow-sm);\n overflow: hidden;\n }\n .sm-def .h {\n padding: 13px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface-2);\n }\n .sm-def .h .nm {\n font-family: var(--mono);\n font-weight: 700;\n font-size: 15px;\n }\n .sm-def .h .pm {\n font-family: var(--mono);\n font-size: 12px;\n color: var(--text-dim);\n display: block;\n margin-top: 3px;\n }\n .sm-def .h .pm b {\n color: var(--accent);\n }\n .sm-def .body {\n padding: 12px 16px 16px;\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n }\n .muted-note {\n color: var(--text-faint);\n font-style: italic;\n margin-top: 16px;\n }\n\n /* toolbar */\n .toolbar {\n display: flex;\n gap: 12px;\n align-items: center;\n margin: 18px 0 22px;\n flex-wrap: wrap;\n }\n .search {\n flex: 1;\n min-width: 200px;\n position: relative;\n }\n .search input {\n width: 100%;\n font-family: var(--mono);\n font-size: 14px;\n padding: 10px 12px 10px 34px;\n border-radius: 10px;\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--text);\n }\n .search input:focus {\n outline: none;\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-soft);\n }\n .search svg {\n position: absolute;\n left: 11px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-faint);\n }\n .chips {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n }\n .chip-btn {\n font: inherit;\n font-size: 12.5px;\n padding: 7px 12px;\n border-radius: 999px;\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--text-dim);\n cursor: pointer;\n }\n .chip-btn:hover {\n border-color: var(--border-strong);\n color: var(--text);\n }\n .chip-btn.on {\n background: var(--accent);\n border-color: var(--accent);\n color: #fff;\n }\n .chip-btn:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .legend {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n }\n .legend .li {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n font-size: 12.5px;\n color: var(--text-dim);\n cursor: pointer;\n user-select: none;\n padding: 5px 9px;\n border-radius: 999px;\n border: 1px solid var(--border);\n background: var(--surface);\n }\n .legend .li:hover {\n border-color: var(--border-strong);\n }\n .legend .li.off {\n opacity: 0.4;\n }\n .legend .li:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .legend .li .dot {\n width: 10px;\n height: 10px;\n border-radius: 3px;\n }\n\n /* groups + cards */\n .group {\n margin-bottom: 30px;\n }\n .group-head {\n display: flex;\n align-items: baseline;\n gap: 10px;\n margin: 0 0 12px;\n }\n .group-head h3 {\n font-family: var(--mono);\n font-size: 15px;\n margin: 0;\n letter-spacing: -0.01em;\n }\n .group-head .flow-tag {\n font-family: var(--mono);\n font-size: 11px;\n padding: 2px 8px;\n border-radius: 6px;\n background: var(--surface-2);\n color: var(--text-dim);\n border: 1px solid var(--border);\n }\n .grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));\n gap: 12px;\n }\n .card {\n text-align: left;\n font: inherit;\n cursor: pointer;\n background: var(--surface);\n border: 1px solid var(--border);\n border-left: 4px solid var(--card-accent, var(--border));\n border-radius: 10px;\n padding: 13px 15px;\n box-shadow: var(--shadow-sm);\n transition:\n transform 0.12s ease,\n box-shadow 0.12s ease,\n border-color 0.12s ease;\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .card:hover {\n transform: translateY(-2px);\n box-shadow: var(--shadow);\n }\n .card:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .card[data-actor=\"human\"] {\n --card-accent: var(--human-dot);\n }\n .card[data-actor=\"agent\"] {\n --card-accent: var(--agent-dot);\n }\n .card[data-actor=\"check\"] {\n --card-accent: var(--check-dot);\n }\n .card[data-actor=\"commit\"] {\n --card-accent: var(--engine-dot);\n }\n .card[data-actor=\"unknown\"] {\n --card-accent: var(--unknown-dot);\n }\n .card .name {\n font-family: var(--mono);\n font-weight: 700;\n font-size: 14.5px;\n letter-spacing: -0.01em;\n word-break: break-word;\n }\n .card .row {\n display: flex;\n align-items: center;\n gap: 6px;\n flex-wrap: wrap;\n }\n .card .init-tick {\n position: absolute;\n top: 11px;\n right: 12px;\n font-size: 10px;\n font-family: var(--mono);\n color: var(--accent);\n background: var(--accent-soft);\n padding: 1px 6px;\n border-radius: 5px;\n font-weight: 700;\n }\n\n .pill {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n font-family: var(--mono);\n font-size: 11px;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 999px;\n }\n .pill.actor-human {\n background: var(--human-bg);\n color: var(--human-fg);\n }\n .pill.actor-agent {\n background: var(--agent-bg);\n color: var(--agent-fg);\n }\n .pill.actor-check {\n background: var(--check-bg);\n color: var(--check-fg);\n }\n .pill.actor-engine {\n background: var(--engine-bg);\n color: var(--engine-fg);\n }\n .pill.actor-unknown {\n background: var(--unknown-bg);\n color: var(--unknown-fg);\n }\n .pill .dot {\n width: 7px;\n height: 7px;\n border-radius: 2px;\n }\n .pill.actor-human .dot {\n background: var(--human-dot);\n }\n .pill.actor-agent .dot {\n background: var(--agent-dot);\n }\n .pill.actor-check .dot {\n background: var(--check-dot);\n }\n .pill.actor-engine .dot {\n background: var(--engine-dot);\n }\n .pill.actor-unknown .dot {\n background: var(--unknown-dot);\n }\n .kind {\n font-family: var(--mono);\n font-size: 11px;\n padding: 2px 7px;\n border-radius: 6px;\n border: 1px solid var(--border-strong);\n color: var(--text-dim);\n background: var(--surface-2);\n }\n .flag {\n font-family: var(--mono);\n font-size: 10.5px;\n padding: 1px 6px;\n border-radius: 5px;\n background: var(--accent-soft);\n color: var(--accent);\n font-weight: 600;\n border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);\n }\n\n /* drawer */\n .backdrop {\n position: fixed;\n inset: 0;\n background: rgba(10, 16, 28, 0.5);\n backdrop-filter: blur(2px);\n opacity: 0;\n pointer-events: none;\n transition: opacity 0.22s ease;\n z-index: 90;\n }\n .backdrop.open {\n opacity: 1;\n pointer-events: auto;\n }\n .drawer {\n position: fixed;\n top: 0;\n right: 0;\n height: 100%;\n width: min(560px, 100%);\n background: var(--surface);\n border-left: 1px solid var(--border);\n box-shadow: var(--shadow);\n transform: translateX(100%);\n transition: transform 0.26s cubic-bezier(0.22, 0.61, 0.36, 1);\n z-index: 100;\n display: flex;\n flex-direction: column;\n }\n .drawer.open {\n transform: translateX(0);\n }\n .drawer-head {\n padding: 18px 22px 14px;\n border-bottom: 1px solid var(--border);\n border-top: 4px solid var(--card-accent, var(--accent));\n }\n .drawer-head .top {\n display: flex;\n align-items: flex-start;\n gap: 12px;\n }\n .drawer-head h3 {\n font-family: var(--mono);\n font-size: 20px;\n font-weight: 700;\n margin: 0;\n letter-spacing: -0.02em;\n word-break: break-word;\n }\n .drawer-head .meta-row {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n margin-top: 10px;\n }\n .close-x {\n margin-left: auto;\n border: 1px solid var(--border);\n background: var(--surface-2);\n color: var(--text-dim);\n border-radius: 8px;\n width: 30px;\n height: 30px;\n cursor: pointer;\n flex: none;\n font-size: 15px;\n }\n .close-x:hover {\n color: var(--text);\n }\n .close-x:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .drawer-body {\n overflow-y: auto;\n padding: 20px 22px 40px;\n flex: 1;\n }\n .field {\n margin-bottom: 18px;\n }\n .field > .k {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.09em;\n color: var(--text-faint);\n font-weight: 700;\n margin-bottom: 7px;\n }\n .kv {\n display: grid;\n grid-template-columns: 88px 1fr;\n gap: 4px 14px;\n font-size: 13.5px;\n }\n .kv dt {\n color: var(--text-faint);\n font-size: 12px;\n }\n .kv dd {\n margin: 0;\n font-family: var(--mono);\n font-size: 12.5px;\n word-break: break-word;\n }\n .none {\n color: var(--text-faint);\n font-style: italic;\n font-family: var(--sans);\n }\n\n .edges {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .edge {\n border: 1px solid var(--border);\n border-radius: 9px;\n padding: 10px 12px;\n background: var(--surface-2);\n }\n .edge .er {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n }\n .edge .pat {\n font-family: var(--mono);\n font-size: 12px;\n font-weight: 700;\n background: var(--surface);\n border: 1px solid var(--border-strong);\n padding: 2px 8px;\n border-radius: 6px;\n white-space: nowrap;\n }\n .edge .arr {\n color: var(--text-faint);\n }\n .goto {\n font: inherit;\n font-family: var(--mono);\n font-size: 12.5px;\n font-weight: 700;\n cursor: pointer;\n border: 1px solid var(--card-accent, var(--accent));\n background: transparent;\n color: var(--card-accent, var(--accent));\n padding: 2px 9px;\n border-radius: 6px;\n }\n .goto:hover {\n background: var(--card-accent, var(--accent));\n color: #fff;\n }\n .goto:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .goto[data-actor=\"human\"] {\n --card-accent: var(--human-dot);\n }\n .goto[data-actor=\"agent\"] {\n --card-accent: var(--agent-dot);\n }\n .goto[data-actor=\"check\"] {\n --card-accent: var(--check-dot);\n }\n .goto[data-actor=\"commit\"] {\n --card-accent: var(--engine-dot);\n }\n .goto[data-actor=\"unknown\"] {\n --card-accent: var(--unknown-dot);\n }\n .edge .em {\n font-size: 12.5px;\n color: var(--text-dim);\n margin-top: 6px;\n line-height: 1.45;\n }\n .incoming {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n }\n\n .flag-list {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .flag-item {\n display: flex;\n gap: 9px;\n font-size: 13px;\n align-items: baseline;\n }\n .flag-item .fn {\n font-family: var(--mono);\n font-size: 11.5px;\n font-weight: 700;\n color: var(--accent);\n flex: none;\n padding: 1px 6px;\n border-radius: 5px;\n background: var(--accent-soft);\n }\n .flag-item .fd {\n color: var(--text-dim);\n }\n\n /* vars */\n .vars-grid {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 16px;\n }\n .var-chip {\n font-family: var(--mono);\n font-size: 12px;\n padding: 6px 10px;\n border-radius: 8px;\n border: 1px solid var(--border);\n background: var(--surface);\n box-shadow: var(--shadow-sm);\n }\n .var-chip b {\n color: var(--accent);\n }\n\n footer {\n border-top: 1px solid var(--border);\n margin-top: 56px;\n padding: 24px 0 60px;\n color: var(--text-faint);\n font-size: 13px;\n }\n footer code {\n font-family: var(--mono);\n background: var(--surface-2);\n padding: 1px 6px;\n border-radius: 5px;\n }\n\n .hidden {\n display: none !important;\n }\n @media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition: none !important;\n animation: none !important;\n }\n }\n @media (max-width: 640px) {\n .brand .sub {\n display: none;\n }\n .kv {\n grid-template-columns: 74px 1fr;\n }\n }\n </style>\n </head>\n <body>\n <header class=\"top\">\n <div class=\"top-inner\">\n <div class=\"brand\">\n <span class=\"cmd\"><b>gtd</b> · workflow visualizer</span>\n <span class=\"sub\" id=\"brandSub\">read-only view</span>\n </div>\n <div class=\"summary\" id=\"summary\"></div>\n <button\n class=\"theme-btn\"\n id=\"themeBtn\"\n title=\"Toggle light/dark theme\"\n aria-label=\"Toggle light/dark theme\"\n >\n ◐\n </button>\n </div>\n </header>\n\n <main class=\"wrap\" id=\"app\">\n <section class=\"hero\">\n <h1 id=\"heroTitle\">Loading workflow…</h1>\n <p class=\"lede\" id=\"heroLede\">\n Fetching <code>/workflow.json</code> from the local\n <span class=\"mono\">gtd visualize</span> server.\n </p>\n </section>\n\n <div id=\"content\" class=\"hidden\">\n <div class=\"sec-head\">\n <h2>Main flow</h2>\n <span class=\"rule\"></span>\n </div>\n <p class=\"diagram-note\" id=\"diaNote\"></p>\n <div class=\"dia-card\">\n <div class=\"dia-scroll\" id=\"diaScroll\"><div class=\"dia-msg\">Rendering diagram…</div></div>\n </div>\n\n <div class=\"sec-head\">\n <h2>Sub-machines</h2>\n <span class=\"rule\"></span>\n </div>\n <div id=\"smSection\"></div>\n\n <div class=\"sec-head\">\n <h2>State catalogue</h2>\n <span class=\"rule\"></span>\n </div>\n <div class=\"toolbar\">\n <div class=\"search\">\n <svg\n width=\"15\"\n height=\"15\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2.4\"\n >\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <input\n id=\"search\"\n type=\"search\"\n placeholder=\"filter by name, actor, kind, file, mode, flag…\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n aria-label=\"Filter states\"\n />\n </div>\n <div class=\"chips\" id=\"kindChips\"></div>\n <div class=\"legend\" id=\"legend\"></div>\n </div>\n <div id=\"groups\"></div>\n </div>\n </main>\n\n <div class=\"backdrop\" id=\"backdrop\"></div>\n <aside\n class=\"drawer\"\n id=\"drawer\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"State detail\"\n tabindex=\"-1\"\n ></aside>\n\n <footer class=\"wrap\" id=\"footer\"></footer>\n\n <script type=\"module\">\n // ── theme ──────────────────────────────────────────────────────────────\n const root = document.documentElement\n const prefersDark = () => matchMedia(\"(prefers-color-scheme: dark)\").matches\n const isDark = () => {\n const t = root.getAttribute(\"data-theme\")\n return t ? t === \"dark\" : prefersDark()\n }\n document.getElementById(\"themeBtn\").onclick = () => {\n const next = isDark() ? \"light\" : \"dark\"\n root.setAttribute(\"data-theme\", next)\n renderDiagram()\n }\n\n const esc = (s) =>\n String(s).replace(\n /[&<>\"']/g,\n (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c],\n )\n\n // Actor metadata: color class, css-var prefix, mermaid classDef name, label.\n const ACTOR = {\n human: { cls: \"actor-human\", dot: \"human\", label: \"human\" },\n agent: { cls: \"actor-agent\", dot: \"agent\", label: \"agent\" },\n check: { cls: \"actor-check\", dot: \"check\", label: \"check\" },\n commit: { cls: \"actor-engine\", dot: \"engine\", label: \"commit\" },\n unknown: { cls: \"actor-unknown\", dot: \"unknown\", label: \"?\" },\n }\n // A commit state has no actor — treat it as the engine/commit colour.\n const actorOf = (s) => s.actor || (s.kind === \"commit\" ? \"commit\" : \"unknown\")\n const meta = (a) => ACTOR[a] || ACTOR.unknown\n\n const FLAGS = {\n initial:\n \"The one initial state — an unrecognized HEAD (any non-gtd history) resolves here.\",\n reviewEntry:\n \"This state is a dedicated CLI entry point (e.g. `gtd review <ref>`) that starts a brand-new process.\",\n fixEntry:\n \"This state is the `gtd fix` entry point — a red suite drops into the fixing loop, a green suite is a no-op.\",\n reviewWindow:\n \"While resting here, gtd opens the review checkout window: HEAD is rewound to the review base so the whole diff surfaces as ordinary uncommitted changes.\",\n reviewBase:\n \"Anchors the incremental review diff base — its most-recent in-process commit is where the next reviewing turn and checkout window start.\",\n answerGate:\n \"Edge gate: a step is refused while any open question in the steering file is not answered with exactly one tick.\",\n requireProgress:\n \"Edge gate: refuses a work-free turn that only deletes the instructions file (unless it held a NOTHING ACTIONABLE sentinel).\",\n }\n\n // ── state ──────────────────────────────────────────────────────────────\n let MODEL = null\n let STATES = []\n let byName = {}\n let DIAGRAM_SRC = \"\"\n let mermaidMod = null\n let renderSeq = 0\n\n const actorFilter = new Set([\"human\", \"agent\", \"check\", \"commit\", \"unknown\"])\n let kindFilter = \"all\"\n\n // ── boot ─────────────────────────────────────────────────────────────\n try {\n const res = await fetch(\"/workflow.json\")\n if (!res.ok) throw new Error(\"HTTP \" + res.status)\n MODEL = await res.json()\n } catch (e) {\n document.getElementById(\"heroTitle\").textContent = \"Could not load workflow\"\n document.getElementById(\"heroLede\").innerHTML =\n \"Fetching <code>/workflow.json</code> failed: \" +\n esc(e.message) +\n '. This page must be served by <span class=\"mono\">gtd visualize</span>.'\n throw e\n }\n\n STATES = MODEL.states || []\n byName = Object.fromEntries(STATES.map((s) => [s.name, s]))\n\n // ── header summary + hero ───────────────────────────────────────────\n const counts = { human: 0, agent: 0, check: 0, commit: 0, unknown: 0 }\n STATES.forEach((s) => {\n counts[actorOf(s)]++\n })\n const nGroups = (MODEL.groups || []).length\n const cnt = (a) =>\n counts[a] ? `<span><span class=\"s-num\">${counts[a]}</span> ${meta(a).label}</span>` : \"\"\n document.getElementById(\"summary\").innerHTML = [\n `<span><span class=\"s-num\">${STATES.length}</span> states</span>`,\n `<span><span class=\"s-num\">${nGroups}</span> sub-machine${nGroups === 1 ? \"\" : \"s\"}</span>`,\n `<span class=\"sep\">·</span>`,\n cnt(\"human\"),\n cnt(\"agent\"),\n cnt(\"check\"),\n cnt(\"commit\"),\n cnt(\"unknown\"),\n ]\n .filter(Boolean)\n .join(\" \")\n\n document.getElementById(\"heroTitle\").innerHTML =\n `A workflow of <span class=\"mono\">${STATES.length}</span> states`\n document.getElementById(\"heroLede\").innerHTML =\n `Rendered live from <code>/workflow.json</code>. The initial state is ` +\n `<code>${esc(MODEL.initial)}</code>. Click any state — in the diagram, a sub-machine chip, ` +\n `or the catalogue — to open its full definition, walk its outgoing edges, and see what routes into it.`\n document.getElementById(\"content\").classList.remove(\"hidden\")\n\n document.getElementById(\"footer\").innerHTML =\n `Read-only view served by <code>gtd visualize</code>. Node shape = content kind ` +\n `(<span style=\"font-family:var(--mono)\">([message])</span> · ` +\n `<span style=\"font-family:var(--mono)\">[prompt]</span> · ` +\n `<span style=\"font-family:var(--mono)\">{{script}}</span> · ` +\n `<span style=\"font-family:var(--mono)\">[[commit]]</span>); colour = actor.`\n\n // ── pill helper ────────────────────────────────────────────────────\n const pill = (a) =>\n `<span class=\"pill ${meta(a).cls}\"><span class=\"dot\"></span>${meta(a).label}</span>`\n\n // ── mermaid source generation ──────────────────────────────────────\n // Safe, stable node ids (state names can contain hyphens etc.).\n const idFor = (() => {\n const map = new Map()\n return (name) => {\n if (!map.has(name)) map.set(name, \"n\" + map.size)\n return map.get(name)\n }\n })()\n const shapeFor = (kind, label) => {\n const l = `\"${label.replace(/\"/g, \"&quot;\")}\"`\n switch (kind) {\n case \"message\":\n return `([\"${label.replace(/\"/g, \"&quot;\")}\"])`\n case \"prompt\":\n return `[${l}]`\n case \"script\":\n return `{{${l}}}`\n case \"commit\":\n return `[[${l}]]`\n default:\n return `[${l}]`\n }\n }\n const mlabel = (s) => `\"${String(s).replace(/\"/g, \"&quot;\").replace(/\\|/g, \"&#124;\")}\"`\n\n function buildMermaid() {\n const lines = [\"flowchart TD\"]\n const rendered = new Set()\n const nodeLine = (s) => ` ${idFor(s.name)}${shapeFor(s.kind, s.name)}:::${actorOf(s)}`\n\n // Nodes grouped by their innermost sub-machine.\n const groups = MODEL.groups || []\n const byGroupName = {}\n STATES.forEach((s) => {\n if (s.group) (byGroupName[s.group] ||= []).push(s)\n })\n // Emit one subgraph per group (in declared order), containing only the\n // states whose innermost group is this one.\n groups.forEach((g, i) => {\n const members = byGroupName[g.name] || []\n if (!members.length) return\n lines.push(` subgraph sg${i}[${mlabel(g.name)}]`)\n members.forEach((s) => {\n lines.push(\" \" + nodeLine(s))\n rendered.add(s.name)\n })\n lines.push(\" end\")\n })\n // Ungrouped states sit outside any subgraph.\n STATES.forEach((s) => {\n if (!rendered.has(s.name)) {\n lines.push(nodeLine(s))\n rendered.add(s.name)\n }\n })\n\n // Edges: one per `on` entry, labelled with the pattern.\n STATES.forEach((s) => {\n ;(s.on || []).forEach((e) => {\n if (!byName[e.to]) return\n lines.push(` ${idFor(s.name)} -->|${mlabel(e.pattern)}| ${idFor(e.to)}`)\n })\n // Retry redirect (dashed) — informative, mirrors the \"routes in from\" list.\n if (s.retry && byName[s.retry.otherwise]) {\n lines.push(\n ` ${idFor(s.name)} -.->|${mlabel(\"retry ×\" + s.retry.max)}| ${idFor(s.retry.otherwise)}`,\n )\n }\n })\n\n lines.push(\n \"classDef human fill:#fbeccd,stroke:#d99413,color:#5a3d0a;\",\n \"classDef agent fill:#e5e2fb,stroke:#6d5ce0,color:#3a2c85;\",\n \"classDef check fill:#d3f0ef,stroke:#16a39d,color:#0c524f;\",\n \"classDef commit fill:#fbdfe4,stroke:#d84a68,color:#8a1f38;\",\n \"classDef unknown fill:#eef1f7,stroke:#c2ccdb,color:#55607a,stroke-dasharray:4 3;\",\n )\n return lines.join(\"\\n\")\n }\n\n DIAGRAM_SRC = buildMermaid()\n document.getElementById(\"diaNote\").innerHTML =\n `Every state as a node — shape is the content kind, colour is the actor. Each edge is an ` +\n `<span class=\"mono\">on</span> pattern (first match wins); dashed edges are ` +\n `<span class=\"mono\">retry</span> redirects. Sub-machines are drawn as labelled boxes. ` +\n `Scrolls horizontally if wide.`\n\n // ── diagram render (theme-aware, offline-tolerant) ─────────────────\n async function loadMermaid() {\n if (mermaidMod) return mermaidMod\n const m = await import(\"https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs\")\n mermaidMod = m.default\n return mermaidMod\n }\n async function renderDiagram() {\n const host = document.getElementById(\"diaScroll\")\n const seq = ++renderSeq\n let mermaid\n try {\n mermaid = await loadMermaid()\n } catch (e) {\n host.innerHTML =\n `<div class=\"dia-msg\">Could not load the Mermaid renderer (offline?). ` +\n `The workflow is still fully browsable below. Diagram source:` +\n `<pre class=\"dia-src\">${esc(DIAGRAM_SRC)}</pre></div>`\n return\n }\n if (seq !== renderSeq) return\n try {\n const dark = isDark()\n mermaid.initialize({\n startOnLoad: false,\n theme: \"base\",\n securityLevel: \"strict\",\n flowchart: { useMaxWidth: false, htmlLabels: true },\n themeVariables: {\n fontFamily: \"ui-monospace, Menlo, monospace\",\n fontSize: \"12.5px\",\n lineColor: dark ? \"#6b7690\" : \"#8a93a6\",\n primaryBorderColor: dark ? \"#3a465c\" : \"#c2ccdb\",\n tertiaryColor: dark ? \"#1c2434\" : \"#f0f3f8\",\n clusterBkg: dark ? \"#1c2434\" : \"#f0f3f8\",\n clusterBorder: dark ? \"#3a465c\" : \"#c2ccdb\",\n titleColor: dark ? \"#e6ebf4\" : \"#1a2233\",\n },\n })\n const { svg } = await mermaid.render(\"gtdflow\" + seq, DIAGRAM_SRC)\n if (seq !== renderSeq) return\n host.innerHTML = `<div class=\"mflow\">${svg}</div>`\n } catch (e) {\n host.innerHTML =\n `<div class=\"dia-msg\">Diagram render failed: ${esc(e.message)}. ` +\n `Diagram source:<pre class=\"dia-src\">${esc(DIAGRAM_SRC)}</pre></div>`\n }\n }\n renderDiagram()\n\n // ── sub-machines panel ──────────────────────────────────────────────\n ;(function renderSubmachines() {\n const el = document.getElementById(\"smSection\")\n const groups = MODEL.groups || []\n if (!groups.length) {\n el.innerHTML = `<p class=\"muted-note\">This workflow declares no sub-machines.</p>`\n return\n }\n const grid = document.createElement(\"div\")\n grid.className = \"sm-grid\"\n groups.forEach((g) => {\n const card = document.createElement(\"div\")\n card.className = \"sm-def\"\n const chips = (g.states || [])\n .map((n) => {\n const s = byName[n]\n const a = s ? actorOf(s) : \"unknown\"\n return `<button class=\"goto\" data-actor=\"${a}\" data-to=\"${esc(n)}\">${esc(n)}</button>`\n })\n .join(\"\")\n card.innerHTML =\n `<div class=\"h\"><span class=\"nm\">${esc(g.name)}</span>` +\n `<span class=\"pm\">instantiates <b>${esc(g.submachine)}</b> · ${(g.states || []).length} states</span></div>` +\n `<div class=\"body\">${chips}</div>`\n grid.appendChild(card)\n })\n el.innerHTML = \"\"\n el.appendChild(grid)\n el.querySelectorAll(\".goto\").forEach((b) => (b.onclick = () => openDrawer(b.dataset.to)))\n })()\n\n // ── vars ─────────────────────────────────────────────────────────────\n ;(function renderVars() {\n const vars = MODEL.vars || {}\n const keys = Object.keys(vars)\n if (!keys.length) return\n const el = document.getElementById(\"smSection\")\n const box = document.createElement(\"div\")\n box.innerHTML =\n `<div class=\"sec-head\" style=\"margin-top:34px\"><h2>Workflow variables</h2><span class=\"rule\"></span></div>` +\n `<div class=\"vars-grid\">${keys\n .map((k) => `<span class=\"var-chip\"><b>${esc(k)}</b> = ${esc(vars[k])}</span>`)\n .join(\"\")}</div>`\n el.appendChild(box)\n })()\n\n // ── kind chips + actor legend ──────────────────────────────────────\n const kindChipsEl = document.getElementById(\"kindChips\")\n const presentKinds = [\"all\", ...Array.from(new Set(STATES.map((s) => s.kind)))]\n presentKinds.forEach((k) => {\n const b = document.createElement(\"button\")\n b.className = \"chip-btn\" + (k === \"all\" ? \" on\" : \"\")\n b.textContent = k\n b.dataset.kind = k\n b.onclick = () => {\n kindFilter = k\n ;[...kindChipsEl.children].forEach((c) =>\n c.classList.toggle(\"on\", c.dataset.kind === kindFilter),\n )\n renderCatalogue()\n }\n kindChipsEl.appendChild(b)\n })\n\n const legendEl = document.getElementById(\"legend\")\n ;[\"human\", \"agent\", \"check\", \"commit\", \"unknown\"].forEach((a) => {\n if (!counts[a]) return\n const el = document.createElement(\"button\")\n el.className = \"li\"\n el.dataset.actor = a\n el.setAttribute(\"aria-pressed\", \"true\")\n el.innerHTML = `<span class=\"dot\" style=\"background:var(--${meta(a).dot}-dot)\"></span>${meta(a).label}`\n el.onclick = () => {\n const on = actorFilter.has(a)\n on ? actorFilter.delete(a) : actorFilter.add(a)\n el.classList.toggle(\"off\", on)\n el.setAttribute(\"aria-pressed\", String(!on))\n renderCatalogue()\n }\n legendEl.appendChild(el)\n })\n\n // ── catalogue ──────────────────────────────────────────────────────\n const groupsEl = document.getElementById(\"groups\")\n function stateMatches(s, q) {\n if (!actorFilter.has(actorOf(s))) return false\n if (kindFilter !== \"all\" && s.kind !== kindFilter) return false\n if (!q) return true\n const hay = [\n s.name,\n s.actor,\n s.kind,\n s.file,\n s.mode,\n s.model,\n s.memory,\n (s.flags || []).join(\" \"),\n ]\n .filter(Boolean)\n .join(\" \")\n .toLowerCase()\n return hay.includes(q)\n }\n function cardFor(s) {\n const a = actorOf(s)\n const card = document.createElement(\"button\")\n card.className = \"card\"\n card.dataset.actor = a\n const flags = (s.flags || [])\n .filter((f) => f !== \"initial\")\n .map((f) => `<span class=\"flag\">${esc(f)}</span>`)\n .join(\"\")\n const retry = s.retry ? `<span class=\"flag\">retry ${s.retry.max}</span>` : \"\"\n const initTick = s.initial ? `<div class=\"init-tick\">initial</div>` : \"\"\n card.innerHTML =\n `${initTick}<div class=\"name\">${esc(s.name)}</div>` +\n `<div class=\"row\">${pill(a)}<span class=\"kind\">${esc(s.kind)}</span>${retry}${flags}</div>`\n card.onclick = () => openDrawer(s.name)\n return card\n }\n function renderCatalogue() {\n const q = document.getElementById(\"search\").value.trim().toLowerCase()\n groupsEl.innerHTML = \"\"\n const sections = []\n ;(MODEL.groups || []).forEach((g) => {\n sections.push({\n label: g.name,\n flow: g.submachine,\n items: STATES.filter((s) => s.group === g.name),\n })\n })\n const ungrouped = STATES.filter((s) => !s.group)\n if (ungrouped.length) sections.push({ label: \"Ungrouped\", flow: \"\", items: ungrouped })\n\n let shown = 0\n sections.forEach((sec) => {\n const items = sec.items.filter((s) => stateMatches(s, q))\n if (!items.length) return\n shown += items.length\n const wrap = document.createElement(\"div\")\n wrap.className = \"group\"\n wrap.innerHTML =\n `<div class=\"group-head\">${sec.flow ? `<span class=\"flow-tag\">${esc(sec.flow)}</span>` : \"\"}` +\n `<h3>${esc(sec.label)}</h3></div>`\n const grid = document.createElement(\"div\")\n grid.className = \"grid\"\n items.forEach((s) => grid.appendChild(cardFor(s)))\n wrap.appendChild(grid)\n groupsEl.appendChild(wrap)\n })\n if (!shown)\n groupsEl.innerHTML = `<p class=\"none\" style=\"padding:20px 0\">No states match this filter.</p>`\n }\n document.getElementById(\"search\").addEventListener(\"input\", renderCatalogue)\n renderCatalogue()\n\n // ── drawer ─────────────────────────────────────────────────────────\n const drawer = document.getElementById(\"drawer\")\n const backdrop = document.getElementById(\"backdrop\")\n let lastFocus = null\n\n function openDrawer(name) {\n const s = byName[name]\n if (!s) return\n const a = actorOf(s)\n if (!drawer.classList.contains(\"open\")) lastFocus = document.activeElement\n\n const kv = []\n kv.push([\"actor\", pill(a)])\n kv.push([\"kind\", `<span class=\"kind\">${esc(s.kind)}</span>`])\n kv.push([\"model\", s.model ? esc(s.model) : '<span class=\"none\">—</span>'])\n kv.push([\"memory\", s.memory ? esc(s.memory) : '<span class=\"none\">—</span>'])\n kv.push([\"file\", s.file ? esc(s.file) : '<span class=\"none\">—</span>'])\n kv.push([\"mode\", s.mode ? esc(s.mode) : '<span class=\"none\">—</span>'])\n if (s.retry)\n kv.push([\n \"retry\",\n `max ${s.retry.max} → <span style=\"color:var(--accent)\">${esc(s.retry.otherwise)}</span>`,\n ])\n if (s.group) kv.push([\"group\", esc(s.group)])\n\n const flags = s.flags || []\n const flagBlock = flags.length\n ? `<div class=\"field\"><div class=\"k\">flags & gates</div><div class=\"flag-list\">${flags\n .map(\n (f) =>\n `<div class=\"flag-item\"><span class=\"fn\">${esc(f)}</span><span class=\"fd\">${esc(FLAGS[f] || \"\")}</span></div>`,\n )\n .join(\"\")}</div></div>`\n : \"\"\n\n const edges = s.on || []\n const edgesBlock = edges.length\n ? `<div class=\"field\"><div class=\"k\">outgoing edges — on (first match wins)</div><div class=\"edges\">${edges\n .map((e) => {\n const t = byName[e.to]\n const ta = t ? actorOf(t) : \"unknown\"\n return (\n `<div class=\"edge\"><div class=\"er\"><span class=\"pat\">${esc(e.pattern)}</span><span class=\"arr\">→</span>` +\n `<button class=\"goto\" data-actor=\"${ta}\" data-to=\"${esc(e.to)}\">${esc(e.to)}</button></div>` +\n `${e.describe ? `<div class=\"em\">${esc(e.describe)}</div>` : \"\"}</div>`\n )\n })\n .join(\"\")}</div></div>`\n : `<div class=\"field\"><div class=\"k\">outgoing edges</div><p class=\"none\">${\n s.kind === \"commit\"\n ? \"Commit state — no outgoing edges. Entering it ends the process in one squash.\"\n : \"No outgoing edges declared.\"\n }</p></div>`\n\n const retryOut =\n s.retry && byName[s.retry.otherwise]\n ? `<div class=\"field\"><div class=\"k\">retry redirect</div><div class=\"edge\"><div class=\"er\">` +\n `<span class=\"pat\">after ${esc(s.retry.max)} tries</span><span class=\"arr\">→</span>` +\n `<button class=\"goto\" data-actor=\"${actorOf(byName[s.retry.otherwise])}\" data-to=\"${esc(s.retry.otherwise)}\">${esc(s.retry.otherwise)}</button>` +\n `</div></div></div>`\n : \"\"\n\n const inc = s.incoming || []\n const incBlock =\n `<div class=\"field\"><div class=\"k\">routes in from (${inc.length})</div>` +\n (inc.length\n ? `<div class=\"incoming\">${inc\n .map((i) => {\n const f = byName[i.from]\n return `<button class=\"goto\" data-actor=\"${f ? actorOf(f) : \"unknown\"}\" data-to=\"${esc(i.from)}\" title=\"${esc(i.pattern)}\">${esc(i.from)}</button>`\n })\n .join(\"\")}</div>`\n : `<p class=\"none\">Reached only as an entry point (a CLI command enters it directly).</p>`) +\n `</div>`\n\n drawer.style.setProperty(\"--card-accent\", `var(--${meta(a).dot}-dot)`)\n drawer.innerHTML =\n `<div class=\"drawer-head\" style=\"--card-accent:var(--${meta(a).dot}-dot)\">` +\n `<div class=\"top\"><h3>${esc(s.name)}</h3>` +\n `<button class=\"close-x\" id=\"closeX\" aria-label=\"Close detail\">✕</button></div>` +\n `<div class=\"meta-row\">${pill(a)}<span class=\"kind\">${esc(s.kind)}</span>` +\n `${s.initial ? '<span class=\"flag\">initial</span>' : \"\"}` +\n `${s.retry ? `<span class=\"flag\">retry ${s.retry.max}</span>` : \"\"}` +\n `${flags\n .filter((f) => f !== \"initial\")\n .map((f) => `<span class=\"flag\">${esc(f)}</span>`)\n .join(\"\")}</div></div>` +\n `<div class=\"drawer-body\">` +\n `<div class=\"field\"><div class=\"k\">definition</div><dl class=\"kv\">${kv\n .map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`)\n .join(\"\")}</dl></div>` +\n flagBlock +\n edgesBlock +\n retryOut +\n incBlock +\n `</div>`\n\n drawer\n .querySelectorAll(\".goto\")\n .forEach((b) => (b.onclick = () => openDrawer(b.dataset.to)))\n drawer.querySelector(\"#closeX\").onclick = closeDrawer\n\n backdrop.classList.add(\"open\")\n drawer.classList.add(\"open\")\n drawer.querySelector(\".drawer-body\").scrollTop = 0\n drawer.querySelector(\"#closeX\").focus()\n }\n function closeDrawer() {\n backdrop.classList.remove(\"open\")\n drawer.classList.remove(\"open\")\n if (lastFocus && lastFocus.focus) lastFocus.focus()\n }\n backdrop.onclick = closeDrawer\n document.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Escape\" && drawer.classList.contains(\"open\")) closeDrawer()\n })\n\n // expose for interactions\n window.__openState = openDrawer\n <\/script>\n </body>\n</html>\n";
205054
+ var visualize_default = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>gtd · workflow visualizer</title>\n <style>\n :root {\n --bg: #f4f6fa;\n --surface: #ffffff;\n --surface-2: #eef1f7;\n --surface-3: #e6ebf3;\n --border: #d9dee8;\n --border-strong: #c2ccdb;\n --text: #1a2233;\n --text-dim: #55607a;\n --text-faint: #8a93a6;\n --accent: #2d6cdf;\n --accent-soft: #e4edfd;\n --human-bg: #fbeccd;\n --human-fg: #7a5310;\n --human-dot: #d99413;\n --agent-bg: #e5e2fb;\n --agent-fg: #463693;\n --agent-dot: #6d5ce0;\n --check-bg: #d3f0ef;\n --check-fg: #0c524f;\n --check-dot: #16a39d;\n --engine-bg: #fbdfe4;\n --engine-fg: #8a1f38;\n --engine-dot: #d84a68;\n --unknown-bg: #eef1f7;\n --unknown-fg: #55607a;\n --unknown-dot: #8a93a6;\n --shadow: 0 1px 2px rgba(20, 30, 50, 0.06), 0 8px 28px rgba(20, 30, 50, 0.1);\n --shadow-sm: 0 1px 2px rgba(20, 30, 50, 0.08);\n --radius: 12px;\n --mono:\n ui-monospace, \"SF Mono\", \"SFMono-Regular\", Menlo, Consolas, \"Liberation Mono\", monospace;\n --sans:\n ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n --dia-bg: #f7f9fc;\n }\n @media (prefers-color-scheme: dark) {\n :root {\n --bg: #0e1420;\n --surface: #161d2b;\n --surface-2: #1c2434;\n --surface-3: #232c3e;\n --border: #2b3547;\n --border-strong: #3a465c;\n --text: #e6ebf4;\n --text-dim: #9aa6bd;\n --text-faint: #6b7690;\n --accent: #5b8def;\n --accent-soft: #1b2740;\n --human-bg: #3a2e12;\n --human-fg: #f0c674;\n --human-dot: #e0a83e;\n --agent-bg: #26224a;\n --agent-fg: #b6abf7;\n --agent-dot: #8b7cf0;\n --check-bg: #123433;\n --check-fg: #68dcd4;\n --check-dot: #2dc4bc;\n --engine-bg: #3a1b24;\n --engine-fg: #f2a3b3;\n --engine-dot: #e05f7c;\n --unknown-bg: #1c2434;\n --unknown-fg: #9aa6bd;\n --unknown-dot: #6b7690;\n --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 34px rgba(0, 0, 0, 0.45);\n --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);\n --dia-bg: #0b1018;\n }\n }\n :root[data-theme=\"light\"] {\n --bg: #f4f6fa;\n --surface: #ffffff;\n --surface-2: #eef1f7;\n --surface-3: #e6ebf3;\n --border: #d9dee8;\n --border-strong: #c2ccdb;\n --text: #1a2233;\n --text-dim: #55607a;\n --text-faint: #8a93a6;\n --accent: #2d6cdf;\n --accent-soft: #e4edfd;\n --human-bg: #fbeccd;\n --human-fg: #7a5310;\n --human-dot: #d99413;\n --agent-bg: #e5e2fb;\n --agent-fg: #463693;\n --agent-dot: #6d5ce0;\n --check-bg: #d3f0ef;\n --check-fg: #0c524f;\n --check-dot: #16a39d;\n --engine-bg: #fbdfe4;\n --engine-fg: #8a1f38;\n --engine-dot: #d84a68;\n --unknown-bg: #eef1f7;\n --unknown-fg: #55607a;\n --unknown-dot: #8a93a6;\n --shadow: 0 1px 2px rgba(20, 30, 50, 0.06), 0 8px 28px rgba(20, 30, 50, 0.1);\n --shadow-sm: 0 1px 2px rgba(20, 30, 50, 0.08);\n --dia-bg: #f7f9fc;\n }\n :root[data-theme=\"dark\"] {\n --bg: #0e1420;\n --surface: #161d2b;\n --surface-2: #1c2434;\n --surface-3: #232c3e;\n --border: #2b3547;\n --border-strong: #3a465c;\n --text: #e6ebf4;\n --text-dim: #9aa6bd;\n --text-faint: #6b7690;\n --accent: #5b8def;\n --accent-soft: #1b2740;\n --human-bg: #3a2e12;\n --human-fg: #f0c674;\n --human-dot: #e0a83e;\n --agent-bg: #26224a;\n --agent-fg: #b6abf7;\n --agent-dot: #8b7cf0;\n --check-bg: #123433;\n --check-fg: #68dcd4;\n --check-dot: #2dc4bc;\n --engine-bg: #3a1b24;\n --engine-fg: #f2a3b3;\n --engine-dot: #e05f7c;\n --unknown-bg: #1c2434;\n --unknown-fg: #9aa6bd;\n --unknown-dot: #6b7690;\n --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 34px rgba(0, 0, 0, 0.45);\n --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);\n --dia-bg: #0b1018;\n }\n\n * {\n box-sizing: border-box;\n }\n html,\n body {\n max-width: 100%;\n overflow-x: hidden;\n }\n body {\n margin: 0;\n background: var(--bg);\n color: var(--text);\n font-family: var(--sans);\n font-size: 15px;\n line-height: 1.55;\n -webkit-font-smoothing: antialiased;\n }\n ::selection {\n background: var(--accent);\n color: #fff;\n }\n .wrap {\n max-width: 1180px;\n margin: 0 auto;\n padding: 0 20px;\n }\n\n /* header */\n header.top {\n position: sticky;\n top: 0;\n z-index: 40;\n background: color-mix(in srgb, var(--bg) 88%, transparent);\n backdrop-filter: blur(10px);\n border-bottom: 1px solid var(--border);\n }\n .top-inner {\n display: flex;\n align-items: center;\n gap: 18px;\n padding: 12px 20px;\n max-width: 1180px;\n margin: 0 auto;\n flex-wrap: wrap;\n }\n .brand {\n display: flex;\n align-items: baseline;\n gap: 10px;\n margin-right: auto;\n }\n .brand .cmd {\n font-family: var(--mono);\n font-weight: 700;\n font-size: 17px;\n letter-spacing: -0.02em;\n }\n .brand .cmd b {\n color: var(--accent);\n }\n .brand .sub {\n font-size: 12px;\n color: var(--text-faint);\n letter-spacing: 0.04em;\n text-transform: uppercase;\n }\n .summary {\n font-size: 12.5px;\n color: var(--text-dim);\n display: flex;\n gap: 10px;\n flex-wrap: wrap;\n align-items: center;\n }\n .summary .s-num {\n font-family: var(--mono);\n font-weight: 700;\n color: var(--text);\n }\n .summary .sep {\n color: var(--border-strong);\n }\n .theme-btn {\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--text-dim);\n border-radius: 8px;\n width: 32px;\n height: 32px;\n cursor: pointer;\n font-size: 14px;\n display: grid;\n place-items: center;\n }\n .theme-btn:hover {\n color: var(--text);\n border-color: var(--border-strong);\n }\n .theme-btn:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n\n /* hero */\n .hero {\n padding: 40px 0 4px;\n }\n h1 {\n font-size: clamp(26px, 4vw, 36px);\n line-height: 1.08;\n letter-spacing: -0.025em;\n margin: 0 0 12px;\n font-weight: 780;\n }\n h1 .mono {\n font-family: var(--mono);\n font-weight: 700;\n }\n .lede {\n font-size: 16px;\n color: var(--text-dim);\n max-width: 70ch;\n margin: 0;\n }\n .lede code {\n font-family: var(--mono);\n font-size: 0.88em;\n background: var(--surface-2);\n padding: 1px 6px;\n border-radius: 5px;\n color: var(--text);\n }\n\n /* section headers */\n .sec-head {\n display: flex;\n align-items: baseline;\n gap: 12px;\n margin: 46px 0 4px;\n }\n .sec-head h2 {\n font-size: 13px;\n text-transform: uppercase;\n letter-spacing: 0.12em;\n color: var(--text-faint);\n margin: 0;\n font-weight: 700;\n }\n .sec-head .rule {\n flex: 1;\n height: 1px;\n background: var(--border);\n }\n\n /* diagram */\n .diagram-note {\n font-size: 13px;\n color: var(--text-dim);\n margin: 16px 0 14px;\n max-width: 78ch;\n }\n .diagram-note .mono {\n font-family: var(--mono);\n }\n .dia-card {\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n box-shadow: var(--shadow-sm);\n overflow: hidden;\n }\n .sm-card summary.h {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n cursor: pointer;\n list-style: none;\n padding: 14px 18px;\n transition: background 0.12s ease;\n }\n .sm-card summary.h:hover {\n background: var(--surface-2);\n }\n .sm-card[open] summary.h {\n border-bottom: 1px solid var(--border);\n }\n .sm-card summary::-webkit-details-marker {\n display: none;\n }\n .sm-card summary::after {\n content: \"+\";\n align-self: center;\n color: var(--text-dim);\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n transform: translateY(-0.08em);\n transition: color 0.15s ease;\n }\n .sm-card summary:hover::after {\n color: var(--text);\n }\n .sm-card[open] summary::after {\n content: \"\\2212\";\n }\n .dia-scroll {\n position: relative;\n overflow: hidden;\n background: var(--dia-bg);\n height: 460px;\n cursor: grab;\n touch-action: none;\n }\n .dia-scroll.pz-dragging {\n cursor: grabbing;\n }\n .dia-scroll .mflow {\n position: absolute;\n top: 0;\n left: 0;\n padding: 18px 16px;\n transform-origin: 0 0;\n }\n .dia-scroll .mflow svg {\n max-width: none;\n height: auto;\n display: block;\n }\n .pz-controls {\n position: absolute;\n right: 12px;\n bottom: 12px;\n display: flex;\n gap: 6px;\n z-index: 5;\n }\n .pz-btn {\n width: 30px;\n height: 30px;\n border-radius: 8px;\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--text-dim);\n cursor: pointer;\n font-size: 15px;\n font-family: var(--mono);\n display: grid;\n place-items: center;\n box-shadow: var(--shadow-sm);\n padding: 0;\n }\n .pz-btn:hover {\n color: var(--text);\n border-color: var(--border-strong);\n }\n .pz-btn:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .dia-msg {\n padding: 22px 18px;\n font-size: 13.5px;\n color: var(--text-dim);\n }\n .dia-msg code {\n font-family: var(--mono);\n font-size: 12px;\n background: var(--surface-2);\n padding: 1px 6px;\n border-radius: 5px;\n }\n .dia-src {\n margin: 10px 0 0;\n padding: 12px 14px;\n background: var(--surface-2);\n border-radius: 9px;\n font-family: var(--mono);\n font-size: 11.5px;\n white-space: pre;\n overflow-x: auto;\n color: var(--text-dim);\n max-height: 260px;\n }\n .state-content {\n max-height: 340px;\n white-space: pre-wrap;\n word-break: break-word;\n }\n\n /* sub-machines */\n .sm-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));\n gap: 14px;\n margin-top: 18px;\n }\n .sm-def {\n border: 1.5px dashed var(--border-strong);\n border-radius: var(--radius);\n background: var(--surface);\n box-shadow: var(--shadow-sm);\n overflow: hidden;\n }\n .sm-def .h {\n padding: 13px 16px;\n border-bottom: 1px solid var(--border);\n background: var(--surface-2);\n }\n .sm-def .h .nm {\n font-family: var(--mono);\n font-weight: 700;\n font-size: 15px;\n }\n .sm-def .h .pm {\n font-family: var(--mono);\n font-size: 12px;\n color: var(--text-dim);\n display: block;\n margin-top: 3px;\n }\n .sm-def .h .pm b {\n color: var(--accent);\n }\n .sm-def .body {\n padding: 12px 16px 16px;\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n }\n .muted-note {\n color: var(--text-faint);\n font-style: italic;\n margin-top: 16px;\n }\n .sm-def .h {\n display: flex;\n align-items: baseline;\n justify-content: space-between;\n gap: 8px;\n }\n .sm-def .h .goto-sm {\n font: inherit;\n font-family: var(--mono);\n font-size: 11px;\n color: var(--accent);\n background: none;\n border: none;\n cursor: pointer;\n text-decoration: underline;\n padding: 0;\n }\n\n /* current-state panel */\n .current-panel {\n background: var(--surface);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n box-shadow: var(--shadow-sm);\n padding: 18px 20px 20px;\n margin: 16px 0 34px;\n }\n .cur-head {\n display: flex;\n align-items: center;\n gap: 10px;\n flex-wrap: wrap;\n cursor: pointer;\n }\n .cur-name {\n font-family: var(--mono);\n font-weight: 700;\n font-size: 18px;\n letter-spacing: -0.01em;\n }\n .cur-none {\n color: var(--text-faint);\n font-style: italic;\n }\n .edge.cur-matched {\n border-color: var(--accent);\n background: var(--accent-soft);\n }\n .cur-tag {\n font-family: var(--mono);\n font-size: 10.5px;\n font-weight: 700;\n color: var(--accent);\n background: var(--surface);\n border: 1px solid var(--accent);\n padding: 1px 7px;\n border-radius: 999px;\n margin-left: auto;\n }\n\n /* pending-changes readout in the current-state panel */\n .pending-list {\n display: flex;\n flex-direction: column;\n gap: 3px;\n font-family: var(--mono);\n font-size: 12.5px;\n margin-bottom: 14px;\n }\n .pending-list .pc {\n display: flex;\n gap: 9px;\n }\n .pending-list .st {\n font-weight: 700;\n width: 1ch;\n flex: none;\n }\n .pending-list .path {\n color: var(--text-dim);\n word-break: break-all;\n }\n .pending-clean {\n color: var(--text-faint);\n font-style: italic;\n font-size: 12.5px;\n margin-bottom: 14px;\n }\n\n /* mermaid black-box / ghost node labels (rendered inside the SVG via htmlLabels) */\n .sm-hint {\n display: block;\n margin-top: 2px;\n font-size: 10.5px;\n opacity: 0.72;\n font-weight: 500;\n }\n\n /* the current-state highlight ring on a rendered SVG node/group box */\n g.node.gtd-current {\n filter: drop-shadow(0 0 6px var(--accent));\n }\n g.node.gtd-current > rect,\n g.node.gtd-current > polygon,\n g.node.gtd-current > circle,\n g.node.gtd-current > ellipse,\n g.node.gtd-current > path.basic {\n stroke: var(--accent) !important;\n stroke-width: 3px !important;\n }\n\n /* toolbar */\n .toolbar {\n display: flex;\n gap: 12px;\n align-items: center;\n margin: 18px 0 22px;\n flex-wrap: wrap;\n }\n .search {\n flex: 1;\n min-width: 200px;\n position: relative;\n }\n .search input {\n width: 100%;\n font-family: var(--mono);\n font-size: 14px;\n padding: 10px 12px 10px 34px;\n border-radius: 10px;\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--text);\n }\n .search input:focus {\n outline: none;\n border-color: var(--accent);\n box-shadow: 0 0 0 3px var(--accent-soft);\n }\n .search svg {\n position: absolute;\n left: 11px;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-faint);\n }\n .chips {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n }\n .chip-btn {\n font: inherit;\n font-size: 12.5px;\n padding: 7px 12px;\n border-radius: 999px;\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--text-dim);\n cursor: pointer;\n }\n .chip-btn:hover {\n border-color: var(--border-strong);\n color: var(--text);\n }\n .chip-btn.on {\n background: var(--accent);\n border-color: var(--accent);\n color: #fff;\n }\n .chip-btn:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .legend {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n }\n .legend .li {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n font-size: 12.5px;\n color: var(--text-dim);\n cursor: pointer;\n user-select: none;\n padding: 5px 9px;\n border-radius: 999px;\n border: 1px solid var(--border);\n background: var(--surface);\n }\n .legend .li:hover {\n border-color: var(--border-strong);\n }\n .legend .li.off {\n opacity: 0.4;\n }\n .legend .li:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .legend .li .dot {\n width: 10px;\n height: 10px;\n border-radius: 3px;\n }\n\n /* groups + cards */\n .group {\n margin-bottom: 30px;\n }\n .group-head {\n display: flex;\n align-items: baseline;\n gap: 10px;\n margin: 0 0 12px;\n }\n .group-head h3 {\n font-family: var(--mono);\n font-size: 15px;\n margin: 0;\n letter-spacing: -0.01em;\n }\n .group-head .flow-tag {\n font-family: var(--mono);\n font-size: 11px;\n padding: 2px 8px;\n border-radius: 6px;\n background: var(--surface-2);\n color: var(--text-dim);\n border: 1px solid var(--border);\n }\n .grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));\n gap: 12px;\n }\n .card {\n text-align: left;\n font: inherit;\n color: var(--text);\n cursor: pointer;\n background: var(--surface);\n border: 1px solid var(--border);\n border-left: 4px solid var(--card-accent, var(--border));\n border-radius: 10px;\n padding: 13px 15px;\n box-shadow: var(--shadow-sm);\n transition:\n transform 0.12s ease,\n box-shadow 0.12s ease,\n border-color 0.12s ease;\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .card:hover {\n transform: translateY(-2px);\n box-shadow: var(--shadow);\n }\n .card:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .card[data-actor=\"human\"] {\n --card-accent: var(--human-dot);\n }\n .card[data-actor=\"agent\"] {\n --card-accent: var(--agent-dot);\n }\n .card[data-actor=\"check\"] {\n --card-accent: var(--check-dot);\n }\n .card[data-actor=\"commit\"] {\n --card-accent: var(--engine-dot);\n }\n .card[data-actor=\"unknown\"] {\n --card-accent: var(--unknown-dot);\n }\n .card .name {\n font-family: var(--mono);\n font-weight: 700;\n font-size: 14.5px;\n letter-spacing: -0.01em;\n word-break: break-word;\n }\n .card .row {\n display: flex;\n align-items: center;\n gap: 6px;\n flex-wrap: wrap;\n }\n .card .init-tick {\n position: absolute;\n top: 11px;\n right: 12px;\n font-size: 10px;\n font-family: var(--mono);\n color: var(--accent);\n background: var(--accent-soft);\n padding: 1px 6px;\n border-radius: 5px;\n font-weight: 700;\n }\n\n .pill {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n font-family: var(--mono);\n font-size: 11px;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 999px;\n }\n .pill.actor-human {\n background: var(--human-bg);\n color: var(--human-fg);\n }\n .pill.actor-agent {\n background: var(--agent-bg);\n color: var(--agent-fg);\n }\n .pill.actor-check {\n background: var(--check-bg);\n color: var(--check-fg);\n }\n .pill.actor-engine {\n background: var(--engine-bg);\n color: var(--engine-fg);\n }\n .pill.actor-unknown {\n background: var(--unknown-bg);\n color: var(--unknown-fg);\n }\n .pill .dot {\n width: 7px;\n height: 7px;\n border-radius: 2px;\n }\n .pill.actor-human .dot {\n background: var(--human-dot);\n }\n .pill.actor-agent .dot {\n background: var(--agent-dot);\n }\n .pill.actor-check .dot {\n background: var(--check-dot);\n }\n .pill.actor-engine .dot {\n background: var(--engine-dot);\n }\n .pill.actor-unknown .dot {\n background: var(--unknown-dot);\n }\n .kind {\n font-family: var(--mono);\n font-size: 11px;\n padding: 2px 7px;\n border-radius: 6px;\n border: 1px solid var(--border-strong);\n color: var(--text-dim);\n background: var(--surface-2);\n }\n .flag {\n font-family: var(--mono);\n font-size: 10.5px;\n padding: 1px 6px;\n border-radius: 5px;\n background: var(--accent-soft);\n color: var(--accent);\n font-weight: 600;\n border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);\n }\n\n /* drawer */\n .backdrop {\n position: fixed;\n inset: 0;\n background: rgba(10, 16, 28, 0.5);\n backdrop-filter: blur(2px);\n opacity: 0;\n pointer-events: none;\n transition: opacity 0.22s ease;\n z-index: 90;\n }\n .backdrop.open {\n opacity: 1;\n pointer-events: auto;\n }\n .drawer {\n position: fixed;\n top: 0;\n right: 0;\n height: 100%;\n width: min(560px, 100%);\n background: var(--surface);\n border-left: 1px solid var(--border);\n box-shadow: var(--shadow);\n transform: translateX(100%);\n transition: transform 0.26s cubic-bezier(0.22, 0.61, 0.36, 1);\n z-index: 100;\n display: flex;\n flex-direction: column;\n }\n .drawer.open {\n transform: translateX(0);\n }\n .drawer-head {\n padding: 18px 22px 14px;\n border-bottom: 1px solid var(--border);\n border-top: 4px solid var(--card-accent, var(--accent));\n }\n .drawer-head .top {\n display: flex;\n align-items: flex-start;\n gap: 12px;\n }\n .drawer-head h3 {\n font-family: var(--mono);\n font-size: 20px;\n font-weight: 700;\n margin: 0;\n letter-spacing: -0.02em;\n word-break: break-word;\n }\n .drawer-head .meta-row {\n display: flex;\n gap: 6px;\n flex-wrap: wrap;\n margin-top: 10px;\n }\n .close-x {\n margin-left: auto;\n border: 1px solid var(--border);\n background: var(--surface-2);\n color: var(--text-dim);\n border-radius: 8px;\n width: 30px;\n height: 30px;\n cursor: pointer;\n flex: none;\n font-size: 15px;\n }\n .close-x:hover {\n color: var(--text);\n }\n .close-x:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .drawer-body {\n overflow-y: auto;\n padding: 20px 22px 40px;\n flex: 1;\n }\n .field {\n margin-bottom: 18px;\n }\n .field > .k {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.09em;\n color: var(--text-faint);\n font-weight: 700;\n margin-bottom: 7px;\n }\n .kv {\n display: grid;\n grid-template-columns: 88px 1fr;\n gap: 4px 14px;\n font-size: 13.5px;\n }\n .kv dt {\n color: var(--text-faint);\n font-size: 12px;\n }\n .kv dd {\n margin: 0;\n font-family: var(--mono);\n font-size: 12.5px;\n word-break: break-word;\n }\n .none {\n color: var(--text-faint);\n font-style: italic;\n font-family: var(--sans);\n }\n\n .edges {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .edge {\n border: 1px solid var(--border);\n border-radius: 9px;\n padding: 10px 12px;\n background: var(--surface-2);\n }\n .edge .er {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n }\n .edge .pat {\n font-family: var(--mono);\n font-size: 12px;\n font-weight: 700;\n background: var(--surface);\n border: 1px solid var(--border-strong);\n padding: 2px 8px;\n border-radius: 6px;\n white-space: nowrap;\n }\n .edge .arr {\n color: var(--text-faint);\n }\n .goto {\n font: inherit;\n font-family: var(--mono);\n font-size: 12.5px;\n font-weight: 700;\n cursor: pointer;\n border: 1px solid var(--card-accent, var(--accent));\n background: transparent;\n color: var(--card-accent, var(--accent));\n padding: 2px 9px;\n border-radius: 6px;\n }\n .goto:hover {\n background: var(--card-accent, var(--accent));\n color: #fff;\n }\n .goto:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .goto[data-actor=\"human\"] {\n --card-accent: var(--human-dot);\n }\n .goto[data-actor=\"agent\"] {\n --card-accent: var(--agent-dot);\n }\n .goto[data-actor=\"check\"] {\n --card-accent: var(--check-dot);\n }\n .goto[data-actor=\"commit\"] {\n --card-accent: var(--engine-dot);\n }\n .goto[data-actor=\"unknown\"] {\n --card-accent: var(--unknown-dot);\n }\n .edge .em {\n font-size: 12.5px;\n color: var(--text-dim);\n margin-top: 6px;\n line-height: 1.45;\n }\n .incoming {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n }\n\n .flag-list {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .flag-item {\n display: flex;\n gap: 9px;\n font-size: 13px;\n align-items: baseline;\n }\n .flag-item .fn {\n font-family: var(--mono);\n font-size: 11.5px;\n font-weight: 700;\n color: var(--accent);\n flex: none;\n padding: 1px 6px;\n border-radius: 5px;\n background: var(--accent-soft);\n }\n .flag-item .fd {\n color: var(--text-dim);\n }\n\n /* vars */\n .vars-grid {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 16px;\n }\n .var-chip {\n font-family: var(--mono);\n font-size: 12px;\n padding: 6px 10px;\n border-radius: 8px;\n border: 1px solid var(--border);\n background: var(--surface);\n box-shadow: var(--shadow-sm);\n }\n .var-chip b {\n color: var(--accent);\n }\n\n footer {\n border-top: 1px solid var(--border);\n margin-top: 56px;\n padding: 40px 0 72px;\n color: var(--text-faint);\n font-size: 13px;\n }\n footer code {\n font-family: var(--mono);\n background: var(--surface-2);\n padding: 1px 6px;\n border-radius: 5px;\n }\n footer .foot-lead {\n margin: 0 0 14px;\n padding-top: 16px;\n color: var(--text-dim);\n }\n footer .foot-legend {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n padding-bottom: 16px;\n }\n footer .foot-chip {\n font-family: var(--mono);\n font-size: 12px;\n padding: 3px 9px;\n border-radius: 7px;\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--text-dim);\n }\n\n .hidden {\n display: none !important;\n }\n @media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition: none !important;\n animation: none !important;\n }\n }\n @media (max-width: 640px) {\n .brand .sub {\n display: none;\n }\n .kv {\n grid-template-columns: 74px 1fr;\n }\n }\n </style>\n </head>\n <body>\n <header class=\"top\">\n <div class=\"top-inner\">\n <div class=\"brand\">\n <span class=\"cmd\"><b>gtd</b> · workflow visualizer</span>\n <span class=\"sub\" id=\"brandSub\">read-only view</span>\n </div>\n <div class=\"summary\" id=\"summary\"></div>\n <button\n class=\"theme-btn\"\n id=\"themeBtn\"\n title=\"Toggle light/dark theme\"\n aria-label=\"Toggle light/dark theme\"\n >\n ◐\n </button>\n </div>\n </header>\n\n <main class=\"wrap\" id=\"app\">\n <section class=\"hero\">\n <h1 id=\"heroTitle\">Loading workflow…</h1>\n <p class=\"lede\" id=\"heroLede\">\n Fetching <code>/workflow.json</code> from the local\n <span class=\"mono\">gtd visualize</span> server.\n </p>\n </section>\n\n <div id=\"content\" class=\"hidden\">\n <div class=\"sec-head\">\n <h2>Current state</h2>\n <span class=\"rule\"></span>\n </div>\n <div class=\"current-panel hidden\" id=\"currentPanel\"></div>\n <div id=\"varsSection\"></div>\n\n <div class=\"sec-head\">\n <h2>Main flow</h2>\n <span class=\"rule\"></span>\n </div>\n <p class=\"diagram-note\" id=\"diaNote\"></p>\n <div class=\"dia-card\">\n <div class=\"dia-scroll\" id=\"diaScroll\"><div class=\"dia-msg\">Rendering diagram…</div></div>\n </div>\n\n <div class=\"sec-head\">\n <h2>Sub-machines</h2>\n <span class=\"rule\"></span>\n </div>\n <div id=\"smSection\"></div>\n\n <div class=\"sec-head\">\n <h2>State catalogue</h2>\n <span class=\"rule\"></span>\n </div>\n <div class=\"toolbar\">\n <div class=\"search\">\n <svg\n width=\"15\"\n height=\"15\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2.4\"\n >\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <input\n id=\"search\"\n type=\"search\"\n placeholder=\"filter by name, actor, kind, file, mode, flag…\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n aria-label=\"Filter states\"\n />\n </div>\n <div class=\"chips\" id=\"kindChips\"></div>\n <div class=\"legend\" id=\"legend\"></div>\n </div>\n <div id=\"groups\"></div>\n </div>\n </main>\n\n <div class=\"backdrop\" id=\"backdrop\"></div>\n <aside\n class=\"drawer\"\n id=\"drawer\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"State detail\"\n tabindex=\"-1\"\n ></aside>\n\n <footer class=\"wrap\" id=\"footer\"></footer>\n\n <script type=\"module\">\n // ── theme ──────────────────────────────────────────────────────────────\n const root = document.documentElement\n const prefersDark = () => matchMedia(\"(prefers-color-scheme: dark)\").matches\n const isDark = () => {\n const t = root.getAttribute(\"data-theme\")\n return t ? t === \"dark\" : prefersDark()\n }\n document.getElementById(\"themeBtn\").onclick = () => {\n const next = isDark() ? \"light\" : \"dark\"\n root.setAttribute(\"data-theme\", next)\n reinitializeAndRenderAll()\n }\n // Follow the OS when the user hasn't made an explicit choice — an\n // explicit `data-theme` override always wins over an OS flip.\n matchMedia(\"(prefers-color-scheme: dark)\").addEventListener(\"change\", () => {\n if (!root.getAttribute(\"data-theme\")) reinitializeAndRenderAll()\n })\n\n const esc = (s) =>\n String(s).replace(\n /[&<>\"']/g,\n (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c],\n )\n\n // Actor metadata: color class, css-var prefix, mermaid classDef name, label.\n const ACTOR = {\n human: { cls: \"actor-human\", dot: \"human\", label: \"human\" },\n agent: { cls: \"actor-agent\", dot: \"agent\", label: \"agent\" },\n check: { cls: \"actor-check\", dot: \"check\", label: \"check\" },\n commit: { cls: \"actor-engine\", dot: \"engine\", label: \"commit\" },\n unknown: { cls: \"actor-unknown\", dot: \"unknown\", label: \"?\" },\n }\n // A commit state has no actor — treat it as the engine/commit colour.\n const actorOf = (s) => s.actor || (s.kind === \"commit\" ? \"commit\" : \"unknown\")\n const meta = (a) => ACTOR[a] || ACTOR.unknown\n\n const FLAGS = {\n initial:\n \"The one initial state — an unrecognized HEAD (any non-gtd history) resolves here.\",\n reviewEntry:\n \"This state is a dedicated CLI entry point (e.g. `gtd review <ref>`) that starts a brand-new process.\",\n fixEntry:\n \"This state is the `gtd fix` entry point — a red suite drops into the fixing loop, a green suite is a no-op.\",\n reviewWindow:\n \"While resting here, gtd opens the review checkout window: HEAD is rewound to the review base so the whole diff surfaces as ordinary uncommitted changes.\",\n reviewBase:\n \"Anchors the incremental review diff base — its most-recent in-process commit is where the next reviewing turn and checkout window start.\",\n answerGate:\n \"Edge gate: a step is refused while any open question in the steering file is not answered with exactly one tick.\",\n requireProgress:\n \"Edge gate: refuses a work-free turn that only deletes the instructions file (unless it held a NOTHING ACTIONABLE sentinel).\",\n }\n\n // ── state ──────────────────────────────────────────────────────────────\n let MODEL = null\n let STATES = []\n let byName = {}\n let CURRENT = null\n let lastCurrentSig = undefined\n let mermaidMod = null\n\n // Fetches `/state.json`, updates `CURRENT`, and reports whether the\n // resolved body actually changed since the last call (compared on the\n // full JSON so an edge's `matched` flag flipping counts as a change too).\n async function refreshCurrent() {\n let next = null\n try {\n const res = await fetch(\"/state.json\")\n const body = res.ok ? await res.json() : {}\n next = body && body.state ? body : null\n } catch (e) {\n next = null\n }\n const sig = JSON.stringify(next)\n if (sig === lastCurrentSig) return false\n lastCurrentSig = sig\n CURRENT = next\n return true\n }\n\n const actorFilter = new Set([\"human\", \"agent\", \"check\", \"commit\", \"unknown\"])\n let kindFilter = \"all\"\n\n // ── boot ─────────────────────────────────────────────────────────────\n try {\n const res = await fetch(\"/workflow.json\")\n if (!res.ok) throw new Error(\"HTTP \" + res.status)\n MODEL = await res.json()\n } catch (e) {\n document.getElementById(\"heroTitle\").textContent = \"Could not load workflow\"\n document.getElementById(\"heroLede\").innerHTML =\n \"Fetching <code>/workflow.json</code> failed: \" +\n esc(e.message) +\n '. This page must be served by <span class=\"mono\">gtd visualize</span>.'\n throw e\n }\n\n // Resolved at page load, then re-polled every 3s (see `poll()` below) so\n // advancing the process shows up without a manual refresh. Tolerates a\n // 404/error (an older server) or an empty `{}` (no active process/not a repo).\n await refreshCurrent()\n\n STATES = MODEL.states || []\n byName = Object.fromEntries(STATES.map((s) => [s.name, s]))\n\n // ── header summary + hero ───────────────────────────────────────────\n const counts = { human: 0, agent: 0, check: 0, commit: 0, unknown: 0 }\n STATES.forEach((s) => {\n counts[actorOf(s)]++\n })\n const nGroups = (MODEL.groups || []).length\n const cnt = (a) =>\n counts[a] ? `<span><span class=\"s-num\">${counts[a]}</span> ${meta(a).label}</span>` : \"\"\n document.getElementById(\"summary\").innerHTML = [\n `<span><span class=\"s-num\">${STATES.length}</span> states</span>`,\n `<span><span class=\"s-num\">${nGroups}</span> sub-machine${nGroups === 1 ? \"\" : \"s\"}</span>`,\n `<span class=\"sep\">·</span>`,\n cnt(\"human\"),\n cnt(\"agent\"),\n cnt(\"check\"),\n cnt(\"commit\"),\n cnt(\"unknown\"),\n ]\n .filter(Boolean)\n .join(\" \")\n\n document.getElementById(\"heroTitle\").innerHTML = `GTD workflow visualization`\n document.getElementById(\"heroLede\").innerHTML =\n `Rendered live from your workflow. The initial state is ` +\n `<code>${esc(MODEL.initial)}</code>. Click any state — in the diagram, a sub-machine chip, ` +\n `or the catalogue — to open its full definition, walk its outgoing edges, and see what routes into it.`\n document.getElementById(\"content\").classList.remove(\"hidden\")\n\n document.getElementById(\"footer\").innerHTML =\n `<p class=\"foot-lead\">Read-only view served by <code>gtd visualize</code>.</p>` +\n `<div class=\"foot-legend\">` +\n `<span class=\"foot-chip\">([message])</span>` +\n `<span class=\"foot-chip\">[prompt]</span>` +\n `<span class=\"foot-chip\">{{script}}</span>` +\n `<span class=\"foot-chip\">[[commit]]</span>` +\n `<span>— node shape = content kind; colour = actor.</span>` +\n `</div>`\n\n // ── pill helper ────────────────────────────────────────────────────\n const pill = (a) =>\n `<span class=\"pill ${meta(a).cls}\"><span class=\"dot\"></span>${meta(a).label}</span>`\n\n // ── mermaid source generation ──────────────────────────────────────\n // Safe, stable node ids (state names can contain hyphens etc.), with a\n // reverse lookup — needed to map a rendered SVG node's id back to its\n // state name when wiring clicks (see `nodeIdOf`/`wire*Clicks` below).\n const idFor = (() => {\n const map = new Map()\n const rev = new Map()\n const fn = (name) => {\n if (!map.has(name)) {\n const id = \"n\" + map.size\n map.set(name, id)\n rev.set(id, name)\n }\n return map.get(name)\n }\n fn.nameOf = (id) => rev.get(id)\n return fn\n })()\n const groupBoxId = (i) => \"g\" + i\n const groupIndexOf = (name) => (MODEL.groups || []).findIndex((g) => g.name === name)\n const shapeFor = (kind, label) => {\n const l = `\"${label.replace(/\"/g, \"&quot;\")}\"`\n switch (kind) {\n case \"message\":\n return `([\"${label.replace(/\"/g, \"&quot;\")}\"])`\n case \"prompt\":\n return `[${l}]`\n case \"script\":\n return `{{${l}}}`\n case \"commit\":\n return `[[${l}]]`\n default:\n return `[${l}]`\n }\n }\n const mlabel = (s) => `\"${String(s).replace(/\"/g, \"&quot;\").replace(/\\|/g, \"&#124;\")}\"`\n /**\n * The mermaid `classDef` lines for actor/ghost node fills, chosen per\n * theme so dark mode doesn't render washed-out light pastels.\n */\n function classDefs(dark) {\n return [\n dark\n ? \"classDef human fill:#3a2e12,stroke:#e0a83e,color:#f0c674;\"\n : \"classDef human fill:#fbeccd,stroke:#d99413,color:#5a3d0a;\",\n dark\n ? \"classDef agent fill:#26224a,stroke:#8b7cf0,color:#b6abf7;\"\n : \"classDef agent fill:#e5e2fb,stroke:#6d5ce0,color:#3a2c85;\",\n dark\n ? \"classDef check fill:#123433,stroke:#2dc4bc,color:#68dcd4;\"\n : \"classDef check fill:#d3f0ef,stroke:#16a39d,color:#0c524f;\",\n dark\n ? \"classDef commit fill:#3a1b24,stroke:#e05f7c,color:#f2a3b3;\"\n : \"classDef commit fill:#fbdfe4,stroke:#d84a68,color:#8a1f38;\",\n dark\n ? \"classDef unknown fill:#1c2434,stroke:#3a465c,color:#9aa6bd,stroke-dasharray:4 3;\"\n : \"classDef unknown fill:#eef1f7,stroke:#c2ccdb,color:#55607a,stroke-dasharray:4 3;\",\n // Sub-machine invocation: a muted slate box (not one of the actor\n // colours) so it still reads as its own distinct \"black-box\" kind,\n // without the harsh opaque-black fill clashing with a light theme.\n dark\n ? \"classDef submachine fill:#2a3142,stroke:#5a6b8c,color:#dbe4f5,font-weight:700;\"\n : \"classDef submachine fill:#e4e9f2,stroke:#8695b3,color:#2a3142,font-weight:700;\",\n // A muted, dashed ghost node standing in for a target OUTSIDE the\n // current diagram (a sub-machine diagram's external edge).\n dark\n ? \"classDef external fill:#1c2434,stroke:#3a465c,color:#9aa6bd,stroke-dasharray:5 4;\"\n : \"classDef external fill:#eef1f7,stroke:#c2ccdb,color:#55607a,stroke-dasharray:5 4;\",\n ]\n }\n\n /**\n * Build one flowchart's mermaid source over `states` (a diagram scope):\n * the whole machine for the main flow (`opts.scope` unset), or one\n * sub-machine's own member states (`opts.scope` = that group's name).\n * A member state whose innermost group differs from `opts.scope`\n * collapses into a black box for ITS group (handles a sub-machine\n * nested inside another); a target outside `states` entirely renders as\n * a muted, dashed ghost node instead (only possible in a sub-machine\n * diagram — the main flow's `states` is every state, so nothing is ever\n * \"outside\" it).\n */\n function buildFlowSrc(states, opts) {\n const scope = opts && opts.scope\n const inSet = new Set(states.map((s) => s.name))\n const lines = [\"flowchart TD\"]\n\n const unitOf = (name) => {\n const s = byName[name]\n if (s && s.group && s.group !== scope) return { kind: \"box\", group: s.group }\n return { kind: \"node\", name }\n }\n const unitId = (name) => {\n const u = unitOf(name)\n return u.kind === \"box\" ? groupBoxId(groupIndexOf(u.group)) : idFor(name)\n }\n\n const emittedNodes = new Set()\n const emittedBoxes = new Set()\n const emittedGhosts = new Set()\n const nodeLine = (s) => ` ${idFor(s.name)}${shapeFor(s.kind, s.name)}:::${actorOf(s)}`\n const boxLine = (g, i) =>\n ` ${groupBoxId(i)}[\"${esc(g.name)}<br/><span class='sm-hint'>▸ ${g.states.length} state${\n g.states.length === 1 ? \"\" : \"s\"\n }</span>\"]:::submachine`\n const ghostLine = (s) => ` e${idFor(s.name)}${shapeFor(s.kind, s.name)}:::external`\n\n states.forEach((s) => {\n const u = unitOf(s.name)\n if (u.kind === \"node\") {\n if (emittedNodes.has(s.name)) return\n emittedNodes.add(s.name)\n lines.push(nodeLine(s))\n } else if (!emittedBoxes.has(u.group)) {\n emittedBoxes.add(u.group)\n const i = groupIndexOf(u.group)\n if (i >= 0) lines.push(boxLine(MODEL.groups[i], i))\n }\n })\n\n // Edges within this diagram's own state set, collapsed through box\n // units, deduped, and label-dropped when several patterns land on the\n // same box-level edge (a black box hides which one specifically).\n const solid = new Map()\n const dashed = new Map()\n const addEdge = (map, from, to, label) => {\n if (from === to) return\n const key = from + \" \" + to\n if (!map.has(key)) map.set(key, [])\n map.get(key).push(label)\n }\n states.forEach((s) => {\n ;(s.on || []).forEach((e) => {\n if (!inSet.has(e.to) || !byName[e.to]) return\n addEdge(solid, unitId(s.name), unitId(e.to), e.pattern)\n })\n if (s.retry && inSet.has(s.retry.otherwise)) {\n addEdge(dashed, unitId(s.name), unitId(s.retry.otherwise), \"retry ×\" + s.retry.max)\n }\n })\n // Ghost edges to a target entirely outside this diagram's state set\n // (only fires for a sub-machine diagram — the main flow's set is\n // every state, so nothing is ever \"outside\" it).\n states.forEach((s) => {\n ;(s.on || []).forEach((e) => {\n if (inSet.has(e.to) || !byName[e.to]) return\n const target = byName[e.to]\n if (!emittedGhosts.has(e.to)) {\n emittedGhosts.add(e.to)\n lines.push(ghostLine(target))\n }\n lines.push(` ${idFor(s.name)} -->|${mlabel(e.pattern)}| e${idFor(e.to)}`)\n })\n if (s.retry && byName[s.retry.otherwise] && !inSet.has(s.retry.otherwise)) {\n const target = byName[s.retry.otherwise]\n if (!emittedGhosts.has(s.retry.otherwise)) {\n emittedGhosts.add(s.retry.otherwise)\n lines.push(ghostLine(target))\n }\n lines.push(\n ` ${idFor(s.name)} -.->|${mlabel(\"retry ×\" + s.retry.max)}| e${idFor(s.retry.otherwise)}`,\n )\n }\n })\n\n solid.forEach((labels, key) => {\n const [from, to] = key.split(\" \")\n const label = labels.length === 1 ? mlabel(labels[0]) : null\n lines.push(` ${from} -->${label ? \"|\" + label + \"|\" : \"\"} ${to}`)\n })\n dashed.forEach((labels, key) => {\n const [from, to] = key.split(\" \")\n const label = labels.length === 1 ? mlabel(labels[0]) : null\n lines.push(` ${from} -.->${label ? \"|\" + label + \"|\" : \"\"} ${to}`)\n })\n\n lines.push(...classDefs(isDark()))\n return lines.join(\"\\n\")\n }\n\n document.getElementById(\"diaNote\").innerHTML =\n `Every state as a node — shape is the content kind, colour is the actor. Each edge is an ` +\n `<span class=\"mono\">on</span> pattern (first match wins); dashed edges are ` +\n `<span class=\"mono\">retry</span> redirects. A sub-machine invocation collapses into a single ` +\n `dark box (click it to jump to its own diagram below); an edge label is dropped when several ` +\n `patterns collapse onto the same box-to-box edge. Scroll/pinch or drag to pan and zoom; use the ` +\n `<span class=\"mono\">+</span>/<span class=\"mono\">−</span>/<span class=\"mono\">⤢</span> controls in the corner.`\n\n // ── diagram render (theme-aware, offline-tolerant) ─────────────────\n async function loadMermaid() {\n if (mermaidMod) return mermaidMod\n const m = await import(\"https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs\")\n mermaidMod = m.default\n return mermaidMod\n }\n\n const renderSeqByBase = new Map()\n const nextSeq = (base) => {\n const n = (renderSeqByBase.get(base) || 0) + 1\n renderSeqByBase.set(base, n)\n return n\n }\n\n /** Render `src` into `host` via mermaid, guarding against a stale (superseded) render; falls back to a readable error + raw source on failure. Returns the mermaid render id used (so the caller can wire clicks/highlight against ITS OWN node ids), or `null` if nothing rendered. */\n async function renderInto(host, src, renderIdBase) {\n const seq = nextSeq(renderIdBase)\n let mermaid\n try {\n mermaid = await loadMermaid()\n } catch (e) {\n host.innerHTML =\n `<div class=\"dia-msg\">Could not load the Mermaid renderer (offline?). ` +\n `The workflow is still fully browsable below. Diagram source:` +\n `<pre class=\"dia-src\">${esc(src)}</pre></div>`\n return null\n }\n if (seq !== renderSeqByBase.get(renderIdBase)) return null\n const renderId = renderIdBase + seq\n try {\n const { svg } = await mermaid.render(renderId, src)\n if (seq !== renderSeqByBase.get(renderIdBase)) return null\n host.innerHTML = `<div class=\"mflow\">${svg}</div>`\n return renderId\n } catch (e) {\n host.innerHTML =\n `<div class=\"dia-msg\">Diagram render failed: ${esc(e.message)}. ` +\n `Diagram source:<pre class=\"dia-src\">${esc(src)}</pre></div>`\n return null\n }\n }\n\n // Every live diagram (main + one per sub-machine) — re-rendered as a\n // group on every theme toggle (replaces the old single DIAGRAM_SRC).\n // `build` is a thunk (not a fixed string) because the diagram source\n // embeds theme-dependent classDefs — it must be re-emitted fresh on\n // every re-render, not cached from first render.\n const diagrams = []\n function registerDiagram(host, build, renderIdBase, onRendered, highlight) {\n diagrams.push({ host, build, renderIdBase, onRendered, highlight, lastRenderId: null })\n }\n async function renderAllDiagrams() {\n for (const d of diagrams) {\n const renderId = await renderInto(d.host, d.build(), d.renderIdBase)\n if (renderId) d.lastRenderId = renderId\n if (renderId && d.onRendered) d.onRendered(renderId, d.host)\n }\n }\n async function reinitializeAndRenderAll() {\n const mermaid = await loadMermaid().catch(() => null)\n if (mermaid) {\n const dark = isDark()\n mermaid.initialize({\n startOnLoad: false,\n theme: \"base\",\n securityLevel: \"strict\",\n flowchart: { useMaxWidth: false, htmlLabels: true },\n themeVariables: {\n fontFamily: \"ui-monospace, Menlo, monospace\",\n fontSize: \"12.5px\",\n lineColor: dark ? \"#6b7690\" : \"#8a93a6\",\n primaryBorderColor: dark ? \"#3a465c\" : \"#c2ccdb\",\n tertiaryColor: dark ? \"#1c2434\" : \"#f0f3f8\",\n mainBkg: dark ? \"#232c3e\" : \"#ffffff\",\n clusterBkg: dark ? \"#1c2434\" : \"#f0f3f8\",\n clusterBorder: dark ? \"#3a465c\" : \"#c2ccdb\",\n titleColor: dark ? \"#e6ebf4\" : \"#1a2233\",\n nodeTextColor: dark ? \"#e6ebf4\" : \"#1a2233\",\n textColor: dark ? \"#e6ebf4\" : \"#1a2233\",\n edgeLabelBackground: dark ? \"#161d2b\" : \"#f4f6fa\",\n },\n })\n }\n await renderAllDiagrams()\n }\n\n // ── click wiring + current-state highlight (post-render — mermaid's own\n // `click` callbacks are disabled by securityLevel:\"strict\") ──────────\n // A rendered node group's `id` is `${renderId}-flowchart-${ourNodeId}-<n>`\n // (verified against mermaid@11's actual output) — strip that wrapper to\n // recover the id we generated the diagram with.\n function nodeIdOf(el, renderId) {\n const prefix = renderId + \"-flowchart-\"\n if (!el.id || !el.id.startsWith(prefix)) return null\n const m = /^(.+)-\\d+$/.exec(el.id.slice(prefix.length))\n return m ? m[1] : null\n }\n const nodeGroupsOf = (host) => [...host.querySelectorAll(\"g.node\")]\n\n function scrollToSubmachine(i) {\n const el = document.getElementById(\"sm-\" + i)\n if (!el) return\n el.open = true\n el.scrollIntoView({ behavior: \"smooth\", block: \"start\" })\n }\n function navigateTo(name) {\n const s = byName[name]\n if (s && s.group) {\n const gi = groupIndexOf(s.group)\n if (gi >= 0) return scrollToSubmachine(gi)\n }\n openDrawer(name)\n }\n\n function applyCurrentHighlight(renderId, host, mode, gi) {\n if (!CURRENT || !CURRENT.state) return\n const wantId =\n mode === \"main\"\n ? CURRENT.group\n ? groupBoxId(groupIndexOf(CURRENT.group))\n : idFor(CURRENT.state)\n : CURRENT.group === MODEL.groups[gi].name\n ? idFor(CURRENT.state)\n : null\n if (wantId === null) return\n nodeGroupsOf(host).forEach((el) => {\n if (nodeIdOf(el, renderId) === wantId) el.classList.add(\"gtd-current\")\n })\n }\n\n // Re-applies the current-state highlight to every already-rendered\n // diagram, without re-running mermaid — used by the poll loop below.\n function repaintHighlights() {\n for (const d of diagrams) {\n nodeGroupsOf(d.host).forEach((el) => el.classList.remove(\"gtd-current\"))\n if (d.lastRenderId && d.highlight) {\n applyCurrentHighlight(d.lastRenderId, d.host, d.highlight.mode, d.highlight.gi)\n }\n }\n }\n\n function wireMainDiagramClicks(renderId, host) {\n nodeGroupsOf(host).forEach((el) => {\n const rawId = nodeIdOf(el, renderId)\n if (!rawId) return\n el.classList.add(\"clickable\")\n el.style.cursor = \"pointer\"\n if (rawId[0] === \"g\") {\n el.onclick = () => scrollToSubmachine(Number(rawId.slice(1)))\n } else {\n const name = idFor.nameOf(rawId)\n if (name) el.onclick = () => openDrawer(name)\n }\n })\n applyCurrentHighlight(renderId, host, \"main\")\n attachPanZoom(host)\n }\n\n function wireSubmachineDiagramClicks(renderId, host, gi) {\n nodeGroupsOf(host).forEach((el) => {\n const rawId = nodeIdOf(el, renderId)\n if (!rawId) return\n el.classList.add(\"clickable\")\n el.style.cursor = \"pointer\"\n if (rawId[0] === \"g\") {\n el.onclick = () => scrollToSubmachine(Number(rawId.slice(1)))\n } else if (rawId[0] === \"e\") {\n const name = idFor.nameOf(rawId.slice(1))\n if (name) el.onclick = () => navigateTo(name)\n } else {\n const name = idFor.nameOf(rawId)\n if (name) el.onclick = () => openDrawer(name)\n }\n })\n applyCurrentHighlight(renderId, host, \"submachine\", gi)\n attachPanZoom(host)\n }\n\n // ── diagram pan & zoom (hand-rolled — no pan-zoom dependency, see plan) ──\n // `host` is a `.dia-scroll` element whose content mermaid just replaced\n // wholesale (`renderInto`'s `host.innerHTML = ...`), so old listeners on\n // its previous children are already gone — this re-creates the control\n // cluster and re-attaches fresh listeners on every render, never\n // accumulating stale ones.\n const REDUCED_MOTION = matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n const clampScale = (s) => Math.min(3, Math.max(0.4, s))\n\n function attachPanZoom(host) {\n const flow = host.querySelector(\".mflow\")\n const svg = flow && flow.querySelector(\"svg\")\n if (!flow || !svg) return\n\n const controls = document.createElement(\"div\")\n controls.className = \"pz-controls\"\n controls.innerHTML =\n `<button class=\"pz-btn\" data-act=\"in\" title=\"Zoom in\" aria-label=\"Zoom in\">+</button>` +\n `<button class=\"pz-btn\" data-act=\"out\" title=\"Zoom out\" aria-label=\"Zoom out\">−</button>` +\n `<button class=\"pz-btn\" data-act=\"fit\" title=\"Fit to width\" aria-label=\"Fit to width\">⤢</button>`\n host.appendChild(controls)\n\n const box = svg.getBBox ? svg.getBBox() : { width: 0, height: 0 }\n const naturalWidth = box.width || svg.clientWidth || 1\n const naturalHeight = box.height || svg.clientHeight || 1\n\n const state = { scale: 1, tx: 0, ty: 0 }\n const apply = () => {\n flow.style.transform = `translate(${state.tx}px, ${state.ty}px) scale(${state.scale})`\n }\n // Fit-to-width, capped at 1× so a small workflow renders at its\n // natural size (the pre-pan/zoom default) instead of being blown up.\n const fit = () => {\n const r = host.getBoundingClientRect()\n state.scale = clampScale(Math.min(1, Math.max(r.width - 32, 40) / naturalWidth))\n state.tx = (r.width - naturalWidth * state.scale) / 2\n state.ty = (r.height - naturalHeight * state.scale) / 2\n apply()\n }\n const zoomBy = (factor, cx, cy) => {\n const r = host.getBoundingClientRect()\n const px = cx == null ? r.width / 2 : cx\n const py = cy == null ? r.height / 2 : cy\n const next = clampScale(state.scale * factor)\n const ratio = next / state.scale\n state.tx = px - (px - state.tx) * ratio\n state.ty = py - (py - state.ty) * ratio\n state.scale = next\n apply()\n }\n // Animate only a button-triggered zoom/fit (an explicit, discrete\n // action) — wheel/drag stay instant so the transform tracks the\n // cursor/finger with no lag. Skipped entirely under\n // prefers-reduced-motion.\n const animated = (fn) => {\n if (REDUCED_MOTION) return fn()\n flow.style.transition = \"transform 0.18s ease\"\n fn()\n flow.addEventListener(\"transitionend\", () => (flow.style.transition = \"\"), {\n once: true,\n })\n }\n fit()\n\n host.addEventListener(\n \"wheel\",\n (e) => {\n e.preventDefault()\n flow.style.transition = \"none\"\n const r = host.getBoundingClientRect()\n zoomBy(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - r.left, e.clientY - r.top)\n },\n { passive: false },\n )\n\n // Drag-to-pan: a small movement threshold before a pointerdown counts\n // as a drag (rather than a click), and — once it does — the resulting\n // synthetic `click` is swallowed so dragging over a node never opens\n // its drawer. Pointer capture is deferred until that threshold is\n // actually crossed: a captured pointer retargets its WHOLE mouse-event\n // sequence (including the eventual `click`) to `host`, so capturing\n // eagerly on every pointerdown would silently break a plain click on\n // any node (or the zoom/fit buttons) — not just an actual drag.\n let drag = null\n host.addEventListener(\"pointerdown\", (e) => {\n if (e.button !== 0 || e.target.closest(\".pz-controls\")) return\n drag = { x: e.clientX, y: e.clientY, tx: state.tx, ty: state.ty, moved: false }\n flow.style.transition = \"none\"\n })\n host.addEventListener(\"pointermove\", (e) => {\n if (!drag) return\n const dx = e.clientX - drag.x\n const dy = e.clientY - drag.y\n if (!drag.moved && Math.hypot(dx, dy) > 4) {\n drag.moved = true\n host.classList.add(\"pz-dragging\")\n host.setPointerCapture(e.pointerId)\n }\n if (drag.moved) {\n state.tx = drag.tx + dx\n state.ty = drag.ty + dy\n apply()\n }\n })\n const endDrag = () => {\n if (!drag) return\n if (drag.moved) {\n host.classList.remove(\"pz-dragging\")\n host.addEventListener(\n \"click\",\n (ev) => {\n ev.stopPropagation()\n ev.preventDefault()\n },\n { capture: true, once: true },\n )\n }\n drag = null\n }\n host.addEventListener(\"pointerup\", endDrag)\n host.addEventListener(\"pointercancel\", endDrag)\n\n controls.querySelector('[data-act=\"in\"]').onclick = (e) => {\n e.stopPropagation()\n animated(() => zoomBy(1.25))\n }\n controls.querySelector('[data-act=\"out\"]').onclick = (e) => {\n e.stopPropagation()\n animated(() => zoomBy(1 / 1.25))\n }\n controls.querySelector('[data-act=\"fit\"]').onclick = (e) => {\n e.stopPropagation()\n animated(fit)\n }\n\n // Exposed so a collapsed sub-machine `<details>` panel can re-fit once\n // it's actually opened (its content renders at zero width while hidden).\n host.__pz = { fit, zoomBy }\n }\n\n // ── main flow ────────────────────────────────────────────────────────\n registerDiagram(\n document.getElementById(\"diaScroll\"),\n () => buildFlowSrc(STATES, {}),\n \"gtdmain\",\n wireMainDiagramClicks,\n { mode: \"main\" },\n )\n\n // ── sub-machines panel — one real mermaid diagram per invocation ─────\n ;(function renderSubmachines() {\n const el = document.getElementById(\"smSection\")\n const groups = MODEL.groups || []\n if (!groups.length) {\n el.innerHTML = `<p class=\"muted-note\">This workflow declares no sub-machines.</p>`\n return\n }\n el.innerHTML = \"\"\n groups.forEach((g, i) => {\n const card = document.createElement(\"details\")\n card.className = \"dia-card sm-card\"\n card.id = \"sm-\" + i\n card.style.marginBottom = \"16px\"\n if (CURRENT && CURRENT.group === g.name) card.open = true\n card.innerHTML =\n `<summary class=\"h\"><span class=\"nm\">${esc(g.name)} · instantiates <b>${esc(g.submachine)}</b></span></summary>` +\n `<div class=\"dia-scroll\" id=\"smDia${i}\"><div class=\"dia-msg\">Rendering diagram…</div></div>`\n el.appendChild(card)\n\n const members = (g.states || []).map((n) => byName[n]).filter(Boolean)\n registerDiagram(\n card.querySelector(`#smDia${i}`),\n () => buildFlowSrc(members, { scope: g.name }),\n \"gtdsm\" + i + \"-\",\n (renderId, host) => wireSubmachineDiagramClicks(renderId, host, i),\n { mode: \"submachine\", gi: i },\n )\n // A collapsed <details> renders its content at zero width, so the\n // pan/zoom \"fit\" computed while hidden is wrong — recompute it once\n // the panel is actually opened.\n card.addEventListener(\"toggle\", () => {\n if (!card.open) return\n const host = card.querySelector(`#smDia${i}`)\n if (host && host.__pz) host.__pz.fit()\n })\n })\n })()\n\n // ── vars ─────────────────────────────────────────────────────────────\n ;(function renderVars() {\n const vars = MODEL.vars || {}\n const keys = Object.keys(vars)\n if (!keys.length) return\n const el = document.getElementById(\"varsSection\")\n el.innerHTML =\n `<div class=\"sec-head\"><h2>Workflow variables</h2><span class=\"rule\"></span></div>` +\n `<div class=\"vars-grid\">${keys\n .map((k) => `<span class=\"var-chip\"><b>${esc(k)}</b> = ${esc(vars[k])}</span>`)\n .join(\"\")}</div>`\n })()\n\n // ── current-state panel ─────────────────────────────────────────────\n // Status-letter colour reuses existing actor tokens rather than adding new\n // theme variables: A (added) reads as the check colour, M (modified) as\n // the accent, D (deleted) as the engine/commit colour.\n const PENDING_STATUS_COLOR = {\n A: \"var(--check-fg)\",\n M: \"var(--accent)\",\n D: \"var(--engine-fg)\",\n }\n function pendingListHtml(pending) {\n if (!pending || !pending.length) {\n return `<p class=\"pending-clean\">working tree clean</p>`\n }\n return (\n `<div class=\"pending-list\">` +\n pending\n .map(\n (c) =>\n `<div class=\"pc\"><span class=\"st\" style=\"color:${PENDING_STATUS_COLOR[c.status] || \"var(--text-dim)\"}\">${esc(c.status)}</span><span class=\"path\">${esc(c.path)}</span></div>`,\n )\n .join(\"\") +\n `</div>`\n )\n }\n function renderCurrentPanel() {\n const panel = document.getElementById(\"currentPanel\")\n if (!CURRENT || !CURRENT.state) {\n panel.classList.add(\"hidden\")\n return\n }\n panel.classList.remove(\"hidden\")\n const a = CURRENT.actor\n const edgeRow = (pattern, to, matched, retryOf) => {\n const t = byName[to]\n return (\n `<div class=\"edge${matched ? \" cur-matched\" : \"\"}\"><div class=\"er\">` +\n `<span class=\"pat\">${retryOf ? \"after \" + esc(retryOf) + \" tries\" : \"if \" + esc(pattern)}</span>` +\n `<span class=\"arr\">→</span>` +\n `<button class=\"goto\" data-actor=\"${t ? actorOf(t) : \"unknown\"}\" data-to=\"${esc(to)}\">${esc(to)}</button>` +\n `${matched ? '<span class=\"cur-tag\">current match</span>' : \"\"}</div></div>`\n )\n }\n const rows = (CURRENT.edges || []).map((e) => edgeRow(e.pattern, e.to, e.matched)).join(\"\")\n const retryRow = CURRENT.retry\n ? edgeRow(null, CURRENT.retry.otherwise, false, CURRENT.retry.max)\n : \"\"\n panel.innerHTML =\n `<div class=\"cur-head\"><span class=\"cur-name\">${esc(CURRENT.state)}</span>` +\n `${pill(a)}<span class=\"kind\">${esc(CURRENT.kind)}</span>` +\n `${CURRENT.group ? `<span class=\"flow-tag\">${esc(CURRENT.group)}</span>` : \"\"}</div>` +\n `<div style=\"margin-top:14px\">${pendingListHtml(CURRENT.pending)}</div>` +\n `<div class=\"edges\">${\n rows || retryRow ? rows + retryRow : '<p class=\"none\">No outgoing edges declared.</p>'\n }</div>`\n panel.querySelector(\".cur-head\").onclick = () => navigateTo(CURRENT.state)\n panel.querySelectorAll(\".goto\").forEach((b) => (b.onclick = () => navigateTo(b.dataset.to)))\n }\n renderCurrentPanel()\n\n reinitializeAndRenderAll()\n\n // ── live current-state polling ──────────────────────────────────────\n // Keeps the panel + diagram highlight in sync with an in-progress\n // process without a manual page refresh. Skipped while the tab is\n // hidden; becoming visible again triggers an immediate refresh. No\n // in-flight guard — a slow tick just means the next one also awaits.\n async function poll() {\n if (document.hidden) return\n const changed = await refreshCurrent()\n if (changed) {\n renderCurrentPanel()\n repaintHighlights()\n }\n }\n setInterval(poll, 3000)\n document.addEventListener(\"visibilitychange\", () => {\n if (!document.hidden) poll()\n })\n\n // ── kind chips + actor legend ──────────────────────────────────────\n const kindChipsEl = document.getElementById(\"kindChips\")\n const presentKinds = [\"all\", ...Array.from(new Set(STATES.map((s) => s.kind)))]\n presentKinds.forEach((k) => {\n const b = document.createElement(\"button\")\n b.className = \"chip-btn\" + (k === \"all\" ? \" on\" : \"\")\n b.textContent = k\n b.dataset.kind = k\n b.onclick = () => {\n kindFilter = k\n ;[...kindChipsEl.children].forEach((c) =>\n c.classList.toggle(\"on\", c.dataset.kind === kindFilter),\n )\n renderCatalogue()\n }\n kindChipsEl.appendChild(b)\n })\n\n const legendEl = document.getElementById(\"legend\")\n ;[\"human\", \"agent\", \"check\", \"commit\", \"unknown\"].forEach((a) => {\n if (!counts[a]) return\n const el = document.createElement(\"button\")\n el.className = \"li\"\n el.dataset.actor = a\n el.setAttribute(\"aria-pressed\", \"true\")\n el.innerHTML = `<span class=\"dot\" style=\"background:var(--${meta(a).dot}-dot)\"></span>${meta(a).label}`\n el.onclick = () => {\n const on = actorFilter.has(a)\n on ? actorFilter.delete(a) : actorFilter.add(a)\n el.classList.toggle(\"off\", on)\n el.setAttribute(\"aria-pressed\", String(!on))\n renderCatalogue()\n }\n legendEl.appendChild(el)\n })\n\n // ── catalogue ──────────────────────────────────────────────────────\n const groupsEl = document.getElementById(\"groups\")\n function stateMatches(s, q) {\n if (!actorFilter.has(actorOf(s))) return false\n if (kindFilter !== \"all\" && s.kind !== kindFilter) return false\n if (!q) return true\n const hay = [\n s.name,\n s.actor,\n s.kind,\n s.file,\n s.mode,\n s.model,\n s.memory,\n (s.flags || []).join(\" \"),\n ]\n .filter(Boolean)\n .join(\" \")\n .toLowerCase()\n return hay.includes(q)\n }\n function cardFor(s) {\n const a = actorOf(s)\n const card = document.createElement(\"button\")\n card.className = \"card\"\n card.dataset.actor = a\n const flags = (s.flags || [])\n .filter((f) => f !== \"initial\")\n .map((f) => `<span class=\"flag\">${esc(f)}</span>`)\n .join(\"\")\n const retry = s.retry ? `<span class=\"flag\">retry ${s.retry.max}</span>` : \"\"\n const initTick = s.initial ? `<div class=\"init-tick\">initial</div>` : \"\"\n card.innerHTML =\n `${initTick}<div class=\"name\">${esc(s.name)}</div>` +\n `<div class=\"row\">${pill(a)}<span class=\"kind\">${esc(s.kind)}</span>${retry}${flags}</div>`\n card.onclick = () => openDrawer(s.name)\n return card\n }\n function renderCatalogue() {\n const q = document.getElementById(\"search\").value.trim().toLowerCase()\n groupsEl.innerHTML = \"\"\n const sections = []\n ;(MODEL.groups || []).forEach((g) => {\n sections.push({\n label: g.name,\n flow: g.submachine,\n items: STATES.filter((s) => s.group === g.name),\n })\n })\n const ungrouped = STATES.filter((s) => !s.group)\n if (ungrouped.length) sections.push({ label: \"Ungrouped\", flow: \"\", items: ungrouped })\n\n let shown = 0\n sections.forEach((sec) => {\n const items = sec.items.filter((s) => stateMatches(s, q))\n if (!items.length) return\n shown += items.length\n const wrap = document.createElement(\"div\")\n wrap.className = \"group\"\n wrap.innerHTML =\n `<div class=\"group-head\">${sec.flow ? `<span class=\"flow-tag\">${esc(sec.flow)}</span>` : \"\"}` +\n `<h3>${esc(sec.label)}</h3></div>`\n const grid = document.createElement(\"div\")\n grid.className = \"grid\"\n items.forEach((s) => grid.appendChild(cardFor(s)))\n wrap.appendChild(grid)\n groupsEl.appendChild(wrap)\n })\n if (!shown)\n groupsEl.innerHTML = `<p class=\"none\" style=\"padding:20px 0\">No states match this filter.</p>`\n }\n document.getElementById(\"search\").addEventListener(\"input\", renderCatalogue)\n renderCatalogue()\n\n // ── drawer ─────────────────────────────────────────────────────────\n const drawer = document.getElementById(\"drawer\")\n const backdrop = document.getElementById(\"backdrop\")\n let lastFocus = null\n\n function openDrawer(name) {\n const s = byName[name]\n if (!s) return\n const a = actorOf(s)\n if (!drawer.classList.contains(\"open\")) lastFocus = document.activeElement\n\n const kv = []\n kv.push([\"actor\", pill(a)])\n kv.push([\"kind\", `<span class=\"kind\">${esc(s.kind)}</span>`])\n kv.push([\"model\", s.model ? esc(s.model) : '<span class=\"none\">—</span>'])\n kv.push([\"memory\", s.memory ? esc(s.memory) : '<span class=\"none\">—</span>'])\n kv.push([\"file\", s.file ? esc(s.file) : '<span class=\"none\">—</span>'])\n kv.push([\"mode\", s.mode ? esc(s.mode) : '<span class=\"none\">—</span>'])\n if (s.retry)\n kv.push([\n \"retry\",\n `max ${s.retry.max} → <span style=\"color:var(--accent)\">${esc(s.retry.otherwise)}</span>`,\n ])\n if (s.group) kv.push([\"group\", esc(s.group)])\n\n const contentBlock = s.content\n ? `<div class=\"field\"><div class=\"k\">content — ${esc(s.kind)}</div><pre class=\"dia-src state-content\">${esc(s.content)}</pre></div>`\n : \"\"\n\n const flags = s.flags || []\n const flagBlock = flags.length\n ? `<div class=\"field\"><div class=\"k\">flags & gates</div><div class=\"flag-list\">${flags\n .map(\n (f) =>\n `<div class=\"flag-item\"><span class=\"fn\">${esc(f)}</span><span class=\"fd\">${esc(FLAGS[f] || \"\")}</span></div>`,\n )\n .join(\"\")}</div></div>`\n : \"\"\n\n const edges = s.on || []\n const edgesBlock = edges.length\n ? `<div class=\"field\"><div class=\"k\">outgoing edges — on (first match wins)</div><div class=\"edges\">${edges\n .map((e) => {\n const t = byName[e.to]\n const ta = t ? actorOf(t) : \"unknown\"\n return (\n `<div class=\"edge\"><div class=\"er\"><span class=\"pat\">${esc(e.pattern)}</span><span class=\"arr\">→</span>` +\n `<button class=\"goto\" data-actor=\"${ta}\" data-to=\"${esc(e.to)}\">${esc(e.to)}</button></div>` +\n `${e.describe ? `<div class=\"em\">${esc(e.describe)}</div>` : \"\"}</div>`\n )\n })\n .join(\"\")}</div></div>`\n : `<div class=\"field\"><div class=\"k\">outgoing edges</div><p class=\"none\">${\n s.kind === \"commit\"\n ? \"Commit state — no outgoing edges. Entering it ends the process in one squash.\"\n : \"No outgoing edges declared.\"\n }</p></div>`\n\n const retryOut =\n s.retry && byName[s.retry.otherwise]\n ? `<div class=\"field\"><div class=\"k\">retry redirect</div><div class=\"edge\"><div class=\"er\">` +\n `<span class=\"pat\">after ${esc(s.retry.max)} tries</span><span class=\"arr\">→</span>` +\n `<button class=\"goto\" data-actor=\"${actorOf(byName[s.retry.otherwise])}\" data-to=\"${esc(s.retry.otherwise)}\">${esc(s.retry.otherwise)}</button>` +\n `</div></div></div>`\n : \"\"\n\n const inc = s.incoming || []\n const incBlock =\n `<div class=\"field\"><div class=\"k\">routes in from (${inc.length})</div>` +\n (inc.length\n ? `<div class=\"incoming\">${inc\n .map((i) => {\n const f = byName[i.from]\n return `<button class=\"goto\" data-actor=\"${f ? actorOf(f) : \"unknown\"}\" data-to=\"${esc(i.from)}\" title=\"${esc(i.pattern)}\">${esc(i.from)}</button>`\n })\n .join(\"\")}</div>`\n : `<p class=\"none\">Reached only as an entry point (a CLI command enters it directly).</p>`) +\n `</div>`\n\n drawer.style.setProperty(\"--card-accent\", `var(--${meta(a).dot}-dot)`)\n drawer.innerHTML =\n `<div class=\"drawer-head\" style=\"--card-accent:var(--${meta(a).dot}-dot)\">` +\n `<div class=\"top\"><h3>${esc(s.name)}</h3>` +\n `<button class=\"close-x\" id=\"closeX\" aria-label=\"Close detail\">✕</button></div>` +\n `<div class=\"meta-row\">${pill(a)}<span class=\"kind\">${esc(s.kind)}</span>` +\n `${s.initial ? '<span class=\"flag\">initial</span>' : \"\"}` +\n `${s.retry ? `<span class=\"flag\">retry ${s.retry.max}</span>` : \"\"}` +\n `${flags\n .filter((f) => f !== \"initial\")\n .map((f) => `<span class=\"flag\">${esc(f)}</span>`)\n .join(\"\")}</div></div>` +\n `<div class=\"drawer-body\">` +\n `<div class=\"field\"><div class=\"k\">definition</div><dl class=\"kv\">${kv\n .map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`)\n .join(\"\")}</dl></div>` +\n contentBlock +\n flagBlock +\n edgesBlock +\n retryOut +\n incBlock +\n `</div>`\n\n drawer\n .querySelectorAll(\".goto\")\n .forEach((b) => (b.onclick = () => openDrawer(b.dataset.to)))\n drawer.querySelector(\"#closeX\").onclick = closeDrawer\n\n backdrop.classList.add(\"open\")\n drawer.classList.add(\"open\")\n drawer.querySelector(\".drawer-body\").scrollTop = 0\n drawer.querySelector(\"#closeX\").focus()\n }\n function closeDrawer() {\n backdrop.classList.remove(\"open\")\n drawer.classList.remove(\"open\")\n if (lastFocus && lastFocus.focus) lastFocus.focus()\n }\n backdrop.onclick = closeDrawer\n document.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Escape\" && drawer.classList.contains(\"open\")) closeDrawer()\n })\n\n // expose for interactions\n window.__openState = openDrawer\n <\/script>\n </body>\n</html>\n";
205006
205055
  //#endregion
205007
205056
  //#region src/Visualize.ts
205008
205057
  const FLAG_KEYS = [
@@ -205032,6 +205081,7 @@ const toVizState = (name, def, group, incoming) => stripUndefined({
205032
205081
  name,
205033
205082
  actor: def.actor,
205034
205083
  kind: contentKindOf(def) ?? "unknown",
205084
+ content: contentOf(def),
205035
205085
  initial: def.initial === true ? true : void 0,
205036
205086
  model: def.model,
205037
205087
  memory: def.memory,
@@ -205082,6 +205132,35 @@ const buildVizModel = (workflow, rawWorkflow, vars) => {
205082
205132
  };
205083
205133
  };
205084
205134
  /**
205135
+ * Describe the currently-rested state for the viewer: its `on` edges each
205136
+ * flagged with whether it's the one that would fire on the CURRENT pending
205137
+ * changes (same first-match semantics `PatternMachine.step` decides a real
205138
+ * step with), plus the retry redirect and pending changes verbatim. `group`
205139
+ * is threaded in by the caller (already computed once by `buildVizModel`
205140
+ * from the same workflow) rather than recomputed here.
205141
+ */
205142
+ const buildCurrentStateModel = (rest, changes, group) => {
205143
+ const onEdges = rest.stateDef.on ?? [];
205144
+ const matchedIndex = onEdges.findIndex(([patternStr]) => {
205145
+ const parsed = parsePattern(patternStr);
205146
+ return parsed !== void 0 && matchesPattern(parsed, changes);
205147
+ });
205148
+ const edges = onEdges.map(([pattern, to], i) => ({
205149
+ pattern,
205150
+ to,
205151
+ matched: i === matchedIndex
205152
+ }));
205153
+ return stripUndefined({
205154
+ state: rest.state,
205155
+ actor: rest.actor,
205156
+ kind: contentKindOf(rest.stateDef) ?? "unknown",
205157
+ group,
205158
+ edges,
205159
+ retry: rest.stateDef.retry,
205160
+ pending: changes
205161
+ });
205162
+ };
205163
+ /**
205085
205164
  * Route one request (pure): `/` (or `/index.html`) serves the viewer page,
205086
205165
  * `/workflow.json` serves the model, anything else is a 404. Kept separate from
205087
205166
  * the server so it unit-tests without a socket.
@@ -205107,10 +205186,25 @@ const handleVizRequest = (pathname, model) => {
205107
205186
  * Start the viewer's HTTP server, resolving once it is listening with the
205108
205187
  * chosen URL (a `port` of `0` picks a free port). Rejects if the port is in
205109
205188
  * use. The caller owns the returned `server` (closes it on shutdown).
205189
+ *
205190
+ * `resolveCurrent`, when given, backs an extra `/state.json` route serving
205191
+ * where the active process rests right now (resolved fresh on every request
205192
+ * — there's no polling on the browser side, just a single fetch at page
205193
+ * load). It resolves to `null` when there's no active process to report
205194
+ * (not a repo, no commits) — served as `{}`. This route is handled INLINE
205195
+ * (not through `handleVizRequest`) because it's async and per-request, unlike
205196
+ * the static routes `handleVizRequest` serves synchronously from a fixed model.
205110
205197
  */
205111
- const startVizServer = (model, port, host = "127.0.0.1") => new Promise((resolve, reject) => {
205198
+ const startVizServer = (model, port, host = "127.0.0.1", resolveCurrent) => new Promise((resolve, reject) => {
205112
205199
  const server = createServer((req, res) => {
205113
205200
  const { pathname } = new URL(req.url ?? "/", "http://localhost");
205201
+ if (pathname === "/state.json") {
205202
+ Promise.resolve(resolveCurrent ? resolveCurrent() : null).then((current) => {
205203
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
205204
+ res.end(JSON.stringify(current ?? {}));
205205
+ });
205206
+ return;
205207
+ }
205114
205208
  const { status, contentType, body } = handleVizRequest(pathname, model);
205115
205209
  res.writeHead(status, { "content-type": contentType });
205116
205210
  res.end(body);
@@ -205257,6 +205351,11 @@ const GTD_VERSION = createRequire(import.meta.url)("../package.json").version;
205257
205351
  const HELP_TEXT = `Usage: gtd [command] [options]
205258
205352
 
205259
205353
  Commands:
205354
+ (no command), loop
205355
+ Launch the loop driver (bin/gtd), which repeatedly drives
205356
+ an agent through gtd next/gtd step calls until the
205357
+ workflow returns to its initial state again. A bare gtd
205358
+ invocation and gtd loop both launch it identically
205260
205359
  init Scaffold a minimal .gtdrc.json for this repo, seeding the
205261
205360
  default variables you are most likely to change (the test
205262
205361
  command) and a Prettier formatting suggestion. gtd runs its
@@ -206084,12 +206183,33 @@ const parseVisualizeOptions = (rest) => {
206084
206183
  };
206085
206184
  };
206086
206185
  /**
206186
+ * Best-effort resolution of the currently-rested state for the viewer's
206187
+ * `/state.json` route: prefers the review checkout window's saved head
206188
+ * (`REVIEW_HEAD_REF`) over HEAD itself, since `gtd visualize` is dispatched
206189
+ * BEFORE the review-window bracket and a request landing mid-window would
206190
+ * otherwise read a HEAD that's been temporarily rewound (see
206191
+ * `src/ReviewWindow.ts`). Any failure (not a repo, no commits, resolves to
206192
+ * the initial state with no process underway) is swallowed to `null` — the
206193
+ * browser just hides the panel.
206194
+ */
206195
+ const computeCurrentState = (model) => gen(function* () {
206196
+ const git = yield* GitService;
206197
+ const reviewHead = yield* git.readRefOption(REVIEW_HEAD_REF);
206198
+ const currentRest = yield* resolveRest(getOrUndefined(reviewHead));
206199
+ const changes = yield* pendingChanges(git);
206200
+ const group = model.states.find((s) => s.name === currentRest.state)?.group;
206201
+ return buildCurrentStateModel(currentRest, changes, group);
206202
+ });
206203
+ /**
206087
206204
  * `gtd visualize`: serve an interactive diagram of the ACTIVE workflow on a
206088
206205
  * local HTTP server (see `src/Visualize.ts`). Dispatched early (like `lsp`/
206089
206206
  * `init`) — it reads the config but never touches git, HEAD, or the review
206090
- * window — and parses its OWN options (`--port`, `--no-open`), since the global
206091
- * unknown-option check does not know them. `--json` prints the model and exits
206092
- * without starting a server (the testable path).
206207
+ * window ITSELF (though its `/state.json` route best-effort reads git state
206208
+ * per request, see `computeCurrentState`) — and parses its OWN options
206209
+ * (`--port`, `--no-open`), since the global unknown-option check does not
206210
+ * know them. `--json` prints the model and exits without starting a server
206211
+ * (the testable path; live state is a server-only concern, so `--json`'s
206212
+ * shape is unchanged).
206093
206213
  */
206094
206214
  const runVisualizeCommand = (argv, json, write) => gen(function* () {
206095
206215
  const tokens = argv.slice(2);
@@ -206106,8 +206226,10 @@ const runVisualizeCommand = (argv, json, write) => gen(function* () {
206106
206226
  write(JSON.stringify(model, null, 2) + "\n");
206107
206227
  return;
206108
206228
  }
206229
+ const runtime$4 = yield* runtime();
206230
+ const resolveCurrent = () => runPromise(runtime$4)(computeCurrentState(model).pipe(either)).then(getOrNull);
206109
206231
  const { server, url } = yield* tryPromise({
206110
- try: () => startVizServer(model, opts.port),
206232
+ try: () => startVizServer(model, opts.port, "127.0.0.1", resolveCurrent),
206111
206233
  catch: (e) => /* @__PURE__ */ new Error(`gtd visualize: could not start server: ${e instanceof Error ? e.message : String(e)}`)
206112
206234
  });
206113
206235
  write(`gtd visualize running at ${url} — Ctrl-C to stop\n`);
package/docs/cli.md CHANGED
@@ -4,12 +4,19 @@
4
4
  Usage: gtd [command] [options]
5
5
 
6
6
  Commands:
7
- init Scaffold a minimal .gtdrc.json for this repo, seeding a
8
- testCommand var (npm test) and a ready-to-edit Prettier
9
- formatting suggestion under modes:. Writes no workflow — gtd
10
- runs the bundled unified workflow as its built-in default.
11
- Takes no argument. Refuses if a gtd config already exists.
12
- Leaves the file uncommitted for you to review and commit
7
+ (no command), loop
8
+ Launch the loop driver (bin/gtd), which repeatedly drives
9
+ an agent through gtd next/gtd step calls until the
10
+ workflow returns to its initial state again. A bare gtd
11
+ invocation and gtd loop both launch it identically
12
+ init Scaffold a minimal .gtdrc.json for this repo, seeding the
13
+ default variables you are most likely to change (the test
14
+ command) and a Prettier formatting suggestion. gtd runs its
15
+ built-in workflow by default, so no workflow is written —
16
+ add a workflow: key only to customize the machine itself.
17
+ Takes no argument. Run once per repo; refuses if a gtd
18
+ config already exists. Leaves the file uncommitted for you
19
+ to review and commit
13
20
  step <actor> Authenticate as <actor>, match the resolved rest's
14
21
  declared patterns against the pending changes, and commit
15
22
  (or squash) the one resulting transition. Pass
@@ -44,7 +51,9 @@ Commands:
44
51
  declares, with its mode's commands (its file:/mode:);
45
52
  exits non-zero with the findings when it is invalid
46
53
  lsp Start the LSP server for .gtd/ steering files (stdio)
47
- visualize Serve an interactive diagram of the active workflow locally
54
+ visualize Serve an interactive diagram of the active workflow on a
55
+ local web server (--port <n>, --no-open; --json prints the
56
+ model and exits)
48
57
  version Print version and exit
49
58
  help Print this help and exit
50
59
 
@@ -61,9 +70,11 @@ Options:
61
70
  `--version` (`-v`) / `gtd version` and `--help` (`-h`) / `gtd help`
62
71
  short-circuit before any git or repository-state work — they run outside a repo
63
72
  and in any repo state (the bare subcommands are exact equivalents of their flag
64
- forms). Bare `gtd` (no subcommand) is a usage error: it prints the help text and
65
- exits 1 without touching the repository. Every other command must be run from
66
- the **repository root** — gtd derives the workflow, pending changes, and process
73
+ forms). Bare `gtd` (no subcommand) and `gtd loop` both launch the loop driver
74
+ (`bin/gtd`) immediately — neither is a usage error. Any other, truly unknown
75
+ subcommand remains a usage error: it prints the help text and exits 1 without
76
+ touching the repository. Every other (recognized) command must be run from the
77
+ **repository root** — gtd derives the workflow, pending changes, and process
67
78
  history relative to cwd, so it refuses with a clear error if invoked from a
68
79
  subdirectory.
69
80
 
@@ -401,7 +412,7 @@ which are JSON-only. `--json` emits
401
412
 
402
413
  There is no `gtd` subcommand that executes a workflow script — gtd never runs a
403
414
  workflow script itself. When `gtd next` resolves to a `script`-content rest, the
404
- **driver** (`bin/gtd-loop`, or any loop harness) executes the emitted `content`
415
+ **driver** (`bin/gtd`, or any loop harness) executes the emitted `content`
405
416
  verbatim via `bash -c` — foreground, inherited stdio, exit code deliberately
406
417
  ignored (a check script encodes its outcome in the tree, e.g. writing a findings
407
418
  file, never in its exit status) — then runs `gtd step <actor>` for that state's
@@ -493,7 +504,7 @@ agent or human:
493
504
  - **The producing agent self-validates.** Plain `gtd next` appends a "run
494
505
  `gtd validate` and fix all violations" instruction to a `prompt` rest that
495
506
  declares `file:`+`mode:`; `gtd next --json` withholds it and the driving loop
496
- runs `gtd validate` after the turn instead (see `bin/gtd-loop` /
507
+ runs `gtd validate` after the turn instead (see `bin/gtd` /
497
508
  [STATES.md §12](../STATES.md#12-steering-file-validation-gtd-validate)).
498
509
  - **`gtd step` enforces the same gate.** Capturing a turn out of a state that
499
510
  declares `file:`+`mode:` formats that file in place and validates it first
@@ -543,9 +554,17 @@ cleanly.
543
554
  ## `gtd visualize [--port=<n>] [--no-open] [--json]`
544
555
 
545
556
  Serve an interactive diagram of the ACTIVE workflow on a local web server: the
546
- main flow as a graph (one box per sub-machine), and a click-through inspector
547
- with each state's actor, content kind, model/memory, steering file+mode, retry,
548
- flags, and outgoing/incoming edges. This is the replacement for the removed
557
+ main flow as a graph (each sub-machine invocation collapsed into a single opaque
558
+ black-box node — click it to jump to that sub-machine's own diagram, rendered
559
+ separately below with its true member states/shapes/colours and a muted ghost
560
+ node for any edge leaving the group; every diagram supports scroll-to-zoom,
561
+ drag-to-pan, and a corner `+`/`−`/fit control cluster), a click-through
562
+ inspector with each state's actor, content kind, its own raw
563
+ prompt/message/script text, model/memory, steering file+mode, retry, flags, and
564
+ outgoing/incoming edges, and — read ONCE at page load, never polled — a "Current
565
+ state" panel showing where the active process rests, its pending changes, and
566
+ which `on` pattern (or retry redirect) currently leads where, with the resting
567
+ node highlighted in the diagrams. This is the replacement for the removed
549
568
  `gtd mermaid` — a live viewer instead of a static diagram dump.
550
569
 
551
570
  ```
@@ -553,25 +572,34 @@ $ gtd visualize
553
572
  gtd visualize running at http://127.0.0.1:53017 — Ctrl-C to stop
554
573
  ```
555
574
 
556
- The server serves two routes: `/` (the self-contained HTML page) and
557
- `/workflow.json` (the model the page renders). It runs until interrupted
575
+ The server serves three routes: `/` (the self-contained HTML page),
576
+ `/workflow.json` (the model the page renders), and `/state.json` (the current
577
+ process's resting state, or `{}` when there isn't one — not a repo, no commits,
578
+ or an older server; the browser tolerates either). It runs until interrupted
558
579
  (Ctrl-C), then closes cleanly. Options (orthogonal, `gtd visualize` only):
559
580
 
560
581
  - `--port=<n>` (or `--port <n>`) — serve on a specific port (0–65535); the
561
582
  default is a free ephemeral port, printed on start.
562
583
  - `--no-open` — do not open the default browser (the URL is always printed).
563
584
  - `--json` — print the workflow model to stdout and exit WITHOUT starting a
564
- server. The model is `{ states, initial, groups, vars }`; each state carries
565
- its `actor`/`kind`/`model`/`memory`/`file`/`mode`/`retry`/`flags`, its `on`
566
- edges, its computed `incoming` edges, and its sub-machine `group`. `groups`
567
- lists each sub-machine invocation and the concrete states it produced.
585
+ server (unchanged shape — live state is a server-only concern). The model is
586
+ `{ states, initial, groups, vars }`; each state carries its
587
+ `actor`/`kind`/`content`/`model`/`memory`/`file`/`mode`/`retry`/`flags`
588
+ (`content` is the state's own raw script/prompt/message text, omitted for a
589
+ commit state), its `on` edges, its computed `incoming` edges, and its
590
+ sub-machine `group`. `groups` lists each sub-machine invocation and the
591
+ concrete states it produced.
568
592
 
569
593
  Dispatched before the repository-root guard and the config-reading path's review
570
594
  window — it reads the active workflow (the built-in default when none is
571
- configured) but touches no git/HEAD/review-window state. The diagram is rendered
572
- with Mermaid loaded from a CDN, so the graph needs network access the first time
573
- a browser loads the page; the inspector works offline regardless. Rejects
574
- unknown options and unexpected positional arguments.
595
+ configured) but touches no git/HEAD/review-window state ITSELF; the
596
+ `/state.json` route best-effort reads git state per request instead (any failure
597
+ — not a repo, no commits — serves `{}`), preferring the review checkout window's
598
+ saved head over HEAD so a request landing mid-window still reports the state the
599
+ process actually rests at. The diagram is rendered with Mermaid loaded from a
600
+ CDN, so the graph needs network access the first time a browser loads the page;
601
+ the inspector and current-state panel work offline regardless. Rejects unknown
602
+ options and unexpected positional arguments.
575
603
 
576
604
  ## Error envelope
577
605
 
package/docs/loop.md CHANGED
@@ -57,9 +57,10 @@ gtd next --json # ask who's up and what they should do
57
57
  ```
58
58
 
59
59
  See [`skills/loop/SKILL.md`](../skills/loop/SKILL.md) for the agent-facing
60
- instructions that follow the same pinned contract. `gtd-loop`, installed
61
- alongside `gtd` (see below), is the packaged, ready-to-run implementation of
62
- that same script for anyone who doesn't want to drive the loop by hand.
60
+ instructions that follow the same pinned contract. `gtd` itself (bare, or with
61
+ `loop` as its first argument — see below) is the packaged, ready-to-run
62
+ implementation of that same script for anyone who doesn't want to drive the loop
63
+ by hand.
63
64
 
64
65
  ## The reference loop driver
65
66
 
@@ -68,17 +69,29 @@ A minimal bash implementation of the pinned protocol, driving an agent CLI (e.g.
68
69
  reference for what a loop driver must do; keep any other implementation
69
70
  (including `skills/loop/SKILL.md`) consistent with it rather than editing both
70
71
  independently. Both open with the same move — capture the human's pending edit
71
- when the machine rests at a `"message"` gate — so `gtd-loop` is the only command
72
- a human runs. `bin/gtd-loop` is this exact script, packaged as the `gtd-loop`
73
- binary, with four additions: it stops with a diagnostic if the same `"prompt"`
74
- state/content repeat with no progress (see `skills/loop/SKILL.md`'s "Stall
75
- detection"); it lets `GTD_LOOP_AGENT_CMD` swap in any coding agent CLI in place
76
- of the default `claude -p`, receiving the prompt via `$GTD_LOOP_PROMPT`; it
77
- exports the resolved state's optional `model` hint as `$GTD_LOOP_MODEL`,
78
- appending `--model "$GTD_LOOP_MODEL"` to the default `claude -p` invocation
79
- whenever it's non-empty; and it acts on the optional `memory` scope hint (see
80
- "Agent memory scope" below) — continuing one agent session across consecutive
81
- same-scope turns and starting fresh when the scope changes.
72
+ when the machine rests at a `"message"` gate — so `gtd` is the only command a
73
+ human runs. `bin/gtd` is the packaged entry point: invoked bare, or with `loop`
74
+ as its first argument, it runs this exact script; any other first argument (e.g.
75
+ `next`, `step`, `status`) hands off to `node dist/gtd.bundle.mjs` instead. The
76
+ loop body it runs adds four things on top of the reference script above: it
77
+ stops with a diagnostic if the same `"prompt"` state/content repeat with no
78
+ progress (see `skills/loop/SKILL.md`'s "Stall detection"); it lets
79
+ `GTD_LOOP_AGENT_CMD` swap in any coding agent CLI in place of the default
80
+ `claude -p`, receiving the prompt via `$GTD_LOOP_PROMPT`; it exports the
81
+ resolved state's optional `model` hint as `$GTD_LOOP_MODEL`, appending
82
+ `--model "$GTD_LOOP_MODEL"` to the default `claude -p` invocation whenever it's
83
+ non-empty; and it acts on the optional `memory` scope hint (see "Agent memory
84
+ scope" below) — continuing one agent session across consecutive same-scope turns
85
+ and starting fresh when the scope changes.
86
+
87
+ `bin/gtd` resolves both its bundle hand-off and the loop's own internal `gtd`
88
+ calls against `dist/gtd.bundle.mjs` next to its own location. Set `GTD_BIN` — a
89
+ full command, not just a path — to point both at source instead, e.g. for
90
+ development or testing:
91
+
92
+ ```bash
93
+ GTD_BIN="node $PWD/dev/run.mjs" bin/gtd
94
+ ```
82
95
 
83
96
  ```bash
84
97
  #!/usr/bin/env bash
@@ -144,9 +157,9 @@ turn, or either side has no `memory` — start fresh.
144
157
  This is what makes a loop retain memory while a phase boundary clears it: a loop
145
158
  that keeps re-entering one state emits the same label every lap, so the agent
146
159
  accumulates context across the loop; the move to the next phase's
147
- differently-labelled state resets it. `bin/gtd-loop` implements this by mapping
148
- the signal onto claude's session flags — `--session-id` to pin a fresh session
149
- when the scope changes, `--resume` to continue it while the scope holds — and
160
+ differently-labelled state resets it. `bin/gtd` implements this by mapping the
161
+ signal onto claude's session flags — `--session-id` to pin a fresh session when
162
+ the scope changes, `--resume` to continue it while the scope holds — and
150
163
  persists the current scope + session id in the git dir (never the working tree,
151
164
  so `gtd status` and the pending diff never see them) so retention survives even
152
165
  across the restarts a human gate forces mid-loop. See `skills/loop/SKILL.md`'s
@@ -154,7 +167,7 @@ across the restarts a human gate forces mid-loop. See `skills/loop/SKILL.md`'s
154
167
 
155
168
  ## Using a different agent
156
169
 
157
- `gtd-loop` defaults to
170
+ `gtd` (bare, or `gtd loop`) defaults to
158
171
  `claude -p "$GTD_LOOP_PROMPT" --dangerously-skip-permissions`, but the agent
159
172
  invocation is swappable: set `GTD_LOOP_AGENT_CMD` to any shell command, and it
160
173
  runs with the prompt available as `$GTD_LOOP_PROMPT` in its environment, along
@@ -165,7 +178,7 @@ session). An adapter that ignores any of these keeps working unchanged. For
165
178
  example, to drive a different agent CLI:
166
179
 
167
180
  ```bash
168
- GTD_LOOP_AGENT_CMD='my-agent-cli --prompt "$GTD_LOOP_PROMPT"' gtd-loop
181
+ GTD_LOOP_AGENT_CMD='my-agent-cli --prompt "$GTD_LOOP_PROMPT"' gtd
169
182
  ```
170
183
 
171
184
  ## Herdr integration (optional)
package/docs/upgrading.md CHANGED
@@ -159,6 +159,47 @@ loudly at load time rather than silently misinterpreting.
159
159
  or agent harness, upgrading the `gtd` binary also means re-copying that skill
160
160
  from this release.
161
161
 
162
+ ## 7.4: `gtd-loop` is gone — bare `gtd` and `gtd loop` run it directly
163
+
164
+ `gtd-loop`, the standalone loop-driver binary installed alongside `gtd`, no
165
+ longer exists. Its body now lives in `bin/gtd` itself, which is a bash entry
166
+ script that dispatches on its first argument:
167
+
168
+ | behavior | before (≤ 7.3) | from 7.4 |
169
+ | ------------------------------------------------------ | ---------------------------------------------- | ----------------------------------------------- |
170
+ | `gtd-loop` | separate installed binary | removed |
171
+ | bare `gtd` (no subcommand) | usage error, prints help, exits 1 | runs the loop driver immediately |
172
+ | `gtd loop` | not a recognized subcommand | runs the loop driver immediately |
173
+ | any other subcommand (`step`, `status`, `validate`, …) | handled by `node dist/gtd.bundle.mjs` directly | same, now reached via `bin/gtd`'s hand-off/exec |
174
+
175
+ **Bare `gtd` is a behavior flip, not merely an addition** — the same kind of
176
+ change as the v1 → v2 label grammar below: previously it printed the help text
177
+ and exited 1 without touching the repository; from 7.4 it launches the loop
178
+ driver immediately, identically to `gtd loop`. A script that relied on bare
179
+ `gtd` failing fast (e.g. to detect a missing subcommand) now gets a running loop
180
+ instead of an error.
181
+
182
+ Because `bin/gtd` is a bash script for every invocation now, **running `gtd` at
183
+ all — including any subcommand — requires bash**. Before 7.4, only the loop
184
+ driver (`gtd-loop`) needed bash; a subcommand could be invoked directly via the
185
+ bundle (e.g. `node node_modules/.bin/gtd`) with no bash in between. This is
186
+ intentional and accepted, not an oversight: gtd's loop, its `script`-content
187
+ checks, and a steering-file mode's own `format:`/`validate:` commands already
188
+ run via `bash -c` (see
189
+ [Configuration](configuration.md#modes--pluggable-steering-file-modes)) — gtd
190
+ already assumes a unix/bash agent environment everywhere else, so the entry
191
+ point catching up removes an inconsistency rather than introducing a new
192
+ dependency.
193
+
194
+ **Migration:**
195
+
196
+ - Anyone invoking `gtd-loop` directly — in scripts, CI, or docs — should invoke
197
+ `gtd` or `gtd loop` instead.
198
+ - Anyone relying on `node node_modules/.bin/gtd` resolving straight to the
199
+ bundle should note `gtd` is now a bash script that execs the bundle for
200
+ subcommands, so behavior for actual subcommands (`gtd step`, `gtd status`,
201
+ etc.) is unchanged — only the underlying file changed.
202
+
162
203
  ## 7.2: the review checkout window is now per worktree
163
204
 
164
205
  The review checkout window's two refs moved from the SHARED `refs/gtd/*`
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "7.2.0",
3
+ "version": "7.4.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
7
- "gtd": "dist/gtd.bundle.mjs",
8
- "gtd-loop": "bin/gtd-loop"
7
+ "gtd": "bin/gtd"
9
8
  },
10
9
  "files": [
11
10
  "dist/",