@pmelab/gtd 8.3.1 → 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 +260 -82
- package/bin/gtd +79 -28
- package/dist/gtd.bundle.mjs +1435 -876
- package/package.json +1 -1
- package/schema.json +147 -265
package/dist/gtd.bundle.mjs
CHANGED
|
@@ -189295,20 +189295,26 @@ const isCommitState = (state) => state.commit !== void 0;
|
|
|
189295
189295
|
const contentOf = (state) => state.script ?? state.prompt ?? state.message;
|
|
189296
189296
|
/** True when a rest at `state` should open the review checkout window (see `StateDef.reviewWindow`). Safe for an unknown state name (returns `false`). */
|
|
189297
189297
|
const isReviewWindowState = (def, state) => def.states[state]?.reviewWindow === true;
|
|
189298
|
-
/** 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. */
|
|
189299
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
|
+
};
|
|
189300
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`). */
|
|
189301
189306
|
const isRequireProgressState = (def, state) => def.states[state]?.requireProgress === true;
|
|
189302
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`). */
|
|
189303
189308
|
const isAnswerGateState = (def, state) => def.states[state]?.answerGate === true;
|
|
189304
|
-
/**
|
|
189305
|
-
|
|
189306
|
-
|
|
189307
|
-
|
|
189308
|
-
|
|
189309
|
-
|
|
189310
|
-
|
|
189311
|
-
|
|
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();
|
|
189312
189318
|
/** The ` → ` that separates a transition subject's source state from its target. */
|
|
189313
189319
|
const TRANSITION_SEP = " → ";
|
|
189314
189320
|
/**
|
|
@@ -189317,8 +189323,9 @@ const TRANSITION_SEP = " → ";
|
|
|
189317
189323
|
* state being ENTERED and `<from>` the state the authored changes were made
|
|
189318
189324
|
* in, so the subject reads as what this commit DID, not just where the machine
|
|
189319
189325
|
* is headed. `from` is optional: when it is omitted or equals `to` (a
|
|
189320
|
-
* self-loop, or a manual entry like `gtd
|
|
189321
|
-
* 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.
|
|
189322
189329
|
* `resolveState` reads back only `<to>` — the ` → ` prefix is human context.
|
|
189323
189330
|
*/
|
|
189324
189331
|
const stateSubject = (actor, to, from) => from === void 0 || from === to ? `gtd(${actor}): ${to}` : `gtd(${actor}): ${from}${TRANSITION_SEP}${to}`;
|
|
@@ -189351,17 +189358,8 @@ const parseStateSubject = (subject) => {
|
|
|
189351
189358
|
from
|
|
189352
189359
|
};
|
|
189353
189360
|
};
|
|
189354
|
-
/**
|
|
189355
|
-
|
|
189356
|
-
* is assumed — `validateDefinition` enforces this at config-load time).
|
|
189357
|
-
* Throws if none is declared: a caller invoking `resolveState`/`step`
|
|
189358
|
-
* against an unvalidated, malformed definition is a programmer error, not a
|
|
189359
|
-
* value this module tries to guess through.
|
|
189360
|
-
*/
|
|
189361
|
-
const initialStateOf = (def) => {
|
|
189362
|
-
for (const [name, state] of Object.entries(def.states)) if (state.initial === true) return name;
|
|
189363
|
-
throw new Error("workflow definition has no initial state");
|
|
189364
|
-
};
|
|
189361
|
+
/** The workflow's declared initial state (`entries.default` — required, so there is always a value). */
|
|
189362
|
+
const initialStateOf = (def) => def.entries.default;
|
|
189365
189363
|
/**
|
|
189366
189364
|
* Every actor declared by ANY state in the workflow — the closed-world
|
|
189367
189365
|
* vocabulary a parsed subject's actor is checked against by `resolveState`.
|
|
@@ -189505,6 +189503,54 @@ const applyRetry = (def, target, trace, visited = /* @__PURE__ */ new Set()) =>
|
|
|
189505
189503
|
return applyRetry(def, targetDef.retry.otherwise, trace, /* @__PURE__ */ new Set([...visited, target]));
|
|
189506
189504
|
};
|
|
189507
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
|
+
/**
|
|
189508
189554
|
* Decide what invoking `invoker` at `state` does — a pure decision, not an
|
|
189509
189555
|
* effect. Refusals: `invoker` isn't `state`'s declared actor (out-of-turn),
|
|
189510
189556
|
* or the tree is dirty and no `on` pattern matches (no-match, naming the
|
|
@@ -189568,12 +189614,33 @@ const CONTENT_KEYS$1 = [
|
|
|
189568
189614
|
"message",
|
|
189569
189615
|
"commit"
|
|
189570
189616
|
];
|
|
189571
|
-
/**
|
|
189572
|
-
|
|
189573
|
-
|
|
189574
|
-
|
|
189575
|
-
|
|
189576
|
-
|
|
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;
|
|
189577
189644
|
};
|
|
189578
189645
|
/** Exactly one of script/prompt/message/commit. */
|
|
189579
189646
|
const validateContentKind = (name, state) => {
|
|
@@ -189595,14 +189662,7 @@ const validateModel = (name, state) => {
|
|
|
189595
189662
|
if (isCommitState(state) && state.model !== void 0) errors.push(`state "${name}": a commit state cannot declare "model"`);
|
|
189596
189663
|
return errors;
|
|
189597
189664
|
};
|
|
189598
|
-
/** `
|
|
189599
|
-
const validateMemory = (name, state) => {
|
|
189600
|
-
const errors = [];
|
|
189601
|
-
if (state.memory !== void 0 && state.memory === "") errors.push(`state "${name}": "memory" must be a non-empty string`);
|
|
189602
|
-
if (isCommitState(state) && state.memory !== void 0) errors.push(`state "${name}": a commit state cannot declare "memory"`);
|
|
189603
|
-
return errors;
|
|
189604
|
-
};
|
|
189605
|
-
/** `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. */
|
|
189606
189666
|
const validateLabel = (name, state) => {
|
|
189607
189667
|
const errors = [];
|
|
189608
189668
|
if (state.label !== void 0 && state.label === "") errors.push(`state "${name}": "label" must be a non-empty string`);
|
|
@@ -189655,10 +189715,10 @@ const validateMode = (def, name, state) => {
|
|
|
189655
189715
|
return errors;
|
|
189656
189716
|
};
|
|
189657
189717
|
/**
|
|
189658
|
-
* `reviewWindow`/`reviewBase`, when present, are
|
|
189659
|
-
* enforces the type) —
|
|
189660
|
-
*
|
|
189661
|
-
* 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.
|
|
189662
189722
|
*/
|
|
189663
189723
|
const validateReviewWindow = (name, state) => {
|
|
189664
189724
|
if (!isCommitState(state)) return [];
|
|
@@ -189668,36 +189728,16 @@ const validateReviewWindow = (name, state) => {
|
|
|
189668
189728
|
return errors;
|
|
189669
189729
|
};
|
|
189670
189730
|
/**
|
|
189671
|
-
* `
|
|
189672
|
-
*
|
|
189673
|
-
*
|
|
189674
|
-
*
|
|
189675
|
-
*
|
|
189676
|
-
*
|
|
189677
|
-
* The at-most-one-state rule is checked separately, over the whole
|
|
189678
|
-
* definition (see `validateReviewEntryUniqueness`), since it isn't a
|
|
189679
|
-
* 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`.
|
|
189680
189737
|
*/
|
|
189681
|
-
const
|
|
189682
|
-
if (state.
|
|
189683
|
-
|
|
189684
|
-
if (isCommitState(state)) errors.push(`state "${name}": a commit state cannot declare "reviewEntry"`);
|
|
189685
|
-
if (state.initial === true) errors.push(`state "${name}": the initial state cannot declare "reviewEntry"`);
|
|
189686
|
-
return errors;
|
|
189687
|
-
};
|
|
189688
|
-
/**
|
|
189689
|
-
* `fixEntry`, when present, is forbidden on a commit state and on the initial
|
|
189690
|
-
* state — the same rule family as `reviewEntry` (see `validateReviewEntry`):
|
|
189691
|
-
* `gtd fix` REQUIRES resting at the initial state before it acts, so its entry
|
|
189692
|
-
* target must be a distinct state. The at-most-one-state rule is checked
|
|
189693
|
-
* separately over the whole definition (see `validateFixEntryUniqueness`).
|
|
189694
|
-
*/
|
|
189695
|
-
const validateFixEntry = (name, state) => {
|
|
189696
|
-
if (state.fixEntry === void 0) return [];
|
|
189697
|
-
const errors = [];
|
|
189698
|
-
if (isCommitState(state)) errors.push(`state "${name}": a commit state cannot declare "fixEntry"`);
|
|
189699
|
-
if (state.initial === true) errors.push(`state "${name}": the initial state cannot declare "fixEntry"`);
|
|
189700
|
-
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`] : [];
|
|
189701
189741
|
};
|
|
189702
189742
|
/**
|
|
189703
189743
|
* `requireProgress`, when present, is forbidden on a commit state (never at
|
|
@@ -189727,16 +189767,6 @@ const validateAnswerGate = (name, state) => {
|
|
|
189727
189767
|
if (state.file === void 0) errors.push(`state "${name}": "answerGate" requires "file"`);
|
|
189728
189768
|
return errors;
|
|
189729
189769
|
};
|
|
189730
|
-
/** At most one state may declare `reviewEntry: true` — `gtd review <commitish>` (`src/program.ts`) needs a single, unambiguous state name to enter (see `reviewEntryStateOf`). */
|
|
189731
|
-
const validateReviewEntryUniqueness = (def, names) => {
|
|
189732
|
-
const entryNames = names.filter((name) => def.states[name].reviewEntry === true);
|
|
189733
|
-
return entryNames.length > 1 ? [`at most one state may declare "reviewEntry" (found ${entryNames.length}: ${entryNames.join(", ")})`] : [];
|
|
189734
|
-
};
|
|
189735
|
-
/** At most one state may declare `fixEntry: true` — `gtd fix` (`src/program.ts`) needs a single, unambiguous state name to enter (see `fixEntryStateOf`). */
|
|
189736
|
-
const validateFixEntryUniqueness = (def, names) => {
|
|
189737
|
-
const entryNames = names.filter((name) => def.states[name].fixEntry === true);
|
|
189738
|
-
return entryNames.length > 1 ? [`at most one state may declare "fixEntry" (found ${entryNames.length}: ${entryNames.join(", ")})`] : [];
|
|
189739
|
-
};
|
|
189740
189770
|
/** Every `on` row parses, and its target names a defined state. */
|
|
189741
189771
|
const validateOnEdges = (name, state, names) => {
|
|
189742
189772
|
const errors = [];
|
|
@@ -189758,28 +189788,25 @@ const validateRetry = (name, state, names) => {
|
|
|
189758
189788
|
* Every state is reachable from an ENTRY ROOT by walking `on` targets and
|
|
189759
189789
|
* `retry.otherwise` redirects (a redirect ENTERS its `otherwise` state exactly
|
|
189760
189790
|
* like an `on` match enters its target — see `applyRetry` — so both are real
|
|
189761
|
-
* edges). The roots are
|
|
189762
|
-
*
|
|
189763
|
-
* reachable only from one of them is
|
|
189764
|
-
* (without seeding them, e.g. a
|
|
189765
|
-
*
|
|
189766
|
-
* states are skipped here (they
|
|
189767
|
-
* of their own). Only called
|
|
189768
|
-
*
|
|
189769
|
-
*
|
|
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.
|
|
189770
189801
|
*
|
|
189771
189802
|
* An unreachable state is an ERROR, not a warning: a workflow is bound to a
|
|
189772
|
-
* project and edited as a project-wide change, so "kept on purpose for
|
|
189773
|
-
*
|
|
189774
|
-
*
|
|
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
|
|
189775
189806
|
* config is exactly what load-time validation exists to catch.
|
|
189776
189807
|
*/
|
|
189777
189808
|
const validateReachability = (def, names) => {
|
|
189778
|
-
const roots = [
|
|
189779
|
-
names.find((name) => def.states[name].initial === true),
|
|
189780
|
-
reviewEntryStateOf(def),
|
|
189781
|
-
fixEntryStateOf(def)
|
|
189782
|
-
].filter((name) => name !== void 0);
|
|
189809
|
+
const roots = [def.entries.default, ...def.entries.manual];
|
|
189783
189810
|
const visited = new Set(roots);
|
|
189784
189811
|
const queue = [...roots];
|
|
189785
189812
|
while (queue.length > 0) {
|
|
@@ -189802,13 +189829,11 @@ const validateState = (def, name, names) => {
|
|
|
189802
189829
|
...validateOnEdges(name, state, names),
|
|
189803
189830
|
...validateRetry(name, state, names),
|
|
189804
189831
|
...validateModel(name, state),
|
|
189805
|
-
...validateMemory(name, state),
|
|
189806
189832
|
...validateLabel(name, state),
|
|
189807
189833
|
...validateFile(name, state),
|
|
189808
189834
|
...validateMode(def, name, state),
|
|
189809
189835
|
...validateReviewWindow(name, state),
|
|
189810
|
-
...
|
|
189811
|
-
...validateFixEntry(name, state),
|
|
189836
|
+
...validateReviewBaseTemplate(name, state),
|
|
189812
189837
|
...validateRequireProgress(name, state),
|
|
189813
189838
|
...validateAnswerGate(name, state)
|
|
189814
189839
|
];
|
|
@@ -189816,104 +189841,42 @@ const validateState = (def, name, names) => {
|
|
|
189816
189841
|
/**
|
|
189817
189842
|
* Validate a `WorkflowDefinition`, returning human-readable error strings
|
|
189818
189843
|
* (empty = valid). Pure — Phase 2 calls this at config-load time. Checks:
|
|
189819
|
-
* at least one state;
|
|
189820
|
-
*
|
|
189821
|
-
*
|
|
189822
|
-
*
|
|
189823
|
-
* content kind; commit states carry no `actor` and no `on`;
|
|
189824
|
-
* states carry an `actor`; every `on` pattern parses and every
|
|
189825
|
-
* 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
|
|
189826
189851
|
* non-negative integer; `model`, when present, is a non-empty string and is
|
|
189827
|
-
* never declared on a commit state; `
|
|
189852
|
+
* never declared on a commit state; `label`, when present, is a non-empty
|
|
189828
189853
|
* string and is never declared on a commit state (same rule family as
|
|
189829
|
-
* `model`); `
|
|
189830
|
-
*
|
|
189831
|
-
* present,
|
|
189832
|
-
*
|
|
189833
|
-
*
|
|
189834
|
-
* `
|
|
189835
|
-
*
|
|
189836
|
-
* `
|
|
189837
|
-
*
|
|
189838
|
-
*
|
|
189839
|
-
*
|
|
189840
|
-
*
|
|
189841
|
-
* `fixEntry` state) by walking `on` targets and `retry.otherwise` redirects
|
|
189842
|
-
* (checked only when the initial-state rule itself passed — see
|
|
189843
|
-
* `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`).
|
|
189844
189866
|
*/
|
|
189845
189867
|
const validateDefinition = (def) => {
|
|
189846
189868
|
const names = Object.keys(def.states);
|
|
189847
189869
|
if (names.length === 0) return ["workflow must declare at least one state"];
|
|
189848
|
-
const
|
|
189870
|
+
const entriesErrors = validateEntries(def, names);
|
|
189849
189871
|
return [
|
|
189850
|
-
...
|
|
189872
|
+
...entriesErrors,
|
|
189851
189873
|
...validateModes(def),
|
|
189852
189874
|
...names.flatMap((name) => validateState(def, name, names)),
|
|
189853
|
-
...
|
|
189854
|
-
...validateFixEntryUniqueness(def, names),
|
|
189855
|
-
...initialErrors.length === 0 ? validateReachability(def, names) : []
|
|
189875
|
+
...entriesErrors.length === 0 ? validateReachability(def, names) : []
|
|
189856
189876
|
];
|
|
189857
189877
|
};
|
|
189858
189878
|
//#endregion
|
|
189859
|
-
//#region src/
|
|
189860
|
-
/**
|
|
189861
|
-
* Compile-time SUB-MACHINE EXPANSION (Option C) — a raw → raw pre-pass that runs
|
|
189862
|
-
* at the very top of `compileWorkflowConfig` (src/PatternConfig.ts), BEFORE any
|
|
189863
|
-
* per-state compilation. It turns two OPTIONAL top-level `workflow:` keys —
|
|
189864
|
-
*
|
|
189865
|
-
* ```yaml
|
|
189866
|
-
* submachines: # reusable, parameterized clusters of states
|
|
189867
|
-
* <name>:
|
|
189868
|
-
* params: [<param>, ...]
|
|
189869
|
-
* states: { <local>: <StateDef, with `$param` placeholders> }
|
|
189870
|
-
* use: # invocations, each expanded into concrete states
|
|
189871
|
-
* - submachine: <name>
|
|
189872
|
-
* as: { <local>: <concreteName>, ... } # rename map (default: identity)
|
|
189873
|
-
* with: { <param>: <value>, ... } # bind each `$param`
|
|
189874
|
-
* set: { <local>: { <field>: <value> } } # extra per-instance state fields
|
|
189875
|
-
* ```
|
|
189876
|
-
*
|
|
189877
|
-
* — into ordinary concrete `states`, then strips `submachines`/`use` from its
|
|
189878
|
-
* output so the rest of the compiler (and `KNOWN_TOP_KEYS`) only ever sees the
|
|
189879
|
-
* familiar `{ vars?, states, modes? }` shape. The pure engine (`PatternMachine`)
|
|
189880
|
-
* is entirely unaware sub-machines exist: expansion is a source-authoring
|
|
189881
|
-
* convenience with NO runtime footprint. Aliasing
|
|
189882
|
-
* each invocation's local state names (via `as:`) to the exact concrete names a
|
|
189883
|
-
* hand-written workflow would use makes the expanded definition BYTE-IDENTICAL to
|
|
189884
|
-
* the flat one — the guarantee the bundled template's golden test pins
|
|
189885
|
-
* (src/workflows/templates.test.ts).
|
|
189886
|
-
*
|
|
189887
|
-
* ## Two uses of one mechanism
|
|
189888
|
-
*
|
|
189889
|
-
* - **Dedup** — a multi-instance sub-machine (a gate/loop authored once, invoked
|
|
189890
|
-
* several times with different `with:`/`as:`) removes duplicated states.
|
|
189891
|
-
* - **Encapsulation** — a single-instance sub-machine groups a complex cluster
|
|
189892
|
-
* into one named block for source comprehension. It expands 1:1, so it has no
|
|
189893
|
-
* runtime effect; its value is authoring clarity (and a hook for a future
|
|
189894
|
-
* viewer).
|
|
189895
|
-
*
|
|
189896
|
-
* ## Substitution
|
|
189897
|
-
*
|
|
189898
|
-
* A `$name` token is resolved from the invocation's `with:` bindings. It is
|
|
189899
|
-
* matched ONLY as a WHOLE string value — a field whose entire value is `$name`,
|
|
189900
|
-
* or an `on`/`retry.otherwise` target that is exactly `$name` — never as a
|
|
189901
|
-
* substring, so bash `$var`/`${var}` inside a `script:` and Eta
|
|
189902
|
-
* `<%= it.vars.x %>` inside any content are left untouched. Text that varies per
|
|
189903
|
-
* instance (a message, a describe sentence, a prompt) is therefore passed WHOLE
|
|
189904
|
-
* as a `$name` binding, not spliced in.
|
|
189905
|
-
*
|
|
189906
|
-
* An `on`/`retry.otherwise` target that names one of the sub-machine's OWN local
|
|
189907
|
-
* states is rewritten through the `as:` rename map (so intra-cluster edges point
|
|
189908
|
-
* at the renamed concrete states); any other target passes through verbatim (it
|
|
189909
|
-
* names a concrete top-level state, resolved after the merge). `on` PATTERN keys
|
|
189910
|
-
* are never touched (they are literal, exactly as the engine treats them).
|
|
189911
|
-
*
|
|
189912
|
-
* All findings are collected into the shared `errors` array the caller threads
|
|
189913
|
-
* through, aggregated with every other config-shape/validation finding into the
|
|
189914
|
-
* single error `compileWorkflowConfig` throws — a bad `submachines:`/`use:` fails
|
|
189915
|
-
* loudly at load time, never at step time.
|
|
189916
|
-
*/
|
|
189879
|
+
//#region src/Machines.ts
|
|
189917
189880
|
const isPlainObject$2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
189918
189881
|
const describeType$1 = (v) => {
|
|
189919
189882
|
if (v === null) return "null";
|
|
@@ -189922,256 +189885,325 @@ const describeType$1 = (v) => {
|
|
|
189922
189885
|
};
|
|
189923
189886
|
/** A whole-value parameter reference: a string that is EXACTLY `$name`. */
|
|
189924
189887
|
const PARAM_REF = /^\$([A-Za-z_][A-Za-z0-9_]*)$/;
|
|
189925
|
-
const
|
|
189926
|
-
|
|
189927
|
-
|
|
189928
|
-
|
|
189929
|
-
|
|
189930
|
-
|
|
189931
|
-
|
|
189932
|
-
|
|
189933
|
-
|
|
189934
|
-
|
|
189935
|
-
|
|
189936
|
-
if (
|
|
189937
|
-
const
|
|
189938
|
-
if (
|
|
189939
|
-
|
|
189940
|
-
return
|
|
189941
|
-
}
|
|
189942
|
-
if (typeof value !== "string") {
|
|
189943
|
-
errors.push(`${ctx.where}: param "$${m[1]}" used as a target must be a string`);
|
|
189944
|
-
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;
|
|
189945
189904
|
}
|
|
189946
|
-
return value;
|
|
189947
189905
|
}
|
|
189948
|
-
|
|
189949
|
-
|
|
189906
|
+
return {
|
|
189907
|
+
value,
|
|
189908
|
+
scope: callerPath
|
|
189909
|
+
};
|
|
189950
189910
|
};
|
|
189951
|
-
/**
|
|
189952
|
-
|
|
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) => {
|
|
189953
190059
|
if (typeof value !== "string") return value;
|
|
189954
190060
|
const m = PARAM_REF.exec(value);
|
|
189955
190061
|
if (!m) return value;
|
|
189956
|
-
const
|
|
189957
|
-
|
|
189958
|
-
|
|
190062
|
+
const name = m[1];
|
|
190063
|
+
const binding = instance.bindings[name];
|
|
190064
|
+
if (binding === void 0) {
|
|
190065
|
+
errors.push(`${where}: references unbound param "$${name}"`);
|
|
189959
190066
|
return value;
|
|
189960
190067
|
}
|
|
189961
|
-
return
|
|
190068
|
+
return binding.value;
|
|
190069
|
+
};
|
|
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;
|
|
189962
190077
|
};
|
|
189963
|
-
/** Rewrite the `on` mapping — each row's target (string, or `{ to, describe }`) — preserving pattern keys and declaration order. */
|
|
189964
|
-
const
|
|
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) => {
|
|
189965
190080
|
if (!isPlainObject$2(raw)) return raw;
|
|
189966
190081
|
const out = {};
|
|
189967
|
-
for (const [pattern, value] of Object.entries(raw)) if (typeof value === "string") out[pattern] =
|
|
189968
|
-
else if (isPlainObject$2(value))
|
|
189969
|
-
|
|
189970
|
-
if (typeof value["to"] === "string") next["to"] = resolveTarget(value["to"], ctx, errors);
|
|
189971
|
-
if (typeof value["describe"] === "string") next["describe"] = substituteScalar(value["describe"], ctx, errors);
|
|
189972
|
-
if (typeof value["action"] === "string") next["action"] = substituteScalar(value["action"], ctx, errors);
|
|
189973
|
-
out[pattern] = next;
|
|
189974
|
-
} 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;
|
|
189975
190085
|
return out;
|
|
189976
190086
|
};
|
|
189977
|
-
/** Rewrite a `retry` block's `otherwise` target (and pass `max` through). */
|
|
189978
|
-
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) => {
|
|
189979
190089
|
if (!isPlainObject$2(raw)) return raw;
|
|
189980
190090
|
const next = { ...raw };
|
|
189981
|
-
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"];
|
|
189982
190092
|
return next;
|
|
189983
190093
|
};
|
|
189984
|
-
/** Clone one
|
|
189985
|
-
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}`;
|
|
189986
190097
|
if (!isPlainObject$2(stateRaw)) {
|
|
189987
|
-
errors.push(`${
|
|
190098
|
+
errors.push(`${where}: state must be an object, got ${describeType$1(stateRaw)}`);
|
|
189988
190099
|
return {};
|
|
189989
190100
|
}
|
|
189990
190101
|
const out = {};
|
|
189991
|
-
for (const [key, value] of Object.entries(stateRaw))
|
|
189992
|
-
|
|
189993
|
-
|
|
189994
|
-
return out;
|
|
189995
|
-
};
|
|
189996
|
-
/** Validate the `submachines:` map's shape (findings pushed onto `errors`), returning it best-effort. */
|
|
189997
|
-
const readSubmachines = (raw, declared, errors) => {
|
|
189998
|
-
const submachines = isPlainObject$2(raw) ? raw : {};
|
|
189999
|
-
if (declared && !isPlainObject$2(raw)) errors.push(`"submachines" must be a mapping of name -> { params, states }, got ${describeType$1(raw)}`);
|
|
190000
|
-
for (const [name, sm] of Object.entries(submachines)) {
|
|
190001
|
-
if (!isPlainObject$2(sm)) {
|
|
190002
|
-
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);
|
|
190003
190105
|
continue;
|
|
190004
190106
|
}
|
|
190005
|
-
|
|
190006
|
-
|
|
190007
|
-
if (!isPlainObject$2(sm["states"]) || Object.keys(sm["states"]).length === 0) errors.push(`submachine "${name}": "states" must be a non-empty mapping`);
|
|
190008
|
-
}
|
|
190009
|
-
return submachines;
|
|
190010
|
-
};
|
|
190011
|
-
/** Read one invocation's `as:` rename map (local -> concrete), validating against the sub-machine's locals. */
|
|
190012
|
-
const readAs = (raw, smStates, where, errors) => {
|
|
190013
|
-
const as = {};
|
|
190014
|
-
if (raw === void 0) return as;
|
|
190015
|
-
if (!isPlainObject$2(raw)) {
|
|
190016
|
-
errors.push(`${where}: "as" must be a mapping of local -> concrete name`);
|
|
190017
|
-
return as;
|
|
190018
|
-
}
|
|
190019
|
-
for (const [local, concrete] of Object.entries(raw)) {
|
|
190020
|
-
if (typeof concrete !== "string") {
|
|
190021
|
-
errors.push(`${where}: "as.${local}" must be a string`);
|
|
190107
|
+
if (key === "retry") {
|
|
190108
|
+
out[key] = emitRetry(value, instance, where, instancesByPath, machinesRaw, errors);
|
|
190022
190109
|
continue;
|
|
190023
190110
|
}
|
|
190024
|
-
|
|
190025
|
-
|
|
190111
|
+
const resolved = substituteScalar(value, instance, where, errors);
|
|
190112
|
+
if (typeof value === "string" && PARAM_REF.test(value) && resolved === "") continue;
|
|
190113
|
+
out[key] = resolved;
|
|
190026
190114
|
}
|
|
190027
|
-
return
|
|
190115
|
+
return out;
|
|
190028
190116
|
};
|
|
190029
|
-
/**
|
|
190030
|
-
|
|
190031
|
-
|
|
190032
|
-
|
|
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
|
+
};
|
|
190033
190167
|
if (!isPlainObject$2(raw)) {
|
|
190034
|
-
errors.push(
|
|
190035
|
-
return
|
|
190036
|
-
}
|
|
190037
|
-
for (const [local, fields] of Object.entries(raw)) {
|
|
190038
|
-
if (!isPlainObject$2(fields)) {
|
|
190039
|
-
errors.push(`${where}: "set.${local}" must be an object of extra fields`);
|
|
190040
|
-
continue;
|
|
190041
|
-
}
|
|
190042
|
-
if (!(local in smStates)) errors.push(`${where}: "set" names "${local}", not a state of it`);
|
|
190043
|
-
set[local] = fields;
|
|
190044
|
-
}
|
|
190045
|
-
return set;
|
|
190046
|
-
};
|
|
190047
|
-
/** Validate one invocation's shape and resolve its named sub-machine; `undefined` (with a finding) on anything malformed. */
|
|
190048
|
-
const resolveInvocation = (invRaw, index, submachines, errors) => {
|
|
190049
|
-
const where = `use[${index}]`;
|
|
190050
|
-
if (!isPlainObject$2(invRaw)) {
|
|
190051
|
-
errors.push(`${where}: each invocation must be an object with a "submachine" key`);
|
|
190052
|
-
return;
|
|
190053
|
-
}
|
|
190054
|
-
const unknown = Object.keys(invRaw).filter((k) => !KNOWN_INVOCATION_KEYS.has(k));
|
|
190055
|
-
if (unknown.length > 0) errors.push(`${where}: unknown key(s) ${unknown.join(", ")}`);
|
|
190056
|
-
const smName = invRaw["submachine"];
|
|
190057
|
-
if (typeof smName !== "string") {
|
|
190058
|
-
errors.push(`${where}: "submachine" must be a string naming a declared sub-machine`);
|
|
190059
|
-
return;
|
|
190060
|
-
}
|
|
190061
|
-
const sm = submachines[smName];
|
|
190062
|
-
if (!isPlainObject$2(sm) || !isPlainObject$2(sm["states"])) {
|
|
190063
|
-
errors.push(`${where}: unknown sub-machine "${smName}"`);
|
|
190064
|
-
return;
|
|
190168
|
+
errors.push(`workflow must be an object, got ${describeType$1(raw)}`);
|
|
190169
|
+
return empty;
|
|
190065
190170
|
}
|
|
190066
|
-
|
|
190067
|
-
|
|
190068
|
-
|
|
190069
|
-
|
|
190070
|
-
};
|
|
190071
|
-
};
|
|
190072
|
-
/** Clone each local state into its concrete name, claiming names (collision → finding) and applying `set:` overrides. */
|
|
190073
|
-
const produceStates = (smStates, ctx, set, claimed, errors) => {
|
|
190074
|
-
const out = {};
|
|
190075
|
-
for (const [local, stateRaw] of Object.entries(smStates)) {
|
|
190076
|
-
const concrete = ctx.as[local] ?? local;
|
|
190077
|
-
if (claimed.has(concrete)) {
|
|
190078
|
-
errors.push(`${ctx.where}: expands to state "${concrete}", which collides with an existing state`);
|
|
190079
|
-
continue;
|
|
190080
|
-
}
|
|
190081
|
-
claimed.add(concrete);
|
|
190082
|
-
const state = expandState(stateRaw, ctx, errors);
|
|
190083
|
-
const overrides = set[local];
|
|
190084
|
-
out[concrete] = overrides !== void 0 ? {
|
|
190085
|
-
...state,
|
|
190086
|
-
...overrides
|
|
190087
|
-
} : 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;
|
|
190088
190175
|
}
|
|
190089
|
-
|
|
190090
|
-
|
|
190091
|
-
|
|
190092
|
-
|
|
190093
|
-
|
|
190094
|
-
|
|
190095
|
-
|
|
190096
|
-
|
|
190097
|
-
|
|
190098
|
-
|
|
190099
|
-
|
|
190100
|
-
const
|
|
190101
|
-
|
|
190102
|
-
const
|
|
190103
|
-
|
|
190104
|
-
|
|
190105
|
-
const
|
|
190106
|
-
|
|
190107
|
-
|
|
190108
|
-
|
|
190109
|
-
|
|
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
|
|
190110
190200
|
};
|
|
190111
|
-
return
|
|
190112
|
-
|
|
190113
|
-
|
|
190114
|
-
|
|
190115
|
-
|
|
190116
|
-
* declaring NEITHER key, is returned unchanged (so ordinary workflows are a
|
|
190117
|
-
* pure passthrough). Findings are pushed onto `errors`.
|
|
190118
|
-
*/
|
|
190119
|
-
const expandSubmachines = (raw, errors) => {
|
|
190120
|
-
if (!isPlainObject$2(raw)) return raw;
|
|
190121
|
-
if (raw["submachines"] === void 0 && raw["use"] === void 0) return raw;
|
|
190122
|
-
const submachines = readSubmachines(raw["submachines"], raw["submachines"] !== void 0, errors);
|
|
190123
|
-
const useRaw = raw["use"];
|
|
190124
|
-
const invocations = Array.isArray(useRaw) ? useRaw : [];
|
|
190125
|
-
if (raw["use"] !== void 0 && !Array.isArray(useRaw)) errors.push(`"use" must be an array of invocations, got ${describeType$1(useRaw)}`);
|
|
190126
|
-
const baseStates = isPlainObject$2(raw["states"]) ? { ...raw["states"] } : {};
|
|
190127
|
-
const claimed = new Set(Object.keys(baseStates));
|
|
190128
|
-
const expanded = {};
|
|
190129
|
-
invocations.forEach((invRaw, index) => {
|
|
190130
|
-
Object.assign(expanded, expandInvocation(invRaw, index, submachines, claimed, errors));
|
|
190131
|
-
});
|
|
190132
|
-
const result = {
|
|
190133
|
-
...raw,
|
|
190134
|
-
states: {
|
|
190135
|
-
...baseStates,
|
|
190136
|
-
...expanded
|
|
190137
|
-
}
|
|
190201
|
+
return {
|
|
190202
|
+
states,
|
|
190203
|
+
entries: { default: defaultResolved },
|
|
190204
|
+
tree,
|
|
190205
|
+
scopes
|
|
190138
190206
|
};
|
|
190139
|
-
delete result["submachines"];
|
|
190140
|
-
delete result["use"];
|
|
190141
|
-
return result;
|
|
190142
|
-
};
|
|
190143
|
-
/**
|
|
190144
|
-
* Describe a raw workflow's `use:` invocations as visualization GROUPS — which
|
|
190145
|
-
* concrete states each sub-machine invocation produced — WITHOUT expanding or
|
|
190146
|
-
* validating (a best-effort read for tooling like `gtd visualize`; malformed
|
|
190147
|
-
* entries are skipped, not reported). A workflow with no `use:` yields `[]`.
|
|
190148
|
-
* This is the only way to recover the sub-machine grouping after
|
|
190149
|
-
* `compileWorkflowConfig` has flattened it away.
|
|
190150
|
-
*/
|
|
190151
|
-
const collectGroups = (raw) => {
|
|
190152
|
-
if (!isPlainObject$2(raw)) return [];
|
|
190153
|
-
const submachines = isPlainObject$2(raw["submachines"]) ? raw["submachines"] : {};
|
|
190154
|
-
const use = Array.isArray(raw["use"]) ? raw["use"] : [];
|
|
190155
|
-
const smUseCount = /* @__PURE__ */ new Map();
|
|
190156
|
-
for (const inv of use) if (isPlainObject$2(inv) && typeof inv["submachine"] === "string") smUseCount.set(inv["submachine"], (smUseCount.get(inv["submachine"]) ?? 0) + 1);
|
|
190157
|
-
const groups = [];
|
|
190158
|
-
use.forEach((inv, index) => {
|
|
190159
|
-
if (!isPlainObject$2(inv)) return;
|
|
190160
|
-
const smName = inv["submachine"];
|
|
190161
|
-
if (typeof smName !== "string") return;
|
|
190162
|
-
const sm = submachines[smName];
|
|
190163
|
-
if (!isPlainObject$2(sm) || !isPlainObject$2(sm["states"])) return;
|
|
190164
|
-
const smStates = sm["states"];
|
|
190165
|
-
const as = isPlainObject$2(inv["as"]) ? inv["as"] : {};
|
|
190166
|
-
const states = Object.keys(smStates).map((local) => typeof as[local] === "string" ? as[local] : local);
|
|
190167
|
-
const name = typeof inv["name"] === "string" ? inv["name"] : (smUseCount.get(smName) ?? 0) > 1 ? `${smName}#${index}` : smName;
|
|
190168
|
-
groups.push({
|
|
190169
|
-
submachine: smName,
|
|
190170
|
-
name,
|
|
190171
|
-
states
|
|
190172
|
-
});
|
|
190173
|
-
});
|
|
190174
|
-
return groups;
|
|
190175
190207
|
};
|
|
190176
190208
|
//#endregion
|
|
190177
190209
|
//#region src/PatternConfig.ts
|
|
@@ -190193,24 +190225,35 @@ const collectGroups = (raw) => {
|
|
|
190193
190225
|
* <name>:
|
|
190194
190226
|
* format: <shell command> # at least one of format/validate
|
|
190195
190227
|
* validate: <shell command>
|
|
190196
|
-
*
|
|
190228
|
+
* entry:
|
|
190229
|
+
* default: <machine name> # which machine is the ROOT instance
|
|
190230
|
+
* machines:
|
|
190197
190231
|
* <name>:
|
|
190198
|
-
*
|
|
190199
|
-
*
|
|
190200
|
-
*
|
|
190201
|
-
*
|
|
190202
|
-
*
|
|
190203
|
-
*
|
|
190204
|
-
*
|
|
190205
|
-
*
|
|
190206
|
-
*
|
|
190207
|
-
*
|
|
190208
|
-
*
|
|
190209
|
-
*
|
|
190210
|
-
*
|
|
190211
|
-
*
|
|
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
|
|
190212
190250
|
* ```
|
|
190213
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
|
+
*
|
|
190214
190257
|
* ## `vars:` — one of `it.vars`'s three layers
|
|
190215
190258
|
*
|
|
190216
190259
|
* A sibling `vars:` key INSIDE the `workflow:` value declares the workflow's
|
|
@@ -190372,25 +190415,166 @@ const KNOWN_STATE_KEYS = /* @__PURE__ */ new Set([
|
|
|
190372
190415
|
"actor",
|
|
190373
190416
|
...CONTENT_KEYS,
|
|
190374
190417
|
"on",
|
|
190375
|
-
"initial",
|
|
190376
190418
|
"retry",
|
|
190377
190419
|
"model",
|
|
190378
|
-
"memory",
|
|
190379
190420
|
"label",
|
|
190380
190421
|
"file",
|
|
190381
190422
|
"mode",
|
|
190382
190423
|
"reviewWindow",
|
|
190383
190424
|
"reviewBase",
|
|
190384
|
-
"reviewEntry",
|
|
190385
|
-
"fixEntry",
|
|
190386
190425
|
"requireProgress",
|
|
190387
|
-
"answerGate"
|
|
190426
|
+
"answerGate",
|
|
190427
|
+
"entry"
|
|
190388
190428
|
]);
|
|
190389
190429
|
const KNOWN_TOP_KEYS = /* @__PURE__ */ new Set([
|
|
190430
|
+
"entry",
|
|
190431
|
+
"machines",
|
|
190390
190432
|
"vars",
|
|
190391
|
-
"states",
|
|
190392
190433
|
"modes"
|
|
190393
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
|
+
};
|
|
190394
190578
|
const formatErrors = (errors) => `workflow config:\n${errors.map((e) => ` - ${e}`).join("\n")}`;
|
|
190395
190579
|
/**
|
|
190396
190580
|
* Resolve one content string: inline text passes through verbatim; a file
|
|
@@ -190413,6 +190597,8 @@ const resolveContent = (value, configDir, where, errors) => {
|
|
|
190413
190597
|
return;
|
|
190414
190598
|
}
|
|
190415
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";
|
|
190416
190602
|
/**
|
|
190417
190603
|
* Inline every `./`/`../` content file reference in ONE raw `workflow:` value
|
|
190418
190604
|
* against `configDir` — the directory of the `.gtdrc` that DECLARED it — and
|
|
@@ -190421,39 +190607,53 @@ const resolveContent = (value, configDir, where, errors) => {
|
|
|
190421
190607
|
* Used by `src/Config.ts`'s `loadMerged` to resolve each config level's
|
|
190422
190608
|
* references against its OWN file's directory BEFORE the levels are
|
|
190423
190609
|
* deep-merged. The merge collapses every level into one anonymous object, so it
|
|
190424
|
-
* erases which file a given `
|
|
190425
|
-
* per level, is the only way `./gtd-prompts/x.md` in a parent `.gtdrc`
|
|
190426
|
-
* against the parent (not the cwd a child repo runs from).
|
|
190427
|
-
* is then invoked with `inlineFileRefs: false` on the
|
|
190428
|
-
*
|
|
190429
|
-
*
|
|
190430
|
-
*
|
|
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 —
|
|
190431
190620
|
* `compileWorkflowConfig`/`validateDefinition` own those findings; this pass
|
|
190432
190621
|
* only rewrites the strings it recognizes as file references, collecting a load
|
|
190433
190622
|
* error (via `resolveContent`) for any that is missing or unreadable.
|
|
190434
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
|
+
};
|
|
190435
190648
|
const inlineWorkflowFileRefs = (rawWorkflow, configDir, errors) => {
|
|
190436
190649
|
if (!isPlainObject$1(rawWorkflow)) return rawWorkflow;
|
|
190437
|
-
const
|
|
190438
|
-
if (!isPlainObject$1(
|
|
190439
|
-
const
|
|
190440
|
-
for (const [
|
|
190441
|
-
if (!isPlainObject$1(state)) {
|
|
190442
|
-
states[name] = state;
|
|
190443
|
-
continue;
|
|
190444
|
-
}
|
|
190445
|
-
const next = { ...state };
|
|
190446
|
-
for (const key of CONTENT_KEYS) {
|
|
190447
|
-
const value = state[key];
|
|
190448
|
-
if (typeof value !== "string" || !isFileReference(value)) continue;
|
|
190449
|
-
const resolved = resolveContent(value, configDir, `state "${name}" (${key})`, errors);
|
|
190450
|
-
if (resolved !== void 0) next[key] = resolved;
|
|
190451
|
-
}
|
|
190452
|
-
states[name] = next;
|
|
190453
|
-
}
|
|
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);
|
|
190454
190654
|
return {
|
|
190455
190655
|
...rawWorkflow,
|
|
190456
|
-
|
|
190656
|
+
machines
|
|
190457
190657
|
};
|
|
190458
190658
|
};
|
|
190459
190659
|
const KNOWN_EDGE_KEYS = /* @__PURE__ */ new Set([
|
|
@@ -190584,7 +190784,7 @@ const compileActor = (raw, name, errors) => {
|
|
|
190584
190784
|
}
|
|
190585
190785
|
return raw.actor;
|
|
190586
190786
|
};
|
|
190587
|
-
/** 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`. */
|
|
190588
190788
|
const compileModel = (raw, name, errors) => {
|
|
190589
190789
|
if (raw.model === void 0) return void 0;
|
|
190590
190790
|
if (typeof raw.model !== "string") {
|
|
@@ -190593,15 +190793,6 @@ const compileModel = (raw, name, errors) => {
|
|
|
190593
190793
|
}
|
|
190594
190794
|
return raw.model;
|
|
190595
190795
|
};
|
|
190596
|
-
/** 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`. */
|
|
190597
|
-
const compileMemory = (raw, name, errors) => {
|
|
190598
|
-
if (raw.memory === void 0) return void 0;
|
|
190599
|
-
if (typeof raw.memory !== "string") {
|
|
190600
|
-
errors.push(`state "${name}": "memory" must be a string`);
|
|
190601
|
-
return;
|
|
190602
|
-
}
|
|
190603
|
-
return raw.memory;
|
|
190604
|
-
};
|
|
190605
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`. */
|
|
190606
190797
|
const compileLabel = (raw, name, errors) => {
|
|
190607
190798
|
if (raw.label === void 0) return void 0;
|
|
@@ -190629,13 +190820,13 @@ const compileMode = (raw, name, errors) => {
|
|
|
190629
190820
|
}
|
|
190630
190821
|
return raw.mode;
|
|
190631
190822
|
};
|
|
190632
|
-
/** The `initial` field: `true` only when the raw value is the literal boolean `true`. */
|
|
190633
|
-
const compileInitial = (raw, name, errors) => compileBooleanFlag(raw, "initial", name, errors);
|
|
190634
190823
|
/**
|
|
190635
|
-
* A boolean state flag (`
|
|
190636
|
-
* when the raw value is the literal `true`; a
|
|
190637
|
-
* `false` (or absent) compiles away to
|
|
190638
|
-
* `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`.
|
|
190639
190830
|
*/
|
|
190640
190831
|
const compileBooleanFlag = (raw, key, name, errors) => {
|
|
190641
190832
|
const value = raw[key];
|
|
@@ -190646,6 +190837,21 @@ const compileBooleanFlag = (raw, key, name, errors) => {
|
|
|
190646
190837
|
}
|
|
190647
190838
|
return value === true ? true : void 0;
|
|
190648
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
|
+
};
|
|
190649
190855
|
const assembleContentFields = (content) => ({
|
|
190650
190856
|
...content.script !== void 0 ? { script: content.script } : {},
|
|
190651
190857
|
...content.prompt !== void 0 ? { prompt: content.prompt } : {},
|
|
@@ -190664,94 +190870,143 @@ const assembleStateDef = (parts) => ({
|
|
|
190664
190870
|
...definedEntries({
|
|
190665
190871
|
actor: parts.actor,
|
|
190666
190872
|
on: parts.on,
|
|
190667
|
-
initial: parts.initial,
|
|
190668
190873
|
retry: parts.retry,
|
|
190669
190874
|
model: parts.model,
|
|
190670
|
-
memory: parts.memory,
|
|
190671
190875
|
label: parts.label,
|
|
190672
190876
|
file: parts.file,
|
|
190673
190877
|
mode: parts.mode,
|
|
190674
190878
|
reviewWindow: parts.reviewWindow,
|
|
190675
190879
|
reviewBase: parts.reviewBase,
|
|
190676
|
-
reviewEntry: parts.reviewEntry,
|
|
190677
|
-
fixEntry: parts.fixEntry,
|
|
190678
190880
|
requireProgress: parts.requireProgress,
|
|
190679
190881
|
answerGate: parts.answerGate
|
|
190680
190882
|
}),
|
|
190681
190883
|
...assembleContentFields(parts.content)
|
|
190682
190884
|
});
|
|
190683
|
-
/**
|
|
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
|
+
*/
|
|
190684
190897
|
const compileState = (name, raw, configDir, errors, inlineFileRefs) => {
|
|
190685
190898
|
if (!isPlainObject$1(raw)) {
|
|
190686
190899
|
errors.push(`state "${name}": must be an object, got ${describeType(raw)}`);
|
|
190687
190900
|
return {};
|
|
190688
190901
|
}
|
|
190689
190902
|
const unknownKeys = Object.keys(raw).filter((k) => !KNOWN_STATE_KEYS.has(k));
|
|
190690
|
-
if (unknownKeys.length > 0) errors.push(`state "${name}": unknown key(s) ${unknownKeys
|
|
190691
|
-
|
|
190903
|
+
if (unknownKeys.length > 0) errors.push(`state "${name}": unknown key(s) ${formatUnknownKeys(unknownKeys, LEGACY_STATE_KEY_HINTS)}`);
|
|
190904
|
+
const def = assembleStateDef({
|
|
190692
190905
|
actor: compileActor(raw, name, errors),
|
|
190693
190906
|
content: compileContent(raw, name, configDir, errors, inlineFileRefs),
|
|
190694
190907
|
on: compileOn(raw.on, name, errors),
|
|
190695
|
-
initial: compileInitial(raw, name, errors),
|
|
190696
190908
|
retry: compileRetry(raw.retry, name, errors),
|
|
190697
190909
|
model: compileModel(raw, name, errors),
|
|
190698
|
-
memory: compileMemory(raw, name, errors),
|
|
190699
190910
|
label: compileLabel(raw, name, errors),
|
|
190700
190911
|
file: compileFile(raw, name, errors),
|
|
190701
190912
|
mode: compileMode(raw, name, errors),
|
|
190702
190913
|
reviewWindow: compileBooleanFlag(raw, "reviewWindow", name, errors),
|
|
190703
|
-
reviewBase:
|
|
190704
|
-
reviewEntry: compileBooleanFlag(raw, "reviewEntry", name, errors),
|
|
190705
|
-
fixEntry: compileBooleanFlag(raw, "fixEntry", name, errors),
|
|
190914
|
+
reviewBase: compileBooleanOrTemplateFlag(raw, "reviewBase", name, errors),
|
|
190706
190915
|
requireProgress: compileBooleanFlag(raw, "requireProgress", name, errors),
|
|
190707
190916
|
answerGate: compileBooleanFlag(raw, "answerGate", name, errors)
|
|
190708
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}"`);
|
|
190709
190933
|
};
|
|
190710
190934
|
/**
|
|
190711
190935
|
* Compile the raw, decoded `workflow:` YAML value into a `WorkflowDefinition`
|
|
190712
|
-
* plus the workflow's own compiled `vars:` map. `configDir`
|
|
190713
|
-
* file's own directory, used to resolve `./`/`../` file
|
|
190714
|
-
* is the already-compiled top-level `.gtdrc` `modes:` key
|
|
190715
|
-
* layered over the workflow's own `modes:` per half BEFORE
|
|
190716
|
-
* state may name a mode either layer declares.
|
|
190717
|
-
* `true`) inlines content file references against
|
|
190718
|
-
* path (`src/Config.ts`'s `loadMerged`) passes
|
|
190719
|
-
* inlined every reference per declaring file
|
|
190720
|
-
* `configDir` is then irrelevant. Throws a single
|
|
190721
|
-
* `"workflow config:\n - ..."`, one line per finding) on ANY
|
|
190722
|
-
* 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.
|
|
190723
190964
|
*/
|
|
190724
190965
|
const compileWorkflowConfig = (raw, configDir, rcModes, inlineFileRefs = true) => {
|
|
190725
190966
|
if (!isPlainObject$1(raw)) throw new Error(`workflow config: must be an object, got ${describeType(raw)}`);
|
|
190967
|
+
detectLegacyShape(raw);
|
|
190968
|
+
detectLegacyEntryKeys(raw);
|
|
190726
190969
|
const errors = [];
|
|
190727
|
-
const
|
|
190728
|
-
const cfg = isPlainObject$1(expanded) ? expanded : raw;
|
|
190729
|
-
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));
|
|
190730
190971
|
if (unknownTopKeys.length > 0) errors.push(`unknown top-level key(s) ${unknownTopKeys.join(", ")}`);
|
|
190731
|
-
const vars = compileVarsMap(
|
|
190732
|
-
const modes = mergeModes(compileModesMap(
|
|
190733
|
-
|
|
190734
|
-
|
|
190735
|
-
|
|
190736
|
-
throw new Error(formatErrors(errors));
|
|
190737
|
-
}
|
|
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));
|
|
190738
190977
|
const states = {};
|
|
190739
|
-
|
|
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
|
+
};
|
|
190740
190988
|
const definition = modes !== void 0 ? {
|
|
190741
190989
|
states,
|
|
190990
|
+
entries,
|
|
190742
190991
|
modes
|
|
190743
|
-
} : {
|
|
190992
|
+
} : {
|
|
190993
|
+
states,
|
|
190994
|
+
entries
|
|
190995
|
+
};
|
|
190996
|
+
assertScopesCoverStates(Object.keys(states), flattened.scopes, errors);
|
|
190744
190997
|
const definitionErrors = validateDefinition(definition);
|
|
190745
190998
|
const allErrors = Array.from(/* @__PURE__ */ new Set([...errors, ...definitionErrors]));
|
|
190746
190999
|
if (allErrors.length > 0) throw new Error(formatErrors(allErrors));
|
|
190747
191000
|
return {
|
|
190748
191001
|
definition,
|
|
190749
|
-
vars
|
|
191002
|
+
vars,
|
|
191003
|
+
tree: flattened.tree,
|
|
191004
|
+
scopes: flattened.scopes
|
|
190750
191005
|
};
|
|
190751
191006
|
};
|
|
190752
191007
|
//#endregion
|
|
190753
191008
|
//#region src/workflows/unified.yaml
|
|
190754
|
-
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";
|
|
190755
191010
|
//#endregion
|
|
190756
191011
|
//#region src/workflows/templates.ts
|
|
190757
191012
|
/**
|
|
@@ -190819,13 +191074,21 @@ const DEFAULT_WORKFLOW = compileWorkflowConfig((0, import_dist$1.parse)(UNIFIED_
|
|
|
190819
191074
|
/** The compiled built-in default workflow definition — the fallback when no `workflow:` is configured. */
|
|
190820
191075
|
const defaultWorkflowDefinition = DEFAULT_WORKFLOW.definition;
|
|
190821
191076
|
/**
|
|
190822
|
-
* The built-in default's
|
|
190823
|
-
*
|
|
190824
|
-
*
|
|
190825
|
-
*
|
|
190826
|
-
*
|
|
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.
|
|
190827
191082
|
*/
|
|
190828
|
-
const
|
|
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.
|
|
191090
|
+
*/
|
|
191091
|
+
const defaultStateScopes = DEFAULT_WORKFLOW.scopes;
|
|
190829
191092
|
/** The built-in default workflow's own declared `vars:` defaults (layer 1 of the merged `it.vars`). */
|
|
190830
191093
|
const defaultWorkflowVars = DEFAULT_WORKFLOW.vars;
|
|
190831
191094
|
/**
|
|
@@ -190919,187 +191182,172 @@ const modesJsonSchema = {
|
|
|
190919
191182
|
}
|
|
190920
191183
|
}
|
|
190921
191184
|
};
|
|
190922
|
-
/** One state's shape — mirrors `PatternConfig.ts`'s `KNOWN_STATE_KEYS` and per-field compilers. */
|
|
190923
|
-
const stateJsonSchema = {
|
|
190924
|
-
type: "object",
|
|
190925
|
-
description: "One workflow state. Declare exactly one content kind (script/prompt/message/commit). A commit state is final: no actor, no on.",
|
|
190926
|
-
additionalProperties: false,
|
|
190927
|
-
properties: {
|
|
190928
|
-
actor: {
|
|
190929
|
-
type: "string",
|
|
190930
|
-
description: "Who acts at this state. Required on every non-commit state; forbidden on a commit state."
|
|
190931
|
-
},
|
|
190932
|
-
script: {
|
|
190933
|
-
type: "string",
|
|
190934
|
-
description: "Content kind: a shell script (Eta template). The loop driver executes it verbatim via bash, then steps this state's actor."
|
|
190935
|
-
},
|
|
190936
|
-
prompt: {
|
|
190937
|
-
type: "string",
|
|
190938
|
-
description: "Content kind: an agent prompt (Eta template), emitted by `gtd next`."
|
|
190939
|
-
},
|
|
190940
|
-
message: {
|
|
190941
|
-
type: "string",
|
|
190942
|
-
description: "Content kind: a human-facing message (Eta template), emitted by `gtd next`."
|
|
190943
|
-
},
|
|
190944
|
-
commit: {
|
|
190945
|
-
type: "string",
|
|
190946
|
-
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."
|
|
190947
|
-
},
|
|
190948
|
-
on: {
|
|
190949
|
-
type: "object",
|
|
190950
|
-
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).",
|
|
190951
|
-
additionalProperties: { oneOf: [{
|
|
190952
|
-
type: "string",
|
|
190953
|
-
description: "The target state name."
|
|
190954
|
-
}, {
|
|
190955
|
-
type: "object",
|
|
190956
|
-
additionalProperties: false,
|
|
190957
|
-
required: ["to"],
|
|
190958
|
-
properties: {
|
|
190959
|
-
to: {
|
|
190960
|
-
type: "string",
|
|
190961
|
-
description: "The target state name."
|
|
190962
|
-
},
|
|
190963
|
-
describe: {
|
|
190964
|
-
type: "string",
|
|
190965
|
-
description: "Human-readable sentence describing where this change routes; surfaced verbatim (never Eta-rendered) to templates as it.edges[].describe."
|
|
190966
|
-
}
|
|
190967
|
-
}
|
|
190968
|
-
}] }
|
|
190969
|
-
},
|
|
190970
|
-
initial: {
|
|
190971
|
-
type: "boolean",
|
|
190972
|
-
description: "Exactly one state in the workflow must declare initial: true (and it must not be a commit state)."
|
|
190973
|
-
},
|
|
190974
|
-
retry: {
|
|
190975
|
-
type: "object",
|
|
190976
|
-
description: "Redirect transitions INTO this state once it has been entered `max` times in the current process.",
|
|
190977
|
-
additionalProperties: false,
|
|
190978
|
-
required: ["max", "otherwise"],
|
|
190979
|
-
properties: {
|
|
190980
|
-
max: {
|
|
190981
|
-
type: "integer",
|
|
190982
|
-
minimum: 0,
|
|
190983
|
-
description: "Entries allowed this process before redirecting."
|
|
190984
|
-
},
|
|
190985
|
-
otherwise: {
|
|
190986
|
-
type: "string",
|
|
190987
|
-
description: "Defined state to redirect to once over the cap."
|
|
190988
|
-
}
|
|
190989
|
-
}
|
|
190990
|
-
},
|
|
190991
|
-
model: {
|
|
190992
|
-
type: "string",
|
|
190993
|
-
description: "Opaque harness hint passed through `gtd next --json`/`gtd status --json` (e.g. \"smart\"). Never interpreted by gtd. Forbidden on a commit state."
|
|
190994
|
-
},
|
|
190995
|
-
memory: {
|
|
190996
|
-
type: "string",
|
|
190997
|
-
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."
|
|
190998
|
-
},
|
|
190999
|
-
file: {
|
|
191000
|
-
type: "string",
|
|
191001
|
-
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."
|
|
191002
|
-
},
|
|
191003
|
-
mode: {
|
|
191004
|
-
type: "string",
|
|
191005
|
-
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."
|
|
191006
|
-
},
|
|
191007
|
-
reviewWindow: {
|
|
191008
|
-
type: "boolean",
|
|
191009
|
-
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."
|
|
191010
|
-
},
|
|
191011
|
-
reviewBase: {
|
|
191012
|
-
type: "boolean",
|
|
191013
|
-
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."
|
|
191014
|
-
},
|
|
191015
|
-
reviewEntry: {
|
|
191016
|
-
type: "boolean",
|
|
191017
|
-
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."
|
|
191018
|
-
},
|
|
191019
|
-
fixEntry: {
|
|
191020
|
-
type: "boolean",
|
|
191021
|
-
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."
|
|
191022
|
-
},
|
|
191023
|
-
requireProgress: {
|
|
191024
|
-
type: "boolean",
|
|
191025
|
-
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."
|
|
191026
|
-
},
|
|
191027
|
-
answerGate: {
|
|
191028
|
-
type: "boolean",
|
|
191029
|
-
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."
|
|
191030
|
-
}
|
|
191031
|
-
}
|
|
191032
|
-
};
|
|
191033
191185
|
/** The whole `workflow:` value — see `PatternConfig.ts`'s module docstring for the authoritative schema. */
|
|
191034
191186
|
const workflowJsonSchema = {
|
|
191035
191187
|
type: "object",
|
|
191036
|
-
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).",
|
|
191037
191189
|
additionalProperties: false,
|
|
191038
|
-
required: ["
|
|
191190
|
+
required: ["entry", "machines"],
|
|
191039
191191
|
properties: {
|
|
191040
191192
|
vars: varsJsonSchema,
|
|
191041
191193
|
modes: modesJsonSchema,
|
|
191042
|
-
|
|
191194
|
+
entry: {
|
|
191043
191195
|
type: "object",
|
|
191044
|
-
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,
|
|
191045
191208
|
additionalProperties: {
|
|
191046
191209
|
type: "object",
|
|
191047
|
-
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).",
|
|
191048
191211
|
additionalProperties: false,
|
|
191049
|
-
required: ["states"],
|
|
191212
|
+
required: ["entry", "states"],
|
|
191050
191213
|
properties: {
|
|
191051
191214
|
params: {
|
|
191052
191215
|
type: "array",
|
|
191053
191216
|
items: { type: "string" },
|
|
191054
|
-
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."
|
|
191055
191218
|
},
|
|
191056
|
-
|
|
191057
|
-
type: "object",
|
|
191058
|
-
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:`.",
|
|
191059
|
-
minProperties: 1,
|
|
191060
|
-
additionalProperties: stateJsonSchema
|
|
191061
|
-
}
|
|
191062
|
-
}
|
|
191063
|
-
}
|
|
191064
|
-
},
|
|
191065
|
-
use: {
|
|
191066
|
-
type: "array",
|
|
191067
|
-
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.",
|
|
191068
|
-
items: {
|
|
191069
|
-
type: "object",
|
|
191070
|
-
additionalProperties: false,
|
|
191071
|
-
required: ["submachine"],
|
|
191072
|
-
properties: {
|
|
191073
|
-
submachine: {
|
|
191219
|
+
entry: {
|
|
191074
191220
|
type: "string",
|
|
191075
|
-
description: "
|
|
191221
|
+
description: "This machine's own default local (a local state name, or a reference key), resolved recursively."
|
|
191076
191222
|
},
|
|
191077
|
-
|
|
191223
|
+
model: {
|
|
191078
191224
|
type: "string",
|
|
191079
|
-
description: "
|
|
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."
|
|
191080
191226
|
},
|
|
191081
|
-
|
|
191082
|
-
type: "object",
|
|
191083
|
-
description: "Rename map: local state name -> concrete state name (identity when omitted).",
|
|
191084
|
-
additionalProperties: { type: "string" }
|
|
191085
|
-
},
|
|
191086
|
-
with: {
|
|
191087
|
-
type: "object",
|
|
191088
|
-
description: "Bindings for the sub-machine's `$param`s."
|
|
191089
|
-
},
|
|
191090
|
-
set: {
|
|
191227
|
+
states: {
|
|
191091
191228
|
type: "object",
|
|
191092
|
-
description: "
|
|
191093
|
-
|
|
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
|
+
}] }
|
|
191094
191348
|
}
|
|
191095
191349
|
}
|
|
191096
191350
|
}
|
|
191097
|
-
},
|
|
191098
|
-
states: {
|
|
191099
|
-
type: "object",
|
|
191100
|
-
description: "The workflow's named states. At least one; exactly one with initial: true.",
|
|
191101
|
-
minProperties: 1,
|
|
191102
|
-
additionalProperties: stateJsonSchema
|
|
191103
191351
|
}
|
|
191104
191352
|
}
|
|
191105
191353
|
};
|
|
@@ -191306,15 +191554,17 @@ const toOperations = (decoded, root) => {
|
|
|
191306
191554
|
} : defaultWorkflowDefinition,
|
|
191307
191555
|
workflowVars: defaultWorkflowVars,
|
|
191308
191556
|
rcVars,
|
|
191309
|
-
|
|
191557
|
+
machineTree: defaultMachineTree,
|
|
191558
|
+
stateScopes: defaultStateScopes
|
|
191310
191559
|
};
|
|
191311
191560
|
}
|
|
191312
|
-
const { definition, vars: workflowVars } = compileWorkflowConfig(decoded.workflow, root, rcModes, false);
|
|
191561
|
+
const { definition, vars: workflowVars, tree, scopes } = compileWorkflowConfig(decoded.workflow, root, rcModes, false);
|
|
191313
191562
|
return {
|
|
191314
191563
|
workflow: definition,
|
|
191315
191564
|
workflowVars,
|
|
191316
191565
|
rcVars,
|
|
191317
|
-
|
|
191566
|
+
machineTree: tree,
|
|
191567
|
+
stateScopes: scopes
|
|
191318
191568
|
};
|
|
191319
191569
|
};
|
|
191320
191570
|
const formatSchemaError = (e) => {
|
|
@@ -192079,7 +192329,7 @@ const HISTORY_TRAILER_RE = /^Gtd-History:[ \t]*(\S+)[ \t]*$/m;
|
|
|
192079
192329
|
/**
|
|
192080
192330
|
* Append a `Gtd-History: <hash>` trailer (after a blank line) to a commit
|
|
192081
192331
|
* `subject` — records the pre-squash tip commit hash so a later `gtd restore`
|
|
192082
|
-
* command can read it back. Mirrors `Edge.ts`'s `
|
|
192332
|
+
* command can read it back. Mirrors `Edge.ts`'s `withEntryTrailers`
|
|
192083
192333
|
* placement: the subject (first line) is untouched.
|
|
192084
192334
|
*/
|
|
192085
192335
|
const withHistoryTrailer = (subject, hash) => `${subject}\n\n${HISTORY_TRAILER_PREFIX}${hash}`;
|
|
@@ -192169,20 +192419,40 @@ const parseCostTrailers = (messages) => {
|
|
|
192169
192419
|
const REVIEW_BASE_TRAILER_PREFIX = "Gtd-Review-Base: ";
|
|
192170
192420
|
const REVIEW_BASE_TRAILER_RE = /^Gtd-Review-Base:[ \t]*(\S+)[ \t]*$/m;
|
|
192171
192421
|
/**
|
|
192172
|
-
*
|
|
192173
|
-
*
|
|
192174
|
-
*
|
|
192175
|
-
*
|
|
192176
|
-
* exactly like any other turn commit.
|
|
192177
|
-
*/
|
|
192178
|
-
const withReviewBaseTrailer = (subject, base) => `${subject}\n\n${REVIEW_BASE_TRAILER_PREFIX}${base}`;
|
|
192179
|
-
/**
|
|
192180
|
-
* The `Gtd-Review-Base: <hash>` trailer recorded on a `gtd review <commitish>`
|
|
192181
|
-
* entry commit (see `withReviewBaseTrailer`), or `undefined` when `message`
|
|
192182
|
-
* carries none. Read back by `computeProcessRun` — ONLY off the process's
|
|
192183
|
-
* 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.
|
|
192184
192426
|
*/
|
|
192185
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
|
+
};
|
|
192186
192456
|
/** The total token cost across the given entries (`0` when none). */
|
|
192187
192457
|
const totalCostOf = (entries) => entries.reduce((sum, entry) => sum + entry.cost, 0);
|
|
192188
192458
|
/** Per-model token totals, highest-cost first (ties broken by model name for a stable order). */
|
|
@@ -192203,11 +192473,31 @@ const normalizeStatus = (raw) => raw === "A" ? "A" : raw === "D" ? "D" : "M";
|
|
|
192203
192473
|
* visualize`'s best-effort current-state read, which prefers the review
|
|
192204
192474
|
* checkout window's saved head over a HEAD that may be mid-window-rewind (see
|
|
192205
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.
|
|
192206
192493
|
*/
|
|
192207
192494
|
const resolveRest = (atRef) => gen(function* () {
|
|
192208
192495
|
const git = yield* GitService;
|
|
192209
192496
|
const def = (yield* (yield* ConfigService).load).workflow;
|
|
192210
|
-
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);
|
|
192211
192501
|
const stateDef = def.states[state];
|
|
192212
192502
|
if (stateDef.actor === void 0) return yield* fail$2(/* @__PURE__ */ new Error(`gtd: resolved at commit state "${state}" — a process never rests there`));
|
|
192213
192503
|
return {
|
|
@@ -192223,6 +192513,23 @@ const pendingChanges = (git) => git.changedPaths().pipe(map$4((entries) => entri
|
|
|
192223
192513
|
path: e.path
|
|
192224
192514
|
}))));
|
|
192225
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
|
+
/**
|
|
192226
192533
|
* Walk first-parent history backward from HEAD while each commit's subject
|
|
192227
192534
|
* parses as `gtd(actor): state` (a v3 workflow commit) AND that state isn't
|
|
192228
192535
|
* the workflow's initial state; the walk stops — EXCLUDING that boundary
|
|
@@ -192247,7 +192554,8 @@ const computeProcessRun = (git, def) => gen(function* () {
|
|
|
192247
192554
|
startParentHash: EMPTY_TREE$2,
|
|
192248
192555
|
diffBase: EMPTY_TREE$2,
|
|
192249
192556
|
trace: [],
|
|
192250
|
-
costEntries: []
|
|
192557
|
+
costEntries: [],
|
|
192558
|
+
entryVars: {}
|
|
192251
192559
|
};
|
|
192252
192560
|
const initialState = initialStateOf(def);
|
|
192253
192561
|
const history = yield* git.commitHistory();
|
|
@@ -192259,37 +192567,81 @@ const computeProcessRun = (git, def) => gen(function* () {
|
|
|
192259
192567
|
}
|
|
192260
192568
|
const startIdx = i + 1;
|
|
192261
192569
|
const processCommits = history.slice(startIdx);
|
|
192262
|
-
const trace = processCommits.map((h) =>
|
|
192570
|
+
const trace = processCommits.map((h) => ({
|
|
192571
|
+
state: parseStateSubject(subjectOf$1(h.message)).state,
|
|
192572
|
+
hash: h.hash
|
|
192573
|
+
}));
|
|
192263
192574
|
const costEntries = parseCostTrailers(processCommits.map((h) => h.message));
|
|
192264
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);
|
|
192265
192578
|
return {
|
|
192266
|
-
startHash
|
|
192579
|
+
startHash,
|
|
192267
192580
|
startParentHash,
|
|
192268
|
-
diffBase:
|
|
192581
|
+
diffBase: reviewBaseOverride ?? startParentHash,
|
|
192269
192582
|
trace,
|
|
192270
|
-
costEntries
|
|
192583
|
+
costEntries,
|
|
192584
|
+
entryVars
|
|
192271
192585
|
};
|
|
192272
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
|
+
};
|
|
192273
192619
|
const PREFIX = "GTD_";
|
|
192274
192620
|
/**
|
|
192275
|
-
* Assemble the merged `it.vars` map every template sees, from
|
|
192621
|
+
* Assemble the merged `it.vars` map every template sees, from four layers
|
|
192276
192622
|
* (later wins): the active workflow's own declared `vars:` defaults
|
|
192277
192623
|
* (`ConfigOperations.workflowVars`), the top-level `.gtdrc` `vars:` key
|
|
192278
|
-
* (`ConfigOperations.rcVars`),
|
|
192279
|
-
*
|
|
192280
|
-
*
|
|
192281
|
-
* name
|
|
192282
|
-
*
|
|
192283
|
-
*
|
|
192284
|
-
*
|
|
192285
|
-
*
|
|
192286
|
-
*
|
|
192287
|
-
*
|
|
192288
|
-
|
|
192289
|
-
|
|
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) => {
|
|
192290
192641
|
const merged = {
|
|
192291
192642
|
...workflowVars,
|
|
192292
|
-
...rcVars
|
|
192643
|
+
...rcVars,
|
|
192644
|
+
...entryVars
|
|
192293
192645
|
};
|
|
192294
192646
|
for (const name of Object.keys(merged)) {
|
|
192295
192647
|
const value = env[PREFIX + name.toUpperCase()];
|
|
@@ -192415,18 +192767,6 @@ const renderModel = (stateDef, context) => try_({
|
|
|
192415
192767
|
catch: (e) => e instanceof Error ? e : new Error(String(e))
|
|
192416
192768
|
});
|
|
192417
192769
|
/**
|
|
192418
|
-
* Render a state's declared `memory:` scope label (if any) through the SAME
|
|
192419
|
-
* template context as its content/`model` — see `renderModel`'s doc comment;
|
|
192420
|
-
* a plain label (e.g. `"plan"`) passes through unchanged, while
|
|
192421
|
-
* `memory: "<%= it.vars.planScope %>"` resolves against the merged `it.vars`.
|
|
192422
|
-
* The render-failure semantics are identical (propagates as a thrown/rejected
|
|
192423
|
-
* error, same call site as `gtd next`/`gtd status`).
|
|
192424
|
-
*/
|
|
192425
|
-
const renderMemory = (stateDef, context) => try_({
|
|
192426
|
-
try: () => stateDef.memory !== void 0 ? renderStateTemplate(stateDef.memory, context) : void 0,
|
|
192427
|
-
catch: (e) => e instanceof Error ? e : new Error(String(e))
|
|
192428
|
-
});
|
|
192429
|
-
/**
|
|
192430
192770
|
* Render a state's declared `label:` (if any) through the SAME template
|
|
192431
192771
|
* context as its content/`model` — see `renderModel`'s doc comment; a plain
|
|
192432
192772
|
* label (e.g. `"planning"`) passes through unchanged, while
|
|
@@ -192449,8 +192789,16 @@ const renderFile = (stateDef, context) => try_({
|
|
|
192449
192789
|
catch: (e) => e instanceof Error ? e : new Error(String(e))
|
|
192450
192790
|
});
|
|
192451
192791
|
const omitUndefined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
|
|
192452
|
-
/**
|
|
192453
|
-
|
|
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* () {
|
|
192454
192802
|
const kind = contentKindOf(rest.stateDef);
|
|
192455
192803
|
if (kind === void 0) return yield* fail$2(/* @__PURE__ */ new Error(`state "${rest.state}" declares no content — invalid definition`));
|
|
192456
192804
|
const template = rest.stateDef.script ?? rest.stateDef.prompt ?? rest.stateDef.message ?? rest.stateDef.commit;
|
|
@@ -192460,7 +192808,7 @@ const renderRest = (rest, context) => gen(function* () {
|
|
|
192460
192808
|
});
|
|
192461
192809
|
const hints = {
|
|
192462
192810
|
model: yield* renderModel(rest.stateDef, context),
|
|
192463
|
-
memory
|
|
192811
|
+
memory,
|
|
192464
192812
|
label: yield* renderLabel(rest.stateDef, context),
|
|
192465
192813
|
file: yield* renderFile(rest.stateDef, context),
|
|
192466
192814
|
mode: rest.stateDef.mode
|
|
@@ -204390,7 +204738,7 @@ const worktreeLayerForRoot = (root) => WorktreeReader.Live.pipe(provide(Cwd.laye
|
|
|
204390
204738
|
const loadModeMap = async (root, onWarn) => {
|
|
204391
204739
|
try {
|
|
204392
204740
|
const config = await runPromise$1(ConfigService.pipe(flatMap$4((c) => c.load), provide$1(configLayerForRoot(root))));
|
|
204393
|
-
const vars = resolveVars(config.workflowVars, config.rcVars, process.env);
|
|
204741
|
+
const vars = resolveVars(config.workflowVars, config.rcVars, {}, process.env);
|
|
204394
204742
|
const { map, warnings } = buildFileModeMap(config.workflow, vars, root);
|
|
204395
204743
|
for (const warning of warnings) onWarn(warning);
|
|
204396
204744
|
return map;
|
|
@@ -204428,7 +204776,7 @@ const resolveSteeringFile = (root) => gen(function* () {
|
|
|
204428
204776
|
const envVars = yield* EnvVars;
|
|
204429
204777
|
const rest = yield* resolveRest();
|
|
204430
204778
|
const run = yield* computeProcessRun(git, rest.def);
|
|
204431
|
-
const vars = resolveVars(config.workflowVars, config.rcVars, envVars.all);
|
|
204779
|
+
const vars = resolveVars(config.workflowVars, config.rcVars, {}, envVars.all);
|
|
204432
204780
|
const context = yield* buildTemplateContext(git, worktree.read, rest.state, rest.actor, run, vars, rest.stateDef.on);
|
|
204433
204781
|
const file = yield* renderFile(rest.stateDef, context);
|
|
204434
204782
|
return {
|
|
@@ -204523,14 +204871,12 @@ const startLspServer = () => gen(function* () {
|
|
|
204523
204871
|
});
|
|
204524
204872
|
//#endregion
|
|
204525
204873
|
//#region src/visualize.html
|
|
204526
|
-
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";
|
|
204527
204875
|
//#endregion
|
|
204528
204876
|
//#region src/Visualize.ts
|
|
204529
204877
|
const FLAG_KEYS = [
|
|
204530
204878
|
"reviewWindow",
|
|
204531
204879
|
"reviewBase",
|
|
204532
|
-
"reviewEntry",
|
|
204533
|
-
"fixEntry",
|
|
204534
204880
|
"requireProgress",
|
|
204535
204881
|
"answerGate"
|
|
204536
204882
|
];
|
|
@@ -204546,19 +204892,18 @@ const stripUndefined = (o) => {
|
|
|
204546
204892
|
for (const key of Object.keys(o)) if (o[key] === void 0) delete o[key];
|
|
204547
204893
|
return o;
|
|
204548
204894
|
};
|
|
204549
|
-
/** 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. */
|
|
204550
|
-
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({
|
|
204551
204897
|
name,
|
|
204552
204898
|
actor: def.actor,
|
|
204553
204899
|
kind: contentKindOf(def) ?? "unknown",
|
|
204554
204900
|
content: contentOf(def),
|
|
204555
|
-
initial:
|
|
204901
|
+
initial: entries.default === name ? true : void 0,
|
|
204556
204902
|
model: def.model,
|
|
204557
|
-
memory: def.memory,
|
|
204558
204903
|
file: def.file,
|
|
204559
204904
|
mode: def.mode,
|
|
204560
204905
|
retry: def.retry,
|
|
204561
|
-
flags: flagsOf(def),
|
|
204906
|
+
flags: [...flagsOf(def), ...entries.manual.includes(name) ? ["entry"] : []],
|
|
204562
204907
|
on: onEdges.map(edgeToViz),
|
|
204563
204908
|
incoming,
|
|
204564
204909
|
group
|
|
@@ -204586,22 +204931,64 @@ const renderedOnByState = (workflow, vars) => new Map(Object.entries(workflow.st
|
|
|
204586
204931
|
describe
|
|
204587
204932
|
] : [renderedPattern, target];
|
|
204588
204933
|
})]));
|
|
204589
|
-
/**
|
|
204590
|
-
|
|
204591
|
-
|
|
204592
|
-
|
|
204593
|
-
|
|
204594
|
-
|
|
204595
|
-
*
|
|
204596
|
-
|
|
204597
|
-
|
|
204598
|
-
|
|
204599
|
-
|
|
204600
|
-
|
|
204601
|
-
|
|
204602
|
-
|
|
204603
|
-
|
|
204604
|
-
|
|
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
|
+
});
|
|
204605
204992
|
const renderedOn = renderedOnByState(workflow, vars);
|
|
204606
204993
|
const incoming = /* @__PURE__ */ new Map();
|
|
204607
204994
|
const addIncoming = (target, from, pattern) => {
|
|
@@ -204617,13 +205004,9 @@ const buildVizModel = (workflow, rawWorkflow, vars) => {
|
|
|
204617
205004
|
if (def.retry) addIncoming(def.retry.otherwise, name, `retry ×${def.retry.max}`);
|
|
204618
205005
|
}
|
|
204619
205006
|
return {
|
|
204620
|
-
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)),
|
|
204621
205008
|
initial: initialStateOf(workflow),
|
|
204622
|
-
groups
|
|
204623
|
-
name: g.name,
|
|
204624
|
-
submachine: g.submachine,
|
|
204625
|
-
states: [...g.states]
|
|
204626
|
-
})),
|
|
205009
|
+
groups,
|
|
204627
205010
|
vars
|
|
204628
205011
|
};
|
|
204629
205012
|
};
|
|
@@ -204874,16 +205257,16 @@ Commands:
|
|
|
204874
205257
|
(or squash) the one resulting transition. Pass
|
|
204875
205258
|
--cost=<n> (optionally --model=<name>) to record the
|
|
204876
205259
|
just-finished invocation's token cost and model on the
|
|
204877
|
-
turn commit (summed into it.processCost/processCostByModel)
|
|
204878
|
-
|
|
204879
|
-
|
|
204880
|
-
review-
|
|
204881
|
-
|
|
204882
|
-
|
|
204883
|
-
|
|
204884
|
-
|
|
204885
|
-
|
|
204886
|
-
|
|
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'
|
|
204887
205270
|
abandon End the process currently underway without completing it:
|
|
204888
205271
|
close any open review checkout window, then rewind HEAD to
|
|
204889
205272
|
the commit the process started from, keeping everything it
|
|
@@ -204915,6 +205298,15 @@ Options:
|
|
|
204915
205298
|
--no-open (gtd visualize only) do not open the browser
|
|
204916
205299
|
--cost=<n> (gtd step only) record the invocation's token cost
|
|
204917
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:
|
|
204918
205310
|
--version, -v Print version and exit
|
|
204919
205311
|
--help, -h Print this help and exit
|
|
204920
205312
|
`;
|
|
@@ -204939,8 +205331,66 @@ const cliErrorLine = (error) => {
|
|
|
204939
205331
|
const message = error instanceof Error ? error.message : String(error);
|
|
204940
205332
|
return /^gtd[: ]/.test(message) ? message : `gtd: ${message}`;
|
|
204941
205333
|
};
|
|
204942
|
-
/**
|
|
204943
|
-
|
|
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
|
+
};
|
|
204944
205394
|
const COST_FLAG = "--cost=";
|
|
204945
205395
|
/**
|
|
204946
205396
|
* Parse the optional `--cost=<n>` flag (only `gtd step` accepts it — see
|
|
@@ -204993,6 +205443,55 @@ const parseStepFlags = (argv, positional) => gen(function* () {
|
|
|
204993
205443
|
model
|
|
204994
205444
|
};
|
|
204995
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
|
+
});
|
|
204996
205495
|
/** Rejects extra positional arguments for a subcommand that takes none (`status`, `run`). */
|
|
204997
205496
|
const rejectExtraArgs = (command, argv) => {
|
|
204998
205497
|
const args = commandArgs(argv);
|
|
@@ -205056,7 +205555,11 @@ const renderOnEdgesOrFail = (onEdges, vars) => try_({
|
|
|
205056
205555
|
* path renders a DIFFERENT state, so it builds its own context inline
|
|
205057
205556
|
* instead of sharing this helper). `renderedOn` is the resting state's `on`
|
|
205058
205557
|
* edges already rendered against `it.vars` (`renderOnEdges`) — `next`/`status`
|
|
205059
|
-
* 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.
|
|
205060
205563
|
*/
|
|
205061
205564
|
const resolveRestContext = (git) => gen(function* () {
|
|
205062
205565
|
const config = yield* (yield* ConfigService).load;
|
|
@@ -205064,14 +205567,15 @@ const resolveRestContext = (git) => gen(function* () {
|
|
|
205064
205567
|
const envVars = yield* EnvVars;
|
|
205065
205568
|
const rest = yield* resolveRest();
|
|
205066
205569
|
const run = yield* computeProcessRun(git, rest.def);
|
|
205067
|
-
const vars = resolveVars(config.workflowVars, config.rcVars, envVars.all);
|
|
205570
|
+
const vars = resolveVars(config.workflowVars, config.rcVars, run.entryVars, envVars.all);
|
|
205068
205571
|
const renderedOn = yield* renderOnEdgesOrFail(rest.stateDef.on, vars);
|
|
205069
205572
|
const reviewBase = yield* reviewBaseHash(git, rest.def, run);
|
|
205070
205573
|
return {
|
|
205071
205574
|
rest,
|
|
205072
205575
|
run,
|
|
205073
205576
|
context: yield* buildTemplateContext(git, worktree.read, rest.state, rest.actor, run, vars, renderedOn, 0, void 0, reviewBase),
|
|
205074
|
-
renderedOn
|
|
205577
|
+
renderedOn,
|
|
205578
|
+
memory: memoryKeyFor(config.stateScopes, rest, run)
|
|
205075
205579
|
};
|
|
205076
205580
|
});
|
|
205077
205581
|
/** The user-facing message for a `step` refusal — out-of-turn names the awaited actor, no-match names every declared pattern. */
|
|
@@ -205091,11 +205595,11 @@ const stepAsActor = (invoker, cost, model) => gen(function* () {
|
|
|
205091
205595
|
const rest = yield* resolveRest();
|
|
205092
205596
|
const run = yield* computeProcessRun(git, rest.def);
|
|
205093
205597
|
const changes = yield* pendingChanges(git);
|
|
205094
|
-
const vars = resolveVars(config.workflowVars, config.rcVars, envVars.all);
|
|
205598
|
+
const vars = resolveVars(config.workflowVars, config.rcVars, run.entryVars, envVars.all);
|
|
205095
205599
|
const renderedOn = yield* renderOnEdgesOrFail(rest.stateDef.on, vars);
|
|
205096
205600
|
const decision = step(withRenderedOn(rest.def, rest.state, renderedOn), rest.state, invoker, {
|
|
205097
205601
|
changes,
|
|
205098
|
-
processTrace: run.trace
|
|
205602
|
+
processTrace: run.trace.map((entry) => entry.state)
|
|
205099
205603
|
});
|
|
205100
205604
|
if (decision.kind === "refusal") return yield* fail$2(new Error(formatStepRefusal(invoker, decision)));
|
|
205101
205605
|
if (decision.kind === "noop") return {
|
|
@@ -205144,88 +205648,94 @@ const reportStepResult = (result, json, write) => {
|
|
|
205144
205648
|
}) + "\n");
|
|
205145
205649
|
else write(result.subject !== null ? `committed: ${result.subject}\n` : `nothing to do at "${result.state}"\n`);
|
|
205146
205650
|
};
|
|
205147
|
-
/**
|
|
205148
|
-
|
|
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* () {
|
|
205149
205661
|
const args = commandArgs(argv);
|
|
205150
205662
|
if (args.length === 0) return yield* fail$2(/* @__PURE__ */ new Error("gtd step: missing actor argument"));
|
|
205151
205663
|
if (args.length > 1) return yield* fail$2(/* @__PURE__ */ new Error(`gtd step: too many arguments — expected one actor, got: ${args.join(", ")}`));
|
|
205152
|
-
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);
|
|
205153
205667
|
reportStepResult(result, json, write);
|
|
205154
205668
|
});
|
|
205155
205669
|
/**
|
|
205156
|
-
* `gtd
|
|
205157
|
-
*
|
|
205158
|
-
* `
|
|
205159
|
-
*
|
|
205160
|
-
*
|
|
205161
|
-
*
|
|
205162
|
-
*
|
|
205163
|
-
* `
|
|
205164
|
-
*
|
|
205165
|
-
*
|
|
205166
|
-
* `
|
|
205167
|
-
*
|
|
205168
|
-
*
|
|
205169
|
-
*
|
|
205170
|
-
*
|
|
205171
|
-
*
|
|
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
|
|
205172
205689
|
* plain non-gtd branch (the normal case) resolves there via the
|
|
205173
|
-
* inert-subject rule (see `resolveState`); a process already underway
|
|
205174
|
-
*
|
|
205175
|
-
*
|
|
205176
|
-
*
|
|
205177
|
-
*
|
|
205178
|
-
|
|
205179
|
-
|
|
205180
|
-
|
|
205181
|
-
|
|
205182
|
-
|
|
205183
|
-
|
|
205184
|
-
|
|
205185
|
-
|
|
205186
|
-
|
|
205187
|
-
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"));
|
|
205188
|
-
const entryState = reviewEntryStateOf(rest.def);
|
|
205189
|
-
if (entryState === void 0) return yield* fail$2(/* @__PURE__ */ new Error("gtd review: the active workflow declares no review entry state"));
|
|
205190
|
-
const resolvedBase = yield* git.resolveRef(commitish).pipe(mapError$1(() => /* @__PURE__ */ new Error(`gtd review: "${commitish}" does not resolve to a commit`)));
|
|
205191
|
-
if (resolvedBase === (yield* git.resolveRef("HEAD"))) return yield* fail$2(/* @__PURE__ */ new Error(`gtd review: "${commitish}" is HEAD — nothing to review`));
|
|
205192
|
-
if (!(yield* git.isAncestor(resolvedBase, "HEAD"))) return yield* fail$2(/* @__PURE__ */ new Error(`gtd review: "${commitish}" is not an ancestor of HEAD`));
|
|
205193
|
-
const subject = stateSubject("human", entryState);
|
|
205194
|
-
yield* git.commitAsIs(withReviewBaseTrailer(subject, resolvedBase));
|
|
205195
|
-
if (json) write(JSON.stringify({
|
|
205196
|
-
state: entryState,
|
|
205197
|
-
subject
|
|
205198
|
-
}) + "\n");
|
|
205199
|
-
else write(`committed: ${subject}\n`);
|
|
205200
|
-
});
|
|
205201
|
-
/**
|
|
205202
|
-
* `gtd fix`: start a brand NEW process at the active workflow's declared
|
|
205203
|
-
* `fixEntry: true` state (see `PatternMachine.fixEntryStateOf`), which goes
|
|
205204
|
-
* straight into repairing the current failing tests. Mirrors `runReviewCommand`
|
|
205205
|
-
* but simpler — it takes no argument and writes NO `Gtd-Review-Base:` trailer:
|
|
205206
|
-
* the fix process reviews its own fixes from the ordinary process start, so a
|
|
205207
|
-
* plain empty turn commit (`gtd(human): <fix-entry-state>`) is all it needs.
|
|
205208
|
-
* `computeProcessRun` then treats that commit as the process's first turn like
|
|
205209
|
-
* any other, and the shared health/review/squash tail runs unmodified.
|
|
205210
|
-
*
|
|
205211
|
-
* Any failure below is a plain refusal: nothing is written.
|
|
205212
|
-
*
|
|
205213
|
-
* a. The machine must currently rest at the workflow's INITIAL state — a
|
|
205214
|
-
* dedicated `gtd fix` entry is distinct from the "no active cycle" rest, and
|
|
205215
|
-
* a process already underway refuses (finish or abandon it first).
|
|
205216
|
-
* b. The working tree must be clean — the fixes belong to their own process.
|
|
205217
|
-
* c. The active workflow must declare a `fixEntry: true` state.
|
|
205218
|
-
*/
|
|
205219
|
-
const runFixCommand = (argv, json, write) => gen(function* () {
|
|
205220
|
-
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* () {
|
|
205221
205704
|
const git = yield* GitService;
|
|
205705
|
+
const config = yield* (yield* ConfigService).load;
|
|
205706
|
+
const envVars = yield* EnvVars;
|
|
205222
205707
|
const rest = yield* resolveRest();
|
|
205223
|
-
if (rest.state !== initialStateOf(rest.def)) return yield* fail$2(/* @__PURE__ */ new Error(
|
|
205224
|
-
|
|
205225
|
-
|
|
205226
|
-
|
|
205227
|
-
|
|
205228
|
-
|
|
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
|
+
}));
|
|
205229
205739
|
if (json) write(JSON.stringify({
|
|
205230
205740
|
state: entryState,
|
|
205231
205741
|
subject
|
|
@@ -205236,8 +205746,10 @@ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
|
205236
205746
|
/**
|
|
205237
205747
|
* `gtd abandon`: end the process currently underway WITHOUT completing it,
|
|
205238
205748
|
* returning the machine to the workflow's initial state — the recovery path out
|
|
205239
|
-
* of a process nobody is going to finish (
|
|
205240
|
-
*
|
|
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).
|
|
205241
205753
|
*
|
|
205242
205754
|
* NOTHING is discarded. The shared bracket in `makeProgram` has already closed
|
|
205243
205755
|
* any open review checkout window (so HEAD is the real head), and abandon then
|
|
@@ -205248,6 +205760,15 @@ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
|
205248
205760
|
* uncommitted changes for the human to keep, re-commit, or discard with
|
|
205249
205761
|
* ordinary git.
|
|
205250
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
|
+
*
|
|
205251
205772
|
* Idempotent: resting at the initial state (a plain non-gtd branch, or a
|
|
205252
205773
|
* just-squashed cycle) is a no-op SUCCESS, not a refusal — a recovery command
|
|
205253
205774
|
* that fails when there is nothing to recover is a worse tool. The one refusal
|
|
@@ -205257,9 +205778,10 @@ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
|
205257
205778
|
const runAbandonCommand = (argv, json, write) => gen(function* () {
|
|
205258
205779
|
yield* rejectExtraArgs("abandon", argv);
|
|
205259
205780
|
const git = yield* GitService;
|
|
205260
|
-
const
|
|
205261
|
-
const initial = initialStateOf(
|
|
205262
|
-
|
|
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) {
|
|
205263
205785
|
if (json) write(JSON.stringify({
|
|
205264
205786
|
state: initial,
|
|
205265
205787
|
abandoned: false
|
|
@@ -205267,18 +205789,18 @@ const runAbandonCommand = (argv, json, write) => gen(function* () {
|
|
|
205267
205789
|
else write(`no gtd process is underway (resting at "${initial}") — nothing to abandon\n`);
|
|
205268
205790
|
return;
|
|
205269
205791
|
}
|
|
205270
|
-
const
|
|
205271
|
-
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`));
|
|
205272
205794
|
yield* retainHistory(git, yield* git.resolveRef("HEAD"), run.startParentHash);
|
|
205273
205795
|
yield* git.mixedResetTo(run.startParentHash);
|
|
205274
205796
|
const subject = yield* git.lastCommitSubject();
|
|
205275
205797
|
if (json) write(JSON.stringify({
|
|
205276
205798
|
state: initial,
|
|
205277
205799
|
abandoned: true,
|
|
205278
|
-
from:
|
|
205800
|
+
from: restState,
|
|
205279
205801
|
head: run.startParentHash
|
|
205280
205802
|
}) + "\n");
|
|
205281
|
-
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`);
|
|
205282
205804
|
});
|
|
205283
205805
|
/**
|
|
205284
205806
|
* `gtd restore`: hard-reset HEAD back to the pre-squash tip a squash or
|
|
@@ -205351,8 +205873,8 @@ const nextPlainOutput = (rendered) => {
|
|
|
205351
205873
|
};
|
|
205352
205874
|
const runNextCommand = (json, write) => gen(function* () {
|
|
205353
205875
|
const git = yield* GitService;
|
|
205354
|
-
const { rest, context } = yield* resolveRestContext(git);
|
|
205355
|
-
const rendered = yield* renderRest(rest, context);
|
|
205876
|
+
const { rest, context, memory } = yield* resolveRestContext(git);
|
|
205877
|
+
const rendered = yield* renderRest(rest, context, memory);
|
|
205356
205878
|
write(json ? nextJsonOutput(rendered) : nextPlainOutput(rendered));
|
|
205357
205879
|
});
|
|
205358
205880
|
/**
|
|
@@ -205702,10 +206224,9 @@ const writeStatusPlain = (write, rest, statusChanges, next, model, memory, label
|
|
|
205702
206224
|
const runStatusCommand = (argv, json, write) => gen(function* () {
|
|
205703
206225
|
yield* rejectExtraArgs("status", argv);
|
|
205704
206226
|
const git = yield* GitService;
|
|
205705
|
-
const { rest, context, renderedOn } = yield* resolveRestContext(git);
|
|
206227
|
+
const { rest, context, renderedOn, memory } = yield* resolveRestContext(git);
|
|
205706
206228
|
const changes = yield* pendingChanges(git);
|
|
205707
206229
|
const model = yield* renderModel(rest.stateDef, context);
|
|
205708
|
-
const memory = yield* renderMemory(rest.stateDef, context);
|
|
205709
206230
|
const label = yield* renderLabel(rest.stateDef, context);
|
|
205710
206231
|
const file = yield* renderFile(rest.stateDef, context);
|
|
205711
206232
|
const statusChanges = computeStatusChanges(renderedOn, changes);
|
|
@@ -205775,8 +206296,9 @@ const computeCurrentState = (model) => gen(function* () {
|
|
|
205775
206296
|
const envVars = yield* EnvVars;
|
|
205776
206297
|
const reviewHead = yield* git.readRefOption(REVIEW_HEAD_REF);
|
|
205777
206298
|
const currentRest = yield* resolveRest(getOrUndefined(reviewHead));
|
|
206299
|
+
const run = yield* computeProcessRun(git, currentRest.def);
|
|
205778
206300
|
const changes = yield* pendingChanges(git);
|
|
205779
|
-
const vars = resolveVars(config.workflowVars, config.rcVars, envVars.all);
|
|
206301
|
+
const vars = resolveVars(config.workflowVars, config.rcVars, run.entryVars, envVars.all);
|
|
205780
206302
|
const renderedOn = yield* renderOnEdgesOrFail(currentRest.stateDef.on, vars);
|
|
205781
206303
|
const group = model.states.find((s) => s.name === currentRest.state)?.group;
|
|
205782
206304
|
return buildCurrentStateModel(currentRest, changes, renderedOn, group);
|
|
@@ -205799,10 +206321,10 @@ const runVisualizeCommand = (argv, json, write) => gen(function* () {
|
|
|
205799
206321
|
const opts = parseVisualizeOptions(rest);
|
|
205800
206322
|
if ("error" in opts) return yield* fail$2(new Error(opts.error));
|
|
205801
206323
|
const config = yield* (yield* ConfigService).load;
|
|
205802
|
-
const model = buildVizModel(config.workflow, config.
|
|
206324
|
+
const model = buildVizModel(config.workflow, config.machineTree, {
|
|
205803
206325
|
...config.workflowVars,
|
|
205804
206326
|
...config.rcVars
|
|
205805
|
-
});
|
|
206327
|
+
}, config.stateScopes);
|
|
205806
206328
|
if (json) {
|
|
205807
206329
|
write(JSON.stringify(model, null, 2) + "\n");
|
|
205808
206330
|
return;
|
|
@@ -205819,8 +206341,6 @@ const runVisualizeCommand = (argv, json, write) => gen(function* () {
|
|
|
205819
206341
|
});
|
|
205820
206342
|
const KNOWN_SUBCOMMANDS = [
|
|
205821
206343
|
"step",
|
|
205822
|
-
"review",
|
|
205823
|
-
"fix",
|
|
205824
206344
|
"abandon",
|
|
205825
206345
|
"restore",
|
|
205826
206346
|
"next",
|
|
@@ -205828,6 +206348,19 @@ const KNOWN_SUBCOMMANDS = [
|
|
|
205828
206348
|
"validate"
|
|
205829
206349
|
];
|
|
205830
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
|
+
/**
|
|
205831
206364
|
* `--version`/`-v`/`version` or `--help`/`-h`/`help`: short-circuits before any
|
|
205832
206365
|
* git or state work, so it works outside a repo too. The bare `version`/`help`
|
|
205833
206366
|
* subcommands are equivalent to their flag forms. Exported so main.ts can run
|
|
@@ -205855,6 +206388,8 @@ const requireKnownSubcommand = (sub, json, write) => {
|
|
|
205855
206388
|
if (!json) write(HELP_TEXT);
|
|
205856
206389
|
return fail$2(/* @__PURE__ */ new Error("gtd: missing command — see usage above (`gtd --help`)"));
|
|
205857
206390
|
}
|
|
206391
|
+
const removedMessage = REMOVED_SUBCOMMANDS[sub];
|
|
206392
|
+
if (removedMessage !== void 0) return fail$2(new Error(removedMessage));
|
|
205858
206393
|
if (!KNOWN_SUBCOMMANDS.includes(sub)) return fail$2(/* @__PURE__ */ new Error(`unknown command '${sub}'`));
|
|
205859
206394
|
return succeed$3(sub);
|
|
205860
206395
|
};
|
|
@@ -205888,12 +206423,10 @@ const assertInitLocation = (git, fs) => gen(function* () {
|
|
|
205888
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()}`));
|
|
205889
206424
|
return true;
|
|
205890
206425
|
});
|
|
205891
|
-
/** Dispatches to the named `run*Command` handler for every known subcommand. */
|
|
205892
|
-
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) => {
|
|
205893
206428
|
switch (sub) {
|
|
205894
|
-
case "step": return runStepCommand(argv, json, write, cost, model);
|
|
205895
|
-
case "review": return runReviewCommand(argv, json, write);
|
|
205896
|
-
case "fix": return runFixCommand(argv, json, write);
|
|
206429
|
+
case "step": return runStepCommand(argv, json, write, cost, model, entryFlags);
|
|
205897
206430
|
case "abandon": return runAbandonCommand(argv, json, write);
|
|
205898
206431
|
case "restore": return runRestoreCommand(argv, json, write);
|
|
205899
206432
|
case "next": return runNextCommand(json, write);
|
|
@@ -205902,6 +206435,22 @@ const dispatchKnownSubcommand = (sub, argv, json, write, cost, model) => {
|
|
|
205902
206435
|
}
|
|
205903
206436
|
};
|
|
205904
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
|
+
/**
|
|
205905
206454
|
* Factory that returns the gtd driver Effect with the given I/O options.
|
|
205906
206455
|
*
|
|
205907
206456
|
* The returned Effect requires `GitService | FileSystem.FileSystem |
|
|
@@ -205909,36 +206458,46 @@ const dispatchKnownSubcommand = (sub, argv, json, write, cost, model) => {
|
|
|
205909
206458
|
* this with no arguments; the test world supplies an in-memory layer set and
|
|
205910
206459
|
* captures stdout via the `write` callback.
|
|
205911
206460
|
*
|
|
205912
|
-
* v3 command surface: `step <actor>` / `
|
|
205913
|
-
* `
|
|
205914
|
-
*
|
|
205915
|
-
*
|
|
205916
|
-
*
|
|
205917
|
-
*
|
|
205918
|
-
* `
|
|
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.
|
|
205919
206472
|
*/
|
|
205920
206473
|
function makeProgram(opts = {}) {
|
|
205921
206474
|
const argv = opts.argv ?? process.argv;
|
|
205922
206475
|
const write = opts.write ?? ((chunk) => process.stdout.write(chunk));
|
|
205923
206476
|
const json = argv.includes("--json");
|
|
205924
|
-
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;
|
|
205925
206482
|
return gen(function* () {
|
|
205926
206483
|
if (runVersionOrHelp(argv, write)) return;
|
|
205927
206484
|
if (positional === "visualize") return yield* runVisualizeCommand(argv, json, write);
|
|
205928
|
-
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}=`));
|
|
205929
206486
|
if (unknownOption !== void 0) return yield* fail$2(/* @__PURE__ */ new Error(`gtd: unknown option '${unknownOption}' — see \`gtd --help\``));
|
|
205930
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"));
|
|
205931
206490
|
if (positional === "lsp") return yield* runLspCommand(argv, json);
|
|
205932
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
|
+
}
|
|
205933
206497
|
const sub = yield* requireKnownSubcommand(positional, json, write);
|
|
205934
206498
|
const git = yield* GitService;
|
|
205935
206499
|
const fs = yield* FileSystem;
|
|
205936
|
-
yield*
|
|
205937
|
-
yield* closeReviewWindow;
|
|
205938
|
-
const outcome = yield* either(dispatchKnownSubcommand(sub, argv, json, write, cost, model));
|
|
205939
|
-
const rearm = yield* either(openReviewWindow);
|
|
205940
|
-
if (isLeft(outcome)) return yield* fail$2(outcome.left);
|
|
205941
|
-
if (isLeft(rearm)) return yield* fail$2(rearm.left);
|
|
206500
|
+
yield* runInReviewWindowBracket(git, fs, dispatchKnownSubcommand(sub, argv, json, write, cost, model, entryFlags));
|
|
205942
206501
|
}).pipe(json ? catchAll((error) => sync$1(() => write(JSON.stringify({
|
|
205943
206502
|
state: "error",
|
|
205944
206503
|
prompt: error.message
|