@pmelab/gtd 7.2.0 → 7.3.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
@@ -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);
@@ -206084,12 +206178,33 @@ const parseVisualizeOptions = (rest) => {
206084
206178
  };
206085
206179
  };
206086
206180
  /**
206181
+ * Best-effort resolution of the currently-rested state for the viewer's
206182
+ * `/state.json` route: prefers the review checkout window's saved head
206183
+ * (`REVIEW_HEAD_REF`) over HEAD itself, since `gtd visualize` is dispatched
206184
+ * BEFORE the review-window bracket and a request landing mid-window would
206185
+ * otherwise read a HEAD that's been temporarily rewound (see
206186
+ * `src/ReviewWindow.ts`). Any failure (not a repo, no commits, resolves to
206187
+ * the initial state with no process underway) is swallowed to `null` — the
206188
+ * browser just hides the panel.
206189
+ */
206190
+ const computeCurrentState = (model) => gen(function* () {
206191
+ const git = yield* GitService;
206192
+ const reviewHead = yield* git.readRefOption(REVIEW_HEAD_REF);
206193
+ const currentRest = yield* resolveRest(getOrUndefined(reviewHead));
206194
+ const changes = yield* pendingChanges(git);
206195
+ const group = model.states.find((s) => s.name === currentRest.state)?.group;
206196
+ return buildCurrentStateModel(currentRest, changes, group);
206197
+ });
206198
+ /**
206087
206199
  * `gtd visualize`: serve an interactive diagram of the ACTIVE workflow on a
206088
206200
  * local HTTP server (see `src/Visualize.ts`). Dispatched early (like `lsp`/
206089
206201
  * `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).
206202
+ * window ITSELF (though its `/state.json` route best-effort reads git state
206203
+ * per request, see `computeCurrentState`) — and parses its OWN options
206204
+ * (`--port`, `--no-open`), since the global unknown-option check does not
206205
+ * know them. `--json` prints the model and exits without starting a server
206206
+ * (the testable path; live state is a server-only concern, so `--json`'s
206207
+ * shape is unchanged).
206093
206208
  */
206094
206209
  const runVisualizeCommand = (argv, json, write) => gen(function* () {
206095
206210
  const tokens = argv.slice(2);
@@ -206106,8 +206221,10 @@ const runVisualizeCommand = (argv, json, write) => gen(function* () {
206106
206221
  write(JSON.stringify(model, null, 2) + "\n");
206107
206222
  return;
206108
206223
  }
206224
+ const runtime$4 = yield* runtime();
206225
+ const resolveCurrent = () => runPromise(runtime$4)(computeCurrentState(model).pipe(either)).then(getOrNull);
206109
206226
  const { server, url } = yield* tryPromise({
206110
- try: () => startVizServer(model, opts.port),
206227
+ try: () => startVizServer(model, opts.port, "127.0.0.1", resolveCurrent),
206111
206228
  catch: (e) => /* @__PURE__ */ new Error(`gtd visualize: could not start server: ${e instanceof Error ? e.message : String(e)}`)
206112
206229
  });
206113
206230
  write(`gtd visualize running at ${url} — Ctrl-C to stop\n`);
package/docs/cli.md CHANGED
@@ -543,9 +543,17 @@ cleanly.
543
543
  ## `gtd visualize [--port=<n>] [--no-open] [--json]`
544
544
 
545
545
  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
546
+ main flow as a graph (each sub-machine invocation collapsed into a single opaque
547
+ black-box node — click it to jump to that sub-machine's own diagram, rendered
548
+ separately below with its true member states/shapes/colours and a muted ghost
549
+ node for any edge leaving the group; every diagram supports scroll-to-zoom,
550
+ drag-to-pan, and a corner `+`/`−`/fit control cluster), a click-through
551
+ inspector with each state's actor, content kind, its own raw
552
+ prompt/message/script text, model/memory, steering file+mode, retry, flags, and
553
+ outgoing/incoming edges, and — read ONCE at page load, never polled — a "Current
554
+ state" panel showing where the active process rests, its pending changes, and
555
+ which `on` pattern (or retry redirect) currently leads where, with the resting
556
+ node highlighted in the diagrams. This is the replacement for the removed
549
557
  `gtd mermaid` — a live viewer instead of a static diagram dump.
550
558
 
551
559
  ```
@@ -553,25 +561,34 @@ $ gtd visualize
553
561
  gtd visualize running at http://127.0.0.1:53017 — Ctrl-C to stop
554
562
  ```
555
563
 
556
- The server serves two routes: `/` (the self-contained HTML page) and
557
- `/workflow.json` (the model the page renders). It runs until interrupted
564
+ The server serves three routes: `/` (the self-contained HTML page),
565
+ `/workflow.json` (the model the page renders), and `/state.json` (the current
566
+ process's resting state, or `{}` when there isn't one — not a repo, no commits,
567
+ or an older server; the browser tolerates either). It runs until interrupted
558
568
  (Ctrl-C), then closes cleanly. Options (orthogonal, `gtd visualize` only):
559
569
 
560
570
  - `--port=<n>` (or `--port <n>`) — serve on a specific port (0–65535); the
561
571
  default is a free ephemeral port, printed on start.
562
572
  - `--no-open` — do not open the default browser (the URL is always printed).
563
573
  - `--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.
574
+ server (unchanged shape — live state is a server-only concern). The model is
575
+ `{ states, initial, groups, vars }`; each state carries its
576
+ `actor`/`kind`/`content`/`model`/`memory`/`file`/`mode`/`retry`/`flags`
577
+ (`content` is the state's own raw script/prompt/message text, omitted for a
578
+ commit state), its `on` edges, its computed `incoming` edges, and its
579
+ sub-machine `group`. `groups` lists each sub-machine invocation and the
580
+ concrete states it produced.
568
581
 
569
582
  Dispatched before the repository-root guard and the config-reading path's review
570
583
  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.
584
+ configured) but touches no git/HEAD/review-window state ITSELF; the
585
+ `/state.json` route best-effort reads git state per request instead (any failure
586
+ — not a repo, no commits — serves `{}`), preferring the review checkout window's
587
+ saved head over HEAD so a request landing mid-window still reports the state the
588
+ process actually rests at. The diagram is rendered with Mermaid loaded from a
589
+ CDN, so the graph needs network access the first time a browser loads the page;
590
+ the inspector and current-state panel work offline regardless. Rejects unknown
591
+ options and unexpected positional arguments.
575
592
 
576
593
  ## Error envelope
577
594
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "7.2.0",
3
+ "version": "7.3.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": {