@pmelab/gtd 8.3.0 → 8.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.
@@ -6541,6 +6541,14 @@ const Equivalence$3 = (self, that) => matchWith(self, that, {
6541
6541
  * @since 2.0.0
6542
6542
  * @category math
6543
6543
  */
6544
+ const times = /*#__PURE__*/ dual(2, (self, times) => match$6(self, {
6545
+ onMillis: (millis) => make$40(millis * times),
6546
+ onNanos: (nanos) => make$40(nanos * BigInt(times))
6547
+ }));
6548
+ /**
6549
+ * @since 2.0.0
6550
+ * @category math
6551
+ */
6544
6552
  const sum = /*#__PURE__*/ dual(2, (self, that) => matchWith(self, that, {
6545
6553
  onMillis: (self, that) => make$40(self + that),
6546
6554
  onNanos: (self, that) => make$40(self + that)
@@ -9804,6 +9812,10 @@ const defaultServicesWith = (f) => withFiberRuntime$1((fiber) => f(fiber.current
9804
9812
  const clockWith = (f) => defaultServicesWith((services) => f(services.unsafeMap.get(clockTag.key)));
9805
9813
  /** @internal */
9806
9814
  const currentTimeMillis$1 = /*#__PURE__*/ clockWith((clock) => clock.currentTimeMillis);
9815
+ /** @internal */
9816
+ const randomWith = (f) => defaultServicesWith((services) => f(services.unsafeMap.get(randomTag.key)));
9817
+ /** @internal */
9818
+ const next$1 = /*#__PURE__*/ randomWith((random) => random.next);
9807
9819
  //#endregion
9808
9820
  //#region node_modules/effect/dist/esm/Boolean.js
9809
9821
  /**
@@ -11786,7 +11798,7 @@ const fromIterable = (iterable) => {
11786
11798
  return new MetricBoundariesImpl(pipe(iterable, appendAll$2(of$1(Number.POSITIVE_INFINITY)), dedupe));
11787
11799
  };
11788
11800
  /** @internal */
11789
- const exponential = (options) => pipe(makeBy(options.count - 1, (i) => options.start * Math.pow(options.factor, i)), unsafeFromArray, fromIterable);
11801
+ const exponential$2 = (options) => pipe(makeBy(options.count - 1, (i) => options.start * Math.pow(options.factor, i)), unsafeFromArray, fromIterable);
11790
11802
  /** @internal */
11791
11803
  const MetricKeyTypeTypeId = /*#__PURE__*/ Symbol.for("effect/MetricKeyType");
11792
11804
  /** @internal */
@@ -12860,7 +12872,7 @@ const fiberSuccesses = /*#__PURE__*/ counter("effect_fiber_successes", { increme
12860
12872
  /** @internal */
12861
12873
  const fiberFailures = /*#__PURE__*/ counter("effect_fiber_failures", { incremental: true });
12862
12874
  /** @internal */
12863
- const fiberLifetimes = /*#__PURE__*/ tagged$1(/*#__PURE__*/ histogram("effect_fiber_lifetimes", /*#__PURE__*/ exponential({
12875
+ const fiberLifetimes = /*#__PURE__*/ tagged$1(/*#__PURE__*/ histogram("effect_fiber_lifetimes", /*#__PURE__*/ exponential$2({
12864
12876
  start: .5,
12865
12877
  factor: 2,
12866
12878
  count: 35
@@ -14303,7 +14315,7 @@ const isEmpty$3 = (self) => {
14303
14315
  return self.startMillis >= self.endMillis;
14304
14316
  };
14305
14317
  /** @internal */
14306
- const intersect$4 = /*#__PURE__*/ dual(2, (self, that) => {
14318
+ const intersect$5 = /*#__PURE__*/ dual(2, (self, that) => {
14307
14319
  const start = Math.max(self.startMillis, that.startMillis);
14308
14320
  const end = Math.min(self.endMillis, that.endMillis);
14309
14321
  return make$22(start, end);
@@ -14356,7 +14368,7 @@ const isEmpty$2 = isEmpty$3;
14356
14368
  * @since 2.0.0
14357
14369
  * @category ordering
14358
14370
  */
14359
- const intersect$3 = intersect$4;
14371
+ const intersect$4 = intersect$5;
14360
14372
  /**
14361
14373
  * Calculates the size of the `Interval` as the `Duration` from the start of the
14362
14374
  * interval to the end of the interval.
@@ -14383,14 +14395,14 @@ const make$20 = (intervals) => {
14383
14395
  };
14384
14396
  };
14385
14397
  /** @internal */
14386
- const intersect$2 = /*#__PURE__*/ dual(2, (self, that) => intersectLoop(self.intervals, that.intervals, empty$24()));
14398
+ const intersect$3 = /*#__PURE__*/ dual(2, (self, that) => intersectLoop(self.intervals, that.intervals, empty$24()));
14387
14399
  /** @internal */
14388
14400
  const intersectLoop = (_left, _right, _acc) => {
14389
14401
  let left = _left;
14390
14402
  let right = _right;
14391
14403
  let acc = _acc;
14392
14404
  while (isNonEmpty$4(left) && isNonEmpty$4(right)) {
14393
- const interval = pipe(headNonEmpty(left), intersect$3(headNonEmpty(right)));
14405
+ const interval = pipe(headNonEmpty(left), intersect$4(headNonEmpty(right)));
14394
14406
  const intervals = isEmpty$2(interval) ? acc : pipe(acc, prepend$1(interval));
14395
14407
  if (pipe(headNonEmpty(left), lessThan$3(headNonEmpty(right)))) left = tailNonEmpty(left);
14396
14408
  else right = tailNonEmpty(right);
@@ -14427,7 +14439,7 @@ const make$19 = make$20;
14427
14439
  * @since 2.0.0
14428
14440
  * @category utils
14429
14441
  */
14430
- const intersect$1 = intersect$2;
14442
+ const intersect$2 = intersect$3;
14431
14443
  /**
14432
14444
  * The start of the earliest interval in the specified `Intervals`.
14433
14445
  *
@@ -15740,6 +15752,15 @@ const uncapitalize = (self) => {
15740
15752
  * @since 2.0.0
15741
15753
  */
15742
15754
  const isNonEmpty$1 = (self) => self.length > 0;
15755
+ //#endregion
15756
+ //#region node_modules/effect/dist/esm/Random.js
15757
+ /**
15758
+ * Returns the next numeric value from the pseudo-random number generator.
15759
+ *
15760
+ * @since 2.0.0
15761
+ * @category constructors
15762
+ */
15763
+ const next = next$1;
15743
15764
  /** @internal */
15744
15765
  const ScheduleTypeId = /*#__PURE__*/ Symbol.for("effect/Schedule");
15745
15766
  /** @internal */
@@ -15866,9 +15887,18 @@ const checkEffect = /*#__PURE__*/ dual(2, (self, test) => makeWithState(self.ini
15866
15887
  ]);
15867
15888
  })));
15868
15889
  /** @internal */
15890
+ const delayedEffect = /*#__PURE__*/ dual(2, (self, f) => modifyDelayEffect(self, (_, delay) => f(delay)));
15891
+ /** @internal */
15892
+ const delayedSchedule = (schedule) => addDelay(schedule, (x) => x);
15893
+ /** @internal */
15869
15894
  const driver = (self) => pipe(make$29([none$4(), self.initial]), map$8((ref) => new ScheduleDriverImpl(self, ref)));
15870
15895
  /** @internal */
15871
- const intersect = /*#__PURE__*/ dual(2, (self, that) => intersectWith(self, that, intersect$1));
15896
+ const exponential$1 = (baseInput, factor = 2) => {
15897
+ const base = decode(baseInput);
15898
+ return delayedSchedule(map$5(forever, (i) => times(base, Math.pow(factor, i))));
15899
+ };
15900
+ /** @internal */
15901
+ const intersect$1 = /*#__PURE__*/ dual(2, (self, that) => intersectWith(self, that, intersect$2));
15872
15902
  /** @internal */
15873
15903
  const intersectWith = /*#__PURE__*/ dual(3, (self, that, f) => makeWithState([self.initial, that.initial], (now, input, state) => pipe(zipWith$2(self.step(now, input, state[0]), that.step(now, input, state[1]), (a, b) => [a, b]), flatMap$6(([[lState, out, lDecision], [rState, out2, rDecision]]) => {
15874
15904
  if (isContinue(lDecision) && isContinue(rDecision)) return intersectWithLoop(self, that, input, lState, out, lDecision.intervals, rState, out2, rDecision.intervals, f);
@@ -15904,6 +15934,23 @@ const intersectWithLoop = (self, that, input, lState, out, lInterval, rState, ou
15904
15934
  });
15905
15935
  };
15906
15936
  /** @internal */
15937
+ const jittered$1 = (self) => jitteredWith(self, {
15938
+ min: .8,
15939
+ max: 1.2
15940
+ });
15941
+ /** @internal */
15942
+ const jitteredWith = /*#__PURE__*/ dual(2, (self, options) => {
15943
+ const { max, min } = Object.assign({
15944
+ min: .8,
15945
+ max: 1.2
15946
+ }, options);
15947
+ return delayedEffect(self, (duration) => map$8(next, (random) => {
15948
+ const d = toMillis(duration);
15949
+ const jittered = d * min * (1 - random) + d * max * random;
15950
+ return millis(jittered);
15951
+ }));
15952
+ });
15953
+ /** @internal */
15907
15954
  const map$5 = /*#__PURE__*/ dual(2, (self, f) => mapEffect(self, (out) => sync$2(() => f(out))));
15908
15955
  /** @internal */
15909
15956
  const mapEffect = /*#__PURE__*/ dual(2, (self, f) => makeWithState(self.initial, (now, input, state) => flatMap$6(self.step(now, input, state), ([state, out, decision]) => map$8(f(out), (out2) => [
@@ -15941,7 +15988,7 @@ const passthrough = (self) => makeWithState(self.initial, (now, input, state) =>
15941
15988
  decision
15942
15989
  ])));
15943
15990
  /** @internal */
15944
- const recurs = (n) => whileOutput(forever, (out) => out < n);
15991
+ const recurs$1 = (n) => whileOutput(forever, (out) => out < n);
15945
15992
  /** @internal */
15946
15993
  const spaced$1 = (duration) => addDelay(forever, () => duration);
15947
15994
  /** @internal */
@@ -15990,7 +16037,7 @@ const repeat_combined = /*#__PURE__*/ dual(2, (self, options) => {
15990
16037
  if (typeof applied === "boolean") return succeed$8(applied);
15991
16038
  return scheduleDefectWrap(applied);
15992
16039
  }) : withWhile;
15993
- const withTimes = options.times ? intersect(withUntil, recurs(options.times)).pipe(map$5((intersectionPair) => intersectionPair[0])) : withUntil;
16040
+ const withTimes = options.times ? intersect$1(withUntil, recurs$1(options.times)).pipe(map$5((intersectionPair) => intersectionPair[0])) : withUntil;
15994
16041
  return scheduleDefectRefail(repeat_Effect(self, withTimes));
15995
16042
  });
15996
16043
  /** @internal */
@@ -16026,7 +16073,7 @@ const fromRetryOptions = (options) => {
16026
16073
  if (typeof applied === "boolean") return succeed$8(applied);
16027
16074
  return scheduleDefectWrap(applied);
16028
16075
  }) : withWhile;
16029
- return options.times !== void 0 ? intersect(withUntil, recurs(options.times)) : withUntil;
16076
+ return options.times !== void 0 ? intersect$1(withUntil, recurs$1(options.times)) : withUntil;
16030
16077
  };
16031
16078
  /** @internal */
16032
16079
  const retryOrElse_Effect = /*#__PURE__*/ dual(3, (self, policy, orElse) => flatMap$6(driver(policy), (driver) => retryOrElse_EffectLoop(provideServiceEffect(self, CurrentIterationMetadata, get$4(driver.iterationMeta)), driver, (e, out) => provideServiceEffect(orElse(e, out), CurrentIterationMetadata, get$4(driver.iterationMeta)))));
@@ -20184,6 +20231,79 @@ const provideMerge = provideMerge$1;
20184
20231
  //#endregion
20185
20232
  //#region node_modules/effect/dist/esm/Schedule.js
20186
20233
  /**
20234
+ * Creates a schedule that recurs indefinitely with exponentially increasing
20235
+ * delays.
20236
+ *
20237
+ * **Details**
20238
+ *
20239
+ * This schedule starts with an initial delay of `base` and increases the delay
20240
+ * exponentially on each repetition using the formula `base * factor^n`, where
20241
+ * `n` is the number of times the schedule has executed so far. If no `factor`
20242
+ * is provided, it defaults to `2`, causing the delay to double after each
20243
+ * execution.
20244
+ *
20245
+ * @since 2.0.0
20246
+ * @category Constructors
20247
+ */
20248
+ const exponential = exponential$1;
20249
+ /**
20250
+ * Combines two schedules, continuing only if both schedules want to continue,
20251
+ * using the longer delay.
20252
+ *
20253
+ * **Details**
20254
+ *
20255
+ * This function takes two schedules and creates a new schedule that only
20256
+ * continues execution if both schedules allow it. The interval between
20257
+ * recurrences is determined by the longer delay between the two schedules.
20258
+ *
20259
+ * The output of the resulting schedule is a tuple containing the outputs of
20260
+ * both schedules. The input type is the intersection of both schedules' input
20261
+ * types.
20262
+ *
20263
+ * This is useful when coordinating multiple scheduling conditions where
20264
+ * execution should proceed only when both schedules permit it.
20265
+ *
20266
+ * @see {@link intersectWith} If you need to use a custom merge function.
20267
+ *
20268
+ * @since 2.0.0
20269
+ * @category Composition
20270
+ */
20271
+ const intersect = intersect$1;
20272
+ /**
20273
+ * Returns a new schedule that randomly adjusts the interval size within a
20274
+ * range.
20275
+ *
20276
+ * **Details**
20277
+ *
20278
+ * This function modifies a schedule so that its delay between executions is
20279
+ * randomly varied within a range. By default, the delay is adjusted between
20280
+ * `80%` (`0.8 * interval`) and `120%` (`1.2 * interval`) of the original
20281
+ * interval size.
20282
+ *
20283
+ * This is useful for adding randomness to repeated executions, reducing
20284
+ * contention in distributed systems, and avoiding synchronized execution
20285
+ * patterns that can cause bottlenecks.
20286
+ *
20287
+ * @see {@link jitteredWith} If you need to specify custom min/max values.
20288
+ *
20289
+ * @since 2.0.0
20290
+ * @category Timing & Delay
20291
+ */
20292
+ const jittered = jittered$1;
20293
+ /**
20294
+ * A schedule that recurs a fixed number of times before terminating.
20295
+ *
20296
+ * **Details**
20297
+ *
20298
+ * This schedule will continue executing until it has been stepped `n` times,
20299
+ * after which it will stop. The output of the schedule is the current count of
20300
+ * recurrences.
20301
+ *
20302
+ * @category Constructors
20303
+ * @since 2.0.0
20304
+ */
20305
+ const recurs = recurs$1;
20306
+ /**
20187
20307
  * Returns a schedule that recurs continuously, with each repetition
20188
20308
  * spaced by the specified `duration` from the last run.
20189
20309
  *
@@ -189175,20 +189295,26 @@ const isCommitState = (state) => state.commit !== void 0;
189175
189295
  const contentOf = (state) => state.script ?? state.prompt ?? state.message;
189176
189296
  /** True when a rest at `state` should open the review checkout window (see `StateDef.reviewWindow`). Safe for an unknown state name (returns `false`). */
189177
189297
  const isReviewWindowState = (def, state) => def.states[state]?.reviewWindow === true;
189178
- /** True when `state` anchors the review window's diff base (see `StateDef.reviewBase`). Safe for an unknown state name (returns `false`). */
189298
+ /** True when `state` anchors the review window's diff base (see `StateDef.reviewBase`). Safe for an unknown state name (returns `false`). The string/template form of `reviewBase` is NOT a window anchor — this stays narrowed to `=== true` on purpose, never truthy. */
189179
189299
  const isReviewBaseState = (def, state) => def.states[state]?.reviewBase === true;
189300
+ /** The named state's `reviewBase` when it is a STRING (an Eta template rendering a commitish that fixes the whole process's diff base — see `StateDef.reviewBase`) — `undefined` when `reviewBase` is `true`, absent, or `state` doesn't exist. Pure accessor only: rendering the template is an edge concern. */
189301
+ const entryBaseTemplateOf = (def, state) => {
189302
+ const reviewBase = def.states[state]?.reviewBase;
189303
+ return typeof reviewBase === "string" ? reviewBase : void 0;
189304
+ };
189180
189305
  /** True when a step at `state` must be refused if its only change is deleting the state's `file:` (see `StateDef.requireProgress`). Safe for an unknown state name (returns `false`). */
189181
189306
  const isRequireProgressState = (def, state) => def.states[state]?.requireProgress === true;
189182
189307
  /** True when a step at `state` must be refused unless every open question in its `qa`-mode file is answered (see `StateDef.answerGate`). Safe for an unknown state name (returns `false`). */
189183
189308
  const isAnswerGateState = (def, state) => def.states[state]?.answerGate === true;
189184
- /** The workflow's declared review-entry state name (see `StateDef.reviewEntry`) — `gtd review <commitish>` (`src/program.ts`) enters this state to start a new review process. `undefined` when no state declares one; `validateDefinition` guarantees at most one does, so the first (only) match wins. */
189185
- const reviewEntryStateOf = (def) => {
189186
- for (const [name, state] of Object.entries(def.states)) if (state.reviewEntry === true) return name;
189187
- };
189188
- /** The workflow's declared fix-entry state name (see `StateDef.fixEntry`) — `gtd fix` (`src/program.ts`) enters this state to start a new fix process. `undefined` when no state declares one; `validateDefinition` guarantees at most one does, so the first (only) match wins. */
189189
- const fixEntryStateOf = (def) => {
189190
- for (const [name, state] of Object.entries(def.states)) if (state.fixEntry === true) return name;
189191
- };
189309
+ /**
189310
+ * Every declared state that is NOT a commit state, sorted (plain string
189311
+ * sort). This is intentionally ALL non-commit states, not just ones that
189312
+ * declared `entry: true` — `entry: true` is a reachability/visualizer
189313
+ * concern (it seeds `entries.manual`), while this drives the CLI's
189314
+ * `--entry <state>` guard and its error message (naming every legal choice),
189315
+ * a broader set on purpose.
189316
+ */
189317
+ const enterableStates = (def) => Object.keys(def.states).filter((name) => !isCommitState(def.states[name])).sort();
189192
189318
  /** The ` → ` that separates a transition subject's source state from its target. */
189193
189319
  const TRANSITION_SEP = " → ";
189194
189320
  /**
@@ -189197,8 +189323,9 @@ const TRANSITION_SEP = " → ";
189197
189323
  * state being ENTERED and `<from>` the state the authored changes were made
189198
189324
  * in, so the subject reads as what this commit DID, not just where the machine
189199
189325
  * is headed. `from` is optional: when it is omitted or equals `to` (a
189200
- * self-loop, or a manual entry like `gtd review` that has no meaningful
189201
- * source), the subject collapses to the bare `gtd(<actor>): <to>` form.
189326
+ * self-loop, or a manual entry like `gtd step <actor> --entry <state>` that
189327
+ * has no meaningful source), the subject collapses to the bare
189328
+ * `gtd(<actor>): <to>` form.
189202
189329
  * `resolveState` reads back only `<to>` — the ` → ` prefix is human context.
189203
189330
  */
189204
189331
  const stateSubject = (actor, to, from) => from === void 0 || from === to ? `gtd(${actor}): ${to}` : `gtd(${actor}): ${from}${TRANSITION_SEP}${to}`;
@@ -189231,17 +189358,8 @@ const parseStateSubject = (subject) => {
189231
189358
  from
189232
189359
  };
189233
189360
  };
189234
- /**
189235
- * The workflow's declared initial state (exactly one `initial: true` state
189236
- * is assumed — `validateDefinition` enforces this at config-load time).
189237
- * Throws if none is declared: a caller invoking `resolveState`/`step`
189238
- * against an unvalidated, malformed definition is a programmer error, not a
189239
- * value this module tries to guess through.
189240
- */
189241
- const initialStateOf = (def) => {
189242
- for (const [name, state] of Object.entries(def.states)) if (state.initial === true) return name;
189243
- throw new Error("workflow definition has no initial state");
189244
- };
189361
+ /** The workflow's declared initial state (`entries.default` — required, so there is always a value). */
189362
+ const initialStateOf = (def) => def.entries.default;
189245
189363
  /**
189246
189364
  * Every actor declared by ANY state in the workflow — the closed-world
189247
189365
  * vocabulary a parsed subject's actor is checked against by `resolveState`.
@@ -189385,6 +189503,54 @@ const applyRetry = (def, target, trace, visited = /* @__PURE__ */ new Set()) =>
189385
189503
  return applyRetry(def, targetDef.retry.otherwise, trace, /* @__PURE__ */ new Set([...visited, target]));
189386
189504
  };
189387
189505
  /**
189506
+ * A plain string-prefix test: is `state` inside `scope`'s subtree? Sound
189507
+ * because `src/Machines.ts` already refuses any local name containing a
189508
+ * `.` (a compile-time error), so a qualified name's dotted path segments are
189509
+ * unambiguous — no tree walk or parent map needed. `scope === ""` is the
189510
+ * root scope and matches every state. Otherwise it's an exact match, or a
189511
+ * TRUE dotted descendant (`state.startsWith(scope + ".")`) — a same-prefix
189512
+ * SIBLING is deliberately not a match: `inScope("packages.itemx.building",
189513
+ * "packages.item")` is `false`, because `"packages.itemx"` merely starts
189514
+ * with the characters `"packages.item"` without the `.` separator that
189515
+ * would make it an actual descendant.
189516
+ */
189517
+ const inScope = (state, scope) => scope === "" || state === scope || state.startsWith(`${scope}.`);
189518
+ /**
189519
+ * Resolve the memory scope for `state`, given the process `trace` so far.
189520
+ * This is a pure primitive a later package's memory-key computation is
189521
+ * built on — it never touches git or the filesystem.
189522
+ *
189523
+ * `undefined` means `state` itself isn't in `scopes` — memory is an
189524
+ * optimization, never a correctness input, so this ambiguous case resolves
189525
+ * toward "fresh" by refusing to resolve at all. Otherwise the result always
189526
+ * carries `state`'s own scope `M = scopes[state]`, plus an `entryIndex`:
189527
+ * the trace position where the CURRENT unbroken run of "in `M`'s subtree"
189528
+ * rows began, so a state's own conversation can dip into child scopes
189529
+ * (`packages.item.health`, `packages.item.spec`, ...) and back without
189530
+ * losing its place — only a trace row whose scope is a sibling or ancestor
189531
+ * of `M` (not inside `M`'s subtree) breaks the run. `entryIndex: -1` covers
189532
+ * both an empty `trace` and a `trace` with nothing ever inside `M`'s
189533
+ * subtree — both are "fresh", just for different reasons. A trace row
189534
+ * naming a state absent from `scopes` is skipped rather than thrown on —
189535
+ * treated as not-in-scope for both membership and run-continuity.
189536
+ */
189537
+ const memoryScopeAt = (scopes, state, trace) => {
189538
+ const scope = scopes[state];
189539
+ if (scope === void 0) return void 0;
189540
+ let entryIndex = -1;
189541
+ for (let k = 0; k < trace.length; k++) {
189542
+ const rowScope = scopes[trace[k]];
189543
+ if (rowScope === void 0 || !inScope(rowScope, scope)) continue;
189544
+ const prevScope = k === 0 ? void 0 : scopes[trace[k - 1]];
189545
+ const prevInScope = prevScope !== void 0 && inScope(prevScope, scope);
189546
+ if (k === 0 || !prevInScope) entryIndex = k;
189547
+ }
189548
+ return {
189549
+ scope,
189550
+ entryIndex
189551
+ };
189552
+ };
189553
+ /**
189388
189554
  * Decide what invoking `invoker` at `state` does — a pure decision, not an
189389
189555
  * effect. Refusals: `invoker` isn't `state`'s declared actor (out-of-turn),
189390
189556
  * or the tree is dirty and no `on` pattern matches (no-match, naming the
@@ -189448,12 +189614,33 @@ const CONTENT_KEYS$1 = [
189448
189614
  "message",
189449
189615
  "commit"
189450
189616
  ];
189451
- /** Exactly one `initial: true` state, and it must not be a commit state. */
189452
- const validateInitial = (def, names) => {
189453
- const initialNames = names.filter((name) => def.states[name].initial === true);
189454
- if (initialNames.length !== 1) return [`workflow must declare exactly one initial state (found ${initialNames.length}${initialNames.length > 0 ? `: ${initialNames.join(", ")}` : ""})`];
189455
- const only = initialNames[0];
189456
- return isCommitState(def.states[only]) ? [`initial state "${only}" must not be a commit state`] : [];
189617
+ /**
189618
+ * `entries.default` names a defined, non-commit state. Every entry in
189619
+ * `entries.manual`, when present, must likewise name a defined, non-commit
189620
+ * state distinct from `entries.default` — a manual entry is a DELIBERATE,
189621
+ * distinct starting point from the workflow's ordinary "no active cycle"
189622
+ * rest (a manual entry requires resting at the default entry before it acts
189623
+ * — see `src/program.ts`), so the two must stay distinguishable — and
189624
+ * `entries.manual` must carry no duplicate state name within itself.
189625
+ */
189626
+ const validateEntries = (def, names) => {
189627
+ const errors = [];
189628
+ const checkEntry = (key, state) => {
189629
+ if (!names.includes(state)) {
189630
+ errors.push(`entries.${key} "${state}" is not a defined state`);
189631
+ return;
189632
+ }
189633
+ if (isCommitState(def.states[state])) errors.push(`entries.${key} "${state}" must not be a commit state`);
189634
+ if (key !== "default" && state === def.entries.default) errors.push(`entries.${key} "${state}" must not be the same state as entries.default`);
189635
+ };
189636
+ checkEntry("default", def.entries.default);
189637
+ const seen = /* @__PURE__ */ new Set();
189638
+ for (const state of def.entries.manual) {
189639
+ checkEntry("manual", state);
189640
+ if (seen.has(state)) errors.push(`entries.manual declares "${state}" more than once`);
189641
+ seen.add(state);
189642
+ }
189643
+ return errors;
189457
189644
  };
189458
189645
  /** Exactly one of script/prompt/message/commit. */
189459
189646
  const validateContentKind = (name, state) => {
@@ -189475,14 +189662,7 @@ const validateModel = (name, state) => {
189475
189662
  if (isCommitState(state) && state.model !== void 0) errors.push(`state "${name}": a commit state cannot declare "model"`);
189476
189663
  return errors;
189477
189664
  };
189478
- /** `memory`, when present, must be a non-empty string; forbidden on a commit state — same rule family as `model` (`validateModel`): a commit state is never at rest, so no agent turn there could carry or continue a memory scope. */
189479
- const validateMemory = (name, state) => {
189480
- const errors = [];
189481
- if (state.memory !== void 0 && state.memory === "") errors.push(`state "${name}": "memory" must be a non-empty string`);
189482
- if (isCommitState(state) && state.memory !== void 0) errors.push(`state "${name}": a commit state cannot declare "memory"`);
189483
- return errors;
189484
- };
189485
- /** `label`, when present, must be a non-empty string; forbidden on a commit state — same rule family as `model`/`memory` (`validateModel`/`validateMemory`): a commit state is never at rest and emits nothing for a driver/viewer to display a label for. */
189665
+ /** `label`, when present, must be a non-empty string; forbidden on a commit state — same rule family as `model` (`validateModel`): a commit state is never at rest and emits nothing for a driver/viewer to display a label for. */
189486
189666
  const validateLabel = (name, state) => {
189487
189667
  const errors = [];
189488
189668
  if (state.label !== void 0 && state.label === "") errors.push(`state "${name}": "label" must be a non-empty string`);
@@ -189535,10 +189715,10 @@ const validateMode = (def, name, state) => {
189535
189715
  return errors;
189536
189716
  };
189537
189717
  /**
189538
- * `reviewWindow`/`reviewBase`, when present, are booleans (the compiler
189539
- * enforces the type) — forbidden on a commit state, same rule family as
189540
- * `model`/`file`/`mode`: a commit state is never at rest, so no window ever
189541
- * opens or anchors there.
189718
+ * `reviewWindow`/`reviewBase`, when present, are a boolean and a
189719
+ * boolean-or-string respectively (the compiler enforces the type) —
189720
+ * forbidden on a commit state, same rule family as `model`/`file`/`mode`: a
189721
+ * commit state is never at rest, so no window ever opens or anchors there.
189542
189722
  */
189543
189723
  const validateReviewWindow = (name, state) => {
189544
189724
  if (!isCommitState(state)) return [];
@@ -189548,36 +189728,16 @@ const validateReviewWindow = (name, state) => {
189548
189728
  return errors;
189549
189729
  };
189550
189730
  /**
189551
- * `reviewEntry`, when present, is forbidden on a commit state (never at
189552
- * rest — same rule family as `reviewWindow`/`reviewBase`) and on the initial
189553
- * state: a review entry is a deliberate, distinct starting point from the
189554
- * workflow's ordinary "no active cycle" rest (`gtd review` itself REQUIRES
189555
- * resting at the initial state before it will act — see `src/program.ts` —
189556
- * so the two must stay distinguishable rather than collapsing into one).
189557
- * The at-most-one-state rule is checked separately, over the whole
189558
- * definition (see `validateReviewEntryUniqueness`), since it isn't a
189559
- * per-state finding.
189731
+ * When `reviewBase` is a STRING (the template form — see `StateDef.reviewBase`),
189732
+ * its source must be non-blank: a literal `reviewBase: ""` (or whitespace-only)
189733
+ * is an authoring error, distinct from the runtime concern of the RENDERED
189734
+ * result coming out blank (that refusal lives at the edge, at CLI time — not
189735
+ * checked here). Deliberately does NOT check that the template mentions a
189736
+ * declared var: a base may legitimately be a literal commitish like `main`.
189560
189737
  */
189561
- const validateReviewEntry = (name, state) => {
189562
- if (state.reviewEntry === void 0) return [];
189563
- const errors = [];
189564
- if (isCommitState(state)) errors.push(`state "${name}": a commit state cannot declare "reviewEntry"`);
189565
- if (state.initial === true) errors.push(`state "${name}": the initial state cannot declare "reviewEntry"`);
189566
- return errors;
189567
- };
189568
- /**
189569
- * `fixEntry`, when present, is forbidden on a commit state and on the initial
189570
- * state — the same rule family as `reviewEntry` (see `validateReviewEntry`):
189571
- * `gtd fix` REQUIRES resting at the initial state before it acts, so its entry
189572
- * target must be a distinct state. The at-most-one-state rule is checked
189573
- * separately over the whole definition (see `validateFixEntryUniqueness`).
189574
- */
189575
- const validateFixEntry = (name, state) => {
189576
- if (state.fixEntry === void 0) return [];
189577
- const errors = [];
189578
- if (isCommitState(state)) errors.push(`state "${name}": a commit state cannot declare "fixEntry"`);
189579
- if (state.initial === true) errors.push(`state "${name}": the initial state cannot declare "fixEntry"`);
189580
- return errors;
189738
+ const validateReviewBaseTemplate = (name, state) => {
189739
+ if (typeof state.reviewBase !== "string") return [];
189740
+ return state.reviewBase.trim() === "" ? [`state "${name}": "reviewBase" template must not be blank`] : [];
189581
189741
  };
189582
189742
  /**
189583
189743
  * `requireProgress`, when present, is forbidden on a commit state (never at
@@ -189607,16 +189767,6 @@ const validateAnswerGate = (name, state) => {
189607
189767
  if (state.file === void 0) errors.push(`state "${name}": "answerGate" requires "file"`);
189608
189768
  return errors;
189609
189769
  };
189610
- /** At most one state may declare `reviewEntry: true` — `gtd review <commitish>` (`src/program.ts`) needs a single, unambiguous state name to enter (see `reviewEntryStateOf`). */
189611
- const validateReviewEntryUniqueness = (def, names) => {
189612
- const entryNames = names.filter((name) => def.states[name].reviewEntry === true);
189613
- return entryNames.length > 1 ? [`at most one state may declare "reviewEntry" (found ${entryNames.length}: ${entryNames.join(", ")})`] : [];
189614
- };
189615
- /** At most one state may declare `fixEntry: true` — `gtd fix` (`src/program.ts`) needs a single, unambiguous state name to enter (see `fixEntryStateOf`). */
189616
- const validateFixEntryUniqueness = (def, names) => {
189617
- const entryNames = names.filter((name) => def.states[name].fixEntry === true);
189618
- return entryNames.length > 1 ? [`at most one state may declare "fixEntry" (found ${entryNames.length}: ${entryNames.join(", ")})`] : [];
189619
- };
189620
189770
  /** Every `on` row parses, and its target names a defined state. */
189621
189771
  const validateOnEdges = (name, state, names) => {
189622
189772
  const errors = [];
@@ -189638,28 +189788,25 @@ const validateRetry = (name, state, names) => {
189638
189788
  * Every state is reachable from an ENTRY ROOT by walking `on` targets and
189639
189789
  * `retry.otherwise` redirects (a redirect ENTERS its `otherwise` state exactly
189640
189790
  * like an `on` match enters its target — see `applyRetry` — so both are real
189641
- * edges). The roots are the initial state PLUS the CLI-command entry states
189642
- * (`reviewEntry`/`fixEntry`): a command enters those directly, so a state
189643
- * reachable only from one of them is legitimately reachable, not dead config
189644
- * (without seeding them, e.g. a `fixEntry` check whose only inbound path is
189645
- * `gtd fix` would be wrongly flagged). Plain BFS; targets naming undefined
189646
- * states are skipped here (they are `validateOnEdges`/`validateRetry` findings
189647
- * of their own). Only called when `validateInitial` found no problem: with zero
189648
- * or several `initial` states there is no well-defined start to walk from, and
189649
- * reporting every state as unreachable would bury the real finding.
189791
+ * edges). The roots are `def.entries` — `default` PLUS every state named in
189792
+ * `entries.manual`: a manual entry is entered directly (`gtd step <actor>
189793
+ * --entry <state>`), so a state reachable only from one of them is
189794
+ * legitimately reachable, not dead config (without seeding them, e.g. a
189795
+ * manual entry whose only inbound path is `--entry` would be wrongly
189796
+ * flagged). Plain BFS; targets naming undefined states are skipped here (they
189797
+ * are `validateOnEdges`/`validateRetry` findings of their own). Only called
189798
+ * when `validateEntries` found no problem: with an invalid `entries` there is
189799
+ * no well-defined start to walk from, and reporting every state as
189800
+ * unreachable would bury the real finding.
189650
189801
  *
189651
189802
  * An unreachable state is an ERROR, not a warning: a workflow is bound to a
189652
- * project and edited as a project-wide change, so "kept on purpose for manual
189653
- * entry via a hand-authored subject" is not a supported authoring pattern —
189654
- * an unreachable state is a typo'd rename or a leftover, and silently-dead
189803
+ * project and edited as a project-wide change, so "kept on purpose for entry
189804
+ * via a hand-authored subject" is not a supported authoring pattern — an
189805
+ * unreachable state is a typo'd rename or a leftover, and silently-dead
189655
189806
  * config is exactly what load-time validation exists to catch.
189656
189807
  */
189657
189808
  const validateReachability = (def, names) => {
189658
- const roots = [
189659
- names.find((name) => def.states[name].initial === true),
189660
- reviewEntryStateOf(def),
189661
- fixEntryStateOf(def)
189662
- ].filter((name) => name !== void 0);
189809
+ const roots = [def.entries.default, ...def.entries.manual];
189663
189810
  const visited = new Set(roots);
189664
189811
  const queue = [...roots];
189665
189812
  while (queue.length > 0) {
@@ -189682,13 +189829,11 @@ const validateState = (def, name, names) => {
189682
189829
  ...validateOnEdges(name, state, names),
189683
189830
  ...validateRetry(name, state, names),
189684
189831
  ...validateModel(name, state),
189685
- ...validateMemory(name, state),
189686
189832
  ...validateLabel(name, state),
189687
189833
  ...validateFile(name, state),
189688
189834
  ...validateMode(def, name, state),
189689
189835
  ...validateReviewWindow(name, state),
189690
- ...validateReviewEntry(name, state),
189691
- ...validateFixEntry(name, state),
189836
+ ...validateReviewBaseTemplate(name, state),
189692
189837
  ...validateRequireProgress(name, state),
189693
189838
  ...validateAnswerGate(name, state)
189694
189839
  ];
@@ -189696,104 +189841,42 @@ const validateState = (def, name, names) => {
189696
189841
  /**
189697
189842
  * Validate a `WorkflowDefinition`, returning human-readable error strings
189698
189843
  * (empty = valid). Pure — Phase 2 calls this at config-load time. Checks:
189699
- * at least one state; exactly one `initial: true` state (and it must not be
189700
- * a commit state — a workflow can't start already finished, a small
189701
- * addition beyond the plan's literal list, called out here since it's easy
189702
- * to drop if a later phase disagrees); every state declares exactly one
189703
- * content kind; commit states carry no `actor` and no `on`; non-commit
189704
- * states carry an `actor`; every `on` pattern parses and every `on` target
189705
- * and `retry.otherwise` names a defined state; `retry.max` is a
189844
+ * at least one state; `entries.default` names a defined, non-commit state,
189845
+ * and every entry in `entries.manual`, when present, names a defined,
189846
+ * non-commit state distinct from `entries.default` with no duplicate within
189847
+ * `entries.manual` itself (see `validateEntries`); every state declares
189848
+ * exactly one content kind; commit states carry no `actor` and no `on`;
189849
+ * non-commit states carry an `actor`; every `on` pattern parses and every
189850
+ * `on` target and `retry.otherwise` names a defined state; `retry.max` is a
189706
189851
  * non-negative integer; `model`, when present, is a non-empty string and is
189707
- * never declared on a commit state; `memory`, when present, is a non-empty
189852
+ * never declared on a commit state; `label`, when present, is a non-empty
189708
189853
  * string and is never declared on a commit state (same rule family as
189709
- * `model`); `label`, when present, is a non-empty string and is never
189710
- * declared on a commit state (same rule family as `model`); `file`, when
189711
- * present, is a non-empty
189712
- * string and is never declared on a commit state; `mode`, when present, names
189713
- * a mode the definition knows (a built-in or a `modes:` entry — see
189714
- * `knownModes`), requires a sibling `file`, and is never declared on a commit
189715
- * state; every `modes:` entry declares at least one non-blank
189716
- * `format`/`validate` command; `reviewWindow`/`reviewBase`, when
189717
- * present, are never declared on a commit state; `reviewEntry`/`fixEntry`, when
189718
- * present, are never declared on a commit state or the initial state, and at
189719
- * most one state across the whole workflow may declare each; every state is
189720
- * reachable from an entry root (the initial state plus any `reviewEntry`/
189721
- * `fixEntry` state) by walking `on` targets and `retry.otherwise` redirects
189722
- * (checked only when the initial-state rule itself passed — see
189723
- * `validateReachability`).
189854
+ * `model`); `file`, when
189855
+ * present, is a non-empty string and is never declared on a commit state;
189856
+ * `mode`, when present, names a mode the definition knows (a built-in or a
189857
+ * `modes:` entry — see `knownModes`), requires a sibling `file`, and is
189858
+ * never declared on a commit state; every `modes:` entry declares at least
189859
+ * one non-blank `format`/`validate` command; `reviewWindow`/`reviewBase`,
189860
+ * when present, are never declared on a commit state, and a string-form
189861
+ * `reviewBase` template must not be blank (see `validateReviewBaseTemplate`);
189862
+ * every state is reachable from an entry root (`def.entries` — `default`
189863
+ * plus every `entries.manual` state) by walking `on` targets and
189864
+ * `retry.otherwise` redirects (checked only when `validateEntries` itself
189865
+ * found no problem — see `validateReachability`).
189724
189866
  */
189725
189867
  const validateDefinition = (def) => {
189726
189868
  const names = Object.keys(def.states);
189727
189869
  if (names.length === 0) return ["workflow must declare at least one state"];
189728
- const initialErrors = validateInitial(def, names);
189870
+ const entriesErrors = validateEntries(def, names);
189729
189871
  return [
189730
- ...initialErrors,
189872
+ ...entriesErrors,
189731
189873
  ...validateModes(def),
189732
189874
  ...names.flatMap((name) => validateState(def, name, names)),
189733
- ...validateReviewEntryUniqueness(def, names),
189734
- ...validateFixEntryUniqueness(def, names),
189735
- ...initialErrors.length === 0 ? validateReachability(def, names) : []
189875
+ ...entriesErrors.length === 0 ? validateReachability(def, names) : []
189736
189876
  ];
189737
189877
  };
189738
189878
  //#endregion
189739
- //#region src/Submachines.ts
189740
- /**
189741
- * Compile-time SUB-MACHINE EXPANSION (Option C) — a raw → raw pre-pass that runs
189742
- * at the very top of `compileWorkflowConfig` (src/PatternConfig.ts), BEFORE any
189743
- * per-state compilation. It turns two OPTIONAL top-level `workflow:` keys —
189744
- *
189745
- * ```yaml
189746
- * submachines: # reusable, parameterized clusters of states
189747
- * <name>:
189748
- * params: [<param>, ...]
189749
- * states: { <local>: <StateDef, with `$param` placeholders> }
189750
- * use: # invocations, each expanded into concrete states
189751
- * - submachine: <name>
189752
- * as: { <local>: <concreteName>, ... } # rename map (default: identity)
189753
- * with: { <param>: <value>, ... } # bind each `$param`
189754
- * set: { <local>: { <field>: <value> } } # extra per-instance state fields
189755
- * ```
189756
- *
189757
- * — into ordinary concrete `states`, then strips `submachines`/`use` from its
189758
- * output so the rest of the compiler (and `KNOWN_TOP_KEYS`) only ever sees the
189759
- * familiar `{ vars?, states, modes? }` shape. The pure engine (`PatternMachine`)
189760
- * is entirely unaware sub-machines exist: expansion is a source-authoring
189761
- * convenience with NO runtime footprint. Aliasing
189762
- * each invocation's local state names (via `as:`) to the exact concrete names a
189763
- * hand-written workflow would use makes the expanded definition BYTE-IDENTICAL to
189764
- * the flat one — the guarantee the bundled template's golden test pins
189765
- * (src/workflows/templates.test.ts).
189766
- *
189767
- * ## Two uses of one mechanism
189768
- *
189769
- * - **Dedup** — a multi-instance sub-machine (a gate/loop authored once, invoked
189770
- * several times with different `with:`/`as:`) removes duplicated states.
189771
- * - **Encapsulation** — a single-instance sub-machine groups a complex cluster
189772
- * into one named block for source comprehension. It expands 1:1, so it has no
189773
- * runtime effect; its value is authoring clarity (and a hook for a future
189774
- * viewer).
189775
- *
189776
- * ## Substitution
189777
- *
189778
- * A `$name` token is resolved from the invocation's `with:` bindings. It is
189779
- * matched ONLY as a WHOLE string value — a field whose entire value is `$name`,
189780
- * or an `on`/`retry.otherwise` target that is exactly `$name` — never as a
189781
- * substring, so bash `$var`/`${var}` inside a `script:` and Eta
189782
- * `<%= it.vars.x %>` inside any content are left untouched. Text that varies per
189783
- * instance (a message, a describe sentence, a prompt) is therefore passed WHOLE
189784
- * as a `$name` binding, not spliced in.
189785
- *
189786
- * An `on`/`retry.otherwise` target that names one of the sub-machine's OWN local
189787
- * states is rewritten through the `as:` rename map (so intra-cluster edges point
189788
- * at the renamed concrete states); any other target passes through verbatim (it
189789
- * names a concrete top-level state, resolved after the merge). `on` PATTERN keys
189790
- * are never touched (they are literal, exactly as the engine treats them).
189791
- *
189792
- * All findings are collected into the shared `errors` array the caller threads
189793
- * through, aggregated with every other config-shape/validation finding into the
189794
- * single error `compileWorkflowConfig` throws — a bad `submachines:`/`use:` fails
189795
- * loudly at load time, never at step time.
189796
- */
189879
+ //#region src/Machines.ts
189797
189880
  const isPlainObject$2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
189798
189881
  const describeType$1 = (v) => {
189799
189882
  if (v === null) return "null";
@@ -189802,256 +189885,325 @@ const describeType$1 = (v) => {
189802
189885
  };
189803
189886
  /** A whole-value parameter reference: a string that is EXACTLY `$name`. */
189804
189887
  const PARAM_REF = /^\$([A-Za-z_][A-Za-z0-9_]*)$/;
189805
- const KNOWN_SUBMACHINE_KEYS = /* @__PURE__ */ new Set(["params", "states"]);
189806
- const KNOWN_INVOCATION_KEYS = /* @__PURE__ */ new Set([
189807
- "submachine",
189808
- "as",
189809
- "with",
189810
- "set",
189811
- "name"
189812
- ]);
189813
- /** Rewrite an `on`/`retry.otherwise` target: a local -> its concrete name, a `$param` -> its binding, anything else verbatim. */
189814
- const resolveTarget = (target, ctx, errors) => {
189815
- const m = PARAM_REF.exec(target);
189816
- if (m) {
189817
- const value = ctx.params[m[1]];
189818
- if (value === void 0) {
189819
- errors.push(`${ctx.where}: target references unbound param "$${m[1]}"`);
189820
- return target;
189821
- }
189822
- if (typeof value !== "string") {
189823
- errors.push(`${ctx.where}: param "$${m[1]}" used as a target must be a string`);
189824
- return target;
189888
+ const qualify = (path, local) => path === "" ? local : `${path}.${local}`;
189889
+ /** A local is a REFERENCE iff its raw value carries a `machine` key. */
189890
+ const isRef = (v) => isPlainObject$2(v) && typeof v["machine"] === "string";
189891
+ /**
189892
+ * Resolve one `with:` value against the CALLER's own bindings: a whole-value
189893
+ * `$param` naming one of them passes that binding down verbatim (value AND
189894
+ * scope unchanged — the mechanism that makes a threaded param resolve in the
189895
+ * grandparent's namespace); anything else is a fresh binding scoped to the
189896
+ * caller's own instance (where it was written).
189897
+ */
189898
+ const resolveWithValue = (value, callerBindings, callerPath) => {
189899
+ if (typeof value === "string") {
189900
+ const m = PARAM_REF.exec(value);
189901
+ if (m) {
189902
+ const existing = callerBindings[m[1]];
189903
+ if (existing !== void 0) return existing;
189825
189904
  }
189826
- return value;
189827
189905
  }
189828
- if (ctx.locals.has(target)) return ctx.as[target] ?? target;
189829
- return target;
189906
+ return {
189907
+ value,
189908
+ scope: callerPath
189909
+ };
189830
189910
  };
189831
- /** Substitute a WHOLE-value `$param` in any scalar string field; non-`$param` strings and non-strings pass through unchanged. */
189832
- const substituteScalar = (value, ctx, errors) => {
189911
+ /**
189912
+ * Instantiate one local of `machineName` (a state or a reference) into
189913
+ * `locals`/`children`, pushing (and skipping only this local for) a dotted
189914
+ * name. A reference recurses via `instantiate`, first resolving its `with:`
189915
+ * bindings against the CALLER's own bindings (see `resolveWithValue`).
189916
+ */
189917
+ const instantiateLocal = (localName, def, machineName, path, nextAncestors, bindings, ctx, locals, children) => {
189918
+ if (localName.includes(".")) {
189919
+ ctx.errors.push(`machine "${machineName}": local name "${localName}" must not contain "."`);
189920
+ return;
189921
+ }
189922
+ if (!isRef(def)) {
189923
+ locals.set(localName, { kind: "state" });
189924
+ return;
189925
+ }
189926
+ locals.set(localName, {
189927
+ kind: "ref",
189928
+ refKey: localName
189929
+ });
189930
+ ctx.referenced.add(def.machine);
189931
+ const withRaw = isPlainObject$2(def.with) ? def.with : {};
189932
+ const childBindings = {};
189933
+ for (const [key, value] of Object.entries(withRaw)) childBindings[key] = resolveWithValue(value, bindings, path);
189934
+ const child = instantiate(def.machine, qualify(path, localName), nextAncestors, childBindings, ctx, `machines.${machineName}.${localName}`);
189935
+ if (child !== void 0) children.push(child);
189936
+ };
189937
+ /**
189938
+ * Instantiate one machine at `path`, recursing into every local reference.
189939
+ * Pushes (and skips only the offending subtree/local for) a cycle, an
189940
+ * unknown `machine:` name, or a dotted local name. `unknownLocation` is the
189941
+ * finding's location prefix if `machineName` itself doesn't resolve (the
189942
+ * reference site for a child, `entry.default` for the root).
189943
+ */
189944
+ const instantiate = (machineName, path, ancestorChain, bindings, ctx, unknownLocation) => {
189945
+ if (ancestorChain.includes(machineName)) {
189946
+ ctx.errors.push(`machine reference cycle: ${[...ancestorChain, machineName].join(" → ")}`);
189947
+ return;
189948
+ }
189949
+ const rawMachine = ctx.machinesRaw[machineName];
189950
+ if (rawMachine === void 0) {
189951
+ ctx.errors.push(`${unknownLocation}: unknown machine "${machineName}"`);
189952
+ return;
189953
+ }
189954
+ if (!isPlainObject$2(rawMachine) || !isPlainObject$2(rawMachine["states"])) {
189955
+ ctx.errors.push(`machine "${machineName}": must declare a "states" mapping`);
189956
+ return;
189957
+ }
189958
+ const statesRaw = rawMachine["states"];
189959
+ const locals = /* @__PURE__ */ new Map();
189960
+ const children = [];
189961
+ const nextAncestors = [...ancestorChain, machineName];
189962
+ for (const [localName, def] of Object.entries(statesRaw)) instantiateLocal(localName, def, machineName, path, nextAncestors, bindings, ctx, locals, children);
189963
+ const instance = {
189964
+ path,
189965
+ machine: machineName,
189966
+ bindings,
189967
+ locals,
189968
+ children
189969
+ };
189970
+ ctx.instancesByPath.set(path, instance);
189971
+ return instance;
189972
+ };
189973
+ /** Resolve a `$param` target: recurse into the binding's own scope instance (see "Bindings carry their scope"), or report it unbound. */
189974
+ const resolveParamTarget = (name, instance, instancesByPath, machinesRaw, trail) => {
189975
+ const binding = instance.bindings[name];
189976
+ if (binding === void 0 || typeof binding.value !== "string") return {
189977
+ kind: "unbound",
189978
+ name,
189979
+ trail
189980
+ };
189981
+ const scopeInstance = instancesByPath.get(binding.scope);
189982
+ if (scopeInstance === void 0) return {
189983
+ kind: "unbound",
189984
+ name,
189985
+ trail
189986
+ };
189987
+ return resolveCore(binding.value, scopeInstance, instancesByPath, machinesRaw, trail);
189988
+ };
189989
+ /** Resolve a local-of-`instance` target: a state (no remainder) qualifies directly; a reference recurses into its child, through its own `entry:` when there's no remainder. */
189990
+ const resolveLocalTarget = (first, remainder, instance, instancesByPath, machinesRaw, trail) => {
189991
+ const local = instance.locals.get(first);
189992
+ if (local === void 0 || local.kind === "state" && remainder !== void 0) return {
189993
+ kind: "invalid",
189994
+ machine: instance.machine,
189995
+ trail
189996
+ };
189997
+ if (local.kind === "state") return {
189998
+ kind: "ok",
189999
+ value: qualify(instance.path, first),
190000
+ trail
190001
+ };
190002
+ const child = instancesByPath.get(qualify(instance.path, first));
190003
+ if (child === void 0) return {
190004
+ kind: "invalid",
190005
+ machine: instance.machine,
190006
+ trail
190007
+ };
190008
+ if (remainder === void 0) {
190009
+ const childMachine = machinesRaw[child.machine];
190010
+ const childEntry = isPlainObject$2(childMachine) ? childMachine["entry"] : void 0;
190011
+ if (typeof childEntry !== "string") return {
190012
+ kind: "invalid",
190013
+ machine: child.machine,
190014
+ trail
190015
+ };
190016
+ return resolveCore(childEntry, child, instancesByPath, machinesRaw, [...trail, first]);
190017
+ }
190018
+ return resolveCore(remainder, child, instancesByPath, machinesRaw, trail);
190019
+ };
190020
+ /**
190021
+ * The pure target resolution at the heart of this module — see the "The
190022
+ * resolver" section of the module doc comment. `trail` accumulates the
190023
+ * reference-key breadcrumb for a "reference, no remainder" hop (recursing
190024
+ * into a machine's own `entry:`), so a finding surfacing from deep inside
190025
+ * that recursion can still name the reference it went through.
190026
+ */
190027
+ const resolveCore = (target, instance, instancesByPath, machinesRaw, trail = []) => {
190028
+ const paramMatch = PARAM_REF.exec(target);
190029
+ if (paramMatch) return resolveParamTarget(paramMatch[1], instance, instancesByPath, machinesRaw, trail);
190030
+ const dotIdx = target.indexOf(".");
190031
+ const first = dotIdx === -1 ? target : target.slice(0, dotIdx);
190032
+ const remainder = dotIdx === -1 ? void 0 : target.slice(dotIdx + 1);
190033
+ return resolveLocalTarget(first, remainder, instance, instancesByPath, machinesRaw, trail);
190034
+ };
190035
+ const formatTrail = (where, trail) => trail.length > 0 ? `${where} (${trail.join(" → ")})` : where;
190036
+ /** Resolve an `on`/`retry.otherwise` target, pushing the "on target" sideways/upward wording on failure. */
190037
+ const resolveOnTarget = (target, instance, where, instancesByPath, machinesRaw, errors) => {
190038
+ const result = resolveCore(target, instance, instancesByPath, machinesRaw);
190039
+ const label = formatTrail(where, result.trail);
190040
+ if (result.kind === "ok") return result.value;
190041
+ if (result.kind === "unbound") {
190042
+ errors.push(`${label}: references unbound param "$${result.name}"`);
190043
+ return;
190044
+ }
190045
+ errors.push(`${label}: "on" target "${target}" is not a state or reference of machine "${result.machine}" — declare a "params:" entry and bind it at the reference site`);
190046
+ };
190047
+ /** Resolve the root machine's own `entry:` (`entry.default`), pushing the distinct "entry" wording on failure. */
190048
+ const resolveEntry = (entryKey, target, instance, instancesByPath, machinesRaw, errors) => {
190049
+ const result = resolveCore(target, instance, instancesByPath, machinesRaw);
190050
+ if (result.kind === "ok") return result.value;
190051
+ if (result.kind === "unbound") {
190052
+ errors.push(`"${entryKey}" references unbound param "$${result.name}"`);
190053
+ return;
190054
+ }
190055
+ errors.push(`"${entryKey}" names "${target}", which is not a state or machine reference`);
190056
+ };
190057
+ /** Substitute a WHOLE-value `$param` in any scalar field against `instance`'s own bindings — a literal splice, no scope-aware resolution. */
190058
+ const substituteScalar = (value, instance, where, errors) => {
189833
190059
  if (typeof value !== "string") return value;
189834
190060
  const m = PARAM_REF.exec(value);
189835
190061
  if (!m) return value;
189836
- const bound = ctx.params[m[1]];
189837
- if (bound === void 0) {
189838
- errors.push(`${ctx.where}: references unbound param "$${m[1]}"`);
190062
+ const name = m[1];
190063
+ const binding = instance.bindings[name];
190064
+ if (binding === void 0) {
190065
+ errors.push(`${where}: references unbound param "$${name}"`);
189839
190066
  return value;
189840
190067
  }
189841
- return bound;
190068
+ return binding.value;
189842
190069
  };
189843
- /** Rewrite the `on` mapping — each row's target (string, or `{ to, describe }`) — preserving pattern keys and declaration order. */
189844
- const expandOn = (raw, ctx, errors) => {
190070
+ /** Rewrite one object-form `on` row (`{ to, describe?, action? }`): `to` through the resolver, `describe`/`action` through whole-value `$param` substitution. */
190071
+ const emitOnObjectEdge = (value, instance, where, instancesByPath, machinesRaw, errors) => {
190072
+ const next = { ...value };
190073
+ if (typeof value["to"] === "string") next["to"] = resolveOnTarget(value["to"], instance, where, instancesByPath, machinesRaw, errors) ?? value["to"];
190074
+ if (typeof value["describe"] === "string") next["describe"] = substituteScalar(value["describe"], instance, where, errors);
190075
+ if (typeof value["action"] === "string") next["action"] = substituteScalar(value["action"], instance, where, errors);
190076
+ return next;
190077
+ };
190078
+ /** Rewrite the `on` mapping — each row's target (string, or `{ to, describe, action }`) — through the resolver, preserving pattern keys and declaration order. */
190079
+ const emitOn = (raw, instance, where, instancesByPath, machinesRaw, errors) => {
189845
190080
  if (!isPlainObject$2(raw)) return raw;
189846
190081
  const out = {};
189847
- for (const [pattern, value] of Object.entries(raw)) if (typeof value === "string") out[pattern] = resolveTarget(value, ctx, errors);
189848
- else if (isPlainObject$2(value)) {
189849
- const next = { ...value };
189850
- if (typeof value["to"] === "string") next["to"] = resolveTarget(value["to"], ctx, errors);
189851
- if (typeof value["describe"] === "string") next["describe"] = substituteScalar(value["describe"], ctx, errors);
189852
- if (typeof value["action"] === "string") next["action"] = substituteScalar(value["action"], ctx, errors);
189853
- out[pattern] = next;
189854
- } else out[pattern] = value;
190082
+ for (const [pattern, value] of Object.entries(raw)) if (typeof value === "string") out[pattern] = resolveOnTarget(value, instance, where, instancesByPath, machinesRaw, errors) ?? value;
190083
+ else if (isPlainObject$2(value)) out[pattern] = emitOnObjectEdge(value, instance, where, instancesByPath, machinesRaw, errors);
190084
+ else out[pattern] = value;
189855
190085
  return out;
189856
190086
  };
189857
- /** Rewrite a `retry` block's `otherwise` target (and pass `max` through). */
189858
- const expandRetry = (raw, ctx, errors) => {
190087
+ /** Rewrite a `retry` block's `otherwise` target through the resolver (and pass `max` through). */
190088
+ const emitRetry = (raw, instance, where, instancesByPath, machinesRaw, errors) => {
189859
190089
  if (!isPlainObject$2(raw)) return raw;
189860
190090
  const next = { ...raw };
189861
- if (typeof raw["otherwise"] === "string") next["otherwise"] = resolveTarget(raw["otherwise"], ctx, errors);
190091
+ if (typeof raw["otherwise"] === "string") next["otherwise"] = resolveOnTarget(raw["otherwise"], instance, where, instancesByPath, machinesRaw, errors) ?? raw["otherwise"];
189862
190092
  return next;
189863
190093
  };
189864
- /** Clone one sub-machine local state, applying param substitution and target rewriting. */
189865
- const expandState = (stateRaw, ctx, errors) => {
190094
+ /** Clone one instance's own local state, applying `$param` substitution and `on`/`retry` target resolution. */
190095
+ const emitState = (stateRaw, instance, localName, instancesByPath, machinesRaw, errors) => {
190096
+ const where = `machines.${instance.machine}.${localName}`;
189866
190097
  if (!isPlainObject$2(stateRaw)) {
189867
- errors.push(`${ctx.where}: sub-machine state must be an object, got ${describeType$1(stateRaw)}`);
190098
+ errors.push(`${where}: state must be an object, got ${describeType$1(stateRaw)}`);
189868
190099
  return {};
189869
190100
  }
189870
190101
  const out = {};
189871
- for (const [key, value] of Object.entries(stateRaw)) if (key === "on") out[key] = expandOn(value, ctx, errors);
189872
- else if (key === "retry") out[key] = expandRetry(value, ctx, errors);
189873
- else out[key] = substituteScalar(value, ctx, errors);
189874
- return out;
189875
- };
189876
- /** Validate the `submachines:` map's shape (findings pushed onto `errors`), returning it best-effort. */
189877
- const readSubmachines = (raw, declared, errors) => {
189878
- const submachines = isPlainObject$2(raw) ? raw : {};
189879
- if (declared && !isPlainObject$2(raw)) errors.push(`"submachines" must be a mapping of name -> { params, states }, got ${describeType$1(raw)}`);
189880
- for (const [name, sm] of Object.entries(submachines)) {
189881
- if (!isPlainObject$2(sm)) {
189882
- errors.push(`submachine "${name}": must be an object with "params" and "states"`);
190102
+ for (const [key, value] of Object.entries(stateRaw)) {
190103
+ if (key === "on") {
190104
+ out[key] = emitOn(value, instance, where, instancesByPath, machinesRaw, errors);
189883
190105
  continue;
189884
190106
  }
189885
- const unknown = Object.keys(sm).filter((k) => !KNOWN_SUBMACHINE_KEYS.has(k));
189886
- if (unknown.length > 0) errors.push(`submachine "${name}": unknown key(s) ${unknown.join(", ")}`);
189887
- if (!isPlainObject$2(sm["states"]) || Object.keys(sm["states"]).length === 0) errors.push(`submachine "${name}": "states" must be a non-empty mapping`);
189888
- }
189889
- return submachines;
189890
- };
189891
- /** Read one invocation's `as:` rename map (local -> concrete), validating against the sub-machine's locals. */
189892
- const readAs = (raw, smStates, where, errors) => {
189893
- const as = {};
189894
- if (raw === void 0) return as;
189895
- if (!isPlainObject$2(raw)) {
189896
- errors.push(`${where}: "as" must be a mapping of local -> concrete name`);
189897
- return as;
189898
- }
189899
- for (const [local, concrete] of Object.entries(raw)) {
189900
- if (typeof concrete !== "string") {
189901
- errors.push(`${where}: "as.${local}" must be a string`);
190107
+ if (key === "retry") {
190108
+ out[key] = emitRetry(value, instance, where, instancesByPath, machinesRaw, errors);
189902
190109
  continue;
189903
190110
  }
189904
- if (!(local in smStates)) errors.push(`${where}: "as" names "${local}", not a state of it`);
189905
- as[local] = concrete;
190111
+ const resolved = substituteScalar(value, instance, where, errors);
190112
+ if (typeof value === "string" && PARAM_REF.test(value) && resolved === "") continue;
190113
+ out[key] = resolved;
189906
190114
  }
189907
- return as;
190115
+ return out;
189908
190116
  };
189909
- /** Read one invocation's `set:` overrides (local -> extra fields), validating against the sub-machine's locals. */
189910
- const readSet = (raw, smStates, where, errors) => {
189911
- const set = {};
189912
- if (raw === void 0) return set;
190117
+ /**
190118
+ * Resolve an instance's own `model` value, substituting a whole-value
190119
+ * `$param` at the reference site. A whole-value `$param` that resolves to the
190120
+ * empty string compiles away to "field absent" (mirrors the same rule in
190121
+ * `emitState`) — an instance that doesn't bind a model for this machine
190122
+ * should not stamp `model: ""`.
190123
+ */
190124
+ const resolveInstanceModel = (rawMachine, instance, errors) => {
190125
+ const rawModel = rawMachine["model"];
190126
+ if (rawModel === void 0) return void 0;
190127
+ const substituted = substituteScalar(rawModel, instance, `machines.${instance.machine}`, errors);
190128
+ if (typeof rawModel === "string" && PARAM_REF.test(rawModel) && substituted === "") return;
190129
+ return substituted;
190130
+ };
190131
+ /** Walk the instance tree, emitting every instance's own direct states into `states` (qualified). */
190132
+ const emitTree = (instance, machinesRaw, states, scopes, instancesByPath, errors) => {
190133
+ const rawMachine = machinesRaw[instance.machine];
190134
+ const statesRaw = rawMachine["states"];
190135
+ const resolvedModel = resolveInstanceModel(rawMachine, instance, errors);
190136
+ for (const [localName, local] of instance.locals) {
190137
+ if (local.kind !== "state") continue;
190138
+ const qualified = qualify(instance.path, localName);
190139
+ const emitted = emitState(statesRaw[localName], instance, localName, instancesByPath, machinesRaw, errors);
190140
+ if (resolvedModel !== void 0 && typeof emitted["prompt"] === "string") emitted["model"] = resolvedModel;
190141
+ states[qualified] = emitted;
190142
+ scopes[qualified] = instance.path;
190143
+ }
190144
+ for (const child of instance.children) emitTree(child, machinesRaw, states, scopes, instancesByPath, errors);
190145
+ };
190146
+ /** Project an `Instance` tree into its `MachineNode` visualization tree. */
190147
+ const buildTree = (instance) => ({
190148
+ key: instance.path === "" ? instance.machine : instance.path,
190149
+ machine: instance.machine,
190150
+ states: Array.from(instance.locals.entries()).filter(([, local]) => local.kind === "state").map(([local]) => qualify(instance.path, local)),
190151
+ children: instance.children.map(buildTree)
190152
+ });
190153
+ /**
190154
+ * Flatten a raw `entry:`/`machines:` config into qualified states, resolved
190155
+ * entry points, and a visualization tree — see the module doc comment.
190156
+ * Findings are pushed onto `errors`; a structurally invalid `raw` (not an
190157
+ * object, or missing `entry.default`) yields the all-empty/`undefined`
190158
+ * shape without attempting the passes.
190159
+ */
190160
+ const flattenMachines = (raw, errors) => {
190161
+ const empty = {
190162
+ states: {},
190163
+ entries: void 0,
190164
+ tree: void 0,
190165
+ scopes: {}
190166
+ };
189913
190167
  if (!isPlainObject$2(raw)) {
189914
- errors.push(`${where}: "set" must be a mapping of local -> { field: value }`);
189915
- return set;
189916
- }
189917
- for (const [local, fields] of Object.entries(raw)) {
189918
- if (!isPlainObject$2(fields)) {
189919
- errors.push(`${where}: "set.${local}" must be an object of extra fields`);
189920
- continue;
189921
- }
189922
- if (!(local in smStates)) errors.push(`${where}: "set" names "${local}", not a state of it`);
189923
- set[local] = fields;
189924
- }
189925
- return set;
189926
- };
189927
- /** Validate one invocation's shape and resolve its named sub-machine; `undefined` (with a finding) on anything malformed. */
189928
- const resolveInvocation = (invRaw, index, submachines, errors) => {
189929
- const where = `use[${index}]`;
189930
- if (!isPlainObject$2(invRaw)) {
189931
- errors.push(`${where}: each invocation must be an object with a "submachine" key`);
189932
- return;
189933
- }
189934
- const unknown = Object.keys(invRaw).filter((k) => !KNOWN_INVOCATION_KEYS.has(k));
189935
- if (unknown.length > 0) errors.push(`${where}: unknown key(s) ${unknown.join(", ")}`);
189936
- const smName = invRaw["submachine"];
189937
- if (typeof smName !== "string") {
189938
- errors.push(`${where}: "submachine" must be a string naming a declared sub-machine`);
189939
- return;
189940
- }
189941
- const sm = submachines[smName];
189942
- if (!isPlainObject$2(sm) || !isPlainObject$2(sm["states"])) {
189943
- errors.push(`${where}: unknown sub-machine "${smName}"`);
189944
- return;
190168
+ errors.push(`workflow must be an object, got ${describeType$1(raw)}`);
190169
+ return empty;
189945
190170
  }
189946
- return {
189947
- smStates: sm["states"],
189948
- scope: `${where} (${smName})`,
189949
- invocation: invRaw
189950
- };
189951
- };
189952
- /** Clone each local state into its concrete name, claiming names (collision → finding) and applying `set:` overrides. */
189953
- const produceStates = (smStates, ctx, set, claimed, errors) => {
189954
- const out = {};
189955
- for (const [local, stateRaw] of Object.entries(smStates)) {
189956
- const concrete = ctx.as[local] ?? local;
189957
- if (claimed.has(concrete)) {
189958
- errors.push(`${ctx.where}: expands to state "${concrete}", which collides with an existing state`);
189959
- continue;
189960
- }
189961
- claimed.add(concrete);
189962
- const state = expandState(stateRaw, ctx, errors);
189963
- const overrides = set[local];
189964
- out[concrete] = overrides !== void 0 ? {
189965
- ...state,
189966
- ...overrides
189967
- } : state;
190171
+ const entryRaw = raw["entry"];
190172
+ if (!isPlainObject$2(entryRaw) || typeof entryRaw["default"] !== "string") {
190173
+ errors.push(`"entry.default" must name a machine`);
190174
+ return empty;
189968
190175
  }
189969
- return out;
189970
- };
189971
- /**
189972
- * Expand ONE `use:` invocation into concrete states, tracking claimed names in
189973
- * `claimed` (mutated) and pushing findings onto `errors`. Returns the produced
189974
- * `concreteName -> stateDef` map (empty on a malformed invocation).
189975
- */
189976
- const expandInvocation = (invRaw, index, submachines, claimed, errors) => {
189977
- const resolved = resolveInvocation(invRaw, index, submachines, errors);
189978
- if (resolved === void 0) return {};
189979
- const { smStates, scope, invocation } = resolved;
189980
- const as = readAs(invocation["as"], smStates, scope, errors);
189981
- const withRaw = invocation["with"];
189982
- const params = isPlainObject$2(withRaw) ? withRaw : {};
189983
- if (withRaw !== void 0 && !isPlainObject$2(withRaw)) errors.push(`${scope}: "with" must be a mapping of param -> value`);
189984
- const set = readSet(invocation["set"], smStates, scope, errors);
189985
- const ctx = {
189986
- locals: new Set(Object.keys(smStates)),
189987
- as,
189988
- params,
189989
- where: scope
190176
+ const machinesRaw = isPlainObject$2(raw["machines"]) ? raw["machines"] : {};
190177
+ const instancesByPath = /* @__PURE__ */ new Map();
190178
+ const referenced = /* @__PURE__ */ new Set();
190179
+ const rootMachineName = entryRaw["default"];
190180
+ referenced.add(rootMachineName);
190181
+ const root = instantiate(rootMachineName, "", [], {}, {
190182
+ machinesRaw,
190183
+ referenced,
190184
+ instancesByPath,
190185
+ errors
190186
+ }, "entry.default");
190187
+ for (const name of Object.keys(machinesRaw)) if (!referenced.has(name)) errors.push(`machine "${name}" is declared but never referenced`);
190188
+ if (root === void 0) return empty;
190189
+ const states = {};
190190
+ const scopes = {};
190191
+ emitTree(root, machinesRaw, states, scopes, instancesByPath, errors);
190192
+ const tree = buildTree(root);
190193
+ const rootEntry = machinesRaw[rootMachineName]["entry"];
190194
+ const defaultResolved = typeof rootEntry === "string" ? resolveEntry("entry.default", rootEntry, root, instancesByPath, machinesRaw, errors) : (errors.push(`machine "${rootMachineName}": "entry" must be a string`), void 0);
190195
+ if (defaultResolved === void 0) return {
190196
+ states,
190197
+ entries: void 0,
190198
+ tree,
190199
+ scopes
189990
190200
  };
189991
- return produceStates(smStates, ctx, set, claimed, errors);
189992
- };
189993
- /**
189994
- * Expand `submachines:`/`use:` into concrete `states`. Returns a NEW raw object
189995
- * with those two keys removed and `states` augmented; a non-object input, or one
189996
- * declaring NEITHER key, is returned unchanged (so ordinary workflows are a
189997
- * pure passthrough). Findings are pushed onto `errors`.
189998
- */
189999
- const expandSubmachines = (raw, errors) => {
190000
- if (!isPlainObject$2(raw)) return raw;
190001
- if (raw["submachines"] === void 0 && raw["use"] === void 0) return raw;
190002
- const submachines = readSubmachines(raw["submachines"], raw["submachines"] !== void 0, errors);
190003
- const useRaw = raw["use"];
190004
- const invocations = Array.isArray(useRaw) ? useRaw : [];
190005
- if (raw["use"] !== void 0 && !Array.isArray(useRaw)) errors.push(`"use" must be an array of invocations, got ${describeType$1(useRaw)}`);
190006
- const baseStates = isPlainObject$2(raw["states"]) ? { ...raw["states"] } : {};
190007
- const claimed = new Set(Object.keys(baseStates));
190008
- const expanded = {};
190009
- invocations.forEach((invRaw, index) => {
190010
- Object.assign(expanded, expandInvocation(invRaw, index, submachines, claimed, errors));
190011
- });
190012
- const result = {
190013
- ...raw,
190014
- states: {
190015
- ...baseStates,
190016
- ...expanded
190017
- }
190201
+ return {
190202
+ states,
190203
+ entries: { default: defaultResolved },
190204
+ tree,
190205
+ scopes
190018
190206
  };
190019
- delete result["submachines"];
190020
- delete result["use"];
190021
- return result;
190022
- };
190023
- /**
190024
- * Describe a raw workflow's `use:` invocations as visualization GROUPS — which
190025
- * concrete states each sub-machine invocation produced — WITHOUT expanding or
190026
- * validating (a best-effort read for tooling like `gtd visualize`; malformed
190027
- * entries are skipped, not reported). A workflow with no `use:` yields `[]`.
190028
- * This is the only way to recover the sub-machine grouping after
190029
- * `compileWorkflowConfig` has flattened it away.
190030
- */
190031
- const collectGroups = (raw) => {
190032
- if (!isPlainObject$2(raw)) return [];
190033
- const submachines = isPlainObject$2(raw["submachines"]) ? raw["submachines"] : {};
190034
- const use = Array.isArray(raw["use"]) ? raw["use"] : [];
190035
- const smUseCount = /* @__PURE__ */ new Map();
190036
- for (const inv of use) if (isPlainObject$2(inv) && typeof inv["submachine"] === "string") smUseCount.set(inv["submachine"], (smUseCount.get(inv["submachine"]) ?? 0) + 1);
190037
- const groups = [];
190038
- use.forEach((inv, index) => {
190039
- if (!isPlainObject$2(inv)) return;
190040
- const smName = inv["submachine"];
190041
- if (typeof smName !== "string") return;
190042
- const sm = submachines[smName];
190043
- if (!isPlainObject$2(sm) || !isPlainObject$2(sm["states"])) return;
190044
- const smStates = sm["states"];
190045
- const as = isPlainObject$2(inv["as"]) ? inv["as"] : {};
190046
- const states = Object.keys(smStates).map((local) => typeof as[local] === "string" ? as[local] : local);
190047
- const name = typeof inv["name"] === "string" ? inv["name"] : (smUseCount.get(smName) ?? 0) > 1 ? `${smName}#${index}` : smName;
190048
- groups.push({
190049
- submachine: smName,
190050
- name,
190051
- states
190052
- });
190053
- });
190054
- return groups;
190055
190207
  };
190056
190208
  //#endregion
190057
190209
  //#region src/PatternConfig.ts
@@ -190073,24 +190225,35 @@ const collectGroups = (raw) => {
190073
190225
  * <name>:
190074
190226
  * format: <shell command> # at least one of format/validate
190075
190227
  * validate: <shell command>
190076
- * states:
190228
+ * entry:
190229
+ * default: <machine name> # which machine is the ROOT instance
190230
+ * machines:
190077
190231
  * <name>:
190078
- * actor: <string> # forbidden on a commit state, required otherwise
190079
- * script: <string> # exactly one of script/prompt/message/commit
190080
- * on: # a mapping, DECLARATION ORDER PRESERVED — each <pattern> KEY is itself an Eta template, rendered against `it.vars` at the edge (src/Edge.ts's renderOnEdges) before the pure engine ever sees it
190081
- * "<pattern>": <targetState> # short form
190082
- * "<pattern>": { to: <targetState>, describe: <sentence> } # with a human-readable route description (describe is NEVER Eta-rendered, unlike the pattern key)
190083
- * initial: true # exactly one state across the whole workflow
190084
- * retry:
190085
- * max: <number>
190086
- * otherwise: <targetState>
190087
- * model: <string> # optional, opaque harness hint — never on a commit state
190088
- * memory: <string> # optional, opaque memory-scope label — never on a commit state
190089
- * label: <string> # optional, opaque display name — never on a commit state
190090
- * file: <string> # optional, an Eta template naming the state's steering file — never on a commit state
190091
- * mode: <modeName> # optional, requires "file" — a built-in (qa/review) or a `modes:` entry; never on a commit state
190232
+ * params: [<param>, ...]? # advisory only — documents which $params a caller may bind
190233
+ * model: <string>? # optional, opaque harness hint — stamped onto every one of THIS machine's own `prompt` states; a state may NOT declare its own
190234
+ * entry: <local or ref key> # this machine's OWN default local, resolved recursively
190235
+ * states:
190236
+ * <local>: # an ordinary state
190237
+ * actor: <string> # forbidden on a commit state, required otherwise
190238
+ * script: <string> # exactly one of script/prompt/message/commit
190239
+ * on: # a mapping, DECLARATION ORDER PRESERVED — each <pattern> KEY is itself an Eta template, rendered against `it.vars` at the edge (src/Edge.ts's renderOnEdges) before the pure engine ever sees it
190240
+ * "<pattern>": <targetState> # short form
190241
+ * "<pattern>": { to: <targetState>, describe: <sentence> } # with a human-readable route description (describe is NEVER Eta-rendered, unlike the pattern key)
190242
+ * retry:
190243
+ * max: <number>
190244
+ * otherwise: <targetState>
190245
+ * label: <string> # optional, opaque display name — never on a commit state
190246
+ * file: <string> # optional, an Eta template naming the state's steering file — never on a commit state
190247
+ * mode: <modeName> # optional, requires "file" — a built-in (qa/review) or a `modes:` entry; never on a commit state
190248
+ * entry: true # optional — an EXTRA reachability root (WorkflowEntries.manual), enterable via `gtd step <actor> --entry <this state's qualified name>`; distinct from the top-level `entry:` key above
190249
+ * <local>: { machine: <name>, with: { ... } } # a REFERENCE — instantiates <name> as a child, see src/Machines.ts
190092
190250
  * ```
190093
190251
  *
190252
+ * The raw `entry:`/`machines:` value is flattened by `src/Machines.ts`'s
190253
+ * `flattenMachines` into qualified states plus resolved entry points BEFORE any
190254
+ * per-state compilation below ever runs — this module never sees an unresolved
190255
+ * `$param` or a bare reference path.
190256
+ *
190094
190257
  * ## `vars:` — one of `it.vars`'s three layers
190095
190258
  *
190096
190259
  * A sibling `vars:` key INSIDE the `workflow:` value declares the workflow's
@@ -190252,25 +190415,166 @@ const KNOWN_STATE_KEYS = /* @__PURE__ */ new Set([
190252
190415
  "actor",
190253
190416
  ...CONTENT_KEYS,
190254
190417
  "on",
190255
- "initial",
190256
190418
  "retry",
190257
190419
  "model",
190258
- "memory",
190259
190420
  "label",
190260
190421
  "file",
190261
190422
  "mode",
190262
190423
  "reviewWindow",
190263
190424
  "reviewBase",
190264
- "reviewEntry",
190265
- "fixEntry",
190266
190425
  "requireProgress",
190267
- "answerGate"
190426
+ "answerGate",
190427
+ "entry"
190268
190428
  ]);
190269
190429
  const KNOWN_TOP_KEYS = /* @__PURE__ */ new Set([
190430
+ "entry",
190431
+ "machines",
190270
190432
  "vars",
190271
- "states",
190272
190433
  "modes"
190273
190434
  ]);
190435
+ const KNOWN_MACHINE_KEYS = /* @__PURE__ */ new Set([
190436
+ "params",
190437
+ "entry",
190438
+ "states",
190439
+ "model"
190440
+ ]);
190441
+ const KNOWN_REF_KEYS = /* @__PURE__ */ new Set(["machine", "with"]);
190442
+ /** A state-level key removed by the `entry:`/`machines:` rewrite (or, for `memory`, by the machine-scoped-memory restructure), naming its replacement so a stale config's error points somewhere useful instead of a bare "unknown key". The state-level `model` case is instead caught pre-flatten by `LEGACY_AUTHORED_STATE_KEY_HINTS`/`validateMachineStateKeys`, because by the time a state reaches `KNOWN_STATE_KEYS`/`compileState` its `model` may be a legitimately machine-stamped key. */
190443
+ const LEGACY_STATE_KEY_HINTS = {
190444
+ initial: `"initial" no longer exists — declare this state's qualified path in the top-level "entry.default" instead`,
190445
+ reviewEntry: `"reviewEntry" no longer exists — declare "entry: true" on this state instead`,
190446
+ fixEntry: `"fixEntry" no longer exists — declare "entry: true" on this state instead`,
190447
+ memory: `"memory" no longer exists — a machine's memory scope is derived from its position in the tree and starts fresh on every entry`
190448
+ };
190449
+ /** A reference-level key from the old `use:` invocation shape, naming its replacement. */
190450
+ const LEGACY_REF_KEY_HINTS = {
190451
+ as: `"as" no longer exists — a reference's local name (the key itself) IS the concrete name; there is nothing left to rename`,
190452
+ name: `"name" no longer exists — a reference's local name (the key itself) names the instance`,
190453
+ set: `"set" no longer exists — bind extra per-instance values via "with:" instead`
190454
+ };
190455
+ /** Render an unknown-key list, appending a legacy-key hint in parentheses for any key `hints` recognizes. */
190456
+ const formatUnknownKeys = (keys, hints) => keys.map((k) => hints[k] !== void 0 ? `${k} (${hints[k]})` : k).join(", ");
190457
+ /**
190458
+ * A top-level key from the pre-Package-02 raw shape (flat `states:`, or the
190459
+ * sub-machine expander's `submachines:`/`use:`). Detected and thrown FIRST, before
190460
+ * any other validation, so a stale config produces only this migration table —
190461
+ * never forty downstream "machines is required"-style findings piled on top.
190462
+ */
190463
+ const LEGACY_TOP_KEY_MESSAGES = {
190464
+ states: `top-level "states:" is no longer supported — declare a machine under "machines:" and name it in "entry.default:"`,
190465
+ submachines: `top-level "submachines:" is no longer supported — declare machines directly under "machines:"`,
190466
+ use: `top-level "use:" is no longer supported — reference a machine inline via a { machine, with } entry inside a machine's own "states:"`
190467
+ };
190468
+ const detectLegacyShape = (raw) => {
190469
+ const found = Object.keys(LEGACY_TOP_KEY_MESSAGES).filter((k) => k in raw);
190470
+ if (found.length === 0) return;
190471
+ throw new Error(formatErrors(found.map((k) => LEGACY_TOP_KEY_MESSAGES[k])));
190472
+ };
190473
+ /**
190474
+ * A top-level `entry.review`/`entry.fix` key from the pre-`entry: true`
190475
+ * shape. Detected and thrown FIRST — right after `detectLegacyShape`, before
190476
+ * `flattenMachines` (or anything else) ever runs — so a stale config
190477
+ * migrating off the named review/fix entry points gets ONLY this message,
190478
+ * never mixed with `detectLegacyShape`'s own unrelated top-level findings or
190479
+ * any generic downstream complaint about an unknown key inside `entry:`.
190480
+ */
190481
+ const LEGACY_ENTRY_KEY_MESSAGES = {
190482
+ review: `entry.review is no longer supported — declare \`entry: true\` on that state and enter it with \`gtd --entry <state>\``,
190483
+ fix: `entry.fix is no longer supported — declare \`entry: true\` on that state and enter it with \`gtd --entry <state>\``
190484
+ };
190485
+ const detectLegacyEntryKeys = (raw) => {
190486
+ const entryRaw = raw["entry"];
190487
+ if (!isPlainObject$1(entryRaw)) return;
190488
+ const found = Object.keys(LEGACY_ENTRY_KEY_MESSAGES).filter((k) => k in entryRaw);
190489
+ if (found.length === 0) return;
190490
+ throw new Error(formatErrors(found.map((k) => LEGACY_ENTRY_KEY_MESSAGES[k])));
190491
+ };
190492
+ /**
190493
+ * Structural validation over the raw `machines:` map that `src/Machines.ts`'s
190494
+ * `flattenMachines` does not itself perform: unknown keys on a machine
190495
+ * (`KNOWN_MACHINE_KEYS`), keys that have MOVED off a state
190496
+ * (`LEGACY_AUTHORED_STATE_KEY_HINTS`), and unknown keys on a reference local
190497
+ * (`KNOWN_REF_KEYS`), the last surfacing the old `use:` invocation's
190498
+ * `as`/`name`/`set` keys through `LEGACY_REF_KEY_HINTS` rather than a bare
190499
+ * "unknown key". Findings are pushed onto `errors`; a malformed shape (not an
190500
+ * object, a non-object machine/state) is left for `flattenMachines`/
190501
+ * `compileState` to report — this pass only adds what they don't already cover.
190502
+ */
190503
+ /** Validate one machine's own reference locals against `KNOWN_REF_KEYS`, pushing findings onto `errors`. */
190504
+ const validateMachineRefs = (machineName, machineRaw, errors) => {
190505
+ const statesRaw = machineRaw["states"];
190506
+ if (!isPlainObject$1(statesRaw)) return;
190507
+ for (const [local, def] of Object.entries(statesRaw)) {
190508
+ if (!isRefRaw(def)) continue;
190509
+ const unknownRefKeys = Object.keys(def).filter((k) => !KNOWN_REF_KEYS.has(k));
190510
+ if (unknownRefKeys.length > 0) errors.push(`machine "${machineName}": reference "${local}": unknown key(s) ${formatUnknownKeys(unknownRefKeys, LEGACY_REF_KEY_HINTS)}`);
190511
+ }
190512
+ };
190513
+ /**
190514
+ * A key an author may no longer put on a STATE, mapped to the hint naming its
190515
+ * replacement. Checked against the AUTHORED `machines.<name>.states` map rather
190516
+ * than through `KNOWN_STATE_KEYS`/`compileState`, because those two see the
190517
+ * FLATTENED shape — where `src/Machines.ts` has already stamped the owning
190518
+ * machine's own `model` onto each of its `prompt` states, and a `model` key is
190519
+ * exactly what a well-formed emitted state carries.
190520
+ */
190521
+ const LEGACY_AUTHORED_STATE_KEY_HINTS = { model: (machineName) => `"model" is no longer a state key — declare it once on the machine that owns this state ("machines.${machineName}.model")` };
190522
+ /** Flag any `LEGACY_AUTHORED_STATE_KEY_HINTS` key declared on one machine's own (non-reference) states. */
190523
+ const validateMachineStateKeys = (machineName, machineRaw, errors) => {
190524
+ const statesRaw = machineRaw["states"];
190525
+ if (!isPlainObject$1(statesRaw)) return;
190526
+ for (const [local, def] of Object.entries(statesRaw)) {
190527
+ if (!isPlainObject$1(def) || isRefRaw(def)) continue;
190528
+ for (const [key, hint] of Object.entries(LEGACY_AUTHORED_STATE_KEY_HINTS)) {
190529
+ if (!(key in def)) continue;
190530
+ errors.push(`machine "${machineName}": state "${local}": unknown key(s) ${key} (${hint(machineName)})`);
190531
+ }
190532
+ }
190533
+ };
190534
+ /**
190535
+ * The machine-level `model:` field — the ONLY place a model may be authored
190536
+ * (`validateMachineStateKeys`, above, rejects the state-level form): a
190537
+ * non-empty string, else a load error naming the machine. Validates the value
190538
+ * a machine stamps onto its own `prompt` states (`src/Machines.ts`'s
190539
+ * `resolveInstanceModel`/`emitTree`); the per-state `compileModel` (below) is
190540
+ * then only the type guard over that STAMPED value.
190541
+ */
190542
+ const compileMachineModel = (machineName, machineRaw, errors) => {
190543
+ if (machineRaw.model === void 0) return;
190544
+ if (typeof machineRaw.model !== "string" || machineRaw.model === "") errors.push(`machines.${machineName}: "model" must be a non-empty string`);
190545
+ };
190546
+ /** Does `machineRaw` declare at least one of its OWN (non-reference) states with content kind `prompt`? Mirrors exactly which states `src/Machines.ts` stamps a machine-level `model` onto — a reference local's states belong to the CHILD machine, never this one. */
190547
+ const machineHasPromptState = (machineRaw) => {
190548
+ const statesRaw = machineRaw["states"];
190549
+ if (!isPlainObject$1(statesRaw)) return false;
190550
+ return Object.values(statesRaw).some((def) => isPlainObject$1(def) && !isRefRaw(def) && typeof def["prompt"] === "string");
190551
+ };
190552
+ /**
190553
+ * A machine declaring `model:` with no `prompt`-content state anywhere among
190554
+ * its own states is a load error, not a silent no-op — see this module's
190555
+ * "BOUNDARY CORRECTION" note in package 02: the model would never land on any
190556
+ * emitted state, so the declaration does nothing.
190557
+ */
190558
+ const validateMachineModelHasPromptState = (machineName, machineRaw, errors) => {
190559
+ if (machineRaw.model === void 0) return;
190560
+ if (!machineHasPromptState(machineRaw)) errors.push(`machine "${machineName}": declares "model" but has no "prompt" state — this would never take effect`);
190561
+ };
190562
+ const validateMachinesShape = (raw, errors) => {
190563
+ if (raw === void 0) return;
190564
+ if (!isPlainObject$1(raw)) {
190565
+ errors.push(`"machines" must be a mapping of machine name -> { params?, entry, states }, got ${describeType(raw)}`);
190566
+ return;
190567
+ }
190568
+ for (const [machineName, machineRaw] of Object.entries(raw)) {
190569
+ if (!isPlainObject$1(machineRaw)) continue;
190570
+ const unknownMachineKeys = Object.keys(machineRaw).filter((k) => !KNOWN_MACHINE_KEYS.has(k));
190571
+ if (unknownMachineKeys.length > 0) errors.push(`machine "${machineName}": unknown key(s) ${unknownMachineKeys.join(", ")}`);
190572
+ validateMachineRefs(machineName, machineRaw, errors);
190573
+ validateMachineStateKeys(machineName, machineRaw, errors);
190574
+ compileMachineModel(machineName, machineRaw, errors);
190575
+ validateMachineModelHasPromptState(machineName, machineRaw, errors);
190576
+ }
190577
+ };
190274
190578
  const formatErrors = (errors) => `workflow config:\n${errors.map((e) => ` - ${e}`).join("\n")}`;
190275
190579
  /**
190276
190580
  * Resolve one content string: inline text passes through verbatim; a file
@@ -190293,6 +190597,8 @@ const resolveContent = (value, configDir, where, errors) => {
190293
190597
  return;
190294
190598
  }
190295
190599
  };
190600
+ /** A local is a REFERENCE iff its raw value carries a `machine` key — the same predicate `src/Machines.ts` uses. */
190601
+ const isRefRaw = (v) => isPlainObject$1(v) && typeof v["machine"] === "string";
190296
190602
  /**
190297
190603
  * Inline every `./`/`../` content file reference in ONE raw `workflow:` value
190298
190604
  * against `configDir` — the directory of the `.gtdrc` that DECLARED it — and
@@ -190301,39 +190607,53 @@ const resolveContent = (value, configDir, where, errors) => {
190301
190607
  * Used by `src/Config.ts`'s `loadMerged` to resolve each config level's
190302
190608
  * references against its OWN file's directory BEFORE the levels are
190303
190609
  * deep-merged. The merge collapses every level into one anonymous object, so it
190304
- * erases which file a given `states.x.prompt` came from; resolving up front,
190305
- * per level, is the only way `./gtd-prompts/x.md` in a parent `.gtdrc` resolves
190306
- * against the parent (not the cwd a child repo runs from). `compileWorkflowConfig`
190307
- * is then invoked with `inlineFileRefs: false` on the merged result.
190308
- *
190309
- * Malformed shapes (a non-object workflow, non-object `states`, a non-object
190310
- * state, a non-string content value) pass through untouched —
190610
+ * erases which file a given `machines.x.states.y.prompt` came from; resolving up
190611
+ * front, per level, is the only way `./gtd-prompts/x.md` in a parent `.gtdrc`
190612
+ * resolves against the parent (not the cwd a child repo runs from).
190613
+ * `compileWorkflowConfig` is then invoked with `inlineFileRefs: false` on the
190614
+ * merged result. Walks `machines[*].states[*]`, skipping reference entries (an
190615
+ * object carrying a `machine` key) — a reference has no content of its own to
190616
+ * inline.
190617
+ *
190618
+ * Malformed shapes (a non-object workflow, non-object `machines`, a non-object
190619
+ * machine/state, a non-string content value) pass through untouched —
190311
190620
  * `compileWorkflowConfig`/`validateDefinition` own those findings; this pass
190312
190621
  * only rewrites the strings it recognizes as file references, collecting a load
190313
190622
  * error (via `resolveContent`) for any that is missing or unreadable.
190314
190623
  */
190624
+ /** Inline one ordinary (non-reference) state's own content file references; a reference passes through untouched. */
190625
+ const inlineStateFileRefs = (def, machineName, local, configDir, errors) => {
190626
+ if (typeof def["machine"] === "string") return def;
190627
+ const next = { ...def };
190628
+ for (const key of CONTENT_KEYS) {
190629
+ const value = def[key];
190630
+ if (typeof value !== "string" || !isFileReference(value)) continue;
190631
+ const resolved = resolveContent(value, configDir, `machine "${machineName}" state "${local}" (${key})`, errors);
190632
+ if (resolved !== void 0) next[key] = resolved;
190633
+ }
190634
+ return next;
190635
+ };
190636
+ /** Inline every state's content file references for one machine; a malformed machine/states shape passes through untouched. */
190637
+ const inlineMachineFileRefs = (machineRaw, machineName, configDir, errors) => {
190638
+ if (!isPlainObject$1(machineRaw)) return machineRaw;
190639
+ const rawStates = machineRaw["states"];
190640
+ if (!isPlainObject$1(rawStates)) return machineRaw;
190641
+ const states = {};
190642
+ for (const [local, def] of Object.entries(rawStates)) states[local] = isPlainObject$1(def) ? inlineStateFileRefs(def, machineName, local, configDir, errors) : def;
190643
+ return {
190644
+ ...machineRaw,
190645
+ states
190646
+ };
190647
+ };
190315
190648
  const inlineWorkflowFileRefs = (rawWorkflow, configDir, errors) => {
190316
190649
  if (!isPlainObject$1(rawWorkflow)) return rawWorkflow;
190317
- const rawStates = rawWorkflow["states"];
190318
- if (!isPlainObject$1(rawStates)) return rawWorkflow;
190319
- const states = {};
190320
- for (const [name, state] of Object.entries(rawStates)) {
190321
- if (!isPlainObject$1(state)) {
190322
- states[name] = state;
190323
- continue;
190324
- }
190325
- const next = { ...state };
190326
- for (const key of CONTENT_KEYS) {
190327
- const value = state[key];
190328
- if (typeof value !== "string" || !isFileReference(value)) continue;
190329
- const resolved = resolveContent(value, configDir, `state "${name}" (${key})`, errors);
190330
- if (resolved !== void 0) next[key] = resolved;
190331
- }
190332
- states[name] = next;
190333
- }
190650
+ const rawMachines = rawWorkflow["machines"];
190651
+ if (!isPlainObject$1(rawMachines)) return rawWorkflow;
190652
+ const machines = {};
190653
+ for (const [machineName, machineRaw] of Object.entries(rawMachines)) machines[machineName] = inlineMachineFileRefs(machineRaw, machineName, configDir, errors);
190334
190654
  return {
190335
190655
  ...rawWorkflow,
190336
- states
190656
+ machines
190337
190657
  };
190338
190658
  };
190339
190659
  const KNOWN_EDGE_KEYS = /* @__PURE__ */ new Set([
@@ -190464,7 +190784,7 @@ const compileActor = (raw, name, errors) => {
190464
190784
  }
190465
190785
  return raw.actor;
190466
190786
  };
190467
- /** The `model` field: an opaque string, or undefined (either absent or invalid — the type mismatch is its own error). Never interpreted or validated beyond "is it a string" — see `PatternMachine.StateDef.model`. */
190787
+ /** The `model` field as it reaches a FLATTENED state — never authored there (`validateMachineStateKeys` rejects that), only stamped on by the owning machine (`src/Machines.ts`'s `emitTree`). An opaque string, or undefined (either absent or invalid — the type mismatch is its own error). Never interpreted or validated beyond "is it a string" — see `PatternMachine.StateDef.model`. */
190468
190788
  const compileModel = (raw, name, errors) => {
190469
190789
  if (raw.model === void 0) return void 0;
190470
190790
  if (typeof raw.model !== "string") {
@@ -190473,15 +190793,6 @@ const compileModel = (raw, name, errors) => {
190473
190793
  }
190474
190794
  return raw.model;
190475
190795
  };
190476
- /** The `memory` field: an opaque memory-scope label (Eta template), or undefined (either absent or invalid — the type mismatch is its own error). Never interpreted or validated beyond "is it a string" — see `PatternMachine.StateDef.memory`. */
190477
- const compileMemory = (raw, name, errors) => {
190478
- if (raw.memory === void 0) return void 0;
190479
- if (typeof raw.memory !== "string") {
190480
- errors.push(`state "${name}": "memory" must be a string`);
190481
- return;
190482
- }
190483
- return raw.memory;
190484
- };
190485
190796
  /** The `label` field: an opaque display name, or undefined (either absent or invalid — the type mismatch is its own error). Never interpreted or validated beyond "is it a string" — see `PatternMachine.StateDef.label`. */
190486
190797
  const compileLabel = (raw, name, errors) => {
190487
190798
  if (raw.label === void 0) return void 0;
@@ -190509,13 +190820,13 @@ const compileMode = (raw, name, errors) => {
190509
190820
  }
190510
190821
  return raw.mode;
190511
190822
  };
190512
- /** The `initial` field: `true` only when the raw value is the literal boolean `true`. */
190513
- const compileInitial = (raw, name, errors) => compileBooleanFlag(raw, "initial", name, errors);
190514
190823
  /**
190515
- * A boolean state flag (`initial`/`reviewWindow`/`reviewBase`/`reviewEntry`/`fixEntry`/`requireProgress`/`answerGate`): `true` only
190516
- * when the raw value is the literal `true`; a non-boolean is a config error;
190517
- * `false` (or absent) compiles away to `undefined` so it never lands in the
190518
- * `StateDef` — `false` and "unset" mean the same thing for every such flag.
190824
+ * A boolean state flag (`reviewWindow`/`requireProgress`/`answerGate`/
190825
+ * `entry`): `true` only when the raw value is the literal `true`; a
190826
+ * non-boolean is a config error; `false` (or absent) compiles away to
190827
+ * `undefined` so it never lands in the `StateDef` — `false` and "unset" mean
190828
+ * the same thing for every such flag. `reviewBase` no longer goes through
190829
+ * this — see `compileBooleanOrTemplateFlag`.
190519
190830
  */
190520
190831
  const compileBooleanFlag = (raw, key, name, errors) => {
190521
190832
  const value = raw[key];
@@ -190526,6 +190837,21 @@ const compileBooleanFlag = (raw, key, name, errors) => {
190526
190837
  }
190527
190838
  return value === true ? true : void 0;
190528
190839
  };
190840
+ /**
190841
+ * `reviewBase`'s own flag shape: the literal `true`, OR a non-blank string (an
190842
+ * Eta template rendering a commitish, returned verbatim — no trimming, just a
190843
+ * blank-after-trim rejection) — see `StateDef.reviewBase`'s widened type.
190844
+ * `false`, a number, an object, or a blank string are all rejected with the
190845
+ * same finding shape `compileBooleanFlag` uses; `undefined` (absent) passes
190846
+ * through untouched.
190847
+ */
190848
+ const compileBooleanOrTemplateFlag = (raw, key, name, errors) => {
190849
+ const value = raw[key];
190850
+ if (value === void 0) return void 0;
190851
+ if (value === true) return true;
190852
+ if (typeof value === "string" && value.trim() !== "") return value;
190853
+ errors.push(`state "${name}": "${key}" must be a boolean or a non-blank string`);
190854
+ };
190529
190855
  const assembleContentFields = (content) => ({
190530
190856
  ...content.script !== void 0 ? { script: content.script } : {},
190531
190857
  ...content.prompt !== void 0 ? { prompt: content.prompt } : {},
@@ -190544,94 +190870,143 @@ const assembleStateDef = (parts) => ({
190544
190870
  ...definedEntries({
190545
190871
  actor: parts.actor,
190546
190872
  on: parts.on,
190547
- initial: parts.initial,
190548
190873
  retry: parts.retry,
190549
190874
  model: parts.model,
190550
- memory: parts.memory,
190551
190875
  label: parts.label,
190552
190876
  file: parts.file,
190553
190877
  mode: parts.mode,
190554
190878
  reviewWindow: parts.reviewWindow,
190555
190879
  reviewBase: parts.reviewBase,
190556
- reviewEntry: parts.reviewEntry,
190557
- fixEntry: parts.fixEntry,
190558
190880
  requireProgress: parts.requireProgress,
190559
190881
  answerGate: parts.answerGate
190560
190882
  }),
190561
190883
  ...assembleContentFields(parts.content)
190562
190884
  });
190563
- /** One state's full shape: actor, content, `on`, `initial`, `retry`. */
190885
+ /**
190886
+ * One state's full shape: actor, content, `on`, `retry`. Operates on a
190887
+ * QUALIFIED state entry from `FlattenedWorkflow.states` (`src/Machines.ts`) —
190888
+ * `$param`s already substituted, every `on`/`retry.otherwise` target already
190889
+ * an absolute qualified name — so this is exactly the flat compilation the
190890
+ * pre-`entry:`/`machines:` compiler already did. The old named entry-point
190891
+ * flags (`initial`/`reviewEntry`/`fixEntry`) are gone from this shape
190892
+ * entirely: the flattener resolves `entry.default` directly into
190893
+ * `WorkflowDefinition.entries.default`, and a state's own `entry: true` flag
190894
+ * (validated here, collected by the caller) seeds `entries.manual` instead —
190895
+ * neither ever lands on the compiled `StateDef`.
190896
+ */
190564
190897
  const compileState = (name, raw, configDir, errors, inlineFileRefs) => {
190565
190898
  if (!isPlainObject$1(raw)) {
190566
190899
  errors.push(`state "${name}": must be an object, got ${describeType(raw)}`);
190567
190900
  return {};
190568
190901
  }
190569
190902
  const unknownKeys = Object.keys(raw).filter((k) => !KNOWN_STATE_KEYS.has(k));
190570
- if (unknownKeys.length > 0) errors.push(`state "${name}": unknown key(s) ${unknownKeys.join(", ")}`);
190571
- return assembleStateDef({
190903
+ if (unknownKeys.length > 0) errors.push(`state "${name}": unknown key(s) ${formatUnknownKeys(unknownKeys, LEGACY_STATE_KEY_HINTS)}`);
190904
+ const def = assembleStateDef({
190572
190905
  actor: compileActor(raw, name, errors),
190573
190906
  content: compileContent(raw, name, configDir, errors, inlineFileRefs),
190574
190907
  on: compileOn(raw.on, name, errors),
190575
- initial: compileInitial(raw, name, errors),
190576
190908
  retry: compileRetry(raw.retry, name, errors),
190577
190909
  model: compileModel(raw, name, errors),
190578
- memory: compileMemory(raw, name, errors),
190579
190910
  label: compileLabel(raw, name, errors),
190580
190911
  file: compileFile(raw, name, errors),
190581
190912
  mode: compileMode(raw, name, errors),
190582
190913
  reviewWindow: compileBooleanFlag(raw, "reviewWindow", name, errors),
190583
- reviewBase: compileBooleanFlag(raw, "reviewBase", name, errors),
190584
- reviewEntry: compileBooleanFlag(raw, "reviewEntry", name, errors),
190585
- fixEntry: compileBooleanFlag(raw, "fixEntry", name, errors),
190914
+ reviewBase: compileBooleanOrTemplateFlag(raw, "reviewBase", name, errors),
190586
190915
  requireProgress: compileBooleanFlag(raw, "requireProgress", name, errors),
190587
190916
  answerGate: compileBooleanFlag(raw, "answerGate", name, errors)
190588
190917
  });
190918
+ compileBooleanFlag(raw, "entry", name, errors);
190919
+ return def;
190920
+ };
190921
+ /**
190922
+ * Compiler invariant, not a workflow-author finding: `flattenMachines`
190923
+ * (`src/Machines.ts`) promises a `scopes` entry for EVERY state it emits (see
190924
+ * that module's doc comment, "Bindings carry their scope") — a mismatch here
190925
+ * means the flattener itself missed a state, which must fail loudly rather
190926
+ * than silently produce a memory key that reads as "outside every scope".
190927
+ * Exported so this invariant is directly testable against a contrived
190928
+ * `scopes` map, independent of whether `flattenMachines` itself can currently
190929
+ * be made to violate its own guarantee.
190930
+ */
190931
+ const assertScopesCoverStates = (stateNames, scopes, errors) => {
190932
+ for (const name of stateNames) if (!(name in scopes)) errors.push(`internal error: scopes map produced by the flattener is missing state "${name}"`);
190589
190933
  };
190590
190934
  /**
190591
190935
  * Compile the raw, decoded `workflow:` YAML value into a `WorkflowDefinition`
190592
- * plus the workflow's own compiled `vars:` map. `configDir` is the config
190593
- * file's own directory, used to resolve `./`/`../` file references. `rcModes`
190594
- * is the already-compiled top-level `.gtdrc` `modes:` key (`src/Config.ts`),
190595
- * layered over the workflow's own `modes:` per half BEFORE validation — so a
190596
- * state may name a mode either layer declares. `inlineFileRefs` (default
190597
- * `true`) inlines content file references against `configDir`; the real config
190598
- * path (`src/Config.ts`'s `loadMerged`) passes `false` because it has already
190599
- * inlined every reference per declaring file across the merge chain, so
190600
- * `configDir` is then irrelevant. Throws a single `Error` (message:
190601
- * `"workflow config:\n - ..."`, one line per finding) on ANY config-shape
190602
- * problem or `validateDefinition` finding — never partially succeeds.
190936
+ * plus the workflow's own compiled `vars:` map and its machine tree. `configDir`
190937
+ * is the config file's own directory, used to resolve `./`/`../` file
190938
+ * references. `rcModes` is the already-compiled top-level `.gtdrc` `modes:` key
190939
+ * (`src/Config.ts`), layered over the workflow's own `modes:` per half BEFORE
190940
+ * validation — so a state may name a mode either layer declares.
190941
+ * `inlineFileRefs` (default `true`) inlines content file references against
190942
+ * `configDir`; the real config path (`src/Config.ts`'s `loadMerged`) passes
190943
+ * `false` because it has already inlined every reference per declaring file
190944
+ * across the merge chain, so `configDir` is then irrelevant. Throws a single
190945
+ * `Error` (message: `"workflow config:\n - ..."`, one line per finding) on ANY
190946
+ * config-shape problem or `validateDefinition` finding — never partially
190947
+ * succeeds.
190948
+ *
190949
+ * Four error-sequencing rules, in order:
190950
+ *
190951
+ * 1. `detectLegacyShape` short-circuits first — a stale pre-`entry:`/`machines:`
190952
+ * config throws with ONLY the migration findings, never mixed with
190953
+ * downstream noise.
190954
+ * 2. `detectLegacyEntryKeys` short-circuits next — a stale top-level
190955
+ * `entry.review`/`entry.fix` key (the pre-`entry: true` named entry points)
190956
+ * throws with ONLY its own migration findings, before `flattenMachines`
190957
+ * (or anything else) has a chance to notice those same keys some other way.
190958
+ * 3. Unassemblable throws early: `flattenMachines`'s `entries` coming back
190959
+ * `undefined` (no `entry.default`, no `machines`, an unresolvable root)
190960
+ * means there is no definition for `validateDefinition` to add findings to
190961
+ * — throw immediately with whatever findings exist so far.
190962
+ * 4. Otherwise the flattener's findings and `validateDefinition`'s findings
190963
+ * merge into one de-duplicated, thrown error.
190603
190964
  */
190604
190965
  const compileWorkflowConfig = (raw, configDir, rcModes, inlineFileRefs = true) => {
190605
190966
  if (!isPlainObject$1(raw)) throw new Error(`workflow config: must be an object, got ${describeType(raw)}`);
190967
+ detectLegacyShape(raw);
190968
+ detectLegacyEntryKeys(raw);
190606
190969
  const errors = [];
190607
- const expanded = expandSubmachines(raw, errors);
190608
- const cfg = isPlainObject$1(expanded) ? expanded : raw;
190609
- const unknownTopKeys = Object.keys(cfg).filter((k) => !KNOWN_TOP_KEYS.has(k));
190970
+ const unknownTopKeys = Object.keys(raw).filter((k) => !KNOWN_TOP_KEYS.has(k));
190610
190971
  if (unknownTopKeys.length > 0) errors.push(`unknown top-level key(s) ${unknownTopKeys.join(", ")}`);
190611
- const vars = compileVarsMap(cfg.vars, errors);
190612
- const modes = mergeModes(compileModesMap(cfg.modes, errors), rcModes);
190613
- const rawStates = cfg.states;
190614
- if (!isPlainObject$1(rawStates) || Object.keys(rawStates).length === 0) {
190615
- errors.push(`"states" must be a non-empty object`);
190616
- throw new Error(formatErrors(errors));
190617
- }
190972
+ const vars = compileVarsMap(raw.vars, errors);
190973
+ const modes = mergeModes(compileModesMap(raw.modes, errors), rcModes);
190974
+ validateMachinesShape(raw.machines, errors);
190975
+ const flattened = flattenMachines(raw, errors);
190976
+ if (flattened.entries === void 0) throw new Error(formatErrors(errors));
190618
190977
  const states = {};
190619
- for (const [name, s] of Object.entries(rawStates)) states[name] = compileState(name, s, configDir, errors, inlineFileRefs);
190978
+ const manualSet = /* @__PURE__ */ new Set();
190979
+ for (const [name, s] of Object.entries(flattened.states)) {
190980
+ states[name] = compileState(name, s, configDir, errors, inlineFileRefs);
190981
+ if (isPlainObject$1(s) && s["entry"] === true) manualSet.add(name);
190982
+ }
190983
+ const manual = Array.from(manualSet).sort();
190984
+ const entries = {
190985
+ default: flattened.entries.default,
190986
+ manual
190987
+ };
190620
190988
  const definition = modes !== void 0 ? {
190621
190989
  states,
190990
+ entries,
190622
190991
  modes
190623
- } : { states };
190992
+ } : {
190993
+ states,
190994
+ entries
190995
+ };
190996
+ assertScopesCoverStates(Object.keys(states), flattened.scopes, errors);
190624
190997
  const definitionErrors = validateDefinition(definition);
190625
190998
  const allErrors = Array.from(/* @__PURE__ */ new Set([...errors, ...definitionErrors]));
190626
190999
  if (allErrors.length > 0) throw new Error(formatErrors(allErrors));
190627
191000
  return {
190628
191001
  definition,
190629
- vars
191002
+ vars,
191003
+ tree: flattened.tree,
191004
+ scopes: flattened.scopes
190630
191005
  };
190631
191006
  };
190632
191007
  //#endregion
190633
191008
  //#region src/workflows/unified.yaml
190634
- var unified_default = "# The unified gtd workflow template — gtd's BUILT-IN DEFAULT workflow. When no\n# `workflow:` key is configured anywhere in the cwd→home config chain,\n# src/Config.ts runs this template directly, so a state command works with no\n# configuration at all. `gtd init` seeds only the settings a project tunes\n# (vars.testCommand + a modes: formatting suggestion), never the workflow\n# itself. See STATES.md §10 for the full walkthrough.\n#\n# Compiled through the exact same `compileWorkflowConfig` a user's `.gtdrc`\n# `workflow:` key goes through — no privileged code path\n# (src/workflows/templates.ts does the compiling). Every content string below\n# is INLINE (no `./`-relative file references): the bundled template ships\n# inside a single-file build (`dist/gtd.bundle.mjs`), so its content must\n# already be resolved at BUILD time. `renderInitConfig` (src/workflows/\n# templates.ts) can MATERIALIZE this inline source into a `.gtdrc.json`\n# `workflow:` value — the way to eject/customize the machine, and the hermetic\n# test fixture — but `gtd init` itself no longer writes the workflow.\n#\n# ── Authored with SUB-MACHINES (Option C) ──────────────────────────────────\n#\n# This template is written with `submachines:`/`use:` (see src/Submachines.ts).\n# Expansion is a compile-time raw → raw pre-pass with NO runtime footprint: the\n# pure engine only ever sees the flat concrete states, and each invocation's\n# `as:` rename map spells out the concrete state name every expanded state gets,\n# so the compiled `WorkflowDefinition` is exactly what a hand-written flat\n# workflow would produce. The sub-machines split into DEDUP (a gate/loop\n# authored once, invoked several times) and ENCAPSULATION (a single-instance\n# cluster grouped for reading):\n#\n# * makeGreen (dedup ×2) — the check/fix/escalate health triple shared by\n# the simple build tail and the advanced package\n# loop (identical script/prompt/message).\n# * assertGreen (dedup ×3) — the green-baseline gate (check + human blocked)\n# guarding the simple, advanced and review entries.\n# * qaLoop (dedup ×2) — the checkbox Q&A author/answer loop shared by the\n# product (product-qa) and technical (technical-qa)\n# phases of the advanced flow.\n# * planLoop (encap) — the simple flow's plan-iteration loop.\n# * humanReview (encap) — the shared review tail (reviewing → await-review →\n# review-deciding → feedback collect/build).\n# * specGate (encap) — the advanced flow's per-package agentic review.\n# * packageLoop (encap) — the advanced flow's per-package build queue.\n#\n# ── Four entry points behind a green-baseline gate, one shared tail ─────────\n#\n# EVERY entry first runs the test suite and only proceeds when it is GREEN — you\n# never start new work (or a review) on top of a red baseline. A red run halts\n# at a human `*-blocked` gate (fix the failures and re-run, exactly like\n# the `escalate` states), or is what `gtd fix` exists to repair.\n#\n# The initial `idle` state FORKS on which steering file the human creates,\n# routing into that flow's OWN start gate:\n#\n# * the configured requirementsFile (default .gtd/REQUIREMENTS.md) ->\n# `spec-precheck` -> the ADVANCED flow (product + technical Q&A, package\n# decomposition, per-package parallel build + agentic spec-review).\n# * anything else (e.g. the configured todoFile, default .gtd/TODO.md) ->\n# `plan-precheck` -> the SIMPLE flow (one grilling Q&A loop, a monolithic\n# build, no decomposition, no agentic review).\n#\n# A third entry, `gtd review <commitish>`, jumps to `review-precheck`\n# (reviewEntry) and, once green, to `reviewing` to review a foreign branch\n# through the same shared tail. A fourth, `gtd fix`, jumps to `fix-precheck`\n# (fixEntry): a red suite drops straight into the shared `fixing` loop and out\n# through the review + squash tail; a green suite is a no-op back to `idle`.\n#\n# All three converge at `reviewing` -> `await-review`. Ticking a REVIEW.md box\n# means only \"I reviewed this hunk\"; a COMMENT (not an unticked box) is what asks\n# for changes. Every human step at `await-review` goes to `review-deciding`,\n# which inspects the step and routes:\n# * no comment (every box ticked, no REVIEW.md note, no code edit) -> the diff\n# is a bare REVIEW.md deletion -> squashing -> done (the ONLY path to the\n# finale — the whole cycle collapses into one commit).\n# * any comment — a note in REVIEW.md OR a hand-edit to code — ->\n# review-deciding writes REVIEW_FEEDBACK.md -> feedback-building implements\n# it (completing any code the human hand-edited, never reverting their\n# lines) -> checking -> reviewing regenerates an INCREMENTAL REVIEW.md\n# (review-deciding carries `reviewBase: true`, so each feedback round's\n# review + checkout window covers only `last-review..HEAD`, i.e. the agent's\n# follow-through, not the human's own edit) -> await-review, looping until\n# sign-off.\n# Two dead ends never commit: the sign-off gate (src/program.ts) REFUSES a step\n# that leaves a box unticked with no comment (finish reviewing first) and a\n# deleted REVIEW.md (restore it and tick the boxes).\n#\n# The shared health segment (`checking`/`fixing`/`escalate`) serves the simple\n# build and the feedback loop — its green edge targets\n# `reviewing`. The advanced per-package loop keeps its OWN health triple\n# (`package-checking`/`package-fixing`/`package-escalate`) because its green edge targets\n# the per-package `spec-review` gate instead.\n#\n# Every `on` pattern's path below is written as the matching var's Eta\n# template (e.g. `\"A <%= it.vars.feedbackFile %>\"`), not a literal `.gtd/...`\n# path — repointing a var reroutes the machine along with every template that\n# reads/writes it (see docs/configuration.md).\n\nvars:\n testCommand: npm test\n # Where gtd keeps its own scratch/bookkeeping (the check scripts' temp output\n # file, review-deciding's state-file exclusion). Independent of the per-file\n # vars below — relocating a file wholesale means setting both.\n stateDir: .gtd\n todoFile: .gtd/TODO.md\n requirementsFile: .gtd/REQUIREMENTS.md\n architectureFile: .gtd/ARCHITECTURE.md\n packagesDir: .gtd/packages\n nextFile: .gtd/NEXT.md\n reviewFile: .gtd/REVIEW.md\n reviewRawFile: .gtd/REVIEW_RAW.md\n reviewFeedbackFile: .gtd/REVIEW_FEEDBACK.md\n feedbackFile: .gtd/FEEDBACK.md\n specFeedbackFile: .gtd/SPEC_FEEDBACK.md\n commitMsgFile: .gtd/COMMIT_MSG.md\n # The two opaque model tiers every agent state draws from — repoint them here\n # (or via a top-level `.gtdrc` `vars:` key / a `GTD_PLANNERMODEL` env var)\n # to change every state on that tier at once. `plannerModel` (default \"smart\")\n # is the heavier tier for the one-shot planning/technical-qa/review turns;\n # `coderModel` (default \"base\") is the tier for the build/fix turns.\n plannerModel: smart\n coderModel: base\n\nsubmachines:\n # ── DEDUP: the check/fix/escalate health triple ───────────────────────────\n # Runs the suite; a red run records the configured feedbackFile, a green run\n # cleans it up.\n # The `on` rules turn that into \"fix\" vs \"$onGreen\". The fix turn retries up to\n # 3× before escalating to a human. Invoked for the shared build tail\n # (checking/fixing/escalate -> reviewing) and the advanced package loop\n # (package-checking/package-fixing/package-escalate -> spec-review).\n makeGreen:\n params: [onGreen, escalateDescribe, checkLabel, fixLabel, escalateLabel]\n states:\n check:\n actor: check\n label: $checkLabel\n # NOT the shared `&suiteCheck` anchor (that lives on `assertGreen`,\n # aliased by it and by `fix-precheck`): this script also sweeps spent\n # steering files (`todoFile`, `reviewRawFile`, `reviewFeedbackFile`) —\n # a mechanic the entry/fix gates must NOT run, since they execute\n # before planning, while a fresh sketch legitimately still exists.\n script: |\n #!/usr/bin/env bash\n # gtd check turn — run the suite; a red run records .gtd/FEEDBACK.md, a\n # green run cleans it up. The `on` rules decide what that means.\n set +e\n mkdir -p \"<%~ it.vars.stateDir %>\"\n # Hoist every path once, at the TOP: Eta's autoTrim eats the newline\n # after an interpolation tag, so no tag may be the last token on a\n # line — it would glue the next line's `else`/`fi` onto it and break\n # the script. Below this point everything is plain bash.\n feedback=\"<%~ it.vars.feedbackFile %>\"\n mkdir -p \"$(dirname \"$feedback\")\"\n # Sweep steering files spent by the states that led here: the plan\n # (building), the raw review capture (feedback-collecting), and the\n # feedback instructions (feedback-building) — each `rm -f` is a\n # no-op when the file is already gone or never existed on this path.\n todo=\"<%~ it.vars.todoFile %>\"\n reviewRaw=\"<%~ it.vars.reviewRawFile %>\"\n reviewFeedback=\"<%~ it.vars.reviewFeedbackFile %>\"\n rm -f \"$todo\" \"$reviewRaw\" \"$reviewFeedback\"\n <%~ it.vars.testCommand %> > <%~ it.vars.stateDir %>/.check-output 2>&1\n code=$?\n if [ \"$code\" -ne 0 ]; then\n if [ -s <%~ it.vars.stateDir %>/.check-output ]; then\n mv <%~ it.vars.stateDir %>/.check-output \"$feedback\"\n else\n rm -f <%~ it.vars.stateDir %>/.check-output\n printf 'the test command failed with exit code %s and produced no output.' \"$code\" > \"$feedback\"\n fi\n # A repeat identical failure would otherwise leave FEEDBACK.md\n # byte-identical to its committed copy — no diff, so the check would\n # look GREEN. Stamp each red run with the current HEAD (which always\n # advances between checks) so a still-red re-run always re-registers\n # as an M/A edit and takes the red edge.\n printf '\\n<!-- gtd check %s -->\\n' \"$(git rev-parse --short HEAD 2>/dev/null || echo pending)\" >> \"$feedback\"\n else\n rm -f <%~ it.vars.stateDir %>/.check-output\n rm -f \"$feedback\"\n fi\n on:\n \"A <%= it.vars.feedbackFile %>\": fix\n \"M <%= it.vars.feedbackFile %>\": fix\n \"* **\": $onGreen\n \"C\": $onGreen\n fix:\n actor: agent\n label: $fixLabel\n file: <%= it.vars.feedbackFile %>\n model: <%= it.vars.coderModel %>\n memory: fix\n retry:\n max: 3\n otherwise: escalate\n prompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state file this\n turn touches is `<%= it.vars.feedbackFile %>`; fix it per its\n contents, or delete it if the feedback turns out to be wrong. Do not\n create other files to hold notes or output.\n\n Read `<%= it.vars.feedbackFile %>` (the failing test output) and fix the\n code so the suite passes. Keep the change focused — do not refactor\n unrelated code. If the feedback is wrong, empty or delete\n `<%= it.vars.feedbackFile %>` instead of \"fixing\" a non-issue.\n\n Leave everything uncommitted and finish your turn — do not commit.\n on:\n \"* **\": check\n escalate:\n actor: human\n label: $escalateLabel\n file: <%= it.vars.feedbackFile %>\n message: |\n The agent could not get the check to pass after repeated attempts.\n `<%= it.vars.feedbackFile %>` holds the last failing output.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n on:\n \"* **\":\n to: check\n action: Retry check\n describe: $escalateDescribe\n\n # ── DEDUP: the green-baseline gate ─────────────────────────────────────────\n # Runs the suite before any work starts (its script is the health check's test\n # wrapper — red -> the configured feedbackFile, green -> remove it); the `on` rules turn\n # that into \"halt at blocked\" vs \"proceed to $onGreen\". The script and blocked\n # message/describe differ per entry, so they ride in as `with:` params.\n assertGreen:\n params: [onGreen, blockedMessage, blockedDescribe, checkLabel, blockedLabel]\n states:\n check:\n actor: check\n label: $checkLabel\n # Anchored here and aliased by `fix-precheck` (`gtd fix` entry): one\n # suite-check script shared by all four call sites (this submachine's\n # ×3 uses plus `fix-precheck`) instead of four source copies. These gates\n # run BEFORE planning, so — unlike `makeGreen.check` — they must NOT\n # sweep `todoFile`/`requirementsFile`: the human's fresh sketch still\n # legitimately exists here.\n script: &suiteCheck |\n #!/usr/bin/env bash\n # gtd check turn — run the suite; a red run records .gtd/FEEDBACK.md, a\n # green run cleans it up. The `on` rules decide what that means.\n set +e\n mkdir -p \"<%~ it.vars.stateDir %>\"\n # Hoist the feedback path once, at the TOP: Eta's autoTrim eats the\n # newline after an interpolation tag, so no tag may be the last token on\n # a line — it would glue the next line's `else`/`fi` onto it and break\n # the script. Below this point everything is plain bash.\n feedback=\"<%~ it.vars.feedbackFile %>\"\n mkdir -p \"$(dirname \"$feedback\")\"\n <%~ it.vars.testCommand %> > <%~ it.vars.stateDir %>/.check-output 2>&1\n code=$?\n if [ \"$code\" -ne 0 ]; then\n if [ -s <%~ it.vars.stateDir %>/.check-output ]; then\n mv <%~ it.vars.stateDir %>/.check-output \"$feedback\"\n else\n rm -f <%~ it.vars.stateDir %>/.check-output\n printf 'the test command failed with exit code %s and produced no output.' \"$code\" > \"$feedback\"\n fi\n # A repeat identical failure would otherwise leave FEEDBACK.md\n # byte-identical to its committed copy — no diff, so the check would\n # look GREEN. Stamp each red run with the current HEAD (which always\n # advances between checks) so a still-red re-run always re-registers\n # as an M/A edit and takes the red edge.\n printf '\\n<!-- gtd check %s -->\\n' \"$(git rev-parse --short HEAD 2>/dev/null || echo pending)\" >> \"$feedback\"\n else\n rm -f <%~ it.vars.stateDir %>/.check-output\n rm -f \"$feedback\"\n fi\n on:\n \"A <%= it.vars.feedbackFile %>\": blocked\n \"M <%= it.vars.feedbackFile %>\": blocked\n \"D <%= it.vars.feedbackFile %>\": $onGreen\n \"C\": $onGreen\n blocked:\n actor: human\n label: $blockedLabel\n file: <%= it.vars.feedbackFile %>\n message: $blockedMessage\n on:\n \"* **\":\n to: check\n action: Retry check\n describe: $blockedDescribe\n\n # ── DEDUP: the checkbox Q&A author/answer loop ─────────────────────────────\n # The agent develops a plan file, surfacing `## Open Questions` as checkbox\n # lists; the human answers (answerGate refuses an unanswered step) and either\n # advances or sends it back. Invoked for the product (product-qa) and\n # technical (technical-qa) phases of the advanced flow.\n qaLoop:\n params:\n [\n authorFile,\n authorPrompt,\n answerMessage,\n onAdvance,\n advanceDescribe,\n editDescribe,\n authorLabel,\n answerLabel,\n ]\n states:\n author:\n actor: agent\n label: $authorLabel\n file: $authorFile\n mode: qa\n model: <%= it.vars.plannerModel %>\n memory: plan\n prompt: $authorPrompt\n on:\n \"* **\": answer\n answer:\n actor: human\n label: $answerLabel\n file: $authorFile\n mode: qa\n answerGate: true\n message: $answerMessage\n on:\n \"C\":\n to: $onAdvance\n action: Accept answers\n describe: $advanceDescribe\n \"* **\":\n to: author\n action: Revise answers\n describe: $editDescribe\n\n # ── ENCAPSULATION: the simple flow's plan-iteration loop ───────────────────\n planLoop:\n params: [onAccept]\n states:\n planning:\n actor: agent\n label: Refining the plan\n file: <%= it.vars.todoFile %>\n mode: prose\n model: <%= it.vars.plannerModel %>\n memory: plan\n prompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state file this\n turn touches is `<%= it.vars.todoFile %>`; develop it into the plan.\n Do not create other files to hold notes or output.\n\n `<%= it.vars.todoFile %>` holds a short sketch of what to build — or, on a\n return lap, the plan you already wrote plus the human's edits and comments\n on it. Read it, explore the codebase, and develop it into a concrete\n implementation plan: the files to change, the approach, and the steps to\n take.\n\n Do NOT ask questions and do NOT add an \"Open Questions\" section — decide\n every open point yourself and write the plan as a set of concrete\n decisions. Where a decision is genuinely a judgement call, state the\n choice you made and, in one line, why. The human iterates on the plan by\n editing it directly, not by answering questions.\n\n On a return lap, `<%= it.vars.todoFile %>` carries the human's edits and\n inline comments on your previous plan. Fold every edit and comment into a\n revised, self-contained plan and remove the addressed comments — the plan\n is always the current, clean proposal, never a diff.\n\n Leave `<%= it.vars.todoFile %>` uncommitted and finish your turn — do\n not commit, and do not run `gtd step agent` yourself; the harness does\n that.\n on:\n \"* **\": await-plan\n\n await-plan:\n actor: human\n label: Awaiting your review\n file: <%= it.vars.todoFile %>\n mode: prose\n message: |\n `<%= it.vars.todoFile %>` holds the proposed implementation plan. Read it,\n then either accept it as-is or edit it — rewrite a step, add a\n constraint, or drop an inline comment — to send it back for another round.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n on:\n \"C\":\n to: $onAccept\n action: Accept plan\n describe: >-\n change nothing to move on to implementation (**building**).\n \"* **\":\n to: planning\n action: Revise plan\n describe: >-\n rewrite a step, add a constraint, or leave an inline comment to\n send it back for another round (**planning**).\n\n # ── ENCAPSULATION: the shared review tail ──────────────────────────────────\n humanReview:\n params: [onSignoff]\n states:\n reviewing:\n actor: agent\n label: Reviewing\n file: <%= it.vars.reviewFile %>\n mode: review\n model: <%= it.vars.plannerModel %>\n memory: review\n # `gtd review <commitish>` (a plain non-gtd branch, e.g. a colleague's PR)\n # does NOT enter here directly — it enters `review-precheck` (reviewEntry)\n # first, which runs the green-baseline gate and, once green, transitions\n # here. The `Gtd-Review-Base:` trailer rides on that entry commit (the\n # process's oldest), so everything below still reuses the same\n # reviewing/await-review/feedback machinery over <commitish>..HEAD.\n prompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state file this\n turn touches is `<%= it.vars.reviewFile %>`; write it at exactly\n that path. Do not create other files to hold notes or output.\n\n Write `<%= it.vars.reviewFile %>` to help a human review the changes in\n this EXACT format:\n\n - First non-blank line: `# Review: <%= it.currentCommit.slice(0, 7) %>`\n - Somewhere in the document: `<!-- base: <%= it.reviewBase %> -->`\n - At least one `## <Chunk Title>` heading, grouping hunks semantically\n (same feature/refactor/fix, even across files) — a short explanation of\n what changed and why, then one `- [ ] ./path/to/file.ts#42 — note` file\n pointer per hunk (checkboxes are for the human to tick, not you; every\n path is `./`-relative, with an optional `#line` and an optional\n ` — note`).\n\n This prompt carries no diff — read the changes yourself. The range runs\n from `<%= it.reviewBase %>` to the working tree (committed turns plus\n anything still pending); on a feedback round that base is the previous\n review's boundary, so it covers only what changed since then, not items\n already signed off.\n\n Leave `<%= it.vars.reviewFile %>` uncommitted and finish your turn.\n on:\n \"* **\": await-review\n\n await-review:\n actor: human\n label: Awaiting your review\n file: <%= it.vars.reviewFile %>\n mode: review\n # While the cycle rests here, gtd opens a \"review checkout window\": raw HEAD\n # is rewound to the review base (the most-recent `review-deciding` commit —\n # so a feedback round shows only `last-review..HEAD` — or the process start\n # on the first review) with the working tree untouched, surfacing the diff\n # as ordinary uncommitted changes. It closes on the next invocation.\n #\n # Every human step here routes to `review-deciding`, which decides sign-off\n # vs. feedback from the step's CONTENT (a comment, not a tick). Ticking a box\n # only records \"I reviewed this hunk\". Two dead ends can't be told apart by a\n # file-pattern edge, so the sign-off gate (`enforceReviewSignoffGate` in\n # src/program.ts) catches them BEFORE the step commits and refuses: an\n # unfinished review (a box still `- [ ]` with no comment) and a deleted\n # REVIEW.md.\n reviewWindow: true\n message: |\n `<%= it.vars.reviewFile %>` holds the review record for the cycle — one\n `- [ ]` checkbox per reviewable item, grouped into chunks. Tick a box\n (`- [x]`) as you review each hunk; ticking only records that you've read\n it, it is not sign-off.\n\n When you've been through the whole diff, run `gtd step human`:\n\n - **Sign off** — tick EVERY box and leave no comment — no note in\n `<%= it.vars.reviewFile %>`, no code edit — to collapse the cycle into\n one commit (**squashing**).\n - **Request changes** — leave a comment: a note on a\n `<%= it.vars.reviewFile %>` line, or a direct code edit — to send it to a\n build + re-review round (**review-deciding** → **feedback-building**). A\n code edit counts as your own fix: the agent completes the follow-through\n it implies and never reverts your lines.\n\n Stepping with a box still unticked and no comment is refused (finish\n reviewing first); so is deleting `<%= it.vars.reviewFile %>`.\n on:\n \"* **\": review-deciding\n\n review-deciding:\n actor: check\n label: Reviewing\n file: <%= it.vars.reviewFile %>\n mode: review\n # Anchors the INCREMENTAL review base: its most-recent in-process commit is\n # the point the previous review round ended, so the next `reviewing` turn\n # (and the review checkout window) covers only the changes since then. On\n # the first review there is no such commit, so the base is the cycle start.\n reviewBase: true\n script: |\n #!/usr/bin/env bash\n # gtd check turn — decide the human's review step from its CONTENT. HEAD is\n # the human's `await-review -> review-deciding` commit; HEAD^ is the\n # `reviewing` commit that wrote the agent's original REVIEW.md (the sign-off\n # gate refuses an incomplete step, so review-deciding is entered exactly\n # once per round — nothing commits in between). It is FEEDBACK when the\n # human left a comment: a note in REVIEW.md (any edit beyond a \"[ ]\"->\"[x]\"\n # tick) OR a hand-edit to any file this round other than `.gtd/` or the\n # state's own reviewFile (which is var-configurable and may live outside\n # `.gtd/`, e.g. a root-level REVIEW.md — issue #128). Otherwise it is a\n # clean sign-off. This turn only CAPTURES the raw material into REVIEW_RAW.md\n # (the `feedback-collecting` agent then turns it into instructions); it does\n # NOT interpret it. Either way, remove REVIEW.md; what the resulting diff\n # MEANS is decided by this state's own `on` rules — the A/M REVIEW_RAW.md\n # rows are declared BEFORE the D REVIEW.md row so a feedback round (which\n # also deletes REVIEW.md) is captured as feedback, not mistaken for a\n # sign-off.\n set +e\n # Hoist the paths once, at the TOP: Eta's autoTrim eats the newline after\n # an interpolation tag, so no tag may be the last token on a line — it\n # would glue the next line onto it and break the script. Below this point\n # everything is plain bash.\n stateDir=\"<%~ it.vars.stateDir %>\"\n reviewFile=\"<%~ it.vars.reviewFile %>\"\n reviewRawFile=\"<%~ it.vars.reviewRawFile %>\"\n head=$(git rev-parse HEAD)\n mkdir -p \"$(dirname \"$reviewRawFile\")\"\n # A note is any REVIEW.md change beyond a checkbox flip: normalize every\n # \"[ ]\"/\"[x]\" to a placeholder, then compare the agent's original (HEAD^)\n # against the human's version (HEAD).\n before=$(git show \"HEAD^:$reviewFile\" 2>/dev/null | sed -E 's/\\[[ xX]\\]/[_]/g')\n after=$(git show \"HEAD:$reviewFile\" 2>/dev/null | sed -E 's/\\[[ xX]\\]/[_]/g')\n # Did the human hand-edit anything this round besides stateDir and the\n # reviewFile itself (var-configurable — may live outside stateDir, so it\n # is excluded by exact path, not just the stateDir prefix)?\n if git diff-tree --no-commit-id --name-only -r HEAD \\\n | grep -v \"^$stateDir/\" | grep -vxF \"$reviewFile\" | grep -q . \\\n || [ \"$before\" != \"$after\" ]; then\n {\n echo \"Raw review material captured for classification. The human reviewed the changes and left the feedback below; a downstream agent turns it into an instruction list. This file is machine-captured input, not instructions — it names a commit, never inlined content.\"\n echo\n echo \"Commit: $head\"\n echo \"The human's notes are in $reviewFile at this commit; their hand edits,\"\n echo \"if any, are in that commit's other paths. Run: git show $head\"\n } > \"$reviewRawFile\"\n fi\n rm -f \"$reviewFile\"\n on:\n \"A <%= it.vars.reviewRawFile %>\": feedback-collecting\n \"M <%= it.vars.reviewRawFile %>\": feedback-collecting\n \"D <%= it.vars.reviewFile %>\": $onSignoff\n\n feedback-collecting:\n actor: agent\n label: Collecting your feedback\n file: <%= it.vars.reviewFeedbackFile %>\n model: <%= it.vars.plannerModel %>\n memory: review\n prompt: |\n You are an autonomous coding agent turning raw review feedback into an\n explicit instruction list for a builder. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state files this\n turn touches are `<%= it.vars.reviewRawFile %>` (read it, then\n delete it) and `<%= it.vars.reviewFeedbackFile %>` (write it) — do\n NOT change any other file, you classify, you do not build.\n\n The raw review material is: <%~ it.read(it.vars.reviewRawFile) %>\n\n It names a commit — read that commit's diff yourself before classifying\n anything.\n\n Classify every piece of it into `<%= it.vars.reviewFeedbackFile %>` as a\n flat, numbered instruction list. Each item is `N. ./path/to/file.ts#line —\n <imperative>`. Apply these rules exactly:\n\n 1. NOTES the human added to REVIEW.md — each note is a MANDATORY change,\n regardless of whether its checkbox is ticked (ticking means only \"I read\n this hunk\", never sign-off). Anchor each to the `./path#line` its review\n item names (or the chunk it sits under). Emit it as a concrete\n imperative.\n 2. Every COMMENT the human ADDED this round (a `+` line that is a code\n comment, in the hand-edited files) is a MANDATORY instruction the human\n is giving the builder — even a plain-prose one, even if its box is\n ticked. Emit an imperative to satisfy it and note that the comment line\n itself must be REMOVED once addressed. If a comment turns out to describe\n behaviour the code already has, the instruction is still \"confirm the\n code matches and remove the comment line\".\n 3. Every NON-comment code line the human hand-edited is an already-committed\n INTENT, NOT a do-then-delete item. Emit an instruction that names those\n files and says: their lines are final — complete ONLY the follow-through\n they imply (callers, tests, types, docs) and NEVER revert or rewrite\n them. Do not turn their own edit into a task.\n\n Split each hand-edited hunk per line: comment `+` lines follow rule 2, the\n surrounding real code follows rule 3.\n\n If — and only if — nothing in the raw material is actionable (e.g. the human\n left only an approving remark), write `<%= it.vars.reviewFeedbackFile %>`\n containing exactly one line: `NOTHING ACTIONABLE — <one-sentence reason>`.\n Never invent work to satisfy a non-actionable remark.\n\n Delete `<%= it.vars.reviewRawFile %>`, leave everything else uncommitted,\n and finish your turn.\n on:\n \"A <%= it.vars.reviewFeedbackFile %>\": feedback-building\n \"M <%= it.vars.reviewFeedbackFile %>\": feedback-building\n\n feedback-building:\n actor: agent\n label: Addressing your feedback\n file: <%= it.vars.reviewFeedbackFile %>\n model: <%= it.vars.coderModel %>\n memory: build\n # Refuse a work-free turn: a step whose only pending change is deleting the\n # instructions file (the original \"captured then discarded\" bug). The edge\n # gate (`enforceFeedbackProgressGate` in src/program.ts) reads this flag and\n # exempts a `NOTHING ACTIONABLE` sentinel (a non-actionable review round,\n # which legitimately makes no code change). The pure engine carries it inert.\n requireProgress: true\n prompt: |\n You are an autonomous coding agent addressing review feedback. This\n workflow steers itself through its own state files — treat them as\n its private scratchpad, never as project code or documentation. The\n only state file this turn touches is\n `<%= it.vars.reviewFeedbackFile %>`; address it, then delete it. Do\n not create other files to hold notes or output.\n\n The instructions to implement are: <%~ it.read(it.vars.reviewFeedbackFile) %>\n\n This is a flat instruction list, not a diff — execute every item exactly as\n written, no Q&A, no re-planning, using TDD discipline:\n\n - An item that says the human's own lines are final: build ON TOP of them to\n complete only the follow-through it names (callers, tests, types, docs).\n NEVER revert or rewrite the lines they already committed.\n - An item derived from a human comment: satisfy it AND delete that comment\n line from the code (the human's review comments are transient — none of\n them survive this round).\n\n If the file's sole content is a `NOTHING ACTIONABLE` line, make no code\n change; just delete the instructions file.\n\n Delete `<%= it.vars.reviewFeedbackFile %>` once every item is addressed.\n Leave everything else uncommitted and finish your turn. Deleting the\n instructions file WITHOUT making the changes it asks for is refused — the\n feedback must actually be addressed.\n on:\n \"* **\": checking\n\n # ── ENCAPSULATION: the advanced flow's per-package agentic review ──────────\n specGate:\n params: [onApproved]\n states:\n spec-review:\n actor: agent\n label: Reviewing the package\n model: <%= it.vars.plannerModel %>\n memory: review\n # Bounded per cycle: once entered `max` times, a transition into it is\n # redirected to `closing` (force-close the package and move on — the\n # unresolved concern is deferred to the shared human review at the tail,\n # which sees the whole diff anyway). NOTE the cap is PROCESS-scoped, so it\n # pools across packages — the same documented limitation the shared\n # `fixing` cap has (see STATES.md / docs/configuration.md).\n retry:\n max: 3\n otherwise: $onApproved\n prompt: |\n You are an autonomous coding agent reviewing a freshly-built work package\n against its own spec. This workflow steers itself through its own\n state files — treat them as its private scratchpad, never as project\n code or documentation. The only state file this turn touches is\n `<%= it.vars.specFeedbackFile %>`; write it only when — and only\n when — you find problems. Do not create other files to hold notes or\n output.\n\n The package spec is: <%~ it.read(it.vars.nextFile) %>\n\n Read that spec, then verify the package's implementation against it: are\n all its tasks done, acceptance criteria met, and the code sound and\n consistent with the codebase? This prompt carries no diff — read it\n yourself: the range runs from `<%= it.startCommit %>` to the working\n tree.\n\n Note the range is process-wide (from the start of the whole build, not\n just this package), so it can span earlier packages too.\n\n - If it fully satisfies the spec, write NOTHING and finish your turn — a\n clean turn is your approval.\n - If it does not, write `<%= it.vars.specFeedbackFile %>` listing the\n concrete problems for a fix turn to address. Be specific.\n\n Do not fix anything yourself and do not delete the package file — a later\n step owns that. Finish your turn.\n on:\n \"A <%= it.vars.specFeedbackFile %>\": spec-fix\n \"M <%= it.vars.specFeedbackFile %>\": spec-fix\n \"D <%= it.vars.specFeedbackFile %>\": $onApproved\n \"C\": $onApproved\n\n spec-fix:\n actor: agent\n label: Fixing review feedback\n file: <%= it.vars.specFeedbackFile %>\n model: <%= it.vars.coderModel %>\n memory: fix\n prompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state file this\n turn touches is `<%= it.vars.specFeedbackFile %>`; address it, then\n delete it. Do not create other files to hold notes or output.\n\n Read `<%= it.vars.specFeedbackFile %>` (the reviewer's concerns about the\n current package) and the package spec (`<%= it.read(it.vars.nextFile) %>`),\n then fix the code to resolve every concern. Keep the change focused.\n\n Delete `<%= it.vars.specFeedbackFile %>` once every concern is addressed.\n Leave everything else uncommitted and finish your turn.\n on:\n \"* **\": package-checking\n\n # ── ENCAPSULATION: the advanced flow's per-package build queue ─────────────\n packageLoop:\n params: [onDrained]\n states:\n picking:\n actor: check\n label: Picking the next package\n script: |\n #!/usr/bin/env bash\n # gtd check turn (the package queue arbiter) — the loop driver executes\n # this verbatim, then steps the check actor. Mechanics only: take the\n # first package file (by name) under the packages dir into NEXT.md, or\n # remove NEXT.md when the queue is empty. What NEXT.md's presence/absence\n # MEANS (another package to build vs. the queue closing out to review) is\n # decided by this state's own `on` rules at capture time — never here.\n set +e\n # Hoist the NEXT path once, at the TOP: Eta's autoTrim eats the newline\n # after an interpolation tag, so no tag may be the last token on a line —\n # it would glue the next line's `else`/`fi` onto it and break the script.\n # Below this point everything is plain bash.\n nextFile=\"<%~ it.vars.nextFile %>\"\n # Sweep the advanced flow's spent Q&A steering files: requirementsFile\n # is consumed by technical-qa, architectureFile by decompose — both are\n # gone by the time the package queue starts. No-op if already removed.\n requirements=\"<%~ it.vars.requirementsFile %>\"\n architecture=\"<%~ it.vars.architectureFile %>\"\n rm -f \"$requirements\" \"$architecture\"\n next=$(ls <%~ it.vars.packagesDir %>/*.md 2>/dev/null | head -n 1)\n if [ -n \"$next\" ]; then\n mkdir -p \"$(dirname \"$nextFile\")\"\n printf '%s' \"$next\" > \"$nextFile\"\n else\n rm -f \"$nextFile\"\n fi\n on:\n \"D <%= it.vars.nextFile %>\": $onDrained\n \"* <%= it.vars.nextFile %>\": package-building\n \"C\": $onDrained\n\n package-building:\n actor: agent\n label: Building\n model: <%= it.vars.coderModel %>\n memory: build\n prompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. This turn writes no state\n file: do NOT delete the package file (the `spec-review` gate reads\n it after you) and never touch `<%= it.vars.nextFile %>` — the\n `picking` state owns it.\n\n The package to implement is: <%~ it.read(it.vars.nextFile) %>\n\n That file describes a set of INDEPENDENT tasks. Implement ALL of them in\n this one turn, fanning the independent tasks out to parallel subagents\n where your harness supports it — each task is self-contained and can be\n built concurrently. Implement EXACTLY the tasks this package describes — no\n more, no less; leave every other package file untouched. Use TDD\n discipline within each task.\n\n Leave the package file in place and everything else uncommitted, then\n finish your turn.\n on:\n \"* **\": package-checking\n\n closing:\n actor: check\n label: Closing out the package\n script: |\n #!/usr/bin/env bash\n # gtd check turn (the package closer) — remove the just-reviewed package\n # file (its path is in NEXT.md) plus any leftover spec feedback, so\n # `picking` selects the NEXT package (or falls through to review when the\n # queue is empty). Reached on approval (spec-review clean) and on the\n # spec-review retry cap (force-close, deferring the concern to the tail).\n set +e\n pkg=$(cat <%~ it.vars.nextFile %> 2>/dev/null)\n [ -n \"$pkg\" ] && rm -f \"$pkg\"\n rm -f <%~ it.vars.specFeedbackFile %> <%~ it.vars.nextFile %>\n on:\n \"* **\": picking\n\nuse:\n # Shared health triple: checking/fixing/escalate -> reviewing.\n - submachine: makeGreen\n name: build\n as: { check: checking, fix: fixing, escalate: escalate }\n with:\n onGreen: reviewing\n escalateDescribe: >-\n edit the code and/or `.gtd/FEEDBACK.md` yourself (**checking**).\n checkLabel: Running checks\n fixLabel: Fixing the check\n escalateLabel: Escalating to a human\n # Advanced package health triple: package-checking/package-fixing/package-escalate -> spec-review.\n - submachine: makeGreen\n name: package\n as: { check: package-checking, fix: package-fixing, escalate: package-escalate }\n with:\n onGreen: spec-review\n escalateDescribe: >-\n edit the code and/or `.gtd/FEEDBACK.md` yourself (**package-checking**).\n checkLabel: Running checks\n fixLabel: Fixing the check\n escalateLabel: Escalating to a human\n\n # Green-baseline gate: simple flow entry.\n - submachine: assertGreen\n name: plan\n as: { check: plan-precheck, blocked: plan-blocked }\n with:\n onGreen: planning\n blockedMessage: |\n The test baseline is red — gtd will not start new work on a broken suite.\n `<%= it.vars.feedbackFile %>` holds the failing output.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n blockedDescribe: >-\n edit the code and/or `.gtd/FEEDBACK.md` to fix the failing tests\n (**plan-precheck**). To repair the baseline as its own separate\n reviewed commit instead, abandon this start and run `gtd fix` from a\n clean `idle`.\n checkLabel: Checking the baseline\n blockedLabel: Baseline is red\n\n # Green-baseline gate: advanced flow entry.\n - submachine: assertGreen\n name: spec\n as: { check: spec-precheck, blocked: spec-blocked }\n with:\n onGreen: product-qa\n blockedMessage: |\n The test baseline is red — gtd will not start new work on a broken suite.\n `<%= it.vars.feedbackFile %>` holds the failing output.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n blockedDescribe: >-\n edit the code and/or `.gtd/FEEDBACK.md` to fix the failing tests\n (**spec-precheck**). To repair the baseline as its own separate\n reviewed commit instead, abandon this start and run `gtd fix` from a\n clean `idle`.\n checkLabel: Checking the baseline\n blockedLabel: Baseline is red\n\n # Green-baseline gate: `gtd review` entry (reviewEntry -> reviewing).\n - submachine: assertGreen\n name: review\n as: { check: review-precheck, blocked: review-blocked }\n set:\n check: { reviewEntry: true }\n with:\n onGreen: reviewing\n blockedMessage: |\n The test baseline is red — gtd will not start a review on a broken suite.\n `<%= it.vars.feedbackFile %>` holds the failing output.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n blockedDescribe: >-\n edit the code and/or `.gtd/FEEDBACK.md` to fix the failing tests\n (**review-precheck**).\n checkLabel: Checking the baseline\n blockedLabel: Baseline is red\n\n # Advanced flow: product Q&A loop (product-qa/product-answer -> technical-qa).\n - submachine: qaLoop\n name: product\n as: { author: product-qa, answer: product-answer }\n with:\n authorFile: <%= it.vars.requirementsFile %>\n onAdvance: technical-qa\n authorPrompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state file this turn\n touches is `<%= it.vars.requirementsFile %>`; develop it. Do not\n create other files to hold notes or output.\n\n `<%= it.vars.requirementsFile %>` holds the product requirements (or the\n plan you already wrote plus the human's answers on a return lap). Read it,\n explore the codebase, and develop it into a concrete, PRODUCT-level plan\n in this one turn.\n\n Scope: product and user-facing decisions only. Leave implementation\n details (file/module structure, data models, tech-stack choices) for the\n next phase (`<%= it.vars.architectureFile %>`).\n\n For every open point you cannot settle yourself, add a question under a\n `## Open Questions` heading near the top: one `### <question>` sub-heading\n each, followed by a checkbox list of candidate answers — aim for TWO\n concrete options plus a final free-text slot, ALL left UNTICKED:\n\n ### <the question>\n\n - [ ] <first option — a concrete answer, a few words of rationale>\n - [ ] <second option>\n - [ ] _your answer_\n\n Never tick a box yourself — the human ticks exactly one per question. The\n last `- [ ] _your answer_` line is the human's own-answer slot; write it\n with the literal `_your answer_` placeholder and leave it for them.\n\n On a RETURN lap the human has ticked one option per question (or replaced\n `_your answer_` with their own text). For each answered question: fold the\n chosen answer into the plan prose, then MOVE the question down into a\n `## Answered Questions` section as `### <question>` followed by the\n resolved answer in plain PROSE (drop the checkboxes). Never re-raise a\n question the human deleted; if the human deleted the whole\n `## Open Questions` section, treat the plan as accepted and surface no new\n questions unless something is genuinely still blocking. Omit\n `## Open Questions` entirely once there are none left.\n\n Leave `<%= it.vars.requirementsFile %>` uncommitted and finish your turn.\n answerMessage: |\n `<%= it.vars.requirementsFile %>` holds the product plan under\n development. Each question under `## Open Questions` offers a few options\n plus a `- [ ] _your answer_` slot. Answer EVERY question by ticking\n exactly one box (`- [x]`); for your own answer, replace `_your answer_`\n with your text and tick that line. Stepping is refused while any question\n is unanswered.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n advanceDescribe: >-\n with no open questions left (the agent surfaced none, or you deleted\n the section and it finalized the plan), change nothing to move on to\n technical Q&A (**technical-qa**).\n editDescribe: >-\n tick exactly one option per open question (replace `_your answer_` for\n your own) to send the plan back for the agent to fold your answers in\n (**product-qa**). Delete a question to skip it, or delete the whole\n `## Open Questions` section to accept the plan as-is.\n authorLabel: Refining the product plan\n answerLabel: Awaiting your answers\n\n # Advanced flow: technical Q&A loop (technical-qa/technical-answer -> decompose).\n - submachine: qaLoop\n name: technical\n as: { author: technical-qa, answer: technical-answer }\n with:\n authorFile: <%= it.vars.architectureFile %>\n onAdvance: decompose\n authorPrompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state files this\n turn touches are `<%= it.vars.architectureFile %>` (write it) and\n `<%= it.vars.requirementsFile %>` (delete it once its content is\n folded in). Do not create other files to hold notes or output.\n\n Read `<%= it.vars.requirementsFile %>` (the converged product plan) and\n develop `<%= it.vars.architectureFile %>` from it in this one turn:\n file/module structure, data models, library/tech-stack choices,\n error-handling strategy — the *how*, building on the *what* already\n settled. Do not re-open product-level decisions; treat them as settled.\n\n For every open point you cannot settle yourself, add a question under a\n `## Open Questions` heading exactly as the grilling phase does: one\n `### <question>` each, followed by a checkbox list — TWO concrete options\n plus a final free-text slot, ALL left UNTICKED:\n\n ### <the question>\n\n - [ ] <first option>\n - [ ] <second option>\n - [ ] _your answer_\n\n Never tick a box yourself. On a RETURN lap the human has ticked one option\n per question (or replaced `_your answer_` with their own text): fold each\n chosen answer into the plan prose, then MOVE the question into a\n `## Answered Questions` section as `### <question>` + the resolved answer\n in plain PROSE (drop the checkboxes). Never re-raise a deleted question;\n treat a deleted `## Open Questions` section as acceptance. Omit\n `## Open Questions` once there are none left.\n\n Once `<%= it.vars.architectureFile %>` is written, delete\n `<%= it.vars.requirementsFile %>` — its content has been folded in and it\n must not linger. Leave everything uncommitted and finish your turn.\n answerMessage: |\n `<%= it.vars.architectureFile %>` holds the technical plan under\n development. Each question under `## Open Questions` offers a few options\n plus a `- [ ] _your answer_` slot. Answer EVERY question by ticking\n exactly one box (`- [x]`); for your own answer, replace `_your answer_`\n with your text and tick that line. Stepping is refused while any question\n is unanswered.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n advanceDescribe: >-\n with no open questions left (the agent surfaced none, or you deleted\n the section and it finalized the plan), change nothing to move on to\n decomposing the work into packages (**decompose**).\n editDescribe: >-\n tick exactly one option per open question (replace `_your answer_` for\n your own) to send the plan back for the agent to fold your answers in\n (**technical-qa**). Delete a question to skip it, or delete the whole\n `## Open Questions` section to accept the plan as-is.\n authorLabel: Refining the technical plan\n answerLabel: Awaiting your answers\n\n # Simple flow plan-iteration loop (planning/await-plan -> building).\n - submachine: planLoop\n with:\n onAccept: building\n\n # Shared review tail (reviewing -> await-review -> ... -> squashing).\n - submachine: humanReview\n with:\n onSignoff: squashing\n\n # Advanced per-package agentic review (spec-review/spec-fix -> closing).\n - submachine: specGate\n with:\n onApproved: closing\n\n # Advanced per-package build queue (picking/package-building/closing -> reviewing).\n - submachine: packageLoop\n with:\n onDrained: reviewing\n\nstates:\n # ── Shared entry ────────────────────────────────────────────────────────\n idle:\n actor: human\n initial: true\n label: Idle\n # Every human-gate `message:` below closes with the same rendered \"What\n # each change does next\" list, built from `it.edges` (the resting state's\n # own `on` edges — each edge's `describe:` is the single source of truth).\n message: |\n No active gtd cycle.\n\n To start one, create a steering file describing what you want built:\n\n - `<%= it.vars.todoFile %>` — a short sketch — starts the SIMPLE flow\n (plan, build, review).\n - `<%= it.vars.requirementsFile %>` — the product requirements — starts\n the ADVANCED flow (product + technical Q&A, package decomposition,\n parallel build, per-package agentic review).\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n on:\n \"* <%= it.vars.requirementsFile %>\":\n to: spec-precheck\n action: Start advanced flow\n describe: >-\n save the product requirements to `.gtd/REQUIREMENTS.md` — gtd first\n checks the test baseline is green, then the agent develops them\n through product then technical Q&A, decomposes the work into\n packages, and builds each in parallel (**spec-precheck**).\n \"* **\":\n to: plan-precheck\n action: Start simple flow\n describe: >-\n save a short sketch to `.gtd/TODO.md` (or any other change) — gtd\n first checks the test baseline is green, then the agent reads it,\n explores the codebase, and develops it into a concrete\n implementation plan (**plan-precheck**).\n\n # ── Simple flow: monolithic build (plan loop is the `planLoop` sub-machine) ─\n building:\n actor: agent\n label: Building\n file: <%= it.vars.todoFile %>\n model: <%= it.vars.coderModel %>\n memory: build\n prompt: |\n You are an autonomous coding agent. This workflow steers itself through\n its own state files — treat them as its private scratchpad, never as\n project code or documentation. The only state file this turn touches is\n `<%= it.vars.todoFile %>`; delete it once its work is complete. Do not\n create other files to hold notes or output.\n\n The plan to implement is: <%~ it.read(it.vars.todoFile) %>\n\n Read that plan and implement it in full. Use TDD discipline: one test,\n then the implementation that passes it, then the next — never write all\n the tests first.\n\n Delete `<%= it.vars.todoFile %>` once the work it describes is complete\n and verified. Leave everything else uncommitted and finish your turn.\n on:\n \"* **\": checking\n\n # ── Advanced flow: decomposition (Q&A loops are the `qaLoop` sub-machine) ───\n decompose:\n actor: agent\n label: Decomposing into packages\n model: <%= it.vars.coderModel %>\n memory: build\n prompt: |\n You are an autonomous coding agent. This workflow steers itself through\n its own state files — treat them as its private scratchpad, never as\n project code or documentation. The only state files this turn touches\n are the package files it writes under `<%= it.vars.packagesDir %>/` and\n `<%= it.vars.architectureFile %>`, which it deletes. Do not create other\n files to hold notes or output.\n\n Read `<%= it.vars.architectureFile %>` (the converged technical plan) and\n decompose it into an ordered set of WORK PACKAGES, one file each under\n `<%= it.vars.packagesDir %>/` (e.g. `<%= it.vars.packagesDir %>/01-name.md`,\n `<%= it.vars.packagesDir %>/02-name.md`, ...). Order the packages so the\n test suite can pass again after each one lands.\n\n Each package file describes a set of INDEPENDENT tasks that can be\n implemented concurrently without ordering constraints between them — a\n single build turn will fan them out to parallel subagents. Give each task\n a clear description, acceptance criteria as `- [ ]` checkboxes, and the\n relevant paths. Also state the package's overall requirement so it can be\n reviewed against its spec. Do NOT put ordering-dependent tasks in the same\n package — split them across ordered packages instead.\n\n No package file may reference any other `.gtd/` file. Once the package\n files are written, delete `<%= it.vars.architectureFile %>`. Leave\n everything uncommitted and finish your turn.\n on:\n \"* <%= it.vars.packagesDir %>/**\": picking\n\n # ── Green-baseline gate: `gtd fix` entry ──────────────────────────────────\n # `gtd fix` enters HERE (fixEntry): run the suite, then a red run drops\n # straight into the shared `fixing` loop (-> checking -> review + squash\n # tail); a green run is a no-op back to `idle` (nothing to fix).\n fix-precheck:\n actor: check\n fixEntry: true\n label: Checking the baseline\n script: *suiteCheck\n on:\n \"A <%= it.vars.feedbackFile %>\": fixing\n \"M <%= it.vars.feedbackFile %>\": fixing\n \"D <%= it.vars.feedbackFile %>\": idle\n \"C\": idle\n\n # ── Shared tail: squash finale (review loop is the `humanReview` sub-machine) ─\n squashing:\n actor: agent\n label: Squashing\n file: <%= it.vars.commitMsgFile %>\n model: <%= it.vars.coderModel %>\n memory: build\n prompt: |\n You are an autonomous coding agent. The cycle is approved and done. This\n workflow steers itself through its own state files — treat them as its\n private scratchpad, never as project code or documentation. The only\n state file this turn touches is `<%= it.vars.commitMsgFile %>`; write\n it. Do not create other files to hold notes or output.\n\n Leave `<%= it.vars.commitMsgFile %>` uncommitted and finish your turn —\n entering this file squashes everything this process retains into one\n commit using its content as the message. This prompt carries no diff —\n read the range yourself; it runs from `<%= it.retainedBase %>` to the\n working tree, and is exactly what that commit will contain, the changes\n THIS process made (its trace/retry boundary onward). For a normal build\n cycle that is the whole feature; for a `gtd review` process it is only\n the fixes made during the review — NOT the changeset that was under\n review.\n\n If that range is non-empty, write `<%= it.vars.commitMsgFile %>` with ONE\n conventional-commits message for the changes (subject line\n `type(scope): subject`, imperative mood, <= 72 characters; a body\n explaining the why, trade-offs, and key decisions). Plain text, no\n markdown wrapper.\n\n If that range is empty (a clean review sign-off with no fixes), write\n `<%= it.vars.commitMsgFile %>` with exactly this one line and nothing\n else:\n\n chore: human review\n on:\n \"A <%= it.vars.commitMsgFile %>\": done\n \"M <%= it.vars.commitMsgFile %>\": done\n\n done:\n commit: |\n <%~ it.read(it.vars.commitMsgFile) %>\n\n Token cost: <%= it.processCost %>\n <% it.processCostByModel.forEach(function(m){ %>\n - <%= m.model %>: <%= m.cost %>\n <% }) %>\n";
191009
+ var unified_default = "# The unified gtd workflow template — gtd's BUILT-IN DEFAULT workflow. When no\n# `workflow:` key is configured anywhere in the cwd→home config chain,\n# src/Config.ts runs this template directly, so a state command works with no\n# configuration at all. `gtd init` seeds only the settings a project tunes\n# (vars.testCommand + a modes: formatting suggestion), never the workflow\n# itself. See the README's \"How it works\" and \"Driving the loop\" sections for\n# the full walkthrough.\n#\n# Compiled through the exact same `compileWorkflowConfig` a user's `.gtdrc`\n# `workflow:` key goes through — no privileged code path\n# (src/workflows/templates.ts does the compiling). Every content string below\n# is INLINE (no `./`-relative file references): the bundled template ships\n# inside a single-file build (`dist/gtd.bundle.mjs`), so its content must\n# already be resolved at BUILD time. `renderInitConfig` (src/workflows/\n# templates.ts) can MATERIALIZE this raw source into a `.gtdrc.json`\n# `workflow:` value — the way to eject/customize the machine, and the hermetic\n# test fixture — but `gtd init` itself no longer writes the workflow.\n#\n# ── Authored with MACHINES (entry:/machines:) ───────────────────────────────\n#\n# This template is written as a tree of reusable, parameterized MACHINES (see\n# src/Machines.ts) — a gate/loop written once and instantiated several times\n# with different `with:` bindings (dedup), or a complex cluster grouped under\n# one name for source comprehension (encapsulation). Every reference below is\n# an ordinary `{ machine: <name>, with: { ... } }` local, expanded at\n# compile-time into concrete, qualified states before the pure engine ever\n# sees the definition — no runtime footprint.\n#\n# MACHINE BOUNDARIES LINE UP WITH CONVERSATIONAL IDENTITY: a machine that holds\n# an identity (a planner or a coder persona) declares its own `model:` ONCE, at\n# the machine level (stamped onto every one of its own `prompt` states — see\n# src/Machines.ts's `resolveInstanceModel`), instead of repeating `model:` on\n# every state; no state anywhere declares `model:` or `memory:` directly any\n# more (the memory key is computed from the machine tree — see\n# src/PatternMachine.ts's `memoryScopeAt` / src/Edge.ts's `memoryKeyFor`). Two\n# gate/queue machines below are deliberately IDENTITY-FREE (no `model:`):\n# `entryGate`/`healthGate` are infrastructure, not a persona, and `packageLoop`\n# is a bookkeeping queue, not a builder.\n#\n# * entryGate (dedup ×3) — the green-baseline gate (check + human blocked)\n# guarding the simple (`plan-gate`), advanced\n# (`spec-gate`) and review (`review-gate`)\n# entries. Identity-free.\n# * healthGate (dedup ×2) — the check/escalate health pair shared by the\n# simple build's own tail (`build.health.*`) and\n# the per-package build's own tail\n# (`packages.item.health.*`) — each caller's OWN\n# `fix` state (a coder-identity action) lives\n# beside this machine, not inside it, since\n# fixing IS a coder action and this machine has\n# no identity of its own. Identity-free.\n# * advancedPlan (encap) — the advanced flow's one design conversation:\n# product Q&A, technical Q&A, and decomposition\n# (`design.*`: product-author/-answer,\n# technical-author/-answer, decompose) all share\n# ONE machine identity, so the agent explores the\n# codebase once, not three times, and the human's\n# answer rationale survives into decomposition.\n# ▸ planner.\n# * planLoop (encap) — the simple flow's plan-iteration loop (`plan.*`).\n# ▸ planner.\n# * humanReview (encap) — the shared review tail, nested INSIDE\n# `simpleBuild` (`build.review.*`: reviewing →\n# await-review → deciding → collecting) — see\n# \"Why humanReview is nested, not a root sibling\"\n# below. ▸ planner — the implementer's OWN\n# follow-through on feedback is a coder action,\n# not a reviewer one, so it lives in\n# `build.addressing` instead.\n# * specReview (encap) — the advanced flow's per-package agentic review,\n# now a SINGLE state (`packages.item.spec.review`)\n# — a machine whose entire job is to be a\n# separate mind from the implementer. ▸ planner\n# — fixing the reviewer's own findings is a coder\n# action, so it lives in `packages.item.fix-spec`\n# instead.\n# * packageLoop (encap) — the advanced flow's per-package build queue\n# (`packages.picking`). Identity-free — the\n# actual per-package build identity lives in its\n# own `item` child (packageItem, below).\n# * packageItem (encap) — the advanced flow's per-package build identity\n# (`packages.item.*`: building, fix-suite,\n# fix-spec, closing), plus its own `health` and\n# `spec` children. ▸ coder.\n# * simpleBuild (encap) — the simple flow's build identity (`build.*`:\n# building, squashing, addressing, fix), plus its\n# own `health` child AND its own `review` child\n# (humanReview, above). ▸ coder.\n#\n# ── Why humanReview is nested, not a root sibling ───────────────────────────\n#\n# `humanReview` sits UNDER `simpleBuild` (`build.review`) rather than beside it\n# at the root, because a machine's memory scope is its dotted instance path\n# (see src/PatternMachine.ts's `memoryScopeAt`): entering a DESCENDANT scope\n# never breaks the parent's unbroken run, but entering a sibling does. The\n# review tail sits between the two coder turns that most need continuity —\n# `build.addressing` (follows through on review feedback) and `build.squashing`\n# (drafts the finale from the range this process built) — so hoisting it back\n# to a root sibling would force both of those turns to re-derive the feature\n# from scratch on every pass through the tail, instead of resuming the session\n# that built it. The reviewer itself is unaffected either way: `build.review.*`\n# is its own scope regardless of where it's nested, so it never resumes the\n# builder's session.\n#\n# ── Four entry points behind a green-baseline gate, one shared tail ─────────\n#\n# EVERY entry first runs the test suite and only proceeds when it is GREEN — you\n# never start new work (or a review) on top of a red baseline. A red run halts\n# at a human blocked gate (fix the failures and re-run, exactly like the\n# escalate states), or is what `gtd --entry fix-precheck` exists to repair.\n#\n# The initial `idle` state FORKS on which steering file the human creates,\n# routing into that flow's OWN start gate:\n#\n# * the configured requirementsFile (default .gtd/REQUIREMENTS.md) ->\n# `spec-gate.check` -> the ADVANCED flow (one design conversation across\n# product + technical Q&A and decomposition, per-package parallel build +\n# agentic spec-review).\n# * anything else (e.g. the configured todoFile, default .gtd/TODO.md) ->\n# `plan-gate.check` -> the SIMPLE flow (one grilling Q&A loop, a monolithic\n# build, no decomposition, no agentic review).\n#\n# A third entry, `gtd --entry review-gate.check`, jumps there directly and,\n# once green, to `build.review.reviewing` to review a foreign branch through\n# the same shared tail. A fourth, `gtd --entry fix-precheck`, jumps there: a\n# red suite drops straight into the shared `build.fix` loop and out through the\n# review + squash tail; a green suite is a no-op back to `idle`. Both states\n# declare `entry: true` — otherwise unreachable from `entries.default`.\n#\n# All three converge at `build.review.reviewing` -> `build.review.await-review`.\n# Ticking a REVIEW.md box means only \"I reviewed this hunk\"; a COMMENT (not an\n# unticked box) is what asks for changes. Every human step at\n# `build.review.await-review` goes to `build.review.deciding`, which inspects\n# the step and routes:\n# * no comment (every box ticked, no REVIEW.md note, no code edit) -> the diff\n# is a bare REVIEW.md deletion -> build.squashing -> done (the ONLY path\n# to the finale — the whole cycle collapses into one commit).\n# * any comment — a note in REVIEW.md OR a hand-edit to code — ->\n# build.review.deciding writes REVIEW_FEEDBACK.md -> build.review.collecting\n# implements it (completing any code the human hand-edited, never\n# reverting their lines) -> build.addressing (a coder action, not a\n# reviewer one — see \"Why humanReview is nested\" above) -> back through\n# `build.health.check` (the shared health pair, bound via the\n# `$onFeedback`/local `review` reference) -> build.review.reviewing\n# regenerates an INCREMENTAL REVIEW.md (build.review.deciding carries\n# `reviewBase: true`, so each feedback round's review + checkout window\n# covers only `last-review..HEAD`, i.e. the agent's follow-through, not the\n# human's own edit) -> await-review, looping until sign-off.\n# Two dead ends never commit: the sign-off gate (src/program.ts) REFUSES a step\n# that leaves a box unticked with no comment (finish reviewing first) and a\n# deleted REVIEW.md (restore it and tick the boxes).\n#\n# The shared health pair (`build.health.check`/`.escalate`, plus the caller's\n# own `build.fix`) serves the simple build and the feedback loop — its green\n# edge targets `build.review.reviewing`. The advanced per-package loop keeps\n# its OWN health pair (`packages.item.health.check`/`.escalate`, plus\n# `packages.item.fix-suite`) because its green edge targets the per-package\n# `packages.item.spec.review` gate instead.\n#\n# Every `on` pattern's path below is written as the matching var's Eta\n# template (e.g. `\"A <%= it.vars.feedbackFile %>\"`), not a literal `.gtd/...`\n# path — repointing a var reroutes the machine along with every template that\n# reads/writes it (see the README's \"Variables\" section).\n\nvars:\n testCommand: npm test\n # Where gtd keeps its own scratch/bookkeeping (the check scripts' temp output\n # file, review-deciding's state-file exclusion). Independent of the per-file\n # vars below — relocating a file wholesale means setting both.\n stateDir: .gtd\n todoFile: .gtd/TODO.md\n requirementsFile: .gtd/REQUIREMENTS.md\n architectureFile: .gtd/ARCHITECTURE.md\n packagesDir: .gtd/packages\n nextFile: .gtd/NEXT.md\n reviewFile: .gtd/REVIEW.md\n reviewRawFile: .gtd/REVIEW_RAW.md\n reviewFeedbackFile: .gtd/REVIEW_FEEDBACK.md\n feedbackFile: .gtd/FEEDBACK.md\n specFeedbackFile: .gtd/SPEC_FEEDBACK.md\n commitMsgFile: .gtd/COMMIT_MSG.md\n # The two opaque model tiers every agent state draws from — repoint them here\n # (or via a top-level `.gtdrc` `vars:` key / a `GTD_PLANNERMODEL` env var)\n # to change every state on that tier at once. `plannerModel` (default \"smart\")\n # is the heavier tier for the one-shot planning/technical-qa/review turns;\n # `coderModel` (default \"base\") is the tier for the build/fix turns.\n plannerModel: smart\n coderModel: base\n # The fixed diff base for `gtd --entry review-gate.check --var\n # reviewBase=<commitish>` — a plain non-gtd branch reviewed against that\n # commitish rather than the process start. Declared here (rather than left\n # undeclared) so `--var reviewBase=...` passes the CLI's declared-name\n # check; the default empty string renders blank, which the entry command\n # refuses (a review entry always requires an explicit base).\n reviewBase: \"\"\n\nentry:\n default: unified\n\nmachines:\n # ── DEDUP: the green-baseline entry gate (identity-free) ──────────────────\n # Runs the suite before any work starts (its script is the shared suite-check\n # wrapper — red -> the configured feedbackFile, green -> remove it); the `on`\n # rules turn that into \"halt at blocked\" vs \"proceed to $onGreen\". The script\n # and blocked message/describe differ per entry, so they ride in as `with:`\n # params. Kept SEPARATE from `healthGate` (below) even though both are a\n # check+something-else shape: this gate runs BEFORE planning, so it must NOT\n # sweep the plan/review steering files healthGate does — a human's fresh\n # sketch legitimately still exists here.\n entryGate:\n params: [onGreen, blockedMessage, blockedDescribe, checkLabel, blockedLabel, reviewBase]\n entry: check\n states:\n check:\n actor: check\n label: $checkLabel\n # Anchored here and aliased by `fix-precheck` (`gtd --entry\n # fix-precheck` entry): one suite-check script shared by all four\n # call sites (this machine's ×3 instances plus `fix-precheck`)\n # instead of four source copies. These gates run BEFORE planning, so\n # — unlike `healthGate.check` — they must NOT sweep\n # `todoFile`/`requirementsFile`: the human's fresh sketch still\n # legitimately exists here.\n # `entry: true` on the shared local marks all three `entryGate`\n # instances (plan-gate/spec-gate/review-gate) as manual entries —\n # only `review-gate.check` actually needs the reachability root (it\n # is otherwise unreachable from `entries.default`), but the shared\n # dedup makes marking one mark all three. Accepted as correct: all\n # three ARE the workflow's entry gates.\n entry: true\n # `reviewBase: $reviewBase` — only `review-gate` binds a real\n # template (`<%= it.vars.reviewBase %>`), fixing the whole process's\n # diff base to whatever `gtd --entry review-gate.check --var\n # reviewBase=<commitish>` supplies. `plan-gate`/`spec-gate` bind the\n # literal empty string, which compiles away to \"no base\" here — they\n # are reached from `idle`, never manually entered with a base.\n reviewBase: $reviewBase\n script: &suiteCheck |\n #!/usr/bin/env bash\n # gtd check turn — run the suite; a red run records .gtd/FEEDBACK.md, a\n # green run cleans it up. The `on` rules decide what that means.\n set +e\n mkdir -p \"<%~ it.vars.stateDir %>\"\n # Hoist the feedback path once, at the TOP: Eta's autoTrim eats the\n # newline after an interpolation tag, so no tag may be the last token on\n # a line — it would glue the next line's `else`/`fi` onto it and break\n # the script. Below this point everything is plain bash.\n feedback=\"<%~ it.vars.feedbackFile %>\"\n mkdir -p \"$(dirname \"$feedback\")\"\n <%~ it.vars.testCommand %> > <%~ it.vars.stateDir %>/.check-output 2>&1\n code=$?\n if [ \"$code\" -ne 0 ]; then\n if [ -s <%~ it.vars.stateDir %>/.check-output ]; then\n mv <%~ it.vars.stateDir %>/.check-output \"$feedback\"\n else\n rm -f <%~ it.vars.stateDir %>/.check-output\n printf 'the test command failed with exit code %s and produced no output.' \"$code\" > \"$feedback\"\n fi\n # A repeat identical failure would otherwise leave FEEDBACK.md\n # byte-identical to its committed copy — no diff, so the check would\n # look GREEN. Stamp each red run with the current HEAD (which always\n # advances between checks) so a still-red re-run always re-registers\n # as an M/A edit and takes the red edge.\n printf '\\n<!-- gtd check %s -->\\n' \"$(git rev-parse --short HEAD 2>/dev/null || echo pending)\" >> \"$feedback\"\n else\n rm -f <%~ it.vars.stateDir %>/.check-output\n rm -f \"$feedback\"\n fi\n on:\n \"A <%= it.vars.feedbackFile %>\": blocked\n \"M <%= it.vars.feedbackFile %>\": blocked\n \"D <%= it.vars.feedbackFile %>\": $onGreen\n \"C\": $onGreen\n blocked:\n actor: human\n label: $blockedLabel\n file: <%= it.vars.feedbackFile %>\n message: $blockedMessage\n on:\n \"* **\":\n to: check\n action: Retry check\n describe: $blockedDescribe\n\n # ── DEDUP: the check/escalate health pair (identity-free) ─────────────────\n # Runs the suite; a red run records the configured feedbackFile and routes to\n # the caller's OWN fixer (`$onRed` — a coder-identity action that lives\n # beside this machine, not inside it, since fixing IS a coder action and this\n # machine has no identity); a green run cleans it up and routes to\n # `$onGreen`. Escalation redirects here after the caller's fixer has retried\n # `max` times without success. Instantiated for the simple build's own tail\n # (`build.health.*`, caller's fixer is `build.fix`) and the per-package\n # build's own tail (`packages.item.health.*`, caller's fixer is\n # `packages.item.fix-suite`).\n healthGate:\n params: [onGreen, onRed, escalateDescribe, checkLabel, escalateLabel]\n entry: check\n states:\n check:\n actor: check\n label: $checkLabel\n # This script ALSO sweeps spent steering files (`todoFile`,\n # `reviewRawFile`, `reviewFeedbackFile`) — a mechanic `entryGate.check`\n # must NOT run, since it executes before planning, while a fresh\n # sketch legitimately still exists.\n script: |\n #!/usr/bin/env bash\n # gtd check turn — run the suite; a red run records .gtd/FEEDBACK.md, a\n # green run cleans it up. The `on` rules decide what that means.\n set +e\n mkdir -p \"<%~ it.vars.stateDir %>\"\n # Hoist every path once, at the TOP: Eta's autoTrim eats the newline\n # after an interpolation tag, so no tag may be the last token on a\n # line — it would glue the next line's `else`/`fi` onto it and break\n # the script. Below this point everything is plain bash.\n feedback=\"<%~ it.vars.feedbackFile %>\"\n mkdir -p \"$(dirname \"$feedback\")\"\n # Sweep steering files spent by the states that led here: the plan\n # (building), the raw review capture (review.collecting), and the\n # feedback instructions (build.addressing) — each `rm -f` is a\n # no-op when the file is already gone or never existed on this path.\n todo=\"<%~ it.vars.todoFile %>\"\n reviewRaw=\"<%~ it.vars.reviewRawFile %>\"\n reviewFeedback=\"<%~ it.vars.reviewFeedbackFile %>\"\n rm -f \"$todo\" \"$reviewRaw\" \"$reviewFeedback\"\n <%~ it.vars.testCommand %> > <%~ it.vars.stateDir %>/.check-output 2>&1\n code=$?\n if [ \"$code\" -ne 0 ]; then\n if [ -s <%~ it.vars.stateDir %>/.check-output ]; then\n mv <%~ it.vars.stateDir %>/.check-output \"$feedback\"\n else\n rm -f <%~ it.vars.stateDir %>/.check-output\n printf 'the test command failed with exit code %s and produced no output.' \"$code\" > \"$feedback\"\n fi\n # A repeat identical failure would otherwise leave FEEDBACK.md\n # byte-identical to its committed copy — no diff, so the check would\n # look GREEN. Stamp each red run with the current HEAD (which always\n # advances between checks) so a still-red re-run always re-registers\n # as an M/A edit and takes the red edge.\n printf '\\n<!-- gtd check %s -->\\n' \"$(git rev-parse --short HEAD 2>/dev/null || echo pending)\" >> \"$feedback\"\n else\n rm -f <%~ it.vars.stateDir %>/.check-output\n rm -f \"$feedback\"\n fi\n on:\n \"A <%= it.vars.feedbackFile %>\": $onRed\n \"M <%= it.vars.feedbackFile %>\": $onRed\n \"* **\": $onGreen\n \"C\": $onGreen\n escalate:\n actor: human\n label: $escalateLabel\n file: <%= it.vars.feedbackFile %>\n message: |\n The agent could not get the check to pass after repeated attempts.\n `<%= it.vars.feedbackFile %>` holds the last failing output.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n on:\n \"* **\":\n to: check\n action: Retry check\n describe: $escalateDescribe\n\n # ── ENCAPSULATION: the advanced flow's design conversation ─────────────────\n # One planner identity across product Q&A -> technical Q&A -> decomposition:\n # a single codebase exploration instead of three, and the human's answer\n # rationale survives from product into technical and into decomposition (all\n # five states share one memory scope, `design`). Replaces the former\n # `qaLoop ×2` (which split one design conversation into two minds that each\n # re-explored the codebase) plus `simpleBuild`'s own `decompose` (which\n # landed on the coder tier only because it happened to live in the build\n # machine — decomposition is judgement-heavy planning work, not building).\n # ▸ planner identity — `model:` declared once here, stamped onto\n # `product-author`, `technical-author`, and `decompose` (its own prompt\n # states; the `*-answer` states are human turns and carry no prompt).\n advancedPlan:\n model: <%= it.vars.plannerModel %>\n params: [onPackages]\n entry: product-author\n states:\n product-author:\n actor: agent\n label: Refining the product plan\n file: <%= it.vars.requirementsFile %>\n mode: qa\n prompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state file this turn\n touches is `<%= it.vars.requirementsFile %>`; develop it. Do not\n create other files to hold notes or output.\n\n `<%= it.vars.requirementsFile %>` holds the product requirements (or the\n plan you already wrote plus the human's answers on a return lap). Read it,\n explore the codebase, and develop it into a concrete, PRODUCT-level plan\n in this one turn.\n\n Scope: product and user-facing decisions only. Leave implementation\n details (file/module structure, data models, tech-stack choices) for the\n next phase (`<%= it.vars.architectureFile %>`).\n\n For every open point you cannot settle yourself, add a question under a\n `## Open Questions` heading near the top: one `### <question>` sub-heading\n each, followed by a checkbox list of candidate answers — aim for TWO\n concrete options plus a final free-text slot, ALL left UNTICKED:\n\n ### <the question>\n\n - [ ] <first option — a concrete answer, a few words of rationale>\n - [ ] <second option>\n - [ ] _your answer_\n\n Never tick a box yourself — the human ticks exactly one per question. The\n last `- [ ] _your answer_` line is the human's own-answer slot; write it\n with the literal `_your answer_` placeholder and leave it for them.\n\n On a RETURN lap the human has ticked one option per question (or replaced\n `_your answer_` with their own text). For each answered question: fold the\n chosen answer into the plan prose, then MOVE the question down into a\n `## Answered Questions` section as `### <question>` followed by the\n resolved answer in plain PROSE (drop the checkboxes). Never re-raise a\n question the human deleted; if the human deleted the whole\n `## Open Questions` section, treat the plan as accepted and surface no new\n questions unless something is genuinely still blocking. Omit\n `## Open Questions` entirely once there are none left.\n\n Leave `<%= it.vars.requirementsFile %>` uncommitted and finish your turn.\n on:\n \"* **\": product-answer\n\n product-answer:\n actor: human\n label: Awaiting your answers\n file: <%= it.vars.requirementsFile %>\n mode: qa\n answerGate: true\n message: |\n `<%= it.vars.requirementsFile %>` holds the product plan under\n development. Each question under `## Open Questions` offers a few options\n plus a `- [ ] _your answer_` slot. Answer EVERY question by ticking\n exactly one box (`- [x]`); for your own answer, replace `_your answer_`\n with your text and tick that line. Stepping is refused while any question\n is unanswered.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n on:\n \"C\":\n to: technical-author\n action: Accept answers\n describe: >-\n with no open questions left (the agent surfaced none, or you deleted\n the section and it finalized the plan), change nothing to move on to\n technical Q&A (**design.technical-author**).\n \"* **\":\n to: product-author\n action: Revise answers\n describe: >-\n tick exactly one option per open question (replace `_your answer_` for\n your own) to send the plan back for the agent to fold your answers in\n (**design.product-author**). Delete a question to skip it, or delete the whole\n `## Open Questions` section to accept the plan as-is.\n\n technical-author:\n actor: agent\n label: Refining the technical plan\n file: <%= it.vars.architectureFile %>\n mode: qa\n prompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state files this\n turn touches are `<%= it.vars.architectureFile %>` (write it) and\n `<%= it.vars.requirementsFile %>` (delete it once its content is\n folded in). Do not create other files to hold notes or output.\n\n If you developed those requirements earlier in this conversation, work\n from what you already settled; otherwise read\n `<%= it.vars.requirementsFile %>` (the converged product plan) yourself.\n Develop `<%= it.vars.architectureFile %>` from it in this one turn:\n file/module structure, data models, library/tech-stack choices,\n error-handling strategy — the *how*, building on the *what* already\n settled. Do not re-open product-level decisions; treat them as settled.\n\n For every open point you cannot settle yourself, add a question under a\n `## Open Questions` heading exactly as the grilling phase does: one\n `### <question>` each, followed by a checkbox list — TWO concrete options\n plus a final free-text slot, ALL left UNTICKED:\n\n ### <the question>\n\n - [ ] <first option>\n - [ ] <second option>\n - [ ] _your answer_\n\n Never tick a box yourself. On a RETURN lap the human has ticked one option\n per question (or replaced `_your answer_` with their own text): fold each\n chosen answer into the plan prose, then MOVE the question into a\n `## Answered Questions` section as `### <question>` + the resolved answer\n in plain PROSE (drop the checkboxes). Never re-raise a deleted question;\n treat a deleted `## Open Questions` section as acceptance. Omit\n `## Open Questions` once there are none left.\n\n Once `<%= it.vars.architectureFile %>` is written, delete\n `<%= it.vars.requirementsFile %>` — its content has been folded in and it\n must not linger. Leave everything uncommitted and finish your turn.\n on:\n \"* **\": technical-answer\n\n technical-answer:\n actor: human\n label: Awaiting your answers\n file: <%= it.vars.architectureFile %>\n mode: qa\n answerGate: true\n message: |\n `<%= it.vars.architectureFile %>` holds the technical plan under\n development. Each question under `## Open Questions` offers a few options\n plus a `- [ ] _your answer_` slot. Answer EVERY question by ticking\n exactly one box (`- [x]`); for your own answer, replace `_your answer_`\n with your text and tick that line. Stepping is refused while any question\n is unanswered.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n on:\n \"C\":\n to: decompose\n action: Accept answers\n describe: >-\n with no open questions left (the agent surfaced none, or you deleted\n the section and it finalized the plan), change nothing to move on to\n decomposing the work into packages (**design.decompose**).\n \"* **\":\n to: technical-author\n action: Revise answers\n describe: >-\n tick exactly one option per open question (replace `_your answer_` for\n your own) to send the plan back for the agent to fold your answers in\n (**design.technical-author**). Delete a question to skip it, or delete the whole\n `## Open Questions` section to accept the plan as-is.\n\n decompose:\n actor: agent\n label: Decomposing into packages\n prompt: |\n You are an autonomous coding agent. This workflow steers itself through\n its own state files — treat them as its private scratchpad, never as\n project code or documentation. The only state files this turn touches\n are the package files it writes under `<%= it.vars.packagesDir %>/` and\n `<%= it.vars.architectureFile %>`, which it deletes. Do not create other\n files to hold notes or output.\n\n If you wrote `<%= it.vars.architectureFile %>` earlier in this\n conversation, work from what you already settled; otherwise read it\n (the converged technical plan) yourself. Decompose it into an ordered\n set of WORK PACKAGES, one file each under\n `<%= it.vars.packagesDir %>/` (e.g. `<%= it.vars.packagesDir %>/01-name.md`,\n `<%= it.vars.packagesDir %>/02-name.md`, ...). Order the packages so the\n test suite can pass again after each one lands.\n\n Each package file describes a set of INDEPENDENT tasks that can be\n implemented concurrently without ordering constraints between them — a\n single build turn will fan them out to parallel subagents. Give each task\n a clear description, acceptance criteria as `- [ ]` checkboxes, and the\n relevant paths. Also state the package's overall requirement so it can be\n reviewed against its spec. Do NOT put ordering-dependent tasks in the same\n package — split them across ordered packages instead.\n\n No package file may reference any other `.gtd/` file. Once the package\n files are written, delete `<%= it.vars.architectureFile %>`. Leave\n everything uncommitted and finish your turn.\n on:\n \"* <%= it.vars.packagesDir %>/**\": $onPackages\n\n # ── ENCAPSULATION: the simple flow's plan-iteration loop ───────────────────\n # ▸ planner identity — `model:` declared once here, stamped onto `planning`\n # (its only prompt state).\n planLoop:\n model: <%= it.vars.plannerModel %>\n params: [onAccept]\n entry: planning\n states:\n planning:\n actor: agent\n label: Refining the plan\n file: <%= it.vars.todoFile %>\n mode: prose\n prompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state file this\n turn touches is `<%= it.vars.todoFile %>`; develop it into the plan.\n Do not create other files to hold notes or output.\n\n `<%= it.vars.todoFile %>` holds a short sketch of what to build — or, on a\n return lap, the plan you already wrote plus the human's edits and comments\n on it. Read it, explore the codebase, and develop it into a concrete\n implementation plan: the files to change, the approach, and the steps to\n take.\n\n Do NOT ask questions and do NOT add an \"Open Questions\" section — decide\n every open point yourself and write the plan as a set of concrete\n decisions. Where a decision is genuinely a judgement call, state the\n choice you made and, in one line, why. The human iterates on the plan by\n editing it directly, not by answering questions.\n\n On a return lap, `<%= it.vars.todoFile %>` carries the human's edits and\n inline comments on your previous plan. If you wrote that plan earlier in\n this conversation, work from what you already know plus these edits;\n otherwise read the file fresh. Fold every edit and comment into a\n revised, self-contained plan and remove the addressed comments — the plan\n is always the current, clean proposal, never a diff.\n\n Leave `<%= it.vars.todoFile %>` uncommitted and finish your turn — do\n not commit, and do not run `gtd step agent` yourself; the harness does\n that.\n on:\n \"* **\": await-plan\n\n await-plan:\n actor: human\n label: Awaiting your review\n file: <%= it.vars.todoFile %>\n mode: prose\n message: |\n `<%= it.vars.todoFile %>` holds the proposed implementation plan. Read it,\n then either accept it as-is or edit it — rewrite a step, add a\n constraint, or drop an inline comment — to send it back for another round.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n on:\n \"C\":\n to: $onAccept\n action: Accept plan\n describe: >-\n change nothing to move on to implementation (**build.building**).\n \"* **\":\n to: planning\n action: Revise plan\n describe: >-\n rewrite a step, add a constraint, or leave an inline comment to\n send it back for another round (**plan.planning**).\n\n # ── ENCAPSULATION: the shared review tail ──────────────────────────────────\n # ▸ planner identity — `model:` declared once here, stamped onto `reviewing`\n # and `collecting` (its only prompt states). The implementer's OWN\n # follow-through on feedback is a coder action, not a reviewer one — it\n # lives in `build.addressing` (`$onFeedback`), not inside this machine.\n humanReview:\n model: <%= it.vars.plannerModel %>\n entry: reviewing\n states:\n reviewing:\n actor: agent\n label: Reviewing\n file: <%= it.vars.reviewFile %>\n mode: review\n # `gtd --entry review-gate.check` (a plain non-gtd branch, e.g. a\n # colleague's PR) does NOT enter here directly — it enters\n # `review-gate.check` first, which runs the green-baseline gate and,\n # once green, transitions here. The `Gtd-Review-Base:` trailer rides on that\n # entry commit (the process's oldest), so everything below still reuses\n # the same reviewing/await-review/feedback machinery over <commitish>..HEAD.\n prompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state file this\n turn touches is `<%= it.vars.reviewFile %>`; write it at exactly\n that path. Do not create other files to hold notes or output.\n\n Write `<%= it.vars.reviewFile %>` to help a human review the changes in\n this EXACT format:\n\n - First non-blank line: `# Review: <%= it.currentCommit.slice(0, 7) %>`\n - Somewhere in the document: `<!-- base: <%= it.reviewBase %> -->`\n - At least one `## <Chunk Title>` heading, grouping hunks semantically\n (same feature/refactor/fix, even across files) — a short explanation of\n what changed and why, then one `- [ ] ./path/to/file.ts#42 — note` file\n pointer per hunk (checkboxes are for the human to tick, not you; every\n path is `./`-relative, with an optional `#line` and an optional\n ` — note`).\n\n This prompt carries no diff — read the changes yourself. The range runs\n from `<%= it.reviewBase %>` to the working tree (committed turns plus\n anything still pending); on a feedback round that base is the previous\n review's boundary, so it covers only what changed since then, not items\n already signed off.\n\n Leave `<%= it.vars.reviewFile %>` uncommitted and finish your turn.\n on:\n \"* **\": await-review\n\n await-review:\n actor: human\n label: Awaiting your review\n file: <%= it.vars.reviewFile %>\n mode: review\n # While the cycle rests here, gtd opens a \"review checkout window\": raw HEAD\n # is rewound to the review base (the most-recent `deciding` commit — so a\n # feedback round shows only `last-review..HEAD` — or the process start on\n # the first review) with the working tree untouched, surfacing the diff\n # as ordinary uncommitted changes. It closes on the next invocation.\n #\n # Every human step here routes to `deciding`, which decides sign-off vs.\n # feedback from the step's CONTENT (a comment, not a tick). Ticking a box\n # only records \"I reviewed this hunk\". Two dead ends can't be told apart by a\n # file-pattern edge, so the sign-off gate (`enforceReviewSignoffGate` in\n # src/program.ts) catches them BEFORE the step commits and refuses: an\n # unfinished review (a box still `- [ ]` with no comment) and a deleted\n # REVIEW.md.\n reviewWindow: true\n message: |\n `<%= it.vars.reviewFile %>` holds the review record for the cycle — one\n `- [ ]` checkbox per reviewable item, grouped into chunks. Tick a box\n (`- [x]`) as you review each hunk; ticking only records that you've read\n it, it is not sign-off.\n\n When you've been through the whole diff, run `gtd step human`:\n\n - **Sign off** — tick EVERY box and leave no comment — no note in\n `<%= it.vars.reviewFile %>`, no code edit — to collapse the cycle into\n one commit (**build.squashing**).\n - **Request changes** — leave a comment: a note on a\n `<%= it.vars.reviewFile %>` line, or a direct code edit — to send it to a\n build + re-review round (**review.deciding** → **build.addressing**). A\n code edit counts as your own fix: the agent completes the follow-through\n it implies and never reverts your lines.\n\n Stepping with a box still unticked and no comment is refused (finish\n reviewing first); so is deleting `<%= it.vars.reviewFile %>`.\n on:\n \"* **\": deciding\n\n deciding:\n actor: check\n label: Reviewing\n file: <%= it.vars.reviewFile %>\n mode: review\n # Anchors the INCREMENTAL review base: its most-recent in-process commit is\n # the point the previous review round ended, so the next `reviewing` turn\n # (and the review checkout window) covers only the changes since then. On\n # the first review there is no such commit, so the base is the cycle start.\n reviewBase: true\n script: |\n #!/usr/bin/env bash\n # gtd check turn — decide the human's review step from its CONTENT. HEAD is\n # the human's `await-review -> deciding` commit; HEAD^ is the `reviewing`\n # commit that wrote the agent's original REVIEW.md (the sign-off gate\n # refuses an incomplete step, so `deciding` is entered exactly once per\n # round — nothing commits in between). It is FEEDBACK when the human left\n # a comment: a note in REVIEW.md (any edit beyond a \"[ ]\"->\"[x]\" tick) OR a\n # hand-edit to any file this round other than `.gtd/` or the state's own\n # reviewFile (which is var-configurable and may live outside `.gtd/`, e.g.\n # a root-level REVIEW.md — issue #128). Otherwise it is a clean sign-off.\n # This turn only CAPTURES the raw material into REVIEW_RAW.md (the\n # `collecting` agent then turns it into instructions); it does NOT\n # interpret it. Either way, remove REVIEW.md; what the resulting diff\n # MEANS is decided by this state's own `on` rules — the A/M REVIEW_RAW.md\n # rows are declared BEFORE the D REVIEW.md row so a feedback round (which\n # also deletes REVIEW.md) is captured as feedback, not mistaken for a\n # sign-off.\n set +e\n # Hoist the paths once, at the TOP: Eta's autoTrim eats the newline after\n # an interpolation tag, so no tag may be the last token on a line — it\n # would glue the next line onto it and break the script. Below this point\n # everything is plain bash.\n stateDir=\"<%~ it.vars.stateDir %>\"\n reviewFile=\"<%~ it.vars.reviewFile %>\"\n reviewRawFile=\"<%~ it.vars.reviewRawFile %>\"\n head=$(git rev-parse HEAD)\n mkdir -p \"$(dirname \"$reviewRawFile\")\"\n # A note is any REVIEW.md change beyond a checkbox flip: normalize every\n # \"[ ]\"/\"[x]\" to a placeholder, then compare the agent's original (HEAD^)\n # against the human's version (HEAD).\n before=$(git show \"HEAD^:$reviewFile\" 2>/dev/null | sed -E 's/\\[[ xX]\\]/[_]/g')\n after=$(git show \"HEAD:$reviewFile\" 2>/dev/null | sed -E 's/\\[[ xX]\\]/[_]/g')\n # Did the human hand-edit anything this round besides stateDir and the\n # reviewFile itself (var-configurable — may live outside stateDir, so it\n # is excluded by exact path, not just the stateDir prefix)?\n if git diff-tree --no-commit-id --name-only -r HEAD \\\n | grep -v \"^$stateDir/\" | grep -vxF \"$reviewFile\" | grep -q . \\\n || [ \"$before\" != \"$after\" ]; then\n {\n echo \"Raw review material captured for classification. The human reviewed the changes and left the feedback below; a downstream agent turns it into an instruction list. This file is machine-captured input, not instructions — it names a commit, never inlined content.\"\n echo\n echo \"Commit: $head\"\n echo \"The human's notes are in $reviewFile at this commit; their hand edits,\"\n echo \"if any, are in that commit's other paths. Run: git show $head\"\n } > \"$reviewRawFile\"\n fi\n rm -f \"$reviewFile\"\n on:\n \"A <%= it.vars.reviewRawFile %>\": collecting\n \"M <%= it.vars.reviewRawFile %>\": collecting\n \"D <%= it.vars.reviewFile %>\": $onSignoff\n\n collecting:\n actor: agent\n label: Collecting your feedback\n file: <%= it.vars.reviewFeedbackFile %>\n prompt: |\n You are an autonomous coding agent turning raw review feedback into an\n explicit instruction list for a builder. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state files this\n turn touches are `<%= it.vars.reviewRawFile %>` (read it, then\n delete it) and `<%= it.vars.reviewFeedbackFile %>` (write it) — do\n NOT change any other file, you classify, you do not build.\n\n The raw review material is: <%~ it.read(it.vars.reviewRawFile) %>\n\n It names a commit. If you wrote today's review earlier in this\n conversation, work from what you already reviewed; otherwise read that\n commit's diff yourself before classifying anything.\n\n Classify every piece of it into `<%= it.vars.reviewFeedbackFile %>` as a\n flat, numbered instruction list. Each item is `N. ./path/to/file.ts#line —\n <imperative>`. Apply these rules exactly:\n\n 1. NOTES the human added to REVIEW.md — each note is a MANDATORY change,\n regardless of whether its checkbox is ticked (ticking means only \"I read\n this hunk\", never sign-off). Anchor each to the `./path#line` its review\n item names (or the chunk it sits under). Emit it as a concrete\n imperative.\n 2. Every COMMENT the human ADDED this round (a `+` line that is a code\n comment, in the hand-edited files) is a MANDATORY instruction the human\n is giving the builder — even a plain-prose one, even if its box is\n ticked. Emit an imperative to satisfy it and note that the comment line\n itself must be REMOVED once addressed. If a comment turns out to describe\n behaviour the code already has, the instruction is still \"confirm the\n code matches and remove the comment line\".\n 3. Every NON-comment code line the human hand-edited is an already-committed\n INTENT, NOT a do-then-delete item. Emit an instruction that names those\n files and says: their lines are final — complete ONLY the follow-through\n they imply (callers, tests, types, docs) and NEVER revert or rewrite\n them. Do not turn their own edit into a task.\n\n Split each hand-edited hunk per line: comment `+` lines follow rule 2, the\n surrounding real code follows rule 3.\n\n If — and only if — nothing in the raw material is actionable (e.g. the human\n left only an approving remark), write `<%= it.vars.reviewFeedbackFile %>`\n containing exactly one line: `NOTHING ACTIONABLE — <one-sentence reason>`.\n Never invent work to satisfy a non-actionable remark.\n\n Delete `<%= it.vars.reviewRawFile %>`, leave everything else uncommitted,\n and finish your turn.\n on:\n \"A <%= it.vars.reviewFeedbackFile %>\": $onFeedback\n \"M <%= it.vars.reviewFeedbackFile %>\": $onFeedback\n\n # ── ENCAPSULATION: the advanced flow's per-package agentic review ──────────\n # A single state: a machine whose entire job is to be a separate MIND from\n # the implementer, distinct from the coder identity that builds/fixes the\n # package (`packages.item.*`). ▸ planner identity — `model:` declared once\n # here, stamped onto `review` (its only prompt state). Fixing the reviewer's\n # own findings is a coder action, not a reviewer one — it lives in\n # `packages.item.fix-spec` (`$onFix`), not inside this machine.\n specReview:\n model: <%= it.vars.plannerModel %>\n params: [onApproved, onFix]\n entry: review\n states:\n review:\n actor: agent\n label: Reviewing the package\n # Bounded per cycle: once entered `max` times, a transition into it is\n # redirected to `$onApproved` (force-close the package and move on — the\n # unresolved concern is deferred to the shared human review at the tail,\n # which sees the whole diff anyway). NOTE the cap is PROCESS-scoped, so it\n # pools across packages — the same documented limitation the shared\n # `build.fix` cap has (see the README's \"Why\" section, \"Bounded, not\n # runaway\").\n retry:\n max: 3\n otherwise: $onApproved\n prompt: |\n You are an autonomous coding agent reviewing a freshly-built work package\n against its own spec. This workflow steers itself through its own\n state files — treat them as its private scratchpad, never as project\n code or documentation. The only state file this turn touches is\n `<%= it.vars.specFeedbackFile %>`; write it only when — and only\n when — you find problems. Do not create other files to hold notes or\n output.\n\n The package spec is: <%~ it.read(it.vars.nextFile) %>\n\n Read that spec, then verify the package's implementation against it: are\n all its tasks done, acceptance criteria met, and the code sound and\n consistent with the codebase? This prompt carries no diff — read it\n yourself: the range runs from `<%= it.startCommit %>` to the working\n tree.\n\n Note the range is process-wide (from the start of the whole build, not\n just this package), so it can span earlier packages too.\n\n - If it fully satisfies the spec, write NOTHING and finish your turn — a\n clean turn is your approval.\n - If it does not, write `<%= it.vars.specFeedbackFile %>` listing the\n concrete problems for a fix turn to address. Be specific.\n\n Do not fix anything yourself and do not delete the package file — a later\n step owns that. Finish your turn.\n on:\n \"A <%= it.vars.specFeedbackFile %>\": $onFix\n \"M <%= it.vars.specFeedbackFile %>\": $onFix\n \"D <%= it.vars.specFeedbackFile %>\": $onApproved\n \"C\": $onApproved\n\n # ── ENCAPSULATION: the advanced flow's per-package build queue ─────────────\n # Identity-free (a bookkeeping queue, not a builder) — the actual per-package\n # build identity lives in its own `item` child (packageItem, below).\n packageLoop:\n params: [onDrained]\n entry: picking\n states:\n picking:\n actor: check\n label: Picking the next package\n script: |\n #!/usr/bin/env bash\n # gtd check turn (the package queue arbiter) — the loop driver executes\n # this verbatim, then steps the check actor. Mechanics only: take the\n # first package file (by name) under the packages dir into NEXT.md, or\n # remove NEXT.md when the queue is empty. What NEXT.md's presence/absence\n # MEANS (another package to build vs. the queue closing out to review) is\n # decided by this state's own `on` rules at capture time — never here.\n set +e\n # Hoist the NEXT path once, at the TOP: Eta's autoTrim eats the newline\n # after an interpolation tag, so no tag may be the last token on a line —\n # it would glue the next line's `else`/`fi` onto it and break the script.\n # Below this point everything is plain bash.\n nextFile=\"<%~ it.vars.nextFile %>\"\n # Sweep the advanced flow's spent Q&A steering files: requirementsFile\n # is consumed by technical-qa, architectureFile by decompose — both are\n # gone by the time the package queue starts. No-op if already removed.\n requirements=\"<%~ it.vars.requirementsFile %>\"\n architecture=\"<%~ it.vars.architectureFile %>\"\n rm -f \"$requirements\" \"$architecture\"\n next=$(ls <%~ it.vars.packagesDir %>/*.md 2>/dev/null | head -n 1)\n if [ -n \"$next\" ]; then\n mkdir -p \"$(dirname \"$nextFile\")\"\n printf '%s' \"$next\" > \"$nextFile\"\n else\n rm -f \"$nextFile\"\n fi\n on:\n \"D <%= it.vars.nextFile %>\": $onDrained\n \"* <%= it.vars.nextFile %>\": item.building\n \"C\": $onDrained\n\n # The per-package build identity — its own coder machine (below), so\n # deleting a package file's own lifecycle stays inside `item` while the\n # queue itself (`packages`) stays a single state.\n item:\n machine: packageItem\n with:\n onNext: picking\n\n # ── ENCAPSULATION: the advanced flow's per-package build identity ──────────\n # ▸ coder identity — `model:` declared once here, stamped onto `building`,\n # `fix-suite`, and `fix-spec` (its own prompt states). `closing` ends that\n # package file's own lifecycle; keeping it here (not in the queue) keeps\n # `packages` itself to a single state. `packages.item.closing` -> `picking`\n # is the ONE upward-resolving target in the whole file, so it MUST be a\n # param (`$onNext`, bound by `packages` — see the machine's own reference).\n packageItem:\n model: <%= it.vars.coderModel %>\n params: [onNext]\n entry: building\n states:\n building:\n actor: agent\n label: Building\n prompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. This turn writes no state\n file: do NOT delete the package file (the spec-review gate reads\n it after you) and never touch `<%= it.vars.nextFile %>` — the\n `picking` state owns it.\n\n The package to implement is: <%~ it.read(it.vars.nextFile) %>\n\n That file describes a set of INDEPENDENT tasks. Implement ALL of them in\n this one turn, fanning the independent tasks out to parallel subagents\n where your harness supports it — each task is self-contained and can be\n built concurrently. Implement EXACTLY the tasks this package describes — no\n more, no less; leave every other package file untouched. Use TDD\n discipline within each task.\n\n Leave the package file in place and everything else uncommitted, then\n finish your turn.\n on:\n \"* **\": health.check\n\n fix-suite:\n actor: agent\n label: Fixing the check\n file: <%= it.vars.feedbackFile %>\n prompt: &suiteFixPrompt |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state file this\n turn touches is `<%= it.vars.feedbackFile %>`; fix it per its\n contents, or delete it if the feedback turns out to be wrong. Do not\n create other files to hold notes or output.\n\n Read `<%= it.vars.feedbackFile %>` (the failing test output) and fix the\n code so the suite passes. Keep the change focused — do not refactor\n unrelated code. If the feedback is wrong, empty or delete\n `<%= it.vars.feedbackFile %>` instead of \"fixing\" a non-issue.\n\n Leave everything uncommitted and finish your turn — do not commit.\n retry:\n max: 3\n otherwise: health.escalate\n on:\n \"* **\": health.check\n\n fix-spec:\n actor: agent\n label: Fixing review feedback\n file: <%= it.vars.specFeedbackFile %>\n prompt: |\n You are an autonomous coding agent. This workflow steers itself\n through its own state files — treat them as its private scratchpad,\n never as project code or documentation. The only state file this\n turn touches is `<%= it.vars.specFeedbackFile %>`; address it, then\n delete it. Do not create other files to hold notes or output.\n\n Read `<%= it.vars.specFeedbackFile %>` (the reviewer's concerns about the\n current package) and the package spec (`<%= it.read(it.vars.nextFile) %>`),\n then fix the code to resolve every concern. Keep the change focused.\n\n Delete `<%= it.vars.specFeedbackFile %>` once every concern is addressed.\n Leave everything else uncommitted and finish your turn.\n on:\n \"* **\": health.check\n\n closing:\n actor: check\n label: Closing out the package\n script: |\n #!/usr/bin/env bash\n # gtd check turn (the package closer) — remove the just-reviewed package\n # file (its path is in NEXT.md) plus any leftover spec feedback, so\n # `picking` selects the NEXT package (or falls through to review when the\n # queue is empty). Reached on approval (spec review clean) and on the\n # spec review retry cap (force-close, deferring the concern to the tail).\n set +e\n pkg=$(cat <%~ it.vars.nextFile %> 2>/dev/null)\n [ -n \"$pkg\" ] && rm -f \"$pkg\"\n rm -f <%~ it.vars.specFeedbackFile %> <%~ it.vars.nextFile %>\n on:\n \"* **\": $onNext\n\n # Per-package health pair: packages.item.health.check/.escalate ->\n # packages.item.spec.review (green) or packages.item.fix-suite (red).\n health:\n machine: healthGate\n with:\n onGreen: spec\n onRed: fix-suite\n escalateDescribe: >-\n edit the code and/or `.gtd/FEEDBACK.md` yourself\n (**packages.item.health.check**).\n checkLabel: Running checks\n escalateLabel: Escalating to a human\n\n # Per-package agentic review: packages.item.spec.review -> closing\n # (approved), or packages.item.fix-spec (findings — re-verify via the\n # full health check before re-reviewing).\n spec:\n machine: specReview\n with:\n onApproved: closing\n onFix: fix-spec\n\n # ── ENCAPSULATION: the simple flow's build identity ────────────────────────\n # ▸ coder identity — `model:` declared once here, stamped onto `building`,\n # `squashing`, `addressing`, and `fix` (its own prompt states). Decomposition\n # (`design.decompose`) belongs to the planner tier now, not here — see\n # `advancedPlan` below. `addressing` is the implementer's OWN follow-through\n # on review feedback (a coder action) — reached via `$onFeedback`, bound at\n # its own `review:` reference below, not nested inside the (planner-only)\n # `humanReview` machine. `humanReview` is nested HERE (not at the root) so\n # the review tail's run stays inside the builder's own scope — see the\n # header comment.\n simpleBuild:\n model: <%= it.vars.coderModel %>\n params: [onDone]\n entry: building\n states:\n building:\n actor: agent\n label: Building\n file: <%= it.vars.todoFile %>\n prompt: |\n You are an autonomous coding agent. This workflow steers itself through\n its own state files — treat them as its private scratchpad, never as\n project code or documentation. The only state file this turn touches is\n `<%= it.vars.todoFile %>`; delete it once its work is complete. Do not\n create other files to hold notes or output.\n\n The plan to implement is: <%~ it.read(it.vars.todoFile) %>\n\n Read that plan and implement it in full. Use TDD discipline: one test,\n then the implementation that passes it, then the next — never write all\n the tests first.\n\n Delete `<%= it.vars.todoFile %>` once the work it describes is complete\n and verified. Leave everything else uncommitted and finish your turn.\n on:\n \"* **\": health.check\n\n squashing:\n actor: agent\n label: Squashing\n file: <%= it.vars.commitMsgFile %>\n prompt: |\n You are an autonomous coding agent. The cycle is approved and done. This\n workflow steers itself through its own state files — treat them as its\n private scratchpad, never as project code or documentation. The only\n state file this turn touches is `<%= it.vars.commitMsgFile %>`; write\n it. Do not create other files to hold notes or output.\n\n Leave `<%= it.vars.commitMsgFile %>` uncommitted and finish your turn —\n entering this file squashes everything this process retains into one\n commit using its content as the message. This prompt carries no diff —\n if you built that range yourself earlier in this conversation, work from\n what you already know; otherwise read the range yourself. It runs from\n `<%= it.retainedBase %>` to the working tree, and is exactly what that\n commit will contain, the changes THIS process made (its trace/retry\n boundary onward). For a normal build\n cycle that is the whole feature; for a `gtd --entry review-gate.check`\n process it is only the fixes made during the review — NOT the changeset\n that was under review.\n\n If that range is non-empty, write `<%= it.vars.commitMsgFile %>` with ONE\n conventional-commits message for the changes (subject line\n `type(scope): subject`, imperative mood, <= 72 characters; a body\n explaining the why, trade-offs, and key decisions). Plain text, no\n markdown wrapper.\n\n If that range is empty (a clean review sign-off with no fixes), write\n `<%= it.vars.commitMsgFile %>` with exactly this one line and nothing\n else:\n\n chore: human review\n on:\n \"A <%= it.vars.commitMsgFile %>\": $onDone\n \"M <%= it.vars.commitMsgFile %>\": $onDone\n\n addressing:\n actor: agent\n label: Addressing your feedback\n file: <%= it.vars.reviewFeedbackFile %>\n # Refuse a work-free turn: a step whose only pending change is deleting the\n # instructions file (the original \"captured then discarded\" bug). The edge\n # gate (`enforceFeedbackProgressGate` in src/program.ts) reads this flag and\n # exempts a `NOTHING ACTIONABLE` sentinel (a non-actionable review round,\n # which legitimately makes no code change). The pure engine carries it inert.\n requireProgress: true\n prompt: |\n You are an autonomous coding agent addressing review feedback. This\n workflow steers itself through its own state files — treat them as\n its private scratchpad, never as project code or documentation. The\n only state file this turn touches is\n `<%= it.vars.reviewFeedbackFile %>`; address it, then delete it. Do\n not create other files to hold notes or output.\n\n The instructions to implement are: <%~ it.read(it.vars.reviewFeedbackFile) %>\n\n This is a flat instruction list, not a diff — execute every item exactly as\n written, no Q&A, no re-planning, using TDD discipline:\n\n - An item that says the human's own lines are final: build ON TOP of them to\n complete only the follow-through it names (callers, tests, types, docs).\n NEVER revert or rewrite the lines they already committed.\n - An item derived from a human comment: satisfy it AND delete that comment\n line from the code (the human's review comments are transient — none of\n them survive this round).\n\n If the file's sole content is a `NOTHING ACTIONABLE` line, make no code\n change; just delete the instructions file.\n\n Delete `<%= it.vars.reviewFeedbackFile %>` once every item is addressed.\n Leave everything else uncommitted and finish your turn. Deleting the\n instructions file WITHOUT making the changes it asks for is refused — the\n feedback must actually be addressed.\n on:\n \"* **\": health.check\n\n fix:\n actor: agent\n label: Fixing the check\n file: <%= it.vars.feedbackFile %>\n prompt: *suiteFixPrompt\n retry:\n max: 3\n otherwise: health.escalate\n on:\n \"* **\": health.check\n\n # Shared health pair: build.health.check/.escalate -> build.review.reviewing\n # (green) or build.fix (red).\n health:\n machine: healthGate\n with:\n onGreen: review\n onRed: fix\n escalateDescribe: >-\n edit the code and/or `.gtd/FEEDBACK.md` yourself\n (**build.health.check**).\n checkLabel: Running checks\n escalateLabel: Escalating to a human\n\n # The shared review tail, nested HERE (not at the root) so its run stays\n # inside the builder's own scope across the feedback round-trip — see\n # the header comment. Sign-off -> squashing; feedback -> addressing (the\n # implementer's own coder-identity follow-through, not a reviewer action).\n review:\n machine: humanReview\n with:\n onSignoff: squashing\n onFeedback: addressing\n\n # The root machine — the whole \"advanced/simple\" flow tree described in\n # this file's header comment. WIRING ONLY: no identity of its own, just the\n # shared entry/exit states and the top-level machine tree.\n unified:\n entry: idle\n states:\n # ── Shared entry ────────────────────────────────────────────────────────\n idle:\n actor: human\n label: Idle\n # Every human-gate `message:` below closes with the same rendered \"What\n # each change does next\" list, built from `it.edges` (the resting state's\n # own `on` edges — each edge's `describe:` is the single source of truth).\n message: |\n No active gtd cycle.\n\n To start one, create a steering file describing what you want built:\n\n - `<%= it.vars.todoFile %>` — a short sketch — starts the SIMPLE flow\n (plan, build, review).\n - `<%= it.vars.requirementsFile %>` — the product requirements — starts\n the ADVANCED flow (product + technical Q&A, package decomposition,\n parallel build, per-package agentic review).\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n on:\n \"* <%= it.vars.requirementsFile %>\":\n to: spec-gate\n action: Start advanced flow\n describe: >-\n save the product requirements to `.gtd/REQUIREMENTS.md` — gtd first\n checks the test baseline is green, then the agent develops them\n through product then technical Q&A, decomposes the work into\n packages, and builds each in parallel (**spec-gate.check**).\n \"* **\":\n to: plan-gate\n action: Start simple flow\n describe: >-\n save a short sketch to `.gtd/TODO.md` (or any other change) — gtd\n first checks the test baseline is green, then the agent reads it,\n explores the codebase, and develops it into a concrete\n implementation plan (**plan-gate.check**).\n\n # ── Green-baseline gate: `gtd --entry fix-precheck` entry ─────────────────\n # `gtd --entry fix-precheck` enters HERE: run the suite, then a red run\n # drops straight into the shared `build.fix` loop (-> build.health.check ->\n # review + squash tail); a green run is a no-op back to `idle` (nothing\n # to fix).\n fix-precheck:\n actor: check\n label: Checking the baseline\n entry: true\n script: *suiteCheck\n on:\n \"A <%= it.vars.feedbackFile %>\": build.fix\n \"M <%= it.vars.feedbackFile %>\": build.fix\n \"D <%= it.vars.feedbackFile %>\": idle\n \"C\": idle\n\n # ── Shared tail: the finale ─────────────────────────────────────────────\n done:\n commit: |\n <%~ it.read(it.vars.commitMsgFile) %>\n\n Token cost: <%= it.processCost %>\n <% it.processCostByModel.forEach(function(m){ %>\n - <%= m.model %>: <%= m.cost %>\n <% }) %>\n\n # ── Green-baseline gate: simple flow entry. ────────────────────────────────\n plan-gate:\n machine: entryGate\n with:\n onGreen: plan\n reviewBase: \"\"\n blockedMessage: |\n The test baseline is red — gtd will not start new work on a broken suite.\n `<%= it.vars.feedbackFile %>` holds the failing output.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n blockedDescribe: >-\n edit the code and/or `.gtd/FEEDBACK.md` to fix the failing tests\n (**plan-gate.check**). To repair the baseline as its own separate\n reviewed commit instead, abandon this start and run\n `gtd --entry fix-precheck` from a clean `idle`.\n checkLabel: Checking the baseline\n blockedLabel: Baseline is red\n\n # ── Green-baseline gate: advanced flow entry. ──────────────────────────────\n spec-gate:\n machine: entryGate\n with:\n onGreen: design\n reviewBase: \"\"\n blockedMessage: |\n The test baseline is red — gtd will not start new work on a broken suite.\n `<%= it.vars.feedbackFile %>` holds the failing output.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n blockedDescribe: >-\n edit the code and/or `.gtd/FEEDBACK.md` to fix the failing tests\n (**spec-gate.check**). To repair the baseline as its own separate\n reviewed commit instead, abandon this start and run\n `gtd --entry fix-precheck` from a clean `idle`.\n checkLabel: Checking the baseline\n blockedLabel: Baseline is red\n\n # ── Green-baseline gate: `gtd --entry review-gate.check` entry -> build.review.reviewing. ─\n review-gate:\n machine: entryGate\n with:\n onGreen: build.review\n reviewBase: <%= it.vars.reviewBase %>\n blockedMessage: |\n The test baseline is red — gtd will not start a review on a broken suite.\n `<%= it.vars.feedbackFile %>` holds the failing output.\n\n What each change does next (then run `gtd step human`):\n <% it.edges.forEach(function (e) { if (e.describe) { %>\n <%~ \"- \" + (e.action ? \"**\" + e.action + \"** — \" : \"\") + e.describe + \"\\n\" %>\n <% } }) %>\n blockedDescribe: >-\n edit the code and/or `.gtd/FEEDBACK.md` to fix the failing tests\n (**review-gate.check**).\n checkLabel: Checking the baseline\n blockedLabel: Baseline is red\n\n # ── Advanced flow: the design conversation (design.product-author/.product-answer ─\n # -> design.technical-author/.technical-answer -> design.decompose -> packages). ──\n design:\n machine: advancedPlan\n with:\n onPackages: packages\n\n # ── Simple flow plan-iteration loop (plan.planning/.await-plan -> build.building). ──\n plan:\n machine: planLoop\n with:\n onAccept: build.building\n\n # ── Advanced per-package build queue (packages.* -> build.review). ────────\n packages:\n machine: packageLoop\n with:\n onDrained: build.review\n\n # ── Simple flow's build identity (build.* -> build.review -> done). ───────\n build:\n machine: simpleBuild\n with:\n onPackages: packages\n onDone: done\n";
190635
191010
  //#endregion
190636
191011
  //#region src/workflows/templates.ts
190637
191012
  /**
@@ -190699,13 +191074,21 @@ const DEFAULT_WORKFLOW = compileWorkflowConfig((0, import_dist$1.parse)(UNIFIED_
190699
191074
  /** The compiled built-in default workflow definition — the fallback when no `workflow:` is configured. */
190700
191075
  const defaultWorkflowDefinition = DEFAULT_WORKFLOW.definition;
190701
191076
  /**
190702
- * The built-in default's RAW workflow value — `unified.yaml` parsed but NOT
190703
- * expanded, so it still carries `submachines:`/`use:`. `src/Config.ts` exposes
190704
- * it as `ConfigOperations.rawWorkflow` for the built-in default; tooling that
190705
- * needs the sub-machine grouping the compiled definition flattens away (e.g.
190706
- * `gtd visualize`'s `collectGroups`) reads it. Parsed once at module load.
191077
+ * The built-in default's machine-instance tree — the same `MachineNode` tree
191078
+ * `flattenMachines` (`src/Machines.ts`) built while compiling `DEFAULT_WORKFLOW`
191079
+ * above, exposed for tooling that needs the machine grouping the compiled
191080
+ * `WorkflowDefinition` flattens away (e.g. `gtd visualize`). `src/Config.ts`
191081
+ * exposes it as `ConfigOperations.machineTree` for the built-in default.
191082
+ */
191083
+ const defaultMachineTree = DEFAULT_WORKFLOW.tree;
191084
+ /**
191085
+ * The built-in default's memory-scope map — qualified state name -> the
191086
+ * machine-instance path that owns it, the same `scopes` map
191087
+ * `flattenMachines` (`src/Machines.ts`) built while compiling
191088
+ * `DEFAULT_WORKFLOW` above. `src/Config.ts` exposes it as
191089
+ * `ConfigOperations.stateScopes` for the built-in default.
190707
191090
  */
190708
- const defaultWorkflowRaw = (0, import_dist$1.parse)(UNIFIED_WORKFLOW);
191091
+ const defaultStateScopes = DEFAULT_WORKFLOW.scopes;
190709
191092
  /** The built-in default workflow's own declared `vars:` defaults (layer 1 of the merged `it.vars`). */
190710
191093
  const defaultWorkflowVars = DEFAULT_WORKFLOW.vars;
190711
191094
  /**
@@ -190799,187 +191182,172 @@ const modesJsonSchema = {
190799
191182
  }
190800
191183
  }
190801
191184
  };
190802
- /** One state's shape — mirrors `PatternConfig.ts`'s `KNOWN_STATE_KEYS` and per-field compilers. */
190803
- const stateJsonSchema = {
190804
- type: "object",
190805
- description: "One workflow state. Declare exactly one content kind (script/prompt/message/commit). A commit state is final: no actor, no on.",
190806
- additionalProperties: false,
190807
- properties: {
190808
- actor: {
190809
- type: "string",
190810
- description: "Who acts at this state. Required on every non-commit state; forbidden on a commit state."
190811
- },
190812
- script: {
190813
- type: "string",
190814
- description: "Content kind: a shell script (Eta template). The loop driver executes it verbatim via bash, then steps this state's actor."
190815
- },
190816
- prompt: {
190817
- type: "string",
190818
- description: "Content kind: an agent prompt (Eta template), emitted by `gtd next`."
190819
- },
190820
- message: {
190821
- type: "string",
190822
- description: "Content kind: a human-facing message (Eta template), emitted by `gtd next`."
190823
- },
190824
- commit: {
190825
- type: "string",
190826
- description: "Content kind: entering this state ends the process by squashing it into one commit with this message (Eta template). Final — no actor, no on."
190827
- },
190828
- on: {
190829
- type: "object",
190830
- description: "Ordered map of change pattern -> target state. Patterns: \"C\" (clean tree) or \"<A|M|D|*> <glob>\" over the pending diff; first declared match wins. Every target must name a defined state, and every non-initial state must be reachable through these edges (or a retry.otherwise). A value is either the target state name (string) or a { to, describe } object whose describe is a human-readable sentence templates can surface as it.edges (e.g. in a human gate's message).",
190831
- additionalProperties: { oneOf: [{
190832
- type: "string",
190833
- description: "The target state name."
190834
- }, {
190835
- type: "object",
190836
- additionalProperties: false,
190837
- required: ["to"],
190838
- properties: {
190839
- to: {
190840
- type: "string",
190841
- description: "The target state name."
190842
- },
190843
- describe: {
190844
- type: "string",
190845
- description: "Human-readable sentence describing where this change routes; surfaced verbatim (never Eta-rendered) to templates as it.edges[].describe."
190846
- }
190847
- }
190848
- }] }
190849
- },
190850
- initial: {
190851
- type: "boolean",
190852
- description: "Exactly one state in the workflow must declare initial: true (and it must not be a commit state)."
190853
- },
190854
- retry: {
190855
- type: "object",
190856
- description: "Redirect transitions INTO this state once it has been entered `max` times in the current process.",
190857
- additionalProperties: false,
190858
- required: ["max", "otherwise"],
190859
- properties: {
190860
- max: {
190861
- type: "integer",
190862
- minimum: 0,
190863
- description: "Entries allowed this process before redirecting."
190864
- },
190865
- otherwise: {
190866
- type: "string",
190867
- description: "Defined state to redirect to once over the cap."
190868
- }
190869
- }
190870
- },
190871
- model: {
190872
- type: "string",
190873
- description: "Opaque harness hint passed through `gtd next --json`/`gtd status --json` (e.g. \"smart\"). Never interpreted by gtd. Forbidden on a commit state."
190874
- },
190875
- memory: {
190876
- type: "string",
190877
- description: "Opaque memory-scope label passed through `gtd next --json`/`gtd status --json` (e.g. \"plan\"). A memory-aware driver retains an agent's memory across consecutive agent turns sharing this label and starts fresh when it changes. Never interpreted by gtd. Forbidden on a commit state."
190878
- },
190879
- file: {
190880
- type: "string",
190881
- description: "The state's steering file: an Eta template naming the file a human/editor should look at while the machine rests here. Forbidden on a commit state."
190882
- },
190883
- mode: {
190884
- type: "string",
190885
- description: "The steering file's format: the name of a built-in mode (qa/review, validated in-process by gtd) or of a `modes:` entry. gtd formats and validates the file with that mode before capturing a turn out of this state, and the LSP dispatches live diagnostics on the built-in names. Requires a sibling `file:`. Forbidden on a commit state."
190886
- },
190887
- reviewWindow: {
190888
- type: "boolean",
190889
- description: "When true, gtd opens a review checkout window while the machine rests here — HEAD/index are rewound to the review base so the whole base..HEAD diff surfaces as uncommitted changes in the editor. Forbidden on a commit state."
190890
- },
190891
- reviewBase: {
190892
- type: "boolean",
190893
- description: "Marks the state whose most-recent in-process commit anchors the review window's diff base; absent any, the base is the process start. Forbidden on a commit state."
190894
- },
190895
- reviewEntry: {
190896
- type: "boolean",
190897
- description: "Marks the (at most one) state `gtd review <commitish>` enters to start a brand new process reviewing <commitish>..HEAD. Forbidden on a commit state and on the initial state."
190898
- },
190899
- fixEntry: {
190900
- type: "boolean",
190901
- description: "Marks the (at most one) state `gtd fix` enters to start a brand new process that goes straight into repairing the current failing tests. Forbidden on a commit state and on the initial state."
190902
- },
190903
- requireProgress: {
190904
- type: "boolean",
190905
- description: "When true, a step at this state is refused if its only pending change is deleting the state's own `file:` — a work-free turn that discards its input without addressing it. A `NOTHING ACTIONABLE` sentinel file is exempt (a legitimately non-actionable round makes no code change). Requires a `file:`. Forbidden on a commit state."
190906
- },
190907
- answerGate: {
190908
- type: "boolean",
190909
- description: "When true, a step at this state is refused unless every open question in its qa-mode `file:` is answered — exactly one checkbox ticked per question. Requires a `file:` and `mode: qa`. Forbidden on a commit state."
190910
- }
190911
- }
190912
- };
190913
191185
  /** The whole `workflow:` value — see `PatternConfig.ts`'s module docstring for the authoritative schema. */
190914
191186
  const workflowJsonSchema = {
190915
191187
  type: "object",
190916
- description: "The whole machine definition: named states (plus the workflow's own vars: defaults and modes: steering-file modes). Compiled and validated by gtd at load time; content strings starting with ./ or ../ are file references inlined from the config file's directory (a modes: command never is — it is a shell command).",
191188
+ description: "The whole machine definition: a tree of named machines rooted at entry.default (plus the workflow's own vars: defaults and modes: steering-file modes). Compiled and validated by gtd at load time; content strings starting with ./ or ../ are file references inlined from the config file's directory (a modes: command never is — it is a shell command).",
190917
191189
  additionalProperties: false,
190918
- required: ["states"],
191190
+ required: ["entry", "machines"],
190919
191191
  properties: {
190920
191192
  vars: varsJsonSchema,
190921
191193
  modes: modesJsonSchema,
190922
- submachines: {
191194
+ entry: {
190923
191195
  type: "object",
190924
- description: "Optional reusable sub-machines, invoked by `use:` and expanded into concrete states at load time (see src/Submachines.ts).",
191196
+ description: "The workflow's root machine, resolved through the same resolver an `on`/`retry.otherwise` target uses (see src/Machines.ts) — accepts either a bare state path or an instance/reference-key path. Extra manual entry points are declared per-state instead, via a state's own `entry: true` (see `stateJsonSchema`'s `entry` property) — not here.",
191197
+ additionalProperties: false,
191198
+ required: ["default"],
191199
+ properties: { default: {
191200
+ type: "string",
191201
+ description: "Which declared `machines:` entry is the ROOT instance."
191202
+ } }
191203
+ },
191204
+ machines: {
191205
+ type: "object",
191206
+ description: "Named, reusable machines — at least the one entry.default names.",
191207
+ minProperties: 1,
190925
191208
  additionalProperties: {
190926
191209
  type: "object",
190927
- description: "A reusable, parameterized cluster of states, expanded at load time by each `use:` invocation into concrete states (see src/Submachines.ts). Purely a source-authoring convenience — the engine only ever sees the expanded states.",
191210
+ description: "A named, reusable machine: an entry local plus a set of states, each either an ordinary state or a reference instantiating another machine as a child (see src/Machines.ts).",
190928
191211
  additionalProperties: false,
190929
- required: ["states"],
191212
+ required: ["entry", "states"],
190930
191213
  properties: {
190931
191214
  params: {
190932
191215
  type: "array",
190933
191216
  items: { type: "string" },
190934
- description: "Parameter names. A `$name` token used as a whole field value or `on`/`retry.otherwise` target is replaced by the invocation's `with:` binding."
191217
+ description: "Advisory only — documents which $params a caller may bind via a reference's `with:`. A `$name` token used as a whole field value or `on`/`retry.otherwise` target is resolved against the binding."
190935
191218
  },
190936
- states: {
190937
- type: "object",
190938
- description: "The sub-machine's local states. `on`/`retry.otherwise` targets naming another local are rewritten via `as:`; a `$name` value is bound from `with:`.",
190939
- minProperties: 1,
190940
- additionalProperties: stateJsonSchema
190941
- }
190942
- }
190943
- }
190944
- },
190945
- use: {
190946
- type: "array",
190947
- description: "Sub-machine invocations. Each clones a `submachines:` entry into concrete states: `as:` renames its locals (identity when omitted), `with:` binds its `$param`s, `set:` adds extra per-instance state fields.",
190948
- items: {
190949
- type: "object",
190950
- additionalProperties: false,
190951
- required: ["submachine"],
190952
- properties: {
190953
- submachine: {
191219
+ entry: {
190954
191220
  type: "string",
190955
- description: "Name of a declared sub-machine."
191221
+ description: "This machine's own default local (a local state name, or a reference key), resolved recursively."
190956
191222
  },
190957
- name: {
191223
+ model: {
190958
191224
  type: "string",
190959
- description: "Optional label for this instance (inert to expansion; used by tooling like `gtd visualize` to name the group). Defaults to the sub-machine name."
190960
- },
190961
- as: {
190962
- type: "object",
190963
- description: "Rename map: local state name -> concrete state name (identity when omitted).",
190964
- additionalProperties: { type: "string" }
190965
- },
190966
- with: {
190967
- type: "object",
190968
- description: "Bindings for the sub-machine's `$param`s."
191225
+ description: "Opaque harness hint stamped onto every one of this machine's own `prompt` states (e.g. \"smart\"), passed through `gtd next --json`/`gtd status --json`. The ONLY place a model may be declared — a state carrying its own `model:` is a config error. Never interpreted by gtd. A machine declaring this with no `prompt` state is a config error."
190969
191226
  },
190970
- set: {
191227
+ states: {
190971
191228
  type: "object",
190972
- description: "Extra fields merged onto a renamed local state, keyed by local name.",
190973
- additionalProperties: { type: "object" }
191229
+ description: "This machine's local states, each either an ordinary state or a `{ machine, with }` reference.",
191230
+ minProperties: 1,
191231
+ additionalProperties: { oneOf: [{
191232
+ type: "object",
191233
+ description: "One workflow state. Declare exactly one content kind (script/prompt/message/commit). A commit state is final: no actor, no on.",
191234
+ additionalProperties: false,
191235
+ properties: {
191236
+ actor: {
191237
+ type: "string",
191238
+ description: "Who acts at this state. Required on every non-commit state; forbidden on a commit state."
191239
+ },
191240
+ script: {
191241
+ type: "string",
191242
+ description: "Content kind: a shell script (Eta template). The loop driver executes it verbatim via bash, then steps this state's actor."
191243
+ },
191244
+ prompt: {
191245
+ type: "string",
191246
+ description: "Content kind: an agent prompt (Eta template), emitted by `gtd next`."
191247
+ },
191248
+ message: {
191249
+ type: "string",
191250
+ description: "Content kind: a human-facing message (Eta template), emitted by `gtd next`."
191251
+ },
191252
+ commit: {
191253
+ type: "string",
191254
+ description: "Content kind: entering this state ends the process by squashing it into one commit with this message (Eta template). Final — no actor, no on."
191255
+ },
191256
+ on: {
191257
+ type: "object",
191258
+ description: "Ordered map of change pattern -> target state. Patterns: \"C\" (clean tree) or \"<A|M|D|*> <glob>\" over the pending diff; first declared match wins. Every target must name a defined state, and every non-initial state must be reachable through these edges (or a retry.otherwise). A value is either the target state name (string) or a { to, describe, action } object whose describe/action are human-readable strings templates can surface as it.edges (e.g. in a human gate's message).",
191259
+ additionalProperties: { oneOf: [{
191260
+ type: "string",
191261
+ description: "The target state name."
191262
+ }, {
191263
+ type: "object",
191264
+ additionalProperties: false,
191265
+ required: ["to"],
191266
+ properties: {
191267
+ to: {
191268
+ type: "string",
191269
+ description: "The target state name."
191270
+ },
191271
+ describe: {
191272
+ type: "string",
191273
+ description: "Human-readable sentence describing where this change routes; surfaced verbatim (never Eta-rendered) to templates as it.edges[].describe."
191274
+ },
191275
+ action: {
191276
+ type: "string",
191277
+ description: "Imperative label for this edge (e.g. \"Accept plan\"); surfaced verbatim (never Eta-rendered) to templates/tooling as it.edges[].action."
191278
+ }
191279
+ }
191280
+ }] }
191281
+ },
191282
+ retry: {
191283
+ type: "object",
191284
+ description: "Redirect transitions INTO this state once it has been entered `max` times in the current process.",
191285
+ additionalProperties: false,
191286
+ required: ["max", "otherwise"],
191287
+ properties: {
191288
+ max: {
191289
+ type: "integer",
191290
+ minimum: 0,
191291
+ description: "Entries allowed this process before redirecting."
191292
+ },
191293
+ otherwise: {
191294
+ type: "string",
191295
+ description: "Defined state to redirect to once over the cap."
191296
+ }
191297
+ }
191298
+ },
191299
+ label: {
191300
+ type: "string",
191301
+ description: "Opaque display name passed through `gtd next --json`/`gtd status --json` so a driver/viewer can show something nicer than the raw state name (e.g. \"Running checks\"). Never interpreted by gtd. Forbidden on a commit state."
191302
+ },
191303
+ file: {
191304
+ type: "string",
191305
+ description: "The state's steering file: an Eta template naming the file a human/editor should look at while the machine rests here. Forbidden on a commit state."
191306
+ },
191307
+ mode: {
191308
+ type: "string",
191309
+ description: "The steering file's format: the name of a built-in mode (qa/review, validated in-process by gtd) or of a `modes:` entry. gtd formats and validates the file with that mode before capturing a turn out of this state, and the LSP dispatches live diagnostics on the built-in names. Requires a sibling `file:`. Forbidden on a commit state."
191310
+ },
191311
+ reviewWindow: {
191312
+ type: "boolean",
191313
+ description: "When true, gtd opens a review checkout window while the machine rests here — HEAD/index are rewound to the review base so the whole base..HEAD diff surfaces as uncommitted changes in the editor. Forbidden on a commit state."
191314
+ },
191315
+ reviewBase: {
191316
+ oneOf: [{ const: true }, { type: "string" }],
191317
+ description: "true marks the state whose most-recent in-process commit anchors the review window's diff base; absent any, the base is the process start. A string is a different shape: an Eta template rendering a commitish that becomes the WHOLE PROCESS's fixed diff base when this state is entered manually via `gtd --entry <state> --base <commitish>` (see the `entry` property below). Forbidden on a commit state."
191318
+ },
191319
+ requireProgress: {
191320
+ type: "boolean",
191321
+ description: "When true, a step at this state is refused if its only pending change is deleting the state's own `file:` — a work-free turn that discards its input without addressing it. A `NOTHING ACTIONABLE` sentinel file is exempt (a legitimately non-actionable round makes no code change). Requires a `file:`. Forbidden on a commit state."
191322
+ },
191323
+ answerGate: {
191324
+ type: "boolean",
191325
+ description: "When true, a step at this state is refused unless every open question in its qa-mode `file:` is answered — exactly one checkbox ticked per question. Requires a `file:` and `mode: qa`. Forbidden on a commit state."
191326
+ },
191327
+ entry: {
191328
+ const: true,
191329
+ description: "Marks this state as an extra manual entry point (WorkflowEntries.manual), enterable via `gtd --entry <state>`. Not to be confused with the top-level `entry:` key naming the root machine (entry.default) — same name, different level, by design."
191330
+ }
191331
+ }
191332
+ }, {
191333
+ type: "object",
191334
+ description: "A reference: instantiates the named machine as a child, at this local's path (see src/Machines.ts). Expanded at load time into concrete, qualified states — the engine only ever sees the flattened result.",
191335
+ additionalProperties: false,
191336
+ required: ["machine"],
191337
+ properties: {
191338
+ machine: {
191339
+ type: "string",
191340
+ description: "Name of a declared entry in `machines:`."
191341
+ },
191342
+ with: {
191343
+ type: "object",
191344
+ description: "Bindings for the referenced machine's `params:`. A bound value naming another of the CALLER's own bindings (a whole-value `$name`) passes it down verbatim, scope intact."
191345
+ }
191346
+ }
191347
+ }] }
190974
191348
  }
190975
191349
  }
190976
191350
  }
190977
- },
190978
- states: {
190979
- type: "object",
190980
- description: "The workflow's named states. At least one; exactly one with initial: true.",
190981
- minProperties: 1,
190982
- additionalProperties: stateJsonSchema
190983
191351
  }
190984
191352
  }
190985
191353
  };
@@ -191186,15 +191554,17 @@ const toOperations = (decoded, root) => {
191186
191554
  } : defaultWorkflowDefinition,
191187
191555
  workflowVars: defaultWorkflowVars,
191188
191556
  rcVars,
191189
- rawWorkflow: defaultWorkflowRaw
191557
+ machineTree: defaultMachineTree,
191558
+ stateScopes: defaultStateScopes
191190
191559
  };
191191
191560
  }
191192
- const { definition, vars: workflowVars } = compileWorkflowConfig(decoded.workflow, root, rcModes, false);
191561
+ const { definition, vars: workflowVars, tree, scopes } = compileWorkflowConfig(decoded.workflow, root, rcModes, false);
191193
191562
  return {
191194
191563
  workflow: definition,
191195
191564
  workflowVars,
191196
191565
  rcVars,
191197
- rawWorkflow: decoded.workflow
191566
+ machineTree: tree,
191567
+ stateScopes: scopes
191198
191568
  };
191199
191569
  };
191200
191570
  const formatSchemaError = (e) => {
@@ -191264,15 +191634,40 @@ const parseNameStatus = (out) => {
191264
191634
  return result;
191265
191635
  };
191266
191636
  /**
191637
+ * True when `error` is git's `index.lock` contention failure — another process
191638
+ * held the index lock when git tried to take it. gtd shares one worktree index
191639
+ * with every git-aware tool the reviewer runs (editor SCM, `gtd lsp`,
191640
+ * git-aware shell prompts): each refreshes its stat cache by WRITING the index
191641
+ * whenever a `git reset --mixed` in the review window wakes it, so gtd's own
191642
+ * index writes lose the `index.lock` race often on a large repo (where each
191643
+ * write takes long enough for the windows to overlap). The lock failure is a
191644
+ * pure "couldn't start" — git did nothing — so the losing command is safe to
191645
+ * retry verbatim (see `withIndexLockRetry`).
191646
+ */
191647
+ const isIndexLockError = (error) => /index\.lock[\s\S]*File exists|Another git process seems to be running/i.test(error.message);
191648
+ /**
191649
+ * Retry an index-writing git command through transient `index.lock` contention
191650
+ * with jittered exponential backoff (~10ms → ~640ms, capped at 6 retries), and
191651
+ * ONLY on that error — any other failure propagates on the first attempt. This
191652
+ * is the general defense for the concurrent reality `isIndexLockError`
191653
+ * describes; it covers every index writer (`git add -A`, `reset`, `restore`),
191654
+ * not just the review window's own steps.
191655
+ */
191656
+ const withIndexLockRetry = (eff) => retry(eff, {
191657
+ schedule: intersect(recurs(6), exponential(millis(10), 2)).pipe(jittered),
191658
+ while: (error) => isIndexLockError(error)
191659
+ });
191660
+ /**
191267
191661
  * Run a command and return its stdout — FAILING on a non-zero exit code with
191268
191662
  * the command line and stderr in the error message. `Command.string` alone
191269
191663
  * only collects stdout and silently ignores exit codes, which used to make
191270
191664
  * gtd report success on rejected commits (hooks, gpg), resolve Idle outside a
191271
191665
  * repository, and lose files whose quoted paths broke a swallowed `git add`.
191272
191666
  * Callers that expect a probe to fail (missing refs, empty repos) handle it
191273
- * with an explicit `catchAll`.
191667
+ * with an explicit `catchAll`. `index.lock` contention is retried transparently
191668
+ * (`withIndexLockRetry`) before any caller's `catchAll` sees it.
191274
191669
  */
191275
- const run = (root, ...args) => scoped$3(gen(function* () {
191670
+ const run = (root, ...args) => withIndexLockRetry(scoped$3(gen(function* () {
191276
191671
  const process = yield* (yield* CommandExecutor).start(make$1(...args).pipe(workingDirectory(root), stdout("pipe"), stderr("pipe")));
191277
191672
  const collect = (stream) => stream.pipe(decodeText(), mkString);
191278
191673
  const [stdout$2, stderr$2, exitCode] = yield* all([
@@ -191282,7 +191677,7 @@ const run = (root, ...args) => scoped$3(gen(function* () {
191282
191677
  ], { concurrency: "unbounded" });
191283
191678
  if (exitCode !== 0) return yield* fail$2(/* @__PURE__ */ new Error(`${args.join(" ")} failed (exit ${exitCode}): ${stderr$2.trim()}`));
191284
191679
  return stdout$2;
191285
- })).pipe(mapError$1((e) => e instanceof Error ? e : new Error(String(e))));
191680
+ })).pipe(mapError$1((e) => e instanceof Error ? e : new Error(String(e)))));
191286
191681
  const makeGitImpl = (executor, root) => {
191287
191682
  const exec = (...args) => run(root, ...args).pipe(provide$1(succeed$2(CommandExecutor, executor)));
191288
191683
  return {
@@ -191348,8 +191743,7 @@ const makeGitImpl = (executor, root) => {
191348
191743
  deleteRef: (ref) => exec("git", "update-ref", "-d", ref).pipe(asVoid),
191349
191744
  mixedResetTo: (ref) => exec("git", "reset", "--mixed", ref).pipe(asVoid),
191350
191745
  hardResetTo: (ref) => exec("git", "reset", "--hard", ref).pipe(asVoid),
191351
- restoreStagedFrom: (source, paths) => paths.length === 0 ? _void : exec("git", "restore", "--staged", `--source=${source}`, "--", ...paths).pipe(catchAll(() => _void)),
191352
- addIntentToAdd: () => exec("git", "add", "--intent-to-add", ".").pipe(asVoid)
191746
+ restoreStagedFrom: (source, paths) => paths.length === 0 ? _void : exec("git", "restore", "--staged", `--source=${source}`, "--", ...paths).pipe(catchAll(() => _void))
191353
191747
  };
191354
191748
  };
191355
191749
  const makeLiveEffect = gen(function* () {
@@ -191935,7 +192329,7 @@ const HISTORY_TRAILER_RE = /^Gtd-History:[ \t]*(\S+)[ \t]*$/m;
191935
192329
  /**
191936
192330
  * Append a `Gtd-History: <hash>` trailer (after a blank line) to a commit
191937
192331
  * `subject` — records the pre-squash tip commit hash so a later `gtd restore`
191938
- * command can read it back. Mirrors `Edge.ts`'s `withReviewBaseTrailer`
192332
+ * command can read it back. Mirrors `Edge.ts`'s `withEntryTrailers`
191939
192333
  * placement: the subject (first line) is untouched.
191940
192334
  */
191941
192335
  const withHistoryTrailer = (subject, hash) => `${subject}\n\n${HISTORY_TRAILER_PREFIX}${hash}`;
@@ -192025,20 +192419,40 @@ const parseCostTrailers = (messages) => {
192025
192419
  const REVIEW_BASE_TRAILER_PREFIX = "Gtd-Review-Base: ";
192026
192420
  const REVIEW_BASE_TRAILER_RE = /^Gtd-Review-Base:[ \t]*(\S+)[ \t]*$/m;
192027
192421
  /**
192028
- * Append a `Gtd-Review-Base: <base>` trailer (after a blank line) to a commit
192029
- * `subject` — the entry commit `gtd review <commitish>` writes to start a new
192030
- * review process. Mirrors `withCostTrailer`'s placement: the subject (first
192031
- * line) is untouched, so `parseStateSubject`/`resolveState` read it back
192032
- * exactly like any other turn commit.
192033
- */
192034
- const withReviewBaseTrailer = (subject, base) => `${subject}\n\n${REVIEW_BASE_TRAILER_PREFIX}${base}`;
192035
- /**
192036
- * The `Gtd-Review-Base: <hash>` trailer recorded on a `gtd review <commitish>`
192037
- * entry commit (see `withReviewBaseTrailer`), or `undefined` when `message`
192038
- * carries none. Read back by `computeProcessRun` — ONLY off the process's
192039
- * first (oldest) commit — to override the run's diff base.
192422
+ * The `Gtd-Review-Base: <hash>` trailer recorded on a `gtd step <actor>
192423
+ * --entry <state>` entry commit (see `withEntryTrailers`), or `undefined`
192424
+ * when `message` carries none. Read back by `computeProcessRun` — ONLY off
192425
+ * the process's first (oldest) commit — to override the run's diff base.
192040
192426
  */
192041
192427
  const parseReviewBaseTrailer = (message) => REVIEW_BASE_TRAILER_RE.exec(message)?.[1];
192428
+ const ENTRY_VAR_TRAILER_PREFIX = "Gtd-Var: ";
192429
+ const ENTRY_VAR_TRAILER_RE = /^Gtd-Var:[ \t]*([^=\s]+)=(.*)$/gm;
192430
+ /**
192431
+ * Compose, after a blank line, a `Gtd-Review-Base: <base>` line (when
192432
+ * `opts.base !== undefined`, readable back by `parseReviewBaseTrailer`),
192433
+ * followed by one `Gtd-Var: <name>=<value>` line per `opts.vars` entry (in
192434
+ * `Object.entries` order) — the single place that formats either trailer, for
192435
+ * every caller including `gtd review <commitish>`'s entry commit. Mirrors
192436
+ * `withCostTrailer`'s "nothing to add → unchanged" shape: with no base and an
192437
+ * empty `opts.vars`, `subject` is returned untouched.
192438
+ */
192439
+ const withEntryTrailers = (subject, opts) => {
192440
+ const lines = [];
192441
+ if (opts.base !== void 0) lines.push(`${REVIEW_BASE_TRAILER_PREFIX}${opts.base}`);
192442
+ for (const [name, value] of Object.entries(opts.vars)) lines.push(`${ENTRY_VAR_TRAILER_PREFIX}${name}=${value}`);
192443
+ return lines.length === 0 ? subject : `${subject}\n\n${lines.join("\n")}`;
192444
+ };
192445
+ /**
192446
+ * Every `Gtd-Var: <name>=<value>` trailer found in `message` (each value
192447
+ * split on the FIRST `=` only, so a value containing `=` round-trips), or
192448
+ * `{}` when `message` carries none. Read back by `computeProcessRun` — ONLY
192449
+ * off the process's first (oldest) commit — into `ProcessRun.entryVars`.
192450
+ */
192451
+ const parseEntryVarTrailers = (message) => {
192452
+ const vars = {};
192453
+ for (const match of message.matchAll(ENTRY_VAR_TRAILER_RE)) vars[match[1]] = match[2];
192454
+ return vars;
192455
+ };
192042
192456
  /** The total token cost across the given entries (`0` when none). */
192043
192457
  const totalCostOf = (entries) => entries.reduce((sum, entry) => sum + entry.cost, 0);
192044
192458
  /** Per-model token totals, highest-cost first (ties broken by model name for a stable order). */
@@ -192059,11 +192473,31 @@ const normalizeStatus = (raw) => raw === "A" ? "A" : raw === "D" ? "D" : "M";
192059
192473
  * visualize`'s best-effort current-state read, which prefers the review
192060
192474
  * checkout window's saved head over a HEAD that may be mid-window-rewind (see
192061
192475
  * `src/ReviewWindow.ts`'s `REVIEW_HEAD_REF`).
192476
+ *
192477
+ * A HEAD subject that DOES parse as a `gtd(actor): state` commit, but whose
192478
+ * named state the CURRENT workflow definition doesn't declare AT ALL, is
192479
+ * refused loudly here rather than silently falling through `resolveState`'s
192480
+ * own initial-state fallback: an in-flight process left resting at a state a
192481
+ * workflow upgrade then renamed/removed out from under it would otherwise
192482
+ * look like a perfectly fresh, idle repo — the same courtesy the earlier
192483
+ * `entry.review`/`entry.fix` removal got, pointing at the command that
192484
+ * actually resolves it rather than a generic failure. Distinct from
192485
+ * `memoryScopeAt`'s graceful "unmapped state reads as fresh entry" behavior
192486
+ * (`src/PatternMachine.ts`, package 04), which handles a state that IS
192487
+ * declared but merely missing from `scopes` (a compiler bug, not a rename) —
192488
+ * this is about a state that no longer exists in the definition at all. Every
192489
+ * OTHER case `resolveState` already folds into "rest at the initial state"
192490
+ * (an unparseable subject, an actor mismatch, a commit-state target) is left
192491
+ * exactly as it was — those are legitimately "no active process" readings,
192492
+ * not a renamed-out-from-under-us one.
192062
192493
  */
192063
192494
  const resolveRest = (atRef) => gen(function* () {
192064
192495
  const git = yield* GitService;
192065
192496
  const def = (yield* (yield* ConfigService).load).workflow;
192066
- const state = resolveState(def, (yield* git.hasCommits()) ? yield* git.lastCommitSubject(atRef) : "");
192497
+ const headSubject = (yield* git.hasCommits()) ? yield* git.lastCommitSubject(atRef) : "";
192498
+ const parsedHead = parseStateSubject(headSubject);
192499
+ if (parsedHead !== void 0 && def.states[parsedHead.state] === void 0) return yield* fail$2(/* @__PURE__ */ new Error(`gtd: HEAD rests at "${parsedHead.state}", which the active workflow no longer declares — this looks like a process left in-flight from before a workflow change. Run \`gtd abandon\` to discard it and start over (or check out the workflow version it started under).`));
192500
+ const state = resolveState(def, headSubject);
192067
192501
  const stateDef = def.states[state];
192068
192502
  if (stateDef.actor === void 0) return yield* fail$2(/* @__PURE__ */ new Error(`gtd: resolved at commit state "${state}" — a process never rests there`));
192069
192503
  return {
@@ -192079,6 +192513,23 @@ const pendingChanges = (git) => git.changedPaths().pipe(map$4((entries) => entri
192079
192513
  path: e.path
192080
192514
  }))));
192081
192515
  /**
192516
+ * The `Gtd-Review-Base:`/`Gtd-Var:` overrides carried by the process's OLDEST
192517
+ * commit (its entry commit, when `gtd review <commitish>` or a future generic
192518
+ * entry started this process) — a later turn's message is never mistaken for
192519
+ * it. `{reviewBase: undefined, vars: {}}` when the process has no commits yet.
192520
+ */
192521
+ const parseEntryCommitOverrides = (processCommits) => {
192522
+ if (processCommits.length === 0) return {
192523
+ reviewBase: void 0,
192524
+ vars: {}
192525
+ };
192526
+ const message = processCommits[0].message;
192527
+ return {
192528
+ reviewBase: parseReviewBaseTrailer(message),
192529
+ vars: parseEntryVarTrailers(message)
192530
+ };
192531
+ };
192532
+ /**
192082
192533
  * Walk first-parent history backward from HEAD while each commit's subject
192083
192534
  * parses as `gtd(actor): state` (a v3 workflow commit) AND that state isn't
192084
192535
  * the workflow's initial state; the walk stops — EXCLUDING that boundary
@@ -192103,7 +192554,8 @@ const computeProcessRun = (git, def) => gen(function* () {
192103
192554
  startParentHash: EMPTY_TREE$2,
192104
192555
  diffBase: EMPTY_TREE$2,
192105
192556
  trace: [],
192106
- costEntries: []
192557
+ costEntries: [],
192558
+ entryVars: {}
192107
192559
  };
192108
192560
  const initialState = initialStateOf(def);
192109
192561
  const history = yield* git.commitHistory();
@@ -192115,37 +192567,81 @@ const computeProcessRun = (git, def) => gen(function* () {
192115
192567
  }
192116
192568
  const startIdx = i + 1;
192117
192569
  const processCommits = history.slice(startIdx);
192118
- const trace = processCommits.map((h) => parseStateSubject(subjectOf$1(h.message)).state);
192570
+ const trace = processCommits.map((h) => ({
192571
+ state: parseStateSubject(subjectOf$1(h.message)).state,
192572
+ hash: h.hash
192573
+ }));
192119
192574
  const costEntries = parseCostTrailers(processCommits.map((h) => h.message));
192120
192575
  const startParentHash = i >= 0 ? history[i].hash : EMPTY_TREE$2;
192576
+ const startHash = startIdx < history.length ? history[startIdx].hash : history[history.length - 1].hash;
192577
+ const { reviewBase: reviewBaseOverride, vars: entryVars } = parseEntryCommitOverrides(processCommits);
192121
192578
  return {
192122
- startHash: startIdx < history.length ? history[startIdx].hash : history[history.length - 1].hash,
192579
+ startHash,
192123
192580
  startParentHash,
192124
- diffBase: (processCommits.length > 0 ? parseReviewBaseTrailer(processCommits[0].message) : void 0) ?? startParentHash,
192581
+ diffBase: reviewBaseOverride ?? startParentHash,
192125
192582
  trace,
192126
- costEntries
192583
+ costEntries,
192584
+ entryVars
192127
192585
  };
192128
192586
  });
192587
+ const ROOT_MEMORY_SCOPE_NAME = "root";
192588
+ /**
192589
+ * Compute the commit-anchored memory key for the currently-rested state, or
192590
+ * `undefined` when none applies — the pure `memoryScopeAt` (package 04)
192591
+ * result turned into an actual key string a memory-aware driver can group
192592
+ * consecutive agent turns by (see `TraceEntry`/`ProcessRun.trace`).
192593
+ *
192594
+ * Only a `prompt`-content rest carries a memory key — mirrors the rule that
192595
+ * only a prompt state carries `model` (kept here, not inside `memoryScopeAt`,
192596
+ * so that stays a clean, reusable primitive with no content-kind opinion).
192597
+ * `undefined` also propagates when `memoryScopeAt` itself can't resolve a
192598
+ * scope (`rest.state` absent from `scopes` — see its own doc comment).
192599
+ *
192600
+ * The key is `${scope || ROOT_MEMORY_SCOPE_NAME}#${token.slice(0, 7)}`, where
192601
+ * `token` anchors to the commit the CURRENT unbroken scope entry started
192602
+ * FROM, not the entry's own commit: `run.startParentHash` at `entryIndex <=
192603
+ * 0` (an empty trace, or the entry sitting at trace position 0 — no earlier
192604
+ * commit exists yet), else `run.trace[entryIndex - 1].hash`. Anchoring to the
192605
+ * entry's own commit would give a workflow whose initial state is a prompt
192606
+ * state two different tokens across its first two turns (nothing committed
192607
+ * yet before the very first turn) — anchoring to the commit the entry
192608
+ * started FROM makes the empty-trace and position-0 cases coincide by
192609
+ * construction.
192610
+ */
192611
+ const memoryKeyFor = (scopes, rest, run) => {
192612
+ if (contentKindOf(rest.stateDef) !== "prompt") return void 0;
192613
+ const resolved = memoryScopeAt(scopes, rest.state, run.trace.map((entry) => entry.state));
192614
+ if (resolved === void 0) return void 0;
192615
+ const { scope, entryIndex } = resolved;
192616
+ const token = entryIndex <= 0 ? run.startParentHash : run.trace[entryIndex - 1].hash;
192617
+ return `${scope || ROOT_MEMORY_SCOPE_NAME}#${token.slice(0, 7)}`;
192618
+ };
192129
192619
  const PREFIX = "GTD_";
192130
192620
  /**
192131
- * Assemble the merged `it.vars` map every template sees, from three layers
192621
+ * Assemble the merged `it.vars` map every template sees, from four layers
192132
192622
  * (later wins): the active workflow's own declared `vars:` defaults
192133
192623
  * (`ConfigOperations.workflowVars`), the top-level `.gtdrc` `vars:` key
192134
- * (`ConfigOperations.rcVars`), and — for each name declared by either of
192135
- * those two layers — a `GTD_<UPPERCASE-name>` environment variable, if
192136
- * defined. Unlike the first two layers, the environment can only OVERRIDE a
192137
- * name some config layer already declared; it can never introduce a new one
192138
- * (an uppercased env key can't round-trip back to an arbitrary camelCase
192139
- * name), so a `GTD_*` var matching no declared name is silently ignored. A
192140
- * `value === undefined` entry (a name declared-but-unset in the environment)
192141
- * is skipped, never coerced to the string `"undefined"`. Pure: `env` is
192142
- * whatever the caller's `EnvVars` service handed it, never `process.env`
192143
- * read directly here.
192144
- */
192145
- const resolveVars = (workflowVars, rcVars, env) => {
192624
+ * (`ConfigOperations.rcVars`), the current process's entry commit's
192625
+ * `Gtd-Var:` trailers (`ProcessRun.entryVars` — fixed overrides recorded at
192626
+ * the moment a process like `gtd review <commitish>` started it), and — for
192627
+ * each name declared by any of those three layers — a `GTD_<UPPERCASE-name>`
192628
+ * environment variable, if defined. Unlike the first three layers, the
192629
+ * environment can only OVERRIDE a name some earlier layer already declared;
192630
+ * it can never introduce a new one (an uppercased env key can't round-trip
192631
+ * back to an arbitrary camelCase name), so a `GTD_*` var matching no declared
192632
+ * name is silently ignored. A `value === undefined` entry (a name
192633
+ * declared-but-unset in the environment) is skipped, never coerced to the
192634
+ * string `"undefined"`. `entryVars`, by contrast, needs no such filtering —
192635
+ * it's a plain unconditional spread, so a name from an old commit that
192636
+ * matches neither the workflow nor the rc layer still lands in the merged
192637
+ * map (pure and total; never throws). Pure: `env` is whatever the caller's
192638
+ * `EnvVars` service handed it, never `process.env` read directly here.
192639
+ */
192640
+ const resolveVars = (workflowVars, rcVars, entryVars, env) => {
192146
192641
  const merged = {
192147
192642
  ...workflowVars,
192148
- ...rcVars
192643
+ ...rcVars,
192644
+ ...entryVars
192149
192645
  };
192150
192646
  for (const name of Object.keys(merged)) {
192151
192647
  const value = env[PREFIX + name.toUpperCase()];
@@ -192271,18 +192767,6 @@ const renderModel = (stateDef, context) => try_({
192271
192767
  catch: (e) => e instanceof Error ? e : new Error(String(e))
192272
192768
  });
192273
192769
  /**
192274
- * Render a state's declared `memory:` scope label (if any) through the SAME
192275
- * template context as its content/`model` — see `renderModel`'s doc comment;
192276
- * a plain label (e.g. `"plan"`) passes through unchanged, while
192277
- * `memory: "<%= it.vars.planScope %>"` resolves against the merged `it.vars`.
192278
- * The render-failure semantics are identical (propagates as a thrown/rejected
192279
- * error, same call site as `gtd next`/`gtd status`).
192280
- */
192281
- const renderMemory = (stateDef, context) => try_({
192282
- try: () => stateDef.memory !== void 0 ? renderStateTemplate(stateDef.memory, context) : void 0,
192283
- catch: (e) => e instanceof Error ? e : new Error(String(e))
192284
- });
192285
- /**
192286
192770
  * Render a state's declared `label:` (if any) through the SAME template
192287
192771
  * context as its content/`model` — see `renderModel`'s doc comment; a plain
192288
192772
  * label (e.g. `"planning"`) passes through unchanged, while
@@ -192305,8 +192789,16 @@ const renderFile = (stateDef, context) => try_({
192305
192789
  catch: (e) => e instanceof Error ? e : new Error(String(e))
192306
192790
  });
192307
192791
  const omitUndefined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
192308
- /** Render the resolved rest's declared content (script/prompt/message — never `commit`, since `resolveRest` never rests at a commit state) plus its `model:`/`memory:`/`label:`/`file:` hints, if declared (see `renderModel`/`renderMemory`/`renderLabel`/`renderFile`). `mode:` is a closed literal, never Eta-rendered — passed through verbatim. */
192309
- const renderRest = (rest, context) => gen(function* () {
192792
+ /**
192793
+ * Render the resolved rest's declared content (script/prompt/message — never
192794
+ * `commit`, since `resolveRest` never rests at a commit state) plus its
192795
+ * `model:`/`label:`/`file:` hints, if declared (see
192796
+ * `renderModel`/`renderLabel`/`renderFile`), and the COMPUTED memory key the
192797
+ * caller passes in (`memory`, from `memoryKeyFor` — package 05/06; this
192798
+ * module no longer derives it from `rest.stateDef.memory` itself). `mode:` is
192799
+ * a closed literal, never Eta-rendered — passed through verbatim.
192800
+ */
192801
+ const renderRest = (rest, context, memory) => gen(function* () {
192310
192802
  const kind = contentKindOf(rest.stateDef);
192311
192803
  if (kind === void 0) return yield* fail$2(/* @__PURE__ */ new Error(`state "${rest.state}" declares no content — invalid definition`));
192312
192804
  const template = rest.stateDef.script ?? rest.stateDef.prompt ?? rest.stateDef.message ?? rest.stateDef.commit;
@@ -192316,7 +192808,7 @@ const renderRest = (rest, context) => gen(function* () {
192316
192808
  });
192317
192809
  const hints = {
192318
192810
  model: yield* renderModel(rest.stateDef, context),
192319
- memory: yield* renderMemory(rest.stateDef, context),
192811
+ memory,
192320
192812
  label: yield* renderLabel(rest.stateDef, context),
192321
192813
  file: yield* renderFile(rest.stateDef, context),
192322
192814
  mode: rest.stateDef.mode
@@ -192525,8 +193017,8 @@ const closeReviewWindow = gen(function* () {
192525
193017
  * is nothing to surface and the window stays closed.
192526
193018
  *
192527
193019
  * Ordering is crash-safe: base ref → head ref → mixed reset → `.gtd/` index
192528
- * pin → intent-to-add. A crash before the head-ref write leaves only a stale
192529
- * base ref (overwritten on the next open); a crash after it leaves HEAD ==
193020
+ * pin. A crash before the head-ref write leaves only a stale base ref
193021
+ * (overwritten on the next open); a crash after it leaves HEAD ==
192530
193022
  * review-head, which the next invocation's close restores as a no-op.
192531
193023
  */
192532
193024
  const openReviewWindow = gen(function* () {
@@ -192542,7 +193034,6 @@ const openReviewWindow = gen(function* () {
192542
193034
  yield* git.updateRef(REVIEW_HEAD_REF, headHash);
192543
193035
  yield* git.mixedResetTo(base);
192544
193036
  yield* git.restoreStagedFrom(REVIEW_HEAD_REF, [".gtd"]);
192545
- yield* git.addIntentToAdd();
192546
193037
  return { opened: true };
192547
193038
  });
192548
193039
  //#endregion
@@ -204247,7 +204738,7 @@ const worktreeLayerForRoot = (root) => WorktreeReader.Live.pipe(provide(Cwd.laye
204247
204738
  const loadModeMap = async (root, onWarn) => {
204248
204739
  try {
204249
204740
  const config = await runPromise$1(ConfigService.pipe(flatMap$4((c) => c.load), provide$1(configLayerForRoot(root))));
204250
- const vars = resolveVars(config.workflowVars, config.rcVars, process.env);
204741
+ const vars = resolveVars(config.workflowVars, config.rcVars, {}, process.env);
204251
204742
  const { map, warnings } = buildFileModeMap(config.workflow, vars, root);
204252
204743
  for (const warning of warnings) onWarn(warning);
204253
204744
  return map;
@@ -204285,7 +204776,7 @@ const resolveSteeringFile = (root) => gen(function* () {
204285
204776
  const envVars = yield* EnvVars;
204286
204777
  const rest = yield* resolveRest();
204287
204778
  const run = yield* computeProcessRun(git, rest.def);
204288
- const vars = resolveVars(config.workflowVars, config.rcVars, envVars.all);
204779
+ const vars = resolveVars(config.workflowVars, config.rcVars, {}, envVars.all);
204289
204780
  const context = yield* buildTemplateContext(git, worktree.read, rest.state, rest.actor, run, vars, rest.stateDef.on);
204290
204781
  const file = yield* renderFile(rest.stateDef, context);
204291
204782
  return {
@@ -204380,14 +204871,12 @@ const startLspServer = () => gen(function* () {
204380
204871
  });
204381
204872
  //#endregion
204382
204873
  //#region src/visualize.html
204383
- 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 .act {\n font-size: 12.5px;\n font-weight: 700;\n color: var(--accent);\n }\n .edge .arr {\n color: var(--text-faint);\n }\n .goto {\n font: inherit;\n font-family: var(--mono);\n font-size: 12.5px;\n font-weight: 700;\n cursor: pointer;\n border: 1px solid var(--card-accent, var(--accent));\n background: transparent;\n color: var(--card-accent, var(--accent));\n padding: 2px 9px;\n border-radius: 6px;\n }\n .goto:hover {\n background: var(--card-accent, var(--accent));\n color: #fff;\n }\n .goto:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .goto[data-actor=\"human\"] {\n --card-accent: var(--human-dot);\n }\n .goto[data-actor=\"agent\"] {\n --card-accent: var(--agent-dot);\n }\n .goto[data-actor=\"check\"] {\n --card-accent: var(--check-dot);\n }\n .goto[data-actor=\"commit\"] {\n --card-accent: var(--engine-dot);\n }\n .goto[data-actor=\"unknown\"] {\n --card-accent: var(--unknown-dot);\n }\n .edge .em {\n font-size: 12.5px;\n color: var(--text-dim);\n margin-top: 6px;\n line-height: 1.45;\n }\n .incoming {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n }\n\n .flag-list {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .flag-item {\n display: flex;\n gap: 9px;\n font-size: 13px;\n align-items: baseline;\n }\n .flag-item .fn {\n font-family: var(--mono);\n font-size: 11.5px;\n font-weight: 700;\n color: var(--accent);\n flex: none;\n padding: 1px 6px;\n border-radius: 5px;\n background: var(--accent-soft);\n }\n .flag-item .fd {\n color: var(--text-dim);\n }\n\n /* vars */\n .vars-grid {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 16px;\n }\n .var-chip {\n font-family: var(--mono);\n font-size: 12px;\n padding: 6px 10px;\n border-radius: 8px;\n border: 1px solid var(--border);\n background: var(--surface);\n box-shadow: var(--shadow-sm);\n }\n .var-chip b {\n color: var(--accent);\n }\n\n footer {\n border-top: 1px solid var(--border);\n margin-top: 56px;\n padding: 40px 0 72px;\n color: var(--text-faint);\n font-size: 13px;\n }\n footer code {\n font-family: var(--mono);\n background: var(--surface-2);\n padding: 1px 6px;\n border-radius: 5px;\n }\n footer .foot-lead {\n margin: 0 0 14px;\n padding-top: 16px;\n color: var(--text-dim);\n }\n footer .foot-legend {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n padding-bottom: 16px;\n }\n footer .foot-chip {\n font-family: var(--mono);\n font-size: 12px;\n padding: 3px 9px;\n border-radius: 7px;\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--text-dim);\n }\n\n .hidden {\n display: none !important;\n }\n @media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition: none !important;\n animation: none !important;\n }\n }\n @media (max-width: 640px) {\n .brand .sub {\n display: none;\n }\n .kv {\n grid-template-columns: 74px 1fr;\n }\n }\n </style>\n </head>\n <body>\n <header class=\"top\">\n <div class=\"top-inner\">\n <div class=\"brand\">\n <span class=\"cmd\"><b>gtd</b> · workflow visualizer</span>\n <span class=\"sub\" id=\"brandSub\">read-only view</span>\n </div>\n <div class=\"summary\" id=\"summary\"></div>\n <button\n class=\"theme-btn\"\n id=\"themeBtn\"\n title=\"Toggle light/dark theme\"\n aria-label=\"Toggle light/dark theme\"\n >\n ◐\n </button>\n </div>\n </header>\n\n <main class=\"wrap\" id=\"app\">\n <section class=\"hero\">\n <h1 id=\"heroTitle\">Loading workflow…</h1>\n <p class=\"lede\" id=\"heroLede\">\n Fetching <code>/workflow.json</code> from the local\n <span class=\"mono\">gtd visualize</span> server.\n </p>\n </section>\n\n <div id=\"content\" class=\"hidden\">\n <div class=\"sec-head\">\n <h2>Current state</h2>\n <span class=\"rule\"></span>\n </div>\n <div class=\"current-panel hidden\" id=\"currentPanel\"></div>\n <div id=\"varsSection\"></div>\n\n <div class=\"sec-head\">\n <h2>Main flow</h2>\n <span class=\"rule\"></span>\n </div>\n <p class=\"diagram-note\" id=\"diaNote\"></p>\n <div class=\"dia-card\">\n <div class=\"dia-scroll\" id=\"diaScroll\"><div class=\"dia-msg\">Rendering diagram…</div></div>\n </div>\n\n <div class=\"sec-head\">\n <h2>Sub-machines</h2>\n <span class=\"rule\"></span>\n </div>\n <div id=\"smSection\"></div>\n\n <div class=\"sec-head\">\n <h2>State catalogue</h2>\n <span class=\"rule\"></span>\n </div>\n <div class=\"toolbar\">\n <div class=\"search\">\n <svg\n width=\"15\"\n height=\"15\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2.4\"\n >\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <input\n id=\"search\"\n type=\"search\"\n placeholder=\"filter by name, actor, kind, file, mode, flag…\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n aria-label=\"Filter states\"\n />\n </div>\n <div class=\"chips\" id=\"kindChips\"></div>\n <div class=\"legend\" id=\"legend\"></div>\n </div>\n <div id=\"groups\"></div>\n </div>\n </main>\n\n <div class=\"backdrop\" id=\"backdrop\"></div>\n <aside\n class=\"drawer\"\n id=\"drawer\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"State detail\"\n tabindex=\"-1\"\n ></aside>\n\n <footer class=\"wrap\" id=\"footer\"></footer>\n\n <script type=\"module\">\n // ── theme ──────────────────────────────────────────────────────────────\n const root = document.documentElement\n const prefersDark = () => matchMedia(\"(prefers-color-scheme: dark)\").matches\n const isDark = () => {\n const t = root.getAttribute(\"data-theme\")\n return t ? t === \"dark\" : prefersDark()\n }\n document.getElementById(\"themeBtn\").onclick = () => {\n const next = isDark() ? \"light\" : \"dark\"\n root.setAttribute(\"data-theme\", next)\n reinitializeAndRenderAll()\n }\n // Follow the OS when the user hasn't made an explicit choice — an\n // explicit `data-theme` override always wins over an OS flip.\n matchMedia(\"(prefers-color-scheme: dark)\").addEventListener(\"change\", () => {\n if (!root.getAttribute(\"data-theme\")) reinitializeAndRenderAll()\n })\n\n const esc = (s) =>\n String(s).replace(\n /[&<>\"']/g,\n (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c],\n )\n\n // Actor metadata: color class, css-var prefix, mermaid classDef name, label.\n const ACTOR = {\n human: { cls: \"actor-human\", dot: \"human\", label: \"human\" },\n agent: { cls: \"actor-agent\", dot: \"agent\", label: \"agent\" },\n check: { cls: \"actor-check\", dot: \"check\", label: \"check\" },\n commit: { cls: \"actor-engine\", dot: \"engine\", label: \"commit\" },\n unknown: { cls: \"actor-unknown\", dot: \"unknown\", label: \"?\" },\n }\n // A commit state has no actor — treat it as the engine/commit colour.\n const actorOf = (s) => s.actor || (s.kind === \"commit\" ? \"commit\" : \"unknown\")\n const meta = (a) => ACTOR[a] || ACTOR.unknown\n\n const FLAGS = {\n initial:\n \"The one initial state — an unrecognized HEAD (any non-gtd history) resolves here.\",\n reviewEntry:\n \"This state is a dedicated CLI entry point (e.g. `gtd review <ref>`) that starts a brand-new process.\",\n fixEntry:\n \"This state is the `gtd fix` entry point — a red suite drops into the fixing loop, a green suite is a no-op.\",\n reviewWindow:\n \"While resting here, gtd opens the review checkout window: HEAD is rewound to the review base so the whole diff surfaces as ordinary uncommitted changes.\",\n reviewBase:\n \"Anchors the incremental review diff base — its most-recent in-process commit is where the next reviewing turn and checkout window start.\",\n answerGate:\n \"Edge gate: a step is refused while any open question in the steering file is not answered with exactly one tick.\",\n requireProgress:\n \"Edge gate: refuses a work-free turn that only deletes the instructions file (unless it held a NOTHING ACTIONABLE sentinel).\",\n }\n\n // ── state ──────────────────────────────────────────────────────────────\n let MODEL = null\n let STATES = []\n let byName = {}\n let CURRENT = null\n let lastCurrentSig = undefined\n let mermaidMod = null\n\n // Fetches `/state.json`, updates `CURRENT`, and reports whether the\n // resolved body actually changed since the last call (compared on the\n // full JSON so an edge's `matched` flag flipping counts as a change too).\n async function refreshCurrent() {\n let next = null\n try {\n const res = await fetch(\"/state.json\")\n const body = res.ok ? await res.json() : {}\n next = body && body.state ? body : null\n } catch (e) {\n next = null\n }\n const sig = JSON.stringify(next)\n if (sig === lastCurrentSig) return false\n lastCurrentSig = sig\n CURRENT = next\n return true\n }\n\n const actorFilter = new Set([\"human\", \"agent\", \"check\", \"commit\", \"unknown\"])\n let kindFilter = \"all\"\n\n // ── boot ─────────────────────────────────────────────────────────────\n try {\n const res = await fetch(\"/workflow.json\")\n if (!res.ok) throw new Error(\"HTTP \" + res.status)\n MODEL = await res.json()\n } catch (e) {\n document.getElementById(\"heroTitle\").textContent = \"Could not load workflow\"\n document.getElementById(\"heroLede\").innerHTML =\n \"Fetching <code>/workflow.json</code> failed: \" +\n esc(e.message) +\n '. This page must be served by <span class=\"mono\">gtd visualize</span>.'\n throw e\n }\n\n // Resolved at page load, then re-polled every 3s (see `poll()` below) so\n // advancing the process shows up without a manual refresh. Tolerates a\n // 404/error (an older server) or an empty `{}` (no active process/not a repo).\n await refreshCurrent()\n\n STATES = MODEL.states || []\n byName = Object.fromEntries(STATES.map((s) => [s.name, s]))\n\n // ── header summary + hero ───────────────────────────────────────────\n const counts = { human: 0, agent: 0, check: 0, commit: 0, unknown: 0 }\n STATES.forEach((s) => {\n counts[actorOf(s)]++\n })\n const nGroups = (MODEL.groups || []).length\n const cnt = (a) =>\n counts[a] ? `<span><span class=\"s-num\">${counts[a]}</span> ${meta(a).label}</span>` : \"\"\n document.getElementById(\"summary\").innerHTML = [\n `<span><span class=\"s-num\">${STATES.length}</span> states</span>`,\n `<span><span class=\"s-num\">${nGroups}</span> sub-machine${nGroups === 1 ? \"\" : \"s\"}</span>`,\n `<span class=\"sep\">·</span>`,\n cnt(\"human\"),\n cnt(\"agent\"),\n cnt(\"check\"),\n cnt(\"commit\"),\n cnt(\"unknown\"),\n ]\n .filter(Boolean)\n .join(\" \")\n\n document.getElementById(\"heroTitle\").innerHTML = `GTD workflow visualization`\n document.getElementById(\"heroLede\").innerHTML =\n `Rendered live from your workflow. The initial state is ` +\n `<code>${esc(MODEL.initial)}</code>. Click any state — in the diagram, a sub-machine chip, ` +\n `or the catalogue — to open its full definition, walk its outgoing edges, and see what routes into it.`\n document.getElementById(\"content\").classList.remove(\"hidden\")\n\n document.getElementById(\"footer\").innerHTML =\n `<p class=\"foot-lead\">Read-only view served by <code>gtd visualize</code>.</p>` +\n `<div class=\"foot-legend\">` +\n `<span class=\"foot-chip\">([message])</span>` +\n `<span class=\"foot-chip\">[prompt]</span>` +\n `<span class=\"foot-chip\">{{script}}</span>` +\n `<span class=\"foot-chip\">[[commit]]</span>` +\n `<span>— node shape = content kind; colour = actor.</span>` +\n `</div>`\n\n // ── pill helper ────────────────────────────────────────────────────\n const pill = (a) =>\n `<span class=\"pill ${meta(a).cls}\"><span class=\"dot\"></span>${meta(a).label}</span>`\n\n // ── mermaid source generation ──────────────────────────────────────\n // Safe, stable node ids (state names can contain hyphens etc.), with a\n // reverse lookup — needed to map a rendered SVG node's id back to its\n // state name when wiring clicks (see `nodeIdOf`/`wire*Clicks` below).\n const idFor = (() => {\n const map = new Map()\n const rev = new Map()\n const fn = (name) => {\n if (!map.has(name)) {\n const id = \"n\" + map.size\n map.set(name, id)\n rev.set(id, name)\n }\n return map.get(name)\n }\n fn.nameOf = (id) => rev.get(id)\n return fn\n })()\n const groupBoxId = (i) => \"g\" + i\n const groupIndexOf = (name) => (MODEL.groups || []).findIndex((g) => g.name === name)\n const shapeFor = (kind, label) => {\n const l = `\"${label.replace(/\"/g, \"&quot;\")}\"`\n switch (kind) {\n case \"message\":\n return `([\"${label.replace(/\"/g, \"&quot;\")}\"])`\n case \"prompt\":\n return `[${l}]`\n case \"script\":\n return `{{${l}}}`\n case \"commit\":\n return `[[${l}]]`\n default:\n return `[${l}]`\n }\n }\n const mlabel = (s) => `\"${String(s).replace(/\"/g, \"&quot;\").replace(/\\|/g, \"&#124;\")}\"`\n /**\n * The mermaid `classDef` lines for actor/ghost node fills, chosen per\n * theme so dark mode doesn't render washed-out light pastels.\n */\n function classDefs(dark) {\n return [\n dark\n ? \"classDef human fill:#3a2e12,stroke:#e0a83e,color:#f0c674;\"\n : \"classDef human fill:#fbeccd,stroke:#d99413,color:#5a3d0a;\",\n dark\n ? \"classDef agent fill:#26224a,stroke:#8b7cf0,color:#b6abf7;\"\n : \"classDef agent fill:#e5e2fb,stroke:#6d5ce0,color:#3a2c85;\",\n dark\n ? \"classDef check fill:#123433,stroke:#2dc4bc,color:#68dcd4;\"\n : \"classDef check fill:#d3f0ef,stroke:#16a39d,color:#0c524f;\",\n dark\n ? \"classDef commit fill:#3a1b24,stroke:#e05f7c,color:#f2a3b3;\"\n : \"classDef commit fill:#fbdfe4,stroke:#d84a68,color:#8a1f38;\",\n dark\n ? \"classDef unknown fill:#1c2434,stroke:#3a465c,color:#9aa6bd,stroke-dasharray:4 3;\"\n : \"classDef unknown fill:#eef1f7,stroke:#c2ccdb,color:#55607a,stroke-dasharray:4 3;\",\n // Sub-machine invocation: a muted slate box (not one of the actor\n // colours) so it still reads as its own distinct \"black-box\" kind,\n // without the harsh opaque-black fill clashing with a light theme.\n dark\n ? \"classDef submachine fill:#2a3142,stroke:#5a6b8c,color:#dbe4f5,font-weight:700;\"\n : \"classDef submachine fill:#e4e9f2,stroke:#8695b3,color:#2a3142,font-weight:700;\",\n // A muted, dashed ghost node standing in for a target OUTSIDE the\n // current diagram (a sub-machine diagram's external edge).\n dark\n ? \"classDef external fill:#1c2434,stroke:#3a465c,color:#9aa6bd,stroke-dasharray:5 4;\"\n : \"classDef external fill:#eef1f7,stroke:#c2ccdb,color:#55607a,stroke-dasharray:5 4;\",\n ]\n }\n\n /**\n * Build one flowchart's mermaid source over `states` (a diagram scope):\n * the whole machine for the main flow (`opts.scope` unset), or one\n * sub-machine's own member states (`opts.scope` = that group's name).\n * A member state whose innermost group differs from `opts.scope`\n * collapses into a black box for ITS group (handles a sub-machine\n * nested inside another); a target outside `states` entirely renders as\n * a muted, dashed ghost node instead (only possible in a sub-machine\n * diagram — the main flow's `states` is every state, so nothing is ever\n * \"outside\" it).\n */\n function buildFlowSrc(states, opts) {\n const scope = opts && opts.scope\n const inSet = new Set(states.map((s) => s.name))\n const lines = [\"flowchart TD\"]\n\n const unitOf = (name) => {\n const s = byName[name]\n if (s && s.group && s.group !== scope) return { kind: \"box\", group: s.group }\n return { kind: \"node\", name }\n }\n const unitId = (name) => {\n const u = unitOf(name)\n return u.kind === \"box\" ? groupBoxId(groupIndexOf(u.group)) : idFor(name)\n }\n\n const emittedNodes = new Set()\n const emittedBoxes = new Set()\n const emittedGhosts = new Set()\n const nodeLine = (s) => ` ${idFor(s.name)}${shapeFor(s.kind, s.name)}:::${actorOf(s)}`\n const boxLine = (g, i) =>\n ` ${groupBoxId(i)}[\"${esc(g.name)}<br/><span class='sm-hint'>▸ ${g.states.length} state${\n g.states.length === 1 ? \"\" : \"s\"\n }</span>\"]:::submachine`\n const ghostLine = (s) => ` e${idFor(s.name)}${shapeFor(s.kind, s.name)}:::external`\n\n states.forEach((s) => {\n const u = unitOf(s.name)\n if (u.kind === \"node\") {\n if (emittedNodes.has(s.name)) return\n emittedNodes.add(s.name)\n lines.push(nodeLine(s))\n } else if (!emittedBoxes.has(u.group)) {\n emittedBoxes.add(u.group)\n const i = groupIndexOf(u.group)\n if (i >= 0) lines.push(boxLine(MODEL.groups[i], i))\n }\n })\n\n // Edges within this diagram's own state set, collapsed through box\n // units, deduped, and label-dropped when several patterns land on the\n // same box-level edge (a black box hides which one specifically).\n const solid = new Map()\n const dashed = new Map()\n const addEdge = (map, from, to, label) => {\n if (from === to) return\n const key = from + \" \" + to\n if (!map.has(key)) map.set(key, [])\n map.get(key).push(label)\n }\n states.forEach((s) => {\n ;(s.on || []).forEach((e) => {\n if (!inSet.has(e.to) || !byName[e.to]) return\n addEdge(solid, unitId(s.name), unitId(e.to), e.action || 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.action || 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, action) => {\n const t = byName[to]\n return (\n `<div class=\"edge${matched ? \" cur-matched\" : \"\"}\"><div class=\"er\">` +\n `${action ? `<span class=\"act\">${esc(action)}</span>` : \"\"}` +\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 || [])\n .map((e) => edgeRow(e.pattern, e.to, e.matched, undefined, e.action))\n .join(\"\")\n const retryRow = CURRENT.retry\n ? edgeRow(null, CURRENT.retry.otherwise, false, CURRENT.retry.max, undefined)\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\">${\n e.action ? `<span class=\"act\">${esc(e.action)}</span>` : \"\"\n }<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";
204874
+ 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 .act {\n font-size: 12.5px;\n font-weight: 700;\n color: var(--accent);\n }\n .edge .arr {\n color: var(--text-faint);\n }\n .goto {\n font: inherit;\n font-family: var(--mono);\n font-size: 12.5px;\n font-weight: 700;\n cursor: pointer;\n border: 1px solid var(--card-accent, var(--accent));\n background: transparent;\n color: var(--card-accent, var(--accent));\n padding: 2px 9px;\n border-radius: 6px;\n }\n .goto:hover {\n background: var(--card-accent, var(--accent));\n color: #fff;\n }\n .goto:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 2px;\n }\n .goto[data-actor=\"human\"] {\n --card-accent: var(--human-dot);\n }\n .goto[data-actor=\"agent\"] {\n --card-accent: var(--agent-dot);\n }\n .goto[data-actor=\"check\"] {\n --card-accent: var(--check-dot);\n }\n .goto[data-actor=\"commit\"] {\n --card-accent: var(--engine-dot);\n }\n .goto[data-actor=\"unknown\"] {\n --card-accent: var(--unknown-dot);\n }\n .edge .em {\n font-size: 12.5px;\n color: var(--text-dim);\n margin-top: 6px;\n line-height: 1.45;\n }\n .incoming {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n }\n\n .flag-list {\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n .flag-item {\n display: flex;\n gap: 9px;\n font-size: 13px;\n align-items: baseline;\n }\n .flag-item .fn {\n font-family: var(--mono);\n font-size: 11.5px;\n font-weight: 700;\n color: var(--accent);\n flex: none;\n padding: 1px 6px;\n border-radius: 5px;\n background: var(--accent-soft);\n }\n .flag-item .fd {\n color: var(--text-dim);\n }\n\n /* vars */\n .vars-grid {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 16px;\n }\n .var-chip {\n font-family: var(--mono);\n font-size: 12px;\n padding: 6px 10px;\n border-radius: 8px;\n border: 1px solid var(--border);\n background: var(--surface);\n box-shadow: var(--shadow-sm);\n }\n .var-chip b {\n color: var(--accent);\n }\n\n footer {\n border-top: 1px solid var(--border);\n margin-top: 56px;\n padding: 40px 0 72px;\n color: var(--text-faint);\n font-size: 13px;\n }\n footer code {\n font-family: var(--mono);\n background: var(--surface-2);\n padding: 1px 6px;\n border-radius: 5px;\n }\n footer .foot-lead {\n margin: 0 0 14px;\n padding-top: 16px;\n color: var(--text-dim);\n }\n footer .foot-legend {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n padding-bottom: 16px;\n }\n footer .foot-chip {\n font-family: var(--mono);\n font-size: 12px;\n padding: 3px 9px;\n border-radius: 7px;\n border: 1px solid var(--border);\n background: var(--surface);\n color: var(--text-dim);\n }\n\n .hidden {\n display: none !important;\n }\n @media (prefers-reduced-motion: reduce) {\n *,\n *::before,\n *::after {\n transition: none !important;\n animation: none !important;\n }\n }\n @media (max-width: 640px) {\n .brand .sub {\n display: none;\n }\n .kv {\n grid-template-columns: 74px 1fr;\n }\n }\n </style>\n </head>\n <body>\n <header class=\"top\">\n <div class=\"top-inner\">\n <div class=\"brand\">\n <span class=\"cmd\"><b>gtd</b> · workflow visualizer</span>\n <span class=\"sub\" id=\"brandSub\">read-only view</span>\n </div>\n <div class=\"summary\" id=\"summary\"></div>\n <button\n class=\"theme-btn\"\n id=\"themeBtn\"\n title=\"Toggle light/dark theme\"\n aria-label=\"Toggle light/dark theme\"\n >\n ◐\n </button>\n </div>\n </header>\n\n <main class=\"wrap\" id=\"app\">\n <section class=\"hero\">\n <h1 id=\"heroTitle\">Loading workflow…</h1>\n <p class=\"lede\" id=\"heroLede\">\n Fetching <code>/workflow.json</code> from the local\n <span class=\"mono\">gtd visualize</span> server.\n </p>\n </section>\n\n <div id=\"content\" class=\"hidden\">\n <div class=\"sec-head\">\n <h2>Current state</h2>\n <span class=\"rule\"></span>\n </div>\n <div class=\"current-panel hidden\" id=\"currentPanel\"></div>\n <div id=\"varsSection\"></div>\n\n <div class=\"sec-head\">\n <h2>Main flow</h2>\n <span class=\"rule\"></span>\n </div>\n <p class=\"diagram-note\" id=\"diaNote\"></p>\n <div class=\"dia-card\">\n <div class=\"dia-scroll\" id=\"diaScroll\"><div class=\"dia-msg\">Rendering diagram…</div></div>\n </div>\n\n <div class=\"sec-head\">\n <h2>Sub-machines</h2>\n <span class=\"rule\"></span>\n </div>\n <div id=\"smSection\"></div>\n\n <div class=\"sec-head\">\n <h2>State catalogue</h2>\n <span class=\"rule\"></span>\n </div>\n <div class=\"toolbar\">\n <div class=\"search\">\n <svg\n width=\"15\"\n height=\"15\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2.4\"\n >\n <circle cx=\"11\" cy=\"11\" r=\"7\" />\n <path d=\"m21 21-4.3-4.3\" />\n </svg>\n <input\n id=\"search\"\n type=\"search\"\n placeholder=\"filter by name, actor, kind, file, mode, flag…\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n aria-label=\"Filter states\"\n />\n </div>\n <div class=\"chips\" id=\"kindChips\"></div>\n <div class=\"legend\" id=\"legend\"></div>\n </div>\n <div id=\"groups\"></div>\n </div>\n </main>\n\n <div class=\"backdrop\" id=\"backdrop\"></div>\n <aside\n class=\"drawer\"\n id=\"drawer\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"State detail\"\n tabindex=\"-1\"\n ></aside>\n\n <footer class=\"wrap\" id=\"footer\"></footer>\n\n <script type=\"module\">\n // ── theme ──────────────────────────────────────────────────────────────\n const root = document.documentElement\n const prefersDark = () => matchMedia(\"(prefers-color-scheme: dark)\").matches\n const isDark = () => {\n const t = root.getAttribute(\"data-theme\")\n return t ? t === \"dark\" : prefersDark()\n }\n document.getElementById(\"themeBtn\").onclick = () => {\n const next = isDark() ? \"light\" : \"dark\"\n root.setAttribute(\"data-theme\", next)\n reinitializeAndRenderAll()\n }\n // Follow the OS when the user hasn't made an explicit choice — an\n // explicit `data-theme` override always wins over an OS flip.\n matchMedia(\"(prefers-color-scheme: dark)\").addEventListener(\"change\", () => {\n if (!root.getAttribute(\"data-theme\")) reinitializeAndRenderAll()\n })\n\n const esc = (s) =>\n String(s).replace(\n /[&<>\"']/g,\n (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c],\n )\n\n // Actor metadata: color class, css-var prefix, mermaid classDef name, label.\n const ACTOR = {\n human: { cls: \"actor-human\", dot: \"human\", label: \"human\" },\n agent: { cls: \"actor-agent\", dot: \"agent\", label: \"agent\" },\n check: { cls: \"actor-check\", dot: \"check\", label: \"check\" },\n commit: { cls: \"actor-engine\", dot: \"engine\", label: \"commit\" },\n unknown: { cls: \"actor-unknown\", dot: \"unknown\", label: \"?\" },\n }\n // A commit state has no actor — treat it as the engine/commit colour.\n const actorOf = (s) => s.actor || (s.kind === \"commit\" ? \"commit\" : \"unknown\")\n const meta = (a) => ACTOR[a] || ACTOR.unknown\n\n const FLAGS = {\n initial:\n \"The one initial state — an unrecognized HEAD (any non-gtd history) resolves here.\",\n entry:\n \"This state is a manual entry point (`entry: true`) — a brand-new process may start here via `gtd step <actor> --entry <state>` (or the subcommand-less `gtd --entry <state>`), in addition to the workflow's ordinary `initial` state.\",\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 // `g.states` is that instance's DIRECT states only (`MODEL.groups` is a\n // flat, depth-first projection of the machine tree) — a diagram scoped to\n // `g` (the main flow collapsing `g`, or `g`'s own sub-machine panel) needs\n // its whole subtree: direct states unioned with every descendant's.\n const allStatesOf = (g) => {\n const prefix = g.name + \".\"\n const states = [...(g.states || [])]\n ;(MODEL.groups || []).forEach((other) => {\n if (other.name.startsWith(prefix)) states.push(...(other.states || []))\n })\n return states\n }\n const shapeFor = (kind, label) => {\n const l = `\"${label.replace(/\"/g, \"&quot;\")}\"`\n switch (kind) {\n case \"message\":\n return `([\"${label.replace(/\"/g, \"&quot;\")}\"])`\n case \"prompt\":\n return `[${l}]`\n case \"script\":\n return `{{${l}}}`\n case \"commit\":\n return `[[${l}]]`\n default:\n return `[${l}]`\n }\n }\n const mlabel = (s) => `\"${String(s).replace(/\"/g, \"&quot;\").replace(/\\|/g, \"&#124;\")}\"`\n /**\n * 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 // Collapse a state to the outermost instance strictly below `scope`\n // (the child of `scope` that is an ancestor of the state), not the\n // state's own innermost group — walk from `scope` toward the state one\n // path segment at a time and stop at the first instance boundary. A\n // state whose group IS `scope` (a direct member) stays a real node.\n const unitOf = (name) => {\n const s = byName[name]\n const group = s ? s.group : undefined\n if (group === scope) return { kind: \"node\", name }\n const scopePrefix = scope ? scope + \".\" : \"\"\n const rest = name.startsWith(scopePrefix) ? name.slice(scopePrefix.length) : name\n const firstSeg = rest.split(\".\")[0]\n const boundary = scope ? scope + \".\" + firstSeg : firstSeg\n return { kind: \"box\", group: boundary }\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 const n = allStatesOf(g).length\n const modelSuffix = g.model ? ` · model: ${esc(g.model)}` : \"\"\n return ` ${groupBoxId(i)}[\"${esc(g.name)}<br/><span class='sm-hint'>▸ ${n} state${\n n === 1 ? \"\" : \"s\"\n }${modelSuffix}</span>\"]:::submachine`\n }\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.action || 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.action || 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 card.style.marginLeft = (g.depth || 0) * 24 + \"px\"\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.machine)}</b>${\n g.model ? ` · model: ${esc(g.model)}` : \"\"\n }</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 = allStatesOf(g)\n .map((n) => byName[n])\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, action) => {\n const t = byName[to]\n return (\n `<div class=\"edge${matched ? \" cur-matched\" : \"\"}\"><div class=\"er\">` +\n `${action ? `<span class=\"act\">${esc(action)}</span>` : \"\"}` +\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 || [])\n .map((e) => edgeRow(e.pattern, e.to, e.matched, undefined, e.action))\n .join(\"\")\n const retryRow = CURRENT.retry\n ? edgeRow(null, CURRENT.retry.otherwise, false, CURRENT.retry.max, undefined)\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 = [s.name, s.actor, s.kind, s.file, s.mode, s.model, (s.flags || []).join(\" \")]\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.machine,\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([\"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\">${\n e.action ? `<span class=\"act\">${esc(e.action)}</span>` : \"\"\n }<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";
204384
204875
  //#endregion
204385
204876
  //#region src/Visualize.ts
204386
204877
  const FLAG_KEYS = [
204387
204878
  "reviewWindow",
204388
204879
  "reviewBase",
204389
- "reviewEntry",
204390
- "fixEntry",
204391
204880
  "requireProgress",
204392
204881
  "answerGate"
204393
204882
  ];
@@ -204403,19 +204892,18 @@ const stripUndefined = (o) => {
204403
204892
  for (const key of Object.keys(o)) if (o[key] === void 0) delete o[key];
204404
204893
  return o;
204405
204894
  };
204406
- /** Describe one compiled state for the viewer (optional fields omitted when unset). `onEdges` is that state's `on`, ALREADY RENDERED against `it.vars` (see `buildVizModel`) — never `def.on` directly, which would show the unrendered literal. */
204407
- const toVizState = (name, def, onEdges, group, incoming) => stripUndefined({
204895
+ /** Describe one compiled state for the viewer (optional fields omitted when unset). `onEdges` is that state's `on`, ALREADY RENDERED against `it.vars` (see `buildVizModel`) — never `def.on` directly, which would show the unrendered literal. `entries` is the workflow's `entries` — `name`'s match against `.default`/`.manual` drives `initial`/the `entry` flag. */
204896
+ const toVizState = (name, def, onEdges, group, incoming, entries) => stripUndefined({
204408
204897
  name,
204409
204898
  actor: def.actor,
204410
204899
  kind: contentKindOf(def) ?? "unknown",
204411
204900
  content: contentOf(def),
204412
- initial: def.initial === true ? true : void 0,
204901
+ initial: entries.default === name ? true : void 0,
204413
204902
  model: def.model,
204414
- memory: def.memory,
204415
204903
  file: def.file,
204416
204904
  mode: def.mode,
204417
204905
  retry: def.retry,
204418
- flags: flagsOf(def),
204906
+ flags: [...flagsOf(def), ...entries.manual.includes(name) ? ["entry"] : []],
204419
204907
  on: onEdges.map(edgeToViz),
204420
204908
  incoming,
204421
204909
  group
@@ -204443,22 +204931,64 @@ const renderedOnByState = (workflow, vars) => new Map(Object.entries(workflow.st
204443
204931
  describe
204444
204932
  ] : [renderedPattern, target];
204445
204933
  })]));
204446
- /**
204447
- * Build the viewer's JSON description from the active COMPILED workflow plus its
204448
- * RAW value (the pre-expansion `submachines:`/`use:` form — `rawWorkflow` from
204449
- * `ConfigService`), which is the only place the sub-machine grouping survives
204450
- * (`compileWorkflowConfig` flattens it away). `vars` is shown for reference, AND
204451
- * used to render every state's `on` pattern (see `renderedOnByState`) so the
204452
- * diagram shows real paths rather than a repointed var's stale literal.
204453
- */
204454
- const buildVizModel = (workflow, rawWorkflow, vars) => {
204455
- const groups = collectGroups(rawWorkflow);
204456
- const sizeOf = new Map(groups.map((g) => [g.name, g.states.length]));
204457
- const groupOf = /* @__PURE__ */ new Map();
204458
- for (const g of groups) for (const s of g.states) {
204459
- const cur = groupOf.get(s);
204460
- if (cur === void 0 || sizeOf.get(g.name) < sizeOf.get(cur)) groupOf.set(s, g.name);
204461
- }
204934
+ /** A state's owning instance path, straight from `scopes` (`CompiledWorkflowConfig.scopes`/`ConfigOperations.stateScopes`) — a direct lookup, NOT a string chop off the qualified name (a state's group is not always "everything before the last dot"). `""` (the root instance's own path) means root-owned, reported as `undefined` here — same convention `VizState.group` has always had for a root-owned state. */
204935
+ const groupOf = (name, scopes) => {
204936
+ const scope = scopes[name];
204937
+ return scope === "" || scope === void 0 ? void 0 : scope;
204938
+ };
204939
+ /**
204940
+ * Flatten a `MachineNode` tree (`CompiledWorkflowConfig.tree`, from
204941
+ * `src/Machines.ts`'s `flattenMachines`) into `VizModel.groups` — a flat,
204942
+ * depth-first array of every instance STRICTLY BELOW the root (the root
204943
+ * machine itself is the canvas, never a box). Kept flat (rather than a
204944
+ * recursive payload) so every index-based front-end helper
204945
+ * (`groupBoxId`/`groupIndexOf`/`scrollToSubmachine`/etc — `src/visualize.html`)
204946
+ * keeps working unchanged.
204947
+ */
204948
+ const flattenTree = (node, parent, depth, out) => {
204949
+ for (const child of node.children) {
204950
+ out.push({
204951
+ name: child.key,
204952
+ machine: child.machine,
204953
+ states: [...child.states],
204954
+ ...parent !== void 0 ? { parent } : {},
204955
+ depth
204956
+ });
204957
+ flattenTree(child, child.key, depth + 1, out);
204958
+ }
204959
+ };
204960
+ /**
204961
+ * The `model` of one group — a machine instance's own `model:`, read off any
204962
+ * ONE of that group's own prompt-content states (by construction every prompt
204963
+ * state one machine instance owns carries the identical `def.model` — see
204964
+ * `Machines.ts`'s `resolveInstanceModel`/`emitTree`). `undefined` when the
204965
+ * group owns no prompt state (e.g. a queue/gate machine of only
204966
+ * script/message/commit states) or its machine declares no `model:` — never
204967
+ * guessed.
204968
+ */
204969
+ const modelOfGroup = (groupName, workflow, scopes) => {
204970
+ for (const [name, def] of Object.entries(workflow.states)) if (scopes[name] === groupName && contentKindOf(def) === "prompt") return def.model;
204971
+ };
204972
+ /**
204973
+ * Build the viewer's JSON description from the active COMPILED workflow plus
204974
+ * the machine tree its flattening produced (`CompiledWorkflowConfig.tree`)
204975
+ * and the memory-scope map its flattening produced alongside it
204976
+ * (`CompiledWorkflowConfig.scopes`/`ConfigOperations.stateScopes`) — qualified
204977
+ * state name -> the machine-instance path that owns it. `vars` is shown for
204978
+ * reference, AND used to render every state's `on` pattern (see
204979
+ * `renderedOnByState`) so the diagram shows real paths rather than a
204980
+ * repointed var's stale literal.
204981
+ */
204982
+ const buildVizModel = (workflow, tree, vars, scopes) => {
204983
+ const flatGroups = [];
204984
+ flattenTree(tree, void 0, 0, flatGroups);
204985
+ const groups = flatGroups.map((group) => {
204986
+ const model = modelOfGroup(group.name, workflow, scopes);
204987
+ return model !== void 0 ? {
204988
+ ...group,
204989
+ model
204990
+ } : group;
204991
+ });
204462
204992
  const renderedOn = renderedOnByState(workflow, vars);
204463
204993
  const incoming = /* @__PURE__ */ new Map();
204464
204994
  const addIncoming = (target, from, pattern) => {
@@ -204474,13 +205004,9 @@ const buildVizModel = (workflow, rawWorkflow, vars) => {
204474
205004
  if (def.retry) addIncoming(def.retry.otherwise, name, `retry ×${def.retry.max}`);
204475
205005
  }
204476
205006
  return {
204477
- states: Object.entries(workflow.states).map(([name, def]) => toVizState(name, def, renderedOn.get(name) ?? [], groupOf.get(name), incoming.get(name) ?? [])),
205007
+ states: Object.entries(workflow.states).map(([name, def]) => toVizState(name, def, renderedOn.get(name) ?? [], groupOf(name, scopes), incoming.get(name) ?? [], workflow.entries)),
204478
205008
  initial: initialStateOf(workflow),
204479
- groups: groups.map((g) => ({
204480
- name: g.name,
204481
- submachine: g.submachine,
204482
- states: [...g.states]
204483
- })),
205009
+ groups,
204484
205010
  vars
204485
205011
  };
204486
205012
  };
@@ -204731,16 +205257,16 @@ Commands:
204731
205257
  (or squash) the one resulting transition. Pass
204732
205258
  --cost=<n> (optionally --model=<name>) to record the
204733
205259
  just-finished invocation's token cost and model on the
204734
- turn commit (summed into it.processCost/processCostByModel)
204735
- review <commitish>
204736
- Start a NEW review process at the workflow's declared
204737
- review-entry state (reviewEntry: true), reviewing
204738
- <commitish>..HEAD — e.g. a colleague's PR branch. Requires
204739
- a clean tree resting at the workflow's initial state
204740
- fix Start a NEW process at the workflow's declared fix-entry
204741
- state (fixEntry: true) that goes straight into repairing the
204742
- current failing tests. Requires a clean tree resting at the
204743
- workflow's initial state
205260
+ turn commit (summed into it.processCost/processCostByModel).
205261
+ Pass --entry <state> to start a brand NEW process at
205262
+ <state> instead — any declared, non-commit state (e.g.
205263
+ review-gate.check or fix-precheck on the bundled unified
205264
+ template) — with repeatable --var <name>=<value> supplying
205265
+ that new process's fixed it.vars overrides
205266
+ (no command) --entry <state>
205267
+ Short form of 'step human --entry <state>' — starts a new
205268
+ process authenticated as human, e.g.
205269
+ 'gtd --entry review-gate.check'
204744
205270
  abandon End the process currently underway without completing it:
204745
205271
  close any open review checkout window, then rewind HEAD to
204746
205272
  the commit the process started from, keeping everything it
@@ -204772,6 +205298,15 @@ Options:
204772
205298
  --no-open (gtd visualize only) do not open the browser
204773
205299
  --cost=<n> (gtd step only) record the invocation's token cost
204774
205300
  --model=<name> (gtd step only, with --cost) tag that cost's model
205301
+ --entry <state> (gtd step, or with no command at all) start a brand new
205302
+ process at <state> — any declared, non-commit state —
205303
+ instead of stepping the one currently resting. Not
205304
+ combinable with --cost/--model (an entry is not a metered
205305
+ agent turn)
205306
+ --var <name>=<value>
205307
+ (with --entry; repeatable) supply a fixed it.vars
205308
+ override for the new process; the name must already be
205309
+ declared by the workflow's own vars: or the .gtdrc vars:
204775
205310
  --version, -v Print version and exit
204776
205311
  --help, -h Print this help and exit
204777
205312
  `;
@@ -204796,8 +205331,66 @@ const cliErrorLine = (error) => {
204796
205331
  const message = error instanceof Error ? error.message : String(error);
204797
205332
  return /^gtd[: ]/.test(message) ? message : `gtd: ${message}`;
204798
205333
  };
204799
- /** Non-flag positional arguments past the subcommand name (argv[3..]). */
204800
- const commandArgs = (argv) => argv.slice(3).filter((a) => a.length > 0 && !a.startsWith("--"));
205334
+ /**
205335
+ * Every value `flag` carries in `argv`, in BOTH `--flag=value` and `--flag
205336
+ * value` (space-separated) forms, plus every argv INDEX consumed producing
205337
+ * them — the flag token itself, and (for the space-separated form) the next
205338
+ * token too. A trailing bare occurrence (`flag` as the very last argv token,
205339
+ * with no following value) still consumes its own index but contributes an
205340
+ * EMPTY STRING to `values` — indistinguishable from an explicit `flag=`, and
205341
+ * deliberately so: both are "no value given" and a caller that cares (see
205342
+ * `parseEntryFlags`) rejects an empty value with its own message. Repeatable
205343
+ * flags (`--var`) collect one entry per occurrence, in argv order. Shared by
205344
+ * the positional-extraction fix below (`commandArgs`, and the top-level
205345
+ * `positional` lookup in `makeProgram`) and by `parseEntryFlags`'s own
205346
+ * `--entry`/`--var` parsing, so both agree on exactly which indices a flag's
205347
+ * value occupies.
205348
+ */
205349
+ const takeFlagValues = (argv, flag) => {
205350
+ const values = [];
205351
+ const consumed = /* @__PURE__ */ new Set();
205352
+ const eqPrefix = `${flag}=`;
205353
+ for (let i = 0; i < argv.length; i++) {
205354
+ const arg = argv[i];
205355
+ if (arg === flag) {
205356
+ consumed.add(i);
205357
+ if (i + 1 < argv.length) {
205358
+ values.push(argv[i + 1]);
205359
+ consumed.add(i + 1);
205360
+ } else values.push("");
205361
+ } else if (arg.startsWith(eqPrefix)) {
205362
+ consumed.add(i);
205363
+ values.push(arg.slice(eqPrefix.length));
205364
+ }
205365
+ }
205366
+ return {
205367
+ values,
205368
+ consumed
205369
+ };
205370
+ };
205371
+ const ENTRY_FLAG = "--entry";
205372
+ const VAR_FLAG = "--var";
205373
+ /**
205374
+ * The absolute argv indices `takeFlagValues` reports as consumed for
205375
+ * `--entry`/`--var`, merged — the set both `commandArgs` and the top-level
205376
+ * `positional` lookup in `makeProgram` must SKIP so a flag's value (e.g.
205377
+ * `--entry review-gate.check`, which carries no `--` prefix of its own) is
205378
+ * never mistaken for a stray extra positional argument.
205379
+ */
205380
+ const flagConsumedIndices = (argv) => {
205381
+ const consumed = /* @__PURE__ */ new Set();
205382
+ for (const i of takeFlagValues(argv, ENTRY_FLAG).consumed) consumed.add(i);
205383
+ for (const i of takeFlagValues(argv, VAR_FLAG).consumed) consumed.add(i);
205384
+ return consumed;
205385
+ };
205386
+ /** Non-flag positional arguments past the subcommand name (argv[3..]), skipping any index `flagConsumedIndices` reports as an `--entry`/`--var` value (which carries no `--` prefix of its own and would otherwise read as a stray extra positional — see `takeFlagValues`). */
205387
+ const commandArgs = (argv) => {
205388
+ const consumed = flagConsumedIndices(argv);
205389
+ return argv.map((a, i) => ({
205390
+ a,
205391
+ i
205392
+ })).slice(3).filter(({ a, i }) => a.length > 0 && !a.startsWith("--") && !consumed.has(i)).map(({ a }) => a);
205393
+ };
204801
205394
  const COST_FLAG = "--cost=";
204802
205395
  /**
204803
205396
  * Parse the optional `--cost=<n>` flag (only `gtd step` accepts it — see
@@ -204850,6 +205443,55 @@ const parseStepFlags = (argv, positional) => gen(function* () {
204850
205443
  model
204851
205444
  };
204852
205445
  });
205446
+ /**
205447
+ * Parse the `--entry <state>`/`--entry=<state>` flag and the repeatable
205448
+ * `--var <name>=<value>`/`--var=<name>=<value>` flag together (see
205449
+ * `takeFlagValues`) — mirrors `parseStepFlags`'s shape/Effect style. `--entry`
205450
+ * names the state a brand-new process should START at (see
205451
+ * `runEntryCommand`), reached via `gtd step <actor> --entry <state>` or the
205452
+ * subcommand-less short form `gtd --entry <state>` — so, like `--cost`/
205453
+ * `--model`, it is rejected outright on any OTHER command rather than
205454
+ * silently ignored. `--var` supplies that new process's fixed `it.vars`
205455
+ * overrides, and only means anything alongside `--entry`.
205456
+ *
205457
+ * - At most one `--entry` occurrence — a second is a usage error (not
205458
+ * last-wins).
205459
+ * - A bare `--entry` with no following value is a usage error (mirrors
205460
+ * `parseCostFlag`'s bare-`--cost` handling).
205461
+ * - Each `--var` value must be `<name>=<value>` with a non-empty name and a
205462
+ * single-line value (mirrors `parseModelFlag`'s multiline-rejection idiom);
205463
+ * a duplicate `--var` NAME is a usage error (no silent last-wins).
205464
+ * - `--var` present with no `--entry` is a usage error.
205465
+ *
205466
+ * Deliberately does NOT reject `--cost`/`--model` alongside `--entry` — that
205467
+ * combination check lives where `--cost`/`--model` are already validated
205468
+ * (see `makeProgram`).
205469
+ */
205470
+ const parseEntryFlags = (argv, positional) => gen(function* () {
205471
+ const entryValues = takeFlagValues(argv, ENTRY_FLAG).values;
205472
+ if (entryValues.length > 1) return yield* fail$2(/* @__PURE__ */ new Error("gtd: --entry may be given at most once"));
205473
+ const entryRaw = entryValues[0];
205474
+ if (entryRaw === "") return yield* fail$2(/* @__PURE__ */ new Error("gtd: --entry requires a value — use --entry=<state> or --entry <state>"));
205475
+ const entry = entryRaw;
205476
+ if (entry !== void 0 && positional !== "step" && positional !== void 0) return yield* fail$2(/* @__PURE__ */ new Error("gtd: --entry is only valid for `gtd step` or the bare `gtd --entry <state>` form"));
205477
+ const vars = {};
205478
+ const seenNames = /* @__PURE__ */ new Set();
205479
+ for (const raw of takeFlagValues(argv, VAR_FLAG).values) {
205480
+ const eq = raw.indexOf("=");
205481
+ if (eq <= 0) return yield* fail$2(/* @__PURE__ */ new Error(`gtd: --var must be <name>=<value> with a non-empty name — got "${raw}"`));
205482
+ const name = raw.slice(0, eq);
205483
+ const value = raw.slice(eq + 1);
205484
+ if (/[\r\n]/.test(value)) return yield* fail$2(/* @__PURE__ */ new Error(`gtd: --var ${name} must be a single-line value`));
205485
+ if (seenNames.has(name)) return yield* fail$2(/* @__PURE__ */ new Error(`gtd: --var ${name} specified more than once`));
205486
+ seenNames.add(name);
205487
+ vars[name] = value;
205488
+ }
205489
+ if (entry === void 0 && Object.keys(vars).length > 0) return yield* fail$2(/* @__PURE__ */ new Error("gtd: --var requires --entry"));
205490
+ return {
205491
+ entry,
205492
+ vars
205493
+ };
205494
+ });
204853
205495
  /** Rejects extra positional arguments for a subcommand that takes none (`status`, `run`). */
204854
205496
  const rejectExtraArgs = (command, argv) => {
204855
205497
  const args = commandArgs(argv);
@@ -204913,7 +205555,11 @@ const renderOnEdgesOrFail = (onEdges, vars) => try_({
204913
205555
  * path renders a DIFFERENT state, so it builds its own context inline
204914
205556
  * instead of sharing this helper). `renderedOn` is the resting state's `on`
204915
205557
  * edges already rendered against `it.vars` (`renderOnEdges`) — `next`/`status`
204916
- * reuse it instead of re-rendering the same patterns themselves.
205558
+ * reuse it instead of re-rendering the same patterns themselves. `memory` is
205559
+ * the COMPUTED memory key (`memoryKeyFor`, package 05/06) for this rest —
205560
+ * `undefined` for a non-`prompt` rest or when no scope resolves — computed
205561
+ * once here (against the loaded config's `stateScopes`) rather than by each
205562
+ * caller separately.
204917
205563
  */
204918
205564
  const resolveRestContext = (git) => gen(function* () {
204919
205565
  const config = yield* (yield* ConfigService).load;
@@ -204921,14 +205567,15 @@ const resolveRestContext = (git) => gen(function* () {
204921
205567
  const envVars = yield* EnvVars;
204922
205568
  const rest = yield* resolveRest();
204923
205569
  const run = yield* computeProcessRun(git, rest.def);
204924
- const vars = resolveVars(config.workflowVars, config.rcVars, envVars.all);
205570
+ const vars = resolveVars(config.workflowVars, config.rcVars, run.entryVars, envVars.all);
204925
205571
  const renderedOn = yield* renderOnEdgesOrFail(rest.stateDef.on, vars);
204926
205572
  const reviewBase = yield* reviewBaseHash(git, rest.def, run);
204927
205573
  return {
204928
205574
  rest,
204929
205575
  run,
204930
205576
  context: yield* buildTemplateContext(git, worktree.read, rest.state, rest.actor, run, vars, renderedOn, 0, void 0, reviewBase),
204931
- renderedOn
205577
+ renderedOn,
205578
+ memory: memoryKeyFor(config.stateScopes, rest, run)
204932
205579
  };
204933
205580
  });
204934
205581
  /** The user-facing message for a `step` refusal — out-of-turn names the awaited actor, no-match names every declared pattern. */
@@ -204948,11 +205595,11 @@ const stepAsActor = (invoker, cost, model) => gen(function* () {
204948
205595
  const rest = yield* resolveRest();
204949
205596
  const run = yield* computeProcessRun(git, rest.def);
204950
205597
  const changes = yield* pendingChanges(git);
204951
- const vars = resolveVars(config.workflowVars, config.rcVars, envVars.all);
205598
+ const vars = resolveVars(config.workflowVars, config.rcVars, run.entryVars, envVars.all);
204952
205599
  const renderedOn = yield* renderOnEdgesOrFail(rest.stateDef.on, vars);
204953
205600
  const decision = step(withRenderedOn(rest.def, rest.state, renderedOn), rest.state, invoker, {
204954
205601
  changes,
204955
- processTrace: run.trace
205602
+ processTrace: run.trace.map((entry) => entry.state)
204956
205603
  });
204957
205604
  if (decision.kind === "refusal") return yield* fail$2(new Error(formatStepRefusal(invoker, decision)));
204958
205605
  if (decision.kind === "noop") return {
@@ -205001,88 +205648,94 @@ const reportStepResult = (result, json, write) => {
205001
205648
  }) + "\n");
205002
205649
  else write(result.subject !== null ? `committed: ${result.subject}\n` : `nothing to do at "${result.state}"\n`);
205003
205650
  };
205004
- /** `gtd step <actor> [--cost=<n>] [--model=<name>]`: authenticate as `<actor>` and perform the one resulting transition, recording `--cost`/`--model` as a `Gtd-Cost:` trailer. */
205005
- const runStepCommand = (argv, json, write, cost, model) => gen(function* () {
205651
+ /**
205652
+ * `gtd step <actor> [--cost=<n>] [--model=<name>]`: authenticate as `<actor>`
205653
+ * and perform the one resulting transition, recording `--cost`/`--model` as a
205654
+ * `Gtd-Cost:` trailer. When `--entry <state>` is present, the actor argument
205655
+ * is still required (it names who authors the entry commit) but the ordinary
205656
+ * pattern-matched step never runs — dispatches to `runEntryCommand` instead
205657
+ * (see `makeProgram`'s parsing of `--entry`/`--var`, and its sibling
205658
+ * subcommand-less short form).
205659
+ */
205660
+ const runStepCommand = (argv, json, write, cost, model, entryFlags) => gen(function* () {
205006
205661
  const args = commandArgs(argv);
205007
205662
  if (args.length === 0) return yield* fail$2(/* @__PURE__ */ new Error("gtd step: missing actor argument"));
205008
205663
  if (args.length > 1) return yield* fail$2(/* @__PURE__ */ new Error(`gtd step: too many arguments — expected one actor, got: ${args.join(", ")}`));
205009
- const result = yield* stepAsActor(args[0], cost, model);
205664
+ const actor = args[0];
205665
+ if (entryFlags.entry !== void 0) return yield* runEntryCommand(actor, entryFlags.entry, entryFlags.vars, json, write, `gtd step ${actor} --entry ${entryFlags.entry}`);
205666
+ const result = yield* stepAsActor(actor, cost, model);
205010
205667
  reportStepResult(result, json, write);
205011
205668
  });
205012
205669
  /**
205013
- * `gtd review <commitish>`: start a brand NEW review process at the active
205014
- * workflow's declared `reviewEntry: true` state (see
205015
- * `PatternMachine.reviewEntryStateOf`), reviewing `<commitish>..HEAD` — e.g. a
205016
- * colleague's PR branch pushed on top of a shared base, with no gtd process of
205017
- * its own. Writes an ordinary EMPTY turn commit
205018
- * (`gtd(human): <review-entry-state>`) carrying the resolved `<commitish>`'s
205019
- * full hash as a `Gtd-Review-Base:` trailer (`withReviewBaseTrailer`) —
205020
- * `computeProcessRun` reads that trailer back off this same commit (its
205021
- * process's own oldest) to override the new process's diff base, so the
205022
- * ENTIRE existing review flow (reviewing → await-review → feedback laps, the
205023
- * `await-review` review checkout window) operates over `<commitish>..HEAD`
205024
- * with zero duplicated logic — see `src/Edge.ts`/`src/ReviewWindow.ts`.
205025
- *
205026
- * Any failure below is a plain refusal: nothing is written.
205027
- *
205028
- * a. The machine must currently rest at the workflow's INITIAL state — a
205670
+ * `gtd step <actor> --entry <state> [--var <name>=<value> ...]` (or its
205671
+ * subcommand-less short form `gtd --entry <state> ...`, `actor` defaulting to
205672
+ * `human` there — see `makeProgram`): start a brand NEW process at `<state>`
205673
+ * — any declared, non-commit state (see `PatternMachine.enterableStates`) —
205674
+ * replacing the two former named commands `gtd review <commitish>`/`gtd fix`
205675
+ * with one generic mechanism. Writes an ordinary turn commit
205676
+ * (`gtd(<actor>): <state>`) carrying zero or more `Gtd-Var: <name>=<value>`
205677
+ * trailers (`withEntryTrailers`) for each `--var` override, plus — when
205678
+ * `<state>` declares a string `reviewBase:` — a `Gtd-Review-Base:` trailer
205679
+ * pinning the new process's diff base (rendered from that template against
205680
+ * the merged `it.vars`, resolved to a commit, and checked sane). Unlike the
205681
+ * old commands (which required a clean tree and used `commitAsIs`), this
205682
+ * commits via `commitAllWithPrefix` — capturing whatever the working tree
205683
+ * carries at the moment of entry, exactly like an ordinary `gtd step`
205684
+ * capture, rather than demanding a clean tree first.
205685
+ *
205686
+ * Any failure below is a plain refusal: nothing is written. Checked in order:
205687
+ *
205688
+ * 1. The machine must currently rest at the workflow's INITIAL state — a
205029
205689
  * plain non-gtd branch (the normal case) resolves there via the
205030
- * inert-subject rule (see `resolveState`); a process already underway refuses.
205031
- * b. The working tree must be clean.
205032
- * c. The active workflow must declare a `reviewEntry: true` state.
205033
- * d. `<commitish>` must resolve to a commit, be an ancestor of HEAD, and
205034
- * differ from HEAD (nothing to review otherwise).
205035
- */
205036
- const runReviewCommand = (argv, json, write) => gen(function* () {
205037
- const args = commandArgs(argv);
205038
- if (args.length === 0) return yield* fail$2(/* @__PURE__ */ new Error("gtd review: missing <commitish> argument"));
205039
- if (args.length > 1) return yield* fail$2(/* @__PURE__ */ new Error(`gtd review: too many arguments — expected one <commitish>, got: ${args.join(", ")}`));
205040
- const commitish = args[0];
205041
- const git = yield* GitService;
205042
- const rest = yield* resolveRest();
205043
- if (rest.state !== initialStateOf(rest.def)) return yield* fail$2(/* @__PURE__ */ new Error(`gtd review: a process is already underway (resting at "${rest.state}") — finish it, or run \`gtd abandon\`, before starting a review`));
205044
- if ((yield* pendingChanges(git)).length > 0) return yield* fail$2(/* @__PURE__ */ new Error("gtd review: the working tree must be clean before starting a review"));
205045
- const entryState = reviewEntryStateOf(rest.def);
205046
- if (entryState === void 0) return yield* fail$2(/* @__PURE__ */ new Error("gtd review: the active workflow declares no review entry state"));
205047
- const resolvedBase = yield* git.resolveRef(commitish).pipe(mapError$1(() => /* @__PURE__ */ new Error(`gtd review: "${commitish}" does not resolve to a commit`)));
205048
- if (resolvedBase === (yield* git.resolveRef("HEAD"))) return yield* fail$2(/* @__PURE__ */ new Error(`gtd review: "${commitish}" is HEAD — nothing to review`));
205049
- if (!(yield* git.isAncestor(resolvedBase, "HEAD"))) return yield* fail$2(/* @__PURE__ */ new Error(`gtd review: "${commitish}" is not an ancestor of HEAD`));
205050
- const subject = stateSubject("human", entryState);
205051
- yield* git.commitAsIs(withReviewBaseTrailer(subject, resolvedBase));
205052
- if (json) write(JSON.stringify({
205053
- state: entryState,
205054
- subject
205055
- }) + "\n");
205056
- else write(`committed: ${subject}\n`);
205057
- });
205058
- /**
205059
- * `gtd fix`: start a brand NEW process at the active workflow's declared
205060
- * `fixEntry: true` state (see `PatternMachine.fixEntryStateOf`), which goes
205061
- * straight into repairing the current failing tests. Mirrors `runReviewCommand`
205062
- * but simpler — it takes no argument and writes NO `Gtd-Review-Base:` trailer:
205063
- * the fix process reviews its own fixes from the ordinary process start, so a
205064
- * plain empty turn commit (`gtd(human): <fix-entry-state>`) is all it needs.
205065
- * `computeProcessRun` then treats that commit as the process's first turn like
205066
- * any other, and the shared health/review/squash tail runs unmodified.
205067
- *
205068
- * Any failure below is a plain refusal: nothing is written.
205069
- *
205070
- * a. The machine must currently rest at the workflow's INITIAL state — a
205071
- * dedicated `gtd fix` entry is distinct from the "no active cycle" rest, and
205072
- * a process already underway refuses (finish or abandon it first).
205073
- * b. The working tree must be clean — the fixes belong to their own process.
205074
- * c. The active workflow must declare a `fixEntry: true` state.
205075
- */
205076
- const runFixCommand = (argv, json, write) => gen(function* () {
205077
- yield* rejectExtraArgs("fix", argv);
205690
+ * inert-subject rule (see `resolveState`); a process already underway
205691
+ * refuses.
205692
+ * 2. `<state>` must be one of `enterableStates(rest.def)` — every declared,
205693
+ * non-commit state, NOT narrowed to whatever declared `entry: true` (that
205694
+ * narrower set only seeds the workflow's own `entries.manual` reachability
205695
+ * roots — this command lets an operator enter any of them).
205696
+ * 3. Every `--var` name must already be declared by the workflow's own
205697
+ * `vars:` or the top-level `.gtdrc` `vars:` — an undeclared name is a
205698
+ * usage error, not a silently-ignored override.
205699
+ * 4. When `<state>` declares a string `reviewBase:`, that template must
205700
+ * render to a NON-BLANK commitish that resolves to a commit, is an
205701
+ * ancestor of HEAD, and differs from HEAD.
205702
+ */
205703
+ const runEntryCommand = (actor, entryState, varOverrides, json, write, commandLabel) => gen(function* () {
205078
205704
  const git = yield* GitService;
205705
+ const config = yield* (yield* ConfigService).load;
205706
+ const envVars = yield* EnvVars;
205079
205707
  const rest = yield* resolveRest();
205080
- if (rest.state !== initialStateOf(rest.def)) return yield* fail$2(/* @__PURE__ */ new Error(`gtd fix: a process is already underway (resting at "${rest.state}") — finish it, or run \`gtd abandon\`, before starting a fix`));
205081
- if ((yield* pendingChanges(git)).length > 0) return yield* fail$2(/* @__PURE__ */ new Error("gtd fix: the working tree must be clean before starting a fix"));
205082
- const entryState = fixEntryStateOf(rest.def);
205083
- if (entryState === void 0) return yield* fail$2(/* @__PURE__ */ new Error("gtd fix: the active workflow declares no fix entry state"));
205084
- const subject = stateSubject("human", entryState);
205085
- yield* git.commitAsIs(subject);
205708
+ if (rest.state !== initialStateOf(rest.def)) return yield* fail$2(/* @__PURE__ */ new Error(`${commandLabel}: a process is already underway (resting at "${rest.state}") — finish it, or run \`gtd abandon\`, before entering`));
205709
+ const enterable = enterableStates(rest.def);
205710
+ if (!enterable.includes(entryState)) return yield* fail$2(/* @__PURE__ */ new Error(`${commandLabel}: "${entryState}" is not an enterable state — enterable states:\n${enterable.map((s) => ` ${s}`).join("\n")}`));
205711
+ const declaredNames = Object.keys({
205712
+ ...config.workflowVars,
205713
+ ...config.rcVars
205714
+ });
205715
+ const undeclared = Object.keys(varOverrides).filter((name) => !declaredNames.includes(name));
205716
+ if (undeclared.length > 0) return yield* fail$2(/* @__PURE__ */ new Error(`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(", ")} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`));
205717
+ const vars = resolveVars(config.workflowVars, config.rcVars, varOverrides, envVars.all);
205718
+ let base;
205719
+ const baseTemplate = entryBaseTemplateOf(rest.def, entryState);
205720
+ if (baseTemplate !== void 0) {
205721
+ const rendered = yield* try_({
205722
+ try: () => renderStateTemplate(baseTemplate, varsOnlyContext(vars, entryState)),
205723
+ catch: (e) => /* @__PURE__ */ new Error(`${commandLabel}: ${e instanceof Error ? e.message : String(e)}`)
205724
+ });
205725
+ if (rendered.trim() === "") {
205726
+ const refs = Array.from(new Set(Array.from(baseTemplate.matchAll(/it\.vars\.(\w+)/g)).map((m) => m[1])));
205727
+ return yield* fail$2(/* @__PURE__ */ new Error(`${commandLabel}: "${entryState}"'s reviewBase template rendered blank — template: ${JSON.stringify(baseTemplate)}; it.vars references: ${refs.length > 0 ? refs.map((r) => `it.vars.${r}`).join(", ") : "(none found)"}`));
205728
+ }
205729
+ const resolvedBase = yield* git.resolveRef(rendered).pipe(mapError$1(() => /* @__PURE__ */ new Error(`${commandLabel}: "${rendered}" does not resolve to a commit`)));
205730
+ if (!(yield* git.isAncestor(resolvedBase, "HEAD"))) return yield* fail$2(/* @__PURE__ */ new Error(`${commandLabel}: "${rendered}" is not an ancestor of HEAD`));
205731
+ if (resolvedBase === (yield* git.resolveRef("HEAD"))) return yield* fail$2(/* @__PURE__ */ new Error(`${commandLabel}: "${rendered}" is HEAD — nothing to review`));
205732
+ base = resolvedBase;
205733
+ }
205734
+ const subject = stateSubject(actor, entryState);
205735
+ yield* git.commitAllWithPrefix(withEntryTrailers(subject, {
205736
+ ...base !== void 0 ? { base } : {},
205737
+ vars: varOverrides
205738
+ }));
205086
205739
  if (json) write(JSON.stringify({
205087
205740
  state: entryState,
205088
205741
  subject
@@ -205093,8 +205746,10 @@ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
205093
205746
  /**
205094
205747
  * `gtd abandon`: end the process currently underway WITHOUT completing it,
205095
205748
  * returning the machine to the workflow's initial state — the recovery path out
205096
- * of a process nobody is going to finish (the refusals of `gtd review`/`gtd fix`
205097
- * name it: "finish or abandon it before starting a review").
205749
+ * of a process nobody is going to finish (`runEntryCommand`'s "already
205750
+ * underway" refusal names it: "finish it, or run `gtd abandon`, before entering"
205751
+ * — and so does `resolveRest`'s refusal when HEAD names a state a workflow
205752
+ * change has since removed).
205098
205753
  *
205099
205754
  * NOTHING is discarded. The shared bracket in `makeProgram` has already closed
205100
205755
  * any open review checkout window (so HEAD is the real head), and abandon then
@@ -205105,6 +205760,15 @@ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
205105
205760
  * uncommitted changes for the human to keep, re-commit, or discard with
205106
205761
  * ordinary git.
205107
205762
  *
205763
+ * Deliberately reads the current state off `computeProcessRun`'s OWN trace
205764
+ * (its last entry, oldest→newest) rather than `resolveRest` — `resolveRest`
205765
+ * refuses when HEAD names a state the CURRENT workflow no longer declares
205766
+ * (the escape hatch THIS command is), so routing through it here would make
205767
+ * the one command that must still work in that exact situation refuse right
205768
+ * alongside everything else. `computeProcessRun`'s boundary walk only ever
205769
+ * compares state NAMES (never a declaration lookup), so it resolves a
205770
+ * renamed-away trace exactly as well as an ordinary one.
205771
+ *
205108
205772
  * Idempotent: resting at the initial state (a plain non-gtd branch, or a
205109
205773
  * just-squashed cycle) is a no-op SUCCESS, not a refusal — a recovery command
205110
205774
  * that fails when there is nothing to recover is a worse tool. The one refusal
@@ -205114,9 +205778,10 @@ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
205114
205778
  const runAbandonCommand = (argv, json, write) => gen(function* () {
205115
205779
  yield* rejectExtraArgs("abandon", argv);
205116
205780
  const git = yield* GitService;
205117
- const rest = yield* resolveRest();
205118
- const initial = initialStateOf(rest.def);
205119
- if (rest.state === initial) {
205781
+ const def = (yield* (yield* ConfigService).load).workflow;
205782
+ const initial = initialStateOf(def);
205783
+ const run = yield* computeProcessRun(git, def);
205784
+ if (run.trace.length === 0) {
205120
205785
  if (json) write(JSON.stringify({
205121
205786
  state: initial,
205122
205787
  abandoned: false
@@ -205124,18 +205789,18 @@ const runAbandonCommand = (argv, json, write) => gen(function* () {
205124
205789
  else write(`no gtd process is underway (resting at "${initial}") — nothing to abandon\n`);
205125
205790
  return;
205126
205791
  }
205127
- const run = yield* computeProcessRun(git, rest.def);
205128
- if (run.startParentHash === EMPTY_TREE) return yield* fail$2(/* @__PURE__ */ new Error(`gtd abandon: the process underway (resting at "${rest.state}") starts at the repository's first commit — there is no earlier commit to rewind to`));
205792
+ const restState = run.trace[run.trace.length - 1].state;
205793
+ if (run.startParentHash === EMPTY_TREE) return yield* fail$2(/* @__PURE__ */ new Error(`gtd abandon: the process underway (resting at "${restState}") starts at the repository's first commit — there is no earlier commit to rewind to`));
205129
205794
  yield* retainHistory(git, yield* git.resolveRef("HEAD"), run.startParentHash);
205130
205795
  yield* git.mixedResetTo(run.startParentHash);
205131
205796
  const subject = yield* git.lastCommitSubject();
205132
205797
  if (json) write(JSON.stringify({
205133
205798
  state: initial,
205134
205799
  abandoned: true,
205135
- from: rest.state,
205800
+ from: restState,
205136
205801
  head: run.startParentHash
205137
205802
  }) + "\n");
205138
- else write(`abandoned the process resting at "${rest.state}" — HEAD is back at ${run.startParentHash.slice(0, 7)} ("${subject}"), resting at "${initial}".\nEverything the process produced is kept as uncommitted changes (\`git status\`); discard them with \`git checkout -- . && git clean -fd .gtd\` for a clean tree.\n`);
205803
+ else write(`abandoned the process resting at "${restState}" — HEAD is back at ${run.startParentHash.slice(0, 7)} ("${subject}"), resting at "${initial}".\nEverything the process produced is kept as uncommitted changes (\`git status\`); discard them with \`git checkout -- . && git clean -fd .gtd\` for a clean tree.\n`);
205139
205804
  });
205140
205805
  /**
205141
205806
  * `gtd restore`: hard-reset HEAD back to the pre-squash tip a squash or
@@ -205208,8 +205873,8 @@ const nextPlainOutput = (rendered) => {
205208
205873
  };
205209
205874
  const runNextCommand = (json, write) => gen(function* () {
205210
205875
  const git = yield* GitService;
205211
- const { rest, context } = yield* resolveRestContext(git);
205212
- const rendered = yield* renderRest(rest, context);
205876
+ const { rest, context, memory } = yield* resolveRestContext(git);
205877
+ const rendered = yield* renderRest(rest, context, memory);
205213
205878
  write(json ? nextJsonOutput(rendered) : nextPlainOutput(rendered));
205214
205879
  });
205215
205880
  /**
@@ -205559,10 +206224,9 @@ const writeStatusPlain = (write, rest, statusChanges, next, model, memory, label
205559
206224
  const runStatusCommand = (argv, json, write) => gen(function* () {
205560
206225
  yield* rejectExtraArgs("status", argv);
205561
206226
  const git = yield* GitService;
205562
- const { rest, context, renderedOn } = yield* resolveRestContext(git);
206227
+ const { rest, context, renderedOn, memory } = yield* resolveRestContext(git);
205563
206228
  const changes = yield* pendingChanges(git);
205564
206229
  const model = yield* renderModel(rest.stateDef, context);
205565
- const memory = yield* renderMemory(rest.stateDef, context);
205566
206230
  const label = yield* renderLabel(rest.stateDef, context);
205567
206231
  const file = yield* renderFile(rest.stateDef, context);
205568
206232
  const statusChanges = computeStatusChanges(renderedOn, changes);
@@ -205632,8 +206296,9 @@ const computeCurrentState = (model) => gen(function* () {
205632
206296
  const envVars = yield* EnvVars;
205633
206297
  const reviewHead = yield* git.readRefOption(REVIEW_HEAD_REF);
205634
206298
  const currentRest = yield* resolveRest(getOrUndefined(reviewHead));
206299
+ const run = yield* computeProcessRun(git, currentRest.def);
205635
206300
  const changes = yield* pendingChanges(git);
205636
- const vars = resolveVars(config.workflowVars, config.rcVars, envVars.all);
206301
+ const vars = resolveVars(config.workflowVars, config.rcVars, run.entryVars, envVars.all);
205637
206302
  const renderedOn = yield* renderOnEdgesOrFail(currentRest.stateDef.on, vars);
205638
206303
  const group = model.states.find((s) => s.name === currentRest.state)?.group;
205639
206304
  return buildCurrentStateModel(currentRest, changes, renderedOn, group);
@@ -205656,10 +206321,10 @@ const runVisualizeCommand = (argv, json, write) => gen(function* () {
205656
206321
  const opts = parseVisualizeOptions(rest);
205657
206322
  if ("error" in opts) return yield* fail$2(new Error(opts.error));
205658
206323
  const config = yield* (yield* ConfigService).load;
205659
- const model = buildVizModel(config.workflow, config.rawWorkflow, {
206324
+ const model = buildVizModel(config.workflow, config.machineTree, {
205660
206325
  ...config.workflowVars,
205661
206326
  ...config.rcVars
205662
- });
206327
+ }, config.stateScopes);
205663
206328
  if (json) {
205664
206329
  write(JSON.stringify(model, null, 2) + "\n");
205665
206330
  return;
@@ -205676,8 +206341,6 @@ const runVisualizeCommand = (argv, json, write) => gen(function* () {
205676
206341
  });
205677
206342
  const KNOWN_SUBCOMMANDS = [
205678
206343
  "step",
205679
- "review",
205680
- "fix",
205681
206344
  "abandon",
205682
206345
  "restore",
205683
206346
  "next",
@@ -205685,6 +206348,19 @@ const KNOWN_SUBCOMMANDS = [
205685
206348
  "validate"
205686
206349
  ];
205687
206350
  /**
206351
+ * The two named commands the generic `--entry` mechanism replaced (see
206352
+ * `runEntryCommand`) — no fallback, so `gtd review`/`gtd fix` must fail with a
206353
+ * message pointing at the replacement rather than the generic "unknown
206354
+ * command" (see `requireKnownSubcommand`). Illustrated with the bundled
206355
+ * unified template's own entry-flagged state names (`review-gate.check`/
206356
+ * `fix-precheck`) since gtd itself has no opinion on any workflow's state
206357
+ * names — a custom workflow names its own.
206358
+ */
206359
+ const REMOVED_SUBCOMMANDS = {
206360
+ review: "gtd: `gtd review <commitish>` is gone — this workflow's own state names aren't known to gtd; run `gtd step <actor> --entry <review-state> --var <name>=<value> ...` instead (e.g. `gtd step human --entry review-gate.check` for the bundled unified template)",
206361
+ fix: "gtd: `gtd fix` is gone — this workflow's own state names aren't known to gtd; run `gtd step <actor> --entry <fix-state>` instead (e.g. `gtd step human --entry fix-precheck` for the bundled unified template)"
206362
+ };
206363
+ /**
205688
206364
  * `--version`/`-v`/`version` or `--help`/`-h`/`help`: short-circuits before any
205689
206365
  * git or state work, so it works outside a repo too. The bare `version`/`help`
205690
206366
  * subcommands are equivalent to their flag forms. Exported so main.ts can run
@@ -205712,6 +206388,8 @@ const requireKnownSubcommand = (sub, json, write) => {
205712
206388
  if (!json) write(HELP_TEXT);
205713
206389
  return fail$2(/* @__PURE__ */ new Error("gtd: missing command — see usage above (`gtd --help`)"));
205714
206390
  }
206391
+ const removedMessage = REMOVED_SUBCOMMANDS[sub];
206392
+ if (removedMessage !== void 0) return fail$2(new Error(removedMessage));
205715
206393
  if (!KNOWN_SUBCOMMANDS.includes(sub)) return fail$2(/* @__PURE__ */ new Error(`unknown command '${sub}'`));
205716
206394
  return succeed$3(sub);
205717
206395
  };
@@ -205745,12 +206423,10 @@ const assertInitLocation = (git, fs) => gen(function* () {
205745
206423
  if ((yield* fs.realPath(topLevel.right)) !== (yield* fs.realPath(process.cwd()))) return yield* fail$2(/* @__PURE__ */ new Error(`gtd init must be run from the repository root (${topLevel.right}) or from a directory outside any git repository; the current directory is a repository subdirectory: ${process.cwd()}`));
205746
206424
  return true;
205747
206425
  });
205748
- /** Dispatches to the named `run*Command` handler for every known subcommand. */
205749
- const dispatchKnownSubcommand = (sub, argv, json, write, cost, model) => {
206426
+ /** Dispatches to the named `run*Command` handler for every known subcommand. `entryFlags` is only consulted by `step` (see `runStepCommand`) — every other subcommand ignores it, since `--entry`/`--var` are meaningless anywhere else (`parseEntryFlags` already rejects that combination before dispatch is reached). */
206427
+ const dispatchKnownSubcommand = (sub, argv, json, write, cost, model, entryFlags) => {
205750
206428
  switch (sub) {
205751
- case "step": return runStepCommand(argv, json, write, cost, model);
205752
- case "review": return runReviewCommand(argv, json, write);
205753
- case "fix": return runFixCommand(argv, json, write);
206429
+ case "step": return runStepCommand(argv, json, write, cost, model, entryFlags);
205754
206430
  case "abandon": return runAbandonCommand(argv, json, write);
205755
206431
  case "restore": return runRestoreCommand(argv, json, write);
205756
206432
  case "next": return runNextCommand(json, write);
@@ -205759,6 +206435,22 @@ const dispatchKnownSubcommand = (sub, argv, json, write, cost, model) => {
205759
206435
  }
205760
206436
  };
205761
206437
  /**
206438
+ * Runs `command` inside the bracket every state-touching subcommand shares:
206439
+ * the repo-root guard, then close-any-open-review-window before the command
206440
+ * sees HEAD, then re-arm it afterward whether `command` succeeded or refused
206441
+ * (see `closeReviewWindow`/`openReviewWindow`). Shared by the normal
206442
+ * known-subcommand dispatch and the subcommand-less `gtd --entry <state>`
206443
+ * short form in `makeProgram`, so both open/close the window identically.
206444
+ */
206445
+ const runInReviewWindowBracket = (git, fs, command) => gen(function* () {
206446
+ yield* assertRunningFromRepoRoot(git, fs);
206447
+ yield* closeReviewWindow;
206448
+ const outcome = yield* either(command);
206449
+ const rearm = yield* either(openReviewWindow);
206450
+ if (isLeft(outcome)) return yield* fail$2(outcome.left);
206451
+ if (isLeft(rearm)) return yield* fail$2(rearm.left);
206452
+ });
206453
+ /**
205762
206454
  * Factory that returns the gtd driver Effect with the given I/O options.
205763
206455
  *
205764
206456
  * The returned Effect requires `GitService | FileSystem.FileSystem |
@@ -205766,36 +206458,46 @@ const dispatchKnownSubcommand = (sub, argv, json, write, cost, model) => {
205766
206458
  * this with no arguments; the test world supplies an in-memory layer set and
205767
206459
  * captures stdout via the `write` callback.
205768
206460
  *
205769
- * v3 command surface: `step <actor>` / `review <commitish>` / `fix` / `next` /
205770
- * `status` / `validate` (see `src/Edge.ts`),
205771
- * plus `lsp` and `init` — both dispatched before the config-reading
205772
- * path since neither needs to touch the config. Bare `gtd` or an
205773
- * unknown subcommand is a usage error. Shared setup (argv parsing, the
205774
- * repo-root guard) lives here; each subcommand's own logic is a named
205775
- * `run*Command` function above.
206461
+ * v3 command surface: `step <actor>` / `next` / `status` / `validate` (see
206462
+ * `src/Edge.ts`), plus `lsp` and `init` — both dispatched before the
206463
+ * config-reading path since neither needs to touch the config. `step
206464
+ * <actor> --entry <state>` (and its subcommand-less short form `gtd --entry
206465
+ * <state>`, actor defaulting to `human`) starts a brand-new process at any
206466
+ * declared, non-commit state — the generic mechanism that replaced the old
206467
+ * named `review`/`fix` commands (see `runEntryCommand`; `gtd review`/`gtd
206468
+ * fix` now fail with a message pointing at the replacement, `REMOVED_SUBCOMMANDS`).
206469
+ * Bare `gtd` with no `--entry` present, or an unknown subcommand, is a usage
206470
+ * error. Shared setup (argv parsing, the repo-root guard) lives here; each
206471
+ * subcommand's own logic is a named `run*Command` function above.
205776
206472
  */
205777
206473
  function makeProgram(opts = {}) {
205778
206474
  const argv = opts.argv ?? process.argv;
205779
206475
  const write = opts.write ?? ((chunk) => process.stdout.write(chunk));
205780
206476
  const json = argv.includes("--json");
205781
- const positional = argv.slice(2).find((a) => !a.startsWith("--"));
206477
+ const consumedFlagIndices = flagConsumedIndices(argv);
206478
+ const positional = argv.map((a, i) => ({
206479
+ a,
206480
+ i
206481
+ })).slice(2).find(({ a, i }) => !a.startsWith("--") && !consumedFlagIndices.has(i))?.a;
205782
206482
  return gen(function* () {
205783
206483
  if (runVersionOrHelp(argv, write)) return;
205784
206484
  if (positional === "visualize") return yield* runVisualizeCommand(argv, json, write);
205785
- const unknownOption = argv.slice(2).find((a) => a.startsWith("--") && a !== "--json" && a !== "--cost" && a !== "--model" && !a.startsWith(COST_FLAG) && !a.startsWith(MODEL_FLAG));
206485
+ const unknownOption = argv.slice(2).find((a) => a.startsWith("--") && a !== "--json" && a !== "--cost" && a !== "--model" && a !== ENTRY_FLAG && a !== VAR_FLAG && !a.startsWith(COST_FLAG) && !a.startsWith(MODEL_FLAG) && !a.startsWith(`${ENTRY_FLAG}=`) && !a.startsWith(`${VAR_FLAG}=`));
205786
206486
  if (unknownOption !== void 0) return yield* fail$2(/* @__PURE__ */ new Error(`gtd: unknown option '${unknownOption}' — see \`gtd --help\``));
205787
206487
  const { cost, model } = yield* parseStepFlags(argv, positional);
206488
+ const entryFlags = yield* parseEntryFlags(argv, positional);
206489
+ if (entryFlags.entry !== void 0 && (cost !== void 0 || model !== void 0)) return yield* fail$2(/* @__PURE__ */ new Error("gtd: --cost/--model cannot be combined with --entry — an entry is not a metered agent turn"));
205788
206490
  if (positional === "lsp") return yield* runLspCommand(argv, json);
205789
206491
  if (positional === "init") return yield* runInitCommand(argv, json, write);
206492
+ if (positional === void 0 && entryFlags.entry !== void 0) {
206493
+ const git = yield* GitService;
206494
+ const fs = yield* FileSystem;
206495
+ return yield* runInReviewWindowBracket(git, fs, runEntryCommand("human", entryFlags.entry, entryFlags.vars, json, write, `gtd --entry ${entryFlags.entry}`));
206496
+ }
205790
206497
  const sub = yield* requireKnownSubcommand(positional, json, write);
205791
206498
  const git = yield* GitService;
205792
206499
  const fs = yield* FileSystem;
205793
- yield* assertRunningFromRepoRoot(git, fs);
205794
- yield* closeReviewWindow;
205795
- const outcome = yield* either(dispatchKnownSubcommand(sub, argv, json, write, cost, model));
205796
- const rearm = yield* either(openReviewWindow);
205797
- if (isLeft(outcome)) return yield* fail$2(outcome.left);
205798
- if (isLeft(rearm)) return yield* fail$2(rearm.left);
206500
+ yield* runInReviewWindowBracket(git, fs, dispatchKnownSubcommand(sub, argv, json, write, cost, model, entryFlags));
205799
206501
  }).pipe(json ? catchAll((error) => sync$1(() => write(JSON.stringify({
205800
206502
  state: "error",
205801
206503
  prompt: error.message