@pmelab/gtd 3.0.0 → 3.1.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.
@@ -189422,6 +189422,37 @@ const validateRetry = (name, state, names) => {
189422
189422
  if (!Number.isInteger(state.retry.max) || state.retry.max < 0) errors.push(`state "${name}": retry.max must be a non-negative integer`);
189423
189423
  return errors;
189424
189424
  };
189425
+ /**
189426
+ * Every state is reachable from the initial state by walking `on` targets and
189427
+ * `retry.otherwise` redirects (a redirect ENTERS its `otherwise` state exactly
189428
+ * like an `on` match enters its target — see `applyRetry` — so both are real
189429
+ * edges). Plain BFS; targets naming undefined states are skipped here (they
189430
+ * are `validateOnEdges`/`validateRetry` findings of their own). Only called
189431
+ * when `validateInitial` found no problem: with zero or several `initial`
189432
+ * states there is no well-defined start to walk from, and reporting every
189433
+ * state as unreachable would bury the real finding.
189434
+ *
189435
+ * An unreachable state is an ERROR, not a warning: a workflow is bound to a
189436
+ * project and edited as a project-wide change, so "kept on purpose for manual
189437
+ * entry via a hand-authored subject" is not a supported authoring pattern —
189438
+ * an unreachable state is a typo'd rename or a leftover, and silently-dead
189439
+ * config is exactly what load-time validation exists to catch.
189440
+ */
189441
+ const validateReachability = (def, names) => {
189442
+ const initial = names.find((name) => def.states[name].initial === true);
189443
+ const visited = /* @__PURE__ */ new Set([initial]);
189444
+ const queue = [initial];
189445
+ while (queue.length > 0) {
189446
+ const state = def.states[queue.shift()];
189447
+ const targets = (state.on ?? []).map(([, target]) => target);
189448
+ if (state.retry !== void 0) targets.push(state.retry.otherwise);
189449
+ for (const target of targets) if (def.states[target] !== void 0 && !visited.has(target)) {
189450
+ visited.add(target);
189451
+ queue.push(target);
189452
+ }
189453
+ }
189454
+ return names.filter((name) => !visited.has(name)).map((name) => `state "${name}" is unreachable from initial state "${initial}" (no "on" target or "retry.otherwise" leads to it)`);
189455
+ };
189425
189456
  /** All per-state rule checkers, run over one state. */
189426
189457
  const validateState = (def, name, names) => {
189427
189458
  const state = def.states[name];
@@ -189449,12 +189480,20 @@ const validateState = (def, name, names) => {
189449
189480
  * never declared on a commit state; `file`, when present, is a non-empty
189450
189481
  * string and is never declared on a commit state; `mode`, when present, is
189451
189482
  * one of the closed vocabulary (`qa`/`review`), requires a sibling `file`,
189452
- * and is never declared on a commit state.
189483
+ * and is never declared on a commit state; every state is reachable from the
189484
+ * initial state by walking `on` targets and `retry.otherwise` redirects
189485
+ * (checked only when the initial-state rule itself passed — see
189486
+ * `validateReachability`).
189453
189487
  */
189454
189488
  const validateDefinition = (def) => {
189455
189489
  const names = Object.keys(def.states);
189456
189490
  if (names.length === 0) return ["workflow must declare at least one state"];
189457
- return [...validateInitial(def, names), ...names.flatMap((name) => validateState(def, name, names))];
189491
+ const initialErrors = validateInitial(def, names);
189492
+ return [
189493
+ ...initialErrors,
189494
+ ...names.flatMap((name) => validateState(def, name, names)),
189495
+ ...initialErrors.length === 0 ? validateReachability(def, names) : []
189496
+ ];
189458
189497
  };
189459
189498
  //#endregion
189460
189499
  //#region src/PatternConfig.ts
@@ -189811,6 +189850,22 @@ var Cwd = class Cwd extends Tag("Cwd")() {
189811
189850
  * `./Edge.js`'s `resolveVars`). There are no other blessed config keys (see
189812
189851
  * `./Config.js`'s module docstring for why).
189813
189852
  *
189853
+ * Both keys decode as `Schema.Unknown`: the shape is validated structurally
189854
+ * by the workflow compiler (`src/PatternConfig.ts`), not by effect/schema —
189855
+ * the shape is deep and recursive, and the compiler's errors carry rule
189856
+ * coordinates a flat schema error cannot. The `jsonSchema` ANNOTATIONS below
189857
+ * exist for one consumer only: `scripts/generate-schema.ts`, which publishes
189858
+ * `schema.json` for editor-side autocompletion/validation
189859
+ * (yaml-language-server et al). They describe the same shape the compiler
189860
+ * enforces, minus the rules JSON Schema cannot express (exactly one content
189861
+ * kind per state, exactly one `initial: true` across the workflow, `on`/
189862
+ * `retry.otherwise` targets naming defined states, reachability) — the
189863
+ * compiler stays the source of truth; the annotation is the editor's first
189864
+ * net, never a second validator to keep behaviorally in sync. When the
189865
+ * compiler's accepted shape changes (a new state key, a new content kind),
189866
+ * update the annotation here alongside `src/PatternConfig.ts`'s
189867
+ * `KNOWN_STATE_KEYS`.
189868
+ *
189814
189869
  * Kept in its own module, separate from `./Config.js`, so `scripts/generate-
189815
189870
  * schema.ts` (run via `jiti`, a plain TS-via-Babel loader with no bundler-
189816
189871
  * style pluggable per-extension loaders) can import JUST the schema without
@@ -189819,9 +189874,100 @@ var Cwd = class Cwd extends Tag("Cwd")() {
189819
189874
  * loader, something `jiti` has no equivalent for and doesn't need here: the
189820
189875
  * schema shape never depends on the bundled default workflow's content.
189821
189876
  */
189877
+ /** The `vars:` shape (top-level AND inside `workflow:`): a flat name -> scalar map (`compileVarsMap` coerces every scalar to a string). */
189878
+ const varsJsonSchema = {
189879
+ type: "object",
189880
+ description: "Flat name -> scalar map merged into every template's it.vars. Scalars are coerced to strings.",
189881
+ additionalProperties: { type: [
189882
+ "string",
189883
+ "number",
189884
+ "boolean"
189885
+ ] }
189886
+ };
189887
+ /** The whole `workflow:` value — see `PatternConfig.ts`'s module docstring for the authoritative schema. */
189888
+ const workflowJsonSchema = {
189889
+ type: "object",
189890
+ description: "The whole machine definition: named states (plus the workflow's own vars: defaults). Compiled and validated by gtd at load time; content strings starting with ./ or ../ are file references inlined from the config file's directory.",
189891
+ additionalProperties: false,
189892
+ required: ["states"],
189893
+ properties: {
189894
+ vars: varsJsonSchema,
189895
+ states: {
189896
+ type: "object",
189897
+ description: "The workflow's named states. At least one; exactly one with initial: true.",
189898
+ minProperties: 1,
189899
+ additionalProperties: {
189900
+ type: "object",
189901
+ description: "One workflow state. Declare exactly one content kind (script/prompt/message/commit). A commit state is final: no actor, no on.",
189902
+ additionalProperties: false,
189903
+ properties: {
189904
+ actor: {
189905
+ type: "string",
189906
+ description: "Who acts at this state. Required on every non-commit state; forbidden on a commit state."
189907
+ },
189908
+ script: {
189909
+ type: "string",
189910
+ description: "Content kind: a shell script (Eta template). `gtd run` executes it verbatim via bash, then steps this state's actor."
189911
+ },
189912
+ prompt: {
189913
+ type: "string",
189914
+ description: "Content kind: an agent prompt (Eta template), emitted by `gtd next`."
189915
+ },
189916
+ message: {
189917
+ type: "string",
189918
+ description: "Content kind: a human-facing message (Eta template), emitted by `gtd next`."
189919
+ },
189920
+ commit: {
189921
+ type: "string",
189922
+ 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."
189923
+ },
189924
+ on: {
189925
+ type: "object",
189926
+ 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).",
189927
+ additionalProperties: { type: "string" }
189928
+ },
189929
+ initial: {
189930
+ type: "boolean",
189931
+ description: "Exactly one state in the workflow must declare initial: true (and it must not be a commit state)."
189932
+ },
189933
+ retry: {
189934
+ type: "object",
189935
+ description: "Redirect transitions INTO this state once it has been entered `max` times in the current process.",
189936
+ additionalProperties: false,
189937
+ required: ["max", "otherwise"],
189938
+ properties: {
189939
+ max: {
189940
+ type: "integer",
189941
+ minimum: 0,
189942
+ description: "Entries allowed this process before redirecting."
189943
+ },
189944
+ otherwise: {
189945
+ type: "string",
189946
+ description: "Defined state to redirect to once over the cap."
189947
+ }
189948
+ }
189949
+ },
189950
+ model: {
189951
+ type: "string",
189952
+ description: "Opaque harness hint passed through `gtd next --json`/`gtd status --json` (e.g. \"smart\"). Never interpreted by gtd. Forbidden on a commit state."
189953
+ },
189954
+ file: {
189955
+ type: "string",
189956
+ 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."
189957
+ },
189958
+ mode: {
189959
+ type: "string",
189960
+ enum: ["qa", "review"],
189961
+ description: "The steering file's format, dispatched on by the LSP. Requires a sibling `file:`. Forbidden on a commit state."
189962
+ }
189963
+ }
189964
+ }
189965
+ }
189966
+ }
189967
+ };
189822
189968
  const ConfigSchema = Struct({
189823
- workflow: optional(Unknown),
189824
- vars: optional(Unknown)
189969
+ workflow: optional(Unknown.annotations({ jsonSchema: workflowJsonSchema })),
189970
+ vars: optional(Unknown.annotations({ jsonSchema: varsJsonSchema }))
189825
189971
  });
189826
189972
  //#endregion
189827
189973
  //#region src/Config.ts
@@ -364489,6 +364635,76 @@ const startLspServer = () => gen(function* () {
364489
364635
  });
364490
364636
  });
364491
364637
  //#endregion
364638
+ //#region src/Mermaid.ts
364639
+ /**
364640
+ * `gtd mermaid`: a pure emitter of the active workflow's SHAPE as Mermaid
364641
+ * `stateDiagram-v2` source — one node per state, the `[*] -->` initial-state
364642
+ * marker, one edge per declared `on` row (labeled with its raw pattern
364643
+ * string), `--> [*]` for every commit state (final, no outgoing edges), and
364644
+ * one `note right of` per rest naming its actor/content-kind/retry cap. No
364645
+ * git, no Effect, no template rendering: same purity discipline as
364646
+ * `PatternMachine.ts` — a plain function of an already-`validateDefinition`d
364647
+ * `WorkflowDefinition` (see `Config.ts`'s `toOperations`/`compileWorkflowConfig`
364648
+ * — every `WorkflowDefinition` this module ever sees has already been
364649
+ * validated, so an `on` target or `retry.otherwise` is trusted to name a
364650
+ * defined state rather than re-checked here).
364651
+ */
364652
+ /**
364653
+ * Mermaid identifiers must avoid quotes/newlines inside a quoted label —
364654
+ * collapse both so the label stays on one line and the diagram stays
364655
+ * parseable even if a state name or `on` pattern happens to carry either.
364656
+ */
364657
+ const escapeLabel = (text) => text.replace(/"/g, "'").replace(/\r?\n/g, " ");
364658
+ /**
364659
+ * A Mermaid-safe node id for one state name: every non-word character folds
364660
+ * to `_` (state names may carry hyphens — `todo-validating`,
364661
+ * `review-deciding` — which Mermaid's bare identifier grammar doesn't
364662
+ * guarantee), and a leading digit gets an `s_` prefix (Mermaid ids can't
364663
+ * start with one). The exact original name still reaches the diagram via
364664
+ * this alias's `state "<name>" as <alias>` declaration.
364665
+ */
364666
+ const aliasFor = (name) => {
364667
+ const folded = name.replace(/[^A-Za-z0-9_]/g, "_");
364668
+ return /^[0-9]/.test(folded) ? `s_${folded}` : folded;
364669
+ };
364670
+ /** One rest's actor/content-kind/retry-cap summary, for a `note right of` line. */
364671
+ const stateNote = (state) => {
364672
+ const parts = [state.actor, contentKindOf(state)].filter((p) => p !== void 0);
364673
+ const retry = state.retry !== void 0 ? [`retry ${state.retry.max}→${state.retry.otherwise}`] : [];
364674
+ return [...parts, ...retry].join(" · ");
364675
+ };
364676
+ /**
364677
+ * Render `def`'s shape as Mermaid `stateDiagram-v2` source. One
364678
+ * `state "<name>" as <alias>` declaration per state up front (so every
364679
+ * transition/note below can reference the safe alias while the rendered
364680
+ * diagram still shows the exact declared name), then the initial-state
364681
+ * marker, then one edge per `on` row (declaration order — same "first match
364682
+ * wins" order the engine itself evaluates), then `<alias> --> [*]` for every
364683
+ * commit state, then one actor/content-kind/retry note per rest.
364684
+ */
364685
+ const renderMermaid = (def) => {
364686
+ const names = Object.keys(def.states);
364687
+ const alias = new Map(names.map((name) => [name, aliasFor(name)]));
364688
+ const lines = ["stateDiagram-v2"];
364689
+ for (const name of names) lines.push(` state "${escapeLabel(name)}" as ${alias.get(name)}`);
364690
+ lines.push(` [*] --> ${alias.get(initialStateOf(def))}`);
364691
+ for (const name of names) {
364692
+ const state = def.states[name];
364693
+ const from = alias.get(name);
364694
+ if (isCommitState(state)) {
364695
+ lines.push(` ${from} --> [*]`);
364696
+ continue;
364697
+ }
364698
+ for (const [pattern, target] of state.on ?? []) lines.push(` ${from} --> ${alias.get(target)} : ${escapeLabel(pattern)}`);
364699
+ }
364700
+ for (const name of names) {
364701
+ const state = def.states[name];
364702
+ if (isCommitState(state)) continue;
364703
+ lines.push(` note right of ${alias.get(name)} : ${escapeLabel(stateNote(state))}`);
364704
+ }
364705
+ return lines.join("\n") + "\n";
364706
+ };
364707
+ //#endregion
364492
364708
  //#region src/program.ts
364493
364709
  const _require = createRequire(import.meta.url);
364494
364710
  const GTD_VERSION = _require("../package.json").version;
@@ -364504,6 +364720,8 @@ Commands:
364504
364720
  actor (the built-in script driver)
364505
364721
  status Print the resolved rest's state/actor and which declared
364506
364722
  pattern (if any) each pending change matches (no mutation)
364723
+ mermaid Print the active workflow's shape as Mermaid
364724
+ stateDiagram-v2 source (no mutation)
364507
364725
  format <file> Format a markdown file in place
364508
364726
  lsp Start the LSP server for .gtd/ steering files (stdio)
364509
364727
 
@@ -364714,11 +364932,27 @@ const runStatusCommand = (argv, json, write) => gen(function* () {
364714
364932
  if (json) writeStatusJson(write, rest, statusChanges, model, file);
364715
364933
  else writeStatusPlain(write, rest, statusChanges, model, file);
364716
364934
  });
364935
+ /**
364936
+ * `gtd mermaid`: pure emitter of the active workflow's SHAPE (not the
364937
+ * resolved rest) as Mermaid `stateDiagram-v2` source — see `src/Mermaid.ts`.
364938
+ * Needs only `ConfigService` (no HEAD resolution, no rendering), but is
364939
+ * dispatched alongside `next`/`status` since it still depends on the active
364940
+ * `.gtdrc` — unlike `format`/`lsp`, which need neither git nor config — so it
364941
+ * goes through the same repository-root guard and auto-init. Rejects
364942
+ * `--json`: there is no structured shape to emit beyond the Mermaid source
364943
+ * itself.
364944
+ */
364945
+ const runMermaidCommand = (argv, json, write) => gen(function* () {
364946
+ if (json) return yield* fail$2(/* @__PURE__ */ new Error("gtd mermaid does not accept --json"));
364947
+ yield* rejectExtraArgs("mermaid", argv);
364948
+ write(renderMermaid((yield* ConfigService).workflow));
364949
+ });
364717
364950
  const KNOWN_SUBCOMMANDS = [
364718
364951
  "step",
364719
364952
  "next",
364720
364953
  "run",
364721
- "status"
364954
+ "status",
364955
+ "mermaid"
364722
364956
  ];
364723
364957
  /**
364724
364958
  * `--version`/`-v` or `--help`/`-h`: short-circuits before any git or state
@@ -364768,6 +365002,7 @@ const dispatchKnownSubcommand = (sub, argv, json, write) => {
364768
365002
  case "next": return runNextCommand(json, write);
364769
365003
  case "run": return runRunCommand(argv, json, write);
364770
365004
  case "status": return runStatusCommand(argv, json, write);
365005
+ case "mermaid": return runMermaidCommand(argv, json, write);
364771
365006
  }
364772
365007
  };
364773
365008
  /**
package/docs/cli.md CHANGED
@@ -13,6 +13,8 @@ Commands:
13
13
  actor (the built-in script driver)
14
14
  status Print the resolved rest's state/actor and which declared
15
15
  pattern (if any) each pending change matches (no mutation)
16
+ mermaid Print the active workflow's shape as Mermaid
17
+ stateDiagram-v2 source (no mutation)
16
18
  format <file> Format a markdown file in place
17
19
  lsp Start the LSP server for .gtd/ steering files (stdio)
18
20
 
@@ -175,6 +177,46 @@ never `null`, otherwise):
175
177
 
176
178
  `gtd status` takes no arguments — extra positional args are rejected.
177
179
 
180
+ ## `gtd mermaid`
181
+
182
+ Pure emitter of the active workflow's **shape** — not the resolved rest — as
183
+ Mermaid [`stateDiagram-v2`](https://mermaid.js.org/syntax/stateDiagram.html)
184
+ source (see `src/Mermaid.ts`): one node per declared state, the `[*] -->`
185
+ initial-state marker, one edge per declared `on` row labeled with its raw
186
+ pattern string (same declaration order the engine itself evaluates), a `--> [*]`
187
+ edge for every commit state (final, no outgoing edges — see
188
+ [STATES.md §8](../STATES.md#8-the-squash-lifecycle)), and one `note right of`
189
+ per rest naming its actor, content kind, and retry cap (e.g.
190
+ `agent · prompt · retry 3→escalate`). No git, no HEAD resolution, no template
191
+ rendering — purely a function of the compiled `WorkflowDefinition`, so its
192
+ output is identical regardless of the current process/branch state.
193
+
194
+ ```
195
+ $ gtd mermaid
196
+ stateDiagram-v2
197
+ state "idle" as idle
198
+ state "grilling" as grilling
199
+ ...
200
+ [*] --> idle
201
+ idle --> grilling : * **
202
+ ...
203
+ note right of idle : human · message
204
+ ...
205
+ ```
206
+
207
+ Pipe it straight into a `.md`/`.mmd` file, a GitHub issue/PR description, or any
208
+ Mermaid-aware renderer (GitHub, GitLab, VS Code, Obsidian, the
209
+ [Mermaid Live Editor](https://mermaid.live)) to get a diagram of a custom
210
+ `.gtdrc` `workflow:` with no hand-maintained docs required.
211
+
212
+ State names are aliased to Mermaid-safe identifiers (non-word characters fold to
213
+ `_`; a digit-led name gets an `s_` prefix) via a `state "<name>" as <alias>`
214
+ declaration up front, so a hyphenated name like `todo-validating` still displays
215
+ with its exact declared spelling. Rejects `--json` (exit 1,
216
+ `gtd mermaid does not accept --json`) — there is no structured shape to emit
217
+ beyond the Mermaid source itself — and takes no arguments (extra positional args
218
+ are rejected).
219
+
178
220
  ## `gtd format <file>`
179
221
 
180
222
  Formats a markdown file in place with a bundled prettier (`parser: "markdown"`,
@@ -249,7 +291,7 @@ the plain-text one.
249
291
  green run, and the check's `"C"` pattern never fires. Gitignore every path
250
292
  your scripts write before wiring gtd into a repo.
251
293
  - **Repository root invocation.** Every state subcommand (`step`/`next`/`run`/
252
- `status`) must run from the git repository root — the workflow, pending
253
- changes, and process history are resolved against the process cwd.
294
+ `status`/`mermaid`) must run from the git repository root — the workflow,
295
+ pending changes, and process history are resolved against the process cwd.
254
296
  `--help`/`--version`, `format`, and `lsp` skip this guard entirely (and any
255
297
  git/`.gtdrc` dependency along with it).
@@ -261,6 +261,21 @@ workflow config:
261
261
  - state "idle": "on" target "nowhere" is not a defined state
262
262
  ```
263
263
 
264
+ `validateDefinition`'s findings include the **semantic graph checks**: every
265
+ `on` target and `retry.otherwise` must name a defined state, and every state
266
+ must be **reachable** from the initial state by walking `on` targets and
267
+ `retry.otherwise` redirects — an unreachable state (typically a typo'd rename or
268
+ a leftover) is a load error like any other, since a workflow is bound to a
269
+ project and edited as a project-wide change.
270
+
271
+ Many of these problems never reach gtd at all if your editor validates against
272
+ the published schema: `schema.json` fully types the `workflow:` key (state
273
+ shape, content kinds, `on`/`retry` structure, the `mode` vocabulary), so a
274
+ yaml-language-server-style editor flags unknown keys and wrong types as you
275
+ type. The rules JSON Schema cannot express — exactly one content kind, exactly
276
+ one `initial: true`, targets naming defined states, reachability — remain the
277
+ compiler's job at load time.
278
+
264
279
  Other load failures:
265
280
 
266
281
  - **Parse errors** (malformed YAML/JSON) — message includes the offending
@@ -0,0 +1,437 @@
1
+ # Research: static validation of workflow definitions
2
+
3
+ > Status: PARTIALLY IMPLEMENTED. Scope decision: a workflow is bound to a
4
+ > project and edited as a project-wide change (never mid-process), so the
5
+ > mid-flight/backward-compat concerns in §6 don't apply, and no warning channel
6
+ > is needed — implemented checks are plain errors. Landed: the full `workflow:`
7
+ > typing in `schema.json` (§6.1, via `jsonSchema` annotations in
8
+ > `src/ConfigSchema.ts`) and the unreachable-state error (§3.1, as
9
+ > `validateReachability` in `src/PatternMachine.ts` — invalid `on`/
10
+ > `retry.otherwise` targets were already errors). Everything else in this
11
+ > document is deliberately on hold.
12
+
13
+ ## 1. What is validated today (baseline)
14
+
15
+ Two layers run at config-load time, both aggregate-all-findings-then-throw
16
+ (`compileWorkflowConfig` merges its shape errors with `validateDefinition`'s
17
+ findings into one error — a bad config fails loudly at load, never at step
18
+ time):
19
+
20
+ - **Config shape** (`src/PatternConfig.ts`): unknown top-level/state/retry keys,
21
+ wrong types, unreadable `./` file references, non-scalar `vars:` values.
22
+ - **Definition rules** (`src/PatternMachine.ts` `validateDefinition`): at least
23
+ one state; exactly one non-commit `initial`; exactly one content kind per
24
+ state; commit states carry no `actor`/`on`/`model`/`file`/`mode`; non-commit
25
+ states carry an `actor`; every `on` pattern parses; every `on` target and
26
+ `retry.otherwise` names a defined state; `retry.max` is a non-negative
27
+ integer; `file`/`model` non-empty; `mode` ∈ {qa, review} and requires `file`.
28
+
29
+ Every one of these checks is **local** — a rule over one state (or one field) at
30
+ a time. Nothing today looks at the definition as a _graph_. The v2 design doc
31
+ (`configurable-state-machine.md` §3.11) planned exactly that class of checks
32
+ (totality, reachability, ambiguity, livelock bounds); the v3 rewrite shipped
33
+ without them.
34
+
35
+ ## 2. The gap: definitions that validate clean but are broken
36
+
37
+ Each of these passes today's validation:
38
+
39
+ 1. **Unreachable state** — defined, valid, but no `on` target or
40
+ `retry.otherwise` ever names it (typically a rename that missed the edges).
41
+ It silently never runs.
42
+ 2. **Dead-end state** — a non-commit state with no `on` map (or an empty one).
43
+ Once entered, every step is a no-op (clean) or refusal (dirty) forever; the
44
+ process can never leave.
45
+ 3. **Trap cycle** — a group of states that reach each other but from which no
46
+ terminal (a commit state, or re-entry into the initial state — the bundled
47
+ default's only "end") is reachable. The process runs but can never complete.
48
+ 4. **Unbounded agent loop (livelock)** — a cycle consisting only of agent/check
49
+ states with no `retry` cap on any member. The default's `fixing ↔ checking`
50
+ loop is safe only because `fixing` carries
51
+ `retry: {max: 3, otherwise: escalate}`; delete that key and the machine
52
+ validates clean but can ping-pong forever with no human in the loop.
53
+ 5. **Shadowed `on` row** — `on` is first-match-wins, so
54
+ `{"* **": a, "A .gtd/FORMAT.md": b}` never routes to `b`: the catch-all
55
+ subsumes the specific row. Dead rows are silent today.
56
+ 6. **`retry.otherwise` cycle / self-reference** — `applyRetry` terminates at
57
+ runtime via its `visited` guard, but the guard's "accept the repeated target
58
+ as final" behavior is almost certainly not what the author meant; the config
59
+ is accepted silently.
60
+ 7. **Pointless `retry`** — retry redirection applies when a state is entered _as
61
+ a transition target_; a `retry` on a state nothing targets never fires.
62
+ 8. **Template errors discovered at step time** — Eta syntax errors in
63
+ `prompt`/`script`/`message`/`commit`/`file` templates surface only when that
64
+ state is first rendered, contradicting the "fails at load, never at step
65
+ time" contract everything else honors.
66
+ 9. **The documented `vars` ↔ `on` desync** (`default.yaml`'s KNOWN LIMITATION) —
67
+ `on` patterns are literal strings while `file:` and content templates read
68
+ `it.vars.todoFile` etc.; repointing a var moves what templates read/write
69
+ without moving what patterns match.
70
+
71
+ ## 3. Algorithm survey
72
+
73
+ The good news: a gtd workflow is a small, explicit, finite directed graph (the
74
+ bundled default: 12 nodes, ~25 edges; a custom one is the same order of
75
+ magnitude). Every algorithm below is textbook material and costs microseconds at
76
+ this size — there is no need for a model checker, SAT solver, or symbolic
77
+ anything. The edge set for all graph checks is `on` targets ∪ `retry.otherwise`
78
+ targets.
79
+
80
+ ### 3.1 Reachability — accessible states (BFS/DFS from the initial state)
81
+
82
+ The classic "useless states" removal from automata theory, first half: a state
83
+ is _accessible_ iff some path from the start state reaches it. One BFS/DFS from
84
+ the `initial` state over the edge set, O(V+E). Anything not visited is finding
85
+ #1 (unreachable).
86
+
87
+ **gtd nuance:** history is an attributed state trace, and `resolveState` accepts
88
+ _any_ hand-authored `gtd(<actor>): <state>` subject naming a defined non-commit
89
+ state and a declared actor — so an edge-unreachable state is still _enterable_
90
+ by writing a commit by hand. That makes unreachability a **warning**, not a hard
91
+ error: it is almost always a typo'd rename, but a deliberate "manual entry
92
+ point" state is expressible and legal. (It also means an unreachable state's
93
+ `actor` still widens the closed-world actor vocabulary — one more reason the
94
+ finding deserves surfacing.)
95
+
96
+ ### 3.2 Co-reachability — can-complete (reverse BFS from terminals)
97
+
98
+ The second half of the same textbook construction: a state is _co-accessible_
99
+ iff a terminal is reachable from it; an automaton restricted to accessible ∩
100
+ co-accessible states is called _trim_. Reverse the edges, BFS from the terminal
101
+ set, flag unvisited states.
102
+
103
+ **gtd's terminal set** is `commit` states ∪ the `initial` state — the bundled
104
+ default has no commit state at all; its cycle "ends" by re-entering `idle` (the
105
+ process-boundary rule in `computeProcessRun`). A reachable state that is not
106
+ co-accessible is a trap (finding #3); a state with no outgoing edges at all
107
+ (finding #2) is the degenerate case but deserves its own, more specific message.
108
+
109
+ This is also exactly what **workflow-net soundness** (van der Aalst; the Woflan
110
+ validator) checks in the Petri-net world: _option to complete_ (from every
111
+ reachable marking, the end is reachable) plus _no dead transitions_. gtd's
112
+ machine has no concurrency/tokens, so full soundness collapses to the two
113
+ reachability passes above — the whole "soundness" property is two BFS runs here.
114
+
115
+ ### 3.3 Livelock heuristic — SCC analysis (Tarjan)
116
+
117
+ Full progress proofs are out of reach (whether a pattern fires depends on what
118
+ an agent writes), but the v2 plan's "livelock bounds" idea translates cleanly:
119
+ compute strongly connected components (Tarjan, O(V+E)), and for each cyclic SCC
120
+ check that it contains an _escape guarantee_:
121
+
122
+ - a state whose `actor` is a human-ish rest point (the machine stops and waits —
123
+ a human can always steer out), **or**
124
+ - a member with a `retry` cap whose `otherwise` leaves the SCC (bounded
125
+ redirection out).
126
+
127
+ An SCC with neither — e.g. `building → checking → fixing → checking` with no cap
128
+ — is a machine that can loop autonomously forever. Since "human-ish" is not
129
+ knowable (actors are open-vocabulary strings; the engine deliberately blesses no
130
+ names), the practical formulation is: **flag any cycle in which no member state
131
+ either awaits a different actor than the cycle's dominant one or carries a retry
132
+ cap escaping the cycle** — or, simpler and honest, make it a warning listing the
133
+ cycle and what would bound it. This mirrors how linters handle undecidable
134
+ properties: name the risk, don't pretend to prove it.
135
+
136
+ ### 3.4 Shadowed/dead `on` rows — pattern subsumption (match-arm usefulness)
137
+
138
+ `on` is an ordered first-match-wins list — semantically identical to ML/Rust
139
+ `match` arms, where compilers warn on unreachable arms using the _usefulness_
140
+ algorithm (Maranget, "Warnings for pattern matching"). gtd's pattern grammar is
141
+ far simpler than ML patterns, so full usefulness reduces to pairwise
142
+ subsumption:
143
+
144
+ - Row _j_ is dead iff some earlier row _i_ fires on every diff row _j_ fires on.
145
+ Matching is existential over the change set, so language inclusion is
146
+ sufficient: if row _i_'s (status, glob) language ⊇ row _j_'s, any change
147
+ matching _j_ also matches _i_, hence _i_ fires first.
148
+ - `C` is disjoint from every diff row (clean vs. dirty), so it can only be
149
+ shadowed by an earlier duplicate `C`.
150
+ - Status subsumption is trivial (`*` ⊇ `A`/`M`/`D`; equal otherwise). Glob
151
+ inclusion over the grammar {literal chars, `*`, `**`} is decidable — both
152
+ compile to regular languages (`globToRegExp` already builds the regexes), and
153
+ regular-language inclusion is decidable via product construction. In practice
154
+ a direct segment-wise structural check (the approach gitignore/ file-watcher
155
+ tooling uses) covers it without regex machinery, and even the cheap sufficient
156
+ subset catches the real-world cases:
157
+ - an exact duplicate pattern string earlier in the list (see also §4.1 — YAML
158
+ may reject or silently drop these before the compiler sees them),
159
+ - `* **` (or `* <glob>` with the identical glob) declared before a
160
+ more-specific row,
161
+ - same glob, earlier row's status `*` vs. later row's specific letter.
162
+
163
+ This one is a hard **error** candidate: a dead row is never intentional, and the
164
+ fix (reorder) is mechanical.
165
+
166
+ Deliberately **not** worth adopting from the match-checking world:
167
+ _exhaustiveness_. A dirty tree matching no row is a refusal **by design**
168
+ (AGENTS.md: "something happened that nothing recognizes" is a signal distinct
169
+ from a no-op), and a clean step with no `C` row is the documented default no-op.
170
+ The event space is intentionally non-total; a totality lint would fight the
171
+ semantics.
172
+
173
+ ### 3.5 Retry-chain analysis — cycle detection on a functional graph
174
+
175
+ `retry.otherwise` edges form a functional graph (≤1 outgoing edge per state), so
176
+ cycle detection is a trivial colored DFS (or Floyd's, but at this size DFS with
177
+ a visited set is clearer — `applyRetry` already contains the runtime version of
178
+ it). Findings: `otherwise` chains that cycle (error — the runtime guard's
179
+ "accept the repeated target" fallback is a documented we-must-terminate
180
+ behavior, not a meaning), `otherwise: <self>` (degenerate case of the same), and
181
+ a `retry` on a state that no `on` edge targets (warning — it can never fire, per
182
+ `applyRetry`'s entry-as-target semantics).
183
+
184
+ ### 3.6 Template validation — compile at load time
185
+
186
+ Eta exposes compilation separately from rendering: `compile()` every
187
+ `prompt`/`script`/`message`/`commit`/`file` string at load and report syntax
188
+ errors as load errors, closing gap #8 without executing anything impure
189
+ (rendering stays an edge concern; compiling is pure).
190
+
191
+ One step further, the **undefined-variable lint**: scan template source for
192
+ `it.vars.<name>` references and compare against the merged
193
+ workflow-`vars:`/rc-`vars:` keys. Because a third layer (`GTD_VAR_*` env vars)
194
+ can legitimately supply a name at run time, an unknown name is a **warning**,
195
+ not an error. (A full "render with a tracking proxy and record misses" approach
196
+ also works but executes template code at load — scanning the source is safer and
197
+ catches the common typo just as well.)
198
+
199
+ ### 3.7 Domain cross-checks (gtd-specific lints)
200
+
201
+ Not from the literature — from this codebase's own documented failure modes:
202
+
203
+ - **`file:` ↔ `on` consistency** (gap #9): render each state's `file:` template
204
+ with the workflow's _declared default_ vars (pure — the defaults are right
205
+ there in the definition) and warn when the rendered path is matched by none of
206
+ the workflow's `on` globs. This turns `default.yaml`'s KNOWN LIMITATION
207
+ comment into a machine-checked finding whenever a var repoint desyncs
208
+ templates from patterns. The same check re-run at `gtd status` time with the
209
+ _actual_ merged vars (env layer included) closes the runtime half of the gap.
210
+ - **Actor typo proximity**: the actor vocabulary is open by design, so `agnet`
211
+ is a legal actor — but a state awaiting it will never be stepped by the loop
212
+ driving `agent`. A Levenshtein "did you mean" warning when one declared actor
213
+ is within edit distance 1 of another (a pattern lifted from CLI arg parsers
214
+ and `git`'s command suggestions). Warning-only; false positives are possible
215
+ (`qa1`/`qa2`), so never an error.
216
+
217
+ ### 3.8 Dynamic validation — model-based testing (complement, not substitute)
218
+
219
+ Since `step` is a pure function, the graph can be _executed_ exhaustively in
220
+ tests: for every state and every `on` row, synthesize a minimal diff that the
221
+ row's pattern matches (invert the glob: replace `*`/`**` with a literal segment)
222
+ and assert `step` routes there — i.e., every declared edge is exercisable. This
223
+ is the state-machine-testing pattern XState ships as `@xstate/graph`
224
+ (`getShortestPaths`/`getSimplePaths` for path coverage) and what the
225
+ model-based-testing literature calls transition coverage. It overlaps §3.4 (a
226
+ shadowed row shows up as an unexercisable edge) but from the behavioral side,
227
+ and it doubles as a regression harness for the pattern grammar itself. Fits
228
+ naturally as a property test over `defaultWorkflowDefinition` rather than a
229
+ load-time check.
230
+
231
+ ## 4. Patterns from comparable systems
232
+
233
+ - **Automata theory** — _trim_ construction (accessible ∩ co-accessible),
234
+ §3.1/§3.2. The canonical "early problem detection" for FSMs.
235
+ - **Workflow nets** — soundness (option to complete, no dead transitions);
236
+ degenerates to the same two BFS passes for token-free graphs.
237
+ - **SCXML / W3C statecharts** — conformance validation is exactly this flavor:
238
+ every `target` IDREF resolves, `initial` names a real child, etc. gtd already
239
+ has the local half; the graph half is what's missing.
240
+ - **XState** — beyond TS-level typegen, its ecosystem does reachability via path
241
+ enumeration (`@xstate/graph`) and visual inspection (Stately inspector) rather
242
+ than a lint pass — an argument for also shipping a human-readable graph dump
243
+ (see §5, `gtd lint`).
244
+ - **ML/Rust match checking** — usefulness/unreachable-arm warnings, §3.4; also
245
+ the precedent for making dead-arm a _warning_ in some compilers and an _error_
246
+ in none... but gtd's rows are config, not code paths, and a dead row means a
247
+ whole state may be unreachable — error is defensible.
248
+ - **Model checking (CTL/LTL)** — names the property taxonomy worth keeping:
249
+ today's checks are all _safety_ ("nothing malformed"); §3.2/§3.3 add the cheap
250
+ _liveness_ half ("something good stays reachable"). At 12 nodes,
251
+ `AG EF terminal` is just reverse-BFS — no checker needed.
252
+ - **Linter architecture (ESLint et al.)** — severity tiers (error/warning),
253
+ collect-everything-then-report (gtd already does this for errors), stable
254
+ finding identifiers, and machine-readable output. The missing piece in gtd is
255
+ a **warning channel**: `compileWorkflowConfig` can only throw-or-pass, so
256
+ warning-tier findings (§3.1's manual-entry nuance, §3.3's heuristic, §3.6's
257
+ env-supplied vars, §3.7) currently have nowhere to go.
258
+
259
+ ## 5. Recommendation
260
+
261
+ Tier the findings and keep every new check a pure function over
262
+ `WorkflowDefinition` (they need nothing but the definition — they belong in
263
+ `PatternMachine.ts` next to `validateDefinition`, except the Eta compile check,
264
+ which imports Eta and belongs in `PatternTemplates.ts`):
265
+
266
+ **Errors** (extend `validateDefinition`'s existing thrown aggregate; all
267
+ unambiguous, mechanical fixes):
268
+
269
+ 1. Non-commit state with no `on` rows (dead end, §3.2's degenerate case).
270
+ 2. Reachable state from which no terminal (commit state or initial) is reachable
271
+ (trap, §3.2).
272
+ 3. `on` row strictly subsumed by an earlier row (§3.4).
273
+ 4. `retry.otherwise` self-reference or cycle (§3.5).
274
+ 5. Eta template that does not compile (§3.6).
275
+
276
+ **Warnings** (requires adding a `{ severity, message }` shape and a channel —
277
+ print to stderr at load and/or surface under `gtd status`; a `gtd lint`
278
+ subcommand that prints all findings plus the state graph would follow the
279
+ XState-inspector precedent and cost little):
280
+
281
+ 6. State unreachable via edges from `initial` (§3.1 — legal via hand-authored
282
+ subjects, almost always a typo).
283
+ 7. Autonomous cycle with no retry-capped escape (§3.3 heuristic).
284
+ 8. `retry` on a state no edge targets (§3.5).
285
+ 9. `it.vars.<name>` reference with no declared default (§3.6 — env layer may
286
+ supply it).
287
+ 10. `file:` rendering (with declared-default vars) to a path no `on` glob
288
+ matches (§3.7).
289
+ 11. Declared actor within edit distance 1 of another (§3.7).
290
+
291
+ **Test-side** (not load-time): transition-coverage property test over the
292
+ bundled default — synthesize a matching diff per `on` row, assert `step` takes
293
+ the edge (§3.8).
294
+
295
+ Sizing check: all graph passes are O(V+E) (two BFS + one Tarjan), subsumption is
296
+ O(rows² per state) over a trivial grammar, and template compilation is the only
297
+ cost that scales with content size. On workflows of tens of states this is
298
+ sub-millisecond — safe to run on every invocation, exactly like today's
299
+ `validateDefinition`.
300
+
301
+ ### Interactions to respect
302
+
303
+ - **Severity of unreachability** hinges on `resolveState`'s hand-authored
304
+ subject entry (§3.1) — do not promote finding 6 to an error without also
305
+ deciding that manual-entry states are unsupported.
306
+ - **The bundled default must stay finding-free** — findings 7 and 10 are
307
+ calibrated against it (`fixing`'s cap bounds the check loop; the literal
308
+ `.gtd/...` patterns match the declared default vars). Any new check must be
309
+ validated against `default.yaml` and `docs/examples/advanced-workflow.md`
310
+ before landing.
311
+ - **Warning channel is the only architectural change** — everything else slots
312
+ into the existing collect-and-merge flow in `compileWorkflowConfig`.
313
+
314
+ ## 6. Reframe: safeguards for USER-authored machines
315
+
316
+ §1–§5 read as "validate the definition harder." The actual product goal is
317
+ narrower and different: the bundled default is already covered by e2e; what
318
+ needs protecting is a **user hand-writing a `workflow:` key in `.gtdrc`** — and
319
+ that shifts the design in three ways:
320
+
321
+ 1. **Static analysis is structurally insufficient.** Whether an `on` pattern
322
+ ever fires depends on what an agent writes into the tree at run time. No
323
+ load-time check can prove a user's machine makes progress — it can only catch
324
+ the mechanically-dead shapes (§2's findings 1–8). The worst user-facing
325
+ failures — an agent↔check loop burning tokens all night, a process resting in
326
+ a state whose patterns never match what its own prompt asks the agent to
327
+ produce — need **runtime bounds**, not better proofs.
328
+ 2. **Severity must be recalibrated for other people's configs.** A new hard
329
+ error is a breaking change: a config that ran yesterday must not brick the
330
+ repo after `npm update`, _especially_ mid-process. Checks that are
331
+ "error-worthy" for a definition we author (§5's shadowed-row, trap-state)
332
+ default to **warnings** for user configs; hard errors stay reserved for
333
+ definitions that cannot execute at all (today's structural rules, plus
334
+ templates that don't compile).
335
+ 3. **A broken config must never take reporting down with it.** Today
336
+ `compileWorkflowConfig` throws, and everything — including `gtd status` —
337
+ dies with it. For a user mid-flight that's the difference between "gtd tells
338
+ me what's wrong" and "gtd won't even tell me where I am."
339
+
340
+ The safeguards form four layers, ordered by when they catch the problem:
341
+
342
+ ### 6.1 Authoring time — before the machine ever runs
343
+
344
+ - **A real JSON Schema.** `schema.json` is generated from `ConfigSchema.ts`,
345
+ where `workflow`/`vars` are `Schema.Unknown` — so the published schema says
346
+ `"title": "unknown"` and editors validate NOTHING about the one key users
347
+ hand-write. Hand-authoring the workflow shape into the schema (states map,
348
+ actor, the four content kinds, `on` as string→string, `retry`
349
+ `{max, otherwise}`, `model`/`file`/`mode`) gives every yaml-language-server
350
+ editor squiggles and autocomplete for free, with zero gtd code. The compiler
351
+ stays the source of truth (the schema can't express "exactly one content kind"
352
+ or cross-state rules); the schema is the fast first net, not a replacement.
353
+ Cheapest, highest-leverage single item in this document.
354
+ - **`gtd lint` (a.k.a. "check my machine before I trust it").** Compile the
355
+ config, run every §5 check, print ALL findings (errors + warnings) plus a
356
+ human-readable dump of the graph — states, actors, edges, retry caps, and the
357
+ reachability/terminal classification per state — so an author can _see_ the
358
+ machine they wrote (mermaid `stateDiagram-v2` output fits: renderable in any
359
+ markdown viewer, no new dependency to emit text). Also dry-compiles every Eta
360
+ template. This is where warnings can be exhaustive and loud, because the user
361
+ explicitly asked.
362
+
363
+ ### 6.2 Load time — every invocation, quiet by default
364
+
365
+ - **The §5 checks with user-config severities**: structural rules and
366
+ template-compile failures stay throwing errors; all graph findings
367
+ (unreachable, trap, shadowed row, retry cycle, autonomous cycle) surface as
368
+ stderr warnings — one line each, prefixed (`gtd: workflow warning: ...`),
369
+ never fatal. Errors keep the current all-findings-in-one-throw behavior.
370
+ - **Degrade reporting gracefully**: `gtd status` should still answer with the
371
+ findings when the config is broken (resolve HEAD against nothing, print the
372
+ compile errors as the status) rather than crashing with a stack trace.
373
+ `step`/`run`/`next` keep refusing — acting on a broken machine is worse than
374
+ stopping — but the _reporting_ path must survive any config.
375
+
376
+ ### 6.3 Run time — the bounds static analysis cannot provide
377
+
378
+ These are the actual safeguards, in the circuit-breaker sense. All three are
379
+ pure functions of data the engine already has (`processTrace`, the pending diff,
380
+ the definition) — they fit `PatternMachine.step`'s purity discipline.
381
+
382
+ - **A per-process autonomous-turn budget.** The v2 plan's "chain-depth limit"
383
+ (§3.11: "bound it and hard-error past, say, 32 hops"), reborn for v3: count
384
+ process-trace entries into states whose actor differs from the workflow's
385
+ human-facing rest points — concretely, entries since the last turn authored by
386
+ a DIFFERENT actor than the loop's autonomous pair, or simplest and honest:
387
+ total trace length. Past a cap (workflow-overridable via a reserved-ish var or
388
+ top-level config key, default generous — e.g. 50), `step` refuses with a new
389
+ refusal reason (`"budget"`) naming the count and how to raise the cap. This is
390
+ the one safeguard that holds NO MATTER WHAT the user's graph looks like — the
391
+ livelock heuristic (§3.3) can then stay a soft warning without leaving a hole.
392
+ - **Stall detection in the engine, not the skill.** `skills/loop/SKILL.md`
393
+ currently tells the driver, in prose, to stop when the same state+content
394
+ repeats with no new commits. Promote the signal: `gtd next --json` (and
395
+ `status --json`) can report `"revisits": <n>` — how many times the current
396
+ state already appears in the process trace. Any driver (not just one that read
397
+ the skill carefully) can then apply its own stop rule; the skill's prose
398
+ heuristic becomes one consumer of a first-class field. No behavior change in
399
+ the engine's own decisions.
400
+ - **Self-explaining refusals.** A no-match refusal already lists the state's
401
+ declared patterns; for a user debugging their own machine, also list the
402
+ pending changes that failed to match (status + path, first N). "Your tree has
403
+ `M src/x.ts` but this state only matches `A .gtd/FORMAT.md`" turns the most
404
+ common custom-workflow dead end from a mystery into a diff-vs-pattern
405
+ diagnosis the user can act on immediately.
406
+
407
+ ### 6.4 Recovery — no config may trap the repository
408
+
409
+ Already true, worth asserting and documenting as a guarantee: the machine's
410
+ whole state is HEAD's commit subject, so a hand-authored commit
411
+ (`git commit --allow-empty -m "gtd(human): <state>"` for any defined non-commit
412
+ state, or any non-gtd subject to fall back to the initial state) exits ANY state
413
+ a broken config strands a process in. A user's workflow can waste turns; it
414
+ cannot brick a repo. An e2e scenario pinning this (hand-commit out of an
415
+ intentionally-trapped custom workflow) makes the guarantee load-bearing instead
416
+ of incidental — and it is the reason every graph finding in §6.2 can afford to
417
+ be a warning.
418
+
419
+ ### What deliberately stays out
420
+
421
+ - No sandboxing or vetting of `script:` content — the workflow author is
422
+ trusted; scripts already run verbatim by design (`gtd run`).
423
+ - No engine interpretation of actor names for the budget/livelock rules beyond
424
+ counting — the actor vocabulary stays unblessed.
425
+ - No auto-repair ("we removed your unreachable state") — findings report; the
426
+ user decides.
427
+
428
+ ### Revised priority (user-safeguard lens)
429
+
430
+ 1. JSON Schema for the `workflow:` shape (§6.1) — editor-time, zero engine code.
431
+ 2. Warning channel + graceful `gtd status` degradation (§6.2) — the
432
+ architectural enabler.
433
+ 3. Autonomous-turn budget refusal (§6.3) — the unconditional backstop.
434
+ 4. Graph warnings from §5, demoted to warning severity (§6.2).
435
+ 5. Refusal diagnostics + `revisits` in `--json` (§6.3).
436
+ 6. `gtd lint` with graph dump (§6.1) — subsumes nothing above, packages all of
437
+ it for the authoring moment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
package/schema.json CHANGED
@@ -4,12 +4,103 @@
4
4
  "required": [],
5
5
  "properties": {
6
6
  "workflow": {
7
- "$id": "/schemas/unknown",
8
- "title": "unknown"
7
+ "type": "object",
8
+ "description": "The whole machine definition: named states (plus the workflow's own vars: defaults). Compiled and validated by gtd at load time; content strings starting with ./ or ../ are file references inlined from the config file's directory.",
9
+ "additionalProperties": false,
10
+ "required": ["states"],
11
+ "properties": {
12
+ "vars": {
13
+ "type": "object",
14
+ "description": "Flat name -> scalar map merged into every template's it.vars. Scalars are coerced to strings.",
15
+ "additionalProperties": {
16
+ "type": ["string", "number", "boolean"]
17
+ }
18
+ },
19
+ "states": {
20
+ "type": "object",
21
+ "description": "The workflow's named states. At least one; exactly one with initial: true.",
22
+ "minProperties": 1,
23
+ "additionalProperties": {
24
+ "type": "object",
25
+ "description": "One workflow state. Declare exactly one content kind (script/prompt/message/commit). A commit state is final: no actor, no on.",
26
+ "additionalProperties": false,
27
+ "properties": {
28
+ "actor": {
29
+ "type": "string",
30
+ "description": "Who acts at this state. Required on every non-commit state; forbidden on a commit state."
31
+ },
32
+ "script": {
33
+ "type": "string",
34
+ "description": "Content kind: a shell script (Eta template). `gtd run` executes it verbatim via bash, then steps this state's actor."
35
+ },
36
+ "prompt": {
37
+ "type": "string",
38
+ "description": "Content kind: an agent prompt (Eta template), emitted by `gtd next`."
39
+ },
40
+ "message": {
41
+ "type": "string",
42
+ "description": "Content kind: a human-facing message (Eta template), emitted by `gtd next`."
43
+ },
44
+ "commit": {
45
+ "type": "string",
46
+ "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."
47
+ },
48
+ "on": {
49
+ "type": "object",
50
+ "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).",
51
+ "additionalProperties": {
52
+ "type": "string"
53
+ }
54
+ },
55
+ "initial": {
56
+ "type": "boolean",
57
+ "description": "Exactly one state in the workflow must declare initial: true (and it must not be a commit state)."
58
+ },
59
+ "retry": {
60
+ "type": "object",
61
+ "description": "Redirect transitions INTO this state once it has been entered `max` times in the current process.",
62
+ "additionalProperties": false,
63
+ "required": ["max", "otherwise"],
64
+ "properties": {
65
+ "max": {
66
+ "type": "integer",
67
+ "minimum": 0,
68
+ "description": "Entries allowed this process before redirecting."
69
+ },
70
+ "otherwise": {
71
+ "type": "string",
72
+ "description": "Defined state to redirect to once over the cap."
73
+ }
74
+ }
75
+ },
76
+ "model": {
77
+ "type": "string",
78
+ "description": "Opaque harness hint passed through `gtd next --json`/`gtd status --json` (e.g. \"smart\"). Never interpreted by gtd. Forbidden on a commit state."
79
+ },
80
+ "file": {
81
+ "type": "string",
82
+ "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."
83
+ },
84
+ "mode": {
85
+ "type": "string",
86
+ "enum": ["qa", "review"],
87
+ "description": "The steering file's format, dispatched on by the LSP. Requires a sibling `file:`. Forbidden on a commit state."
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
9
93
  },
10
94
  "vars": {
11
- "$id": "/schemas/unknown",
12
- "title": "unknown"
95
+ "type": "object",
96
+ "description": "Flat name -> scalar map merged into every template's it.vars. Scalars are coerced to strings.",
97
+ "additionalProperties": {
98
+ "type": ["string", "number", "boolean"]
99
+ }
100
+ },
101
+ "$schema": {
102
+ "type": "string",
103
+ "description": "Editor-only pointer at this schema. Stripped by gtd before validation."
13
104
  }
14
105
  },
15
106
  "additionalProperties": false