speckl/compiler/specs/forgejo-issue-workflow.speckdl
Scott Scoble 7ffd65f6b3
Some checks failed
Deploy Landing Page to Storj / deploy (push) Failing after 2s
speckl: TypeScript state machine generator + Forgejo issue workflow spec
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
2026-05-03 12:23:33 -07:00

272 lines
8.4 KiB
Text

// forgejo-issue-workflow.speckdl
// Formal specification: A Forgejo issue is a prompt wrapped in a state machine.
//
// Purpose: Make the informal prompt → formal. Track who can do what, when, and
// what evidence is required to move forward. Every transition is a decision.
speck IssueWorkflow {
author: "Scott Scoble" <"scott@scoble.me">
source: conversation ref "discord-2026-05-03"
review: hybrid
// ─── Provenance ───────────────────────────────────────────
provenance {
design_decision: "Issues as prompts + state machines"
parent_spec: "AGENTS.md Invariants"
external_doc: "https://os.scoble.me/forgejo/marcus"
}
// ─── Derivation & Satisfaction ────────────────────────────
derives from AGENTS-INVARIANTS via "formalization of operational rules"
satisfies AGENTS-REQ-01 clause "1.1"
satisfies AGENTS-REQ-02 clause "1.3"
satisfies AGENTS-REQ-03 clause "1.4"
satisfies AGENTS-REQ-04 clause "1.5"
satisfies AGENTS-REQ-05 clause "1.6"
// ─── Types ────────────────────────────────────────────────
interface IssueState {
Open, Grooming, Groomed, Assigned, InProgress, InReview, Merged, Blocked, Closed
}
// ─── Events ───────────────────────────────────────────────
event StateTransition {
from: IssueState,
to: IssueState,
actor: String,
reason: String,
evidence: String,
at: String
}
event EvidenceItem {
kind: String,
ref: String,
verifiedBy: String
}
// ─── Input / Output ────────────────────────────────────────
input: {
prompt: String,
creator: String,
repo: String,
labels: [String],
assignee: String,
createdAt: String
}
output: {
state: IssueState,
stateAt: String,
decisionLog: [StateTransition],
blockedBy: [String],
evidence: [EvidenceItem]
}
// ─── State Variables ──────────────────────────────────────
state: {
status: IssueState,
assignee: String,
repo: String,
decisionCount: Nat,
lastActionBy: String
}
init: {
status := Open,
assignee := "",
repo := "",
decisionCount := 0,
lastActionBy := ""
}
// ─── Actions (State Transitions) ──────────────────────────
action create(creator: String, prompt: String, repo: String, labels: [String]) {
require status == Open
assign status = Open
assign decisionCount = 0
assign lastActionBy = creator
emit StateTransition {
from: Open,
to: Open,
actor: creator,
reason: prompt,
evidence: repo,
at: now()
}
}
action groom(assignee: String, refinedPrompt: String, acceptanceCriteria: [String]) {
require status == Open || status == Grooming
require len(acceptanceCriteria) > 0
assign status = Groomed
assign lastActionBy = assignee
assign decisionCount = decisionCount + 1
emit StateTransition {
from: status,
to: Groomed,
actor: assignee,
reason: refinedPrompt,
evidence: join(acceptanceCriteria, ";"),
at: now()
}
}
action assign(assignee: String, agent: String) {
require status == Groomed || status == Open
require assignee == "sscoble"
require repo != "marcus/coach"
assign status = Assigned
assign lastActionBy = agent
assign decisionCount = decisionCount + 1
emit StateTransition {
from: status,
to: Assigned,
actor: agent,
reason: "delegated to " + agent,
evidence: "",
at: now()
}
}
action startWork(agent: String) {
require status == Assigned
assign status = InProgress
assign lastActionBy = agent
assign decisionCount = decisionCount + 1
emit StateTransition {
from: Assigned,
to: InProgress,
actor: agent,
reason: "beginning implementation",
evidence: "",
at: now()
}
}
action submitPR(agent: String, prNumber: Nat, commitHash: String) {
require status == InProgress
assign status = InReview
assign lastActionBy = agent
assign decisionCount = decisionCount + 1
emit StateTransition {
from: InProgress,
to: InReview,
actor: agent,
reason: "PR submitted",
evidence: commitHash,
at: now()
}
emit EvidenceItem {
kind: "pr",
ref: prNumber,
verifiedBy: agent
}
}
action merge(prNumber: Nat, merger: String, commitHash: String) {
require status == InReview
assign status = Merged
assign lastActionBy = merger
assign decisionCount = decisionCount + 1
emit StateTransition {
from: InReview,
to: Merged,
actor: merger,
reason: "PR merged",
evidence: commitHash,
at: now()
}
emit EvidenceItem {
kind: "commit",
ref: commitHash,
verifiedBy: merger
}
}
action close(closer: String, resolution: String) {
require status == Merged || status == Open || status == Groomed
require closer == assignee || (closer == "sscoble" && assignee == "")
assign status = Closed
assign lastActionBy = closer
assign decisionCount = decisionCount + 1
emit StateTransition {
from: status,
to: Closed,
actor: closer,
reason: resolution,
evidence: "",
at: now()
}
}
action reopen(opener: String, reason: String) {
require status == Closed || status == Blocked
assign status = Open
assign lastActionBy = opener
assign decisionCount = decisionCount + 1
emit StateTransition {
from: status,
to: Open,
actor: opener,
reason: reason,
evidence: "",
at: now()
}
}
action block(agent: String, blocker: String) {
require status != Blocked && status != Closed
assign status = Blocked
assign lastActionBy = agent
assign decisionCount = decisionCount + 1
emit StateTransition {
from: status,
to: Blocked,
actor: agent,
reason: "blocked by: " + blocker,
evidence: "",
at: now()
}
}
action unblock(agent: String, resolution: String) {
require status == Blocked
assign status = Open
assign lastActionBy = agent
assign decisionCount = decisionCount + 1
emit StateTransition {
from: Blocked,
to: Open,
actor: agent,
reason: resolution,
evidence: "",
at: now()
}
}
// ─── Constraints ──────────────────────────────────────────
constraint: len(labels) > 0
constraint: forall l in labels: l in {"bug", "feature", "improvement", "question", "blocked"}
constraint: implies(now() - lastIssueCreatedAt(agent) < 3600, issuesCreatedInLastHour(agent) <= 3)
constraint: implies(now() - lastCommentAt(agent) < 30, false)
constraint: implies(repo in {"marcus/engineering", "marcus/market", "marcus/content", "marcus/trust"}, assignee == "sscoble")
constraint: implies(creator == agent, repo != agentRepo(agent))
constraint: decisionCount == len(decisionLog)
constraint: implies(status == Closed, len(evidence) > 0 || lastStateBeforeClosed() in {Merged, Blocked})
// ─── Verification ─────────────────────────────────────────
verify: always(implies(status == InProgress, eventually(status == Merged || status == Closed)))
verify: always(implies(status == Blocked, eventually(status != Blocked)))
verify: always(implies(lastActionBy != "sscoble", status != Assigned))
verify: always(implies(decisionCount > 0, forall t in decisionLog: len(t.reason) > 0))
verify: always(implies(status == Groomed || status == Assigned || status == InProgress, len(acceptanceCriteria) > 0))
// ─── BOM ──────────────────────────────────────────────────
bom {
compiler: "speckl-compile" version "0.3.1"
solver: "z3" version "4.12.5"
runtime: "OpenClaw" version "2.1.0"
license: "MIT"
}
}