@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.
- package/dist/gtd.bundle.mjs +240 -5
- package/docs/cli.md +44 -2
- package/docs/configuration.md +15 -0
- package/docs/design/state-machine-validation.md +437 -0
- package/package.json +1 -1
- package/schema.json +95 -4
package/dist/gtd.bundle.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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).
|
package/docs/configuration.md
CHANGED
|
@@ -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
package/schema.json
CHANGED
|
@@ -4,12 +4,103 @@
|
|
|
4
4
|
"required": [],
|
|
5
5
|
"properties": {
|
|
6
6
|
"workflow": {
|
|
7
|
-
"
|
|
8
|
-
"
|
|
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
|
-
"
|
|
12
|
-
"
|
|
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
|