End-to-end compile of FederatedMeetup.speckdl (with the cycle-65
Transition envelope added) surfaced a real bug: the speckdl uses
default-value syntax on event fields:
initial_mesh_peers: List<InitialMeshPeer> = []
The default is a Speckl semantic — a state-machine init value —
but the .proto wire format has no place for it. Without stripping,
the type expression 'List<InitialMeshPeer> = []' was emitted
verbatim as the field type, producing broken proto3 syntax that
protoc rejected.
Fix: parseEventBlockMultiline now strips '= <expr>' suffix from
the type expression before calling parseTypeExpr. The default value
is dropped (it's a Speckl semantic only; the wire format carries
no default).
Tests: 1 new parser test covers the federated-meetup
'initial_mesh_peers: List<InitialMeshPeer> = []' shape. All 56
tests pass (55 baseline + 1 new). tsc build clean.
End-to-end verified: with this fix and the cycle-65 federated-
meetup speckdl changes, the Speckl-generated .proto now compiles
cleanly with 'protoc' (previously rejected).
End-to-end compile of FederatedMeetup.speckdl with the new
'state as StateSnapshot' header (cycle 63) surfaced that the
parser's main loop and parseMemberBlock dispatcher were both
missing 'state as ' as a recognized block-starter prefix. The
state-block parser (parseStateBlockMultiline) already extracts
the 'as' clause correctly (cycle 59), but the call site never
reached it: the block fell through the 'state:' / 'state {'
checks and was parsed as a regular member line, producing no
StateNode at all.
Fix: add 'state as ' to the blockStarters list in the main
parse loop, and add a parallel dispatch in parseMemberBlock
that routes 'state as ' headers to parseStateBlockMultiline.
The dispatcher change is symmetric with the existing 'state:'
and 'state {' forms.
Tests: 1 new parser test confirms the dispatch works for
'state as StateSnapshot { ... }'. All 55 tests pass (54
baseline + 1 new). tsc build clean.
End-to-end verified: running generateProtobuf against the
federated-meetup speck with 'state as StateSnapshot' now
emits 'message StateSnapshot { ... }' (correct), where
pre-fix the StateNode was lost entirely.
End-to-end compile of FederatedMeetup.speckdl surfaced a real
generator input bug: inline line comments on field declarations
were bleeding into the parsed type expression. A field like
mesh_ip: Bytes // 4 bytes (IPv4 overlay)
produced a TypeExpr whose name was literally
'Bytes // 4 bytes (IPv4 overlay)'
which the .proto generator emitted verbatim:
bytes // 4 bytes (IPv4 overlay) mesh_ip = 2;
— broken syntax (comment in the type position).
Fix: both parseEventBlockMultiline and parseInterfaceBlockMultiline
now strip '// ...' suffixes from each inner line BEFORE matching the
'name: type' regex or splitting on commas. The state-block parser
already had this; cycle 62 brings event and interface to parity.
Tests: 2 new parser tests covering event and interface blocks, both
with the FederatedMeetup-style inline comment. All 54 tests pass
(52 baseline + 2 new). tsc build clean.
End-to-end verified: running generateProtobuf against the real
FederatedMeetup.speckdl now emits clean 'bytes mesh_ip = 2;' for
the InitialMeshPeer interface, where cycle 62's pre-fix output
was the broken 'Bytes // 4 bytes (IPv4 overlay) mesh_ip = 2;'.
This unblocks the next phase of the federated-meetup Speckl
migration: the consumer Go code can now bind to the generated
proto without a half-broken field in the way.
Adds 'event_suffix: "Payload"' as a top-of-file directive. When set,
the .proto generator appends the suffix to every 'event' block's
emitted message name. Default (no directive) preserves the existing
'event CreateGroup' → 'message CreateGroup' behavior.
Why: federated-meetup's handwritten proto names every transition
payload CreateGroupPayload, AddStewardPayload, etc. (the convention
that the wrapper message carries a 'Payload' suffix and the event
type lives in 'oneof payload { ... }'). Without this directive, the
Speckl-generated proto would emit 'message CreateGroup' and the
consumer Go code would fail to find the types it imports.
The test fixture uses three tests:
- directive parsed and stored on SpeckNode
- directive absent → eventSuffix undefined
- both quoted ("Payload") and bare (Payload) values accepted
The reset of (parseSpeck as any)._protoPackage / _goPackage /
_eventSuffix at the start of parseSpeck fixes a pre-existing
static-field-hack leak where a previous test's metadata would
persist into the next test's parse. Documented as a known
anti-pattern in spec-compiler-dogfood SKILL.md; full cleanup
to a local-object pattern is tracked separately. The minimum
fix here is 'reset at start of parse' which restores test
isolation without refactoring the entire metadata-collection
path.
End-to-end verified: running generateProtobuf against a speck
with event_suffix: "Payload" emits 'message CreateGroupPayload'
and 'message AddStewardPayload' (correct).
All 52 tests pass. tsc build clean.
Reference: .hermes/skills/spec-compiler-dogfood/references/
speckl-federated-meetup-protobuf-migration.md — the migration
unblocks when every generated message name matches what the
consumer's Go imports expect.
Adds a regression test that confirms a bare-identifier interface (no
methods with parens, no fields with colons) parses as kind: 'enum'
with the variants on its methods[] array. The .proto generator already
emits these correctly as 'enum X { X_UNSPECIFIED = 0; FOO = 1; ... }'
- confirmed end-to-end by running generateProtobuf against the
federated-meetup CustodyTier shape.
This was a verification cycle rather than a feature cycle: the compiler
already does the right thing for enums, but without a regression test
a future refactor of classifyInterfaces or parseInterface could break
this silently. The test pins the behavior so a PR that breaks enum
emission will fail CI.
All 49 tests pass (48 + 1 new). tsc build clean.
Reference: federated-meetup.speckdl CustodyTier shape
interface CustodyTier {
ColdHardware
HotSigning
ThresholdSplit
BackupOnly
Compromised
}
Adds 'state as <MessageName> { ... }' syntax for the .proto generator.
When the 'as' clause is present, the generator emits
message <MessageName> { ... }
instead of the default
message <SpeckName>State { ... }.
Why: the federated-meetup consumer (internal/types/types.go and friends)
imports 'pb.StateSnapshot', not 'pb.FederatedMeetupState'. The previous
default of namespacing by speck name broke the migration: the generated
proto would have compiled cleanly in protoc but the consuming Go code
would not have built. With this override, the Speckl-generated proto
emits the exact message name the consumer expects.
The default behavior (no 'as' clause) is unchanged, so existing specs
that relied on the old convention (e.g. tests, examples) still work.
Tests: 2 new parser tests, both green. All 48 tests pass. tsc build clean.
Reference: .hermes/skills/spec-compiler-dogfood/references/
speckl-federated-meetup-protobuf-migration.md
— the build break last session was caused by the missing override.
Adds 'transition Name { ... }' as a top-level member of a speck
block. The AST node is TransitionNode with fields[] plus optional
payloadOneof and typeDiscriminator references. Two field names
have special meaning:
payload: <OneofName> → the generator will emit the referenced
oneof's variants inline as
'oneof payload { ... }' inside the
message body (i.e. payload becomes a
proto oneof, not a regular field)
type: <EnumName> → the discriminator enum for the variants
All other fields are emitted as ordinary proto message fields.
This is the shape federated-meetup needs for its Transition message:
message Transition {
TransitionType type = 1;
StateRoot prior_state = 2;
oneof payload {
CreateGroupPayload create_group = 10;
...
}
Multisig steward_signatures = 31;
bytes hlc = 32;
fixed32 branch_id = 33;
google.protobuf.Timestamp signed_at = 30;
}
The next cycle (64) will wire the .proto generator to emit this shape
from a TransitionNode. Cycle 58 is parser-only — 3 new tests cover
regular fields, payload+type references, and trailing punctuation
tolerance. All 46 tests (39 baseline + 4 oneof + 3 transition) pass.
tsc build clean.
Adds 'oneof Name { field: Type, ... }' as a top-level member of a
speck block, alongside interface, event, service, etc. The AST node
is OneofNode with a variants array of { fieldName, typeName }. The
parser supports multi-line blocks, comments, and trailing commas /
semicolons. Generator integration is a follow-up cycle (proto/TS).
Motivation: federated-meetup's Transition message needs a oneof for
the 15+ payload variants (create_group, add_steward, ...). Without
oneof syntax, the consumer Go code has to hand-roll
'Transition_AddHostPeer' wrapper messages for each event type.
With oneof, the generated .proto emits a real proto3 oneof and the
generated Go bindings have a single 'isXXX()' discriminator API
that matches the consumer's expectations.
Tests: 4 new parser tests covering happy path, trailing punctuation,
comments, and coexistence with other member types. All 43 tests
(39 baseline + 4 new) pass; tsc build clean.
Reference: spec-compiler-dogfood skill, references/speckl-federated-
meetup-protobuf-migration.md — the prior build break was caused by
proto message-shape mismatches between the generated .proto and the
consumer Go code. oneof syntax is the first of 5 compiler extensions
needed to make Speckl-generated proto bindings a drop-in for the
handwritten proto/federated_meetup/v1/{state,rpc}.proto.
Multi-line `input: { ... }` and `output: { ... }` blocks fell through
to parseMember which returned `{ type: 'ident', name: '{' }` instead of
a record with fields. Broke RetryHandler.speck and similar real specks.
Fix: add `input:`/`output:` to blockStarters, dispatch to new
parseInputOutputBlock() that brace-counts across newlines, strips
line comments, and delegates to parseTypeExpr's record branch.
CoS-verified-live-close (cycle 126 pattern): subagent hit max_iterations
on the brace-count edge case. CoS applied focused fix + 2 new tests.
39/39 vitest pass.
Test evidence:
- RetryHandler.speck: input typeExpr.type='record' 4 fields
- 37 prior tests + 2 new = 39 passing
Adds a first-class TypeScript code generator to speckl-compile that emits:
- Const enum for workflow states (IssueWorkflowState)
- Strongly-typed class with guards, state transitions, decision log
- Proper this. prefixing (state vars only, not local params)
- Enum references instead of string literals for state comparisons
- Clean tsc --noEmit compilation
Also includes:
- specs/forgejo-issue-workflow.speckdl — full workflow with 9 actions,
8 constraints, 5 temporal verifications
- Generator deduplication of duplicate AST nodes
- Support for init block initialization of state variables
The generator was validated against the Forgejo issue workflow spec,
producing IssueWorkflow.ts that compiles with zero errors.
Refs: heartbeat conversation re: speckl-compile extensibility
- Fixed speck name regex to allow hyphens (\w → [\w-])
- Added provenance { } block handling in parseMember()
- Updated hyphenated name test from 'My' to 'My-Service-Handler'
- All 48 tests passing (27 parser + 21 validator)