@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.
- package/README.md +270 -87
- package/bin/gtd +79 -28
- package/dist/gtd.bundle.mjs +1597 -895
- package/package.json +1 -1
- package/schema.json +147 -265
package/dist/gtd.bundle.mjs
CHANGED
|
@@ -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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
|
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
|
-
/**
|
|
189185
|
-
|
|
189186
|
-
|
|
189187
|
-
|
|
189188
|
-
|
|
189189
|
-
|
|
189190
|
-
|
|
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
|
|
189201
|
-
* source), the subject collapses to the bare
|
|
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
|
-
|
|
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
|
-
/**
|
|
189452
|
-
|
|
189453
|
-
|
|
189454
|
-
|
|
189455
|
-
|
|
189456
|
-
|
|
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
|
-
/** `
|
|
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
|
|
189539
|
-
* enforces the type) —
|
|
189540
|
-
*
|
|
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
|
-
* `
|
|
189552
|
-
*
|
|
189553
|
-
*
|
|
189554
|
-
*
|
|
189555
|
-
*
|
|
189556
|
-
*
|
|
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
|
|
189562
|
-
if (state.
|
|
189563
|
-
|
|
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
|
|
189642
|
-
*
|
|
189643
|
-
* reachable only from one of them is
|
|
189644
|
-
* (without seeding them, e.g. a
|
|
189645
|
-
*
|
|
189646
|
-
* states are skipped here (they
|
|
189647
|
-
* of their own). Only called
|
|
189648
|
-
*
|
|
189649
|
-
*
|
|
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
|
|
189653
|
-
*
|
|
189654
|
-
*
|
|
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
|
-
...
|
|
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;
|
|
189700
|
-
*
|
|
189701
|
-
*
|
|
189702
|
-
*
|
|
189703
|
-
* content kind; commit states carry no `actor` and no `on`;
|
|
189704
|
-
* states carry an `actor`; every `on` pattern parses and every
|
|
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; `
|
|
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`); `
|
|
189710
|
-
*
|
|
189711
|
-
* present,
|
|
189712
|
-
*
|
|
189713
|
-
*
|
|
189714
|
-
* `
|
|
189715
|
-
*
|
|
189716
|
-
* `
|
|
189717
|
-
*
|
|
189718
|
-
*
|
|
189719
|
-
*
|
|
189720
|
-
*
|
|
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
|
|
189870
|
+
const entriesErrors = validateEntries(def, names);
|
|
189729
189871
|
return [
|
|
189730
|
-
...
|
|
189872
|
+
...entriesErrors,
|
|
189731
189873
|
...validateModes(def),
|
|
189732
189874
|
...names.flatMap((name) => validateState(def, name, names)),
|
|
189733
|
-
...
|
|
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/
|
|
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
|
|
189806
|
-
|
|
189807
|
-
|
|
189808
|
-
|
|
189809
|
-
|
|
189810
|
-
|
|
189811
|
-
|
|
189812
|
-
|
|
189813
|
-
|
|
189814
|
-
|
|
189815
|
-
|
|
189816
|
-
if (
|
|
189817
|
-
const
|
|
189818
|
-
if (
|
|
189819
|
-
|
|
189820
|
-
return
|
|
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
|
-
|
|
189829
|
-
|
|
189906
|
+
return {
|
|
189907
|
+
value,
|
|
189908
|
+
scope: callerPath
|
|
189909
|
+
};
|
|
189830
189910
|
};
|
|
189831
|
-
/**
|
|
189832
|
-
|
|
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
|
|
189837
|
-
|
|
189838
|
-
|
|
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
|
|
190068
|
+
return binding.value;
|
|
189842
190069
|
};
|
|
189843
|
-
/** Rewrite
|
|
189844
|
-
const
|
|
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] =
|
|
189848
|
-
else if (isPlainObject$2(value))
|
|
189849
|
-
|
|
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
|
|
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"] =
|
|
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
|
|
189865
|
-
const
|
|
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(`${
|
|
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))
|
|
189872
|
-
|
|
189873
|
-
|
|
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
|
-
|
|
189886
|
-
|
|
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
|
-
|
|
189905
|
-
|
|
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
|
|
190115
|
+
return out;
|
|
189908
190116
|
};
|
|
189909
|
-
/**
|
|
189910
|
-
|
|
189911
|
-
|
|
189912
|
-
|
|
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(
|
|
189915
|
-
return
|
|
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
|
-
|
|
189947
|
-
|
|
189948
|
-
|
|
189949
|
-
|
|
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
|
-
|
|
189970
|
-
|
|
189971
|
-
|
|
189972
|
-
|
|
189973
|
-
|
|
189974
|
-
|
|
189975
|
-
|
|
189976
|
-
|
|
189977
|
-
|
|
189978
|
-
|
|
189979
|
-
|
|
189980
|
-
const
|
|
189981
|
-
|
|
189982
|
-
const
|
|
189983
|
-
|
|
189984
|
-
|
|
189985
|
-
const
|
|
189986
|
-
|
|
189987
|
-
|
|
189988
|
-
|
|
189989
|
-
|
|
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
|
|
189992
|
-
|
|
189993
|
-
|
|
189994
|
-
|
|
189995
|
-
|
|
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
|
-
*
|
|
190228
|
+
* entry:
|
|
190229
|
+
* default: <machine name> # which machine is the ROOT instance
|
|
190230
|
+
* machines:
|
|
190077
190231
|
* <name>:
|
|
190078
|
-
*
|
|
190079
|
-
*
|
|
190080
|
-
*
|
|
190081
|
-
*
|
|
190082
|
-
*
|
|
190083
|
-
*
|
|
190084
|
-
*
|
|
190085
|
-
*
|
|
190086
|
-
*
|
|
190087
|
-
*
|
|
190088
|
-
*
|
|
190089
|
-
*
|
|
190090
|
-
*
|
|
190091
|
-
*
|
|
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 `
|
|
190305
|
-
* per level, is the only way `./gtd-prompts/x.md` in a parent `.gtdrc`
|
|
190306
|
-
* against the parent (not the cwd a child repo runs from).
|
|
190307
|
-
* is then invoked with `inlineFileRefs: false` on the
|
|
190308
|
-
*
|
|
190309
|
-
*
|
|
190310
|
-
*
|
|
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
|
|
190318
|
-
if (!isPlainObject$1(
|
|
190319
|
-
const
|
|
190320
|
-
for (const [
|
|
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
|
-
|
|
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
|
|
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 (`
|
|
190516
|
-
* when the raw value is the literal `true`; a
|
|
190517
|
-
* `false` (or absent) compiles away to
|
|
190518
|
-
* `StateDef` — `false` and "unset" mean
|
|
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
|
-
/**
|
|
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
|
|
190571
|
-
|
|
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:
|
|
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`
|
|
190593
|
-
* file's own directory, used to resolve `./`/`../` file
|
|
190594
|
-
* is the already-compiled top-level `.gtdrc` `modes:` key
|
|
190595
|
-
* layered over the workflow's own `modes:` per half BEFORE
|
|
190596
|
-
* state may name a mode either layer declares.
|
|
190597
|
-
* `true`) inlines content file references against
|
|
190598
|
-
* path (`src/Config.ts`'s `loadMerged`) passes
|
|
190599
|
-
* inlined every reference per declaring file
|
|
190600
|
-
* `configDir` is then irrelevant. Throws a single
|
|
190601
|
-
* `"workflow config:\n - ..."`, one line per finding) on ANY
|
|
190602
|
-
* problem or `validateDefinition` finding — never partially
|
|
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
|
|
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(
|
|
190612
|
-
const modes = mergeModes(compileModesMap(
|
|
190613
|
-
|
|
190614
|
-
|
|
190615
|
-
|
|
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
|
-
|
|
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
|
-
} : {
|
|
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
|
|
190703
|
-
*
|
|
190704
|
-
*
|
|
190705
|
-
*
|
|
190706
|
-
*
|
|
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
|
|
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
|
|
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: ["
|
|
191190
|
+
required: ["entry", "machines"],
|
|
190919
191191
|
properties: {
|
|
190920
191192
|
vars: varsJsonSchema,
|
|
190921
191193
|
modes: modesJsonSchema,
|
|
190922
|
-
|
|
191194
|
+
entry: {
|
|
190923
191195
|
type: "object",
|
|
190924
|
-
description: "
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
191221
|
+
description: "This machine's own default local (a local state name, or a reference key), resolved recursively."
|
|
190956
191222
|
},
|
|
190957
|
-
|
|
191223
|
+
model: {
|
|
190958
191224
|
type: "string",
|
|
190959
|
-
description: "
|
|
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
|
-
|
|
191227
|
+
states: {
|
|
190971
191228
|
type: "object",
|
|
190972
|
-
description: "
|
|
190973
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
*
|
|
192029
|
-
*
|
|
192030
|
-
*
|
|
192031
|
-
*
|
|
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
|
|
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) =>
|
|
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
|
|
192579
|
+
startHash,
|
|
192123
192580
|
startParentHash,
|
|
192124
|
-
diffBase:
|
|
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
|
|
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`),
|
|
192135
|
-
*
|
|
192136
|
-
*
|
|
192137
|
-
* name
|
|
192138
|
-
*
|
|
192139
|
-
*
|
|
192140
|
-
*
|
|
192141
|
-
*
|
|
192142
|
-
*
|
|
192143
|
-
*
|
|
192144
|
-
|
|
192145
|
-
|
|
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
|
-
/**
|
|
192309
|
-
|
|
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
|
|
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
|
|
192529
|
-
*
|
|
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) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c],\n )\n\n // Actor metadata: color class, css-var prefix, mermaid classDef name, label.\n const ACTOR = {\n human: { cls: \"actor-human\", dot: \"human\", label: \"human\" },\n agent: { cls: \"actor-agent\", dot: \"agent\", label: \"agent\" },\n check: { cls: \"actor-check\", dot: \"check\", label: \"check\" },\n commit: { cls: \"actor-engine\", dot: \"engine\", label: \"commit\" },\n unknown: { cls: \"actor-unknown\", dot: \"unknown\", label: \"?\" },\n }\n // A commit state has no actor — treat it as the engine/commit colour.\n const actorOf = (s) => s.actor || (s.kind === \"commit\" ? \"commit\" : \"unknown\")\n const meta = (a) => ACTOR[a] || ACTOR.unknown\n\n const FLAGS = {\n initial:\n \"The one initial state — an unrecognized HEAD (any non-gtd history) resolves here.\",\n reviewEntry:\n \"This state is a dedicated CLI entry point (e.g. `gtd review <ref>`) that starts a brand-new process.\",\n fixEntry:\n \"This state is the `gtd fix` entry point — a red suite drops into the fixing loop, a green suite is a no-op.\",\n reviewWindow:\n \"While resting here, gtd opens the review checkout window: HEAD is rewound to the review base so the whole diff surfaces as ordinary uncommitted changes.\",\n reviewBase:\n \"Anchors the incremental review diff base — its most-recent in-process commit is where the next reviewing turn and checkout window start.\",\n answerGate:\n \"Edge gate: a step is refused while any open question in the steering file is not answered with exactly one tick.\",\n requireProgress:\n \"Edge gate: refuses a work-free turn that only deletes the instructions file (unless it held a NOTHING ACTIONABLE sentinel).\",\n }\n\n // ── state ──────────────────────────────────────────────────────────────\n let MODEL = null\n let STATES = []\n let byName = {}\n let CURRENT = null\n let lastCurrentSig = undefined\n let mermaidMod = null\n\n // Fetches `/state.json`, updates `CURRENT`, and reports whether the\n // resolved body actually changed since the last call (compared on the\n // full JSON so an edge's `matched` flag flipping counts as a change too).\n async function refreshCurrent() {\n let next = null\n try {\n const res = await fetch(\"/state.json\")\n const body = res.ok ? await res.json() : {}\n next = body && body.state ? body : null\n } catch (e) {\n next = null\n }\n const sig = JSON.stringify(next)\n if (sig === lastCurrentSig) return false\n lastCurrentSig = sig\n CURRENT = next\n return true\n }\n\n const actorFilter = new Set([\"human\", \"agent\", \"check\", \"commit\", \"unknown\"])\n let kindFilter = \"all\"\n\n // ── boot ─────────────────────────────────────────────────────────────\n try {\n const res = await fetch(\"/workflow.json\")\n if (!res.ok) throw new Error(\"HTTP \" + res.status)\n MODEL = await res.json()\n } catch (e) {\n document.getElementById(\"heroTitle\").textContent = \"Could not load workflow\"\n document.getElementById(\"heroLede\").innerHTML =\n \"Fetching <code>/workflow.json</code> failed: \" +\n esc(e.message) +\n '. This page must be served by <span class=\"mono\">gtd visualize</span>.'\n throw e\n }\n\n // Resolved at page load, then re-polled every 3s (see `poll()` below) so\n // advancing the process shows up without a manual refresh. Tolerates a\n // 404/error (an older server) or an empty `{}` (no active process/not a repo).\n await refreshCurrent()\n\n STATES = MODEL.states || []\n byName = Object.fromEntries(STATES.map((s) => [s.name, s]))\n\n // ── header summary + hero ───────────────────────────────────────────\n const counts = { human: 0, agent: 0, check: 0, commit: 0, unknown: 0 }\n STATES.forEach((s) => {\n counts[actorOf(s)]++\n })\n const nGroups = (MODEL.groups || []).length\n const cnt = (a) =>\n counts[a] ? `<span><span class=\"s-num\">${counts[a]}</span> ${meta(a).label}</span>` : \"\"\n document.getElementById(\"summary\").innerHTML = [\n `<span><span class=\"s-num\">${STATES.length}</span> states</span>`,\n `<span><span class=\"s-num\">${nGroups}</span> sub-machine${nGroups === 1 ? \"\" : \"s\"}</span>`,\n `<span class=\"sep\">·</span>`,\n cnt(\"human\"),\n cnt(\"agent\"),\n cnt(\"check\"),\n cnt(\"commit\"),\n cnt(\"unknown\"),\n ]\n .filter(Boolean)\n .join(\" \")\n\n document.getElementById(\"heroTitle\").innerHTML = `GTD workflow visualization`\n document.getElementById(\"heroLede\").innerHTML =\n `Rendered live from your workflow. The initial state is ` +\n `<code>${esc(MODEL.initial)}</code>. Click any state — in the diagram, a sub-machine chip, ` +\n `or the catalogue — to open its full definition, walk its outgoing edges, and see what routes into it.`\n document.getElementById(\"content\").classList.remove(\"hidden\")\n\n document.getElementById(\"footer\").innerHTML =\n `<p class=\"foot-lead\">Read-only view served by <code>gtd visualize</code>.</p>` +\n `<div class=\"foot-legend\">` +\n `<span class=\"foot-chip\">([message])</span>` +\n `<span class=\"foot-chip\">[prompt]</span>` +\n `<span class=\"foot-chip\">{{script}}</span>` +\n `<span class=\"foot-chip\">[[commit]]</span>` +\n `<span>— node shape = content kind; colour = actor.</span>` +\n `</div>`\n\n // ── pill helper ────────────────────────────────────────────────────\n const pill = (a) =>\n `<span class=\"pill ${meta(a).cls}\"><span class=\"dot\"></span>${meta(a).label}</span>`\n\n // ── mermaid source generation ──────────────────────────────────────\n // Safe, stable node ids (state names can contain hyphens etc.), with a\n // reverse lookup — needed to map a rendered SVG node's id back to its\n // state name when wiring clicks (see `nodeIdOf`/`wire*Clicks` below).\n const idFor = (() => {\n const map = new Map()\n const rev = new Map()\n const fn = (name) => {\n if (!map.has(name)) {\n const id = \"n\" + map.size\n map.set(name, id)\n rev.set(id, name)\n }\n return map.get(name)\n }\n fn.nameOf = (id) => rev.get(id)\n return fn\n })()\n const groupBoxId = (i) => \"g\" + i\n const groupIndexOf = (name) => (MODEL.groups || []).findIndex((g) => g.name === name)\n const shapeFor = (kind, label) => {\n const l = `\"${label.replace(/\"/g, \""\")}\"`\n switch (kind) {\n case \"message\":\n return `([\"${label.replace(/\"/g, \""\")}\"])`\n case \"prompt\":\n return `[${l}]`\n case \"script\":\n return `{{${l}}}`\n case \"commit\":\n return `[[${l}]]`\n default:\n return `[${l}]`\n }\n }\n const mlabel = (s) => `\"${String(s).replace(/\"/g, \""\").replace(/\\|/g, \"|\")}\"`\n /**\n * The mermaid `classDef` lines for actor/ghost node fills, chosen per\n * theme so dark mode doesn't render washed-out light pastels.\n */\n function classDefs(dark) {\n return [\n dark\n ? \"classDef human fill:#3a2e12,stroke:#e0a83e,color:#f0c674;\"\n : \"classDef human fill:#fbeccd,stroke:#d99413,color:#5a3d0a;\",\n dark\n ? \"classDef agent fill:#26224a,stroke:#8b7cf0,color:#b6abf7;\"\n : \"classDef agent fill:#e5e2fb,stroke:#6d5ce0,color:#3a2c85;\",\n dark\n ? \"classDef check fill:#123433,stroke:#2dc4bc,color:#68dcd4;\"\n : \"classDef check fill:#d3f0ef,stroke:#16a39d,color:#0c524f;\",\n dark\n ? \"classDef commit fill:#3a1b24,stroke:#e05f7c,color:#f2a3b3;\"\n : \"classDef commit fill:#fbdfe4,stroke:#d84a68,color:#8a1f38;\",\n dark\n ? \"classDef unknown fill:#1c2434,stroke:#3a465c,color:#9aa6bd,stroke-dasharray:4 3;\"\n : \"classDef unknown fill:#eef1f7,stroke:#c2ccdb,color:#55607a,stroke-dasharray:4 3;\",\n // Sub-machine invocation: a muted slate box (not one of the actor\n // colours) so it still reads as its own distinct \"black-box\" kind,\n // without the harsh opaque-black fill clashing with a light theme.\n dark\n ? \"classDef submachine fill:#2a3142,stroke:#5a6b8c,color:#dbe4f5,font-weight:700;\"\n : \"classDef submachine fill:#e4e9f2,stroke:#8695b3,color:#2a3142,font-weight:700;\",\n // A muted, dashed ghost node standing in for a target OUTSIDE the\n // current diagram (a sub-machine diagram's external edge).\n dark\n ? \"classDef external fill:#1c2434,stroke:#3a465c,color:#9aa6bd,stroke-dasharray:5 4;\"\n : \"classDef external fill:#eef1f7,stroke:#c2ccdb,color:#55607a,stroke-dasharray:5 4;\",\n ]\n }\n\n /**\n * Build one flowchart's mermaid source over `states` (a diagram scope):\n * the whole machine for the main flow (`opts.scope` unset), or one\n * sub-machine's own member states (`opts.scope` = that group's name).\n * A member state whose innermost group differs from `opts.scope`\n * collapses into a black box for ITS group (handles a sub-machine\n * nested inside another); a target outside `states` entirely renders as\n * a muted, dashed ghost node instead (only possible in a sub-machine\n * diagram — the main flow's `states` is every state, so nothing is ever\n * \"outside\" it).\n */\n function buildFlowSrc(states, opts) {\n const scope = opts && opts.scope\n const inSet = new Set(states.map((s) => s.name))\n const lines = [\"flowchart TD\"]\n\n const unitOf = (name) => {\n const s = byName[name]\n if (s && s.group && s.group !== scope) return { kind: \"box\", group: s.group }\n return { kind: \"node\", name }\n }\n const unitId = (name) => {\n const u = unitOf(name)\n return u.kind === \"box\" ? groupBoxId(groupIndexOf(u.group)) : idFor(name)\n }\n\n const emittedNodes = new Set()\n const emittedBoxes = new Set()\n const emittedGhosts = new Set()\n const nodeLine = (s) => ` ${idFor(s.name)}${shapeFor(s.kind, s.name)}:::${actorOf(s)}`\n const boxLine = (g, i) =>\n ` ${groupBoxId(i)}[\"${esc(g.name)}<br/><span class='sm-hint'>▸ ${g.states.length} state${\n g.states.length === 1 ? \"\" : \"s\"\n }</span>\"]:::submachine`\n const ghostLine = (s) => ` e${idFor(s.name)}${shapeFor(s.kind, s.name)}:::external`\n\n states.forEach((s) => {\n const u = unitOf(s.name)\n if (u.kind === \"node\") {\n if (emittedNodes.has(s.name)) return\n emittedNodes.add(s.name)\n lines.push(nodeLine(s))\n } else if (!emittedBoxes.has(u.group)) {\n emittedBoxes.add(u.group)\n const i = groupIndexOf(u.group)\n if (i >= 0) lines.push(boxLine(MODEL.groups[i], i))\n }\n })\n\n // Edges within this diagram's own state set, collapsed through box\n // units, deduped, and label-dropped when several patterns land on the\n // same box-level edge (a black box hides which one specifically).\n const solid = new Map()\n const dashed = new Map()\n const addEdge = (map, from, to, label) => {\n if (from === to) return\n const key = from + \" \" + to\n if (!map.has(key)) map.set(key, [])\n map.get(key).push(label)\n }\n states.forEach((s) => {\n ;(s.on || []).forEach((e) => {\n if (!inSet.has(e.to) || !byName[e.to]) return\n addEdge(solid, unitId(s.name), unitId(e.to), e.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) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[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, \""\")}\"`\n switch (kind) {\n case \"message\":\n return `([\"${label.replace(/\"/g, \""\")}\"])`\n case \"prompt\":\n return `[${l}]`\n case \"script\":\n return `{{${l}}}`\n case \"commit\":\n return `[[${l}]]`\n default:\n return `[${l}]`\n }\n }\n const mlabel = (s) => `\"${String(s).replace(/\"/g, \""\").replace(/\\|/g, \"|\")}\"`\n /**\n * The mermaid `classDef` lines for actor/ghost node fills, chosen per\n * theme so dark mode doesn't render washed-out light pastels.\n */\n function classDefs(dark) {\n return [\n dark\n ? \"classDef human fill:#3a2e12,stroke:#e0a83e,color:#f0c674;\"\n : \"classDef human fill:#fbeccd,stroke:#d99413,color:#5a3d0a;\",\n dark\n ? \"classDef agent fill:#26224a,stroke:#8b7cf0,color:#b6abf7;\"\n : \"classDef agent fill:#e5e2fb,stroke:#6d5ce0,color:#3a2c85;\",\n dark\n ? \"classDef check fill:#123433,stroke:#2dc4bc,color:#68dcd4;\"\n : \"classDef check fill:#d3f0ef,stroke:#16a39d,color:#0c524f;\",\n dark\n ? \"classDef commit fill:#3a1b24,stroke:#e05f7c,color:#f2a3b3;\"\n : \"classDef commit fill:#fbdfe4,stroke:#d84a68,color:#8a1f38;\",\n dark\n ? \"classDef unknown fill:#1c2434,stroke:#3a465c,color:#9aa6bd,stroke-dasharray:4 3;\"\n : \"classDef unknown fill:#eef1f7,stroke:#c2ccdb,color:#55607a,stroke-dasharray:4 3;\",\n // Sub-machine invocation: a muted slate box (not one of the actor\n // colours) so it still reads as its own distinct \"black-box\" kind,\n // without the harsh opaque-black fill clashing with a light theme.\n dark\n ? \"classDef submachine fill:#2a3142,stroke:#5a6b8c,color:#dbe4f5,font-weight:700;\"\n : \"classDef submachine fill:#e4e9f2,stroke:#8695b3,color:#2a3142,font-weight:700;\",\n // A muted, dashed ghost node standing in for a target OUTSIDE the\n // current diagram (a sub-machine diagram's external edge).\n dark\n ? \"classDef external fill:#1c2434,stroke:#3a465c,color:#9aa6bd,stroke-dasharray:5 4;\"\n : \"classDef external fill:#eef1f7,stroke:#c2ccdb,color:#55607a,stroke-dasharray:5 4;\",\n ]\n }\n\n /**\n * Build one flowchart's mermaid source over `states` (a diagram scope):\n * the whole machine for the main flow (`opts.scope` unset), or one\n * sub-machine's own member states (`opts.scope` = that group's name).\n * A member state whose innermost group differs from `opts.scope`\n * collapses into a black box for ITS group (handles a sub-machine\n * nested inside another); a target outside `states` entirely renders as\n * a muted, dashed ghost node instead (only possible in a sub-machine\n * diagram — the main flow's `states` is every state, so nothing is ever\n * \"outside\" it).\n */\n function buildFlowSrc(states, opts) {\n const scope = opts && opts.scope\n const inSet = new Set(states.map((s) => s.name))\n const lines = [\"flowchart TD\"]\n\n // 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:
|
|
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
|
-
|
|
204448
|
-
|
|
204449
|
-
|
|
204450
|
-
|
|
204451
|
-
|
|
204452
|
-
*
|
|
204453
|
-
|
|
204454
|
-
|
|
204455
|
-
|
|
204456
|
-
|
|
204457
|
-
|
|
204458
|
-
|
|
204459
|
-
|
|
204460
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
204736
|
-
|
|
204737
|
-
review-
|
|
204738
|
-
|
|
204739
|
-
|
|
204740
|
-
|
|
204741
|
-
|
|
204742
|
-
|
|
204743
|
-
|
|
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
|
-
/**
|
|
204800
|
-
|
|
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
|
-
/**
|
|
205005
|
-
|
|
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
|
|
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
|
|
205014
|
-
*
|
|
205015
|
-
* `
|
|
205016
|
-
*
|
|
205017
|
-
*
|
|
205018
|
-
*
|
|
205019
|
-
*
|
|
205020
|
-
* `
|
|
205021
|
-
*
|
|
205022
|
-
*
|
|
205023
|
-
* `
|
|
205024
|
-
*
|
|
205025
|
-
*
|
|
205026
|
-
*
|
|
205027
|
-
*
|
|
205028
|
-
*
|
|
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
|
|
205031
|
-
*
|
|
205032
|
-
*
|
|
205033
|
-
*
|
|
205034
|
-
*
|
|
205035
|
-
|
|
205036
|
-
|
|
205037
|
-
|
|
205038
|
-
|
|
205039
|
-
|
|
205040
|
-
|
|
205041
|
-
|
|
205042
|
-
|
|
205043
|
-
|
|
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(
|
|
205081
|
-
|
|
205082
|
-
|
|
205083
|
-
|
|
205084
|
-
|
|
205085
|
-
|
|
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 (
|
|
205097
|
-
*
|
|
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
|
|
205118
|
-
const initial = initialStateOf(
|
|
205119
|
-
|
|
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
|
|
205128
|
-
if (run.startParentHash === EMPTY_TREE) return yield* fail$2(/* @__PURE__ */ new Error(`gtd abandon: the process underway (resting at "${
|
|
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:
|
|
205800
|
+
from: restState,
|
|
205136
205801
|
head: run.startParentHash
|
|
205137
205802
|
}) + "\n");
|
|
205138
|
-
else write(`abandoned the process resting at "${
|
|
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.
|
|
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>` / `
|
|
205770
|
-
* `
|
|
205771
|
-
*
|
|
205772
|
-
*
|
|
205773
|
-
*
|
|
205774
|
-
*
|
|
205775
|
-
* `
|
|
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
|
|
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*
|
|
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
|