@pmelab/gtd 7.2.0 → 7.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -8
- package/bin/{gtd-loop → gtd} +43 -8
- package/dist/gtd.bundle.mjs +138 -16
- package/docs/cli.md +53 -25
- package/docs/loop.md +32 -19
- package/docs/upgrading.md +41 -0
- package/package.json +2 -3
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
|
|
102
|
+
local web server: the main flow (each sub-machine collapsed to one box, with
|
|
103
|
+
its own diagram below — pan/zoom with scroll, drag, or the corner controls),
|
|
104
|
+
per-state details including the state's own raw prompt/message/ script text,
|
|
105
|
+
and a "Current state" panel showing where the active process rests, its
|
|
106
|
+
pending changes, and which action leads where. The panel and diagram highlight
|
|
107
|
+
refresh live (~every 3s) while the page is open, so advancing the process
|
|
108
|
+
elsewhere shows up without a manual refresh.
|
|
103
109
|
|
|
104
110
|
`gtd version` (or `gtd --version`/`-v`) prints the installed version and exits;
|
|
105
111
|
`gtd help` (or `gtd --help`/`-h`) prints the command list. Both short-circuit
|
|
@@ -161,11 +167,11 @@ through two `vars` tiers — `plannerModel` (heavier planning and review) and
|
|
|
161
167
|
place (a `vars:` edit or a `GTD_VAR_plannerModel` override) instead of per
|
|
162
168
|
state.
|
|
163
169
|
|
|
164
|
-
`gtd
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
170
|
+
Bare `gtd` (or `gtd loop`) is a ready-to-run driver for the whole protocol —
|
|
171
|
+
point it at a repo and it runs the loop until it's your turn. It is the only
|
|
172
|
+
command you run: at a gate you edit files (answer a plan question, tick a review
|
|
173
|
+
box, fix code) and re-launch it, and it captures your edit as its opening move
|
|
174
|
+
before driving on — so you never run `gtd step human` by hand. See
|
|
169
175
|
[Driving the loop](docs/loop.md).
|
|
170
176
|
|
|
171
177
|
Before wiring gtd into a repo, note the
|
|
@@ -204,8 +210,8 @@ reporting contract.
|
|
|
204
210
|
sub-machines, and the bundled workflow template
|
|
205
211
|
- [CLI reference](docs/cli.md) — every command, exit codes, JSON schemas,
|
|
206
212
|
repository requirements
|
|
207
|
-
- [Driving the loop](docs/loop.md) — the reference loop driver,
|
|
208
|
-
custom agents
|
|
213
|
+
- [Driving the loop](docs/loop.md) — the reference loop driver,
|
|
214
|
+
`gtd`/`gtd loop`, custom agents
|
|
209
215
|
- [Configuration](docs/configuration.md) — `gtd init`, the `.gtdrc` `workflow:`
|
|
210
216
|
schema, lookup
|
|
211
217
|
- [Upgrading](docs/upgrading.md) — breaking changes and migration
|
package/bin/{gtd-loop → gtd}
RENAMED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
|
|
4
|
+
# This file is the single published `bin` entry: `bin/gtd`. Before anything
|
|
5
|
+
# else, it dispatches on $1:
|
|
6
|
+
# - no arguments, or exactly `loop` with no further arguments -> fall through
|
|
7
|
+
# into the loop body below (the loop protocol comment further down).
|
|
8
|
+
# - `loop` with additional arguments -> usage error, exit 2.
|
|
9
|
+
# - anything else -> hand off to the bundle via `run_gtd`, forwarding all
|
|
10
|
+
# arguments unchanged (e.g. `bin/gtd status`, `bin/gtd step human`).
|
|
11
|
+
# `run_gtd` is the ONE place that knows how to invoke "the bundle": by default
|
|
12
|
+
# it resolves `dist/gtd.bundle.mjs` relative to this script's own location
|
|
13
|
+
# (not cwd), but `GTD_BIN` (a dev/test override for the whole invocation
|
|
14
|
+
# command, e.g. `GTD_BIN="node $PWD/dev/run.mjs"`) takes precedence when set.
|
|
15
|
+
# Every call the loop body makes below goes through `run_gtd` too, so the loop
|
|
16
|
+
# always drives the exact same build/source it was launched from — no
|
|
17
|
+
# dependence on a `gtd` binary being on PATH.
|
|
18
|
+
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
|
|
19
|
+
|
|
20
|
+
run_gtd() {
|
|
21
|
+
if [[ -n "${GTD_BIN:-}" ]]; then
|
|
22
|
+
eval "$GTD_BIN" '"$@"'
|
|
23
|
+
else
|
|
24
|
+
node "$SCRIPT_DIR/../dist/gtd.bundle.mjs" "$@"
|
|
25
|
+
fi
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if [[ $# -gt 0 && "$1" != "loop" ]]; then
|
|
29
|
+
run_gtd "$@"
|
|
30
|
+
exit $?
|
|
31
|
+
fi
|
|
32
|
+
if [[ "${1:-}" == "loop" && $# -gt 1 ]]; then
|
|
33
|
+
echo "gtd: 'loop' takes no arguments" >&2
|
|
34
|
+
exit 2
|
|
35
|
+
fi
|
|
36
|
+
# otherwise ($# -eq 0, or exactly "loop" with no further args): fall through
|
|
37
|
+
# into the existing loop body below, unchanged in behavior.
|
|
38
|
+
|
|
4
39
|
# Drives the v3 pattern-machine loop (see docs/loop.md): each iteration asks
|
|
5
40
|
# `gtd next --json` who's up and what they should do, dispatching on `kind`:
|
|
6
41
|
# - "message" (a human rest) -> halt, tell the user what gtd is waiting on.
|
|
@@ -101,14 +136,14 @@ run_agent_turn() {
|
|
|
101
136
|
# a harmless no-op; a genuine refusal there (a malformed steering file, or an
|
|
102
137
|
# edit no `on` pattern accepts) is surfaced and stops the loop rather than being
|
|
103
138
|
# driven past.
|
|
104
|
-
if ! peek_json="$(
|
|
139
|
+
if ! peek_json="$(run_gtd next --json 2>&1)"; then
|
|
105
140
|
echo "gtd-loop: could not determine the next step:" >&2
|
|
106
141
|
echo "$peek_json" >&2
|
|
107
142
|
echo "Run \`gtd status\` to inspect the repo." >&2
|
|
108
143
|
exit 1
|
|
109
144
|
fi
|
|
110
145
|
if [[ "$(jq -r .kind <<<"$peek_json")" == "message" ]]; then
|
|
111
|
-
if ! step_out="$(
|
|
146
|
+
if ! step_out="$(run_gtd step human 2>&1)"; then
|
|
112
147
|
echo "gtd-loop: could not capture your changes at the human gate:" >&2
|
|
113
148
|
echo "$step_out" >&2
|
|
114
149
|
echo "Fix the reported issue and re-run gtd-loop." >&2
|
|
@@ -117,7 +152,7 @@ if [[ "$(jq -r .kind <<<"$peek_json")" == "message" ]]; then
|
|
|
117
152
|
fi
|
|
118
153
|
|
|
119
154
|
while true; do
|
|
120
|
-
if ! next_json="$(
|
|
155
|
+
if ! next_json="$(run_gtd next --json 2>&1)"; then
|
|
121
156
|
echo "gtd-loop: could not determine the next step:" >&2
|
|
122
157
|
echo "$next_json" >&2
|
|
123
158
|
echo "Run \`gtd status\` to inspect the repo." >&2
|
|
@@ -141,7 +176,7 @@ while true; do
|
|
|
141
176
|
herdr_report blocked "$label"
|
|
142
177
|
herdr_notify "gtd needs you — $(basename "$PWD")" "$label"
|
|
143
178
|
echo "--- Your turn ($state) ---"
|
|
144
|
-
|
|
179
|
+
run_gtd next
|
|
145
180
|
exit 0
|
|
146
181
|
fi
|
|
147
182
|
|
|
@@ -152,7 +187,7 @@ while true; do
|
|
|
152
187
|
# the tree, and `gtd step` captures whatever it left there via the state's
|
|
153
188
|
# own `on` patterns.
|
|
154
189
|
bash -c "$content" || true
|
|
155
|
-
|
|
190
|
+
run_gtd step "$actor" >/dev/null
|
|
156
191
|
head_after="$(git rev-parse HEAD 2>/dev/null || echo none)"
|
|
157
192
|
if [[ "$head_before" == "$head_after" ]]; then
|
|
158
193
|
herdr_report idle "$label"
|
|
@@ -170,7 +205,7 @@ while true; do
|
|
|
170
205
|
# progress; stop rather than spin on it.
|
|
171
206
|
if [[ "$state" == "$prev_state" && "$content" == "$prev_content" ]]; then
|
|
172
207
|
echo "gtd-loop: no progress at '$state' — the agent's last turn changed nothing. Stopping to avoid spinning." >&2
|
|
173
|
-
|
|
208
|
+
run_gtd next >&2
|
|
174
209
|
exit 1
|
|
175
210
|
fi
|
|
176
211
|
prev_state="$state"
|
|
@@ -213,7 +248,7 @@ while true; do
|
|
|
213
248
|
run_agent_turn "$content" "$resume"
|
|
214
249
|
|
|
215
250
|
fix_attempt=0
|
|
216
|
-
while ! validate_out="$(
|
|
251
|
+
while ! validate_out="$(run_gtd validate 2>&1)"; do
|
|
217
252
|
fix_attempt=$((fix_attempt + 1))
|
|
218
253
|
if ((fix_attempt > 3)); then
|
|
219
254
|
echo "gtd-loop: '$state' still fails \`gtd validate\` after 3 fix attempts:" >&2
|
|
@@ -228,5 +263,5 @@ while true; do
|
|
|
228
263
|
run_agent_turn "$content"$'\n\n'"Your last turn does not pass \`gtd validate\`. Fix these format violations, then finish:"$'\n'"$validate_out" "$fix_resume"
|
|
229
264
|
done
|
|
230
265
|
|
|
231
|
-
|
|
266
|
+
run_gtd step "$actor" >/dev/null
|
|
232
267
|
done
|
package/dist/gtd.bundle.mjs
CHANGED
|
@@ -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$
|
|
29407
|
+
const runtime$5 = (yield* runtime()).pipe(updateContext(omit$1(Scope)));
|
|
29368
29408
|
const fiberSet = yield* make$3();
|
|
29369
|
-
const runFork$3 = runFork(runtime$
|
|
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
|
-
/**
|
|
192732
|
-
|
|
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) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[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, \""\")}\"`\n switch (kind) {\n case \"message\":\n return `([\"${label.replace(/\"/g, \""\")}\"])`\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, \""\").replace(/\\|/g, \"|\")}\"`\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) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[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, \""\")}\"`\n switch (kind) {\n case \"message\":\n return `([\"${label.replace(/\"/g, \""\")}\"])`\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, \""\").replace(/\\|/g, \"|\")}\"`\n /**\n * The mermaid `classDef` lines for actor/ghost node fills, chosen per\n * theme so dark mode doesn't render washed-out light pastels.\n */\n function classDefs(dark) {\n return [\n dark\n ? \"classDef human fill:#3a2e12,stroke:#e0a83e,color:#f0c674;\"\n : \"classDef human fill:#fbeccd,stroke:#d99413,color:#5a3d0a;\",\n dark\n ? \"classDef agent fill:#26224a,stroke:#8b7cf0,color:#b6abf7;\"\n : \"classDef agent fill:#e5e2fb,stroke:#6d5ce0,color:#3a2c85;\",\n dark\n ? \"classDef check fill:#123433,stroke:#2dc4bc,color:#68dcd4;\"\n : \"classDef check fill:#d3f0ef,stroke:#16a39d,color:#0c524f;\",\n dark\n ? \"classDef commit fill:#3a1b24,stroke:#e05f7c,color:#f2a3b3;\"\n : \"classDef commit fill:#fbdfe4,stroke:#d84a68,color:#8a1f38;\",\n dark\n ? \"classDef unknown fill:#1c2434,stroke:#3a465c,color:#9aa6bd,stroke-dasharray:4 3;\"\n : \"classDef unknown fill:#eef1f7,stroke:#c2ccdb,color:#55607a,stroke-dasharray:4 3;\",\n // Sub-machine invocation: a muted slate box (not one of the actor\n // colours) so it still reads as its own distinct \"black-box\" kind,\n // without the harsh opaque-black fill clashing with a light theme.\n dark\n ? \"classDef submachine fill:#2a3142,stroke:#5a6b8c,color:#dbe4f5,font-weight:700;\"\n : \"classDef submachine fill:#e4e9f2,stroke:#8695b3,color:#2a3142,font-weight:700;\",\n // A muted, dashed ghost node standing in for a target OUTSIDE the\n // current diagram (a sub-machine diagram's external edge).\n dark\n ? \"classDef external fill:#1c2434,stroke:#3a465c,color:#9aa6bd,stroke-dasharray:5 4;\"\n : \"classDef external fill:#eef1f7,stroke:#c2ccdb,color:#55607a,stroke-dasharray:5 4;\",\n ]\n }\n\n /**\n * Build one flowchart's mermaid source over `states` (a diagram scope):\n * the whole machine for the main flow (`opts.scope` unset), or one\n * sub-machine's own member states (`opts.scope` = that group's name).\n * A member state whose innermost group differs from `opts.scope`\n * collapses into a black box for ITS group (handles a sub-machine\n * nested inside another); a target outside `states` entirely renders as\n * a muted, dashed ghost node instead (only possible in a sub-machine\n * diagram — the main flow's `states` is every state, so nothing is ever\n * \"outside\" it).\n */\n function buildFlowSrc(states, opts) {\n const scope = opts && opts.scope\n const inSet = new Set(states.map((s) => s.name))\n const lines = [\"flowchart TD\"]\n\n const unitOf = (name) => {\n const s = byName[name]\n if (s && s.group && s.group !== scope) return { kind: \"box\", group: s.group }\n return { kind: \"node\", name }\n }\n const unitId = (name) => {\n const u = unitOf(name)\n return u.kind === \"box\" ? groupBoxId(groupIndexOf(u.group)) : idFor(name)\n }\n\n const emittedNodes = new Set()\n const emittedBoxes = new Set()\n const emittedGhosts = new Set()\n const nodeLine = (s) => ` ${idFor(s.name)}${shapeFor(s.kind, s.name)}:::${actorOf(s)}`\n const boxLine = (g, i) =>\n ` ${groupBoxId(i)}[\"${esc(g.name)}<br/><span class='sm-hint'>▸ ${g.states.length} state${\n g.states.length === 1 ? \"\" : \"s\"\n }</span>\"]:::submachine`\n const ghostLine = (s) => ` e${idFor(s.name)}${shapeFor(s.kind, s.name)}:::external`\n\n states.forEach((s) => {\n const u = unitOf(s.name)\n if (u.kind === \"node\") {\n if (emittedNodes.has(s.name)) return\n emittedNodes.add(s.name)\n lines.push(nodeLine(s))\n } else if (!emittedBoxes.has(u.group)) {\n emittedBoxes.add(u.group)\n const i = groupIndexOf(u.group)\n if (i >= 0) lines.push(boxLine(MODEL.groups[i], i))\n }\n })\n\n // Edges within this diagram's own state set, collapsed through box\n // units, deduped, and label-dropped when several patterns land on the\n // same box-level edge (a black box hides which one specifically).\n const solid = new Map()\n const dashed = new Map()\n const addEdge = (map, from, to, label) => {\n if (from === to) return\n const key = from + \" \" + to\n if (!map.has(key)) map.set(key, [])\n map.get(key).push(label)\n }\n states.forEach((s) => {\n ;(s.on || []).forEach((e) => {\n if (!inSet.has(e.to) || !byName[e.to]) return\n addEdge(solid, unitId(s.name), unitId(e.to), e.pattern)\n })\n if (s.retry && inSet.has(s.retry.otherwise)) {\n addEdge(dashed, unitId(s.name), unitId(s.retry.otherwise), \"retry ×\" + s.retry.max)\n }\n })\n // Ghost edges to a target entirely outside this diagram's state set\n // (only fires for a sub-machine diagram — the main flow's set is\n // every state, so nothing is ever \"outside\" it).\n states.forEach((s) => {\n ;(s.on || []).forEach((e) => {\n if (inSet.has(e.to) || !byName[e.to]) return\n const target = byName[e.to]\n if (!emittedGhosts.has(e.to)) {\n emittedGhosts.add(e.to)\n lines.push(ghostLine(target))\n }\n lines.push(` ${idFor(s.name)} -->|${mlabel(e.pattern)}| e${idFor(e.to)}`)\n })\n if (s.retry && byName[s.retry.otherwise] && !inSet.has(s.retry.otherwise)) {\n const target = byName[s.retry.otherwise]\n if (!emittedGhosts.has(s.retry.otherwise)) {\n emittedGhosts.add(s.retry.otherwise)\n lines.push(ghostLine(target))\n }\n lines.push(\n ` ${idFor(s.name)} -.->|${mlabel(\"retry ×\" + s.retry.max)}| e${idFor(s.retry.otherwise)}`,\n )\n }\n })\n\n solid.forEach((labels, key) => {\n const [from, to] = key.split(\" \")\n const label = labels.length === 1 ? mlabel(labels[0]) : null\n lines.push(` ${from} -->${label ? \"|\" + label + \"|\" : \"\"} ${to}`)\n })\n dashed.forEach((labels, key) => {\n const [from, to] = key.split(\" \")\n const label = labels.length === 1 ? mlabel(labels[0]) : null\n lines.push(` ${from} -.->${label ? \"|\" + label + \"|\" : \"\"} ${to}`)\n })\n\n lines.push(...classDefs(isDark()))\n return lines.join(\"\\n\")\n }\n\n document.getElementById(\"diaNote\").innerHTML =\n `Every state as a node — shape is the content kind, colour is the actor. Each edge is an ` +\n `<span class=\"mono\">on</span> pattern (first match wins); dashed edges are ` +\n `<span class=\"mono\">retry</span> redirects. A sub-machine invocation collapses into a single ` +\n `dark box (click it to jump to its own diagram below); an edge label is dropped when several ` +\n `patterns collapse onto the same box-to-box edge. Scroll/pinch or drag to pan and zoom; use the ` +\n `<span class=\"mono\">+</span>/<span class=\"mono\">−</span>/<span class=\"mono\">⤢</span> controls in the corner.`\n\n // ── diagram render (theme-aware, offline-tolerant) ─────────────────\n async function loadMermaid() {\n if (mermaidMod) return mermaidMod\n const m = await import(\"https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs\")\n mermaidMod = m.default\n return mermaidMod\n }\n\n const renderSeqByBase = new Map()\n const nextSeq = (base) => {\n const n = (renderSeqByBase.get(base) || 0) + 1\n renderSeqByBase.set(base, n)\n return n\n }\n\n /** Render `src` into `host` via mermaid, guarding against a stale (superseded) render; falls back to a readable error + raw source on failure. Returns the mermaid render id used (so the caller can wire clicks/highlight against ITS OWN node ids), or `null` if nothing rendered. */\n async function renderInto(host, src, renderIdBase) {\n const seq = nextSeq(renderIdBase)\n let mermaid\n try {\n mermaid = await loadMermaid()\n } catch (e) {\n host.innerHTML =\n `<div class=\"dia-msg\">Could not load the Mermaid renderer (offline?). ` +\n `The workflow is still fully browsable below. Diagram source:` +\n `<pre class=\"dia-src\">${esc(src)}</pre></div>`\n return null\n }\n if (seq !== renderSeqByBase.get(renderIdBase)) return null\n const renderId = renderIdBase + seq\n try {\n const { svg } = await mermaid.render(renderId, src)\n if (seq !== renderSeqByBase.get(renderIdBase)) return null\n host.innerHTML = `<div class=\"mflow\">${svg}</div>`\n return renderId\n } catch (e) {\n host.innerHTML =\n `<div class=\"dia-msg\">Diagram render failed: ${esc(e.message)}. ` +\n `Diagram source:<pre class=\"dia-src\">${esc(src)}</pre></div>`\n return null\n }\n }\n\n // Every live diagram (main + one per sub-machine) — re-rendered as a\n // group on every theme toggle (replaces the old single DIAGRAM_SRC).\n // `build` is a thunk (not a fixed string) because the diagram source\n // embeds theme-dependent classDefs — it must be re-emitted fresh on\n // every re-render, not cached from first render.\n const diagrams = []\n function registerDiagram(host, build, renderIdBase, onRendered, highlight) {\n diagrams.push({ host, build, renderIdBase, onRendered, highlight, lastRenderId: null })\n }\n async function renderAllDiagrams() {\n for (const d of diagrams) {\n const renderId = await renderInto(d.host, d.build(), d.renderIdBase)\n if (renderId) d.lastRenderId = renderId\n if (renderId && d.onRendered) d.onRendered(renderId, d.host)\n }\n }\n async function reinitializeAndRenderAll() {\n const mermaid = await loadMermaid().catch(() => null)\n if (mermaid) {\n const dark = isDark()\n mermaid.initialize({\n startOnLoad: false,\n theme: \"base\",\n securityLevel: \"strict\",\n flowchart: { useMaxWidth: false, htmlLabels: true },\n themeVariables: {\n fontFamily: \"ui-monospace, Menlo, monospace\",\n fontSize: \"12.5px\",\n lineColor: dark ? \"#6b7690\" : \"#8a93a6\",\n primaryBorderColor: dark ? \"#3a465c\" : \"#c2ccdb\",\n tertiaryColor: dark ? \"#1c2434\" : \"#f0f3f8\",\n mainBkg: dark ? \"#232c3e\" : \"#ffffff\",\n clusterBkg: dark ? \"#1c2434\" : \"#f0f3f8\",\n clusterBorder: dark ? \"#3a465c\" : \"#c2ccdb\",\n titleColor: dark ? \"#e6ebf4\" : \"#1a2233\",\n nodeTextColor: dark ? \"#e6ebf4\" : \"#1a2233\",\n textColor: dark ? \"#e6ebf4\" : \"#1a2233\",\n edgeLabelBackground: dark ? \"#161d2b\" : \"#f4f6fa\",\n },\n })\n }\n await renderAllDiagrams()\n }\n\n // ── click wiring + current-state highlight (post-render — mermaid's own\n // `click` callbacks are disabled by securityLevel:\"strict\") ──────────\n // A rendered node group's `id` is `${renderId}-flowchart-${ourNodeId}-<n>`\n // (verified against mermaid@11's actual output) — strip that wrapper to\n // recover the id we generated the diagram with.\n function nodeIdOf(el, renderId) {\n const prefix = renderId + \"-flowchart-\"\n if (!el.id || !el.id.startsWith(prefix)) return null\n const m = /^(.+)-\\d+$/.exec(el.id.slice(prefix.length))\n return m ? m[1] : null\n }\n const nodeGroupsOf = (host) => [...host.querySelectorAll(\"g.node\")]\n\n function scrollToSubmachine(i) {\n const el = document.getElementById(\"sm-\" + i)\n if (!el) return\n el.open = true\n el.scrollIntoView({ behavior: \"smooth\", block: \"start\" })\n }\n function navigateTo(name) {\n const s = byName[name]\n if (s && s.group) {\n const gi = groupIndexOf(s.group)\n if (gi >= 0) return scrollToSubmachine(gi)\n }\n openDrawer(name)\n }\n\n function applyCurrentHighlight(renderId, host, mode, gi) {\n if (!CURRENT || !CURRENT.state) return\n const wantId =\n mode === \"main\"\n ? CURRENT.group\n ? groupBoxId(groupIndexOf(CURRENT.group))\n : idFor(CURRENT.state)\n : CURRENT.group === MODEL.groups[gi].name\n ? idFor(CURRENT.state)\n : null\n if (wantId === null) return\n nodeGroupsOf(host).forEach((el) => {\n if (nodeIdOf(el, renderId) === wantId) el.classList.add(\"gtd-current\")\n })\n }\n\n // Re-applies the current-state highlight to every already-rendered\n // diagram, without re-running mermaid — used by the poll loop below.\n function repaintHighlights() {\n for (const d of diagrams) {\n nodeGroupsOf(d.host).forEach((el) => el.classList.remove(\"gtd-current\"))\n if (d.lastRenderId && d.highlight) {\n applyCurrentHighlight(d.lastRenderId, d.host, d.highlight.mode, d.highlight.gi)\n }\n }\n }\n\n function wireMainDiagramClicks(renderId, host) {\n nodeGroupsOf(host).forEach((el) => {\n const rawId = nodeIdOf(el, renderId)\n if (!rawId) return\n el.classList.add(\"clickable\")\n el.style.cursor = \"pointer\"\n if (rawId[0] === \"g\") {\n el.onclick = () => scrollToSubmachine(Number(rawId.slice(1)))\n } else {\n const name = idFor.nameOf(rawId)\n if (name) el.onclick = () => openDrawer(name)\n }\n })\n applyCurrentHighlight(renderId, host, \"main\")\n attachPanZoom(host)\n }\n\n function wireSubmachineDiagramClicks(renderId, host, gi) {\n nodeGroupsOf(host).forEach((el) => {\n const rawId = nodeIdOf(el, renderId)\n if (!rawId) return\n el.classList.add(\"clickable\")\n el.style.cursor = \"pointer\"\n if (rawId[0] === \"g\") {\n el.onclick = () => scrollToSubmachine(Number(rawId.slice(1)))\n } else if (rawId[0] === \"e\") {\n const name = idFor.nameOf(rawId.slice(1))\n if (name) el.onclick = () => navigateTo(name)\n } else {\n const name = idFor.nameOf(rawId)\n if (name) el.onclick = () => openDrawer(name)\n }\n })\n applyCurrentHighlight(renderId, host, \"submachine\", gi)\n attachPanZoom(host)\n }\n\n // ── diagram pan & zoom (hand-rolled — no pan-zoom dependency, see plan) ──\n // `host` is a `.dia-scroll` element whose content mermaid just replaced\n // wholesale (`renderInto`'s `host.innerHTML = ...`), so old listeners on\n // its previous children are already gone — this re-creates the control\n // cluster and re-attaches fresh listeners on every render, never\n // accumulating stale ones.\n const REDUCED_MOTION = matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n const clampScale = (s) => Math.min(3, Math.max(0.4, s))\n\n function attachPanZoom(host) {\n const flow = host.querySelector(\".mflow\")\n const svg = flow && flow.querySelector(\"svg\")\n if (!flow || !svg) return\n\n const controls = document.createElement(\"div\")\n controls.className = \"pz-controls\"\n controls.innerHTML =\n `<button class=\"pz-btn\" data-act=\"in\" title=\"Zoom in\" aria-label=\"Zoom in\">+</button>` +\n `<button class=\"pz-btn\" data-act=\"out\" title=\"Zoom out\" aria-label=\"Zoom out\">−</button>` +\n `<button class=\"pz-btn\" data-act=\"fit\" title=\"Fit to width\" aria-label=\"Fit to width\">⤢</button>`\n host.appendChild(controls)\n\n const box = svg.getBBox ? svg.getBBox() : { width: 0, height: 0 }\n const naturalWidth = box.width || svg.clientWidth || 1\n const naturalHeight = box.height || svg.clientHeight || 1\n\n const state = { scale: 1, tx: 0, ty: 0 }\n const apply = () => {\n flow.style.transform = `translate(${state.tx}px, ${state.ty}px) scale(${state.scale})`\n }\n // Fit-to-width, capped at 1× so a small workflow renders at its\n // natural size (the pre-pan/zoom default) instead of being blown up.\n const fit = () => {\n const r = host.getBoundingClientRect()\n state.scale = clampScale(Math.min(1, Math.max(r.width - 32, 40) / naturalWidth))\n state.tx = (r.width - naturalWidth * state.scale) / 2\n state.ty = (r.height - naturalHeight * state.scale) / 2\n apply()\n }\n const zoomBy = (factor, cx, cy) => {\n const r = host.getBoundingClientRect()\n const px = cx == null ? r.width / 2 : cx\n const py = cy == null ? r.height / 2 : cy\n const next = clampScale(state.scale * factor)\n const ratio = next / state.scale\n state.tx = px - (px - state.tx) * ratio\n state.ty = py - (py - state.ty) * ratio\n state.scale = next\n apply()\n }\n // Animate only a button-triggered zoom/fit (an explicit, discrete\n // action) — wheel/drag stay instant so the transform tracks the\n // cursor/finger with no lag. Skipped entirely under\n // prefers-reduced-motion.\n const animated = (fn) => {\n if (REDUCED_MOTION) return fn()\n flow.style.transition = \"transform 0.18s ease\"\n fn()\n flow.addEventListener(\"transitionend\", () => (flow.style.transition = \"\"), {\n once: true,\n })\n }\n fit()\n\n host.addEventListener(\n \"wheel\",\n (e) => {\n e.preventDefault()\n flow.style.transition = \"none\"\n const r = host.getBoundingClientRect()\n zoomBy(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - r.left, e.clientY - r.top)\n },\n { passive: false },\n )\n\n // Drag-to-pan: a small movement threshold before a pointerdown counts\n // as a drag (rather than a click), and — once it does — the resulting\n // synthetic `click` is swallowed so dragging over a node never opens\n // its drawer. Pointer capture is deferred until that threshold is\n // actually crossed: a captured pointer retargets its WHOLE mouse-event\n // sequence (including the eventual `click`) to `host`, so capturing\n // eagerly on every pointerdown would silently break a plain click on\n // any node (or the zoom/fit buttons) — not just an actual drag.\n let drag = null\n host.addEventListener(\"pointerdown\", (e) => {\n if (e.button !== 0 || e.target.closest(\".pz-controls\")) return\n drag = { x: e.clientX, y: e.clientY, tx: state.tx, ty: state.ty, moved: false }\n flow.style.transition = \"none\"\n })\n host.addEventListener(\"pointermove\", (e) => {\n if (!drag) return\n const dx = e.clientX - drag.x\n const dy = e.clientY - drag.y\n if (!drag.moved && Math.hypot(dx, dy) > 4) {\n drag.moved = true\n host.classList.add(\"pz-dragging\")\n host.setPointerCapture(e.pointerId)\n }\n if (drag.moved) {\n state.tx = drag.tx + dx\n state.ty = drag.ty + dy\n apply()\n }\n })\n const endDrag = () => {\n if (!drag) return\n if (drag.moved) {\n host.classList.remove(\"pz-dragging\")\n host.addEventListener(\n \"click\",\n (ev) => {\n ev.stopPropagation()\n ev.preventDefault()\n },\n { capture: true, once: true },\n )\n }\n drag = null\n }\n host.addEventListener(\"pointerup\", endDrag)\n host.addEventListener(\"pointercancel\", endDrag)\n\n controls.querySelector('[data-act=\"in\"]').onclick = (e) => {\n e.stopPropagation()\n animated(() => zoomBy(1.25))\n }\n controls.querySelector('[data-act=\"out\"]').onclick = (e) => {\n e.stopPropagation()\n animated(() => zoomBy(1 / 1.25))\n }\n controls.querySelector('[data-act=\"fit\"]').onclick = (e) => {\n e.stopPropagation()\n animated(fit)\n }\n\n // Exposed so a collapsed sub-machine `<details>` panel can re-fit once\n // it's actually opened (its content renders at zero width while hidden).\n host.__pz = { fit, zoomBy }\n }\n\n // ── main flow ────────────────────────────────────────────────────────\n registerDiagram(\n document.getElementById(\"diaScroll\"),\n () => buildFlowSrc(STATES, {}),\n \"gtdmain\",\n wireMainDiagramClicks,\n { mode: \"main\" },\n )\n\n // ── sub-machines panel — one real mermaid diagram per invocation ─────\n ;(function renderSubmachines() {\n const el = document.getElementById(\"smSection\")\n const groups = MODEL.groups || []\n if (!groups.length) {\n el.innerHTML = `<p class=\"muted-note\">This workflow declares no sub-machines.</p>`\n return\n }\n el.innerHTML = \"\"\n groups.forEach((g, i) => {\n const card = document.createElement(\"details\")\n card.className = \"dia-card sm-card\"\n card.id = \"sm-\" + i\n card.style.marginBottom = \"16px\"\n if (CURRENT && CURRENT.group === g.name) card.open = true\n card.innerHTML =\n `<summary class=\"h\"><span class=\"nm\">${esc(g.name)} · instantiates <b>${esc(g.submachine)}</b></span></summary>` +\n `<div class=\"dia-scroll\" id=\"smDia${i}\"><div class=\"dia-msg\">Rendering diagram…</div></div>`\n el.appendChild(card)\n\n const members = (g.states || []).map((n) => byName[n]).filter(Boolean)\n registerDiagram(\n card.querySelector(`#smDia${i}`),\n () => buildFlowSrc(members, { scope: g.name }),\n \"gtdsm\" + i + \"-\",\n (renderId, host) => wireSubmachineDiagramClicks(renderId, host, i),\n { mode: \"submachine\", gi: i },\n )\n // A collapsed <details> renders its content at zero width, so the\n // pan/zoom \"fit\" computed while hidden is wrong — recompute it once\n // the panel is actually opened.\n card.addEventListener(\"toggle\", () => {\n if (!card.open) return\n const host = card.querySelector(`#smDia${i}`)\n if (host && host.__pz) host.__pz.fit()\n })\n })\n })()\n\n // ── vars ─────────────────────────────────────────────────────────────\n ;(function renderVars() {\n const vars = MODEL.vars || {}\n const keys = Object.keys(vars)\n if (!keys.length) return\n const el = document.getElementById(\"varsSection\")\n el.innerHTML =\n `<div class=\"sec-head\"><h2>Workflow variables</h2><span class=\"rule\"></span></div>` +\n `<div class=\"vars-grid\">${keys\n .map((k) => `<span class=\"var-chip\"><b>${esc(k)}</b> = ${esc(vars[k])}</span>`)\n .join(\"\")}</div>`\n })()\n\n // ── current-state panel ─────────────────────────────────────────────\n // Status-letter colour reuses existing actor tokens rather than adding new\n // theme variables: A (added) reads as the check colour, M (modified) as\n // the accent, D (deleted) as the engine/commit colour.\n const PENDING_STATUS_COLOR = {\n A: \"var(--check-fg)\",\n M: \"var(--accent)\",\n D: \"var(--engine-fg)\",\n }\n function pendingListHtml(pending) {\n if (!pending || !pending.length) {\n return `<p class=\"pending-clean\">working tree clean</p>`\n }\n return (\n `<div class=\"pending-list\">` +\n pending\n .map(\n (c) =>\n `<div class=\"pc\"><span class=\"st\" style=\"color:${PENDING_STATUS_COLOR[c.status] || \"var(--text-dim)\"}\">${esc(c.status)}</span><span class=\"path\">${esc(c.path)}</span></div>`,\n )\n .join(\"\") +\n `</div>`\n )\n }\n function renderCurrentPanel() {\n const panel = document.getElementById(\"currentPanel\")\n if (!CURRENT || !CURRENT.state) {\n panel.classList.add(\"hidden\")\n return\n }\n panel.classList.remove(\"hidden\")\n const a = CURRENT.actor\n const edgeRow = (pattern, to, matched, retryOf) => {\n const t = byName[to]\n return (\n `<div class=\"edge${matched ? \" cur-matched\" : \"\"}\"><div class=\"er\">` +\n `<span class=\"pat\">${retryOf ? \"after \" + esc(retryOf) + \" tries\" : \"if \" + esc(pattern)}</span>` +\n `<span class=\"arr\">→</span>` +\n `<button class=\"goto\" data-actor=\"${t ? actorOf(t) : \"unknown\"}\" data-to=\"${esc(to)}\">${esc(to)}</button>` +\n `${matched ? '<span class=\"cur-tag\">current match</span>' : \"\"}</div></div>`\n )\n }\n const rows = (CURRENT.edges || []).map((e) => edgeRow(e.pattern, e.to, e.matched)).join(\"\")\n const retryRow = CURRENT.retry\n ? edgeRow(null, CURRENT.retry.otherwise, false, CURRENT.retry.max)\n : \"\"\n panel.innerHTML =\n `<div class=\"cur-head\"><span class=\"cur-name\">${esc(CURRENT.state)}</span>` +\n `${pill(a)}<span class=\"kind\">${esc(CURRENT.kind)}</span>` +\n `${CURRENT.group ? `<span class=\"flow-tag\">${esc(CURRENT.group)}</span>` : \"\"}</div>` +\n `<div style=\"margin-top:14px\">${pendingListHtml(CURRENT.pending)}</div>` +\n `<div class=\"edges\">${\n rows || retryRow ? rows + retryRow : '<p class=\"none\">No outgoing edges declared.</p>'\n }</div>`\n panel.querySelector(\".cur-head\").onclick = () => navigateTo(CURRENT.state)\n panel.querySelectorAll(\".goto\").forEach((b) => (b.onclick = () => navigateTo(b.dataset.to)))\n }\n renderCurrentPanel()\n\n reinitializeAndRenderAll()\n\n // ── live current-state polling ──────────────────────────────────────\n // Keeps the panel + diagram highlight in sync with an in-progress\n // process without a manual page refresh. Skipped while the tab is\n // hidden; becoming visible again triggers an immediate refresh. No\n // in-flight guard — a slow tick just means the next one also awaits.\n async function poll() {\n if (document.hidden) return\n const changed = await refreshCurrent()\n if (changed) {\n renderCurrentPanel()\n repaintHighlights()\n }\n }\n setInterval(poll, 3000)\n document.addEventListener(\"visibilitychange\", () => {\n if (!document.hidden) poll()\n })\n\n // ── kind chips + actor legend ──────────────────────────────────────\n const kindChipsEl = document.getElementById(\"kindChips\")\n const presentKinds = [\"all\", ...Array.from(new Set(STATES.map((s) => s.kind)))]\n presentKinds.forEach((k) => {\n const b = document.createElement(\"button\")\n b.className = \"chip-btn\" + (k === \"all\" ? \" on\" : \"\")\n b.textContent = k\n b.dataset.kind = k\n b.onclick = () => {\n kindFilter = k\n ;[...kindChipsEl.children].forEach((c) =>\n c.classList.toggle(\"on\", c.dataset.kind === kindFilter),\n )\n renderCatalogue()\n }\n kindChipsEl.appendChild(b)\n })\n\n const legendEl = document.getElementById(\"legend\")\n ;[\"human\", \"agent\", \"check\", \"commit\", \"unknown\"].forEach((a) => {\n if (!counts[a]) return\n const el = document.createElement(\"button\")\n el.className = \"li\"\n el.dataset.actor = a\n el.setAttribute(\"aria-pressed\", \"true\")\n el.innerHTML = `<span class=\"dot\" style=\"background:var(--${meta(a).dot}-dot)\"></span>${meta(a).label}`\n el.onclick = () => {\n const on = actorFilter.has(a)\n on ? actorFilter.delete(a) : actorFilter.add(a)\n el.classList.toggle(\"off\", on)\n el.setAttribute(\"aria-pressed\", String(!on))\n renderCatalogue()\n }\n legendEl.appendChild(el)\n })\n\n // ── catalogue ──────────────────────────────────────────────────────\n const groupsEl = document.getElementById(\"groups\")\n function stateMatches(s, q) {\n if (!actorFilter.has(actorOf(s))) return false\n if (kindFilter !== \"all\" && s.kind !== kindFilter) return false\n if (!q) return true\n const hay = [\n s.name,\n s.actor,\n s.kind,\n s.file,\n s.mode,\n s.model,\n s.memory,\n (s.flags || []).join(\" \"),\n ]\n .filter(Boolean)\n .join(\" \")\n .toLowerCase()\n return hay.includes(q)\n }\n function cardFor(s) {\n const a = actorOf(s)\n const card = document.createElement(\"button\")\n card.className = \"card\"\n card.dataset.actor = a\n const flags = (s.flags || [])\n .filter((f) => f !== \"initial\")\n .map((f) => `<span class=\"flag\">${esc(f)}</span>`)\n .join(\"\")\n const retry = s.retry ? `<span class=\"flag\">retry ${s.retry.max}</span>` : \"\"\n const initTick = s.initial ? `<div class=\"init-tick\">initial</div>` : \"\"\n card.innerHTML =\n `${initTick}<div class=\"name\">${esc(s.name)}</div>` +\n `<div class=\"row\">${pill(a)}<span class=\"kind\">${esc(s.kind)}</span>${retry}${flags}</div>`\n card.onclick = () => openDrawer(s.name)\n return card\n }\n function renderCatalogue() {\n const q = document.getElementById(\"search\").value.trim().toLowerCase()\n groupsEl.innerHTML = \"\"\n const sections = []\n ;(MODEL.groups || []).forEach((g) => {\n sections.push({\n label: g.name,\n flow: g.submachine,\n items: STATES.filter((s) => s.group === g.name),\n })\n })\n const ungrouped = STATES.filter((s) => !s.group)\n if (ungrouped.length) sections.push({ label: \"Ungrouped\", flow: \"\", items: ungrouped })\n\n let shown = 0\n sections.forEach((sec) => {\n const items = sec.items.filter((s) => stateMatches(s, q))\n if (!items.length) return\n shown += items.length\n const wrap = document.createElement(\"div\")\n wrap.className = \"group\"\n wrap.innerHTML =\n `<div class=\"group-head\">${sec.flow ? `<span class=\"flow-tag\">${esc(sec.flow)}</span>` : \"\"}` +\n `<h3>${esc(sec.label)}</h3></div>`\n const grid = document.createElement(\"div\")\n grid.className = \"grid\"\n items.forEach((s) => grid.appendChild(cardFor(s)))\n wrap.appendChild(grid)\n groupsEl.appendChild(wrap)\n })\n if (!shown)\n groupsEl.innerHTML = `<p class=\"none\" style=\"padding:20px 0\">No states match this filter.</p>`\n }\n document.getElementById(\"search\").addEventListener(\"input\", renderCatalogue)\n renderCatalogue()\n\n // ── drawer ─────────────────────────────────────────────────────────\n const drawer = document.getElementById(\"drawer\")\n const backdrop = document.getElementById(\"backdrop\")\n let lastFocus = null\n\n function openDrawer(name) {\n const s = byName[name]\n if (!s) return\n const a = actorOf(s)\n if (!drawer.classList.contains(\"open\")) lastFocus = document.activeElement\n\n const kv = []\n kv.push([\"actor\", pill(a)])\n kv.push([\"kind\", `<span class=\"kind\">${esc(s.kind)}</span>`])\n kv.push([\"model\", s.model ? esc(s.model) : '<span class=\"none\">—</span>'])\n kv.push([\"memory\", s.memory ? esc(s.memory) : '<span class=\"none\">—</span>'])\n kv.push([\"file\", s.file ? esc(s.file) : '<span class=\"none\">—</span>'])\n kv.push([\"mode\", s.mode ? esc(s.mode) : '<span class=\"none\">—</span>'])\n if (s.retry)\n kv.push([\n \"retry\",\n `max ${s.retry.max} → <span style=\"color:var(--accent)\">${esc(s.retry.otherwise)}</span>`,\n ])\n if (s.group) kv.push([\"group\", esc(s.group)])\n\n const contentBlock = s.content\n ? `<div class=\"field\"><div class=\"k\">content — ${esc(s.kind)}</div><pre class=\"dia-src state-content\">${esc(s.content)}</pre></div>`\n : \"\"\n\n const flags = s.flags || []\n const flagBlock = flags.length\n ? `<div class=\"field\"><div class=\"k\">flags & gates</div><div class=\"flag-list\">${flags\n .map(\n (f) =>\n `<div class=\"flag-item\"><span class=\"fn\">${esc(f)}</span><span class=\"fd\">${esc(FLAGS[f] || \"\")}</span></div>`,\n )\n .join(\"\")}</div></div>`\n : \"\"\n\n const edges = s.on || []\n const edgesBlock = edges.length\n ? `<div class=\"field\"><div class=\"k\">outgoing edges — on (first match wins)</div><div class=\"edges\">${edges\n .map((e) => {\n const t = byName[e.to]\n const ta = t ? actorOf(t) : \"unknown\"\n return (\n `<div class=\"edge\"><div class=\"er\"><span class=\"pat\">${esc(e.pattern)}</span><span class=\"arr\">→</span>` +\n `<button class=\"goto\" data-actor=\"${ta}\" data-to=\"${esc(e.to)}\">${esc(e.to)}</button></div>` +\n `${e.describe ? `<div class=\"em\">${esc(e.describe)}</div>` : \"\"}</div>`\n )\n })\n .join(\"\")}</div></div>`\n : `<div class=\"field\"><div class=\"k\">outgoing edges</div><p class=\"none\">${\n s.kind === \"commit\"\n ? \"Commit state — no outgoing edges. Entering it ends the process in one squash.\"\n : \"No outgoing edges declared.\"\n }</p></div>`\n\n const retryOut =\n s.retry && byName[s.retry.otherwise]\n ? `<div class=\"field\"><div class=\"k\">retry redirect</div><div class=\"edge\"><div class=\"er\">` +\n `<span class=\"pat\">after ${esc(s.retry.max)} tries</span><span class=\"arr\">→</span>` +\n `<button class=\"goto\" data-actor=\"${actorOf(byName[s.retry.otherwise])}\" data-to=\"${esc(s.retry.otherwise)}\">${esc(s.retry.otherwise)}</button>` +\n `</div></div></div>`\n : \"\"\n\n const inc = s.incoming || []\n const incBlock =\n `<div class=\"field\"><div class=\"k\">routes in from (${inc.length})</div>` +\n (inc.length\n ? `<div class=\"incoming\">${inc\n .map((i) => {\n const f = byName[i.from]\n return `<button class=\"goto\" data-actor=\"${f ? actorOf(f) : \"unknown\"}\" data-to=\"${esc(i.from)}\" title=\"${esc(i.pattern)}\">${esc(i.from)}</button>`\n })\n .join(\"\")}</div>`\n : `<p class=\"none\">Reached only as an entry point (a CLI command enters it directly).</p>`) +\n `</div>`\n\n drawer.style.setProperty(\"--card-accent\", `var(--${meta(a).dot}-dot)`)\n drawer.innerHTML =\n `<div class=\"drawer-head\" style=\"--card-accent:var(--${meta(a).dot}-dot)\">` +\n `<div class=\"top\"><h3>${esc(s.name)}</h3>` +\n `<button class=\"close-x\" id=\"closeX\" aria-label=\"Close detail\">✕</button></div>` +\n `<div class=\"meta-row\">${pill(a)}<span class=\"kind\">${esc(s.kind)}</span>` +\n `${s.initial ? '<span class=\"flag\">initial</span>' : \"\"}` +\n `${s.retry ? `<span class=\"flag\">retry ${s.retry.max}</span>` : \"\"}` +\n `${flags\n .filter((f) => f !== \"initial\")\n .map((f) => `<span class=\"flag\">${esc(f)}</span>`)\n .join(\"\")}</div></div>` +\n `<div class=\"drawer-body\">` +\n `<div class=\"field\"><div class=\"k\">definition</div><dl class=\"kv\">${kv\n .map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`)\n .join(\"\")}</dl></div>` +\n contentBlock +\n flagBlock +\n edgesBlock +\n retryOut +\n incBlock +\n `</div>`\n\n drawer\n .querySelectorAll(\".goto\")\n .forEach((b) => (b.onclick = () => openDrawer(b.dataset.to)))\n drawer.querySelector(\"#closeX\").onclick = closeDrawer\n\n backdrop.classList.add(\"open\")\n drawer.classList.add(\"open\")\n drawer.querySelector(\".drawer-body\").scrollTop = 0\n drawer.querySelector(\"#closeX\").focus()\n }\n function closeDrawer() {\n backdrop.classList.remove(\"open\")\n drawer.classList.remove(\"open\")\n if (lastFocus && lastFocus.focus) lastFocus.focus()\n }\n backdrop.onclick = closeDrawer\n document.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Escape\" && drawer.classList.contains(\"open\")) closeDrawer()\n })\n\n // expose for interactions\n window.__openState = openDrawer\n <\/script>\n </body>\n</html>\n";
|
|
205006
205055
|
//#endregion
|
|
205007
205056
|
//#region src/Visualize.ts
|
|
205008
205057
|
const FLAG_KEYS = [
|
|
@@ -205032,6 +205081,7 @@ const toVizState = (name, def, group, incoming) => stripUndefined({
|
|
|
205032
205081
|
name,
|
|
205033
205082
|
actor: def.actor,
|
|
205034
205083
|
kind: contentKindOf(def) ?? "unknown",
|
|
205084
|
+
content: contentOf(def),
|
|
205035
205085
|
initial: def.initial === true ? true : void 0,
|
|
205036
205086
|
model: def.model,
|
|
205037
205087
|
memory: def.memory,
|
|
@@ -205082,6 +205132,35 @@ const buildVizModel = (workflow, rawWorkflow, vars) => {
|
|
|
205082
205132
|
};
|
|
205083
205133
|
};
|
|
205084
205134
|
/**
|
|
205135
|
+
* Describe the currently-rested state for the viewer: its `on` edges each
|
|
205136
|
+
* flagged with whether it's the one that would fire on the CURRENT pending
|
|
205137
|
+
* changes (same first-match semantics `PatternMachine.step` decides a real
|
|
205138
|
+
* step with), plus the retry redirect and pending changes verbatim. `group`
|
|
205139
|
+
* is threaded in by the caller (already computed once by `buildVizModel`
|
|
205140
|
+
* from the same workflow) rather than recomputed here.
|
|
205141
|
+
*/
|
|
205142
|
+
const buildCurrentStateModel = (rest, changes, group) => {
|
|
205143
|
+
const onEdges = rest.stateDef.on ?? [];
|
|
205144
|
+
const matchedIndex = onEdges.findIndex(([patternStr]) => {
|
|
205145
|
+
const parsed = parsePattern(patternStr);
|
|
205146
|
+
return parsed !== void 0 && matchesPattern(parsed, changes);
|
|
205147
|
+
});
|
|
205148
|
+
const edges = onEdges.map(([pattern, to], i) => ({
|
|
205149
|
+
pattern,
|
|
205150
|
+
to,
|
|
205151
|
+
matched: i === matchedIndex
|
|
205152
|
+
}));
|
|
205153
|
+
return stripUndefined({
|
|
205154
|
+
state: rest.state,
|
|
205155
|
+
actor: rest.actor,
|
|
205156
|
+
kind: contentKindOf(rest.stateDef) ?? "unknown",
|
|
205157
|
+
group,
|
|
205158
|
+
edges,
|
|
205159
|
+
retry: rest.stateDef.retry,
|
|
205160
|
+
pending: changes
|
|
205161
|
+
});
|
|
205162
|
+
};
|
|
205163
|
+
/**
|
|
205085
205164
|
* Route one request (pure): `/` (or `/index.html`) serves the viewer page,
|
|
205086
205165
|
* `/workflow.json` serves the model, anything else is a 404. Kept separate from
|
|
205087
205166
|
* the server so it unit-tests without a socket.
|
|
@@ -205107,10 +205186,25 @@ const handleVizRequest = (pathname, model) => {
|
|
|
205107
205186
|
* Start the viewer's HTTP server, resolving once it is listening with the
|
|
205108
205187
|
* chosen URL (a `port` of `0` picks a free port). Rejects if the port is in
|
|
205109
205188
|
* use. The caller owns the returned `server` (closes it on shutdown).
|
|
205189
|
+
*
|
|
205190
|
+
* `resolveCurrent`, when given, backs an extra `/state.json` route serving
|
|
205191
|
+
* where the active process rests right now (resolved fresh on every request
|
|
205192
|
+
* — there's no polling on the browser side, just a single fetch at page
|
|
205193
|
+
* load). It resolves to `null` when there's no active process to report
|
|
205194
|
+
* (not a repo, no commits) — served as `{}`. This route is handled INLINE
|
|
205195
|
+
* (not through `handleVizRequest`) because it's async and per-request, unlike
|
|
205196
|
+
* the static routes `handleVizRequest` serves synchronously from a fixed model.
|
|
205110
205197
|
*/
|
|
205111
|
-
const startVizServer = (model, port, host = "127.0.0.1") => new Promise((resolve, reject) => {
|
|
205198
|
+
const startVizServer = (model, port, host = "127.0.0.1", resolveCurrent) => new Promise((resolve, reject) => {
|
|
205112
205199
|
const server = createServer((req, res) => {
|
|
205113
205200
|
const { pathname } = new URL(req.url ?? "/", "http://localhost");
|
|
205201
|
+
if (pathname === "/state.json") {
|
|
205202
|
+
Promise.resolve(resolveCurrent ? resolveCurrent() : null).then((current) => {
|
|
205203
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
205204
|
+
res.end(JSON.stringify(current ?? {}));
|
|
205205
|
+
});
|
|
205206
|
+
return;
|
|
205207
|
+
}
|
|
205114
205208
|
const { status, contentType, body } = handleVizRequest(pathname, model);
|
|
205115
205209
|
res.writeHead(status, { "content-type": contentType });
|
|
205116
205210
|
res.end(body);
|
|
@@ -205257,6 +205351,11 @@ const GTD_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
|
205257
205351
|
const HELP_TEXT = `Usage: gtd [command] [options]
|
|
205258
205352
|
|
|
205259
205353
|
Commands:
|
|
205354
|
+
(no command), loop
|
|
205355
|
+
Launch the loop driver (bin/gtd), which repeatedly drives
|
|
205356
|
+
an agent through gtd next/gtd step calls until the
|
|
205357
|
+
workflow returns to its initial state again. A bare gtd
|
|
205358
|
+
invocation and gtd loop both launch it identically
|
|
205260
205359
|
init Scaffold a minimal .gtdrc.json for this repo, seeding the
|
|
205261
205360
|
default variables you are most likely to change (the test
|
|
205262
205361
|
command) and a Prettier formatting suggestion. gtd runs its
|
|
@@ -206084,12 +206183,33 @@ const parseVisualizeOptions = (rest) => {
|
|
|
206084
206183
|
};
|
|
206085
206184
|
};
|
|
206086
206185
|
/**
|
|
206186
|
+
* Best-effort resolution of the currently-rested state for the viewer's
|
|
206187
|
+
* `/state.json` route: prefers the review checkout window's saved head
|
|
206188
|
+
* (`REVIEW_HEAD_REF`) over HEAD itself, since `gtd visualize` is dispatched
|
|
206189
|
+
* BEFORE the review-window bracket and a request landing mid-window would
|
|
206190
|
+
* otherwise read a HEAD that's been temporarily rewound (see
|
|
206191
|
+
* `src/ReviewWindow.ts`). Any failure (not a repo, no commits, resolves to
|
|
206192
|
+
* the initial state with no process underway) is swallowed to `null` — the
|
|
206193
|
+
* browser just hides the panel.
|
|
206194
|
+
*/
|
|
206195
|
+
const computeCurrentState = (model) => gen(function* () {
|
|
206196
|
+
const git = yield* GitService;
|
|
206197
|
+
const reviewHead = yield* git.readRefOption(REVIEW_HEAD_REF);
|
|
206198
|
+
const currentRest = yield* resolveRest(getOrUndefined(reviewHead));
|
|
206199
|
+
const changes = yield* pendingChanges(git);
|
|
206200
|
+
const group = model.states.find((s) => s.name === currentRest.state)?.group;
|
|
206201
|
+
return buildCurrentStateModel(currentRest, changes, group);
|
|
206202
|
+
});
|
|
206203
|
+
/**
|
|
206087
206204
|
* `gtd visualize`: serve an interactive diagram of the ACTIVE workflow on a
|
|
206088
206205
|
* local HTTP server (see `src/Visualize.ts`). Dispatched early (like `lsp`/
|
|
206089
206206
|
* `init`) — it reads the config but never touches git, HEAD, or the review
|
|
206090
|
-
* window
|
|
206091
|
-
*
|
|
206092
|
-
*
|
|
206207
|
+
* window ITSELF (though its `/state.json` route best-effort reads git state
|
|
206208
|
+
* per request, see `computeCurrentState`) — and parses its OWN options
|
|
206209
|
+
* (`--port`, `--no-open`), since the global unknown-option check does not
|
|
206210
|
+
* know them. `--json` prints the model and exits without starting a server
|
|
206211
|
+
* (the testable path; live state is a server-only concern, so `--json`'s
|
|
206212
|
+
* shape is unchanged).
|
|
206093
206213
|
*/
|
|
206094
206214
|
const runVisualizeCommand = (argv, json, write) => gen(function* () {
|
|
206095
206215
|
const tokens = argv.slice(2);
|
|
@@ -206106,8 +206226,10 @@ const runVisualizeCommand = (argv, json, write) => gen(function* () {
|
|
|
206106
206226
|
write(JSON.stringify(model, null, 2) + "\n");
|
|
206107
206227
|
return;
|
|
206108
206228
|
}
|
|
206229
|
+
const runtime$4 = yield* runtime();
|
|
206230
|
+
const resolveCurrent = () => runPromise(runtime$4)(computeCurrentState(model).pipe(either)).then(getOrNull);
|
|
206109
206231
|
const { server, url } = yield* tryPromise({
|
|
206110
|
-
try: () => startVizServer(model, opts.port),
|
|
206232
|
+
try: () => startVizServer(model, opts.port, "127.0.0.1", resolveCurrent),
|
|
206111
206233
|
catch: (e) => /* @__PURE__ */ new Error(`gtd visualize: could not start server: ${e instanceof Error ? e.message : String(e)}`)
|
|
206112
206234
|
});
|
|
206113
206235
|
write(`gtd visualize running at ${url} — Ctrl-C to stop\n`);
|
package/docs/cli.md
CHANGED
|
@@ -4,12 +4,19 @@
|
|
|
4
4
|
Usage: gtd [command] [options]
|
|
5
5
|
|
|
6
6
|
Commands:
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
(no command), loop
|
|
8
|
+
Launch the loop driver (bin/gtd), which repeatedly drives
|
|
9
|
+
an agent through gtd next/gtd step calls until the
|
|
10
|
+
workflow returns to its initial state again. A bare gtd
|
|
11
|
+
invocation and gtd loop both launch it identically
|
|
12
|
+
init Scaffold a minimal .gtdrc.json for this repo, seeding the
|
|
13
|
+
default variables you are most likely to change (the test
|
|
14
|
+
command) and a Prettier formatting suggestion. gtd runs its
|
|
15
|
+
built-in workflow by default, so no workflow is written —
|
|
16
|
+
add a workflow: key only to customize the machine itself.
|
|
17
|
+
Takes no argument. Run once per repo; refuses if a gtd
|
|
18
|
+
config already exists. Leaves the file uncommitted for you
|
|
19
|
+
to review and commit
|
|
13
20
|
step <actor> Authenticate as <actor>, match the resolved rest's
|
|
14
21
|
declared patterns against the pending changes, and commit
|
|
15
22
|
(or squash) the one resulting transition. Pass
|
|
@@ -44,7 +51,9 @@ Commands:
|
|
|
44
51
|
declares, with its mode's commands (its file:/mode:);
|
|
45
52
|
exits non-zero with the findings when it is invalid
|
|
46
53
|
lsp Start the LSP server for .gtd/ steering files (stdio)
|
|
47
|
-
visualize Serve an interactive diagram of the active workflow
|
|
54
|
+
visualize Serve an interactive diagram of the active workflow on a
|
|
55
|
+
local web server (--port <n>, --no-open; --json prints the
|
|
56
|
+
model and exits)
|
|
48
57
|
version Print version and exit
|
|
49
58
|
help Print this help and exit
|
|
50
59
|
|
|
@@ -61,9 +70,11 @@ Options:
|
|
|
61
70
|
`--version` (`-v`) / `gtd version` and `--help` (`-h`) / `gtd help`
|
|
62
71
|
short-circuit before any git or repository-state work — they run outside a repo
|
|
63
72
|
and in any repo state (the bare subcommands are exact equivalents of their flag
|
|
64
|
-
forms). Bare `gtd` (no subcommand)
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
forms). Bare `gtd` (no subcommand) and `gtd loop` both launch the loop driver
|
|
74
|
+
(`bin/gtd`) immediately — neither is a usage error. Any other, truly unknown
|
|
75
|
+
subcommand remains a usage error: it prints the help text and exits 1 without
|
|
76
|
+
touching the repository. Every other (recognized) command must be run from the
|
|
77
|
+
**repository root** — gtd derives the workflow, pending changes, and process
|
|
67
78
|
history relative to cwd, so it refuses with a clear error if invoked from a
|
|
68
79
|
subdirectory.
|
|
69
80
|
|
|
@@ -401,7 +412,7 @@ which are JSON-only. `--json` emits
|
|
|
401
412
|
|
|
402
413
|
There is no `gtd` subcommand that executes a workflow script — gtd never runs a
|
|
403
414
|
workflow script itself. When `gtd next` resolves to a `script`-content rest, the
|
|
404
|
-
**driver** (`bin/gtd
|
|
415
|
+
**driver** (`bin/gtd`, or any loop harness) executes the emitted `content`
|
|
405
416
|
verbatim via `bash -c` — foreground, inherited stdio, exit code deliberately
|
|
406
417
|
ignored (a check script encodes its outcome in the tree, e.g. writing a findings
|
|
407
418
|
file, never in its exit status) — then runs `gtd step <actor>` for that state's
|
|
@@ -493,7 +504,7 @@ agent or human:
|
|
|
493
504
|
- **The producing agent self-validates.** Plain `gtd next` appends a "run
|
|
494
505
|
`gtd validate` and fix all violations" instruction to a `prompt` rest that
|
|
495
506
|
declares `file:`+`mode:`; `gtd next --json` withholds it and the driving loop
|
|
496
|
-
runs `gtd validate` after the turn instead (see `bin/gtd
|
|
507
|
+
runs `gtd validate` after the turn instead (see `bin/gtd` /
|
|
497
508
|
[STATES.md §12](../STATES.md#12-steering-file-validation-gtd-validate)).
|
|
498
509
|
- **`gtd step` enforces the same gate.** Capturing a turn out of a state that
|
|
499
510
|
declares `file:`+`mode:` formats that file in place and validates it first
|
|
@@ -543,9 +554,17 @@ cleanly.
|
|
|
543
554
|
## `gtd visualize [--port=<n>] [--no-open] [--json]`
|
|
544
555
|
|
|
545
556
|
Serve an interactive diagram of the ACTIVE workflow on a local web server: the
|
|
546
|
-
main flow as a graph (
|
|
547
|
-
|
|
548
|
-
|
|
557
|
+
main flow as a graph (each sub-machine invocation collapsed into a single opaque
|
|
558
|
+
black-box node — click it to jump to that sub-machine's own diagram, rendered
|
|
559
|
+
separately below with its true member states/shapes/colours and a muted ghost
|
|
560
|
+
node for any edge leaving the group; every diagram supports scroll-to-zoom,
|
|
561
|
+
drag-to-pan, and a corner `+`/`−`/fit control cluster), a click-through
|
|
562
|
+
inspector with each state's actor, content kind, its own raw
|
|
563
|
+
prompt/message/script text, model/memory, steering file+mode, retry, flags, and
|
|
564
|
+
outgoing/incoming edges, and — read ONCE at page load, never polled — a "Current
|
|
565
|
+
state" panel showing where the active process rests, its pending changes, and
|
|
566
|
+
which `on` pattern (or retry redirect) currently leads where, with the resting
|
|
567
|
+
node highlighted in the diagrams. This is the replacement for the removed
|
|
549
568
|
`gtd mermaid` — a live viewer instead of a static diagram dump.
|
|
550
569
|
|
|
551
570
|
```
|
|
@@ -553,25 +572,34 @@ $ gtd visualize
|
|
|
553
572
|
gtd visualize running at http://127.0.0.1:53017 — Ctrl-C to stop
|
|
554
573
|
```
|
|
555
574
|
|
|
556
|
-
The server serves
|
|
557
|
-
`/workflow.json` (the model the page renders)
|
|
575
|
+
The server serves three routes: `/` (the self-contained HTML page),
|
|
576
|
+
`/workflow.json` (the model the page renders), and `/state.json` (the current
|
|
577
|
+
process's resting state, or `{}` when there isn't one — not a repo, no commits,
|
|
578
|
+
or an older server; the browser tolerates either). It runs until interrupted
|
|
558
579
|
(Ctrl-C), then closes cleanly. Options (orthogonal, `gtd visualize` only):
|
|
559
580
|
|
|
560
581
|
- `--port=<n>` (or `--port <n>`) — serve on a specific port (0–65535); the
|
|
561
582
|
default is a free ephemeral port, printed on start.
|
|
562
583
|
- `--no-open` — do not open the default browser (the URL is always printed).
|
|
563
584
|
- `--json` — print the workflow model to stdout and exit WITHOUT starting a
|
|
564
|
-
server
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
585
|
+
server (unchanged shape — live state is a server-only concern). The model is
|
|
586
|
+
`{ states, initial, groups, vars }`; each state carries its
|
|
587
|
+
`actor`/`kind`/`content`/`model`/`memory`/`file`/`mode`/`retry`/`flags`
|
|
588
|
+
(`content` is the state's own raw script/prompt/message text, omitted for a
|
|
589
|
+
commit state), its `on` edges, its computed `incoming` edges, and its
|
|
590
|
+
sub-machine `group`. `groups` lists each sub-machine invocation and the
|
|
591
|
+
concrete states it produced.
|
|
568
592
|
|
|
569
593
|
Dispatched before the repository-root guard and the config-reading path's review
|
|
570
594
|
window — it reads the active workflow (the built-in default when none is
|
|
571
|
-
configured) but touches no git/HEAD/review-window state
|
|
572
|
-
|
|
573
|
-
a
|
|
574
|
-
|
|
595
|
+
configured) but touches no git/HEAD/review-window state ITSELF; the
|
|
596
|
+
`/state.json` route best-effort reads git state per request instead (any failure
|
|
597
|
+
— not a repo, no commits — serves `{}`), preferring the review checkout window's
|
|
598
|
+
saved head over HEAD so a request landing mid-window still reports the state the
|
|
599
|
+
process actually rests at. The diagram is rendered with Mermaid loaded from a
|
|
600
|
+
CDN, so the graph needs network access the first time a browser loads the page;
|
|
601
|
+
the inspector and current-state panel work offline regardless. Rejects unknown
|
|
602
|
+
options and unexpected positional arguments.
|
|
575
603
|
|
|
576
604
|
## Error envelope
|
|
577
605
|
|
package/docs/loop.md
CHANGED
|
@@ -57,9 +57,10 @@ gtd next --json # ask who's up and what they should do
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
See [`skills/loop/SKILL.md`](../skills/loop/SKILL.md) for the agent-facing
|
|
60
|
-
instructions that follow the same pinned contract. `gtd
|
|
61
|
-
|
|
62
|
-
that same script for anyone who doesn't want to drive the loop
|
|
60
|
+
instructions that follow the same pinned contract. `gtd` itself (bare, or with
|
|
61
|
+
`loop` as its first argument — see below) is the packaged, ready-to-run
|
|
62
|
+
implementation of that same script for anyone who doesn't want to drive the loop
|
|
63
|
+
by hand.
|
|
63
64
|
|
|
64
65
|
## The reference loop driver
|
|
65
66
|
|
|
@@ -68,17 +69,29 @@ A minimal bash implementation of the pinned protocol, driving an agent CLI (e.g.
|
|
|
68
69
|
reference for what a loop driver must do; keep any other implementation
|
|
69
70
|
(including `skills/loop/SKILL.md`) consistent with it rather than editing both
|
|
70
71
|
independently. Both open with the same move — capture the human's pending edit
|
|
71
|
-
when the machine rests at a `"message"` gate — so `gtd
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
72
|
+
when the machine rests at a `"message"` gate — so `gtd` is the only command a
|
|
73
|
+
human runs. `bin/gtd` is the packaged entry point: invoked bare, or with `loop`
|
|
74
|
+
as its first argument, it runs this exact script; any other first argument (e.g.
|
|
75
|
+
`next`, `step`, `status`) hands off to `node dist/gtd.bundle.mjs` instead. The
|
|
76
|
+
loop body it runs adds four things on top of the reference script above: it
|
|
77
|
+
stops with a diagnostic if the same `"prompt"` state/content repeat with no
|
|
78
|
+
progress (see `skills/loop/SKILL.md`'s "Stall detection"); it lets
|
|
79
|
+
`GTD_LOOP_AGENT_CMD` swap in any coding agent CLI in place of the default
|
|
80
|
+
`claude -p`, receiving the prompt via `$GTD_LOOP_PROMPT`; it exports the
|
|
81
|
+
resolved state's optional `model` hint as `$GTD_LOOP_MODEL`, appending
|
|
82
|
+
`--model "$GTD_LOOP_MODEL"` to the default `claude -p` invocation whenever it's
|
|
83
|
+
non-empty; and it acts on the optional `memory` scope hint (see "Agent memory
|
|
84
|
+
scope" below) — continuing one agent session across consecutive same-scope turns
|
|
85
|
+
and starting fresh when the scope changes.
|
|
86
|
+
|
|
87
|
+
`bin/gtd` resolves both its bundle hand-off and the loop's own internal `gtd`
|
|
88
|
+
calls against `dist/gtd.bundle.mjs` next to its own location. Set `GTD_BIN` — a
|
|
89
|
+
full command, not just a path — to point both at source instead, e.g. for
|
|
90
|
+
development or testing:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
GTD_BIN="node $PWD/dev/run.mjs" bin/gtd
|
|
94
|
+
```
|
|
82
95
|
|
|
83
96
|
```bash
|
|
84
97
|
#!/usr/bin/env bash
|
|
@@ -144,9 +157,9 @@ turn, or either side has no `memory` — start fresh.
|
|
|
144
157
|
This is what makes a loop retain memory while a phase boundary clears it: a loop
|
|
145
158
|
that keeps re-entering one state emits the same label every lap, so the agent
|
|
146
159
|
accumulates context across the loop; the move to the next phase's
|
|
147
|
-
differently-labelled state resets it. `bin/gtd
|
|
148
|
-
|
|
149
|
-
|
|
160
|
+
differently-labelled state resets it. `bin/gtd` implements this by mapping the
|
|
161
|
+
signal onto claude's session flags — `--session-id` to pin a fresh session when
|
|
162
|
+
the scope changes, `--resume` to continue it while the scope holds — and
|
|
150
163
|
persists the current scope + session id in the git dir (never the working tree,
|
|
151
164
|
so `gtd status` and the pending diff never see them) so retention survives even
|
|
152
165
|
across the restarts a human gate forces mid-loop. See `skills/loop/SKILL.md`'s
|
|
@@ -154,7 +167,7 @@ across the restarts a human gate forces mid-loop. See `skills/loop/SKILL.md`'s
|
|
|
154
167
|
|
|
155
168
|
## Using a different agent
|
|
156
169
|
|
|
157
|
-
`gtd
|
|
170
|
+
`gtd` (bare, or `gtd loop`) defaults to
|
|
158
171
|
`claude -p "$GTD_LOOP_PROMPT" --dangerously-skip-permissions`, but the agent
|
|
159
172
|
invocation is swappable: set `GTD_LOOP_AGENT_CMD` to any shell command, and it
|
|
160
173
|
runs with the prompt available as `$GTD_LOOP_PROMPT` in its environment, along
|
|
@@ -165,7 +178,7 @@ session). An adapter that ignores any of these keeps working unchanged. For
|
|
|
165
178
|
example, to drive a different agent CLI:
|
|
166
179
|
|
|
167
180
|
```bash
|
|
168
|
-
GTD_LOOP_AGENT_CMD='my-agent-cli --prompt "$GTD_LOOP_PROMPT"' gtd
|
|
181
|
+
GTD_LOOP_AGENT_CMD='my-agent-cli --prompt "$GTD_LOOP_PROMPT"' gtd
|
|
169
182
|
```
|
|
170
183
|
|
|
171
184
|
## Herdr integration (optional)
|
package/docs/upgrading.md
CHANGED
|
@@ -159,6 +159,47 @@ loudly at load time rather than silently misinterpreting.
|
|
|
159
159
|
or agent harness, upgrading the `gtd` binary also means re-copying that skill
|
|
160
160
|
from this release.
|
|
161
161
|
|
|
162
|
+
## 7.4: `gtd-loop` is gone — bare `gtd` and `gtd loop` run it directly
|
|
163
|
+
|
|
164
|
+
`gtd-loop`, the standalone loop-driver binary installed alongside `gtd`, no
|
|
165
|
+
longer exists. Its body now lives in `bin/gtd` itself, which is a bash entry
|
|
166
|
+
script that dispatches on its first argument:
|
|
167
|
+
|
|
168
|
+
| behavior | before (≤ 7.3) | from 7.4 |
|
|
169
|
+
| ------------------------------------------------------ | ---------------------------------------------- | ----------------------------------------------- |
|
|
170
|
+
| `gtd-loop` | separate installed binary | removed |
|
|
171
|
+
| bare `gtd` (no subcommand) | usage error, prints help, exits 1 | runs the loop driver immediately |
|
|
172
|
+
| `gtd loop` | not a recognized subcommand | runs the loop driver immediately |
|
|
173
|
+
| any other subcommand (`step`, `status`, `validate`, …) | handled by `node dist/gtd.bundle.mjs` directly | same, now reached via `bin/gtd`'s hand-off/exec |
|
|
174
|
+
|
|
175
|
+
**Bare `gtd` is a behavior flip, not merely an addition** — the same kind of
|
|
176
|
+
change as the v1 → v2 label grammar below: previously it printed the help text
|
|
177
|
+
and exited 1 without touching the repository; from 7.4 it launches the loop
|
|
178
|
+
driver immediately, identically to `gtd loop`. A script that relied on bare
|
|
179
|
+
`gtd` failing fast (e.g. to detect a missing subcommand) now gets a running loop
|
|
180
|
+
instead of an error.
|
|
181
|
+
|
|
182
|
+
Because `bin/gtd` is a bash script for every invocation now, **running `gtd` at
|
|
183
|
+
all — including any subcommand — requires bash**. Before 7.4, only the loop
|
|
184
|
+
driver (`gtd-loop`) needed bash; a subcommand could be invoked directly via the
|
|
185
|
+
bundle (e.g. `node node_modules/.bin/gtd`) with no bash in between. This is
|
|
186
|
+
intentional and accepted, not an oversight: gtd's loop, its `script`-content
|
|
187
|
+
checks, and a steering-file mode's own `format:`/`validate:` commands already
|
|
188
|
+
run via `bash -c` (see
|
|
189
|
+
[Configuration](configuration.md#modes--pluggable-steering-file-modes)) — gtd
|
|
190
|
+
already assumes a unix/bash agent environment everywhere else, so the entry
|
|
191
|
+
point catching up removes an inconsistency rather than introducing a new
|
|
192
|
+
dependency.
|
|
193
|
+
|
|
194
|
+
**Migration:**
|
|
195
|
+
|
|
196
|
+
- Anyone invoking `gtd-loop` directly — in scripts, CI, or docs — should invoke
|
|
197
|
+
`gtd` or `gtd loop` instead.
|
|
198
|
+
- Anyone relying on `node node_modules/.bin/gtd` resolving straight to the
|
|
199
|
+
bundle should note `gtd` is now a bash script that execs the bundle for
|
|
200
|
+
subcommands, so behavior for actual subcommands (`gtd step`, `gtd status`,
|
|
201
|
+
etc.) is unchanged — only the underlying file changed.
|
|
202
|
+
|
|
162
203
|
## 7.2: the review checkout window is now per worktree
|
|
163
204
|
|
|
164
205
|
The review checkout window's two refs moved from the SHARED `refs/gtd/*`
|
package/package.json
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmelab/gtd",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
|
|
6
6
|
"bin": {
|
|
7
|
-
"gtd": "
|
|
8
|
-
"gtd-loop": "bin/gtd-loop"
|
|
7
|
+
"gtd": "bin/gtd"
|
|
9
8
|
},
|
|
10
9
|
"files": [
|
|
11
10
|
"dist/",
|