@pmelab/gtd 8.0.0 → 8.2.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.
@@ -1,1439 +0,0 @@
1
- # Design exploration: a fully configurable state machine
2
-
3
- > **Historical design record.** The machinery described here (the v2 model —
4
- > gates, guard functions, actor kinds, conditions) was deleted by the v3
5
- > rewrite; superseded by `docs/design/pattern-machine-plan.md` and
6
- > [STATES.md](../../STATES.md).
7
-
8
- > Status: exploration, not a commitment. This document maps the building blocks
9
- > a configuration-driven workflow engine would need so that the current v2
10
- > machine becomes nothing more than the **default configuration**, and a user
11
- > can define their own states, edges, conditions, and human gates.
12
-
13
- ## 1. What "the state machine" actually is today
14
-
15
- Before designing a configurable version, it's worth being precise about what
16
- would be configured. gtd's machine is **not** a classic FSM with a stored state
17
- pointer. It is a pure re-derivation:
18
-
19
- ```
20
- state = resolve( parse(first-parent history) + snapshot(working tree) + config )
21
- ```
22
-
23
- Nothing is persisted except ordinary git commits; every invocation re-derives
24
- the entire decision from scratch. That property buys crash-safety, human
25
- tamper-tolerance, and v1 backward compatibility — and any configurable design
26
- must preserve it, or it isn't the same product.
27
-
28
- Concretely, the engine has three layers:
29
-
30
- | Layer | Where | What it does |
31
- | -------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
32
- | **Fact gathering** | `src/Events.ts` | IO edge: reads git history + working tree into `CommitEvent[]` flags and a `ResolvePayload` fact snapshot |
33
- | **Pure resolution** | `src/Machine.ts` | folds counters, checks illegal combinations, classifies HEAD (rest vs mid-chain), applies the precedence ladder, layers turn-taking |
34
- | **Effect execution** | `src/Events.ts` | `perform(EdgeAction)`: a closed set of side-effect opcodes (capture turn, routing commit, run tests, squash, …) |
35
-
36
- The observation that makes configurability tractable: **most of the middle layer
37
- is already data, not code.** `counterRules`, the illegal-combination rule lists,
38
- `ROUTING_SUBJECT`, `TURN_GATES`, `INERT_EMPTY_AGENT_GATES`, `GATE_OVERRIDES`,
39
- `STATE_TEMPLATE`, `MODEL_STATE`, and the entry-point pick order are all literal
40
- tables today. `classifyHead` is a hand-unrolled table. What remains as genuine
41
- code is a handful of precedence exceptions and the turn-taking layer. A
42
- configurable machine is therefore less "rewrite the engine" and more "promote
43
- the existing tables into a compiled artifact of a user-facing schema, and find
44
- declarative homes for the ~20% that isn't table-shaped yet."
45
-
46
- ## 2. The shape of the proposal
47
-
48
- Users author a **workflow definition** (states + edges + conditions, statechart
49
- style). A **compile step** turns it into the internal tables the engine already
50
- runs on:
51
-
52
- ```
53
- workflow definition (user config)
54
- │ compile (validate totality, ambiguity, reachability)
55
- ▼
56
- ┌─────────────────────────────────────────────────────────┐
57
- │ generated subject grammar (turn gates, routing phases) │
58
- │ head-classification table (classifyHead rows) │
59
- │ interrupt/precedence rules (steering-file ladder) │
60
- │ counter fold rules (counterRules) │
61
- │ illegal-combination rules (assertLegal) │
62
- │ prompt/model bindings (STATE_TEMPLATE, MODEL_STATE)│
63
- │ turn-taking parameters (empty-turn policies, entry) │
64
- └─────────────────────────────────────────────────────────┘
65
- ▼
66
- same pure resolve() interpreter, now table-driven end to end
67
- ```
68
-
69
- The commit log stays the only persistence. Turn and routing subjects are
70
- **generated from the configured state/edge names**, and the boundary rule is
71
- preserved: any subject outside the configured closed grammar is an inert
72
- boundary commit. This is load-bearing (see §7 on config-change hazards) — it is
73
- the same mechanism that already makes v1 history inert under v2.
74
-
75
- Two design stances worth naming and rejecting:
76
-
77
- - **Generic statechart runtime (XState-style) with a persisted state pointer.**
78
- Simple to configure, but abandons derive-from-history: crash recovery, human
79
- edits landing as ordinary commits, and `gtd next` as a pure query all break.
80
- Rejected.
81
- - **Arbitrary user-defined effects (scripts as actions).** Turns the workflow
82
- into a CI system and destroys the safety analysis (an action's effect on the
83
- next resolution becomes unknowable). Rejected in favor of a **closed primitive
84
- toolbox** (§3.7) — like GitHub Actions, where the _graph_ is yours but the
85
- _runner semantics_ are built in. Custom behavior enters through prompts,
86
- templates, commands (`testCommand`-style strings), and artifacts — not through
87
- new opcodes.
88
-
89
- ## 3. The building blocks
90
-
91
- Eleven blocks. Each lists what it generalizes from the current machine.
92
-
93
- ### 3.1 Actors
94
-
95
- The turn-taking universe. Today: `human` and `agent`, hard-coded into the
96
- subject grammar `gtd(<actor>): <gate>`, the invoker commands (`gtd step`,
97
- `gtd step-agent`), and the refusal logic.
98
-
99
- ```yaml
100
- actors:
101
- human: { kind: interactive } # gtd step
102
- agent: { kind: autonomous } # gtd step-agent
103
- ```
104
-
105
- Keep the set fixed at these two for v1 of configurability. The building block
106
- exists so a later version can add named roles (e.g. a second autonomous
107
- `reviewer` actor invoked as `gtd step-agent --as reviewer`), but nothing in the
108
- current functionality needs it, and multi-actor turn-taking multiplies the
109
- refusal/fixpoint matrix.
110
-
111
- ### 3.2 Artifacts (steering files)
112
-
113
- The single richest block. Every steering file becomes a declared **artifact**,
114
- and almost everything the machine currently knows about files falls out of the
115
- declaration:
116
-
117
- ```yaml
118
- artifacts:
119
- todo:
120
- path: .gtd/TODO.md
121
- validator: open-questions # named, built-in validators (see below)
122
- format: true # run `gtd format` on capture
123
- architecture:
124
- path: .gtd/ARCHITECTURE.md
125
- validator: open-questions
126
- format: true
127
- plan:
128
- path: .gtd/PLAN.md
129
- entryOnly: true # may never coexist with anything else
130
- review:
131
- path: .gtd/REVIEW.md
132
- validator: review-doc
133
- probes: [checkbox-only-diff] # content probes this artifact opts into
134
- feedback:
135
- path: .gtd/FEEDBACK.md
136
- probes: [empty]
137
- consumedByTurn: fixing # precedence exception (see §3.6)
138
- errors:
139
- path: .gtd/ERRORS.md
140
- health:
141
- path: .gtd/HEALTH.md
142
- consumedByTurn: health-fixing
143
- squashMsg:
144
- path: .gtd/SQUASH_MSG.md
145
- template: templates/squash-msg.md # written by write-template; probe
146
- probes: [template-unmodified] # `template-unmodified` guards use it
147
- learnings:
148
- path: .gtd/LEARNINGS.md
149
- template: templates/learnings.md
150
- probes: [template-unmodified]
151
-
152
- packages: # the one non-file artifact KIND
153
- kind: package-set
154
- path: .gtd/
155
- ```
156
-
157
- Each declared artifact automatically contributes **facts** to the guard
158
- vocabulary (§3.4): `todo.exists`, `todo.committed`, `feedback.present`,
159
- `feedback.empty`, `feedback.pendingDeletion`, `review.checkboxOnly`,
160
- `squashMsg.isTemplate`, `packages.present`, `packages.modified`,
161
- `packages.remaining`, … — exactly the `ResolvePayload` fields that exist today,
162
- but _generated per artifact_ instead of hand-listed.
163
-
164
- Three deliberately closed sub-vocabularies live here:
165
-
166
- - **Content probes** — the machine inspects file content in exactly three ways
167
- today (FEEDBACK.md whitespace-emptiness, REVIEW.md checkbox-only diffs,
168
- template-unmodified checks). These stay a closed, named set. No user
169
- expression language ever reads file content; "file presence steers, content
170
- doesn't" survives as a design invariant with three opt-in exceptions.
171
- - **Validators** — `open-questions` and `review-doc` are named built-ins (backed
172
- by `src/OpenQuestions.ts` / `src/ReviewDoc.ts`). A plausible later extension
173
- is a small declarative markdown-structure schema ("required `##` section",
174
- "first body line matches …"), but named validators reproduce today's behavior.
175
- - **`package-set`** — packages are not a file; they are an ordered directory
176
- protocol (`01-*/`, task files, close-lowest-first) with their own facts and
177
- their own effect primitive (`close-package`). Modeling them as a special
178
- artifact _kind_ keeps the machine honest about that instead of pretending a
179
- directory tree is a boolean.
180
-
181
- Mutual-exclusion ("illegal combinations") is declared as conflict sets, not 30
182
- hand-written pairs — the compiler expands `entryOnly` and explicit `conflicts`
183
- groups into the pairwise rule list `assertLegal` runs today:
184
-
185
- ```yaml
186
- conflicts:
187
- - [health, packages, review, feedback, errors, todo, architecture, plan] # health vs world
188
- - [todo, architecture] # lifecycle stages of one document
189
- - [feedback, review]
190
- - requires: { feedback: packages } # FEEDBACK.md without packages → error
191
- ```
192
-
193
- ### 3.3 Commit-event flags and counters
194
-
195
- `gatherEvents` currently derives six flags per commit (`isErrors`, `isFeedback`,
196
- `isPackageStart`, `removedErrors`, `isHealthCheck`, `isTestsGreen`), and
197
- `foldCounters` folds three counters from them with declarative reset/increment
198
- rules. Both generalize cleanly:
199
-
200
- ```yaml
201
- commitFlags:
202
- packageStart: { routing: [planning, package-done] }
203
- feedbackRound:
204
- { turn: { actor: agent, gate: agentic-review }, diffTouches: feedback }
205
- errorsRound: { routing: [errors] }
206
- removedErrors: { diffDeletes: errors }
207
- healthRound: { routing: [health-check] }
208
- testsGreen: { routing: [tests-green] }
209
-
210
- counters:
211
- testFix:
212
- {
213
- resetOn: [packageStart, feedbackRound, removedErrors],
214
- incrementOn: [errorsRound],
215
- }
216
- reviewFix: { resetOn: [packageStart], incrementOn: [feedbackRound] }
217
- healthFix:
218
- {
219
- resetOn: [packageStart, removedErrors, testsGreen],
220
- incrementOn: [healthRound],
221
- }
222
- ```
223
-
224
- A commit-flag predicate needs exactly three matchers to cover everything that
225
- exists: _subject is routing phase X_, _subject is turn (actor, gate)_, and _diff
226
- touches / deletes artifact Y_. `counterRules` in `Machine.ts` already has
227
- precisely this shape — this block is a rename, not an invention. User- defined
228
- counters plus `counterAtLeast` guards (§3.4) are what make custom
229
- budget/threshold loops ("escalate to a human after N rounds of X") expressible
230
- without code.
231
-
232
- ### 3.4 Facts and guard expressions
233
-
234
- The guard vocabulary is the union of:
235
-
236
- - artifact-derived facts (§3.2),
237
- - built-in tree/head facts: `tree.clean`, `code.dirty`, `head.turnEmpty`,
238
- `head.isTurn(actor, gate)`, `head.isRouting(phase)`, `head.isBoundary`,
239
- `reviewable` (base + non-empty diff + commits since last done),
240
- - counters: `counter(name)`,
241
- - config parameters: `param(name)` — the `agenticReview` / `fixAttemptCap` /
242
- `reviewThreshold` / `squash` / `learning` values, which stay ordinary scalar
243
- config and travel to the resolver exactly as they do today (payload fields,
244
- per AGENTS.md's config-vs-mode-flag rule).
245
-
246
- Guards are a small structured boolean AST — JSON/YAML, not an embedded
247
- expression string, in keeping with schema-validated config and mutation-tested
248
- code:
249
-
250
- ```yaml
251
- when:
252
- {
253
- all: [{ not: { param: agenticReview } }],
254
- any: [{ counterAtLeast: { counter: reviewFix, param: reviewThreshold } }],
255
- }
256
- ```
257
-
258
- (Sugar for common forms — `when: feedback.empty` — is a parser nicety, not a
259
- semantic feature.) The force-approve rule, today a code comment thicket, becomes
260
- one named, reusable guard:
261
-
262
- ```yaml
263
- guards:
264
- forceApprove:
265
- any:
266
- - { not: { param: agenticReview } }
267
- - { counterAtLeast: { counter: reviewFix, param: reviewThreshold } }
268
- ```
269
-
270
- ### 3.5 States
271
-
272
- ```yaml
273
- states:
274
- grilling:
275
- awaits: dynamic # resolved per-edge; grilling awaits human OR agent
276
- prompts:
277
- agent: prompts/grilling-agent.md
278
- human: prompts/grilling-answers.md
279
- model: planning # tier ref; per-state override still possible
280
- emptyAgentTurn: inert # the INERT_EMPTY_AGENT_GATES membership
281
- validateAgentTurn: open-questions # refuse agent capture on malformed doc
282
- await-review:
283
- awaits: human
284
- prompts: { human: prompts/await-review.md }
285
- gate: review # GATE_OVERRIDES: turn authored under another gate name
286
- squashing:
287
- awaits: agent
288
- prompts: { agent: prompts/squashing.md }
289
- model: planning
290
- emptyAgentTurn: { inertWhen: squashMsg.isTemplate } # conditional inertness
291
- health-fixing:
292
- awaits: agent
293
- emptyAgentTurn:
294
- { signal: true, inertWhen: { head.isTurn: [human, health-fixing] } }
295
- testing: { kind: edge-only } # never a rest; never renders a prompt
296
- close-package: { kind: edge-only }
297
- idle:
298
- awaits: human
299
- prompts: { human: prompts/idle.md }
300
- ```
301
-
302
- Everything `PROMPT_STATES`, `MODEL_STATE`, `STATE_IS_OWN_GATE`,
303
- `GATE_OVERRIDES`, and `INERT_EMPTY_AGENT_GATES` encode today collapses into
304
- per-state declarations. The **empty-turn policy** deserves emphasis: the current
305
- machine's most subtle invariant (AGENTS.md "Turn Capture") is that each gate
306
- explicitly decides whether a clean-tree capture is a signal or a no-op, guarded
307
- at BOTH the capture layer and the classification layer. Making it a mandatory
308
- per-state field — with `inertWhen` guards for the conditional cases — forces
309
- every custom machine author to make that decision explicitly, and lets the
310
- compiler enforce that both layers read the same declaration (they already share
311
- `isInertEmptyAgentRest`; the config keeps them shared).
312
-
313
- ### 3.6 Edges (the transition rules)
314
-
315
- Resolution today happens in three passes, and the config mirrors them as three
316
- edge families. Order matters in each family and is preserved as written.
317
-
318
- **(a) Interrupt rules** — the steering-file precedence ladder: fire on facts
319
- regardless of HEAD, top priority first. Each rule may carry `unless` exceptions;
320
- the common one ("the turn that consumes this artifact must not be pre-empted by
321
- the artifact's own presence") is auto-generated from the artifact's
322
- `consumedByTurn` declaration:
323
-
324
- ```yaml
325
- interrupts:
326
- - when: errors.present
327
- rest: { state: escalate }
328
- - when: health.present # unless auto-generated: head is gtd(agent): health-fixing
329
- rest: { state: health-fixing }
330
- - when: { any: [feedback.present, feedback.pendingDeletion] }
331
- unless: { all: [guard: forceApprove, head.isRouting: tests-green] }
332
- branch:
333
- - when: { head.isRouting: tests-green } # uncaptured reviewer write
334
- rest: { state: agentic-review }
335
- - when: { all: [feedback.emptyEffective, { not: fact: inFixLoop }] }
336
- chain: { state: close-package, action: close-package }
337
- - rest: { state: fixing }
338
- ```
339
-
340
- **(b) Head rules** — the `classifyHead` table: match the parsed HEAD subject,
341
- branch on guards, land on a **rest** (a state + awaited actor) or a **chain** (a
342
- state label + one effect the driver performs before re-resolving — "mid-chain"
343
- today):
344
-
345
- ```yaml
346
- onTurn:
347
- - match: { actor: human, gate: grilling }
348
- branch:
349
- - when: head.turnEmpty
350
- chain:
351
- state: grilling
352
- action: { route: architecting, seed: { from: todo, to: architecture, banner: seeded-from-todo } }
353
- - rest: { state: grilling, awaits: agent }
354
- - match: { actor: agent, gate: grilled }
355
- branch:
356
- - when: packages.present
357
- chain: { state: grilled, action: { route: planning, remove: [architecture] } }
358
- - rest: { state: grilled, awaits: agent } # guard: no packages → re-emit prompt
359
- - match: { actor: human, gate: review }
360
- branch:
361
- - when: fact: reviewSubstantive
362
- chain: { state: review, action: { route: review-feedback, remove: [review] } }
363
- - chain: { state: review, action: { route: done, remove: [review] } }
364
-
365
- onRouting:
366
- - match: tests-green
367
- branch:
368
- - when: packages.present
369
- branch:
370
- - when: { guard: forceApprove }
371
- chain: { state: close-package, action: close-package }
372
- - rest: { state: agentic-review, awaits: agent }
373
- - use: afterCycleSettles # named, reusable rule (learning → squash → idle)
374
- - match: awaiting-review
375
- rest: { state: await-review, awaits: human }
376
- ```
377
-
378
- `use:` names a shared sub-rule — `nextAfterReviewOrLearning` in today's code
379
- (the learning-then-squash-then-idle decision reached from three different
380
- routing subjects) is exactly such a reusable fragment.
381
-
382
- **(c) Fallback rules** — the boundary-HEAD ladder that runs when neither an
383
- interrupt nor a head rule fired: `packages.modified → planning`,
384
- `todo.exists → grilling`, `architecture.exists → architecting`,
385
- `plan.exists → grilled (awaits human)`, the operational-recovery checkpoint
386
- rule, `reviewable → review`, else `idle`, else **corruption error**. Same
387
- ordered-rule shape as interrupts. The final "no rule matched → hard error, do
388
- not guess" is engine behavior, not configurable — a custom machine keeps the
389
- steering-misuse contract for free.
390
-
391
- **Entry points** are a fourth, tiny rule list consumed by the turn-taking layer
392
- (which artifact in a dirty boundary tree selects which entry gate):
393
-
394
- ```yaml
395
- entry:
396
- - { when: health.exists, gate: health-fixing }
397
- - { when: plan.exists, gate: grilled }
398
- - { when: architecture.exists, gate: architecting }
399
- - { gate: grilling } # default
400
- ```
401
-
402
- The compiler verifies the entry artifacts are pairwise-conflicting (§3.2), so
403
- the pick is provably unambiguous — today that's a prose promise.
404
-
405
- ### 3.7 Effect primitives (the closed toolbox)
406
-
407
- Config composes effects; it never defines new ones. The current eight
408
- `EdgeAction` variants generalize into six parameterized opcodes:
409
-
410
- | Primitive | Parameters | Generalizes |
411
- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
412
- | `capture-turn` | actor, gate (engine-chosen; not really config-facing) | `captureTurn` |
413
- | `route` | routing phase; `remove: [artifact…]`; `seed: {from, to, banner}` | `commitRouting` + its five booleans |
414
- | `run-check` | command ref; `green: route \| stop \| chain`; `red: {write: artifact, cap: {counter, param, write: artifact}, route}`; `removeFirst: [artifact…]` | `runTest` AND `runHealthCheck` |
415
- | `write-template` | artifact (must declare `template:`); route | `writeSquashTemplate` / `writeLearningTemplate` |
416
- | `squash` | base anchor (engine-computed cycle base); message-from artifact | `squashCommit` |
417
- | `close-package` | (package-set artifact) | `closePackage` |
418
-
419
- `run-check` is the significant unification: `runTest` and `runHealthCheck` are
420
- already the same shape (run command → green routes / red writes an artifact,
421
- escalating to a different artifact at a counter cap), differing only in which
422
- artifacts and which follow-up. Parameterizing it also unlocks the most-wanted
423
- custom machines for free: **multiple named checks** (lint gate, typecheck gate,
424
- e2e gate as separate states with separate budgets and separate commands) — today
425
- impossible without forking the code.
426
-
427
- Commands are declared alongside `testCommand`:
428
-
429
- ```yaml
430
- commands:
431
- test: npm run test
432
- lint: npm run lint # available to custom run-check edges
433
- ```
434
-
435
- ### 3.8 The turn-taking engine (fixed, parameterized — not configurable)
436
-
437
- Everything in `applyTurnTaking` stays engine code: invoker-vs-awaited refusal in
438
- both directions, the fixpoint (idempotent re-invocation), the dirty-boundary
439
- entry capture, pending-human-edits-ride-along, and `invoker: "none"` purity. It
440
- is _parameterized_ by config (empty-turn policies, entry rules, per-gate
441
- validators) but its skeleton is the product's safety core — the analysis in
442
- AGENTS.md about guarding both layers exists precisely because this logic is easy
443
- to get fatally wrong. A user configures _which_ gates exist and _who_ they
444
- await; they do not configure what turn-taking means.
445
-
446
- Likewise the **review checkout window** stays wired only in `program.ts`
447
- (program-edge concern, per the architecture rule); config can at most toggle it
448
- per human gate (`checkoutWindow: true` on `await-review`).
449
-
450
- ### 3.9 Prompts and models
451
-
452
- Already halfway configurable (model tiers/states in `.gtdrc`). The remaining
453
- moves:
454
-
455
- - Per-state template **paths** in config, resolving user files with fallback to
456
- the built-ins (which ship as the default config's templates).
457
- - A documented, versioned **view-model contract**: templates receive `context`
458
- (counters, packages, diffs, feedback content, decision log — the
459
- `ResolveContext` fields), `model`, `fenceFor`, and the shared partials
460
- (`@header`, `@diff`, `@package`, `@agent-turn`, …) stay available.
461
- - Model tiers become config-defined names (`planning`, `execution` today) that
462
- states reference; the existing `models.states` override mechanism survives
463
- unchanged.
464
-
465
- Custom states then get prompts the same way built-in ones do; a state without a
466
- prompt and without `kind: edge-only` is a compile error.
467
-
468
- ### 3.10 The subject grammar (generated, namespaced)
469
-
470
- `TurnGate` and `RoutingPhase` closed sets are generated from the config: every
471
- state with an actor gate contributes a turn subject; every `route:` target
472
- contributes a routing subject. Two rules preserve today's guarantees:
473
-
474
- - **Closed-world parsing**: `parseSubject` matches only the configured sets;
475
- everything else is a boundary commit. v1 compatibility and "inert unknown
476
- subjects" survive automatically — including subjects from a _previous
477
- configuration_ (see §7).
478
- - **Reserved namespace**: generated subjects keep the `gtd(actor): gate` /
479
- `gtd: phase` shapes. The compiler rejects state/phase names that collide with
480
- each other or with the parameterized `reviewing <hash>` anchor.
481
-
482
- ### 3.11 The compiler (static validation)
483
-
484
- The piece that makes user-authored machines survivable. At load time (cached;
485
- this runs on every invocation), validate:
486
-
487
- - **Schema** — effect-schema decode with `onExcessProperty: "error"`, exactly
488
- like `ConfigSchema` today; every referenced artifact/guard/counter/command/
489
- state exists; no dangling `route:` targets.
490
- - **Totality** — every routing phase emitted by some edge has an `onRouting`
491
- classification; every rest state has a prompt for its awaited actor(s); every
492
- state is reachable from an entry point; every `run-check` red path writes a
493
- declared artifact.
494
- - **Ambiguity** — interrupt/fallback rules are ordered (documented semantics),
495
- but two `onTurn` matches for the same (actor, gate) with non-disjoint guard
496
- branches get flagged.
497
- - **Livelock bounds** — full progress proofs are out of reach, but two cheap
498
- checks catch the classic traps: (1) every agent-awaited rest whose gate's
499
- empty turn is inert must have _some_ dirty-tree path forward, and (2) every
500
- counter used in a cap guard must have a reset flag reachable from the capped
501
- loop. Plus a runtime chain-depth limit (the driver already loops gather →
502
- resolve → perform; bound it and hard-error past, say, 32 hops).
503
- - **Entry unambiguity** — entry-rule artifacts pairwise conflict (§3.6).
504
-
505
- ## 4. What the default configuration looks like
506
-
507
- The full default config reproduces all 21 states / 12 gates / 17 routing phases;
508
- the sketch below shows one representative slice of each family to make the
509
- flavor concrete (grilling hand-off, the test loop, the human review gate):
510
-
511
- ```yaml
512
- # gtd.workflow.yaml — excerpt of the shipped default
513
- actors: { human: { kind: interactive }, agent: { kind: autonomous } }
514
-
515
- commands: { test: "npm run test" }
516
-
517
- params:
518
- fixAttemptCap: { default: 3 }
519
- reviewThreshold: { default: 3 }
520
- agenticReview: { default: true }
521
- squash: { default: true }
522
- learning: { default: true }
523
-
524
- artifacts:
525
- todo: { path: .gtd/TODO.md, validator: open-questions, format: true }
526
- architecture:
527
- { path: .gtd/ARCHITECTURE.md, validator: open-questions, format: true }
528
- feedback: { path: .gtd/FEEDBACK.md, probes: [empty], consumedByTurn: fixing }
529
- errors: { path: .gtd/ERRORS.md }
530
- review:
531
- {
532
- path: .gtd/REVIEW.md,
533
- validator: review-doc,
534
- probes: [checkbox-only-diff],
535
- }
536
- packages: { kind: package-set, path: .gtd/ }
537
-
538
- counters:
539
- testFix:
540
- {
541
- resetOn: [packageStart, feedbackRound, removedErrors],
542
- incrementOn: [errorsRound],
543
- }
544
- reviewFix: { resetOn: [packageStart], incrementOn: [feedbackRound] }
545
-
546
- guards:
547
- forceApprove:
548
- any:
549
- - { not: { param: agenticReview } }
550
- - { counterAtLeast: { counter: reviewFix, param: reviewThreshold } }
551
-
552
- states:
553
- grilling:
554
- awaits: dynamic
555
- prompts:
556
- { agent: prompts/grilling-agent.md, human: prompts/grilling-answers.md }
557
- model: planning
558
- emptyAgentTurn: inert
559
- validateAgentTurn: open-questions
560
- building:
561
- awaits: agent
562
- prompts: { agent: prompts/building.md }
563
- model: execution
564
- emptyAgentTurn: inert
565
- fixing:
566
- awaits: agent
567
- prompts: { agent: prompts/fixing.md }
568
- model: execution
569
- emptyAgentTurn: inert
570
- escalate:
571
- awaits: human
572
- prompts: { human: prompts/escalate.md }
573
- agentic-review:
574
- awaits: agent
575
- prompts: { agent: prompts/agentic-review.md }
576
- model: planning
577
- emptyAgentTurn: inert
578
- await-review:
579
- awaits: human
580
- prompts: { human: prompts/await-review.md }
581
- gate: review
582
- checkoutWindow: true
583
- testing: { kind: edge-only }
584
- close-package: { kind: edge-only }
585
-
586
- interrupts:
587
- - { when: errors.present, rest: { state: escalate } }
588
- - when: { any: [feedback.present, feedback.pendingDeletion] }
589
- unless: { all: [{ guard: forceApprove }, { head.isRouting: tests-green }] }
590
- branch:
591
- - {
592
- when: { head.isRouting: tests-green },
593
- rest: { state: agentic-review },
594
- }
595
- - when: { all: [feedback.emptyEffective, { not: { fact: inFixLoop } }] }
596
- chain: { state: close-package, action: close-package }
597
- - rest: { state: fixing }
598
-
599
- onTurn:
600
- - match: { actor: agent, gate: building }
601
- chain:
602
- state: building
603
- action:
604
- run-check:
605
- command: test
606
- removeFirst: [feedback]
607
- green: { route: tests-green }
608
- red:
609
- write: feedback
610
- cap: { counter: testFix, param: fixAttemptCap, write: errors }
611
- route: errors
612
- - match: { actor: human, gate: review }
613
- branch:
614
- - when: { fact: reviewSubstantive }
615
- chain:
616
- {
617
- state: review,
618
- action: { route: review-feedback, remove: [review] },
619
- }
620
- - chain: { state: review, action: { route: done, remove: [review] } }
621
-
622
- onRouting:
623
- - match: planning
624
- rest: { state: building, awaits: agent }
625
- - match: errors
626
- branch:
627
- - { when: errors.present, rest: { state: escalate } }
628
- - rest: { state: fixing, awaits: agent }
629
- - match: tests-green
630
- branch:
631
- - when: packages.present
632
- branch:
633
- - {
634
- when: { guard: forceApprove },
635
- chain: { state: close-package, action: close-package },
636
- }
637
- - rest: { state: agentic-review, awaits: agent }
638
- - use: afterCycleSettles
639
- - match: awaiting-review
640
- rest: { state: await-review, awaits: human }
641
-
642
- fallback:
643
- - {
644
- when: { all: [packages.present, packages.modified] },
645
- rest: { state: planning },
646
- }
647
- - { when: todo.exists, rest: { state: grilling, awaits: agent } }
648
- - { when: architecture.exists, rest: { state: architecting, awaits: agent } }
649
- - { when: { fact: reviewable }, rest: { state: review, awaits: agent } }
650
- - rest: { state: idle }
651
-
652
- entry:
653
- - { when: health.exists, gate: health-fixing }
654
- - { when: plan.exists, gate: grilled }
655
- - { when: architecture.exists, gate: architecting }
656
- - { gate: grilling }
657
- ```
658
-
659
- ## 5. What a user-built machine looks like
660
-
661
- A team that wants a minimal loop — no grilling, no learning, no squash, plus a
662
- **security sign-off** human gate after the automated review — writes roughly:
663
-
664
- ```yaml
665
- artifacts:
666
- spec: { path: .gtd/SPEC.md }
667
- security: { path: .gtd/SECURITY.md, probes: [checkbox-only-diff] }
668
- feedback: { path: .gtd/FEEDBACK.md, probes: [empty], consumedByTurn: fixing }
669
- errors: { path: .gtd/ERRORS.md }
670
- packages: { kind: package-set, path: .gtd/ }
671
-
672
- states:
673
- planning:
674
- {
675
- awaits: agent,
676
- prompts: { agent: prompts/plan.md },
677
- model: planning,
678
- emptyAgentTurn: inert,
679
- }
680
- building:
681
- {
682
- awaits: agent,
683
- prompts: { agent: prompts/build.md },
684
- model: execution,
685
- emptyAgentTurn: inert,
686
- }
687
- fixing:
688
- {
689
- awaits: agent,
690
- prompts: { agent: prompts/fix.md },
691
- model: execution,
692
- emptyAgentTurn: inert,
693
- }
694
- security-review:
695
- awaits: agent
696
- prompts: { agent: prompts/security-review.md } # agent writes .gtd/SECURITY.md findings
697
- model: planning
698
- emptyAgentTurn: inert
699
- await-signoff:
700
- awaits: human
701
- prompts: { human: prompts/await-signoff.md }
702
- gate: security-review
703
- checkoutWindow: true
704
- escalate: { awaits: human, prompts: { human: prompts/escalate.md } }
705
- idle: { awaits: human, prompts: { human: prompts/idle.md } }
706
- # … edges: spec.exists → planning; building turn → run-check(test);
707
- # tests-green → security-review; agent security turn (security.present) →
708
- # route awaiting-signoff; human signoff turn: checkbox-only/empty → done,
709
- # substantive → route back to fixing with the human's diff as feedback.
710
- ```
711
-
712
- The point of the example: the _human gate_ is just a state that `awaits: human`
713
- with a checkout window and an empty-turn-is-approval convention — the same three
714
- declarations the built-in review gate uses. Nothing about human gating is
715
- special-cased anymore.
716
-
717
- ## 6. The hard 20% — honest inventory
718
-
719
- Things in today's machine that resist naive table-ization, and where each lands:
720
-
721
- | Today's special case | Home in the design |
722
- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
723
- | `headIsFixerTurn` / `headIsHealthFixerTurn` precedence exceptions | auto-generated `unless` from artifact `consumedByTurn` (§3.2) |
724
- | force-approve interplay with a lingering FEEDBACK.md at tests-green | named guard + explicit `unless` on the interrupt rule (§3.6) — stays visibly ugly, which is honest |
725
- | delete-dispute (`pendingFeedbackDeletion` ≈ emptied file) | artifact-derived fact `*.pendingDeletion` + the `emptyEffective` composite |
726
- | empty-turn signal vs inert, per gate, guarded at two layers | mandatory per-state `emptyAgentTurn` policy; compiler wires both layers (§3.5) |
727
- | health-fix same-chain re-test carve-outs in `applyTurnTaking` | the genuinely hardest fit; likely a `run-check` edge annotation (`resumeChain: true`) rather than pure rules — needs a spike |
728
- | operational-recovery (boundary commit atop a building checkpoint) | engine-level fallback rule parameterized by "checkpoint gates" (states may declare `checkpoint: true`) |
729
- | squash-base / review-base / healthFixBase anchor computation | stays engine code — cycle-base discovery is history analysis, not policy |
730
- | review checkout window | program-edge, per-gate boolean only (§3.8) |
731
- | decision-log accumulation, prompt-cache-friendly concatenation | stays engine code; exposed to templates as a context field |
732
-
733
- If the health-fix carve-outs prove un-declarable, the fallback position is a
734
- small set of engine-recognized **loop idioms** (a "check loop" macro that
735
- expands to state + interrupt + carve-out) rather than leaking special cases into
736
- user configs.
737
-
738
- ## 7. Hazards specific to configurability
739
-
740
- **Config changes mid-history.** The machine's reading of history depends on the
741
- config; edit the config and yesterday's subjects may fall out of the closed
742
- grammar. The boundary rule makes this _degrade safely_ (unknown subjects go
743
- inert — same mechanism as v1 compat) but a rename mid-cycle can still strand a
744
- live cycle at an unresolvable rest. Mitigations, cheapest first: (1) document
745
- "change workflow config only at idle"; (2) the compiler warns when the current
746
- repo state resolves differently (or stops resolving) under the new config — it
747
- can literally run both resolutions and diff them; (3) optionally record a config
748
- content-hash in routing commits so the engine can _detect_ (not replay)
749
- cross-config history. Full config-versioned replay is not worth it — squash
750
- already collapses cycles, so history exposure is short.
751
-
752
- **Livelocks and junk-commit loops.** Today's guards against
753
- empty-turn-consumes-state disasters are hand-analyzed. The compiler checks in
754
- §3.11 catch the mechanical cases; the chain-depth bound catches the rest at
755
- runtime with a hard error instead of an infinite driver loop.
756
-
757
- **Testing story.** Three layers: (1) a **golden equivalence suite** — the
758
- existing `Machine.test.ts` / property tests run against both the legacy
759
- hardcoded resolver and the interpreter-with-default-config, asserting identical
760
- `Result`s for the same event streams (this is the migration's safety net, and
761
- `resolve()`'s purity makes it cheap); (2) the existing cucumber features keep
762
- passing unchanged against the default config, since the CLI surface doesn't
763
- move; (3) for _user_ configs, the engine ships generic property checks (every
764
- generated stream either progresses, rests awaiting an actor, refuses, or
765
- hard-errors — never silently loops), runnable as `gtd workflow check`.
766
-
767
- **Complexity budget.** The interpreter + compiler is a real cost; mutation
768
- testing an interpreter is harder than mutation testing unrolled branches. The
769
- counterweight: the unrolled branches are _already_ ~1800 lines whose safety
770
- rests on prose comments, and every new feature (learning, health, PLAN.md entry)
771
- has grown them by touching seven files (AGENTS.md's own removal checklist is the
772
- symptom). Table-driving shrinks the change surface of the next feature to "edit
773
- the default config."
774
-
775
- ## 8. Migration path
776
-
777
- 1. **Internal table extraction (no behavior change).** Finish what's started:
778
- fold `classifyHead`'s branches into literal rule rows; derive
779
- `ResolvePayload` artifact facts from an internal artifact table; generate
780
- `TurnGate`/`RoutingPhase`/illegal-combination lists from it. Golden tests pin
781
- equivalence. This step is valuable even if configurability never ships — it
782
- collapses the seven-file change surface.
783
- 2. **Interpreter + shipped default config.** Define the schema, write the
784
- compiler, express the default machine in it, and switch `resolve()` to the
785
- compiled tables. The legacy resolver stays as the test oracle for one
786
- release.
787
- 3. **Read user config.** Allow overrides of the _safe_ subset first: prompts,
788
- commands, params, model tiers, toggling shipped optional stages (learning/
789
- squash/health/agentic-review already have kill-switches — they become
790
- config-graph presence instead).
791
- 4. **Full custom machines.** Open states/edges/artifacts, ship
792
- `gtd workflow check`, document the view-model and primitive contracts.
793
-
794
- Each step is independently shippable, and steps 1–2 carry zero user-visible risk
795
- while retiring most of the implementation risk.
796
-
797
- ## Appendix A: the configuration surface, state by state
798
-
799
- ### A.1 Where the configuration lives
800
-
801
- The workflow definition is a separate, **committed** file — the machine's
802
- reading of history depends on it, so it must travel with the repo:
803
-
804
- ```
805
- gtd.workflow.yaml # the workflow definition (states, edges, artifacts…)
806
- .gtdrc.json # scalar knobs, unchanged: testCommand, params, models
807
- prompts/… # user template overrides (optional)
808
- ```
809
-
810
- `.gtdrc` keeps what it has today (per-user/per-machine layering via the cwd→home
811
- walk makes sense for models and commands); the workflow file has no layering —
812
- one repo, one machine. Absent `gtd.workflow.yaml`, the shipped default
813
- definition applies, which is byte-for-byte the current v2 behavior.
814
-
815
- Top-level keys of the workflow file:
816
-
817
- | Key | Contents |
818
- | ------------- | ---------------------------------------------------------------- |
819
- | `actors` | the turn-taking universe (fixed: `human`, `agent`) |
820
- | `commands` | named shell commands `run-check` edges may reference |
821
- | `params` | typed scalar parameters guards may reference (defaults + bounds) |
822
- | `artifacts` | steering-file declarations (§3.2) |
823
- | `commitFlags` | named commit predicates (§3.3) |
824
- | `counters` | fold rules over commit flags (§3.3) |
825
- | `guards` | named, reusable guard expressions (§3.4) |
826
- | `states` | the state declarations (this appendix) |
827
- | `interrupts` | ordered steering-file precedence rules (§3.6a) |
828
- | `onTurn` | HEAD-classification rules for turn commits (§3.6b) |
829
- | `onRouting` | HEAD-classification rules for routing commits (§3.6b) |
830
- | `fallback` | ordered boundary-HEAD ladder (§3.6c) |
831
- | `entry` | dirty-boundary entry-gate selection (§3.6) |
832
-
833
- Note what is **not** under `states`: transitions. Edges live in the four rule
834
- families, because resolution is history-replay — a state declaration describes
835
- what a rest _is_ (who's awaited, what prompt renders, what an empty turn means),
836
- while the rule families describe how HEAD + facts _select_ one.
837
-
838
- ### A.2 State property reference
839
-
840
- ```yaml
841
- states:
842
- <name>:
843
- kind: prompt | label
844
- awaits: human | agent | dynamic
845
- gate: <turn-gate label>
846
- prompts: { agent: <path>, human: <path> }
847
- model: <model-state name>
848
- emptyAgentTurn: inert | signal | { inertWhen: <guard> } | { signal: true, inertWhen: <guard> }
849
- validateAgentTurn: <validator name>
850
- checkoutWindow: <bool>
851
- checkpoint: <bool>
852
- stepAction: { human: <action>, agent: <action> }
853
- ```
854
-
855
- | Property | Applies to | Meaning / allowed values |
856
- | ------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
857
- | `kind` | all | `prompt` — a real rest: `gtd next` renders a prompt, an actor is awaited. `label` — a name that only appears as the `state:` on chain (mid-chain) rules, for `gtd status`/`--json` display; never rests, never prompts. Replaces today's `PROMPT_STATES` complement (testing, planning, close-package, done, health-check, learning-applied). |
858
- | `awaits` | `kind: prompt` | Which actor's `step` is accepted at this rest. `dynamic` — the awaited actor is decided per-rest by the matching rule (`rest: { state, awaits }`), for the states that alternate (grilling/architecting: agent iterates, human answers). Any other invoker is refused. Source of today's `awaitedActor`. |
859
- | `gate` | `kind: prompt` | The `<gate>` label the captured turn commit carries (`gtd(<actor>): <gate>`). Defaults to the state name. Overrides exist for states that _share_ a gate with another state — `await-review` captures under `review`, `await-learning-review` under `learning`. Replaces `STATE_IS_OWN_GATE` + `GATE_OVERRIDES`. Compiler checks the generated grammar for collisions. |
860
- | `prompts` | `kind: prompt` | Template path per awaitable actor. A state with `awaits: dynamic` must bind both; others exactly one. Templates get the documented view-model (`context`, `model`, `fenceFor`, shared partials). Replaces `STATE_TEMPLATE` + the grilling/architecting actor split in `buildPrompt`. |
861
- | `model` | agent-prompting states | Which model-resolution name `{{MODEL}}` resolves against (today's `ModelState`: `grilling`, `architecting`, `decompose`, `building`, `fixing`, `agentic-review`, `clean` — themselves config: each names a tier, overridable per state in `.gtdrc` exactly as now). Human-awaited states carry none. Replaces `MODEL_STATE`. |
862
- | `emptyAgentTurn` | agent-awaited rests | What a clean-tree `gtd step-agent` means here. `inert` — no-op, zero commits, `gtd next` re-emits (the `INERT_EMPTY_AGENT_GATES` set). `signal` — the empty turn is captured and means something (health-fixing's environmental fix). `inertWhen: <guard>` — conditional (squashing/learning while the template is unmodified; health-fixing while HEAD is the human's entry turn). The compiler wires this into BOTH layers (capture guard and historical-HEAD classification), which today must be kept in sync by hand. **Mandatory** for every agent-awaited rest — the author must decide. |
863
- | `validateAgentTurn` | agent-awaited rests | Named structural validator run before capturing the agent's turn; malformed → refusal with the error list, zero commits (today: `open-questions` on grilling/architecting, `review-doc` on review). Never applied to human turns. |
864
- | `checkoutWindow` | human-awaited rests | Hold the review checkout window open while this rest is pending (HEAD/index rewound to the review base so editors surface the diff). Program-edge wiring only; the resolver never sees it. Today: hardcoded to `await-review`. |
865
- | `checkpoint` | agent-awaited rests | This state's turn commit is an operational-recovery resume point: a boundary commit landing on top of it (config fix after a mid-chain crash) re-resolves to this state's continuation instead of hard-erroring. Today: hardcoded to `building`. |
866
- | `stepAction` | `kind: prompt` | Escape hatch for the rests where an actor's `step` performs an action _instead of_ capturing a turn. Today's only case: `idle.stepAction.human = run-check` (a human step at idle re-runs the health check, never authoring an empty turn). Kept deliberately rare — most behavior belongs in `onTurn` rules. |
867
-
868
- What is deliberately **not** a state property: the awaited-human-empty-turn
869
- meaning (an empty human turn is always captured — whether it means "accept
870
- defaults", "approve", or "resume" is decided by the `onTurn` branch that
871
- classifies the captured commit); transitions; and anything that would let a
872
- state execute arbitrary code.
873
-
874
- ### A.3 The default machine, all 21 states
875
-
876
- ```yaml
877
- states:
878
- # ── grilling: product plan ─────────────────────────────────────────────
879
- grilling:
880
- kind: prompt
881
- awaits: dynamic # agent iterates .gtd/TODO.md; human answers open questions
882
- prompts:
883
- agent: prompts/grilling-agent.md
884
- human: prompts/grilling-answers.md
885
- model: grilling # planning tier
886
- emptyAgentTurn: inert
887
- validateAgentTurn: open-questions
888
-
889
- # ── architecting: technical plan ───────────────────────────────────────
890
- architecting:
891
- kind: prompt
892
- awaits: dynamic
893
- prompts:
894
- agent: prompts/architecting-agent.md
895
- human: prompts/architecting-answers.md
896
- model: architecting
897
- emptyAgentTurn: inert
898
- validateAgentTurn: open-questions
899
-
900
- # ── grilled: decompose into packages ───────────────────────────────────
901
- grilled:
902
- kind: prompt
903
- awaits: agent # the human's grilled turn exists only as the PLAN.md entry (entry rules)
904
- prompts: { agent: prompts/decompose.md }
905
- model: decompose
906
- emptyAgentTurn: inert # no packages written → re-emit; never consume ARCHITECTURE.md
907
-
908
- # ── build lifecycle ────────────────────────────────────────────────────
909
- planning: { kind: label } # transient: packages present + .gtd/ modified (promptless today)
910
- building:
911
- kind: prompt
912
- awaits: agent
913
- prompts: { agent: prompts/building.md }
914
- model: building # execution tier
915
- emptyAgentTurn: inert
916
- checkpoint: true # boundary commit atop the building turn resumes the test chain
917
- testing: { kind: label } # the run-check hop
918
- fixing:
919
- kind: prompt
920
- awaits: agent
921
- prompts: { agent: prompts/fixing.md }
922
- model: fixing
923
- emptyAgentTurn: inert
924
- escalate:
925
- kind: prompt
926
- awaits: human # deleting .gtd/ERRORS.md is the move; onTurn rule chains a fresh run-check
927
- prompts: { human: prompts/escalate.md }
928
-
929
- # ── per-package review ─────────────────────────────────────────────────
930
- agentic-review:
931
- kind: prompt
932
- awaits: agent # writes .gtd/FEEDBACK.md; empty file = approval (artifact probe)
933
- prompts: { agent: prompts/agentic-review.md }
934
- model: agentic-review
935
- emptyAgentTurn: inert # no FEEDBACK.md written at all is NOT an approval
936
- close-package: { kind: label }
937
-
938
- # ── cycle-end human review ─────────────────────────────────────────────
939
- review:
940
- kind: prompt
941
- awaits: agent # writes .gtd/REVIEW.md
942
- prompts: { agent: prompts/review.md }
943
- model: clean
944
- emptyAgentTurn: inert
945
- validateAgentTurn: review-doc
946
- await-review:
947
- kind: prompt
948
- awaits: human
949
- gate: review # human turn is captured under the shared "review" gate
950
- prompts: { human: prompts/await-review.md }
951
- checkoutWindow: true
952
- done: { kind: label }
953
-
954
- # ── learning ───────────────────────────────────────────────────────────
955
- learning:
956
- kind: prompt
957
- awaits: agent
958
- prompts: { agent: prompts/learning.md }
959
- model: clean
960
- emptyAgentTurn: { inertWhen: learnings.isTemplate }
961
- await-learning-review:
962
- kind: prompt
963
- awaits: human # empty turn = accept draft; there is no reject path (onTurn rule)
964
- gate: learning
965
- prompts: { human: prompts/await-learning-review.md }
966
- learning-apply:
967
- kind: prompt
968
- awaits: agent
969
- prompts: { agent: prompts/learning-apply.md }
970
- model: clean
971
- emptyAgentTurn: inert # a doc-edit move: clean tree = nothing to apply yet
972
- learning-applied: { kind: label }
973
-
974
- # ── squash ─────────────────────────────────────────────────────────────
975
- squashing:
976
- kind: prompt
977
- awaits: agent
978
- prompts: { agent: prompts/squashing.md }
979
- model: clean
980
- emptyAgentTurn: { inertWhen: squashMsg.isTemplate } # never squash the placeholder
981
-
982
- # ── idle / health ──────────────────────────────────────────────────────
983
- idle:
984
- kind: prompt
985
- awaits: human
986
- prompts: { human: prompts/idle.md }
987
- stepAction: # human step at idle re-runs the health check, never an empty turn
988
- human:
989
- run-check:
990
- command: test
991
- green: { stop: true, chainWhen: { fact: healthFixBasePresent } }
992
- red:
993
- write: health
994
- cap: { counter: healthFix, param: fixAttemptCap, write: errors }
995
- route: health-check
996
- health-check: { kind: label }
997
- health-fixing:
998
- kind: prompt
999
- awaits: agent # also the HEALTH.md entry gate for a human (entry rules)
1000
- prompts: { agent: prompts/health-fixing.md }
1001
- model: fixing
1002
- emptyAgentTurn: # empty agent turn = environmental fix (remove HEALTH.md, re-test)…
1003
- signal: true
1004
- inertWhen: { head.isTurn: [human, health-fixing] } # …unless the human's entry turn is still unread
1005
- ```
1006
-
1007
- Reading the table against today's code: `kind` reproduces
1008
- `PROMPT_STATES`/`isPromptState`; `awaits` reproduces `awaitedActor` plus the
1009
- dynamic grilling/architecting split; `gate` reproduces `gateForState`;
1010
- `prompts` + `model` reproduce `STATE_TEMPLATE` + `MODEL_STATE`; `emptyAgentTurn`
1011
- reproduces `INERT_EMPTY_AGENT_GATES` + `isInertEmptyAgentRest`'s conditional
1012
- cases; `validateAgentTurn` reproduces the two structural-validation refusals in
1013
- `applyTurnTaking`; `checkpoint` reproduces the operational-recovery rung;
1014
- `stepAction` reproduces the idle health-check carve-out. Every hardcoded
1015
- per-state fact in the engine has exactly one home in the declaration — which is
1016
- the test the schema was designed against.
1017
-
1018
- ## Appendix B: the diff-driven chain model (a radical simplification)
1019
-
1020
- > A second-round exploration: can the four rule families of §3.6 collapse into
1021
- > ONE mechanism? Proposal under test: _each state is defined by its previous
1022
- > state(s) plus diff-matching rules; matching always commits the new state; a
1023
- > clean tree means we ARE at the derived state, where exactly one state command
1024
- > runs (its output selecting what happens next) — otherwise the state just emits
1025
- > its prompt._
1026
-
1027
- ### B.1 The model
1028
-
1029
- Three ideas, and everything else falls out:
1030
-
1031
- 1. **State = commit label.** Every workflow commit is labeled with the state it
1032
- entered: `gtd(human): grilling`, `gtd(agent): fixing`, `gtd: tests-green`
1033
- (machine-authored). The current state is simply the label of the nearest
1034
- labeled commit (boundary commits — anything unlabeled — are skipped, as
1035
- `lastWorkflowTurn` already does).
1036
-
1037
- 2. **Transition = diff classification.** A state declares which predecessor
1038
- states it can follow (`from`) and what shape of pending changes selects it
1039
- (`when`: touched/added/deleted paths, plus the closed probe set). When the
1040
- tree is dirty, the engine finds the matching state for _(previous state,
1041
- invoker, diff)_ and **commits the changes under that label**. No separate
1042
- "capture" vs "routing" concepts — the machine's own file writes re-enter the
1043
- same classifier as everyone else's.
1044
-
1045
- 3. **A state either acts or waits.** On a clean tree at state S: if S declares a
1046
- `command`, run it — its outcome (exit code, or a built-in primitive's effect)
1047
- either writes files (which re-enter rule 2 as the machine actor) or stops. If
1048
- S declares no command, emit S's `prompt`. Command XOR prompt.
1049
-
1050
- The driver loop becomes:
1051
-
1052
- ```
1053
- loop:
1054
- prev ← nearest labeled commit (skip boundary commits)
1055
- if tree is dirty:
1056
- rule ← first rule in states[*] with prev ∈ from, actor = invoker, when ⊨ diff
1057
- none matched → refuse (wrong actor's turn, or unrecognized changes)
1058
- git commit -am "gtd(<actor>): <rule.state>" # ← the ONLY mutation
1059
- else if invoker is stepping and some rule has when: empty:
1060
- git commit --allow-empty under that rule's state # empty turn as signal
1061
- else:
1062
- S ← states[prev.label]
1063
- S.command? → run it; primitive writes/deletes files → loop (as machine actor)
1064
- → or terminal outcome → stop
1065
- S.prompt? → emit prompt; stop
1066
- ```
1067
-
1068
- Every iteration either commits (progress) or stops (prompt / refusal / done), so
1069
- termination is the chain-depth bound for free.
1070
-
1071
- ### B.2 What this dissolves
1072
-
1073
- Checking the model against every mechanism in the current engine:
1074
-
1075
- | Current mechanism | Fate in the diff-driven model |
1076
- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1077
- | Turn commits vs routing commits (two namespaces) | **Unified.** One label grammar; the machine is just a third actor whose writes classify like anyone's |
1078
- | `classifyHead` table (§3.6b) | **Dissolved.** HEAD's label IS the state — no re-derivation of "what did this subject mean" |
1079
- | Steering-file precedence ladder (§3.6a) | **Mostly dissolved.** It exists today because subjects under-determine state (a fresh FEEDBACK.md is newer than HEAD). Here every write is committed-with-label at once, so the file/HEAD divergence it compensates for cannot arise |
1080
- | Boundary fallback ladder (§3.6c) | **Dissolved into `from`.** `todo.exists → grilling` etc. were re-anchoring rules; with labels authoritative, only "skip boundary commits" remains |
1081
- | Entry points (dirty tree at boundary → which gate) | **Just rules from `start`.** `from: [start], when: { adds: plan } → decompose-entry` — entry stops being special |
1082
- | `INERT_EMPTY_AGENT_GATES` + `isInertEmptyAgentRest` | **Inert-by-default.** A clean tree matches nothing unless a state explicitly declares `when: empty` — the entire bug class ("does this gate consume state on an empty turn?") becomes opt-in instead of curated |
1083
- | Empty human turn = signal (accept/approve/resume) | An explicit `when: { empty: true }, actor: human` rule per gate that wants it |
1084
- | Counter flags derived from subjects + diffs | **Simplified.** Counters fold directly over state labels in history (`count("test-failed") since last "package-start"-class label`) — no diff inspection needed, the label already encodes it |
1085
- | Crash recovery (write landed, commit didn't) | **Free.** The orphaned write is a dirty tree that reclassifies identically on the next invocation — the write path and the recovery path are the same code |
1086
- | `applyTurnTaking` refusals | Kept, but shrunk: "no rule for this (state, invoker, diff)" is the refusal |
1087
- | `predictTurn` / `gtd status` | Trivial: report which rule the current diff would match |
1088
-
1089
- ### B.3 What must be layered back (the honest list)
1090
-
1091
- The one-sentence model needs eight amendments to reproduce current behavior —
1092
- all small, none breaking the single-mechanism shape:
1093
-
1094
- 1. **Actor on rules.** Diff shape can't distinguish the human's TODO.md answers
1095
- from the agent's TODO.md iteration; the invoker can. Rules match _(from,
1096
- actor, diff)_, not _(from, diff)_.
1097
- 2. **`from` is a list.** Fixing follows building-red AND escalate-resume;
1098
- grilling follows start AND review-feedback. Strict single-predecessor is too
1099
- narrow; lists preserve the shape.
1100
- 3. **Ordered rules per (from, actor).** At awaiting-review, human:
1101
- checkbox-only/deletes-review/empty → approve must be tried before
1102
- anything-else → feedback. First match wins, declaration order.
1103
- 4. **The closed probe set rides on matchers.** `adds: feedback` splits into
1104
- `{ adds: feedback, probe: empty }` vs non-empty;
1105
- `{ touches: review, probe: checkbox-only }`;
1106
- `{ touches: squashMsg, probe: template-modified }`. Same three probes as §3.2
1107
- — content still never steers beyond them.
1108
- 5. **Commands are the closed primitive set, with guarded outcomes.** A state
1109
- command is a shell command (exit-code branch) or a built-in
1110
- (`write-template`, `seed`, `close-package`, `squash`, `stop`). Outcomes may
1111
- carry guards over counters/params
1112
- (`red below cap → write output to feedback; red at cap → write to errors`) —
1113
- this is where `fixAttemptCap`, `reviewThreshold`, `learning`/`squash` toggles
1114
- live.
1115
- 6. **Machine-labeled marker commits.** Some outcomes change no files (tests
1116
- green) but must be observable in history for the fold — `--allow-empty`
1117
- machine commits (`gtd: tests-green`). Today's routing commits survive only in
1118
- this reduced role.
1119
- 7. **Guarded prompt/command split.** `tests-green` is a prompt state
1120
- (agentic-review) _unless_ force-approve holds, where it acts (close-package).
1121
- Either allow `command: { when: <guard>, … }` falling back to the prompt, or
1122
- accept an extra marker commit. The former is one wrinkle; the latter is
1123
- noisier history (squash collapses it anyway).
1124
- 8. **Refusal semantics.** Dirty tree matching no rule = hard refusal (today's
1125
- out-of-turn error + illegal-combination error, unified). The
1126
- corrupt-repo-refuses-to-guess contract survives as "no rule matched."
1127
-
1128
- ### B.4 The default machine, sketched in this model
1129
-
1130
- A representative slice (entry → grilling → build/test loop → review), showing
1131
- that the 21 states redraw naturally as ~a-couple-dozen labeled states:
1132
-
1133
- ```yaml
1134
- states:
1135
- # ── entry (from `start` = boundary HEAD; replaces the entry table) ──────
1136
- grilling-entry:
1137
- from: [start, done]
1138
- actor: human
1139
- when: { any: true } # lowest-priority catch-all: any sketch/notes
1140
- prompt: grilling-agent.md
1141
- architecting-entry:
1142
- from: [start, done]
1143
- actor: human
1144
- when: { adds: architecture }
1145
- prompt: architecting-agent.md
1146
- plan-entry:
1147
- from: [start, done]
1148
- actor: human
1149
- when: { adds: plan }
1150
- command: { seed: { from: plan, to: architecture } } # writes → re-classify
1151
- health-entry:
1152
- from: [start, done]
1153
- actor: human
1154
- when: { adds: health }
1155
- prompt: health-fixing.md
1156
-
1157
- # ── grilling (alternating turns, one label) ─────────────────────────────
1158
- grilling-draft:
1159
- from: [grilling-entry, grilling-answer, review-feedback]
1160
- actor: agent
1161
- when: { touches: todo }
1162
- validate: open-questions
1163
- prompt: grilling-answers.md # awaiting the human
1164
- grilling-answer:
1165
- from: [grilling-draft]
1166
- actor: human
1167
- when: { touches: any }
1168
- prompt: grilling-agent.md # back to the agent
1169
- grilling-accept:
1170
- from: [grilling-draft]
1171
- actor: human
1172
- when: { empty: true } # accept-defaults signal
1173
- command: { seed: { from: todo, to: architecture } }
1174
- architecture-seeded:
1175
- from: [plan-entry, grilling-accept]
1176
- actor: gtd # the machine's own seed write, classified like any diff
1177
- when: { adds: architecture, deletes: [todo, plan] }
1178
- prompt: architecting-agent.md # (decompose.md when from plan-entry)
1179
-
1180
- # ── build / test loop ───────────────────────────────────────────────────
1181
- building:
1182
- from: [planning, package-done]
1183
- actor: agent
1184
- when: { touches: code } # code = everything outside .gtd/
1185
- command:
1186
- run: test
1187
- removeFirst: [feedback]
1188
- green: { markerState: tests-green }
1189
- red:
1190
- write: { output-to: feedback }
1191
- atCap:
1192
- {
1193
- counter: testFix,
1194
- param: fixAttemptCap,
1195
- write: { output-to: errors },
1196
- }
1197
- test-failed:
1198
- from: [building, fixing, escalate-resume]
1199
- actor: gtd
1200
- when: { adds: feedback }
1201
- prompt: fixing.md
1202
- test-failed-cap:
1203
- from: [building, fixing, escalate-resume]
1204
- actor: gtd
1205
- when: { adds: errors }
1206
- prompt: escalate.md
1207
- fixing:
1208
- from: [test-failed, review-findings]
1209
- actor: agent
1210
- when: { touches: any }
1211
- command: { run: test, removeFirst: [feedback], green: …, red: … }
1212
- escalate-resume:
1213
- from: [test-failed-cap]
1214
- actor: human
1215
- when: { deletes: errors } # deletion resets the budget
1216
- command: { run: test, … }
1217
- tests-green:
1218
- from: [building, fixing]
1219
- actor: gtd
1220
- when: { marker: true } # --allow-empty machine commit
1221
- command: # guarded split (amendment 7)
1222
- when: { any: [{ not: { param: agenticReview } }, { counterAtLeast: … }] }
1223
- then: close-package
1224
- prompt: agentic-review.md # otherwise: rest for the reviewer
1225
-
1226
- # ── agentic review verdict: two rules on one file, split by probe ──────
1227
- review-approve:
1228
- from: [tests-green]
1229
- actor: agent
1230
- when: { adds: feedback, probe: empty }
1231
- command: close-package
1232
- review-findings:
1233
- from: [tests-green]
1234
- actor: agent
1235
- when: { adds: feedback }
1236
- prompt: fixing.md
1237
-
1238
- # ── human review gate ───────────────────────────────────────────────────
1239
- review-written:
1240
- from: [package-done] # via a remaining-packages guard on package-done
1241
- actor: agent
1242
- when: { adds: review }
1243
- validate: review-doc
1244
- prompt: await-review.md
1245
- checkoutWindow: true
1246
- review-approved:
1247
- from: [review-written]
1248
- actor: human
1249
- when:
1250
- any:
1251
- [
1252
- { empty: true },
1253
- { touches: review, probe: checkbox-only },
1254
- { deletes: review },
1255
- ]
1256
- command: { remove: [review], markerState: done }
1257
- review-feedback:
1258
- from: [review-written]
1259
- actor: human
1260
- when: { touches: any } # ordered after review-approved
1261
- command: { remove: [review], markerState: … }
1262
- prompt: grilling-agent.md # re-grill with the human's diff
1263
- ```
1264
-
1265
- Readability bonus: `git log --oneline` becomes a legible state trace — every
1266
- commit names the state it entered, with no second grammar to decode.
1267
-
1268
- ### B.5 Assessment
1269
-
1270
- **This is a better core model than §3.6.** It replaces four rule families, two
1271
- commit namespaces, and three hand-curated guard sets with one classifier, and
1272
- its default failure mode is the safe one (unmatched = inert or refused, never
1273
- state-consuming). The earlier building blocks don't disappear — actors,
1274
- artifacts, probes, counters, params, prompts, the primitive toolbox, and the
1275
- compiler all survive unchanged — but §3.5's state schema and §3.6's four rule
1276
- families merge into a single `states` block whose entries are _(from, actor,
1277
- when) → command | prompt_. Roughly: eleven building blocks become seven.
1278
-
1279
- What it trades away, named plainly:
1280
-
1281
- - **File presence stops being a second source of truth.** Today a committed
1282
- ERRORS.md forces escalate no matter what HEAD says — belt and suspenders. Here
1283
- the label is the single truth; a repo whose labels are wrong (manual history
1284
- surgery) degrades differently (boundary-skip to an older label) rather than
1285
- being caught by a file check. The compiler can keep conflict declarations as
1286
- _lint_ (refuse a commit that would create HEALTH.md alongside packages)
1287
- without them steering resolution.
1288
- - **More states.** Alternating-actor gates split into explicit per-actor states
1289
- (`grilling-draft` / `grilling-answer`), and marker states appear. The graph is
1290
- longer but each node is smaller and self-describing — a good trade for a
1291
- _configurable_ machine, where node count is cheap and implicit semantics are
1292
- expensive.
1293
- - **A subtler `from`-set discipline.** Mislabeling a predecessor list produces a
1294
- dead rule; totality/reachability checking (§3.11) stops being nice-to-have and
1295
- becomes the product.
1296
-
1297
- Migration consequence: this model is what the **compiler should target** —
1298
- §3.6's families were designed to mirror the existing engine's passes, but the
1299
- existing passes are the accident, not the spec. Phase 1 (table extraction,
1300
- golden equivalence tests) is unchanged; phase 2's interpreter should implement
1301
- the chain model directly, with the current 21-state behavior expressed in it as
1302
- the shipped default and the equivalence suite pinning the semantics.
1303
-
1304
- ## Appendix C: the purity test — `next = δ(label, diff)` and nothing else
1305
-
1306
- > Third-round exploration: what if the next state were **only** a function of
1307
- > the last commit label and the current diff? What has to change, and where does
1308
- > it fall down? (Reference point: the guard-input taxonomy — HEAD subject,
1309
- > HEAD's own diff, folded history, config, content probes, invoker — of which
1310
- > only tree/diff facts are δ-compatible today.)
1311
-
1312
- ### C.1 The target
1313
-
1314
- ```
1315
- δ(label(nearest labeled commit), pending diff) → next label // then commit it
1316
- ```
1317
-
1318
- One reading choice up front: "last commit label" means the nearest **labeled**
1319
- commit, skipping unlabeled (boundary) commits — the same walk `lastWorkflowTurn`
1320
- does today. This single concession keeps rider commits (config fixes,
1321
- operational recovery) survivable; without it, any manual commit mid-cycle
1322
- destroys the state.
1323
-
1324
- ### C.2 What has to change — five moves
1325
-
1326
- **Move 1: split states until the label carries every read-time branch.** Today
1327
- `gtd(human): grilling` means accept-defaults OR another round, decided by
1328
- inspecting the turn's own diff at the NEXT resolution (a "past diff" —
1329
- inadmissible). Under δ, the decision must be made at capture time, when the
1330
- empty diff IS the current diff, and encoded in the label:
1331
- `gtd(human): grilling-answered` vs `gtd(human): grilling-accepted`. Same for the
1332
- human review turn (`review-approved` / `review-feedback` at capture, from the
1333
- checkbox-only/deletion diff shape). The ~21 states become ~30, each label
1334
- unambiguous.
1335
-
1336
- **Move 2: config decisions move to write time.** `agenticReview` off, or the
1337
- review-fix threshold reached, currently changes what `gtd: tests-green` _means_
1338
- at read time. Under δ, config can't feed the transition — so the check action
1339
- (which runs with config in hand) writes the decided label directly: green →
1340
- `gtd: agentic-review` or `gtd: close-package`, chosen when acting. Semantic
1341
- shift: flipping a kill-switch no longer affects an in-flight rest (the label
1342
- already committed the decision); config is read at decision points, not on every
1343
- resolution. Arguably more predictable — but a behavior change.
1344
-
1345
- **Move 3: counters move into the labels.** `test-failed` below vs at cap is a
1346
- fold over history — inadmissible. Under δ the label carries the counter:
1347
- `gtd: test-failed 2/3`. The writer computes it from the PREVIOUS label's counter
1348
- (+1), so the value is derivable from `(label, diff)` alone and
1349
- crash-recomputable. Cost: budgets become wire format (changing `fixAttemptCap`
1350
- semantics is a grammar change), and any counter that spans several states
1351
- (reviewFixCount survives fixing → building → tests-green) must be carried
1352
- through every intermediate label — a state vector in every subject line.
1353
-
1354
- **Move 4: content probes become diff matchers — and mostly improve.** The three
1355
- probes are largely diff-expressible already: FEEDBACK.md-empty is "adds the file
1356
- with zero content lines"; checkbox-only is literally a diff shape;
1357
- template-unmodified becomes "the pending diff does not touch SQUASH_MSG.md"
1358
- (inert) vs "touches it" (proceed) — arguably _cleaner_ than content comparison.
1359
- Narrow loss: a rider-committed real squash message can no longer be consumed by
1360
- an empty turn (today the content check allows it); under δ an untouched file is
1361
- indistinguishable from an untouched template.
1362
-
1363
- **Move 5: the external world enters only through diffs.** A test run's outcome
1364
- is neither label nor diff — but its EFFECT is: the check writes
1365
- FEEDBACK.md/ERRORS.md/nothing, and that machine-authored write re-enters δ as
1366
- the next transition's diff (plus the outcome label from move 2). This
1367
- generalizes to an invariant the engine must enforce: **every effect must
1368
- materialize as a label or a file change, or it is invisible to the machine.**
1369
-
1370
- With those five moves, the interrupt and fallback ladders genuinely dissolve:
1371
- they exist today because labels under-determine state (a fresh FEEDBACK.md is
1372
- newer than HEAD; ERRORS.md outlives its commit's subject). When every write
1373
- lands with its decided label, file presence never disagrees with the label.
1374
- Entry points survive as δ's rules from the `start` pseudo-label (no labeled
1375
- commit found): `(start, adds .gtd/HEALTH.md) → health-entry`, etc.
1376
-
1377
- ### C.3 A traced scenario
1378
-
1379
- ```
1380
- label (nearest) pending diff at invocation δ → committed label
1381
- ─────────────────────── ───────────────────────────────── ─────────────────────────────
1382
- (start) adds notes.md, .gtd/TODO.md gtd(human): grilling-entry
1383
- grilling-entry touches .gtd/TODO.md gtd(agent): grilling-draft
1384
- grilling-draft (empty — accept defaults) gtd(human): grilling-accepted
1385
- grilling-accepted machine seed: +ARCH.md −TODO.md gtd: architecting
1386
- … … …
1387
- building (pkg 01) code changes gtd(agent): building
1388
- building turn check writes FEEDBACK.md gtd: test-failed 1/3
1389
- test-failed 1/3 code changes (the fix) gtd(agent): fixing
1390
- fixing turn check writes nothing (green) gtd: agentic-review ← config+count decided HERE
1391
- agentic-review adds empty FEEDBACK.md gtd: close-package ← emptiness read from the DIFF
1392
- close-package machine rm: pkg dir, FEEDBACK.md gtd: review ← reviewable decided at write time
1393
- ```
1394
-
1395
- Every row is `(label, diff) → label`. The history reads as a complete replay
1396
- log: anyone (or any tool) can re-derive every state with no counters, no config,
1397
- no file inspection.
1398
-
1399
- ### C.4 Where it falls down
1400
-
1401
- 1. **Turn attribution.** The human's answer and the agent's iteration can be
1402
- byte-identical diffs. δ survives ONLY because state-splitting makes every
1403
- state await exactly one actor — so the actor adds no information to the
1404
- transition. But the invoker is still required as **authentication** (refuse
1405
- the wrong actor); δ can be pure while the CLI cannot. And no diff carries
1406
- authorship: two actors editing before a capture are indistinguishable,
1407
- exactly as today (pending edits ride along).
1408
- 2. **Action parameters and prompts stay impure.** The squash needs the cycle
1409
- base SHA; the review gate needs `diff(reviewBase..HEAD)`; grilling inlines
1410
- the decision log. The _transition_ is δ-pure, but `perform()` and
1411
- `buildPrompt()` must read history — the machine is pure, the product around
1412
- it is not. This is a scoping line, not a flaw, but it must be drawn
1413
- explicitly.
1414
- 3. **Counter-bearing labels are a real wire-format tax.** `gtd: test-failed 2/3`
1415
- is tolerable; carrying reviewFixCount through five unrelated labels is not
1416
- pretty, and every budget-semantics tweak becomes a breaking-history change.
1417
- This is the single strongest argument that the current design (counters
1418
- folded at read time) is the better trade.
1419
- 4. **Write-time config freezes in-flight decisions.** The kill-switch flip that
1420
- today force-approves a package resting at agentic-review would do nothing
1421
- until the next decision point. Defensible, but different.
1422
- 5. **`start` must absorb everything unlabeled.** Post-squash HEADs, fresh repos,
1423
- and pre-gtd history all resolve to `start` — fine. But a _mid-cycle_ history
1424
- whose labels are gone (rebase, squash-by-hand) silently restarts instead of
1425
- erroring; today's corruption checks catch some of this. Strict δ trades
1426
- "refuse to guess" for "quietly re-enter."
1427
-
1428
- ### C.5 Verdict
1429
-
1430
- δ-purity is ~90% achievable and the exercise is clarifying: moves 1, 4, and 5
1431
- (state splitting, probes-as-diff-matchers, effects-as-diffs) would improve the
1432
- current design and are adoptable incrementally without the rest. Moves 2–3
1433
- (config and counters into write-time labels) are where purity starts charging
1434
- rent — wire-format coupling and frozen decisions — and the current
1435
- read-time-fold design is the deliberate impurity that keeps budgets and
1436
- kill-switches out of the commit grammar. The irreducible residue is turn
1437
- authentication (the invoker) and history-reading actions/prompts, which no
1438
- labeling scheme can absorb because neither authorship nor anchors exist in any
1439
- single diff.