Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
cf1c0aa
chore(fpf): refresh corpus to 34b4d63 — temporal claim adequacy
m0n0x41d May 4, 2026
d3ac730
feat(v8): foundation M1 — algebraic types, wire protocol, append-only…
m0n0x41d May 4, 2026
a3253aa
feat(v8): foundation M2 — HTTP+SSE server + turn driver
m0n0x41d May 4, 2026
8e5f704
Add external WorkCommission completion CLI
May 11, 2026
a74d085
fix(serve): accept artifact_ref as alias for decision_ref in measure/…
m0n0x41d May 13, 2026
14a60ab
docs(changelog): record #77 fix under Unreleased
m0n0x41d May 13, 2026
b7c2ec9
fix(review): address Codex P1/P2 findings on agent driver
m0n0x41d May 13, 2026
ea68b72
fix(review): honor artifact_ref alias in bindDecisionRef; unique tmp …
m0n0x41d May 13, 2026
20fb979
fix(review): order turn.started after StartTurn; treat closed-stream-…
m0n0x41d May 13, 2026
d79be83
fix(review): tighten permission validation, match cancel to turn id, …
m0n0x41d May 13, 2026
c6edf0a
fix(review): validate journal appends, dedupe streamed part ids
m0n0x41d May 13, 2026
d2867a7
fix(review): fail turn on tool/flush errors; journal assistant text/r…
m0n0x41d May 13, 2026
5c63eb0
fix(review): send tool args as raw JSON instead of base64
m0n0x41d May 13, 2026
3dfa545
fix(review): serialize per-session Append; safe Hub cancel
m0n0x41d May 13, 2026
86fa47d
fix(review): reject replayed running turns synchronously
m0n0x41d May 13, 2026
024de7f
fix(review): flush deltas on provider error; reject model.set mid-turn
m0n0x41d May 13, 2026
162326b
fix(review): map ErrTurnAlreadyRunning to 409 Conflict
m0n0x41d May 13, 2026
0b80cf6
fix(review): serialize model.set against turn.submit Load
m0n0x41d May 13, 2026
58e6f4b
fix(review): serialize wire-safe SessionPayload on resume
m0n0x41d May 13, 2026
17bd63c
fix(review): map permission errors to 400/404 in HTTP transport
m0n0x41d May 13, 2026
5bc6248
fix(review): serialize store.Load with concurrent Append writes
m0n0x41d May 13, 2026
e2b9cf8
fix(review): cancel-aware turn completion and stale-cancel HTTP status
m0n0x41d May 13, 2026
4b9a839
Merge pull request #79 from karabelaselias/feature/commission-complet…
m0n0x41d May 13, 2026
6cf91b5
docs(changelog): cut [7.1.0] — complete-external CLI + v8 foundation …
m0n0x41d May 13, 2026
a8d72b8
fix(review): journal canceled permissions and surface failTurn publis…
m0n0x41d May 13, 2026
8535cf4
chore(fpf): refresh corpus to ee40821 + add X-SOURCE-RESTORATION pattern
m0n0x41d May 13, 2026
d5001d6
fix(review): tag ModelChoice and SessionMeta for snake_case JSON wire
m0n0x41d May 13, 2026
e5a5b25
docs(changelog): expand 7.1.0 v8 hardening list + artifact store meta…
m0n0x41d May 13, 2026
247697e
feat(reff): cap R at 0.5 for simulation-only and nonrealizable causal…
m0n0x41d May 13, 2026
e80ff87
feat(artifact): add C.28 CausalEvidenceSupportBasis and Realizability…
m0n0x41d May 13, 2026
0dbbf65
feat(surface): C.28 schemas + soft warning + A.15.4 carrier footer on…
m0n0x41d May 13, 2026
e3cad19
test(agentdriver): sync fakeTools.calls and drain SSE until turn.comp…
m0n0x41d May 13, 2026
d45a6e7
test(agentdriver): drain in-flight dispatcher goroutines before store…
m0n0x41d May 13, 2026
5d4954e
fix(review): keep journal authoritative and normalise empty tool args
m0n0x41d May 13, 2026
2750032
docs(changelog): document post-cut v8 P2 fixes and -race CI test-side…
m0n0x41d May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Fixed

- **`haft_decision(measure|baseline|apply)` silently misrouted on `artifact_ref`** ([#77](https://github.com/m0n0x41d/haft/issues/77)) — when an LLM client passed `artifact_ref` (the universal target key in `haft_refresh`, and the only documented ref on `haft_decision(evidence)`), the explicit ID was silently dropped and the call resolved to whichever DecisionRecord came back first from `store.ListByKind(KindDecisionRecord, 1)` — most commonly the most-recently-touched one. Baselines snapshotted the wrong files; measurements landed against the wrong claim chain; `haft_refresh action=scan` still flagged the intended target as `no baseline`. The reporter saw the stale-scan count drop from 42 to 27 in one round after manually switching their integration to `decision_ref`. Both serve-mode (`internal/cli/serve.go`) and tools-mode (`internal/tools/haft.go`) handlers had the same defect. Fix: `measure` and `baseline` now accept either `decision_ref` or `artifact_ref` and refuse to proceed without one — the silent `ListByKind(...,1)` fallback is gone for these two actions because corrupting authoritative state is worse than refusing to act. `apply` accepts both keys and keeps the auto-detect fallback since it is a read-only "generate brief" path with no persistent side effect. Schema descriptions for `decision_ref` and `artifact_ref` updated to list every action that accepts each key, so future LLM clients see the right map at registration time. Three regression tests pin the bug shape: two DecisionRecords exist, the caller names the older one via `artifact_ref`, and the test fails if the implementation reaches for the newer one; a third test guards the new "no ref provided" guidance path.

## [7.0.0] — 2026-04-29

v7 promotes specs to authoritative artifacts. The product is no longer "decision governance plus task execution"; it is **project harnessability**. A repository becomes harnessable only after it carries a parseable ProjectSpecificationSet (TargetSystemSpec + EnablingSystemSpec + TermMap), and Decisions / WorkCommissions / RuntimeRuns / Evidence flow downstream as consequences of that spec. The product surface model is also clearer: one Haft Core (semantic authority) under two production surfaces — MCP Plugin (embedded host-agent surface for Claude Code and Codex) and CLI Harness (operator/runtime surface). Desktop remains an alpha track and is not part of the v7 production envelope. Surfaces dispatch typed actions; they do not invent semantics.
Expand Down
2 changes: 1 addition & 1 deletion data/FPF
Submodule FPF updated 1 files
+2,455 −267 FPF-Spec.md
25 changes: 25 additions & 0 deletions internal/agentcore/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Package agentcore is Layer G2 of the v8 agent stack: pure algebraic types
// for Session/Turn/Part/Permission/SubAgentLink/ModelChoice and the
// pure transitions that move a Session from one state to the next.
//
// All values in this package are immutable. Every transition function takes
// a Session and returns a NEW Session — no field mutation, no shared slice
// state. Errors are returned, never thrown. Side effects (disk, network,
// time) are forbidden here; they live at G0/G1/G3/G5.
//
// This package coexists with the legacy [internal/agent] package during the
// v8 migration. Legacy types remain authoritative for the current coordinator
// (internal/agentloop). Once M2 cuts the coordinator over to G4, legacy
// agent.Session/Message will be deprecated.
//
// Inexpressible (by design):
// - Mutating an existing Turn or Part.
// - Recording a Part without a Turn.
// - Recording a Turn without a Session.
// - Resolving a Permission that was never requested.
// - Completing a Turn that is already complete.
// - Attaching a SubAgent without naming the parent Turn.
//
// Each is rejected by the type system (sealed interfaces, opaque IDs) or by
// the transition function returning a typed error.
package agentcore
21 changes: 21 additions & 0 deletions internal/agentcore/ids.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package agentcore

// Typed identifiers prevent cross-domain ID confusion at compile time.
// SessionID, TurnID, PartID, PermissionID are not interchangeable strings;
// the compiler rejects passing one where another is expected.

type SessionID string

type TurnID string

type PartID string

type PermissionID string

type SubAgentID string

func (s SessionID) String() string { return string(s) }
func (t TurnID) String() string { return string(t) }
func (p PartID) String() string { return string(p) }
func (p PermissionID) String() string { return string(p) }
func (s SubAgentID) String() string { return string(s) }
30 changes: 30 additions & 0 deletions internal/agentcore/model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package agentcore

// ProviderKind enumerates the LLM provider families haft can speak to.
// Adding a kind is a deliberate breaking change to G1 — the wire format
// in Layer P encodes ProviderKind as a string discriminator and consumers
// will reject unknown values rather than silently ignoring them.
type ProviderKind string

const (
ProviderOpenAI ProviderKind = "openai"
ProviderAnthropic ProviderKind = "anthropic"
// ProviderCodex is the ChatGPT-Sub OAuth path that reuses the OpenAI
// API surface but carries chatgpt_account_id auth. It is preserved
// from internal/cli/login.go and remains the only auth flow that
// requires a device-code exchange.
ProviderCodex ProviderKind = "codex"
)

// ModelChoice is the immutable triple a Session pins itself to. Switching
// model mid-session is modeled as the runtime emitting a model.switched
// event and the next Turn binding to a new ModelChoice; the current Turn
// keeps the choice it started with.
type ModelChoice struct {
Provider ProviderKind
Model string // provider-native model id (e.g. "gpt-5.4", "claude-sonnet-4-6")
// CredentialKey identifies which stored credential to use without
// embedding the secret value here. The G1 provider layer dereferences
// the key against the credential store at call time.
CredentialKey string
Comment thread
m0n0x41d marked this conversation as resolved.
Outdated
}
156 changes: 156 additions & 0 deletions internal/agentcore/part.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package agentcore

import "time"

// PartKind discriminates Part variants in the wire format.
// The set is closed — adding a new kind is a breaking change to Layer P.
type PartKind string

const (
PartKindText PartKind = "text"
PartKindReasoning PartKind = "reasoning"
PartKindToolUse PartKind = "tool_use"
PartKindToolResult PartKind = "tool_result"
PartKindFileRef PartKind = "file_ref"
PartKindStepBoundary PartKind = "step_boundary"
)

// Part is a sealed sum type: only the variants in this file implement it.
// Each Part is an immutable value carrying its kind, ID, and the moment it
// was attached to its Turn.
type Part interface {
Kind() PartKind
ID() PartID
CreatedAt() time.Time
partSeal()
}

type partBase struct {
id PartID
createdAt time.Time
}

func (b partBase) ID() PartID { return b.id }
func (b partBase) CreatedAt() time.Time { return b.createdAt }
func (partBase) partSeal() {}

// TextPart carries assistant or user text. Append-only — deltas materialize
// as additional TextParts; the runtime never mutates an existing TextPart's
// Text field. Compaction logic lives at G4 and produces NEW Parts.
type TextPart struct {
partBase
Text string
}

func (TextPart) Kind() PartKind { return PartKindText }

// ReasoningPart carries hidden chain-of-thought text streamed from providers
// that surface it (e.g. OpenAI o1, Anthropic thinking blocks). Rendered
// dimly in the TUI; not folded into LLM input by default.
type ReasoningPart struct {
partBase
Text string
}

func (ReasoningPart) Kind() PartKind { return PartKindReasoning }

// ToolUsePart marks the start of a tool invocation. Args is the raw
// JSON-encoded argument blob the LLM produced. ToolCallID is the
// provider-assigned identifier the matching ToolResultPart will reference.
type ToolUsePart struct {
partBase
ToolCallID string
ToolName string
Args []byte
}

func (ToolUsePart) Kind() PartKind { return PartKindToolUse }

// ToolResultPart carries the typed outcome of a tool invocation. IsError is
// distinct from a non-zero exit code — it signals that the tool layer
// itself failed (timeout, cancelled, dispatch error) and the LLM should
// reason about the failure.
type ToolResultPart struct {
partBase
ToolCallID string
ToolName string
Content string
IsError bool
}

func (ToolResultPart) Kind() PartKind { return PartKindToolResult }

// FileRefPart records that a tool attached a file to the Turn. Path is
// project-relative when possible. The TUI uses this to render an inline
// chip and offers a viewer command.
type FileRefPart struct {
partBase
Path string
MIMEType string
Bytes int64
}

func (FileRefPart) Kind() PartKind { return PartKindFileRef }

// StepBoundaryPart marks a logical step inside a Turn — used by providers
// that batch tool calls into agentic "steps". Lets the TUI fold sections.
// Carries no payload beyond its own identity and timestamp.
type StepBoundaryPart struct {
partBase
Label string
}

func (StepBoundaryPart) Kind() PartKind { return PartKindStepBoundary }

// NewTextPart, NewReasoningPart, ... are the only constructors. They stamp
// CreatedAt at call time. Tests override via the now closure passed to
// transition functions; production calls use time.Now.

func newPartBase(id PartID, now time.Time) partBase {
return partBase{id: id, createdAt: now}
}

func NewTextPart(id PartID, now time.Time, text string) TextPart {
return TextPart{partBase: newPartBase(id, now), Text: text}
}

func NewReasoningPart(id PartID, now time.Time, text string) ReasoningPart {
return ReasoningPart{partBase: newPartBase(id, now), Text: text}
}

func NewToolUsePart(id PartID, now time.Time, callID, name string, args []byte) ToolUsePart {
cloned := make([]byte, len(args))
copy(cloned, args)
return ToolUsePart{
partBase: newPartBase(id, now),
ToolCallID: callID,
ToolName: name,
Args: cloned,
}
}

func NewToolResultPart(id PartID, now time.Time, callID, name, content string, isError bool) ToolResultPart {
return ToolResultPart{
partBase: newPartBase(id, now),
ToolCallID: callID,
ToolName: name,
Content: content,
IsError: isError,
}
}

func NewFileRefPart(id PartID, now time.Time, path, mime string, bytes int64) FileRefPart {
return FileRefPart{
partBase: newPartBase(id, now),
Path: path,
MIMEType: mime,
Bytes: bytes,
}
}

func NewStepBoundaryPart(id PartID, now time.Time, label string) StepBoundaryPart {
return StepBoundaryPart{
partBase: newPartBase(id, now),
Label: label,
}
}
44 changes: 44 additions & 0 deletions internal/agentcore/permission.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package agentcore

import "time"

// PermissionDecision is the operator's response to a permission request.
// Resolution is terminal — once set, it cannot change. Re-asking for the
// same operation creates a NEW Permission.
type PermissionDecision string

const (
PermissionPending PermissionDecision = "pending"
PermissionApproved PermissionDecision = "approved"
PermissionDenied PermissionDecision = "denied"
)

// Permission is a request to run a specific tool invocation, surfaced to
// the operator and resolved by their decision. The Permission record
// participates in the Session graph just like any other entity: it is
// created, eventually resolved, and never mutated after resolution.
type Permission struct {
ID PermissionID
TurnID TurnID
ToolCallID string
ToolName string
Args []byte
Decision PermissionDecision
Reason string // operator-provided justification on deny; optional on approve
RequestedAt time.Time
ResolvedAt time.Time // zero until Decision != Pending
}

// IsResolved reports whether the operator has decided.
func (p Permission) IsResolved() bool {
return p.Decision != PermissionPending
}

// withResolution returns a copy of the Permission marked with the given
// decision at the given time. The receiver is untouched.
func (p Permission) withResolution(d PermissionDecision, reason string, now time.Time) Permission {
p.Decision = d
p.Reason = reason
p.ResolvedAt = now
return p
}
Loading
Loading