diff --git a/Makefile b/Makefile index 5ebc07e0..e6b5418d 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,13 @@ test: @echo "Running test suite (race, count=1)..." go test -race -count=1 ./... +# bench-cli runs the subprocess CLI benchmark: it builds the real izen binary +# and drives it against a scripted local provider, asserting exact artifact +# bytes, the JSON report, exit codes and durable evidence. +bench-cli: + @echo "Running subprocess CLI benchmark suite..." + go test -run 'TestCLIBench' -v -count=1 -timeout 15m ./test/benchmark + lint: @echo "Running golangci-lint (timeout 5m)..." golangci-lint run --timeout=5m ./... diff --git a/cmd/izen/auth.go b/cmd/izen/auth.go new file mode 100644 index 00000000..3aaba2a2 --- /dev/null +++ b/cmd/izen/auth.go @@ -0,0 +1,107 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/PizenLabs/izen/internal/config" +) + +// authUsage describes the `izen auth` command group. +const authUsage = `Usage: + izen auth status Show credential availability per provider (never the secret) + izen auth login Explain how to authenticate + +izen never prints a stored credential. "login" is performed by the interactive +onboarding/TUI model picker or by setting the provider's environment variable. +` + +// runAuthCommand implements `izen auth`. +func runAuthCommand(args []string) int { + jsonOut := false + var rest []string + for _, a := range args { + switch a { + case "-h", "--help": + fmt.Print(authUsage) + return exitOK + case "-json", "--json": + jsonOut = true + default: + rest = append(rest, a) + } + } + if len(rest) == 0 { + fmt.Print(authUsage) + return exitUsage + } + switch rest[0] { + case "status": + return authStatus(jsonOut) + case "login": + fmt.Println("izen: headless login is intentionally not implemented — no credential is") + fmt.Println("written outside the interactive onboarding flow. To authenticate, either:") + fmt.Println(" - run `izen` and use the /models picker, or") + fmt.Println(" - export the provider's API key, e.g. OPENROUTER_API_KEY=...") + return exitOK + default: + fmt.Fprintf(os.Stderr, "izen auth: unknown subcommand %q\n", rest[0]) + fmt.Fprint(os.Stderr, authUsage) + return exitUsage + } +} + +func authStatus(jsonOut bool) int { + cfg, err := config.Load() + if err != nil { + cfg = config.Default() + } + type row struct { + Provider string `json:"provider"` + Source string `json:"credential_source,omitempty"` + Present bool `json:"present"` + EnvVar string `json:"env_var,omitempty"` + } + names := make([]string, 0, len(config.ValidProviderNames)) + for name := range config.ValidProviderNames { + names = append(names, name) + } + sort.Strings(names) + rows := make([]row, 0, len(names)) + for _, name := range names { + src := config.CredentialSourceFor(cfg, name) + rows = append(rows, row{Provider: name, Source: src, Present: src != "", EnvVar: config.EnvVarForProvider(name)}) + } + if jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{ + "active_provider": cfg.ActiveProviderName(), + "active_model": cfg.ActiveModelName(), + "providers": rows, + }); err != nil { + fmt.Fprintf(os.Stderr, "izen auth: encode: %v\n", err) + return exitFailed + } + return exitOK + } + fmt.Printf("active: provider=%s model=%s\n", cfg.ActiveProviderName(), cfg.ActiveModelName()) + for _, r := range rows { + state := "missing" + if r.Present { + state = r.Source + } + fmt.Printf(" %-12s %-8s (env %s)\n", r.Provider, state, envOrDash(r.EnvVar)) + } + return exitOK +} + +func envOrDash(env string) string { + if strings.TrimSpace(env) == "" { + return "-" + } + return env +} diff --git a/cmd/izen/authoritative.go b/cmd/izen/authoritative.go new file mode 100644 index 00000000..a6a10ca6 --- /dev/null +++ b/cmd/izen/authoritative.go @@ -0,0 +1,468 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/PizenLabs/izen/internal/autonomy" + "github.com/PizenLabs/izen/internal/cli" + "github.com/PizenLabs/izen/internal/config" + "github.com/PizenLabs/izen/internal/core/workflow" + "github.com/PizenLabs/izen/internal/execution" + "github.com/PizenLabs/izen/internal/httpx" + "github.com/PizenLabs/izen/internal/project" + appruntime "github.com/PizenLabs/izen/internal/runtime" + "github.com/PizenLabs/izen/internal/runtime/compose" + "github.com/PizenLabs/izen/internal/runtime/executor" + domainorch "github.com/PizenLabs/izen/internal/runtime/orchestrator" +) + +// ── SHARED AUTHORITATIVE CLI COMMAND ──────────────────────────────────────── +// +// `izen run`, `izen prompt`, `izen orchestrate` and `izen resume` are the same +// lifecycle: one objective driven through the production composition root +// (compose.Wire) into the bounded autonomy Driver over the RuntimeExecutor +// execution plane. This file owns the ONE wiring of that lifecycle. Each +// command's only job is to parse its own arguments into an execRequest and +// translate the runtime's terminal projection back to a process exit. None of +// them owns a proposal loop, a provider call, a planner or a terminal truth. +// +// The runtime owns admission, the provider seam, the proposal/approval boundary, +// mutation, verification, recovery, cancellation and terminal truth. The command +// plays exactly one human role: rendering the runtime's own candidate review at +// a parked boundary and issuing the candidate-bound mutation authorization on +// approve. + +// runAuthoritativeCommand wires the authoritative runtime and drives one +// objective through it, playing the human at every parked boundary according to +// the request's approval policy. It always returns a report whose ExitCode is +// the process contract; a nil error means the report is trustworthy. +func runAuthoritativeCommand(req execRequest) *runReport { + start := time.Now() + rep := &runReport{ + Schema: reportSchema, + Surface: req.Scope, + Workspace: req.Dir, + Objective: req.Objective, + Mode: req.Mode, + Targets: append([]string(nil), req.Targets...), + } + + humanOut := io.Writer(os.Stdout) + if req.JSON { + // stdout is reserved for machine-readable output; all human-facing + // rendering and diagnostics move to stderr. + humanOut = os.Stderr + } + diagnostic := func(format string, args ...interface{}) { + if !req.Quiet { + fmt.Fprintf(os.Stderr, format, args...) + } + } + + cfg, err := config.Load() + if err != nil { + cfg = config.Default() + } + + if req.Resume { + task, ok := mostRecentRecoverable(req.Dir) + if !ok { + rep.Outcome = "error" + rep.Error = fmt.Sprintf("no interrupted objective recorded in %s/.izen/runtime", req.Dir) + rep.ExitCode = exitFailed + rep.ElapsedMS = time.Since(start).Milliseconds() + return rep + } + req.Objective = task.Intent + req.ResumeFrom = task.ID + rep.Objective = task.Intent + rep.ResumedFrom = task.ID + if len(req.Targets) == 0 && len(task.ActiveTargetScope) > 0 { + req.Targets = append([]string(nil), task.ActiveTargetScope...) + rep.Targets = append([]string(nil), task.ActiveTargetScope...) + } + diagnostic("izen: resuming recorded objective task=%s status=%s scope=%v\n", + task.ID, task.Status, task.ActiveTargetScope) + } + + providerName, model := resolveSelection(cfg, req) + rep.Provider = providerName + rep.Model = model + + provider, model, err := buildProvider(cfg, providerName, model, req.BaseURL) + if err != nil { + rep.Outcome = "config_error" + rep.Error = err.Error() + rep.ExitCode = exitUsage + rep.ElapsedMS = time.Since(start).Milliseconds() + return rep + } + rep.Provider = provider.Name() + rep.Model = model + diagnostic("izen: authoritative runtime provider=%s model=%s root=%s surface=%s mode=%s\n", + provider.Name(), model, req.Dir, req.Scope, req.Mode) + + opts := []compose.Option{ + compose.WithRoot(req.Dir), + compose.WithConfig(cfg), + compose.WithProvider(provider), + compose.WithAuditDir(filepath.Join(req.Dir, ".izen", "audit")), + } + if detection := project.Detect(req.Dir); detection.Primary != nil { + opts = append(opts, compose.WithLanguageID(detection.Primary.ID)) + } + + app, err := compose.Wire(opts...) + if err != nil { + rep.Outcome = "error" + rep.Error = fmt.Sprintf("wire authoritative runtime: %v", err) + rep.ExitCode = exitFailed + rep.ElapsedMS = time.Since(start).Milliseconds() + return rep + } + defer app.Close() + + if app.Autonomous == nil { + rep.Outcome = "error" + rep.Error = "authoritative runtime driver is not wired" + rep.ExitCode = exitFailed + rep.ElapsedMS = time.Since(start).Milliseconds() + return rep + } + + // ADMISSION GRANT: invoking an objective is the human's authorization to + // attempt it. The runtime's session capability ledger must carry the + // workspace vector before the loop lowers its preflight barrier. The + // per-candidate approval remains a SEPARATE, explicit gate on the mutation. + if app.Autonomy != nil { + app.Autonomy.GrantDefault( + autonomy.CapRead, autonomy.CapAnalyze, autonomy.CapPropose, + autonomy.CapMutate, autonomy.CapVerify, + ) + } + + // An explicit `-target` statement is bound through the authoritative target + // seam: resolved, derived and admission-gated exactly like a target named in + // the objective; an unresolvable target fails closed. + if len(req.Targets) > 0 { + app.Autonomous.SetTargets(req.Targets) + } + + // The mutation authorization the runtime requires is checkpoint-gated; a + // session-start checkpoint is a production precondition for any approval. + if app.Execution != nil && app.Execution.ShadowCP != nil { + if _, err := app.Execution.ShadowCP.CreateSessionStartSnapshot(); err != nil { + rep.Outcome = "error" + rep.Error = fmt.Sprintf("session-start checkpoint: %v", err) + rep.ExitCode = exitFailed + rep.ElapsedMS = time.Since(start).Milliseconds() + return rep + } + } + + timeout := req.Timeout + if timeout <= 0 { + timeout = defaultExecTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // Production preflight dispatch through the same admission front-end every + // other surface uses. + if app.Runtime != nil { + app.Runtime.Start() + if err := app.Runtime.Execute(ctx, appruntime.SubmitPromptCmd{Prompt: req.Objective, Mode: req.Mode}); err != nil { + rep.Outcome = "error" + rep.Error = fmt.Sprintf("preflight dispatch: %v", err) + rep.ExitCode = exitForError(err) + rep.ElapsedMS = time.Since(start).Milliseconds() + return rep + } + } + + // The mutation authorization is issued ONLY after approval, bound to the + // reviewed candidate identity and digest. A refusal rejects; it never falls + // through to a mutation. + recorder := &recordingBridge{} + authorize := func(candidateTargets []string, candidateID, candidateDigest string) error { + if app.Orchestrator != nil { + if err := app.Orchestrator.Transition(domainorch.PhaseBuild, + workflow.TransitionContext{HasPlan: true, HasCapabilities: true}); err != nil { + return fmt.Errorf("orchestrator transition to build: %w", err) + } + } + if app.Auth == nil || app.Executor == nil { + return errors.New("authorization engine is not wired") + } + auth, aerr := app.Auth.AuthorizeBuildCandidateContent( + candidateTargets, app.Caps, app.Budget, app.MicroBudget, false, true, candidateID, candidateDigest) + if aerr != nil { + return aerr + } + app.Executor.SetAuthorization(auth) + return nil + } + + inner := cli.NewDriverTerminalBridge(os.Stdin, humanOut).WithPolicy(req.Approval) + recorder.inner = inner + + res, runErr := cli.RunAuthoritative(ctx, app.Autonomous, recorder, authorize, req.Scope, req.Objective) + + // SESSION FINALIZATION: materialize the derived snapshot at the same + // boundary the retired headless ledger used. The journal remains the source + // of recovery; this is a projection, never a second terminal decision. + if ledger := app.Ledger(); ledger != nil { + if id := ledger.CurrentID(); id != "" { + if cpErr := ledger.Checkpoint(id, "cli-finalize"); cpErr != nil { + diagnostic("izen: durable checkpoint materialization failed: %v\n", cpErr) + } + } + } + + // Audit durability: a flush failure structurally invalidates success. + if flushErr := app.FlushAudit(); flushErr != nil { + rep.Outcome = "error" + rep.Error = fmt.Sprintf("audit persistence failed — evidence integrity compromised: %v", flushErr) + rep.ExitCode = exitFailed + rep.ElapsedMS = time.Since(start).Milliseconds() + return rep + } + + // Project the runtime's own terminal facts. Nothing here re-decides. + rep.RunID = res.RunID + rep.Approved = res.Approved + rep.Inspected = res.Inspected + rep.Rejected = res.Rejected + rep.ClarifyRequired = res.ClarifyRequired + if app.Autonomous != nil { + rep.ObjectiveReport = newObjectiveReport(app.Autonomous.ObjectiveReport()) + in, out, known := app.Autonomous.AggregatedUsage() + rep.Usage = usageReport{InputTokens: in, OutputTokens: out, Known: known} + } + if app.Executor != nil { + rep.Artifacts.Applied = app.Executor.AppliedMutations() + } + if b := recorder.boundary(); b != nil { + rep.Artifacts.CandidateTargets = b.CandidateTargets + rep.Artifacts.CandidateID = b.CandidateID + rep.Artifacts.CandidateDigest = b.CandidateDigest + rep.Artifacts.Operation = b.CandidateOperation + rep.Artifacts.AddedLines = b.CandidateAddedLines + rep.Artifacts.RemovedLines = b.CandidateRemovedLines + } + if res.Termination != nil { + rep.Reason = res.Termination.Reason + } else if res.Parked != nil { + rep.Reason = res.Parked.Reason + } + + classifyOutcome(rep, res, runErr, ctx.Err()) + rep.ElapsedMS = time.Since(start).Milliseconds() + return rep +} + +// recordingBridge wraps the terminal bridge and records the last human +// boundary so the report can carry the runtime's own review facts (candidate +// targets, identity, digest, operation and metrics) without synthesizing them. +type recordingBridge struct { + inner cli.HumanBridge + last *autonomy.HumanBoundary +} + +func (r *recordingBridge) Decide(ctx context.Context, b *autonomy.HumanBoundary) (cli.HumanDecision, error) { + r.last = b + return r.inner.Decide(ctx, b) +} + +func (r *recordingBridge) RenderParked(b *autonomy.HumanBoundary) error { + r.last = b + return r.inner.RenderParked(b) +} + +func (r *recordingBridge) boundary() *autonomy.HumanBoundary { return r.last } + +// resolveSelection resolves the effective provider/model with explicit flags +// winning over the configured binding. It also pins the resolved selection into +// the config binding so the runtime's model authority, admission and executor +// all observe the SAME selection (an explicit override must reach the runtime). +func resolveSelection(cfg *config.Config, req execRequest) (provider, model string) { + provider = cfg.ActiveProviderName() + if req.Provider != "" { + provider = req.Provider + if cfg.AI.Providers == nil { + cfg.AI.Providers = map[string]config.AIProviderConfig{} + } + if _, ok := cfg.AI.Providers[provider]; !ok { + cfg.AI.Providers[provider] = config.AIProviderConfig{} + } + } + model = cfg.ActiveModelName() + if req.Model != "" { + model = req.Model + } + // Pin the resolved binding so ActiveProviderName/ActiveModelName and the + // workspace model authority agree on the effective tuple. + cfg.Bindings.Active.Provider = provider + cfg.Bindings.Active.Model = model + return provider, model +} + +// classifyOutcome maps the runtime's terminal projection to the report's +// outcome label and exit code. It never infers success; a nil termination is a +// park, an error is an error, and only a terminal COMPLETED run is success. +func classifyOutcome(rep *runReport, res cli.AuthoritativeResult, runErr, ctxErr error) { + switch { + case runErr != nil: + rep.Error = runErr.Error() + switch { + case errors.Is(runErr, context.DeadlineExceeded): + rep.Outcome = "timeout" + rep.ExitCode = exitTimeout + default: + code := exitForError(runErr) + switch code { + case exitProvider: + rep.Outcome = "provider_error" + case exitUsage: + rep.Outcome = "config_error" + default: + rep.Outcome = "error" + } + rep.ExitCode = code + } + case errors.Is(ctxErr, context.DeadlineExceeded) && !completedRun(res): + // The wall-clock bound cancelled an unfinished run. The runtime aborts + // it truthfully; the command surfaces the bound as a distinct code. + rep.Outcome = "timeout" + rep.ExitCode = exitTimeout + case res.ClarifyRequired: + rep.Outcome = "clarify_required" + rep.ExitCode = exitHumanRequired + case res.Parked != nil: + if parkedIsProviderFailure(res.Parked.Reason) { + rep.Outcome = "provider_failed" + rep.ExitCode = exitProvider + } else { + rep.Outcome = "parked" + rep.ExitCode = exitHumanRequired + } + case res.Termination == nil: + rep.Outcome = "error" + rep.Error = "runtime returned no terminal state" + rep.ExitCode = exitFailed + default: + switch res.Termination.State { + case autonomy.RuntimeCompleted: + if rep.ObjectiveReport.Progress == string(execution.ProgressProven) { + rep.Outcome = "proven" + } else { + rep.Outcome = "completed" + } + rep.ExitCode = exitOK + case autonomy.RuntimeUnsubstantiated: + rep.Outcome = "unsubstantiated" + rep.ExitCode = exitUnsubstantiated + case autonomy.RuntimeAborted: + if res.Rejected { + rep.Outcome = "denied" + rep.ExitCode = exitDenied + } else { + rep.Outcome = "aborted" + rep.ExitCode = exitFailed + } + default: + rep.Outcome = "error" + rep.ExitCode = exitFailed + } + } +} + +// completedRun reports whether the runtime reached a terminal COMPLETED state. +func completedRun(res cli.AuthoritativeResult) bool { + return res.Termination != nil && res.Termination.State == autonomy.RuntimeCompleted +} + +// parkedIsProviderFailure reports whether a parked boundary reason names a +// provider/transport failure. It is the one place a provider failure that the +// runtime chose to park (after bounded transport recovery) is surfaced as the +// distinct provider exit code rather than a generic human-required park. +func parkedIsProviderFailure(reason string) bool { + r := strings.ToLower(reason) + return strings.Contains(r, "transport_error") || strings.Contains(r, "provider") +} + +// exitForError classifies a command error into a stable exit code. +func exitForError(err error) int { + if err == nil { + return exitOK + } + switch { + case errors.Is(err, context.DeadlineExceeded): + return exitTimeout + case errors.Is(err, executor.ErrInvalidProviderConfiguration), + errors.Is(err, execution.ErrUnassignedTargetModel), + errors.Is(err, execution.ErrProviderModelMismatch): + return exitUsage + } + var pe *httpx.ProviderError + if errors.As(err, &pe) { + return exitProvider + } + return exitFailed +} + +// Stable process exit codes for the execution commands. +const ( + exitOK = 0 + exitFailed = 1 + exitUsage = 2 + exitProvider = 3 + exitHumanRequired = 4 + exitUnsubstantiated = 5 + exitTimeout = 6 + exitDenied = 7 +) + +// runExecRequest parses, executes and renders one execution command. It is the +// single wrapper every execution subcommand calls. It returns the process exit +// code; the report (JSON or human) is written here. +func runExecRequest(command string, args []string, usage string) int { + return runExecRequestAs("$"+command, command, args, usage) +} + +// runExecRequestAs is runExecRequest with an explicit runtime scope, used by +// the retained aliases (izen prompt, izen orchestrate) so the recorded surface +// stays truthful. +func runExecRequestAs(scope, command string, args []string, usage string) int { + req, help, err := parseExecArgs(command, args) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + if usage != "" { + fmt.Fprint(os.Stderr, usage) + } + return exitUsage + } + if help { + fmt.Print(usage) + return exitOK + } + req.Scope = scope + req.Label = command + rep := runAuthoritativeCommand(req) + if req.JSON { + if werr := rep.writeJSON(os.Stdout); werr != nil { + fmt.Fprintf(os.Stderr, "izen %s: encode report: %v\n", command, werr) + return exitFailed + } + } else { + rep.writeHuman(os.Stdout) + } + return rep.ExitCode +} diff --git a/cmd/izen/cli_surface_test.go b/cmd/izen/cli_surface_test.go new file mode 100644 index 00000000..ddad747d --- /dev/null +++ b/cmd/izen/cli_surface_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "bytes" + "os" + "strings" + "testing" +) + +// captureStdout runs fn with os.Stdout redirected and returns what it wrote. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + fn() + _ = w.Close() + os.Stdout = old + var buf bytes.Buffer + if _, err := buf.ReadFrom(r); err != nil { + t.Fatalf("read: %v", err) + } + return buf.String() +} + +// TestModelsCommandRoundTrip proves `izen models` lists providers and +// `izen models use` persists the active binding that `izen config get` reads +// back — a real, non-decorative contract. +func TestModelsCommandRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + if code := runModelsCommand([]string{"use", "opencode", "z-ai/glm-4"}); code != exitOK { + t.Fatalf("models use exit = %d, want 0", code) + } + listed := captureStdout(t, func() { runConfigCommand([]string{"get", "provider"}) }) + if strings.TrimSpace(listed) != "opencode" { + t.Fatalf("config get provider = %q, want opencode", strings.TrimSpace(listed)) + } + listed = captureStdout(t, func() { runConfigCommand([]string{"get", "model"}) }) + if strings.TrimSpace(listed) != "z-ai/glm-4" { + t.Fatalf("config get model = %q, want z-ai/glm-4", strings.TrimSpace(listed)) + } + // The models listing must not fail and must name the active provider. + out := captureStdout(t, func() { + if c := runModelsCommand(nil); c != exitOK { + t.Fatalf("models list exit = %d", c) + } + }) + if !strings.Contains(out, "opencode") { + t.Fatalf("models list omitted opencode:\n%s", out) + } +} + +// TestModelsRejectsUnknownProvider pins the fail-closed selection. +func TestModelsRejectsUnknownProvider(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if code := runModelsCommand([]string{"use", "not-a-provider", "x"}); code != exitUsage { + t.Fatalf("exit = %d, want %d", code, exitUsage) + } +} + +// TestAuthStatusNeverLeaksSecret proves auth status reports credential SOURCE +// without ever printing the secret. +func TestAuthStatusNeverLeaksSecret(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("OPENCODE_API_KEY", "sk-supersecret-value") + out := captureStdout(t, func() { + if c := runAuthCommand([]string{"status"}); c != exitOK { + t.Fatalf("auth status exit = %d", c) + } + }) + if strings.Contains(out, "sk-supersecret-value") { + t.Fatalf("auth status leaked the credential:\n%s", out) + } + if !strings.Contains(out, "opencode") || !strings.Contains(out, "env") { + t.Fatalf("auth status did not report the env credential source:\n%s", out) + } +} + +// TestStatsOnEmptyWorkspace proves `izen stats` reports an empty ledger without +// failing. +func TestStatsOnEmptyWorkspace(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + ws := t.TempDir() + out := captureStdout(t, func() { + if c := runStatsCommand([]string{"-dir", ws}); c != exitOK { + t.Fatalf("stats exit = %d", c) + } + }) + if !strings.Contains(out, "no durable tasks") { + t.Fatalf("stats output = %q", out) + } +} + +// TestRunsOnEmptyWorkspace proves `izen runs` reports no tasks without failing. +func TestRunsOnEmptyWorkspace(t *testing.T) { + ws := t.TempDir() + out := captureStdout(t, func() { + if c := runRunsCommand([]string{"-dir", ws}); c != exitOK { + t.Fatalf("runs exit = %d", c) + } + }) + if !strings.Contains(out, "no durable tasks") { + t.Fatalf("runs output = %q", out) + } +} + +// TestResumeWithoutRecordedTaskFailsClosed proves resume does not invent work. +func TestResumeWithoutRecordedTaskFailsClosed(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + ws := t.TempDir() + if _, ok := mostRecentRecoverable(ws); ok { + t.Fatal("mostRecentRecoverable found a task in an empty workspace") + } + // ResumeUsage is reachable and the -h path exits 0. + out := captureStdout(t, func() { + if c := runResumeCommand([]string{"-h"}); c != exitOK { + t.Fatalf("resume -h exit = %d", c) + } + }) + if !strings.Contains(out, "durable") { + t.Fatalf("resume help missing durable mention:\n%s", out) + } + // A resume against an empty workspace reports a truthful error to stderr. + if code := runExecRequestAs("$resume", "resume", []string{"-resume", "-dir", ws, "-quiet"}, ""); code == exitOK { + t.Fatal("resume against an empty workspace reported success") + } +} diff --git a/cmd/izen/exec.go b/cmd/izen/exec.go new file mode 100644 index 00000000..9873e44e --- /dev/null +++ b/cmd/izen/exec.go @@ -0,0 +1,228 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/PizenLabs/izen/internal/cli" + "github.com/PizenLabs/izen/internal/runtime/handlers" +) + +// execRequest is the normalized input of every non-TUI execution command. It is +// produced by parseExecArgs and consumed by runAuthoritativeCommand, the ONE +// production seam that wires the authoritative runtime. No command builds its +// own execution loop or its own terminal truth. +type execRequest struct { + // Dir is the workspace root ("." by default). + Dir string + // Objective is the human objective, verbatim. The command never rewrites it. + Objective string + // Scope is the command surface recorded on the run ("$run"/"$orchestrate"). + // It selects the runtime-resolved mutation authority; it grants nothing. + Scope string + // Label is the operator-facing command name used only for messages. + Label string + // Targets is the optional explicit target set bound through the + // authoritative target seam (never a bypass). + Targets []string + // Provider and Model are explicit provider/model selections. Empty means + // "use the configured active binding"; an explicit value must reach the + // runtime or the command fails, it is never silently ignored. + Provider string + Model string + // BaseURL overrides the provider's endpoint (automation/self-hosted). + BaseURL string + // Mode is the execution mode (ask|investigate|plan|build|review). Empty + // selects the default ("build") — the mutation-capable mode. + Mode string + // Approval is the human-surface policy at a parked approval boundary. + Approval cli.ApprovalPolicy + // Timeout bounds the whole command wall-clock. Zero selects the default. + Timeout time.Duration + // JSON selects machine-readable stdout. Human diagnostics move to stderr. + JSON bool + // Quiet suppresses operator-facing diagnostics on stderr. + Quiet bool + // Resume continues the most recently recorded non-terminal objective from + // the workspace's durable ledger instead of running the given objective. + Resume bool + // ResumeFrom records the durable task id this run continues, when known. + ResumeFrom string +} + +// defaultExecMode is the mutation-capable mode every execution command uses +// unless the operator selects another. +const defaultExecMode = "build" + +// defaultExecTimeout bounds one CLI objective. +const defaultExecTimeout = 10 * time.Minute + +// parseExecArgs parses the shared execution grammar: +// +// -dir workspace root (default ".") +// -target explicit target (repeatable) +// -provider provider override +// -model model override +// -base-url provider endpoint override +// -mode ask|investigate|plan|build|review +// -approve ask|always|never +// -timeout wall-clock bound (e.g. 90s, 5m) +// -json machine-readable stdout +// -quiet suppress diagnostics +// +// It performs no I/O and no execution, so it is directly testable. Both +// single-dash and double-dash forms are accepted. Unknown flags are rejected +// rather than ignored: a silently ignored option is an option that lies. +func parseExecArgs(command string, args []string) (execRequest, bool, error) { + req := execRequest{ + Dir: ".", + Scope: "$" + command, + Label: command, + Approval: cli.ApprovalAsk, + Mode: defaultExecMode, + Timeout: defaultExecTimeout, + } + for i := 0; i < len(args); i++ { + arg := args[i] + if isHelp(arg) { + return req, true, nil + } + name, inline, hasInline := splitFlag(arg) + next := func() (string, error) { + if hasInline { + return inline, nil + } + if i+1 >= len(args) { + return "", fmt.Errorf("izen %s: %s requires a value", command, name) + } + i++ + return args[i], nil + } + switch name { + case "-dir": + v, err := next() + if err != nil { + return req, false, err + } + req.Dir = v + case "-target": + v, err := next() + if err != nil { + return req, false, err + } + if strings.TrimSpace(v) != "" { + req.Targets = append(req.Targets, v) + } + case "-provider": + v, err := next() + if err != nil { + return req, false, err + } + req.Provider = strings.TrimSpace(v) + case "-model": + v, err := next() + if err != nil { + return req, false, err + } + req.Model = strings.TrimSpace(v) + case "-base-url": + v, err := next() + if err != nil { + return req, false, err + } + req.BaseURL = strings.TrimSpace(v) + case "-mode": + v, err := next() + if err != nil { + return req, false, err + } + req.Mode = strings.ToLower(strings.TrimSpace(v)) + case "-approve": + v, err := next() + if err != nil { + return req, false, err + } + policy, perr := cli.ParseApprovalPolicy(v) + if perr != nil { + return req, false, fmt.Errorf("izen %s: %w", command, perr) + } + req.Approval = policy + case "-timeout": + v, err := next() + if err != nil { + return req, false, err + } + d, perr := time.ParseDuration(v) + if perr != nil || d <= 0 { + return req, false, fmt.Errorf("izen %s: invalid -timeout %q", command, v) + } + req.Timeout = d + case "-json": + req.JSON = true + case "-quiet": + req.Quiet = true + case "-resume": + req.Resume = true + default: + if strings.HasPrefix(name, "-") { + return req, false, fmt.Errorf("izen %s: unknown flag %q", command, arg) + } + if req.Objective != "" { + return req, false, fmt.Errorf("izen %s: exactly one objective argument is required", command) + } + req.Objective = arg + } + } + if strings.TrimSpace(req.Objective) == "" { + if !req.Resume { + return req, false, fmt.Errorf("izen %s: an objective argument is required", command) + } + } else if req.Resume { + return req, false, fmt.Errorf("izen %s: -resume continues the recorded objective and takes no objective argument", command) + } + // Validate the mode and provider/model binding early so a misconfiguration + // fails with a usage error before any wire-up or provider call. + if _, ok := handlers.ParsePhase(req.Mode); !ok { + return req, false, fmt.Errorf("izen %s: invalid -mode %q (want ask|investigate|plan|build|review)", command, req.Mode) + } + if req.Provider != "" && !isKnownProvider(req.Provider) { + return req, false, fmt.Errorf("izen %s: unknown provider %q", command, req.Provider) + } + if err := validateProviderModelBinding(req.Provider, req.Model); err != nil { + return req, false, fmt.Errorf("izen %s: %w", command, err) + } + return req, false, nil +} + +// isHelp reports whether an argument requests help. +func isHelp(arg string) bool { + switch arg { + case "-h", "--help", "help": + return true + } + return false +} + +// splitFlag normalizes "-x"/"--x"/"-x=v"/"--x=v" into a canonical name plus an +// optional inline value. +func splitFlag(arg string) (name, value string, hasValue bool) { + if !strings.HasPrefix(arg, "-") { + return arg, "", false + } + trimmed := strings.TrimLeft(arg, "-") + canonical := "-" + trimmed + if eq := strings.IndexByte(trimmed, '='); eq >= 0 { + return "-" + trimmed[:eq], trimmed[eq+1:], true + } + return canonical, "", false +} + +// isKnownProvider reports whether name is a provider the runtime can construct. +func isKnownProvider(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case "ollama", "openrouter", "openai", "anthropic", "gemini", "groq", "opencode", "9router": + return true + } + return false +} diff --git a/cmd/izen/main.go b/cmd/izen/main.go index c91bf8ec..e74c21d1 100644 --- a/cmd/izen/main.go +++ b/cmd/izen/main.go @@ -52,30 +52,35 @@ func printMinimalistHelp() { fmt.Println("izen — human-centered coding intelligence") fmt.Println() fmt.Println("Usage:") - fmt.Println(" izen Start the interactive TUI") - fmt.Println(" izen version Show version information") - fmt.Println(" izen help Show this help message") - fmt.Println(" izen debug [path] Inspect engine diagnostics (Lea, governance, output)") - fmt.Println(" izen auth login Authenticate with a provider") - fmt.Println(" izen stats Show usage statistics") - fmt.Println(" izen config style Set response style policy (verbose|balanced|terse|ultra)") - fmt.Println(" izen compact Compress prompt overhead in context/memory files") - fmt.Println(" izen memory optimize Alias for izen compact") - fmt.Println(" izen rollback Review recent file mutations") - fmt.Println(" izen run \"\" Execute a prompt through the v3 agent runtime") - fmt.Println(" izen orchestrate \"\" Run the control-plane orchestrator on a target") - fmt.Println(" izen [path] Start TUI at the given project path") + fmt.Println(" izen Start the interactive TUI") + fmt.Println(" izen [path] Start TUI at the given project path") + fmt.Println() + fmt.Println("Execution (one authoritative runtime):") + fmt.Println(" izen run [flags] \"\" Execute an objective (see `izen run -h`)") + fmt.Println(" izen prompt [flags] \"\" Alias of run (surface $prompt)") + fmt.Println(" izen orchestrate [flags] \"\" Alias of run (surface $orchestrate)") + fmt.Println(" izen resume [flags] Continue the recorded interrupted objective") + fmt.Println(" izen runs List durable execution tasks") + fmt.Println() + fmt.Println("Inspect and configure:") + fmt.Println(" izen models [use

] List or set the active provider/model binding") + fmt.Println(" izen auth status Show credential availability (never the secret)") + fmt.Println(" izen config show|get|style Inspect or set configuration") + fmt.Println(" izen stats [-json] Token usage from the durable ledger") + fmt.Println(" izen debug [path] Inspect engine diagnostics (Lea, governance, output)") + fmt.Println(" izen compact Compress prompt overhead in context/memory files") + fmt.Println(" izen memory optimize Alias for izen compact") + fmt.Println(" izen rollback Review recent file mutations") + fmt.Println(" izen version Show version information") + fmt.Println(" izen help Show this help message") + fmt.Println() + fmt.Println("Non-interactive execution flags (izen run/resume):") + fmt.Println(" -dir -target -provider -model -base-url -mode -approve -timeout -json -quiet") + fmt.Println() + fmt.Println("Exit codes: 0 success 1 failed 2 usage 3 provider 4 human-required 5 unsubstantiated 6 timeout 7 denied") fmt.Println() fmt.Println("Interactive Commands (inside TUI):") - fmt.Println(" /ask Explain, inspect, understand (read-only)") - fmt.Println(" /plan Architecture, migrations, refactors (no exec)") - fmt.Println(" /build Implement, refactor, write tests (controlled exec)") - fmt.Println(" /investigate Debug bugs, failures, regressions") - fmt.Println(" /review Audit changes, detect risks") - fmt.Println(" /help Show interactive help") - fmt.Println(" /status Inspect workspace, VCS, index, session, and authority state") - fmt.Println(" /mode Switch mode") - fmt.Println(" /q Exit Izen") + fmt.Println(" /ask /plan /build /investigate /review /help /status /mode /q") fmt.Println(" ! Run a shell command") fmt.Println(" Ctrl+C / Esc Exit Izen") } @@ -92,18 +97,13 @@ func main() { printMinimalistHelp() os.Exit(0) case "auth": - if len(os.Args) > 2 && os.Args[2] == "login" { - fmt.Println("Auth login is not yet implemented.") - os.Exit(0) - } - fmt.Println("Usage: izen auth login") - os.Exit(1) + os.Exit(runAuthCommand(os.Args[2:])) + case "models", "model": + os.Exit(runModelsCommand(os.Args[2:])) case "stats": - fmt.Println("Stats are not yet implemented.") - os.Exit(0) + os.Exit(runStatsCommand(os.Args[2:])) case "config": - runConfigCommand(os.Args[2:]) - os.Exit(0) + os.Exit(runConfigCommand(os.Args[2:])) case "compact": runCompactCommand(os.Args[2:]) os.Exit(0) @@ -116,34 +116,19 @@ func main() { os.Exit(1) } os.Exit(0) - case "run": - if err := runRuntimeCommand(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) - } - os.Exit(0) + case "run", "exec": + os.Exit(runRuntimeCommand(os.Args[2:])) case "prompt": - // E2E verification path: `izen prompt "check this file @index.html and rewrite it"` - // Routes through the hard-gated orchestrator Stack to enforce: - // - DecisionSurface with (recommended)/[HIGH RISK]/[DISABLED] annotations - // - Budget hard-block before model invocation - // - Single snapshot read (zero repetitive disk I/O) - promptStr := strings.Join(os.Args[2:], " ") - if strings.TrimSpace(promptStr) == "" && len(os.Args) > 2 { - promptStr = os.Args[2] - } - // Ensure we have a target file for E2E; default to index.html if none extracted. - if err := runOrchestrateCommand([]string{promptStr}); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) - } - os.Exit(0) + // `izen prompt ""` is an alias of `izen run` recorded + // under the "$prompt" surface. It drives the SAME authoritative + // runtime: compose.Wire → autonomy Driver → RuntimeExecutor. + os.Exit(runPromptCommand(os.Args[2:])) case "orchestrate": - if err := runOrchestrateCommand(os.Args[2:]); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) - } - os.Exit(0) + os.Exit(runOrchestrateCommand(os.Args[2:])) + case "resume": + os.Exit(runResumeCommand(os.Args[2:])) + case "runs": + os.Exit(runRunsCommand(os.Args[2:])) } } @@ -377,40 +362,77 @@ func updateLocalConfig(root string, localCfg *config.LocalConfig, det project.De _ = config.SaveLocalConfig(root, localCfg) } -// runConfigCommand implements `izen config style `: it parses the -// policy, persists it to the global config, and activates it for the current -// process. -func runConfigCommand(args []string) { +// runConfigCommand implements the `izen config` command group: +// +// izen config show print the effective non-secret configuration +// izen config get print one effective value +// izen config style set the response style policy +func runConfigCommand(args []string) int { usage := func() { - fmt.Fprintln(os.Stderr, "Usage: izen config style ") - os.Exit(1) + fmt.Fprintln(os.Stderr, "Usage:") + fmt.Fprintln(os.Stderr, " izen config show") + fmt.Fprintln(os.Stderr, " izen config get ") + fmt.Fprintln(os.Stderr, " izen config style ") } - if len(args) < 2 || args[0] != "style" { + if len(args) == 0 { usage() + return exitUsage } - - policy, err := prompt.ParseStylePolicy(args[1]) - if err != nil { - fmt.Fprintf(os.Stderr, "izen config style: %v\n", err) - os.Exit(2) - } - cfg, err := config.Load() if err != nil { - fmt.Fprintf(os.Stderr, "izen config style: load config: %v\n", err) - os.Exit(1) + cfg = config.Default() } - cfg.Style = string(policy) - if err := config.Save(cfg); err != nil { - fmt.Fprintf(os.Stderr, "izen config style: save config: %v\n", err) - os.Exit(1) + switch args[0] { + case "-h", "--help", "help": + usage() + return exitOK + case "show": + fmt.Printf("provider: %s\n", cfg.ActiveProviderName()) + fmt.Printf("model: %s\n", cfg.ActiveModelName()) + fmt.Printf("style: %s\n", cfg.Style) + fmt.Printf("username: %s\n", cfg.Username) + return exitOK + case "get": + if len(args) < 2 { + usage() + return exitUsage + } + switch args[1] { + case "provider": + fmt.Println(cfg.ActiveProviderName()) + case "model": + fmt.Println(cfg.ActiveModelName()) + case "style": + fmt.Println(cfg.Style) + case "username": + fmt.Println(cfg.Username) + default: + fmt.Fprintf(os.Stderr, "izen config get: unknown key %q\n", args[1]) + return exitUsage + } + return exitOK + case "style": + if len(args) < 2 { + usage() + return exitUsage + } + policy, perr := prompt.ParseStylePolicy(args[1]) + if perr != nil { + fmt.Fprintf(os.Stderr, "izen config style: %v\n", perr) + return exitUsage + } + cfg.Style = string(policy) + if serr := config.Save(cfg); serr != nil { + fmt.Fprintf(os.Stderr, "izen config style: save config: %v\n", serr) + return exitFailed + } + prompt.SetActiveStyle(policy) + fmt.Printf("izen: output style set to %q\n", policy) + return exitOK + default: + usage() + return exitUsage } - prompt.SetActiveStyle(policy) - - home, _ := os.UserHomeDir() - path := filepath.Join(home, ".izen", "config.yml") - fmt.Printf("izen: output style set to %q (saved to %s)\n", policy, path) - fmt.Printf("izen: injected OUTPUT STYLE directive into system prompts:\n%s\n", prompt.StyleDirective(policy)) } // runCompactCommand implements `izen compact` / `izen memory optimize`: it diff --git a/cmd/izen/models.go b/cmd/izen/models.go new file mode 100644 index 00000000..37073449 --- /dev/null +++ b/cmd/izen/models.go @@ -0,0 +1,163 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/PizenLabs/izen/internal/config" +) + +// modelsUsage describes the `izen models` command group. +const modelsUsage = `Usage: + izen models List providers, active binding and models + izen models use Persist the active provider/model binding + +The binding is the single source of truth the runtime resolves at admission; an +explicit -provider/-model flag on ` + "`izen run`" + ` overrides it for one command only. + +Flags: + -json Machine-readable output (list and use) +` + +// runModelsCommand implements `izen models`. +func runModelsCommand(args []string) int { + jsonOut := false + var rest []string + for _, a := range args { + switch a { + case "-h", "--help": + fmt.Print(modelsUsage) + return exitOK + case "-json", "--json": + jsonOut = true + default: + rest = append(rest, a) + } + } + cfg, err := config.Load() + if err != nil { + cfg = config.Default() + } + if len(rest) == 0 { + return listModels(cfg, jsonOut) + } + if rest[0] == "use" { + return useModel(cfg, rest[1:], jsonOut) + } + fmt.Fprintf(os.Stderr, "izen models: unknown subcommand %q\n", rest[0]) + fmt.Fprint(os.Stderr, modelsUsage) + return exitUsage +} + +// providerView is one non-secret provider projection. +type providerView struct { + Name string `json:"name"` + Active bool `json:"active"` + HasBaseURL bool `json:"has_base_url"` + BaseURL string `json:"base_url,omitempty"` + Credential string `json:"credential_source,omitempty"` + HasCredential bool `json:"has_credential"` + ConfiguredModel string `json:"configured_model,omitempty"` +} + +func listModels(cfg *config.Config, jsonOut bool) int { + activeProvider := cfg.ActiveProviderName() + activeModel := cfg.ActiveModelName() + + names := make([]string, 0, len(config.ValidProviderNames)) + for name := range config.ValidProviderNames { + names = append(names, name) + } + sort.Strings(names) + + views := make([]providerView, 0, len(names)) + for _, name := range names { + provCfg := cfg.AI.Providers[name] + src := config.CredentialSourceFor(cfg, name) + views = append(views, providerView{ + Name: name, + Active: name == activeProvider, + HasBaseURL: strings.TrimSpace(provCfg.BaseURL) != "", + BaseURL: provCfg.BaseURL, + Credential: src, + HasCredential: src != "", + ConfiguredModel: provCfg.DefaultModel, + }) + } + + if jsonOut { + out := map[string]any{ + "active_provider": activeProvider, + "active_model": activeModel, + "providers": views, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + fmt.Fprintf(os.Stderr, "izen models: encode: %v\n", err) + return exitFailed + } + return exitOK + } + + fmt.Printf("active: provider=%s model=%s\n\n", activeProvider, activeModel) + for _, v := range views { + mark := " " + if v.Active { + mark = "*" + } + cred := "no credentials" + if v.HasCredential { + cred = "credentials: " + v.Credential + } + base := "" + if v.BaseURL != "" { + base = " base_url=" + v.BaseURL + } + fmt.Printf(" %s %-12s %-18s%s\n", mark, v.Name, cred, base) + } + fmt.Println("\nUse `izen models use ` to set the active binding.") + return exitOK +} + +func useModel(cfg *config.Config, args []string, jsonOut bool) int { + if len(args) != 2 { + fmt.Fprintln(os.Stderr, "Usage: izen models use ") + return exitUsage + } + provider := strings.TrimSpace(args[0]) + model := strings.TrimSpace(args[1]) + if !config.ValidateProviderName(provider) { + fmt.Fprintf(os.Stderr, "izen models use: unknown provider %q\n", provider) + return exitUsage + } + if model == "" { + fmt.Fprintln(os.Stderr, "izen models use: model is required") + return exitUsage + } + if err := validateProviderModelBinding(provider, model); err != nil { + fmt.Fprintf(os.Stderr, "izen models use: %v\n", err) + return exitUsage + } + cfg.Bindings.Active.Provider = provider + cfg.Bindings.Active.Model = model + if cfg.AI.Providers == nil { + cfg.AI.Providers = map[string]config.AIProviderConfig{} + } + if _, ok := cfg.AI.Providers[provider]; !ok { + cfg.AI.Providers[provider] = config.AIProviderConfig{BaseURL: config.WellKnownBaseURL(provider)} + } + if err := config.Save(cfg); err != nil { + fmt.Fprintf(os.Stderr, "izen models use: save config: %v\n", err) + return exitFailed + } + if jsonOut { + _ = json.NewEncoder(os.Stdout).Encode(map[string]string{"provider": provider, "model": model}) + return exitOK + } + fmt.Printf("izen: active binding set to provider=%s model=%s\n", provider, model) + return exitOK +} diff --git a/cmd/izen/orchestrate.go b/cmd/izen/orchestrate.go index 37371b02..903a0ff7 100644 --- a/cmd/izen/orchestrate.go +++ b/cmd/izen/orchestrate.go @@ -1,195 +1,34 @@ package main -import ( - "context" - "errors" - "fmt" - "os" - "os/signal" - "strings" - "syscall" - "time" - - "github.com/PizenLabs/izen/internal/ai" - legacyaudit "github.com/PizenLabs/izen/internal/audit" - "github.com/PizenLabs/izen/internal/cli" - "github.com/PizenLabs/izen/internal/config" - "github.com/PizenLabs/izen/internal/contextcompiler" - "github.com/PizenLabs/izen/internal/runtime/orchestrator" -) - // orchestrateUsage describes the `izen orchestrate` subcommand. -const orchestrateUsage = `Usage: izen orchestrate [flags] "" +const orchestrateUsage = `Usage: izen orchestrate [flags] "" -Run one prompt through the Izen control-plane orchestrator. The prompt is -treated as the target file reference (for example "README.md"). The full -deterministic control loop runs: preflight -> LLM proposal -> validation -> -approval gate -> atomic commit. +Run one objective through the AUTHORITATIVE Izen runtime — the same bounded +autonomy Driver the TUI drives over the RuntimeExecutor execution plane. It is +an alias of ` + "`izen run`" + ` recorded under the "$orchestrate" surface. -Flags: - -dir Workspace root (default ".") +See ` + "`izen run -h`" + ` for the full flag set (dir/target/provider/model/mode/ +approve/timeout/json/quiet). Examples: -The proposal diff is rendered to stdout and an interactive [y/N] approval -prompt blocks until you decide. "y" applies the proposal atomically, "i" -inspects without applying, and anything else (including Enter) rejects it. + izen orchestrate "redesign the portfolio website" + izen orchestrate -json -approve always "add a health check endpoint" ` -// runOrchestrateCommand implements `izen orchestrate`: it wires the control -// plane (preflight, proposal provider, terminal UI projection bridge, and -// orchestrator) and executes one prompt through the deterministic control -// loop. -// -// PHASE 8 M5 — SINGLE-CYCLE CONTRACT (intentional, canonical): one -// invocation performs one cli.Stack cycle (Stack.Run) and returns. -// Continuation requires the caller to re-invoke; the command never enters -// autonomy.Driver and never loops internally. Authorization converges with -// every other entry point (same guards, same approval [y/N]/i semantics); -// only the loop cardinality differs, by explicit contract. -func runOrchestrateCommand(args []string) error { - dir := "." - var prompt string - for i := 0; i < len(args); i++ { - switch args[i] { - case "-h", "--help": - fmt.Print(orchestrateUsage) - return nil - case "-dir": - if i+1 >= len(args) { - return errors.New("izen orchestrate: -dir requires a path") - } - i++ - dir = args[i] - default: - if strings.HasPrefix(args[i], "-") { - return fmt.Errorf("izen orchestrate: unknown flag %q", args[i]) - } - if prompt != "" { - return errors.New("izen orchestrate: exactly one target argument is required") - } - prompt = args[i] - } - } - if prompt == "" { - return errors.New("izen orchestrate: a target argument is required") - } - - cfg, err := config.Load() - if err != nil { - cfg = config.Default() - } - provider, model, err := buildActiveProvider(cfg) - if err != nil { - // For E2E without provider config, still exercise hard-gated DecisionSurface - // via a no-op provider so the binary reflects invariants without needing API keys. - fmt.Fprintf(os.Stderr, "izen v%s (rmah-wired) provider=none model=none root=%s\n", Version, dir) - fmt.Fprintf(os.Stderr, "izen v%s: running hard-gated preflight for prompt %q\n", Version, prompt) - ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel2() - stub := &stubNoopProvider{} - stack2 := cli.Wire(stub, dir, os.Stdin, os.Stdout) - // Force small budget to guarantee BudgetExceeded for large index.html in E2E - // (DefaultTokenBudget 12000 would not exceed for moderate files). - // Override to 1024 to ensure FULL_REWRITE disabled is visible. - stack2.TokenBudget = 1024 - res2, _ := stack2.Run(ctx2, dir, prompt) - if res2 != nil { - fmt.Printf("proposal: %s target: %s action: %s committed: %t\n", res2.ProposalID, res2.Target, cli.ActionLabel(res2.Action), res2.Committed) - } - return nil - } - provider = contextcompiler.New().WrapProvider(provider) - fmt.Fprintf(os.Stderr, "izen v%s: orchestrator provider=%s model=%s root=%s\n", Version, provider.Name(), model, dir) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - stack := cli.Wire(&orchestrateAdapter{provider: provider, model: model}, dir, os.Stdin, os.Stdout) - - // ── AUDIT DURABILITY (Truthful State Transition binding) ────────── - // The synchronous audit seam is wired into the orchestrator BEFORE the - // cycle runs: session finalization performs a blocking Flush whose error - // structurally invalidates success (ErrAuditPersistenceFailed) without - // rolling back the disk mutation. The same blocking flush is tied to - // SIGINT/SIGTERM and the defer chain so termination can never bypass it. - auditFlusher := legacyaudit.NewLogger(dir) - if stack.Orchestrator != nil { - stack.Orchestrator.WithAuditFlusher(auditFlusher) - } - sigCh := make(chan os.Signal, 2) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - sigDone := make(chan struct{}) - defer close(sigDone) - go func() { - select { - case sig := <-sigCh: - fmt.Fprintf(os.Stderr, "izen orchestrate: caught %v — flushing audit log synchronously…\n", sig) - if ferr := auditFlusher.Flush(); ferr != nil { - fmt.Fprintf(os.Stderr, "izen orchestrate: audit flush on signal failed: %v\n", ferr) - } - case <-sigDone: - } - }() - defer func() { - signal.Stop(sigCh) - // Belt-and-braces final flush: RunCycle already flushed at terminal - // finalization and propagated its error; this defer guarantees the - // blocking flush sequence runs even if the cycle panics or returns - // early. Errors are reported, never swallowed. - if ferr := auditFlusher.Flush(); ferr != nil { - fmt.Fprintf(os.Stderr, "izen orchestrate: final audit flush failed: %v\n", ferr) - } - }() - - res, runErr := stack.Run(ctx, dir, prompt) - - if res != nil { - fmt.Printf("proposal: %s target: %s action: %s committed: %t\n", - res.ProposalID, res.Target, cli.ActionLabel(res.Action), res.Committed) - if res.AuditError != nil { - fmt.Fprintf(os.Stderr, "izen orchestrate: audit persistence failed — evidence integrity compromised (mutations stand, success invalidated): %v\n", res.AuditError) - } - } - if runErr != nil { - if errors.Is(runErr, orchestrator.ErrAuditPersistenceFailed) { - return fmt.Errorf("izen orchestrate: %w", runErr) - } - if errors.Is(runErr, orchestrator.ErrExecutionRejected) { - fmt.Println("execution rejected: the proposal was not applied") - return nil - } - return fmt.Errorf("izen orchestrate: %w", runErr) - } - return nil -} - -// orchestrateAdapter adapts the configured ai.Provider to the cli.LLMProvider -// contract so the CLI control plane stays free of a concrete AI dependency. -type orchestrateAdapter struct { - provider ai.Provider - model string -} +// promptUsage describes the `izen prompt` subcommand, an alias recorded under +// the "$prompt" surface. +const promptUsage = `Usage: izen prompt [flags] "" -// stubNoopProvider is a no-op LLM provider for E2E hard-gate verification without API keys. -type stubNoopProvider struct{} +Execute one objective through the authoritative Izen runtime (alias of +` + "`izen run`" + ` under the "$prompt" surface). See ` + "`izen run -h`" + ` for the flags.` -func (s *stubNoopProvider) Complete(_ context.Context, _, _ string) (string, error) { - return "noop", nil +// runOrchestrateCommand implements `izen orchestrate` through the shared +// authoritative lifecycle. +func runOrchestrateCommand(args []string) int { + return runExecRequestAs("$orchestrate", "orchestrate", args, orchestrateUsage) } -// Complete implements cli.LLMProvider. -func (a *orchestrateAdapter) Complete(ctx context.Context, system, prompt string) (string, error) { - resp, err := a.provider.Execute(ctx, ai.Request{ - Model: a.model, - System: system, - Messages: []ai.Message{{Role: "user", Content: prompt}}, - Stream: false, - ContextPhase: "execute", - }) - if err != nil { - return "", err - } - if resp == nil { - return "", errors.New("provider returned an empty response") - } - return resp.Content, nil +// runPromptCommand implements `izen prompt` through the shared authoritative +// lifecycle. +func runPromptCommand(args []string) int { + return runExecRequestAs("$prompt", "prompt", args, promptUsage) } diff --git a/cmd/izen/report.go b/cmd/izen/report.go new file mode 100644 index 00000000..ad7a6a67 --- /dev/null +++ b/cmd/izen/report.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/PizenLabs/izen/internal/execution" +) + +// reportSchema is the stable schema identifier of the machine-readable run +// report. Automation should key off this string, not off prose. +const reportSchema = "izen.run.v1" + +// runReport is the machine-readable projection of ONE authoritative run. Every +// field is derived from runtime state (the driver's terminal result, its +// objective report, the executor's applied mutations and the durable ledger); +// nothing is inferred from model prose and no unavailable evidence is +// fabricated. It distinguishes EXECUTION completion (a mutation happened) from +// OBJECTIVE proof (the runtime observed every completion condition hold). +type runReport struct { + Schema string `json:"schema"` + RunID string `json:"run_id,omitempty"` + Surface string `json:"surface"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Workspace string `json:"workspace"` + Objective string `json:"objective"` + Mode string `json:"mode"` + Targets []string `json:"targets,omitempty"` + // ResumedFrom is the durable task id this run continues, when the run was + // started with -resume. + ResumedFrom string `json:"resumed_from,omitempty"` + + // Outcome is the terminal classification (see exitCodeForOutcome). + Outcome string `json:"outcome"` + // Reason is the runtime's own terminal reason, verbatim. + Reason string `json:"reason,omitempty"` + // Error is the command-level error text when the run could not complete. + Error string `json:"error,omitempty"` + + // Human-surface decisions. + Approved bool `json:"approved"` + Inspected bool `json:"inspected"` + Rejected bool `json:"rejected"` + ClarifyRequired bool `json:"clarify_required"` + + // Objective is the runtime's own completion-contract projection. + ObjectiveReport objectiveReport `json:"objective_contract"` + // Artifacts is the reviewed candidate and the mutations actually applied. + Artifacts artifactReport `json:"artifacts"` + // Usage is the runtime's own aggregated token accounting. + Usage usageReport `json:"usage"` + + // ElapsedMS is the command wall-clock in milliseconds. + ElapsedMS int64 `json:"elapsed_ms"` + // ExitCode is the process exit code this run maps to. + ExitCode int `json:"exit_code"` +} + +type objectiveReport struct { + ObjectiveID string `json:"objective_id,omitempty"` + TaskKind string `json:"task_kind,omitempty"` + Progress string `json:"progress,omitempty"` + Outcome string `json:"outcome,omitempty"` + Continuation string `json:"continuation,omitempty"` + Scope []string `json:"scope,omitempty"` + Pending []string `json:"pending,omitempty"` + Conditions []conditionReport `json:"conditions,omitempty"` + RequirementCount int `json:"requirement_count"` + DischargedCount int `json:"discharged_count"` +} + +type conditionReport struct { + ID string `json:"id"` + Description string `json:"description,omitempty"` + Obligation string `json:"obligation,omitempty"` + Status string `json:"status"` + VerificationState string `json:"verification_state,omitempty"` + EvidenceRefs []string `json:"evidence_refs,omitempty"` +} + +type artifactReport struct { + // CandidateTargets is the reviewed candidate's target set. + CandidateTargets []string `json:"candidate_targets,omitempty"` + // CandidateID and CandidateDigest bind the review to the authorization. + CandidateID string `json:"candidate_id,omitempty"` + CandidateDigest string `json:"candidate_digest,omitempty"` + // Operation is the runtime-derived operation (CREATE/PATCH/...). + Operation string `json:"operation,omitempty"` + // AddedLines and RemovedLines are the reviewed candidate's metrics. + AddedLines int `json:"added_lines"` + RemovedLines int `json:"removed_lines"` + // Applied is the exact target set the executor durably mutated. + Applied []string `json:"applied,omitempty"` +} + +type usageReport struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + Known bool `json:"known"` +} + +// newObjectiveReport projects the runtime's ObjectiveProgressReport without +// re-deciding anything. +func newObjectiveReport(rep execution.ObjectiveProgressReport) objectiveReport { + out := objectiveReport{ + ObjectiveID: rep.ObjectiveID, + TaskKind: string(rep.TaskKind), + Progress: string(rep.Progress), + Outcome: string(rep.Outcome), + Continuation: string(rep.Continuation), + Scope: append([]string(nil), rep.Scope...), + Pending: append([]string(nil), rep.Pending...), + } + out.RequirementCount = len(rep.Requirements) + for _, r := range rep.Requirements { + if r.Discharged { + out.DischargedCount++ + } + } + for _, c := range rep.Conditions { + out.Conditions = append(out.Conditions, conditionReport{ + ID: c.ID, + Description: c.Description, + Obligation: string(c.Obligation), + Status: string(c.Status), + VerificationState: string(c.VerificationState), + EvidenceRefs: append([]string(nil), c.EvidenceRefs...), + }) + } + return out +} + +// writeJSON emits the report as one JSON object on w. +func (r *runReport) writeJSON(w io.Writer) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(r) +} + +// writeHuman renders a compact, truthful terminal summary. It never claims +// proof the runtime did not report. +func (r *runReport) writeHuman(w io.Writer) { + _, _ = fmt.Fprintf(w, "%s: run=%s outcome=%s", r.Surface, r.RunID, r.Outcome) + if r.ObjectiveReport.Progress != "" { + _, _ = fmt.Fprintf(w, " objective=%s", r.ObjectiveReport.Progress) + } + if len(r.Artifacts.Applied) > 0 { + _, _ = fmt.Fprintf(w, " applied=%v", r.Artifacts.Applied) + } + if r.Reason != "" { + _, _ = fmt.Fprintf(w, " reason=%q", r.Reason) + } + if r.Error != "" { + _, _ = fmt.Fprintf(w, " error=%q", r.Error) + } + _, _ = fmt.Fprintln(w) +} diff --git a/cmd/izen/resume.go b/cmd/izen/resume.go new file mode 100644 index 00000000..e8c7525d --- /dev/null +++ b/cmd/izen/resume.go @@ -0,0 +1,106 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/PizenLabs/izen/internal/runtime/durable" +) + +// resumeUsage describes `izen resume` (an alias for `izen run -resume`). +const resumeUsage = `Usage: izen resume [flags] + +Continue the most recently recorded non-terminal objective from the workspace's +durable execution ledger (.izen/runtime/ledger.ndjson) through the SAME +authoritative runtime as ` + "`izen run`" + `. The runtime re-observes the live workspace, +reconciles any interrupted mutation, and re-derives authority; resume never +reuses a stale authorization. + +Flags are the execution flags (without an objective): + -dir Workspace root (default ".") + -approve ask|always|never + -json Emit one machine-readable JSON report on stdout + -timeout Wall-clock bound + +Examples: + izen resume + izen resume -json -approve always +` + +// runResumeCommand implements `izen resume`: `izen run -resume` under the +// truthful "$resume" surface. +func runResumeCommand(args []string) int { + args = append([]string{"-resume"}, args...) + return runExecRequestAs("$resume", "resume", args, resumeUsage) +} + +// runsUsage describes `izen runs`, the read-only durable-state listing. +const runsUsage = `Usage: izen runs [-dir ] + +List the durable execution tasks recorded in .izen/runtime/ledger.ndjson. Each +row is the runtime's own materialized view (id, status, scope, intent); nothing +is inferred and no attempt to resume is made. + +Flags: + -dir Workspace root (default ".") +` + +// runRunsCommand lists the durable tasks without resuming anything. +func runRunsCommand(args []string) int { + dir := "." + for i := 0; i < len(args); i++ { + switch args[i] { + case "-h", "--help": + fmt.Print(runsUsage) + return exitOK + case "-dir": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "izen runs: -dir requires a path") + return exitUsage + } + i++ + dir = args[i] + default: + if strings.HasPrefix(args[i], "-") { + fmt.Fprintf(os.Stderr, "izen runs: unknown flag %q\n", args[i]) + return exitUsage + } + fmt.Fprintf(os.Stderr, "izen runs: unexpected argument %q\n", args[i]) + return exitUsage + } + } + tasks, err := recoverableTasks(dir) + if err != nil { + fmt.Fprintf(os.Stderr, "izen runs: %v\n", err) + return exitFailed + } + if len(tasks) == 0 { + fmt.Println("izen runs: no durable tasks recorded") + return exitOK + } + for _, t := range tasks { + fmt.Printf("%-24s %-12s scope=%v intent=%q\n", t.ID, t.Status, t.ActiveTargetScope, t.Intent) + } + return exitOK +} + +// recoverableTasks returns every non-terminal durable task, most recent first. +func recoverableTasks(dir string) ([]durable.TaskState, error) { + store := durable.NewTaskStore(dir) + if err := store.Open(); err != nil { + return nil, err + } + defer func() { _ = store.Close() }() + return store.RecoverableTasks(), nil +} + +// mostRecentRecoverable returns the most recently replayed non-terminal task. +func mostRecentRecoverable(dir string) (durable.TaskState, bool) { + store := durable.NewTaskStore(dir) + if err := store.Open(); err != nil { + return durable.TaskState{}, false + } + defer func() { _ = store.Close() }() + return store.MostRecentRecoverable() +} diff --git a/cmd/izen/runtime.go b/cmd/izen/runtime.go index aeea8ec1..b020e40d 100644 --- a/cmd/izen/runtime.go +++ b/cmd/izen/runtime.go @@ -1,498 +1,52 @@ package main import ( - "context" - "errors" "fmt" - "os" - "os/signal" - "path/filepath" "strings" - "syscall" - "time" "github.com/PizenLabs/izen/internal/ai" - "github.com/PizenLabs/izen/internal/app" - "github.com/PizenLabs/izen/internal/app/compiler" "github.com/PizenLabs/izen/internal/config" - "github.com/PizenLabs/izen/internal/contextcompiler" - "github.com/PizenLabs/izen/internal/events" - auditevents "github.com/PizenLabs/izen/internal/events/audit" - "github.com/PizenLabs/izen/internal/ir" - "github.com/PizenLabs/izen/internal/knowledge" "github.com/PizenLabs/izen/internal/providers" "github.com/PizenLabs/izen/internal/runtime/executor" - "github.com/PizenLabs/izen/internal/runtime/orchestrator" - "github.com/PizenLabs/izen/internal/runtime/substrate" - "github.com/PizenLabs/izen/internal/tui/components/ask" ) // runRuntimeUsage describes the `izen run` subcommand. -const runRuntimeUsage = `Usage: izen run [flags] "" +const runRuntimeUsage = `Usage: izen run [flags] "" -Execute a single prompt through the Izen V3 Agent Runtime Engine and print the -full pipeline audit trail. Every prompt is routed strictly through: - - Capability Registry -> Extractor Pipeline -> Artifact IR - -> Planner -> ExecutionGraph -> Kernel Engine - -Conversational prompts (greetings, small talk, identity questions) run via a -direct chat pass and return the model's text answer directly. Code-generation -prompts are constrained by the resolved capability set (semantic HTML, -TypeScript, portfolio structure, Go, ...) in the system prompt, and generated -artifacts pass the capability validation gate before the planner and kernel -write anything to disk. Rejected output triggers evidence-based retries. +Execute one objective through the AUTHORITATIVE Izen runtime — the same bounded +autonomy Driver that the TUI drives over the RuntimeExecutor execution plane. +The runtime owns admission, the provider seam, the proposal/approval boundary, +mutation, verification, recovery, cancellation and terminal truth; this command +only plays the human at the approval boundary (or auto-approves/denies when a +non-interactive -approve policy is given). Flags: - -dir Workspace root (default ".") - -target Target file to analyze/modify (repeatable) + -dir Workspace root (default ".") + -target Target file to analyze/modify (repeatable) + -provider Provider override (ollama|openrouter|openai|anthropic|gemini|groq|opencode|9router) + -model Model override (must belong to the selected provider) + -base-url Provider endpoint override (self-hosted / automation) + -mode Execution mode: ask|investigate|plan|build|review (default build) + -approve Approval policy: ask|always|never (default ask) + -timeout Wall-clock bound, e.g. 90s or 5m (default 10m) + -json Emit one machine-readable JSON report on stdout + -quiet Suppress operator diagnostics on stderr + +Exit codes: + 0 success 1 failed 2 usage/config 3 provider + 4 human-required 5 unsubstantiated 6 timeout 7 denied Examples: izen run "redesign the portfolio website" - izen run "scaffold a go api server" - izen run "explain the routing layer" + izen run -target main.go "add a health check endpoint" + izen run -json -approve never "delete the migration files" + izen run -provider opencode -model z-ai/glm-4 "explain the routing layer" ` -// cliGenerator adapts the configured ai.Provider to the V3 pipeline -// Generator contract so the pipeline stays free of any provider dependency. -type cliGenerator struct { - provider ai.Provider - model string -} - -// Complete implements app.Generator. -func (g *cliGenerator) Complete(ctx context.Context, system, prompt string, _ int) (string, error) { - resp, err := g.provider.Execute(ctx, ai.Request{ - Model: g.model, - System: system, - Messages: []ai.Message{{Role: "user", Content: prompt}}, - Stream: false, - ContextPhase: "execute", - }) - if err != nil { - return "", err - } - if resp == nil { - return "", errors.New("provider returned an empty response") - } - return resp.Content, nil -} - -// semanticExtractorAdapter adapts the configured ai.Provider to the intent -// compiler's SemanticExtractor contract so the compiler stays free of a -// concrete AI dependency. -type semanticExtractorAdapter struct { - provider ai.Provider - model string -} - -// Extract implements compiler.SemanticExtractor. -func (e *semanticExtractorAdapter) Extract(ctx context.Context, system, prompt string) (string, error) { - resp, err := e.provider.Execute(ctx, ai.Request{ - Model: e.model, - System: system, - Messages: []ai.Message{{Role: "user", Content: prompt}}, - Stream: false, - ContextPhase: "investigate", - }) - if err != nil { - return "", err - } - if resp == nil { - return "", errors.New("provider returned an empty response") - } - return resp.Content, nil -} - -// runRuntimeCommand implements `izen run`: it builds the V3 pipeline from the -// shared configuration, routes the prompt through app.Pipeline.Run and prints -// the capability, extraction, validation, planning and execution audit trail. -// -// PHASE 8 M5 — SINGLE-CYCLE CONTRACT (intentional, canonical): one -// invocation performs one pipeline run and returns. Continuation requires -// the caller to re-invoke (each run re-reads workspace state). The pipeline -// shares the canonical execution authority, substrate confinement, and -// guards with every other entry point; only the loop cardinality differs -// from the TUI Driver path, by explicit contract rather than by accident. -func runRuntimeCommand(args []string) (err error) { - dir := "." - var targets []string - var prompt string - for i := 0; i < len(args); i++ { - switch args[i] { - case "-h", "--help": - fmt.Print(runRuntimeUsage) - return nil - case "-dir": - if i+1 >= len(args) { - return errors.New("izen run: -dir requires a path") - } - i++ - dir = args[i] - case "-target": - if i+1 >= len(args) { - return errors.New("izen run: -target requires a path") - } - i++ - targets = append(targets, args[i]) - default: - if strings.HasPrefix(args[i], "-") { - return fmt.Errorf("izen run: unknown flag %q", args[i]) - } - if prompt != "" { - return errors.New("izen run: exactly one prompt argument is required") - } - prompt = args[i] - } - } - if prompt == "" { - return errors.New("izen run: a prompt argument is required") - } - - // ── DURABLE EXECUTION LEDGER (§10/§15) ──────────────────────────── - // The journal of record for what this cycle actually does, opened and - // replayed BEFORE the pipeline can dispatch anything. `izen run` builds - // its own pipeline rather than going through compose.Wire, so without - // this the headless path left no reconstructable execution state: the - // audit log it writes has no reader and no fold, and an interrupted run - // left no memory of itself anywhere. - // - // Fail closed here, exactly as compose.Wire does: a ledger that cannot be - // opened or replayed is not an execution truth, and the cycle has not - // mutated anything yet, so aborting costs nothing. - ledger, err := newRunLedger(dir, prompt, targets) - if err != nil { - return fmt.Errorf("izen run: %w", err) - } - defer func() { - // Close releases the runtime file lock. Its error is surfaced on the - // run's own outcome, never dropped: a shut-down runtime still holding - // the journal's writer lock would make the next process's lineage - // unknowable. - err = errRunLedgerClosed(err, ledger.Close()) - }() - - cfg, err := config.Load() - if err != nil { - cfg = config.Default() - } - - provider, model, err := buildActiveProvider(cfg) - if err != nil { - ledger.Fail("provider_unavailable", err) - return err - } - provider = contextcompiler.New().WrapProvider(provider) - fmt.Fprintf(os.Stderr, "izen: v3 engine provider=%s model=%s root=%s\n", provider.Name(), model, dir) - - // A shared RuntimeKnowledge graph caches the workspace scan across the - // intent compilation stage so no redundant disk sweeps occur. - kg := knowledge.NewKnowledgeGraph() - - // The IntentCompiler is wired into the pipeline, which triggers it whenever - // a request carries no compiled intent. OperationSemantics is therefore - // always derived from a strongly-typed ir.Category — never from keyword - // lists in the pipeline layer. - intentCompiler := compiler.NewIntentCompiler(dir, &semanticExtractorAdapter{provider: provider, model: model}, compiler.WithKnowledgeGraph(kg)) - - pipeline, err := app.NewPipeline( - app.WithRoot(dir), - app.WithGenerator(&cliGenerator{provider: provider, model: model}), - app.WithKnowledgeGraph(kg), - app.WithIntentCompiler(intentCompiler), - app.WithSubstrate(substrate.NewConcreteSubstrate(dir)), - // The interactive "Questions Before Implementation" component unblocks - // the pipeline when an ambiguous intent asks its questions. - app.WithClarifier(app.ClarifierFunc(func(ctx context.Context, questions []ir.ClarificationQuestion, resp chan<- ir.ClarificationResponse) error { - r, err := ask.Run(ctx, questions) - if err != nil { - return err - } - select { - case resp <- r: - return nil - case <-ctx.Done(): - return ctx.Err() - } - })), - ) - if err != nil { - ledger.Fail("pipeline_build_failed", err) - return fmt.Errorf("izen run: build v3 pipeline: %w", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - // ── AUDIT PERSISTENCE (durable non-repudiation) ───────────────────── - // Every `izen run` execution persists the complete chronological lifecycle - // sequence to .izen/audit/events.ndjson via the async AuditLogger. The - // logger subscribes to the pipeline bus BEFORE any lifecycle event is - // published so the trail is complete; session finalization performs a - // BLOCKING synchronous Flush (also tied to SIGINT/SIGTERM below) whose - // error structurally invalidates execution success. - auditDir := filepath.Join(dir, ".izen", "audit") - auditLogger, err := auditevents.NewLogger(auditDir, pipeline.Bus()) - if err != nil { - ledger.Fail("audit_logger_failed", err) - return fmt.Errorf("izen run: wire audit logger: %w", err) - } - if err := auditLogger.Start(); err != nil { - ledger.Fail("audit_logger_failed", err) - return fmt.Errorf("izen run: start audit logger: %w", err) - } - // Signal-bound synchronous flush: SIGINT/SIGTERM trigger a blocking - // flush before the process terminates so evidence is never lost to an - // interrupt. The channel is stopped on normal finalization. - sigCh := make(chan os.Signal, 2) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - sigDone := make(chan struct{}) - defer close(sigDone) - go func() { - select { - case sig := <-sigCh: - fmt.Fprintf(os.Stderr, "izen run: caught %v — flushing audit log synchronously…\n", sig) - if ferr := auditLogger.Flush(); ferr != nil { - fmt.Fprintf(os.Stderr, "izen run: audit flush on signal failed: %v\n", ferr) - } - if cerr := auditLogger.Close(); cerr != nil { - fmt.Fprintf(os.Stderr, "izen run: audit teardown on signal failed: %v\n", cerr) - } - signal.Stop(sigCh) - // Re-raise with default disposition so the exit status reflects - // the signal. - p, _ := os.FindProcess(os.Getpid()) - _ = p.Signal(sig) - case <-sigDone: - } - }() - // Defer-chain teardown: guarantees the audit file is closed even on early - // returns. The authoritative session-finalization Flush above already - // propagated its error into the exit status; a teardown failure here is - // reported, never swallowed. - defer func() { - signal.Stop(sigCh) - if err := auditLogger.Close(); err != nil { - fmt.Fprintf(os.Stderr, "izen run: audit teardown failed: %v\n", err) - } - }() - - // Attach a terminal status observer rendering kernel task and pipeline - // stage updates on stderr as they happen. A TUI subscribes with the same - // events.Bus contract. - unsub := pipeline.Bus().SubscribeAll(func(ev events.DomainEvent) { - fmt.Fprintln(os.Stderr, app.StatusLine(ev)) - }) - defer unsub.Cancel() - - // The canonical execution lifecycle opens here: the execution.started - // record is the first line of the chronological audit sequence. - // - // Architecture lock (TestLifecycleEventsGeneratedOnlyFromGraph): the - // typed lifecycle constructors (NewExecutionStarted, - // NewVerificationCompleted, NewExecutionFinished) may ONLY be invoked - // by the runtime-owned execution graph. This command routes through the - // V3 app pipeline — not the graph — so its audit trail is recorded as - // envelope records carrying the same canonical Source discriminators - // and equivalent payloads. Envelopes persist verbatim in events.ndjson - // (Source preserved) and never traverse typed subscriptions, so they - // cannot fabricate graph lifecycle state in any projection: they are - // bookkeeping of what this pipeline actually did. - requestID := events.NewEnvelopeID() - pipeline.Bus().PublishEnvelope(events.NewEnvelope( - events.DomainKindSystem, events.EventExecutionStarted, - events.ExecutionStartedPayload{RequestID: requestID, Mode: "run", Prompt: prompt}, - )) - - // The operation this cycle executes enters the substrate here, BEFORE - // pipeline.Run, so a crash from this point on is reconcilable against the - // pre-dispatch worktree digest. - ledger.Dispatch() - - res, runErr := pipeline.Run(ctx, app.Request{Intent: prompt, Targets: targets}) - - // The lifecycle closes here from the REAL pipeline outcome (never - // synthesised): plan.staged from the produced plan, patch.applied per - // validated artifact, verification.completed from the validation gate, - // and the terminal execution.finished. The audit logger persists every - // one of these because it subscribes to the whole bus. - publishRunLifecycle(pipeline.Bus(), requestID, res, runErr) - - // ── SESSION FINALIZATION: blocking synchronous flush ────────────── - // Audit persistence failure structurally invalidates execution success: - // even when mutations succeeded and tests passed, a flush error forces - // a non-zero exit with ErrAuditPersistenceFailed and marks evidence - // integrity as compromised (mutations are NOT rolled back). - if flushErr := auditLogger.Flush(); flushErr != nil { - _, _ = fmt.Fprintln(os.Stderr, "izen run: audit persistence failed — evidence integrity compromised (mutations stand, success invalidated)") - // Audit persistence failure invalidates execution success, so it is a - // failure of the cycle and is recorded as one — never as a completed - // execution. - ledger.Fail("audit_persistence_failed", flushErr) - return fmt.Errorf("%w: audit flush: %w", orchestrator.ErrAuditPersistenceFailed, flushErr) - } - - fmt.Println() - fmt.Println("── V3 pipeline audit trail ───────────────────────────────") - if res != nil { - if res.Mode != "" { - fmt.Printf("mode: %s\n", res.Mode) - } - if res.IntentIR != nil && len(res.IntentIR.ClarificationQuestions) > 0 { - for _, q := range res.IntentIR.ClarificationQuestions { - choice := q.SelectedOption - if q.CustomAnswer != "" { - choice += " (" + q.CustomAnswer + ")" - } - fmt.Printf("clarified: %s -> %s\n", q.Header, choice) - } - } - if len(res.Capabilities) > 0 { - ids := make([]string, 0, len(res.Capabilities)) - for _, c := range res.Capabilities { - ids = append(ids, string(c.ID())) - } - fmt.Printf("capabilities: %s\n", strings.Join(ids, ", ")) - } - if res.ExtractionAttempts > 0 { - fmt.Printf("extraction_attempts: %d repair_rounds: %d\n", res.ExtractionAttempts, res.RepairRounds) - } - - if res.Answer != "" { - fmt.Println() - fmt.Println("answer:") - fmt.Println(res.Answer) - } - - if len(res.Artifacts) > 0 { - fmt.Printf("artifacts: %d\n", len(res.Artifacts)) - for _, a := range res.Artifacts { - fmt.Printf(" %s (%d bytes)\n", a.Path, len(a.Content)) - } - for _, v := range res.Validations { - status := "PASS" - if !v.Passed { - status = "REJECT" - } - fmt.Printf(" [%s] %s\n", status, v.Artifact.Path) - for _, reason := range v.Reasons { - fmt.Printf(" - %s\n", reason) - } - } - } - - if res.Plan != nil { - fmt.Printf("planner: %s strategy=%s node_count=%s\n", - res.Plan.Metadata["planner"], res.Plan.Metadata["strategy"], res.Plan.Metadata["node_count"]) - } - - var started, completed, failed int - for _, e := range res.Events { - switch e.Type() { - case events.EventTaskStarted: - started++ - case events.EventTaskCompleted: - completed++ - case events.EventTaskFailed: - failed++ - } - } - fmt.Printf("events: task_started=%d task_completed=%d task_failed=%d\n", started, completed, failed) - } - - // ── DURABLE RECORD OF WHAT THE CYCLE ACTUALLY DID ──────────────── - // Every error path records the real reason and parks the task; a cycle - // that finishes without error records the committed execution and the - // validation counts that produced the verdict. Neither is synthesised - // from "the provider returned DONE". - if runErr != nil { - ledger.Fail("pipeline_error", runErr) - return fmt.Errorf("izen run: %w", runErr) - } - - // The cycle completed: the execution committed and the pipeline's - // validation gate produced the counts recorded in the terminal - // verification. This retires a committed EXECUTION, not a proven - // objective — the headless path holds no objective-proof authority. - ledger.Complete(res) - return nil -} - -// publishRunLifecycle closes the canonical execution lifecycle for one -// `izen run` execution from the REAL pipeline outcome. Every record is derived -// from an observed pipeline stage — never synthesised — so the persisted -// .izen/audit/events.ndjson sequence is truthful and chronological: -// -// execution.started (published before Run) → plan.staged → patch.applied* -// → execution.verification.completed → execution.finished -// -// Architecture lock (TestLifecycleEventsGeneratedOnlyFromGraph): the typed -// NewVerificationCompleted / NewExecutionFinished constructors may ONLY be -// invoked by the runtime-owned execution graph, so this non-graph pipeline -// records those two transitions as envelope records with the same canonical -// Source discriminators and equivalent payloads (see the execution.started -// comment above). NewPlanStaged and NewPatchApplied are not graph-locked and -// are published as typed events. -func publishRunLifecycle(bus *events.Bus, requestID string, res *app.Result, runErr error) { - if bus == nil { - return - } - if res != nil && res.Plan != nil { - tasks := make([]string, 0, len(res.Plan.Artifacts)) - for _, a := range res.Plan.Artifacts { - if a.Path != "" { - tasks = append(tasks, a.Path) - } - } - if len(tasks) == 0 { - for _, a := range res.Artifacts { - if a.Path != "" { - tasks = append(tasks, a.Path) - } - } - } - strategy := res.Plan.Metadata["strategy"] - if strategy == "" { - strategy = string(res.Mode) - } - bus.Publish(events.NewPlanStaged(len(tasks), tasks, strategy)) - } - if res != nil { - for _, a := range res.Artifacts { - if a.Path == "" { - continue - } - bus.Publish(events.NewPatchApplied(a.Path, len(a.Content), 0, 0)) - } - passed := true - steps := []string{"capability-validation"} - for _, v := range res.Validations { - if !v.Passed { - passed = false - break - } - } - // Verification is real: it reflects the validation gate verdict over - // the produced artifacts (plus planning when a plan exists). - if len(res.Artifacts) > 0 || res.Plan != nil { - if res.Plan != nil { - steps = append(steps, "plan") - } - bus.PublishEnvelope(events.NewEnvelope( - events.DomainKindSystem, events.EventVerificationCompleted, - events.VerificationCompletedPayload{RequestID: requestID, Passed: passed && runErr == nil, Steps: steps}, - )) - } - } - success := runErr == nil - outcome := "completed" - if !success { - outcome = "failed: " + runErr.Error() - } - bus.PublishEnvelope(events.NewEnvelope( - events.DomainKindSystem, events.EventExecutionFinished, - events.ExecutionFinishedPayload{RequestID: requestID, Success: success, Outcome: outcome}, - )) +// runRuntimeCommand implements `izen run`: it parses the shared execution +// grammar and drives the objective through the one authoritative CLI lifecycle. +func runRuntimeCommand(args []string) int { + return runExecRequest("run", args, runRuntimeUsage) } // validateProviderModelBinding verifies provider/model tuple compatibility @@ -526,42 +80,57 @@ func validateProviderModelBinding(provider, model string) error { return nil } -// buildActiveProvider constructs the ai.Provider for the configured active -// provider and returns it together with the effective model name. The API key -// resolves with strict precedence: ~/.izen/config.yml wins over the shell -// environment variable; an empty resolution surfaces the missing-key prompt. -func buildActiveProvider(cfg *config.Config) (ai.Provider, string, error) { - name := cfg.ActiveProviderName() +// buildProvider constructs the ai.Provider for an explicit (provider, model, +// baseURL) selection. The API key resolves with strict precedence: the +// configured key in ~/.izen/config.yml wins over the provider's environment +// variable. An explicit baseURL override wins over the configured endpoint so +// automation can target a self-hosted or benchmark endpoint, and it is never +// silently dropped. +func buildProvider(cfg *config.Config, providerName, model, baseURL string) (ai.Provider, string, error) { + name := strings.TrimSpace(providerName) + if name == "" { + name = cfg.ActiveProviderName() + } provCfg, ok := cfg.AI.Providers[name] if !ok { provCfg = config.AIProviderConfig{BaseURL: config.WellKnownBaseURL(name)} } + effectiveBase := strings.TrimSpace(baseURL) + if effectiveBase == "" { + effectiveBase = strings.TrimSpace(provCfg.BaseURL) + } + if effectiveBase == "" { + effectiveBase = config.WellKnownBaseURL(name) + } apiKey := cfg.ResolveAPIKey(name) - if apiKey == "" && strings.TrimSpace(provCfg.BaseURL) == "" { + if apiKey == "" && effectiveBase == "" { return nil, "", fmt.Errorf( - "izen run: no AI provider configured (provider %q). Set one via 'izen auth login' or environment variables", + "no AI provider configured (provider %q). Set one via 'izen auth login' or environment variables", name, ) } - if apiKey == "" { + if apiKey == "" && name != "ollama" { return nil, "", fmt.Errorf( - "izen run: no API key for provider %q. Save one via 'izen auth login' or set %s", + "no API key for provider %q. Save one via 'izen auth login' or set %s", name, config.EnvVarForProvider(name), ) } - model := cfg.ActiveModelName() - // Fail-fast provider boundary: a mismatched provider/model tuple (e.g. - // provider "ollama" with model "cohere/north-mini-code:free") aborts here - // with ErrInvalidProviderConfiguration before any execution loop or - // preflight timeout can engage. Zero provider calls are made on mismatch. + if model == "" { + model = cfg.ActiveModelName() + } + if model == "" { + return nil, "", fmt.Errorf("no model selected for provider %q", name) + } + // Fail-fast provider boundary: a mismatched provider/model tuple aborts + // here before any execution loop or preflight timeout can engage. if err := validateProviderModelBinding(name, model); err != nil { return nil, "", err } switch name { case "ollama": - return providers.NewOllamaProvider(provCfg.BaseURL, apiKey, model), model, nil + return providers.NewOllamaProvider(effectiveBase, apiKey, model), model, nil case "openrouter": - return providers.NewOpenRouterProvider(apiKey, model, provCfg.BaseURL), model, nil + return providers.NewOpenRouterProvider(apiKey, model, effectiveBase), model, nil case "openai": return providers.NewOpenAIProvider(apiKey, model), model, nil case "anthropic": @@ -569,12 +138,12 @@ func buildActiveProvider(cfg *config.Config) (ai.Provider, string, error) { case "gemini": return providers.NewGeminiProvider(apiKey, model), model, nil case "groq": - return providers.NewGroqProvider(apiKey, model, provCfg.BaseURL), model, nil + return providers.NewGroqProvider(apiKey, model, effectiveBase), model, nil case "opencode": - return providers.NewOpenCodeProvider(apiKey, model, provCfg.BaseURL), model, nil + return providers.NewOpenCodeProvider(apiKey, model, effectiveBase), model, nil case "9router": - return providers.NewNineRouterProvider(apiKey, model, provCfg.BaseURL), model, nil + return providers.NewNineRouterProvider(apiKey, model, effectiveBase), model, nil default: - return nil, "", fmt.Errorf("izen run: unsupported provider %q", name) + return nil, "", fmt.Errorf("unsupported provider %q", name) } } diff --git a/cmd/izen/runtime_ledger.go b/cmd/izen/runtime_ledger.go deleted file mode 100644 index 0a303999..00000000 --- a/cmd/izen/runtime_ledger.go +++ /dev/null @@ -1,294 +0,0 @@ -package main - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "os" - "strings" - - "github.com/PizenLabs/izen/internal/app" - "github.com/PizenLabs/izen/internal/core/domain" - "github.com/PizenLabs/izen/internal/domain/command" - "github.com/PizenLabs/izen/internal/events" - "github.com/PizenLabs/izen/internal/parser" - "github.com/PizenLabs/izen/internal/runtime/durable" -) - -// ── DURABLE EXECUTION LEDGER FOR THE HEADLESS `izen run` PATH ────────────── -// -// `izen run` builds its own pipeline instead of going through compose.Wire, so -// it used to leave no reconstructable execution state at all: the audit log it -// writes has no reader and no fold, and a process killed mid-cycle took every -// fact about what it had done with it. This file binds the headless cycle to -// the SAME authoritative ledger the TUI Driver writes (durable.TaskStore under -//

/.izen/runtime/), with the SAME event vocabulary and the SAME task -// lifecycle. There is no second journal and no second persistence path. -// -// FAILURE HANDLING — one rule, applied consistently: -// -// - OPEN/REPLAY fails ⇒ FAIL CLOSED. The store cannot be constructed or its -// journal cannot be folded back into state, so the run aborts before the -// pipeline can dispatch anything. This matches compose.Wire exactly: a -// runtime that cannot be reconstructed after an interruption must not be -// allowed to mutate a workspace in the first place. -// - An individual APPEND is refused after the run started ⇒ DEGRADE, loudly. -// By then the workspace may already hold the cycle's mutations; aborting the -// process over the loss of its record would be the worse outcome, but the -// refusal is printed to stderr and never swallowed. This mirrors the TUI -// Driver's ledgerFail sink. - -const runLedgerDetailLimit = 512 - -// runLedger witnesses one headless `izen run` cycle into the durable -// execution ledger. Every method appends through the store's existing API; the -// helper owns no event vocabulary of its own. -type runLedger struct { - store *durable.TaskStore - // taskID is the durable identity of THIS invocation's cycle. - taskID string - // operationID is the causal identity of the single operation the cycle - // dispatches (the run contract is exactly one cycle per invocation). - operationID string - // scope is the target scope declared on the command line. - scope []string -} - -// newRunLedger opens the ledger under dir (the same root convention as -// compose.Wire: /.izen/runtime/) and creates the durable task for this -// objective. -// -// Task identity is scoped to the INVOCATION, not to the objective text: two -// `izen run` invocations over the same workspace are two executions, and -// collapsing them into one task would erase the first one's record — exactly -// what the autonomy Driver's session scoping exists to prevent. An unfinished -// task from an earlier invocation therefore stays PAUSED and recoverable while -// this invocation opens its own. -func newRunLedger(dir, objective string, scope []string) (*runLedger, error) { - store := durable.NewTaskStore(dir) - if err := store.Open(); err != nil { - return nil, fmt.Errorf("open durable execution ledger: %w", err) - } - // A journal whose sequence does not add up is reported, never silently - // folded: the caller decides what an incomplete record means for this - // workspace. Same rule as compose.Wire. - for _, f := range store.SequenceFaults() { - fmt.Fprintf(os.Stderr, "izen run: durable ledger %s %s at sequence %d (expected %d)\n", - store.LedgerPath(), f.Kind, f.Observed, f.Expected) - } - - invocation := events.NewEnvelopeID() - l := &runLedger{ - store: store, - taskID: runLedgerTaskID(invocation, objective), - operationID: "run-" + invocation, - scope: append([]string(nil), scope...), - } - if _, err := store.CreateTaskWithProvenance(l.taskID, objective, l.scope, runScopeProvenance(objective)); err != nil { - // Fail closed: the store cannot name the work it is about to do, so - // the cycle must not start. Release the runtime lock on the way out. - _ = store.Close() - return nil, fmt.Errorf("create durable task: %w", err) - } - return l, nil -} - -// runLedgerTaskID derives the durable identity of one invocation's cycle from -// the invocation identity and the objective text — the same derivation shape -// the autonomy Driver uses for (session, objective). -func runLedgerTaskID(invocation, objective string) string { - sum := sha256.Sum256([]byte(invocation + "\x00" + objective)) - return "run-" + hex.EncodeToString(sum[:8]) -} - -// runScopeProvenance resolves the scope provenance this objective was -// authorized under, from the SAME parser and the SAME call the TUI Driver, the -// IntentGateway and the UI use (WorkspaceBuild, the workspace `izen run` -// executes in). It never invents a value and never defaults to a mutating -// grant: a prompt that carries no $prompt/$hot directive yields the read-only -// zero value, because that is the authorization the human actually typed. The -// durable task inherits exactly that grant — a task must not claim a scope -// authority the objective did not carry. -func runScopeProvenance(objective string) domain.ScopeProvenance { - ast, err := parser.ParseInWorkspace(objective, nil, command.WorkspaceBuild) - if err != nil || ast == nil { - return domain.ScopeNone - } - return ast.ScopeProvenance -} - -// Dispatch records that the cycle's operation entered the substrate. The -// precondition digest is the worktree as observed BEFORE the pipeline runs, so -// a crash between here and the commit can be reconciled later: an unchanged -// worktree is a safe retry, anything else is a conflict that forces re-planning. -// -// The postcondition digest is deliberately left unestablished: this path has no -// execution proof to derive it from, and a fabricated one would let recovery -// skip work that never happened. Reconciliation therefore errs toward -// re-planning, which is the safe direction. -func (l *runLedger) Dispatch() { - if l == nil || l.store == nil { - return - } - cursor := durable.ExecutionCursor{ - TaskID: l.taskID, - StepID: l.operationID, - OperationID: l.operationID, - PreconditionDigest: runWorktreeDigest(l.store.WorkDir(), l.scope), - PostconditionDigest: "", - } - if err := l.store.DispatchCursor(cursor); err != nil { - l.warn("CURSOR_DISPATCHED refused: %v", err) - } -} - -// Complete records a cycle that finished WITHOUT error, from the pipeline -// result it actually produced. -// -// SEMANTICS (read before changing this): the headless path holds no -// objective-proof authority. A provider DONE and a green pipeline only -// establish that the execution committed and that the artifacts passed the -// pipeline's validation gate — never that an objective was proven. So the -// record uses the existing committed/verification vocabulary only: -// -// EXECUTION_COMMITTED the cycle's work reached the workspace -// VERIFICATION_RESULT what was actually observed, with the counts that -// produced the verdict -// VERIFICATION_RESULT terminal: this execution finished (COMPLETED) -// CHECKPOINT_CREATED materialized view written last, so snapshot.json -// reflects the end of the cycle -// -// There is deliberately no "objective proven" notion here: a headless run that -// retired its task has committed and validated an execution, nothing more. -func (l *runLedger) Complete(res *app.Result) { - if l == nil || l.store == nil { - return - } - detail := runOutcomeDetail(res) - if err := l.store.CommitExecution(l.taskID, l.operationID); err != nil { - l.warn("EXECUTION_COMMITTED refused: %v", err) - } - if err := l.store.RecordVerification(l.taskID, l.operationID, true, detail); err != nil { - l.warn("VERIFICATION_RESULT refused: %v", err) - } - terminal := boundedRunDetail(detail + "; execution committed — no objective-proof authority on this path, so this retires a committed EXECUTION, not a proven objective") - if err := l.store.RecordTerminalResult(l.taskID, l.operationID, true, terminal); err != nil { - l.warn("terminal VERIFICATION_RESULT refused: %v", err) - } - l.CheckpointBoundary("completed") -} - -// Fail records a cycle that ended in an error, with the REAL reason, and parks -// the task. It is never a success: FAILURE_CLASSIFIED carries the classified -// reason (what a recovery must read) and TASK_PAUSED leaves the task -// non-terminal and recoverable, because a headless run has no resume — the -// human re-invokes, and the next process must still be able to see that this -// attempt happened and why it stopped. -// -// The order matters: the classification precedes the pause so the journal never -// shows a paused task with no recorded reason. -func (l *runLedger) Fail(reason string, cause error) { - if l == nil || l.store == nil { - return - } - detail := "" - if cause != nil { - detail = boundedRunDetail(cause.Error()) - } - if err := l.store.RecordFailure(l.taskID, reason, l.operationID, detail); err != nil { - l.warn("FAILURE_CLASSIFIED refused: %v", err) - } - pause := boundedRunDetail(reason + ": " + detail) - if err := l.store.PauseTask(l.taskID, pause); err != nil { - l.warn("TASK_PAUSED refused: %v", err) - } - l.CheckpointBoundary("failed") -} - -// CheckpointBoundary materializes the snapshot at a cycle boundary, so "where -// was this run when the process stopped" is readable without replaying the -// whole journal. -func (l *runLedger) CheckpointBoundary(reason string) { - if l == nil || l.store == nil { - return - } - id := l.operationID + "-" + reason - if err := l.store.Checkpoint(l.taskID, id); err != nil { - l.warn("CHECKPOINT_CREATED refused: %v", err) - } -} - -// Close releases the runtime file lock. It is idempotent and its error is -// surfaced by the caller: a shut-down runtime that still holds the journal's -// writer lock would make the next process's lineage unknowable. -func (l *runLedger) Close() error { - if l == nil || l.store == nil { - return nil - } - return l.store.Close() -} - -// warn routes a refused append to stderr. A silent drop is how a journal rots -// unnoticed; the run itself continues because its mutations already stand. -func (l *runLedger) warn(format string, args ...any) { - fmt.Fprintf(os.Stderr, "izen run: durable ledger "+format+"\n", args...) -} - -// runWorktreeDigest computes the pre-dispatch worktree digest over the declared -// scope, degrading to the whole worktree when a declared target does not exist -// yet, and to "" when neither can be read. An empty digest is not a failure of -// the run: it only means recovery will conservatively re-plan rather than -// re-execute. -func runWorktreeDigest(dir string, scope []string) string { - if d, err := durable.ComputeTreeDigest(dir, scope...); err == nil { - return d - } - if len(scope) > 0 { - if d, err := durable.ComputeTreeDigest(dir); err == nil { - return d - } - } - return "" -} - -// runOutcomeDetail renders what a successful cycle actually established, from -// the pipeline result: how it ran, how much it wrote, and how much of what it -// wrote passed validation. It is a record of measurements, not a verdict on an -// objective. -func runOutcomeDetail(res *app.Result) string { - if res == nil { - return "pipeline returned no result" - } - passed := 0 - for _, v := range res.Validations { - if v.Passed { - passed++ - } - } - return boundedRunDetail(fmt.Sprintf("mode=%s artifacts=%d validations=%d/%d passed", - string(res.Mode), len(res.Artifacts), passed, len(res.Validations))) -} - -// boundedRunDetail caps what an error or provider-derived string may write into -// the journal. The ledger is a record of what happened, not a transcript. -func boundedRunDetail(s string) string { - s = strings.TrimSpace(s) - if len(s) <= runLedgerDetailLimit { - return s - } - return s[:runLedgerDetailLimit] + "…" -} - -// errRunLedgerClosed joins a deferred Close error onto the run's own outcome so -// a teardown failure can never be dropped on the floor. -func errRunLedgerClosed(runErr, closeErr error) error { - if closeErr == nil { - return runErr - } - wrapped := fmt.Errorf("izen run: close durable execution ledger: %w", closeErr) - if runErr == nil { - return wrapped - } - return errors.Join(runErr, wrapped) -} diff --git a/cmd/izen/runtime_ledger_test.go b/cmd/izen/runtime_ledger_test.go deleted file mode 100644 index 5eb7b458..00000000 --- a/cmd/izen/runtime_ledger_test.go +++ /dev/null @@ -1,453 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/PizenLabs/izen/internal/app" - "github.com/PizenLabs/izen/internal/ir" - "github.com/PizenLabs/izen/internal/runtime/durable" -) - -// The assertions below read the ledger FILE and the snapshot FILE, never log -// strings: a transition the command claimed to record but never wrote must fail -// here. - -func readLedgerEvents(t *testing.T, dir string) []durable.LedgerEvent { - t.Helper() - raw, err := os.ReadFile(filepath.Join(dir, ".izen", "runtime", "ledger.ndjson")) - if err != nil { - t.Fatalf("read ledger: %v", err) - } - var out []durable.LedgerEvent - for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { - if strings.TrimSpace(line) == "" { - continue - } - var ev durable.LedgerEvent - if err := json.Unmarshal([]byte(line), &ev); err != nil { - t.Fatalf("decode ledger line %q: %v", line, err) - } - out = append(out, ev) - } - return out -} - -func readSnapshot(t *testing.T, dir string) durable.SnapshotFile { - t.Helper() - raw, err := os.ReadFile(filepath.Join(dir, ".izen", "runtime", "snapshot.json")) - if err != nil { - t.Fatalf("read snapshot: %v", err) - } - var snap durable.SnapshotFile - if err := json.Unmarshal(raw, &snap); err != nil { - t.Fatalf("decode snapshot: %v", err) - } - return snap -} - -func ledgerTypes(events []durable.LedgerEvent) []durable.EventType { - out := make([]durable.EventType, 0, len(events)) - for _, ev := range events { - out = append(out, ev.EventType) - } - return out -} - -func hasLedgerEvent(events []durable.LedgerEvent, typ durable.EventType) bool { - for _, ev := range events { - if ev.EventType == typ { - return true - } - } - return false -} - -func payloadString(ev durable.LedgerEvent, key string) string { - v, _ := ev.Payload[key].(string) - return v -} - -func ledgerEventFor(t *testing.T, events []durable.LedgerEvent, taskID string, typ durable.EventType) durable.LedgerEvent { - t.Helper() - for _, ev := range events { - if ev.TaskID == taskID && ev.EventType == typ { - return ev - } - } - t.Fatalf("no %s for task %q (types: %v)", typ, taskID, ledgerTypes(events)) - return durable.LedgerEvent{} -} - -func writeWorkspaceFile(t *testing.T, dir, name, content string) { - t.Helper() - if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { - t.Fatalf("write %s: %v", name, err) - } -} - -// TestRunLedgerFailedCycleIsNotSuccess is the truthfulness boundary for the -// headless path: a cycle that errored (here: the provider produced no choices) -// must leave a task that is NOT retired as complete, carrying the real reason, -// and must remain recoverable so the next process still sees the attempt. -func TestRunLedgerFailedCycleIsNotSuccess(t *testing.T) { - dir := t.TempDir() - writeWorkspaceFile(t, dir, "main.go", "package main\n") - - ledger, err := newRunLedger(dir, "say hello", nil) - if err != nil { - t.Fatalf("newRunLedger: %v", err) - } - ledger.Dispatch() - cause := errors.New("app: generation: openrouter: no choices") - ledger.Fail("pipeline_error", cause) - if err := ledger.Close(); err != nil { - t.Fatalf("Close: %v", err) - } - - events := readLedgerEvents(t, dir) - if len(events) == 0 { - t.Fatal("a headless cycle recorded nothing in the durable ledger") - } - if events[0].EventType != durable.EventTaskCreated { - t.Fatalf("first journal event = %s, want TASK_CREATED (types: %v)", events[0].EventType, ledgerTypes(events)) - } - taskID := events[0].TaskID - - // Monotonic, gap-free sequence for this journal. - for i, ev := range events { - if ev.Sequence != uint64(i+1) { - t.Fatalf("event[%d] sequence = %d, want %d — the journal is not monotonic", i, ev.Sequence, i+1) - } - } - - if !hasLedgerEvent(events, durable.EventCursorDispatched) { - t.Fatalf("no CURSOR_DISPATCHED before the cycle ran (types: %v)", ledgerTypes(events)) - } - if hasLedgerEvent(events, durable.EventExecutionCommitted) { - t.Fatalf("a failed cycle committed an execution (types: %v)", ledgerTypes(events)) - } - - failure := ledgerEventFor(t, events, taskID, durable.EventFailureClassified) - if got := payloadString(failure, "reason"); got != "pipeline_error" { - t.Fatalf("FAILURE_CLASSIFIED reason = %q, want pipeline_error", got) - } - if got := payloadString(failure, "detail"); !strings.Contains(got, "openrouter: no choices") { - t.Fatalf("FAILURE_CLASSIFIED detail = %q, want the real provider error", got) - } - - paused := ledgerEventFor(t, events, taskID, durable.EventTaskPaused) - if got := payloadString(paused, "reason"); !strings.Contains(got, "openrouter: no choices") { - t.Fatalf("TASK_PAUSED reason = %q, want the real provider error", got) - } - if !hasLedgerEvent(events, durable.EventCheckpointCreated) { - t.Fatalf("a failed cycle left no CHECKPOINT_CREATED (types: %v)", ledgerTypes(events)) - } - - // No terminal VERIFICATION_RESULT at all: the cycle was never retired. - for _, ev := range events { - if ev.EventType != durable.EventVerificationResult { - continue - } - if terminal, _ := ev.Payload["terminal"].(bool); terminal { - t.Fatalf("a failed cycle wrote a terminal VERIFICATION_RESULT (payload: %v)", ev.Payload) - } - if ok, _ := ev.Payload["ok"].(bool); ok { - t.Fatalf("a failed cycle wrote ok=true verification (payload: %v)", ev.Payload) - } - } - - st, ok := readSnapshot(t, dir).Tasks[taskID] - if !ok { - t.Fatalf("snapshot.json has no task %q", taskID) - } - if st.Status != durable.TaskPaused { - t.Fatalf("failed cycle task status = %s, want PAUSED", st.Status) - } - if st.Status.Terminal() { - t.Fatalf("failed cycle task status %s is terminal — the run looks finished", st.Status) - } - - // Reopening the journal must surface the unfinished work. - store := durable.NewTaskStore(dir) - if err := store.Open(); err != nil { - t.Fatalf("reopen: %v", err) - } - defer func() { _ = store.Close() }() - if _, ok := store.MostRecentRecoverable(); !ok { - t.Fatal("a failed headless cycle is not recoverable — a restart would think nothing happened") - } - if faults := store.SequenceFaults(); len(faults) != 0 { - t.Fatalf("replay reported sequence faults: %+v", faults) - } -} - -// TestRunLedgerSecondInvocationContinuesTheJournal proves two invocations over -// the same workspace are two executions: a second TASK_CREATED, a sequence that -// continues past the first cycle's last event without reusing or skipping a -// position, and both tasks independently reconstructable from the journal. -func TestRunLedgerSecondInvocationContinuesTheJournal(t *testing.T) { - dir := t.TempDir() - writeWorkspaceFile(t, dir, "main.go", "package main\n") - - first, err := newRunLedger(dir, "say hello", nil) - if err != nil { - t.Fatalf("newRunLedger (first): %v", err) - } - firstTask := first.taskID - first.Dispatch() - first.Fail("pipeline_error", errors.New("app: generation: openrouter: no choices")) - if err := first.Close(); err != nil { - t.Fatalf("Close (first): %v", err) - } - firstEvents := readLedgerEvents(t, dir) - firstLast := firstEvents[len(firstEvents)-1].Sequence - - second, err := newRunLedger(dir, "say hello", nil) - if err != nil { - t.Fatalf("newRunLedger (second): %v", err) - } - if second.taskID == firstTask { - t.Fatalf("second invocation reused the first task id %q — the first cycle's record would be erased", firstTask) - } - secondTask := second.taskID - second.Dispatch() - second.Complete(&app.Result{ - Mode: app.ModeGreenfield, - Artifacts: []ir.Artifact{{Path: "out.txt", Content: []byte("hi")}}, - Validations: []app.ArtifactValidation{{Passed: true}}, - }) - if err := second.Close(); err != nil { - t.Fatalf("Close (second): %v", err) - } - - events := readLedgerEvents(t, dir) - if len(events) <= len(firstEvents) { - t.Fatalf("second invocation appended nothing (types: %v)", ledgerTypes(events)) - } - for i, ev := range events { - if ev.Sequence != uint64(i+1) { - t.Fatalf("event[%d] sequence = %d, want %d — the journal is not monotonic across invocations", i, ev.Sequence, i+1) - } - } - if events[len(firstEvents)].Sequence != firstLast+1 { - t.Fatalf("second invocation resumed at sequence %d, want %d", events[len(firstEvents)].Sequence, firstLast+1) - } - - created := 0 - for _, ev := range events { - if ev.EventType == durable.EventTaskCreated { - created++ - } - } - if created != 2 { - t.Fatalf("TASK_CREATED count = %d, want 2 (types: %v)", created, ledgerTypes(events)) - } - - // The failed cycle is still a failed, recoverable task; the completed one - // is retired — neither overwrote the other. - snap := readSnapshot(t, dir) - failedState, ok := snap.Tasks[firstTask] - if !ok { - t.Fatalf("snapshot.json lost the first task %q", firstTask) - } - if failedState.Status != durable.TaskPaused { - t.Fatalf("first task status = %s, want PAUSED (the second invocation must not resurrect it)", failedState.Status) - } - doneState, ok := snap.Tasks[secondTask] - if !ok { - t.Fatalf("snapshot.json has no second task %q", secondTask) - } - if doneState.Status != durable.TaskCompleted { - t.Fatalf("second task status = %s, want COMPLETED", doneState.Status) - } - - // Deleting the derived view loses nothing: it is a pure fold of the journal. - store := durable.NewTaskStore(dir) - if err := store.Open(); err != nil { - t.Fatalf("reopen: %v", err) - } - if err := store.RebuildSnapshot(); err != nil { - t.Fatalf("RebuildSnapshot: %v", err) - } - if err := store.Close(); err != nil { - t.Fatalf("Close (rebuild): %v", err) - } - rebuilt := readSnapshot(t, dir) - if rebuilt.Tasks[firstTask].Status != durable.TaskPaused || rebuilt.Tasks[secondTask].Status != durable.TaskCompleted { - t.Fatalf("rebuilt snapshot = %+v, want the same statuses as before", rebuilt.Tasks) - } -} - -// TestRunLedgerProvenanceInheritsTheObjectiveDirective proves the durable task -// carries the authorization the objective actually declared: a bare prompt -// inherits the read-only zero value, and $prompt authorizes a dynamic scope. -// Nothing here is invented — it is the SAME parser the TUI path uses. -func TestRunLedgerProvenanceInheritsTheObjectiveDirective(t *testing.T) { - cases := []struct { - objective string - want int - }{ - {"say hello", 0}, // no directive → read-only zero value - {"$prompt add a greeting", 1}, // $prompt → dynamic - {"$hot fix the parser @auth.go", 2}, // $hot → declared - } - for _, tc := range cases { - dir := t.TempDir() - ledger, err := newRunLedger(dir, tc.objective, []string{"auth.go"}) - if err != nil { - t.Fatalf("newRunLedger(%q): %v", tc.objective, err) - } - st, ok := ledger.store.State(ledger.taskID) - if !ok { - t.Fatalf("objective %q: no materialized task", tc.objective) - } - if int(st.ScopeProvenance) != tc.want { - t.Fatalf("objective %q: provenance = %d, want %d", tc.objective, st.ScopeProvenance, tc.want) - } - if len(st.ActiveTargetScope) != 1 || st.ActiveTargetScope[0] != "auth.go" { - t.Fatalf("objective %q: scope = %v, want [auth.go]", tc.objective, st.ActiveTargetScope) - } - if err := ledger.Close(); err != nil { - t.Fatalf("objective %q: Close: %v", tc.objective, err) - } - } -} - -// TestRunLedgerCloseIsIdempotentAndSurfacesItsError proves teardown is safe to -// defer: Close never fails on a live store, and a deferred close error is -// joined onto the run's outcome rather than dropped. -func TestRunLedgerCloseIsIdempotentAndSurfacesItsError(t *testing.T) { - dir := t.TempDir() - ledger, err := newRunLedger(dir, "say hello", nil) - if err != nil { - t.Fatalf("newRunLedger: %v", err) - } - if err := ledger.Close(); err != nil { - t.Fatalf("first Close: %v", err) - } - if err := ledger.Close(); err != nil { - t.Fatalf("second Close must be idempotent: %v", err) - } - - // A nil ledger is a no-op teardown, not a panic. - var absent *runLedger - if err := absent.Close(); err != nil { - t.Fatalf("nil ledger Close: %v", err) - } - - // A refused close error is surfaced: the run's own error survives and the - // teardown failure rides along with it. - runErr := errors.New("pipeline_error: boom") - joined := errRunLedgerClosed(runErr, errors.New("store closed")) - if !errors.Is(joined, runErr) { - t.Fatalf("joined error lost the run failure: %v", joined) - } - if !strings.Contains(joined.Error(), "close durable execution ledger") { - t.Fatalf("joined error dropped the close failure: %v", joined) - } - if got := errRunLedgerClosed(nil, errors.New("store closed")); got == nil { - t.Fatal("a close failure on an otherwise successful run was dropped") - } - if got := errRunLedgerClosed(runErr, nil); !errors.Is(got, runErr) { - t.Fatalf("a clean close changed the run outcome: %v", got) - } - - // A closed store refuses further appends: the journal's last writer is - // knowable. - if err := ledger.store.PauseTask(ledger.taskID, "after close"); err == nil { - t.Fatal("a closed ledger accepted an append — the last writer is unknowable") - } -} - -// TestRunLedgerCompletedCycleRecordsWhatWasVerified pins the success semantics: -// the record establishes a committed EXECUTION and the validation counts that -// produced the verdict. There is no "objective proven" claim anywhere in it — -// the headless path holds no objective-proof authority. -func TestRunLedgerCompletedCycleRecordsWhatWasVerified(t *testing.T) { - dir := t.TempDir() - ledger, err := newRunLedger(dir, "write a greeting", nil) - if err != nil { - t.Fatalf("newRunLedger: %v", err) - } - ledger.Dispatch() - ledger.Complete(&app.Result{ - Mode: app.ModeGreenfield, - Artifacts: []ir.Artifact{{Path: "out.txt", Content: []byte("hi")}}, - Validations: []app.ArtifactValidation{ - {Passed: true}, - {Passed: true}, - }, - }) - taskID := ledger.taskID - if err := ledger.Close(); err != nil { - t.Fatalf("Close: %v", err) - } - - events := readLedgerEvents(t, dir) - if !hasLedgerEvent(events, durable.EventExecutionCommitted) { - t.Fatalf("a completed cycle has no EXECUTION_COMMITTED (types: %v)", ledgerTypes(events)) - } - commit := ledgerEventFor(t, events, taskID, durable.EventExecutionCommitted) - if got := payloadString(commit, "operationId"); got != ledger.operationID { - t.Fatalf("EXECUTION_COMMITTED operationId = %q, want %q", got, ledger.operationID) - } - - var terminal *durable.LedgerEvent - for i := range events { - if events[i].TaskID != taskID || events[i].EventType != durable.EventVerificationResult { - continue - } - if ok, _ := events[i].Payload["terminal"].(bool); ok { - terminal = &events[i] - } - } - if terminal == nil { - t.Fatalf("a completed cycle wrote no terminal VERIFICATION_RESULT (types: %v)", ledgerTypes(events)) - } - if ok, _ := terminal.Payload["ok"].(bool); !ok { - t.Fatalf("terminal verification ok = false (payload: %v)", terminal.Payload) - } - detail := payloadString(*terminal, "detail") - for _, want := range []string{"mode=greenfield", "artifacts=1", "validations=2/2 passed", "no objective-proof authority"} { - if !strings.Contains(detail, want) { - t.Fatalf("terminal detail = %q, want it to contain %q", detail, want) - } - } - - if !hasLedgerEvent(events, durable.EventCheckpointCreated) { - t.Fatalf("a completed cycle left no CHECKPOINT_CREATED (types: %v)", ledgerTypes(events)) - } - if st, ok := readSnapshot(t, dir).Tasks[taskID]; !ok || st.Status != durable.TaskCompleted { - t.Fatalf("completed cycle snapshot = %+v (ok=%v), want COMPLETED", st, ok) - } - - // A retired execution is not offered back to the next process. - store := durable.NewTaskStore(dir) - if err := store.Open(); err != nil { - t.Fatalf("reopen: %v", err) - } - defer func() { _ = store.Close() }() - if rec := store.RecoverableTasks(); len(rec) != 0 { - t.Fatalf("RecoverableTasks = %+v, want none after a completed cycle", rec) - } -} - -// TestBoundedRunDetailCapsProviderDerivedText proves a model answer or a long -// provider error can never dominate the journal. -func TestBoundedRunDetailCapsProviderDerivedText(t *testing.T) { - long := strings.Repeat("x", runLedgerDetailLimit*3) - got := boundedRunDetail(long) - // The ellipsis is a multi-byte rune, so assert the truncated prefix rather - // than an exact byte length. - if !strings.HasPrefix(long, got[:runLedgerDetailLimit]) { - t.Fatalf("bounded detail is not a truncated prefix of the source: %q", got) - } - if got := boundedRunDetail(" trimmed "); got != "trimmed" { - t.Fatalf("bounded detail = %q, want trimmed", got) - } -} diff --git a/cmd/izen/runtime_test.go b/cmd/izen/runtime_test.go new file mode 100644 index 00000000..0abb6182 --- /dev/null +++ b/cmd/izen/runtime_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "testing" + + "github.com/PizenLabs/izen/internal/cli" +) + +// TestParseExecArgs pins the shared execution-command grammar. The public +// syntax (-dir, repeatable -target, exactly one objective, -h) is preserved, +// and the automation flags (provider/model/mode/approve/timeout/json/quiet) +// are parsed strictly: an unknown flag or an invalid value fails with a usage +// error rather than being silently ignored. +func TestParseExecArgs(t *testing.T) { + cases := []struct { + name string + args []string + wantDir string + wantTargets []string + wantPrompt string + wantHelp bool + wantErr bool + wantMode string + wantApproval cli.ApprovalPolicy + wantJSON bool + }{ + {name: "bare prompt", args: []string{"redesign the portfolio"}, + wantDir: ".", wantPrompt: "redesign the portfolio", wantMode: "build", wantApproval: cli.ApprovalAsk}, + {name: "dir and repeatable targets", args: []string{"-dir", "/ws", "add a health check", "-target", "main.go", "-target", "server.go"}, + wantDir: "/ws", wantPrompt: "add a health check", wantTargets: []string{"main.go", "server.go"}, wantMode: "build", wantApproval: cli.ApprovalAsk}, + {name: "automation flags", args: []string{"--json", "-quiet", "-approve", "always", "-mode", "plan", "-timeout", "90s", "-provider", "opencode", "-model", "z-ai/glm-4", "explain it"}, + wantDir: ".", wantPrompt: "explain it", wantMode: "plan", wantApproval: cli.ApprovalAlways, wantJSON: true}, + {name: "inline equals", args: []string{"-dir=/tmp/ws", "-approve=never", "do the thing"}, + wantDir: "/tmp/ws", wantPrompt: "do the thing", wantMode: "build", wantApproval: cli.ApprovalNever}, + {name: "help", args: []string{"-h"}, wantHelp: true}, + {name: "missing prompt", args: nil, wantErr: true}, + {name: "two prompts", args: []string{"one", "two"}, wantErr: true}, + {name: "dir without value", args: []string{"-dir"}, wantErr: true}, + {name: "target without value", args: []string{"-target"}, wantErr: true}, + {name: "unknown flag", args: []string{"-bogus"}, wantErr: true}, + {name: "invalid mode", args: []string{"-mode", "destroy", "x"}, wantErr: true}, + {name: "invalid approve", args: []string{"-approve", "maybe", "x"}, wantErr: true}, + {name: "invalid timeout", args: []string{"-timeout", "soon", "x"}, wantErr: true}, + {name: "unknown provider", args: []string{"-provider", "nope", "x"}, wantErr: true}, + {name: "provider model mismatch", args: []string{"-provider", "ollama", "-model", "cohere/x:free", "x"}, wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req, help, err := parseExecArgs("run", tc.args) + if tc.wantErr { + if err == nil { + t.Fatalf("parseExecArgs(%v) = nil error, want failure", tc.args) + } + return + } + if err != nil { + t.Fatalf("parseExecArgs(%v): %v", tc.args, err) + } + if help != tc.wantHelp { + t.Fatalf("help = %t, want %t", help, tc.wantHelp) + } + if tc.wantHelp { + return + } + if req.Dir != tc.wantDir || req.Objective != tc.wantPrompt { + t.Fatalf("req = (dir=%q objective=%q), want (dir=%q objective=%q)", req.Dir, req.Objective, tc.wantDir, tc.wantPrompt) + } + if req.Mode != tc.wantMode { + t.Fatalf("mode = %q, want %q", req.Mode, tc.wantMode) + } + if req.Approval != tc.wantApproval { + t.Fatalf("approval = %q, want %q", req.Approval, tc.wantApproval) + } + if req.JSON != tc.wantJSON { + t.Fatalf("json = %t, want %t", req.JSON, tc.wantJSON) + } + if req.Scope != "$run" { + t.Fatalf("scope = %q, want $run", req.Scope) + } + if !equalStrings(req.Targets, tc.wantTargets) { + t.Fatalf("targets = %v, want %v", req.Targets, tc.wantTargets) + } + }) + } +} + +// TestParseExecArgsResume pins the resume grammar: -resume carries no objective. +func TestParseExecArgsResume(t *testing.T) { + req, _, err := parseExecArgs("resume", []string{"-resume", "-dir", "/ws"}) + if err != nil { + t.Fatalf("resume parse: %v", err) + } + if !req.Resume || req.Dir != "/ws" || req.Objective != "" { + t.Fatalf("resume req = %+v", req) + } + if _, _, err := parseExecArgs("resume", []string{"-resume", "extra objective"}); err == nil { + t.Fatal("resume accepted an objective argument") + } +} + +// TestParseApprovalPolicy pins the approval vocabulary. +func TestParseApprovalPolicy(t *testing.T) { + cases := map[string]cli.ApprovalPolicy{ + "": cli.ApprovalAsk, + "ask": cli.ApprovalAsk, + "always": cli.ApprovalAlways, + "auto": cli.ApprovalAlways, + "never": cli.ApprovalNever, + "deny": cli.ApprovalNever, + " ASK ": cli.ApprovalAsk, //nolint:gocritic // intentional surrounding whitespace exercises TrimSpace + } + for in, want := range cases { + got, err := cli.ParseApprovalPolicy(in) + if err != nil { + t.Fatalf("ParseApprovalPolicy(%q): %v", in, err) + } + if got != want { + t.Errorf("ParseApprovalPolicy(%q) = %q, want %q", in, got, want) + } + } + if _, err := cli.ParseApprovalPolicy("nonsense"); err == nil { + t.Error("ParseApprovalPolicy accepted nonsense") + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/cmd/izen/stats.go b/cmd/izen/stats.go new file mode 100644 index 00000000..f23c2b76 --- /dev/null +++ b/cmd/izen/stats.go @@ -0,0 +1,142 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/PizenLabs/izen/internal/runtime/durable" +) + +// statsUsage describes `izen stats`, the durable usage report. +const statsUsage = `Usage: izen stats [-dir ] [-json] + +Report token usage from the durable execution ledger +(.izen/runtime/ledger.ndjson). Every figure is the runtime's own always-flushed +cumulative accounting; nothing is estimated here or read from model prose. + +Flags: + -dir Workspace root (default ".") + -json Machine-readable output +` + +type taskUsage struct { + ID string `json:"id"` + Status string `json:"status"` + Intent string `json:"intent"` + PromptTokens int `json:"prompt_tokens"` + OutputTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + Turns int `json:"turns"` + Estimated bool `json:"estimated"` +} + +// runStatsCommand implements `izen stats`. +func runStatsCommand(args []string) int { + dir := "." + jsonOut := false + for i := 0; i < len(args); i++ { + switch args[i] { + case "-h", "--help": + fmt.Print(statsUsage) + return exitOK + case "-json", "--json": + jsonOut = true + case "-dir": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "izen stats: -dir requires a path") + return exitUsage + } + i++ + dir = args[i] + default: + if strings.HasPrefix(args[i], "-") { + fmt.Fprintf(os.Stderr, "izen stats: unknown flag %q\n", args[i]) + return exitUsage + } + fmt.Fprintf(os.Stderr, "izen stats: unexpected argument %q\n", args[i]) + return exitUsage + } + } + + rows, err := readTaskUsage(dir) + if err != nil { + fmt.Fprintf(os.Stderr, "izen stats: %v\n", err) + return exitFailed + } + + var total taskUsage + for _, r := range rows { + total.PromptTokens += r.PromptTokens + total.OutputTokens += r.OutputTokens + total.TotalTokens += r.TotalTokens + total.Turns += r.Turns + total.Estimated = total.Estimated || r.Estimated + } + + if jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"tasks": rows, "total": total}); err != nil { + fmt.Fprintf(os.Stderr, "izen stats: encode: %v\n", err) + return exitFailed + } + return exitOK + } + + if len(rows) == 0 { + fmt.Println("izen stats: no durable tasks recorded") + return exitOK + } + for _, r := range rows { + fmt.Printf("%-24s %-12s ↑%d ↓%d turns=%d %s\n", + r.ID, r.Status, r.PromptTokens, r.OutputTokens, r.Turns, oneLine(r.Intent)) + } + fmt.Printf("%-24s %-12s ↑%d ↓%d turns=%d\n", "TOTAL", "", total.PromptTokens, total.OutputTokens, total.Turns) + return exitOK +} + +// readTaskUsage materializes the durable token usage for every task. +func readTaskUsage(dir string) ([]taskUsage, error) { + store := durable.NewTaskStore(dir) + if err := store.Open(); err != nil { + return nil, err + } + defer func() { _ = store.Close() }() + + ids := make([]string, 0, len(store.TaskScopes())) + for id := range store.TaskScopes() { + ids = append(ids, id) + } + sort.Strings(ids) + + out := make([]taskUsage, 0, len(ids)) + for _, id := range ids { + st, ok := store.State(id) + if !ok { + continue + } + out = append(out, taskUsage{ + ID: st.ID, + Status: string(st.Status), + Intent: st.Intent, + PromptTokens: st.TokenUsage.PromptTokens, + OutputTokens: st.TokenUsage.CompletionTokens, + TotalTokens: st.TokenUsage.TotalTokens, + Turns: st.TokenUsage.Turns, + Estimated: st.TokenUsage.Estimated, + }) + } + return out, nil +} + +// oneLine trims an intent to a single short line for tabular output. +func oneLine(s string) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) > 60 { + return s[:57] + "..." + } + return s +} diff --git a/docs/report/IZEN_ADAPTIVE_PROPOSAL_ORCHESTRATION.md b/docs/report/IZEN_ADAPTIVE_PROPOSAL_ORCHESTRATION.md new file mode 100644 index 00000000..21761009 --- /dev/null +++ b/docs/report/IZEN_ADAPTIVE_PROPOSAL_ORCHESTRATION.md @@ -0,0 +1,257 @@ +# IZEN — Adaptive Proposal Orchestration & End-to-End Objective Execution + +**Status:** implemented and validated, with explicitly documented limitations. +**Branch:** `fix/execution` +**Scope:** first-class adaptive proposal contract; adaptive interaction +selection; revision-safe human decisions; compilation of accepted decisions into +the existing authoritative execution contract; rendered-layout verification; +UI projection; regression tests. + +This is the final report. Sections A–G follow the required format. All claims +below are backed by source that exists in this working tree and by commands run +against it. + +--- + +## A. Verified baseline + +At the start of this phase the working tree was clean on `fix/execution` at +`56e53a5 feat(exec): truthful project-level execution`. I inspected the tree, +the recent commits/diffs and the tests rather than trusting the prior report. +The preceding phase already provided: + +- Evidence-bound, project-level scope derivation + (`internal/execution/derive.go`, `Driver.deriveEvidenceScope`) that binds the + workspace's authored implementation artifacts and excludes documentation, + data, configuration and assets, bounded to 12 files / 5 extension families. +- Truthful completion for broad objectives: `ObjectiveEvidence.BroadObjective`, + `GATE_UNVERIFIED`, and a `verifierSatisfied` that refuses to treat a skipped + verifier as a pass for a broad transformation. +- Durable human-decision recording: `ledgerHumanDecision`, + `durable.EventHumanDecision`, `Driver.ResumeClarify/Approve/Reject/WithProposal` + all append a decision record. + +The single authoritative owner of execution/authorization is the `Driver` +(`internal/runtime/autonomy/driver.go`) over the `ExecutorAdapter`; completion is +owned solely by `execution.ObjectiveCompletionAuthority`. The pre-existing +`DecisionSurface` (`internal/runtime/autonomy/proposal.go`) is a recovery surface +for a CLOSED preflight gate, and `preflight.DecisionSurface` is a budget-overflow +presentation. Neither is a general adaptive proposal. The repository's own +`docs/report/IZEN_OBJECTIVE_ORCHESTRATION_TRUTHFUL_EXECUTION.md` §5 named the +same three remaining gaps this phase closes. + +No resolved defect was reopened; no regression in the three prior fixes was +found. + +## B. Architectural decisions + +| Concern | Owner | Rationale | +|---|---|---| +| Proposal data, revision, decisions, compilation | new pure package `internal/runtime/proposal` | It owns no workspace, provider, executor or authority. It is the typed language and the deterministic rules. | +| Interaction selection | `proposal.SelectInteraction` | Pure function of objective + observed evidence + policy; the runtime does not follow a fixed questionnaire. | +| Proposal transport to the UI | `internal/autonomy.HumanBoundary` (`HumanBoundaryAdaptiveProposal`, `HumanProposalQuestion`, `ProposalDecision`) | Mirrors the existing `HumanProposalOption` pattern; the UI can name it without importing the runtime package. | +| Runtime orchestration | `internal/runtime/autonomy/adaptive_proposal.go` (Driver seam) | The Driver holds the rich `proposal.State`; accepted decisions compile into the SAME `d.req`/resolved scope. No parallel executor, no second durable task owner. | +| Completion | unchanged `execution.ObjectiveCompletionAuthority` | Rendered verification is added as EVIDENCE (`ObjectiveEvidence.RenderedVerification`) consumed by the existing integrity condition; the authority remains the only completion owner. | +| Rendered verification | `internal/runtime/autonomy/rendered_verify.go` | A consumer that renders the real workspace with the local headless browser; it mutates nothing and grants nothing. | +| Authorization | unchanged approval boundary + admission gate | Accepting a strategy is NOT approving a concrete mutation. `applyCompiledProposal` re-binds targets through the canonical gateway and never applies bytes. | + +A model recommendation is `ProvenanceModel`; only a `ProvenanceHuman` decision can +satisfy a required question or compile into a contract. `ProvenancePolicy` +answers are accepted only for questions that declare a `Default`. A proposal can +never grant a capability. + +## C. Implementation + +### New files + +- `internal/runtime/proposal/contract.go` — typed `Contract`, `Provenance`, + observations, assumptions, uncertainties, strategies, trade-offs, risks, + dependencies, phases, acceptance criteria, verification methods, questions + (single/multi/free-form), options, decisions, admission conditions; + `Validate` and revision-safe `Fingerprint`. +- `internal/runtime/proposal/interaction.go` — `Interaction` + (direct/investigate/clarify), `Signal`, `SelectInteraction`, + `ConsequentialQuestions`, `Unanswered`. +- `internal/runtime/proposal/state.go` — revision-safe `State`; `Apply` + (human), `ApplyPolicy` (declared default only), dependency-graph + invalidation, `Complete`, `Compile` → `ExecutionContract`, + `Snapshot`/`Restore` with fingerprint tamper-refusal. +- `internal/runtime/autonomy/adaptive_proposal.go` — `ProposalAdvisor`, + `ProposalFacts`, `ProposalPlan`, `DefaultProposalAdvisor`, + `WithProposalAdvisor`, `WithRenderedVerificationPolicy`, + `maybeAdaptiveProposal`, `ResumeAdaptiveProposal`, `applyCompiledProposal`, + durable proposal snapshot recording, `ProposalSnapshot`/`RestoreProposal`. +- `internal/runtime/autonomy/rendered_verify.go` — `RenderedVerification`, + `RenderedCheck`, `HeadlessBrowserVerifier`, `DetectBrowser`, + `WithRenderedVerifier`, `WithRenderedChecks`, `ensureRenderedVerification`. +- Tests: `internal/runtime/proposal/proposal_test.go`, + `internal/runtime/autonomy/adaptive_proposal_test.go`, + `internal/runtime/autonomy/rendered_verify_test.go`, + `internal/execution/objective_rendered_test.go`, + `internal/ui/adaptive_proposal_test.go`. + +### Changed files + +- `internal/autonomy/runtime_loop.go` — `HumanBoundaryAdaptiveProposal`; + `HumanProposalQuestion`, `HumanProposalStrategy`/`Tradeoff`/`Risk`/`Phase`, + `ProposalDecision`; boundary carry fields; `DeriveBoundaryAction` case. +- `internal/runtime/autonomy/driver.go` — proposal/rendered fields and reset; + `maybeAdaptiveProposal` call; `ensureRenderedVerification` before the + completion authority. +- `internal/runtime/autonomy/objective_completion.go` — `RenderedVerification` + folded into the evidence bundle. +- `internal/execution/objective_authority.go` — + `RenderedVerificationEvidence`, evidence field, `VerifierVerdict`, + `verifierSatisfied`. +- `internal/execution/objective_conditions.go` — rendered facts and the + `ObligationIntegrityHeld` broad-objective branch. +- `internal/runtime/durable/types.go` — `EventProposalSnapshot`. +- `internal/runtime/compose/compose.go` — production wiring of + `DefaultProposalAdvisor` and the env-gated rendered verifier. +- `internal/ui/{autonomous.go,keys.go,model.go}` — render the adaptive proposal + and submit decisions through `Driver.ResumeAdaptiveProposal`. +- `internal/ui/autonomous_test.go` — fake driver implements the new interface + method. + +## D. Demonstrated workflows + +- **Simple task (direct).** `TestAdaptive_DirectTaskKeepsItsPath`: with the + advisor wired, `create a new file named zuru.md …` selects `direct`, forms no + proposal, and reaches the existing approval gate unchanged. +- **Project redesign (investigate → proceed).** + `TestAdaptive_InteractionDiffersByDecisionRequirement` objective 1: a broad + objective with no consequential choice proceeds with the recommendation over + the evidence-derived multi-file scope and records the compiled proposal. + `TestCLIBench_ProjectRedesignDerivesMultiFileScope` still passes end to end. +- **Adaptive proposal (clarify).** Same test objective 2: a consequential + choice parks at `HumanBoundaryAdaptiveProposal` with exactly the material + question and zero provider calls before the human decided. +- **Acceptance → execution contract.** + `TestAdaptive_AcceptedDecisionCompilesIntoExecution`: accepting compiles the + decision into constraints, re-binds the derived targets through the canonical + gateway, dispatches one invocation per target, and reaches the existing + approval gate with nothing written yet. +- **Revision and invalidation.** + `TestAdaptive_IncompleteDecisionReparksWithRevision`: a partial decision set + re-parks at revision 2 with an explicit incompleteness, executes nothing, then + compiles at revision 3 when the remaining decision arrives. Unit-level + dependency invalidation is `TestStateRevisionAndInvalidation`. +- **Authorization boundaries.** `TestAdaptive_ProposalDoesNotWidenAuthority` + (compiled scope ⊆ derived scope; mutation review preserved; no pre-approval + write) and `TestAdaptive_HotNeverGainsRenderedCapability` (`$hot` refuses the + browser capability). +- **Truthful verification.** `TestAdaptive_RenderedEvidenceFlowsToCompletion` + and the `internal/execution` `TestRendered_*` tests: a passing real rendered + check satisfies the broad objective's integrity condition; a failed check is + `GATE_FAIL`; a blocked check is `GATE_UNVERIFIED`; a non-broad skipped gate is + unchanged. +- **Durable decisions / resume.** `TestAdaptive_DecisionsAreDurable` asserts + `HUMAN_DECISION` and `PROPOSAL_SNAPSHOT` in the on-disk ledger and that the + snapshot restores the human decision and revision. +- **UI projection.** `TestAutonomousAdaptiveProposalRendersAndSubmits`, + `…EscapeAborts`, `…DigitSelects`: the TUI renders the proposal and routes the + selected option ids to the runtime. + +## E. Evidence + +Commands run (repository root): + +``` +go build ./... clean +go vet ./... clean +golangci-lint run 0 issues +go test -p 1 ./... -count=1 all packages ok +go test -race ./internal/runtime/proposal/ ok +go test -race ./internal/runtime/autonomy/ -run 'TestAdaptive_|TestRendered' ok +go test -race ./internal/ui/ -run TestAutonomousAdaptiveProposal ok +go test ./test/benchmark/ -run TestCLIBench_ProjectRedesignDerivesMultiFileScope ok +``` + +Real external integration (actual local browser, not a fake): the +`TestRenderedVerifier_RealBrowser` test renders a fixture whose visible text is +produced by JavaScript and checks the RENDERED DOM. Representative log: + +``` +rendered verification: browser="Google Chrome 155.0.8059.39" + revision=4916abf74fd1a9d4 + observations=[rendered index.html artifact_revision=4916abf74fd1a9d4 + check script-ran: satisfied check initial-gone: satisfied] + screenshot="/var/folders/…/TestRenderedVerifier_RealBrowser…/rendered.png" +``` + +Fake-provider tests are explicitly the driver tests that use `mockProvider` / +`targetProvider` (`adaptive_proposal_test.go`, `objective_orchestration_test.go`) +and the `fakeRenderedVerifier` in +`TestAdaptive_RenderedEvidenceFlowsToCompletion`; they exercise the production +`Driver → ExecutorAdapter → RuntimeExecutor` path with a scripted model, and are +labelled as such. The CLI benchmark is a real-binary subprocess test. The +rendered verifier test is a real external integration. + +### Note on parallel-test flakiness + +Running `go test ./...` with default package parallelism twice produced one +failure each time in a DIFFERENT, pre-existing timing-sensitive autonomy test +(`TestExecutionTrace_CanonicalObjectiveSequence`, then +`TestR5_CaseA_EndToEndMutationReachesProven`). Both pass in isolation and the +entire suite is green under `-p 1`. Neither test touches the proposal or +rendered paths (no advisor is wired in them). This is cross-package resource +contention in the existing suite, not a regression introduced here. + +## F. Remaining limitations + +1. **Cross-process proposal resume is not fully automatic.** Decisions and the + proposal snapshot are durably recorded (`EventHumanDecision`, + `EventProposalSnapshot`) and `Driver.ProposalSnapshot`/`RestoreProposal` + restore within a process with tamper-refusal. The durable store does not + currently expose read-back of custom-event payloads, so an automatic restore + of the proposal snapshot from the ledger on a fresh process is not wired. + `RestoreProposal` is the sanctioned seam once read-back exists; this is + deliberate rather than silently reconstructing authorization from + conversational text. +2. **Rendered verification is policy-gated in production.** It is enabled by + `IZEN_RENDERED_VERIFY=1` with `IZEN_RENDERED_ENTRY` and + `IZEN_RENDERED_CONTAINS`. Unconfigured, the objective stays `UNVERIFIED`. + The TUI does not currently expose a configuration surface for it. +3. **Rendered checks are deterministic DOM substring checks.** Model-assessed + visual judgement is NOT implemented. This is intentional: the adapter never + claims an image was visually inspected because a screenshot was generated. +4. **Free-form TUI input.** The runtime API supports and validates free-form + answers (`QuestionFreeForm`, `AllowFreeForm`), and the proposal package tests + cover them. The TUI currently exposes single/multi selection only; free-form + submission is API-only. +5. **Multi-phase migration fixture.** The contract models phases, checkpoints, + boundaries, dependencies and acceptance criteria, and phase/dependency + compilation and invalidation are tested (`TestCompilePhasesCarryTargetsAndAcceptance`, + `TestStateRevisionAndInvalidation`). A full multi-phase migration fixture + driven end to end was not added. +6. **Production default advisor asks no questions.** The domain-neutral + `DefaultProposalAdvisor` recommends the evidence-bound strategy and asks + nothing; consequential questions require a domain/policy advisor. This keeps + simple tasks efficient and is the deliberate boundary that keeps domain + vocabulary out of runtime decision sites. + +## G. Completion verdict + +**PARTIALLY COMPLETE.** + +| # | Definition of Done | Verdict | +|---|---|---| +| 1 | Typed/validated proposal contract | **DONE** — `internal/runtime/proposal`; validated, fingerprinted, tested. | +| 2 | Interaction adapts, not a fixed questionnaire | **DONE** — `SelectInteraction` + `TestAdaptive_InteractionDiffersByDecisionRequirement`, `TestSelectInteractionIsNotAFixedQuestionnaire`. | +| 3 | Repository evidence informs recommendations/targets | **DONE** — advisor consumes `ProposalFacts` derived from observed scope; targets are evidence-derived. | +| 4 | Human decisions durably recorded with revision/provenance | **DONE** — `EventHumanDecision` + `EventProposalSnapshot`; `TestAdaptive_DecisionsAreDurable`. | +| 5 | Accepted decisions compile into authoritative execution contract | **DONE** — `applyCompiledProposal` → existing `d.req`/gateway; `TestAdaptive_AcceptedDecisionCompilesIntoExecution`. | +| 6 | Revision changes invalidate correct dependent state / stale approvals | **DONE** — `State.Apply` dependency invalidation + `invalidateProposalDerivedState` (drops held candidates, invalidates context, re-binds scope); tested. | +| 7 | Mutations stay on the existing authorization/execution authority | **DONE** — no new executor; approval gate + admission preserved; `TestAdaptive_ProposalDoesNotWidenAuthority`. | +| 8 | Simple tasks keep the efficient path | **DONE** — `TestAdaptive_DirectTaskKeepsItsPath`; advisor opt-in. | +| 9 | Multi-artifact objectives execute against a justified scope | **DONE** — existing derivation + `TestCLIBench_ProjectRedesignDerivesMultiFileScope` and adaptive tests. | +| 10 | Completion truthful under skipped/failed verification | **DONE** — `TestRendered_*` and `TestOrchestration_B_ProjectRedesignUnverifiedIsNotProven`. | +| 11 | Rendered verification integrated and validated, or blocker documented | **PARTIAL** — real browser adapter integrated and validated (Chrome 155); production use is policy-gated and checks are deterministic DOM checks; model visual judgement not implemented (documented). | +| 12 | Required regression/validation commands pass | **DONE** — build/vet/lint/race/tests green; `-p 1` full suite green; parallel flake documented. | +| 13 | Report distinguishes implementation/tests/real evidence/unresolved | **DONE** — this report. | + +The phase is not reported COMPLETE because item 11's rendered verification is +deterministic-DOM-only and policy-gated rather than a default, model-assessed +visual verification, and because automatic cross-process proposal restore is not +wired. Both limitations have concrete, documented integration paths and do not +compromise the truthful-state invariants. diff --git a/docs/report/IZEN_AGENT_COMMAND_SURFACE_CONVERGENCE.md b/docs/report/IZEN_AGENT_COMMAND_SURFACE_CONVERGENCE.md new file mode 100644 index 00000000..685f5aa8 --- /dev/null +++ b/docs/report/IZEN_AGENT_COMMAND_SURFACE_CONVERGENCE.md @@ -0,0 +1,559 @@ +# IZEN — Agent Command Surface and Runtime Integration Convergence + +Date: 2026-10-09 · Branch: `fix/execution` · HEAD at start and at report time: +`a13178f` (`feat(exec): unify CLI on authoritative runtime`), working tree dirty +with this change set only (no commit made). + +Distinctions used throughout: + +* **VERIFIED** — proven by a test that ran on this working tree, or by a live + subprocess run of the real binary. +* **OBSERVED** — read in the current source, with a citation. +* **REMAINING / NOT VERIFIED** — a known limitation, disclosed and not claimed. + +--- + +## 1. Verified repository state + +Commands actually run and their results: + +``` +git rev-parse HEAD -> a13178f023be3136c4f2187cf923569864b3db1a +git status --porcelain -> only the files listed in §11 (no reset/clean/stash) +go build ./... -> clean +go vet ./... -> clean +go test -race -count=1 ./... -> see §10 (full suite) +``` + +Baseline before editing (preserved, not overwritten): the tree was clean at +`a13178f`; a full `go test -race -count=1 ./...` was run first and passed +(`baseline_exit=0`). No pre-existing user change was reset, cleaned or +overwritten. + +--- + +## 2. Existing and proposed command hierarchy + +### 2.1 Existing surface before this change (OBSERVED) + +`cmd/izen/main.go` dispatched a flat `switch`: + +``` +izen / izen [path] TUI +izen version|help +izen auth login stub ("not yet implemented") +izen stats stub ("not yet implemented") +izen config style +izen compact | memory optimize +izen debug [path] +izen run [-dir] [-target]... "" interactive [y/N] only +izen prompt "" alias of orchestrate +izen orchestrate [-dir] "" interactive [y/N] only +izen rollback +``` + +There was **no non-interactive execution contract, no structured output, no +documented exit-code semantics, no model/provider selection flag, no durable +task listing and no resumable command**. `izen run` and `izen orchestrate` +always blocked on `[y/N]`; provider/model selection was global config only. +This is the gap the convergence brief targets. + +### 2.2 Proposed hierarchy (implemented) + +``` +izen TUI (unchanged) +izen [path] TUI at path (unchanged) + +Execution — one authoritative runtime + izen run [flags] "" direct execution + izen prompt [flags] "" alias, surface $prompt + izen orchestrate [flags] "" alias, surface $orchestrate + izen resume [flags] continue the recorded non-terminal objective + izen runs [-dir] list durable execution tasks (read-only) + +Inspect / configure + izen models [use ] + izen auth status + izen config show | get | style + izen stats [-dir] [-json] + izen debug [path] + izen compact | izen memory optimize + + izen version | izen help +``` + +**Retained / aliased:** `prompt`, `orchestrate`, `config style`, `compact`, +`memory optimize`, `debug`, `rollback` are all retained. `prompt` and +`orchestrate` are now thin aliases of the same execution seam rather than +separate lifecycles. + +**Deferred (documented, not implemented):** + +* **`izen serve` (HTTP/API boundary)** and **ACP / other agent-protocol + adapters**. Justification: the runtime exposes one in-process lifecycle + (`compose.Wire` → `autonomy.Driver` → `ExecutorAdapter` → `RuntimeExecutor`). + A service boundary requires a *new contract* — concurrent run isolation, + authentication, and a transport-level run/approve/stream protocol — that does + not exist yet. Building it now would either duplicate the driver or expose an + unaudited second execution loop, which the brief forbids. This is the precise + blocker: **the runtime has no external, run-scoped request/approval transport + contract**; the smallest safe contract in scope is the subprocess JSON + contract delivered here (`izen.run.v1`), which an external service can already + consume. + +### 2.3 Flags (documented and effective) + +`izen run` / `izen prompt` / `izen orchestrate` / `izen resume`: + +| Flag | Effect | Reaches | +| --- | --- | --- | +| `-dir ` | workspace root | `compose.WithRoot` | +| `-target ` (repeatable) | explicit target set | `Driver.SetTargets` | +| `-provider ` | provider override | `resolveSelection` → `buildProvider` | +| `-model ` | model override | authority binding + executor `resolveModel` | +| `-base-url ` | endpoint override | provider constructor | +| `-mode ` | ask/investigate/plan/build/review | `SubmitPromptCmd.Mode` (validated via `handlers.ParsePhase`) | +| `-approve ` | ask/always/never | `DriverTerminalBridge.WithPolicy` | +| `-timeout ` | wall-clock bound | `context.WithTimeout` | +| `-json` | machine-readable stdout | report encoder | +| `-quiet` | suppress diagnostics | stderr diagnostics only | +| `-resume` | continue recorded objective | durable `TaskStore.MostRecentRecoverable` | + +Every flag was verified to reach the runtime: an unknown flag, an invalid mode, +an invalid approval policy, an invalid timeout, an unknown provider and a +provider/model mismatch are all rejected with exit 2 **before** any provider +call (`TestParseExecArgs`, `TestCLIBench_InvalidModelSelection`, which asserts +`provider.Calls() == 0`). + +--- + +## 3. Runtime access topology + +There is exactly one execution lifecycle. Every surface below crosses the same +admission front-end and the same driver: + +``` + ┌─────────────────────── human surfaces ───────────────────────┐ + │ izen run / prompt / orchestrate / resume TUI $prompt /build │ + └───────────────┬───────────────────────────────┬──────────────┘ + │ │ + cmd/izen: parseExecArgs │ internal/ui: runAutonomyRoutedCmdExplicit + ▼ ▼ + runExecRequestAs → runAuthoritativeCommand (same admission + decision) + │ + ▼ + compose.Wire (the single composition root) + • Runtime facade (submit_prompt preflight dispatch) + • autonomy.Engine (session grant ledger) + • RuntimeExecutor (provider seam, mutation, verification) + • intent gateway, authorization engine, durable ledger, audit + │ + ▼ + runtimeAutonomy.NewDriver (bounded Observe→Decide→Execute→Interpret) + │ + ▼ + cli.RunAuthoritative (plays the human at parked boundaries only) +``` + +Evidence: + +* `TestExecutionOwnership_AuthoritativeCommandWiresRuntimeAndDriver` + (VERIFIED) pins `runAuthoritativeCommand` → `compose.Wire` + + `cli.RunAuthoritative`, and forbids `NewDriver`/`NewPipeline`/`RunCycle`/ + `NewOrchestrator`/`NewProposalProvider`/`NewTerminalBridge` in the seam. +* `assertAuthoritativeExecSeam` (VERIFIED) pins the shared wrapper + `runExecRequestAs` → `runAuthoritativeCommand` and forbids the same. +* `TestExecutionOwnership_CLIRunDrivesAuthoritativeDriver` and + `..._CLIOrchestrateDrivesAuthoritativeDriver` (VERIFIED, updated transitively) + pin that `run`/`orchestrate` cross the shared seam and construct no competing + lifecycle. +* `TestExecutionOwnership_AllExecutionSurfacesConvergeOnOneRuntime` (VERIFIED, + unchanged) pins the TUI surfaces onto the single runtime seam. + +The CLI owns **no** proposal loop, provider call, planner, approval gate or +terminal decision. Its only human role is rendering the runtime's own candidate +review and issuing the candidate-bound authorization on approve +(`cli.RunAuthoritative`, `authorize` closure in `cmd/izen/authoritative.go`). + +--- + +## 4. CLI/TUI parity evidence + +Both surfaces are wired by the same composition root and drive the same driver; +the CLI only differs in its input/output adapter. + +* **Same driver, same admission.** TUI: `internal/ui/runtime_cutover.go` → + `runAutonomyRoutedCmdExplicit` → autonomy decision → `compose` driver. CLI: + `runAuthoritativeCommand` → `compose.Wire` → `Driver`. Both cross `compose.Wire` + and `cli.RunAuthoritative`-equivalent lifecycle. +* **Subprocess parity (VERIFIED).** `TestCLIBench_SurfaceParity` runs the + identical creation objective through `izen run`, `izen prompt` and + `izen orchestrate` and asserts, for each: exit 0, `outcome=proven`, exact + artifact bytes, and the truthful `surface` (`$run`/`$prompt`/`$orchestrate`). +* **Interactive parity (VERIFIED).** `TestCLIBench_InteractiveApproval` answers + `y` on stdin and asserts the candidate was reviewed, authorized and applied + through the default `-approve ask` policy — the same boundary the TUI renders. +* **Direct-chat parity gap (REMAINING, pre-existing).** A conversational prompt + is not answered directly; the runtime is workspace-evidence-bound and + terminates with an honest non-proof rather than fabricating an answer (Phase 3 + report §8.1, unchanged by this work). + +--- + +## 5. Session and resume semantics + +Durable state is the runtime's `durable.TaskStore` at `.izen/runtime/` +(`ledger.ndjson` + derived `snapshot.json`). Audit is the append-only +`.izen/audit/events.ndjson` written by `compose`'s audit logger. + +* `izen runs` (read-only) lists every non-terminal durable task + (`TaskStore.RecoverableTasks`). It resumes or mutates nothing. +* `izen resume` reads `TaskStore.MostRecentRecoverable`, then re-drives the + **recorded objective and scope** through the same authoritative runtime as + `izen run`. It is a *continuation*, not a replay of a frozen in-memory loop: + the runtime re-observes the live workspace and re-derives authority, so it can + never reuse a stale authorization. The report records `resumed_from`. +* **VERIFIED:** `TestCLIBench_ResumeContinuation` parks a run on a + non-progressing artifact (empty content), confirms `izen runs` lists the + recoverable task, then `izen resume -approve always` against a fixed provider + completes the creation with the exact bytes and `outcome=proven`. +* **Clarification invalidation** is preserved: a clarify boundary left + unanswered aborts the run (`RunAuthoritative` → `Driver.Abort`), and a resumed + objective re-derives scope from scratch (`bindAuthoritativeTargets`). +* **REMAINING:** the loop does not persist a resumable mid-loop program state. + Resume is "recorded objective + live reconciliation", which is exactly what + the durable journal supports. A resume of a task whose status is already + terminal is correctly refused (`Run` duplicate-start guard / no recoverable + task → exit 1). The benchmark does not exercise a hard `SIGKILL` mid-mutation + resume; the durable reconcile surfaces (`ReconcileInterrupted`, + `InspectCursors`) exist but are not exercised by a subprocess test here. + +--- + +## 6. Non-interactive approval and failure semantics + +The approval policy selects behaviour only at the human surface; it changes +nothing about admission, authorization, mutation or verification. + +| Policy | Approval boundary | Clarify boundary | +| --- | --- | --- | +| `ask` (default) | renders review, blocks on `[y/N]` | renders options, reads a target | +| `always` | renders review, auto-approves | cannot answer → abort with `clarify_required` | +| `never` | renders review, auto-rejects | cannot answer → abort with `clarify_required` | + +* **Authorization still binds to the exact candidate.** For `always`, the same + `authorize(b.CandidateTargets, b.CandidateID, b.CandidateDigest)` closure runs + before `ResumeApprove`, so approval binds to the reviewed identity and digest + (`TestRunAuthoritativeAlwaysPolicyApproves`, VERIFIED). +* **Denial is never a silent apply.** `TestCLIBench_ApprovalDenied` proves + `-approve never` yields exit 7 / `outcome=denied` and creates **no file** + (VERIFIED). +* **No unauthorized mutation through non-interactive execution.** Auto-approve + is explicit (`-approve always`) and still requires the runtime's checkpoint- + gated, candidate-bound authorization. Auto-deny applies nothing. +* **Clarification fails closed.** Under a non-interactive policy the bridge + returns an empty decision; the lifecycle aborts and reports + `clarify_required` (exit 4). `TestCLIBench_ReadOnlyAndAmbiguity/ambiguity` + proves an ambiguous objective under `-approve always` yields exit 4 and + mutates no candidate file (VERIFIED). +* **Provider failure.** A provider/transport failure is retried by the runtime's + bounded recovery and, if it parks, is surfaced as `provider_failed` (exit 3), + never success (`TestCLIBench_ProviderFailure`, VERIFIED). +* **False success is structurally detected.** An empty artifact that completes + the stream does not create a file and does not report `proven` + (`TestCLIBench_FalseSuccessDetection`, VERIFIED). + +--- + +## 7. Structured output and exit-code contract + +`-json` emits **one** JSON object on stdout (`schema: izen.run.v1`); all human +diagnostics and the candidate review go to stderr. The report is derived only +from runtime state: + +```json +{ + "schema": "izen.run.v1", + "surface": "$run", + "provider": "opencode", "model": "scripted/model-large", + "workspace": ".", "objective": "...", "mode": "build", + "outcome": "proven", "reason": "objective satisfied ...", + "approved": true, "inspected": false, "rejected": false, + "clarify_required": false, + "objective_contract": { "progress": "PROVEN", "outcome": "PROVEN", + "continuation": "COMPLETE", "conditions": [ ... ] }, + "artifacts": { "applied": ["note.txt"], "candidate_targets": ["note.txt"], + "candidate_digest": "...", "operation": "CREATE" }, + "usage": { "input_tokens": 12, "output_tokens": 7, "known": true }, + "elapsed_ms": 98, "exit_code": 0 +} +``` + +* **Completion vs proof.** `outcome` is the process classification; the nested + `objective_contract.progress` (`PROVEN` / `UNSUBSTANTIATED` / `FAILED` / …) is + the runtime's evidence-backed proof state. They are deliberately distinct. +* **No fabricated evidence.** Absent facts are omitted (`omitempty`), not + invented; token usage carries `known:false` when the provider reported none. +* **`TestCLIBench_JSONContract` (VERIFIED)** asserts stdout is a single JSON + object with the required keys, `schema=izen.run.v1`, `exit_code=0`, and that + diagnostics appear on stderr. + +Exit codes (stable, documented in `izen help` and `izen run -h`): + +| Code | Meaning | +| --- | --- | +| 0 | `proven` / `completed` | +| 1 | failed / aborted | +| 2 | usage or configuration error (unknown flag, invalid mode/model/binding) | +| 3 | provider failure (`provider_failed` / propagated provider error) | +| 4 | human required (parked non-resumable boundary, or `clarify_required`) | +| 5 | `unsubstantiated` (terminal, objective not proven) | +| 6 | `timeout` (wall-clock bound) | +| 7 | `denied` (operator or policy declined the mutation) | + +`TestCLIBench_Timeout` (VERIFIED) proves the wall-clock bound yields exit 6 and +no artifact. `TestCLIBench_HelpAndExitCodes` (VERIFIED) pins help and exit 2. + +--- + +## 8. Benchmark harness — commands and actual results + +The harness is `test/benchmark/cli_harness.go` + `cli_bench_test.go`. It: + +1. builds the **real `izen` binary** (`go build ./cmd/izen`); +2. starts a **scripted OpenAI-compatible provider** on 127.0.0.1 (no network, + no credential) that answers requirement/manifest passes with contract-shaped + empty payloads and streams the scripted artifact; +3. runs the binary in an **isolated `HOME` and a fresh git workspace**, + capturing stdout, stderr and exit status; +4. asserts **exact artifact bytes**, the JSON report, exit code and **durable + evidence** (ledger + audit present). + +Exact commands (no TUI, no credentials): + +```sh +# Full CLI benchmark suite (builds the binary, uses a local scripted provider) +make bench-cli + +# Equivalent direct invocation +go test -run 'TestCLIBench' -v -count=1 -timeout 15m ./test/benchmark + +# One scenario +go test -run 'TestCLIBench_FalseSuccessDetection' -v ./test/benchmark + +# Investigative dump (skipped by default): subprocess output, JSON report, +# every provider request, durable ledger/snapshot/audit tails +IZEN_CLI_DEBUG=1 go test -run TestCLIDebug -v ./test/benchmark +``` + +Actual results on this working tree (all executed): + +``` +--- PASS: TestCLIBench_CreateFile +--- PASS: TestCLIBench_ModifyFile +--- PASS: TestCLIBench_ApprovalDenied +--- PASS: TestCLIBench_InvalidModelSelection +--- PASS: TestCLIBench_ProviderFailure +--- PASS: TestCLIBench_Timeout +--- PASS: TestCLIBench_FalseSuccessDetection +--- PASS: TestCLIBench_ReadOnlyAndAmbiguity (read-only, ambiguity) +--- PASS: TestCLIBench_JSONContract +--- PASS: TestCLIBench_ResumeContinuation +--- PASS: TestCLIBench_InteractiveApproval +--- PASS: TestCLIBench_SurfaceParity (run, prompt, orchestrate) +--- PASS: TestCLIBench_HelpAndExitCodes +ok github.com/PizenLabs/izen/test/benchmark +``` + +Coverage mapping to the brief: + +| Requirement | Scenario | Assertion | +| --- | --- | --- | +| read-only question | `ReadOnlyAndAmbiguity/read-only` | no mutation; exit 0, PROVEN | +| investigation | covered by read-only (`-mode ask`) | no mutation | +| file creation | `CreateFile` | exact bytes, `proven`, ledger | +| file modification | `ModifyFile` | exact bytes, `proven` | +| ambiguity | `ReadOnlyAndAmbiguity/ambiguity` | exit 4, no mutation | +| approval denial | `ApprovalDenied` | exit 7, no file | +| invalid model selection | `InvalidModelSelection` | exit 2, 0 provider calls | +| provider failure | `ProviderFailure` | exit 3, no file | +| verification failure | `FalseSuccessDetection` (empty artifact) | not `proven`, no file | +| non-interactive execution | all `-json -approve always` scenarios | report + exit contract | +| resume / continuation | `ResumeContinuation` | resumed artifact, `resumed_from` | +| interface parity | `SurfaceParity` | identical artifact across surfaces | + +**False-success detection.** No scenario relies on exit code 0 alone: every +successful scenario asserts the exact on-disk bytes, and the anti-false-success +scenario asserts that a stream that reports success but carries no artifact +produces **neither** a file **nor** `outcome=proven`. + +--- + +## 9. Artifact-integrity and durable-state evidence + +* **Applied bytes equal reviewed bytes.** The CLI never synthesizes a proposal; + the review is `autonomy.HumanBoundary` facts (`CandidateDiff`, digest, + targets) rendered from the runtime's own candidate record. The authorization + binds `CandidateID` + `CandidateDigest`. `TestCLIBench_CreateFile` and + `_ModifyFile` assert the final bytes equal the scripted artifact, and + `TestCLIBench_SurfaceParity` asserts the same bytes across all three surfaces + (VERIFIED). +* **Durable journal (VERIFIED, captured from a real create run).** After + `izen run -json -approve always …` on an isolated workspace: + + ``` + .izen/runtime/ledger.ndjson + {"eventType":"TASK_CREATED", "payload":{"intent":"Create ...","scope":["note.txt"]}} + {"eventType":"CHECKPOINT_CREATED","payload":{"checkpointId":"run-1-attempt-0-deciding"}} + {"eventType":"CURSOR_DISPATCHED", "payload":{"operationId":"run-1-attempt-0","status":"DISPATCHED"}} + {"eventType":"VERIFICATION_RESULT","payload":{"detail":"outcome=pending_approval ..."}} + .izen/runtime/snapshot.json + { "status": "COMPLETED", "lastCheckpointId": "cli-finalize", + "activeTargetScope": ["note.txt"] } + .izen/audit/events.ndjson (non-empty) + ``` + + `assertLedgerEvidence` asserts both files exist for every mutating benchmark + scenario. +* **Objective proof is evidence-bound.** The `objective_contract` in the report + is `Driver.ObjectiveReport()` — re-decided from runtime conditions with + `verification_state` and `evidence_refs`; the CLI never writes it. +* **Audit flush is authoritative.** `runAuthoritativeCommand` propagates + `app.FlushAudit()` failure as a hard error (exit 1) and materializes the + derived snapshot at the same boundary the retired headless ledger used. + +--- + +## 10. Test results and unverified areas + +Commands run on this working tree: + +``` +go build ./... clean +go vet ./... clean +go test -race -count=1 ./... PASS (final5_exit=0, 0 FAIL) +make bench-cli / go test -run TestCLIBench ... PASS (13 scenarios) +go test ./cmd/izen/... ./internal/cli/... + ./internal/architecture/... ./test/architecture/... PASS +go test -race ./internal/runtime/autonomy -run TestR5_CaseA... -count=10 PASS +``` + +Focused packages: + +``` +ok github.com/PizenLabs/izen/cmd/izen (parser, resume, models/auth/config/stats, resume fail-closed) +ok github.com/PizenLabs/izen/internal/cli (approval policies, clarify fail-closed, authorization ordering) +ok github.com/PizenLabs/izen/internal/architecture (execution-ownership locks) +ok github.com/PizenLabs/izen/test/architecture (kernel / negative-architecture locks) +ok github.com/PizenLabs/izen/test/benchmark (subprocess CLI suite) +``` + +Full-suite result: `go test -race -count=1 ./...` completed with `final5_exit=0` +(0 `FAIL`) on the standalone, frozen tree. The pre-existing architecture lock +`test/architecture/negative_rules_test.go` and the kernel locks still pass +unchanged; the only architecture test changed was the CLI ownership lock +(`internal/architecture/execution_ownership_convergence_test.go`), updated +transitively (see §11). + +**Load-sensitive flake (disclosed, not hidden).** While two full `-race` suites +were run concurrently (an accidental overlap of verification runs), one +pre-existing test flaked twice: `TestR5_CaseA_EndToEndMutationReachesProven` +(`internal/runtime/autonomy`). Its post-run assertion reads an asynchronous +event-collector snapshot and lost the delivery race under extreme CPU load; the +runtime behavior it checks (terminal COMPLETED, `ObjectiveProgress()==PROVEN`, +non-zero provider calls) passed. This package is **not touched by this change**. +The test passes 10/10 with `-race` in isolation, 3/3 when run together with the +new benchmark package, and the standalone full suite is clean. No production +code was weakened to hide it. A future change could make the collector `Wait` +for the terminal continuation event instead of reading immediately. + +**Unverified / disclosed gaps:** + +* No `izen serve` / ACP adapter (deferred contract, §2.2). +* Resume is not tested across a hard process kill mid-mutation; only the + recorded-objective continuation path is exercised. +* The benchmark's scripted provider is OpenAI-compatible; it does not exercise + Anthropic/Gemini-specific wire shapes (those paths are covered by their own + package tests, not by the subprocess harness). +* `test/live_r2` and other live-provider suites are opt-in and were not run + (no live credential in the benchmark environment); they are unaffected by this + change and skip/degrade deterministically. + +--- + +## 11. Changed files and remaining limitations + +New: + +``` +cmd/izen/exec.go shared execution grammar (parseExecArgs, execRequest) +cmd/izen/report.go izen.run.v1 report + human render +cmd/izen/resume.go izen resume / izen runs + durable readers +cmd/izen/models.go izen models [use] +cmd/izen/auth.go izen auth status (never prints secrets) +cmd/izen/stats.go izen stats (durable token usage) +cmd/izen/cli_surface_test.go +test/benchmark/cli_harness.go scripted provider + subprocess runner +test/benchmark/cli_bench_test.go scenario suite +test/benchmark/cli_debug_test.go investigative dump (skipped by default) +``` + +Modified: + +``` +cmd/izen/main.go command hierarchy + exit codes + config show/get +cmd/izen/authoritative.go one execution seam; report; exit classification +cmd/izen/runtime.go buildProvider with explicit overrides; run handler +cmd/izen/orchestrate.go run/prompt/orchestrate aliases on the shared seam +cmd/izen/runtime_test.go parseExecArgs + approval-policy tests +internal/cli/authoritative.go ApprovalPolicy + bridge policy + result facts +internal/cli/authoritative_test.go policy/clarify tests +internal/architecture/execution_ownership_convergence_test.go transitive seam lock +Makefile bench-cli target +``` + +**Remaining limitations (truthful):** + +1. `izen run` remains interactive by default (`-approve ask`); automation must + pass `-approve always|never`. This is intentional — the runtime's approval + boundary is preserved, not bypassed. +2. A provider failure after bounded recovery surfaces as `provider_failed` + (exit 3) from a parked boundary rather than from a raw error return; the + report's `outcome`/`reason` state this explicitly. +3. Direct conversational answers are still not surfaced (pre-existing Phase 3 + gap). +4. `izen stats` reports the durable task ledger's token accounting; it does not + aggregate a separate historical usage store (none exists). +5. External service/API and ACP adapters remain deferred (§2.2). + +--- + +## 12. Truthful final verdict + +The command surface and its execution integration converge on **one +authoritative runtime**: + +* All execution commands (`run`, `prompt`, `orchestrate`, `resume`) parse + arguments only and delegate to the single `runAuthoritativeCommand` seam, + which wires `compose.Wire` and drives the autonomy `Driver` over the + `RuntimeExecutor`. No command owns an execution loop or a definition of + success. VERIFIED by AST locks and by subprocess parity tests. +* Automation is real: explicit workspace/model/mode/target/timeout selection, + bounded wall-clock, machine-readable `izen.run.v1` output, stable exit codes, + and explicit approval/clarification outcomes — each reaching the runtime and + each with a documented, tested effect. +* The benchmark harness invokes the real binary as a subprocess against a + scripted local provider, asserts exact artifact changes and durable evidence, + and **detects false success**. VERIFIED by 13 passing scenarios. +* Invariants are preserved: model output is untrusted; the runtime owns authority + and state; approval binds to the exact canonical mutation; objective state is + evidence-backed; non-interactive execution cannot mutate without an explicit + policy and the runtime's own candidate-bound authorization; durable state and + audit remain authoritative; resume re-derives authority rather than reusing + stale authorization. + +**One authoritative runtime. Multiple coherent interfaces. Reliable automation. +Reproducible benchmarks. Evidence-backed outcomes.** + +No claim above is made that was not backed by an executed test or a source +citation. Where a capability is deferred or unverified, it is stated as such. diff --git a/docs/report/IZEN_ARTIFACT_APPROVAL_INTEGRITY.md b/docs/report/IZEN_ARTIFACT_APPROVAL_INTEGRITY.md new file mode 100644 index 00000000..92ac0f63 --- /dev/null +++ b/docs/report/IZEN_ARTIFACT_APPROVAL_INTEGRITY.md @@ -0,0 +1,341 @@ +# IZEN — Artifact, Approval and Applied-Bytes Integrity (Phase 2.1) + +Date: 2026-10-09 · Branch: `fix/execution` · HEAD: `041a344` +Reported implementation commit: `368be5b` — **not present in this repository's +history**. The reference report +`docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE_PHASE2.md` is the parent phase; +its §6.9 disclosed this exact issue as a pre-existing artifact-integrity +property. This phase fixes it. + +Distinctions used throughout: + +* **VERIFIED** — proven by a test that ran on this working tree, or by a live + provider run. +* **OBSERVED** — read in the current source, with a citation. +* **REMAINING** — a known deviation, disclosed and not claimed fixed. + +--- + +## 1. Method and baseline + +1. `git rev-parse HEAD` → `041a344fe950b4aadf5f1e50e1f98a4eef26ab4d`, branch + `fix/execution`, clean tree. The reported commit `368be5b` **does not exist** + (`git log --all | grep 368be5b` → empty). HEAD is the Phase 2 convergence + commit; the Phase 2 report was re-read and its §6.9 is the subject of this + phase. +2. Reproduced the defect with an executor-level test before touching source + (§2.3). The reproduction confirmed a real device/approval/applied-byte + mismatch, not a rendering artifact. +3. Traced the concrete CREATE data path by exact source reads, then repeated the + analysis for PATCH. +4. Changed the single owning boundary, added behavioral regression tests, and + ran focused, package, and full-suite tests plus one local live-provider run. + +--- + +## 2. Phase A — the actual data path + +### 2.1 The CREATE path before the correction (OBSERVED) + +The full-artifact staging path is +`RuntimeExecutor.executeTargets` (`internal/execution/executor.go`, the +full-artifact branch beginning at the `ParseMutationArtifacts` call). For a +model-emitted `FILE_CREATE` envelope: + +```text +raw provider bytes + └─ ParseMutationArtifacts(verbatim) executor.go + → MutationArtifact{Form: file_create, Structural: true, + DeclaredPath: "hello.md", Content: ""} + └─ modified = ResolveModifiedContent(original="", raw) + → the WHOLE envelope ("<<<<<<< FILE_CREATE hello.md … END_FILE") + └─ ArtifactBinder.Bind(Content=modified, Form=file_create, …) + → bound.Content = modified (envelope still) + └─ artifactGate(target, modified) (validated the ENVELOPE) + └─ Patch{File: target, Modified: modified} (envelope) + └─ diffs = [ x.compileDiff(raw, target, original) ] (diff of the ENVELOPE) + └─ pendingMutation{patches, diffs} + ├─ CandidatePreview.Diff = wholeFileAdditionDiff(Modified) (envelope) + ├─ CandidatePreview.ArtifactDigest = sha256(Modified) (envelope) + └─ CandidatePreview.Digest() = sha256(id‖targets‖op‖digest‖diff) (envelope) + └─ approval boundary: b.CandidateDiff / b.CandidateDigest (envelope) + └─ authorization bound to CandidateDigest (envelope) + └─ PatchManager.Apply + └─ parseFileCreateBlocks(patch.Modified) → sanitizeCreationBody(block.Content) + → WRITES THE PARSED INNER BODY ("Hello everyone\n") +``` + +The candidate identity, the digest the authorization is bound to, the diff +presented for human review, and the raw artifact all describe the **envelope**; +the mutation boundary writes the **parsed body**. The verification/diff evidence +recorded at apply time (`recordMutationEvidence`) is also measured from the +compiled diff, i.e. from the envelope. The TUI renders the same +`b.CandidateDiff` (`internal/ui/autonomous.go:920`; +`internal/runtime/autonomy/approval_admission.go:181`), so the same mismatch +reaches every human surface. + +### 2.2 The PATCH path does not share the defect (OBSERVED) + +For an existing target, `modified = ResolveModifiedContent(original, raw)` +**applies** the SEARCH/REPLACE or unified diff and returns the resolved full +file content. `patch.Modified` is therefore already canonical; the preview +digest and the applied bytes are the same content, and `Apply`'s re-resolution +is an identity transform for that content. The defect is specific to the +full-artifact CREATE envelope path, where extraction was deferred to apply time. + +### 2.3 Reproduction (VERIFIED, before the correction) + +`internal/execution`, a scripted provider emitting +`<<<<<<< FILE_CREATE hello.md\nHello everyone\n>>>>>>> END_FILE`: + +```text +res.Content = "<<<<<<< FILE_CREATE hello.md\nHello everyone\n>>>>>>> END_FILE\n" +res.Diff = "@@ -0,0 +1,3 @@\n+<<<<<<< FILE_CREATE hello.md\n+Hello everyone\n+>>>>>>> END_FILE\n" +preview.ArtifactDigest = 55bceab4… (envelope) +preview.Diff = (envelope) +applied bytes = "Hello everyone" ← the user approved the envelope +``` + +### 2.4 Classification + +The discrepancy is a **combination**: + +1. **Not display-only** — the digest the authorization is bound to is computed + over the envelope, so the mismatch is an approval-binding defect as well. +2. **Approval-binding defect** — `CandidateDigest` bound the envelope while the + runtime applied the body. +3. **Artifact-integrity defect** — the canonical mutation (the bytes to apply) + was never materialized before approval; two owners resolved it at two times. +4. **Verification-evidence defect** — the diff evidence the mutation boundary + recorded was measured from the envelope, not from the approved artifact. + +It is **not** a runtime-topology or TUI defect: both consumers faithfully +rendered the candidate the executor produced. + +--- + +## 3. Phase B — the canonical artifact contract + +The authoritative representation of the proposed mutation is the staged +`Patch.Modified` **after the artifact boundary has resolved the transport +envelope into its canonical, apply-ready bytes**. The contract now explicitly +distinguishes: + +| Representation | Owner | Role | +|---|---|---| +| raw model output | ingestion trace / `ModelInvocation` | diagnostics only; never the artifact to apply | +| parsed file content | `ParseMutationArtifacts` | structural recognition (`Form`, `DeclaredPath`) | +| canonical mutation | artifact-boundary canonicalization at staging | the bytes the mutation engine will place | +| human-readable diff | `CandidatePreview.Diff` from the canonical bytes + real baseline | review projection of the canonical mutation | +| applied bytes | `PatchManager.Apply` | exactly `Patch.Modified` when `Patch.Canonical` | +| verification evidence | `Verify` gate + mutation boundary | observed after the write, against the actual file | + +Invariants enforced: + +* The candidate content is canonical **before** binding, diff generation, + digesting and approval. +* The authorization binds `CandidateID` + `CandidateDigest` derived from the + canonical content. The applied bytes are that same content. +* A canonical patch is written **verbatim**. Apply never re-derives it from + protocol text, so approval cannot be substituted after the fact. +* Approval requirements, candidate binding, scope authority and artifact + validation are unchanged. Raw provider output remains available + (ingestion trace, `ModelInvocation`) without being mistaken for the artifact. + +--- + +## 4. Phase C — implementation + +### 4.1 Files changed + +| File | Change | +|---|---| +| `internal/execution/patch.go` | Added `Patch.Canonical` (provenance bit). Added `canonicalFileCreateBody` — the single FILE_CREATE body extractor, reusing `parseFileCreateBlocks` + `sanitizeCreationBody`. `Apply` now writes a canonical creation's `Modified` **verbatim** (one shared write/verify/evidence block for both the canonical and the legacy raw-envelope cases). Empty canonical creations are permitted. | +| `internal/execution/artifact.go` | Added `canonicalContractFenceBody`, reusing the existing V3 `extractor.ArtifactContractParser` for `:::artifact ` / ```` ```lang:path ```` transport fences. | +| `internal/execution/executor.go` | Full-artifact staging now resolves a recognized `FILE_CREATE` envelope (and, for creations, a formal contract fence) to its canonical body **before** the binder, digest and preview. The staged `Patch` is marked `Canonical`. The CREATE diff is derived from the canonical bytes (never the envelope). A non-terminated envelope fails closed. An explicitly terminated empty envelope remains a legitimate empty-file creation. | + +No new artifact producer, no second normalization pipeline, no new runtime, +driver, planner or state machine. The parser, binder, candidate preview, +authorization engine, patch manager and verification gate are the existing +boundaries. + +### 4.2 Requirements mapping + +1. **Deterministic envelope parsing / malformed rejection.** A recognized + `FILE_CREATE` envelope with no `END_FILE`, or a contract fence with no closer, + returns `ErrInvalidPatchFormat` + `ErrArtifactRejected`; no candidate is + staged and no approval surface opens. +2. **Canonical content before diff/approval.** `modified` is resolved at staging, + before `ArtifactBinder.Bind`, `artifactGate`, `compileDiff`, and the pending + candidate record. +3. **Preview is the exact mutation.** `CandidatePreview` derives `Diff`, + `ArtifactDigest` and `Digest()` from the same canonical `Patch.Modified`. +4. **Identity/digest bind the approved content.** The digest is the canonical + candidate digest; the authorization is issued and re-checked against it. +5. **Apply cannot substitute.** `Patch.Canonical` makes Apply write the reviewed + bytes verbatim (still gated by authorization, admission, guardrail, + transaction, kernel and verification). +6. **Verification observes the artifact.** The verification gate runs against + the file after the write; the new test greps the applied bytes. +7. **Failure/rejection/cancellation stay distinct.** Existing `Apply` failure, + `Reject`, OCC/cancel paths are untouched; tests pin rejection and + authorization-refusal as non-mutating. +8. **Traceability.** Raw output stays in the ingestion trace/`ModelInvocation`; + the canonicalization is a deterministic function of the recognized form. + +--- + +## 5. Phase D — regression tests + +New file `internal/execution/artifact_approval_integrity_test.go` (12 tests). +All assert actual bytes/identity, not just return values or rendered strings. + +| Test | Pins | +|---|---| +| `TestArtifactIntegrity_FileCreatePreviewIsTheAppliedArtifact` | preview diff/digest carry no envelope; preview digest == candidate content digest; applied bytes == approved content; unrelated file byte-preserved | +| `TestArtifactIntegrity_ContractFenceCreatePreviewIsTheAppliedArtifact` | same for `:::artifact` transport | +| `TestArtifactIntegrity_PlainCreateDigestMatchesAppliedBytes` | the invariant without an envelope | +| `TestArtifactIntegrity_MalformedEnvelopeFailsClosed` | unterminated envelope → error, no candidate, no write | +| `TestArtifactIntegrity_AuthorizationBoundToAppliedDigest` | authorization bound to different content → `ErrAuthorizationCandidateChanged`, no write, candidate still held | +| `TestArtifactIntegrity_AuthorizationRejectsDifferentCandidate` | candidate-identity mismatch → `ErrAuthorizationCandidateMismatch`, no write | +| `TestArtifactIntegrity_RejectionCausesNoMutation` | rejection is distinct and writes nothing | +| `TestArtifactIntegrity_PatchBaselineAndDigest` | PATCH: digest == applied bytes against the real baseline | +| `TestArtifactIntegrity_PostApplyVerificationObservesTheFile` | a verification step that greps the created file passes because the approved body is on disk | +| `TestArtifactIntegrity_AdversarialProseResemblingProtocolIsPreserved` | legitimate content containing `FILE_CREATE`/`END_FILE` prose is not stripped | +| `TestArtifactIntegrity_EmptyTerminatedEnvelopeThroughExecutor` | a terminated empty envelope creates an empty file, digest still binds | +| `TestArtifactIntegrity_PlaceholderEchoRejectedBeforeApproval` | placeholder echo rejected, no candidate, no write | + +Existing tests that exercise the raw-envelope path directly +(`TestCreateEnvelopeNeverLeaksMarkers`, `TestCreateEmptyEnvelopeCreatesEmptyFile`, +`TestCreateStrippedStraySeparator`, …) still pass unchanged: the legacy +`PatchManager.Apply` envelope extraction is preserved for non-canonical callers. + +No architecture test was added. The defect was an ownership split inside one +staging function, not a new lifecycle owner; the behavioral tests above fail if +the canonicalization is removed, so a structural lock would add no coverage. If +a second extraction owner is ever introduced, the `TestArtifactIntegrity_*` +digest/byte assertions will catch the divergence. + +--- + +## 6. Phase E — evidence + +### 6.1 Deterministic tests (VERIFIED) + +```text +go build ./... exit 0 +go vet ./internal/execution/ ./internal/ui/ ./internal/cli/ ./cmd/izen/ exit 0 +go test ./internal/execution/ -run TestArtifactIntegrity -count=1 ok (12/12) +go test ./internal/execution/ ... -count=1 ok +go test ./internal/ui/ ./internal/runtime/autonomy/ ./internal/cli/ \ + ./internal/architecture/ ./cmd/izen/ -count=1 ok +``` + +The same reproduction that produced the envelope diff before the correction now +produces: + +```text +res.Content = "Hello everyone\n" +res.Diff = "@@ -0,0 +1,1 @@\n+Hello everyone\n" +preview.Diff = "@@ -0,0 +1,1 @@\n+Hello everyone\n" +preview.Digest = 096b6b9835… (canonical) +applied bytes = "Hello everyone\n" == res.Content +``` + +### 6.2 Live provider (VERIFIED — local model, zero cost) + +Provider: local **Ollama `qwen2.5-coder:7b`** (real model). Workspace: isolated +`git` repo containing only `keep.md`, baseline committed. + +```text +printf 'y\n' | ./bin/izen orchestrate -dir \ + "Create a new file named hello.md with the content 'Hello everyone'." +``` + +Captured facts (from the runtime's own stdout and audit, never model prose): + +* surface `$orchestrate`, admitted with read/analyze/propose/mutate/verify; +* provider calls: 2; main generation `prompt_tokens=362, completion_tokens=8, + finish_reason=stop`, output 14 chars; +* proposal: `operation=CREATE (+1 -0)`, candidate fingerprint + `de0a622f…`, preview diff `+Hello everyone` — **no envelope**; +* approval → mutation: `execution.mutation.completed Outcome=created + ArtifactPresent=true DiffPresent=true DiffAdds=1 DiffRemoves=0 + ApplyExecuted=true FilesystemChanged=true`; +* verification: `execution.verification.completed Outcome=NOT_APPLICABLE` + ("no verification configured for language") — honest not-applicable; +* terminal: `objective.evaluated state=PROVEN granted=true + authority=objective_completion_authority`; + `execution.finished Success=true Outcome=created`; + `orchestrate: run=run-1 completed approved=true reason="objective satisfied: + created; objective PROVEN by evidence"`; +* `.izen/patches/run-1-patch-1.json` = `{"file":"hello.md","original":"", + "modified":"Hello everyone\n","applied":true}` — the persisted artifact is + the canonical body, **not** the envelope; +* `hello.md` = `Hello everyone\n`; `keep.md` byte-for-byte unchanged. + +A second live run with a prompt that explicitly requested the +`<<<<<<< FILE_CREATE … >>>>>>> END_FILE` envelope still had the small local +model emit the plain body (14 chars), so the **live run exercises the plain +CREATE path**; the envelope path is covered by the deterministic tests in §5. +No paid API was used. + +### 6.3 Full suite (VERIFIED) + +```text +go test ./... +``` + +Passed twice (zero `FAIL`, zero panics). One earlier full-suite run reported two +failures that are **flaky under full parallel load, not regressions**: + +* `TestSemanticLock_ReadOnlyConstraintHasExactlyOneNonTestCaller` — a directory + walk raced a UI test's transient `internal/ui/.izen/session.json.tmp` file; +* `TestR5_CaseA_EndToEndMutationReachesProven` — continuation-record snapshot + timing. + +Both pass in isolation, both pass with `-count=20` / `-count=2`, and the full +suite passed on the clean baseline and on two re-runs with this change. Neither +test touches the modified code (`replace_block` / filesystem-walk paths). + +--- + +## 7. Remaining limitations (REMAINING) + +1. **Nested protocol markers inside content.** A `FILE_CREATE` body whose own + lines are *exact* envelope markers (e.g. a line that is exactly + `>>>>>>> END_FILE`) terminates the envelope at that line. This is inherent to + the line-delimited transport format and is unchanged; protocol-resembling + *prose* (the common case) is preserved and pinned by test. A future structured + encoding is the prerequisite for handling exact nested markers. +2. **Canonicalization is scoped to genuine creations (`original == ""`).** A + `FILE_CREATE` envelope aimed at an *existing* target keeps its historical + resolution (the apply boundary still extracts and writes the body). That + degenerate model behaviour is unchanged and remains a latent mismatch; it is + not the reported CREATE case. A future change can fail-closed on a creation + contract aimed at an existing target. +3. **Plain-body creates rely on the shared sanitizers.** A plain create body is + resolved by `ResolveModifiedContent` and written verbatim; a body that looks + exactly like a unified diff is therefore preserved rather than diff-stripped. + This is intentional (valid content is not stripped) and is now consistent + with the envelope path. +4. **No architecture lock added**, per §5 rationale. + +--- + +## 8. Conclusion + +The Phase 2 §6.9 discrepancy is corrected at its owner: + +```text +ONE ARTIFACT BOUNDARY PARSE + → ONE CANONICAL CANDIDATE (Patch.Modified, Canonical=true) + → ONE DIFF / DIGEST / APPROVAL PAYLOAD (from the canonical bytes) + → ONE APPLY (writes the approved bytes verbatim) + → ONE VERIFICATION (observes the resulting file) +``` + +Verified, not assumed: the reviewed digest equals the applied-bytes digest +(tests + live run); a malformed envelope fails closed; authorization and +rejection remain distinct, candidate-bound and non-mutating; unrelated files are +preserved; and the full regression suite passes. diff --git a/docs/report/IZEN_CLOSURE_AUDIT_REPORT.md b/docs/report/IZEN_CLOSURE_AUDIT_REPORT.md new file mode 100644 index 00000000..00795a50 --- /dev/null +++ b/docs/report/IZEN_CLOSURE_AUDIT_REPORT.md @@ -0,0 +1,523 @@ +# IZEN — Final Closure Audit: Recovery, Interaction Completeness, and Runtime Reliability + +**Status:** all four integration gaps closed; one parallel-test defect root-caused and fixed. +**Branch:** `fix/execution` +**Scope:** durable cross-process proposal recovery; complete free-form TUI +interaction; a multi-phase migration integration fixture; reproduction, root +cause and repair of the parallel test instability; the §6 regression set. + +Every claim below is backed by source in this working tree and by commands run +against it. Tests that use a scripted model are named as such. + +--- + +## A. Verified baseline + +Baseline commit `5e33d5d`, clean tree. I re-read the previous report's six +limitations and verified each against the source rather than trusting it: + +| Prior limitation | State at start | Closed by | +|---|---|---| +| 1. Autematic cross-process restore not wired | `RecordCustomEvent` had no read-back; `RestoreProposal` was in-process only | §B.1 | +| 2. Rendered verification policy-gated | unchanged (out of scope) | — | +| 3. Rendered checks are DOM-only | unchanged (out of scope) | — | +| 4. Free-form TUI input API-only | `QuestionFreeForm`/`AllowFreeForm` existed in the contract and the transport; the TUI projected single/multi only | §B.2 | +| 5. No multi-phase fixture | contract modelled phases; no end-to-end fixture | §B.3 | +| 6. Parallel flake "documented" | two tests, cause not identified | §D | + +The architectural invariants held at baseline and still hold: the `Driver` is the +single execution/authorization owner over the `ExecutorAdapter`, +`execution.ObjectiveCompletionAuthority` is the only completion owner, and a +proposal grants no capability. + +--- + +## B. Implementation + +### New files + +**`internal/runtime/durable/custom_events.go`** — audit-lineage read-back. +`CustomEvent`, `IsCustomEventType`, `TaskStore.CustomEvents(taskID, types…)`, +`TaskStore.CustomEventsTruncated(taskID)`, and a bounded retention window +(`maxRetainedCustomEvents = 512`) folded from `apply`, so live appends and replay +retain identically. The bound is REPORTED, not hidden: a caller concluding "the +human never decided" over a dropped window would be inventing an absence. The +journal remains the only authority; retention is a cache. + +**`internal/runtime/autonomy/proposal_recovery.go`** — the cross-process seam. +`Driver.RestoreProposalFromLedger(objective)` reads the real store, and +`Driver.continueRestoredProposal` is the single consumer of a restored proposal. + +**`internal/runtime/autonomy/proposal_recovery_test.go`** — the real +persistence round trip plus the refusal matrix (§C.1). + +**`internal/runtime/autonomy/proposal_multiphase_test.go`** — the multi-phase +migration fixture (§C.3). + +**`test/benchmark/cli_multiphase_test.go` + `test/benchmark/ledger_inspect.go`** +— the same fixture at the real CLI/runtime process boundary (§C.3). + +### Changed files + +| File | Why | +|---|---| +| `internal/runtime/durable/store.go` | `custom`/`customTruncated` fields, replay reset, `retainCustom(ev)` call in `apply`. 35 lines, no behavioural change to any existing path. | +| `internal/runtime/proposal/state.go` | `Restore` now REFUSES a revision < 1 and a strategy-less contract instead of silently repairing them. A revision of 0 cannot be minted by `State.Apply`; a proposal that compiles to nothing must not be accepted at all. | +| `internal/runtime/autonomy/driver.go` | `Run` restores a durable proposal for an objective whose task is still open, and refuses the run when the recorded state cannot be trusted. 28 lines. | +| `internal/runtime/autonomy/adaptive_proposal.go` | `maybeAdaptiveProposal` continues a restored proposal instead of forming a competing one; `proposalIdentity` split so the objective half is stable across processes; `invalidateProposalDerivedState` now shares `dropStaleDerivedProposalState` with the restore path (no duplicated logic). | +| `internal/ui/autonomous.go` | free-form answer state and its pure renderer; free-form submission folded into the SAME `resumeAutonomousAdaptiveProposal` the option path uses; empty submissions refused before the runtime is invoked. | +| `internal/ui/keys.go` | `i` opens the free-form editor, Enter commits, Esc discards, Backspace edits, ↑/↓ moves the question cursor. | +| `internal/ui/model.go` | `adaptiveFreeform`, `adaptiveFocus`, `adaptiveEditing`, `adaptiveDraft` — presentation state only. | +| `internal/ui/autonomous_test.go` | the fake driver records the fingerprint it was called with. | +| `execution_trace_test.go`, `r5_nonprogress_test.go`, `r3_target_proposal_test.go`, `phase12_portfolio_integration_test.go` | §D's defect. | + +No file was deleted. No architecture test was relaxed. No assertion was weakened +anywhere in this pass — the §D fix replaces un-waited reads of an asynchronous +projection with bounded waits for the same events. + +--- + +## C. The three deliverables + +### C.1 Cross-process recovery — and its evidence + +**The lifecycle, traced end to end.** Human decision → `State.Apply` (revision +advances, dependency graph invalidated) → `Driver.ledgerHumanDecision` → +`TaskStore.RecordCustomEvent(HUMAN_DECISION)` → `appendLocked` (single JSON line, +fsync) → `apply` → `retainCustom`. Snapshot: `State.Snapshot` → `ledgerProposalSnapshot` +→ `PROPOSAL_SNAPSHOT`. On restart: `NewTaskStore(sameDir).Open()` → `replayLocked` +→ `retainCustom` rebuilds the window → `Driver.RestoreProposalFromLedger` reads +it back → `decodeProposalSnapshot` → `reconcileRestoredProposal` → +`proposal.Restore` → `d.proposalState` → `continueRestoredProposal` → the +EXISTING approval gate. + +**What the restore establishes.** + +1. **Identity and revision** — proposal id (objective-derived half), revision, + and fingerprint all read from the journal. +2. **Decisions and provenance** — each decision is cross-checked against its + question and required to be `ProvenanceHuman`. +3. **Dependency graph** — `proposal.Restore` runs `Validate`; `continueRestoredProposal` + calls `SelectInteraction` and `Unanswered` over the reconstructed contract, so + the remaining questions are the ones the human actually left open. +4. **Rejection of malformed / incomplete / stale / tampered** — see the matrix below. +5. **Reconciliation** — objective must match verbatim; the durable task must + exist, be non-terminal, and record the same intent; the payload's own index + fields must agree with the decoded contract; the snapshot history must be + monotonic; and after compilation the targets must still resolve through the + canonical gateway. +6. **Invalidation of derived state** — `dropStaleDerivedProposalState` drops held + candidates, invalidates the compiled context and re-arms the grant gate. +7. **No authorization from a snapshot** — nothing grants anything; the restore is + state only, and the mutation still has to pass the approval gate (asserted). +8. **Completed work preserved** — a retired task restores nothing. +9. **Fail closed** — a recorded-but-untrustworthy proposal aborts the run with a + named reason rather than proceeding as though nothing had been recorded. + +**The round-trip test.** `TestProposalSurvivesProcessRestartThroughTheDurableStore` +constructs a driver over a real temp workspace with a real ledger, runs to the +adaptive-proposal park, copies the identity it must reproduce, sets +`first = nil`, then **closes the store and opens a new one over the same +directory** and **constructs a second driver**. The second driver restores from +`ledger.ndjson` and re-parks at the same revision with the same questions and +zero provider calls. `TestRestoredProposalCompilesAndReachesTheExistingApprovalGate` +does the same after a decision was recorded, and asserts the restored decision's +provenance, that the resumed run still stops at the approval gate, and that +nothing was written before approval. + +Observed: + +``` +ok github.com/PizenLabs/izen/internal/runtime/autonomy + TestProposalSurvivesProcessRestartThroughTheDurableStore PASS + TestRestoredProposalCompilesAndReachesTheExistingApprovalGate PASS + TestRestoreRefusesTamperedAndForeignSnapshots (6 sub-cases) PASS + TestRestoreRefusesASnapshotForAnotherObjective PASS + TestRestoreRefusesADecisionWithoutHumanLineage PASS + TestRestoreSkipsARetiredTask PASS + TestCustomEventReadBackSurvivesReplay PASS + TestCustomEventRetentionIsBounded PASS +``` + +The refusal matrix (each mutates the journal on disk, then reopens): + +| Mutation | Refusal names | +|---|---| +| tampered fingerprint | `fingerprint` | +| empty snapshot body | `no snapshot body` | +| undecodable body | `undecodable` | +| revision 0 (fingerprint re-stamped so the revision is the only defect) | `revision` | +| payload id ≠ contract id | `names proposal` | +| payload revision ≠ contract revision | `names revision` | +| id from a different objective | `this objective` | +| snapshot history that goes backwards | `not monotonic` | +| decision with its `HUMAN_DECISION` record removed | `HUMAN_DECISION` | + +`TestRestoreRefusesADecisionWithoutHumanLineage` is the load-bearing one: a +snapshot claiming a decision the journal never witnessed is refused, which is +what stops authorization being reconstructed from a snapshot alone. + +### C.2 Complete free-form TUI interaction — and its evidence + +The UI never mutates proposal state. `resumeAutonomousAdaptiveProposal` collects +the option ids AND the free-form text and routes both through +`Driver.ResumeAdaptiveProposal` — the same seam, with the same fingerprint +binding. Free-form answers replace any option selection for the same question, +because they are two spellings of one decision. + +Verified, in order: input validation (an empty submission is refused before the +runtime is invoked, and the park survives; a question that permits no free-form +never opens an editor); identity/revision binding (the runtime rejects a stale +fingerprint — asserted); dependency invalidation (the multiphase fixture's +boundary change invalides the dependent phase and its criterion and nothing +else); durable recording (`HUMAN_DECISION` in the journal, restored with +`ProvenanceHuman`); re-parking when decisions remain; and recompilation + +admission before execution resumes. + +Tests: `TestAutonomousAdaptiveProposalFreeFormSubmitsThroughTheDriver`, +`TestAutonomousAdaptiveProposalFreeFormDraftIsDiscarded`, +`TestMigrationFixture_FreeFormDecisionTraversesTheAuthoritativePath`, +`TestAdaptive_BlankFreeFormDecisionIsRefusedByTheAuthoritativePath`. + +The draft is bounded (4000 characters) and control-key input is rejected, so a +paste cannot become a human decision. A draft that was never committed never +reaches the runtime — Escape discards it and the previously committed answer is +untouched. + +Simple tasks stay simple: no proposal workflow and no confirmation screen was +added anywhere, and `TestAdaptive_DirectTaskKeepsItsPath` still passes. + +### C.3 Multi-phase integration fixture — and its trace + +The fixture is a static site whose inline script must be extracted and whose +stylesheet must be adapted. Two phases, two acceptance criteria, two +checkpoints, two material trade-offs, one risk. Two consequential questions: +the strategy, and the phase boundary. + +The trace, with the exact commands: + +``` +$ go test -count=1 -v -run 'TestMigrationFixture_' ./internal/runtime/autonomy/ +=== RUN TestMigrationFixture_IntegratedWorkflow + final state = completed reason="objective satisfied: changed; objective PROVEN by evidence" progress=PROVEN served=map[index.html:1 styles.css:1] +--- PASS: TestMigrationFixture_IntegratedWorkflow (0.61s) +=== RUN TestMigrationFixture_CompletedWorkIsNotRedoneAfterRestore + provider calls before=1 after=2 survived=true served=map[] +--- PASS +=== RUN TestMigrationFixture_DecisionChangeInvalidatesDependentPhase +--- PASS +=== RUN TestMigrationFixture_StaleSnapshotIsNotResumed +--- PASS +=== RUN TestMigrationFixture_FreeFormDecisionTraversesTheAuthoritativePath +--- PASS +ok github.com/PizenLabs/izen/internal/runtime/autonomy 1.80s +``` + +Step by step, all on the production path: + +1. **Discover and inspect the evidence.** The objective names no file; the + evidence-bound derivation binds `index.html` and `styles.css` and excludes + `readme.md`. Asserted. +2. **Produce a proposal.** Recommendation `phased-migration` (with its + trade-off), phases `extract-markup` (order 1, own boundary + checkpoint) and + `adapt-styles` (order 2, own boundary + checkpoint), two acceptance criteria + bound to source-level verification. All asserted off the boundary projection. + Zero provider calls before the human decides. +3. **Record the decisions.** `phase-order=phased-migration` → revision 2, re-park + (executes nothing, zero provider calls); `phase-boundary=after-markup` → + revision 3. +4. **Compile.** Strategy id, both phases' targets, both criteria, every decision + carrying `ProvenanceHuman`, two compiled constraints. The existing approval + gate holds the candidate; nothing was written. +5. **Execute an authorized phase.** Resume-approve; both phases land, each over + its own artifact (`served=map[index.html:1 styles.css:1]` — a mis-dispatch + would have thrown). +6. **Verify.** `VERIFICATION_RESULT` and `EXECUTION_COMMITTED` in the journal. +7. **Interrupt and restore.** The driver and its store are destroyed; a new pair + is opened over the same `ledger.ndjson`; `RestoreProposalFromLedger` restores + revision 3 and the human's `after-markup` answer. +8. **Continue only when valid.** The resumed run compiles the restored proposal + and stops at the approval gate again — the decision survived the boundary, + the authorization did not. A retired task restores nothing, and an artifact + whose phase already landed is not rewritten. +9. **Report the final state.** `completed`, "objective satisfied: changed; + objective PROVEN by evidence", `PROVEN`, durable task terminal. + +**The consequential decision change.** +`TestMigrationFixture_DecisionChangeInvalidatesDependentPhase` answers the +strategy, then the boundary, then changes the boundary from `after-markup` to +`after-styles` while the candidate is held. The change advances the revision; +the fingerprint changes; `InvalidatedElements()` is exactly +`[criterion:crit-styles, phase:adapt-styles]` — the dependent phase and its +criterion, and NOT `criterion:crit-markup` or `phase:extract-markup`, which is +what makes the invalidation dependency-derived rather than a blanket reset. The +held candidate is dropped, `ResumeApprove` no longer lands a byte, and the +compiled execution contract carries the new revision and fingerprint. + +**The CLI/runtime boundary.** +`TestCLIBench_MultiPhaseMigrationDerivesTheImplementationScope` drives the real +`izen` binary against a scripted OpenAI-compatible provider over the same +fixture. Assertions: no filename clarification park, both phases' artifacts +changed, `readme.md` byte-identical, `PROPOSAL_SNAPSHOT` present in +`.izen/runtime/ledger.ndjson`, and `izen resume` over the finished objective +refuses rather than re-running it. Observed outcome `unsubstantiated` with +`GATE_UNVERIFIED` — the truthful non-success for an unverified project-level +change, matching the existing benchmark. + +Fakes are identified: the `targetProvider` scripted model in the in-process +fixtures, and `NewScriptedProvider` in the CLI test. Everything else — CLI +binary, kernel, executor, gateway, verifier, ledger, completion authority — is +the real implementation. + +--- + +## D. Parallel test instability: reproduction, root cause, repair + +### D.1 The exact failing tests and assertions + +The previous phase reported "one failure each time in a DIFFERENT, +pre-existing timing-sensitive autonomy test" and attributed it to cross-package +contention. I did not accept that attribution without evidence. + +The two tests named were `TestExecutionTrace_CanonicalObjectiveSequence` +(`internal/runtime/autonomy/execution_trace_test.go`) and +`TestR5_CaseA_EndToEndMutationReachesProven` +(`internal/runtime/autonomy/r5_nonprogress_test.go`). The failing assertions: + +- `execution_trace_test.go:265` — `objective.accepted: no target resolution evidence` +- `r5_nonprogress_test.go:209` — `no continuation record reported PROVEN progress` + +### D.2 Reproduction + +Five full-suite runs with default package parallelism at baseline were green +(5/5), so the fault is load-sensitive, not deterministic. I reproduced it +deterministically by supplying the contention the default parallelism was +hiding — eight CPU burners while running the two tests: + +``` +$ for c in 1..8; do yes > /dev/null & done +$ for i in $(seq 1 15); do go test -count=1 -run 'TestR5_CaseA_EndToEndMutationReachesProven|TestExecutionTrace_CanonicalObjectiveSequence' ./internal/runtime/autonomy/; done +TOTAL FAILS = 9/15 +``` + +Failing assertions observed across those runs — both of the named ones: + +``` +execution_trace_test.go:265: objective.accepted: no target resolution evidence +r5_nonprogress_test.go:209: no continuation record reported PROVEN progress +``` + +`GOMAXPROCS=1` reproduces it even harder: the whole `autonomy` package failed +15/15 runs, and after fixing the two named tests it still failed 1/1 with three +more instances of the same defect that the default parallelism had never +surfaced: + +``` +phase12_portfolio_integration_test.go:295: no context telemetry was emitted +r3_target_proposal_test.go:343: no authorization record +execution_trace_test.go:372: the run terminated before a second computation was possible +``` + +So the prior phase's "cross-package contention" was a symptom report, not a root +cause, and it was incomplete: the same defect existed in four more places. + +### D.3 Root cause + +All six failures are the same defect: **an assertion reading an +asynchronously-populated bus projection without waiting for delivery.** + +`events.Bus.Publish` does not call a handler inline. It enqueues onto a +per-subscription channel and a `dispatchLoop` goroutine drains it. The bus +deliberately prioritises bounded batches of control events over telemetry, so +delivery of any event can legitimately lag the run that published it by an +unbounded, scheduling-dependent amount. The tests read the recorder +immediately after `Run`/`ResumeApprove`/`Execute` returned and asserted on +absence. Under CPU contention the dispatcher had not yet drained the queue, so +an event that WAS published read as absent. + +This is a defect in the assertions, not in the runtime: the correct statement is +"the run must publish X", and the observation of X must wait for X to be +observable. It is not cross-package shared state, not a filesystem path, not an +environment variable, not a goroutine leak, and not a test-order dependency — +the failing reads are all unsynchronised by construction. + +### D.4 Repair (no assertion weakened) + +Same class of fix in each place: keep the assertion, wait for the observation. + +| File | Before | After | +|---|---|---| +| `execution_trace_test.go` | `hasKind(EventTargetResolved)` and `hasKind(EventExecutionStarted)` read immediately | `waitForKind(5s, …)`, the helper the file already uses for `EventContextPrepared` | +| `execution_trace_test.go` | `collector.loopTransitions() == 0` | `collector.waitTransitions(1, 5s)` | +| `r5_nonprogress_test.go` | `rec.snapshot()` read immediately, then assert PROVEN | `waitForProven(rec)` — a bounded wait for the same condition — then assert PROVEN. `containsProven` is used by both, so the wait looks for exactly what is asserted. | +| `r3_target_proposal_test.go` | `r.Events.authorization()` | `r.Events.waitForAuthorization(5s)` | +| `phase12_portfolio_integration_test.go` | `len(prepared) == 0` under a held mutex | `waitContextPrepared(&mu, &prepared, 5s)` before the mutex is taken (taking it after would deadlock) | + +No `time.Sleep` was added to hide a failure, no `t.Parallel` was added, nothing +was serialised, no timeout was lengthened past what the file already used, and +no assertion was removed. `hasKind` and the `kinds` helper it used are now +deleted rather than left unused. + +### D.5 Repeated-run results after the fix + +``` +$ for i in $(seq 1 20); do go test -count=1 -run 'TestR5_CaseA_…|TestExecutionTrace_|TestR3_|TestPhase12_' ./internal/runtime/autonomy/; done +TOTAL CPU-LOAD FAILS = 0/20 # was 9/15 + +$ for i in $(seq 1 6); do GOMAXPROCS=1 go test -count=1 ./internal/runtime/autonomy/; done +TOTAL GOMAXPROCS=1 FAILS = 0/6 # was 15/15 (package-level) + +$ for i in $(seq 1 10); do GOMAXPROCS=1 go test -count=1 -run 'TestR5_CaseA_EndToEndMutationReachesProven' ./internal/runtime/autonomy/; done +TOTAL = 0/10 # was 10/10 +``` + +Before the fix, `GOMAXPROCS=1` on the package also surfaced a deadlock introduced +by an early draft of the phase-12 change; that draft was corrected before the +counts above, and the final form is the one reported. + +**Conclusion.** The previously reported instability is PRE-EXISTING (not +introduced by the previous phase's changes — none of those files touched these +tests, and the failure reproduces on the untouched baseline), it is a +test-side synchronisation defect, it has been repaired at the root, and the +repair is verified under the contention that produced it. Nothing remains +unresolved in this dimension. + +--- + +## E. Verification semantics preserved + +I did not touch rendered verification, and I did not add any browser +infrastructure. `internal/runtime/autonomy/rendered_verify.go` and its tests are +unchanged, so the existing claims stand exactly as reported: + +- deterministic DOM checks prove only the conditions they inspect; +- a screenshot does not prove visual quality; +- no claim of model-assessed visual judgement is made anywhere; +- a blocked check (`$hot`, no browser, missing entry) is `GATE_UNVERIFIED`, and + the existing `TestRendered_*` matrix still passes. + +The new code adds no path by which a skipped or failed verification could +satisfy an integrity obligation: a restored proposal compiles into the existing +request and passes through the same completion authority, which still refuses to +call an unverified project-level change PROVEN. + +--- + +## F. Validation commands and results + +All run from the repository root, on the final tree. + +| Command | Result | +|---|---| +| `go build ./...` | clean | +| `go vet ./...` | clean | +| `gofmt -l .` | no output | +| `golangci-lint run --timeout=10m ./internal/... ./test/...` | **0 issues** | +| `go test -count=1 -p 1 ./...` | **213 `ok`, 0 failures** (`final2-p1.log`) | +| `go test -count=1 ./...` (default parallelism) ×5 | **5/5 green, 0 failures** | +| `go test -race -count=1 ./internal/runtime/proposal/ ./internal/runtime/durable/` | ok | +| `go test -race -count=1 -run 'TestAdaptive_\|TestMigrationFixture_\|TestProposalSurvives\|TestRestored\|TestRestore\|TestCustomEvent' ./internal/runtime/autonomy/` | ok, 22.5s, no data race | +| `go test -race -count=1 -run TestAutonomousAdaptiveProposal ./internal/ui/` | ok | + +Note on `-p 1` being green: per the instruction, a green serialized suite is not +on its own evidence of reliability. The reliability evidence here is §D.5 — +the previously failing tests now pass 20/20 under the CPU contention that +produced the failures, and 10/10 and 6/6 under `GOMAXPROCS=1`. That is the same +condition the default-parallelism run creates; the serial run simply removes the +contention. + +--- + +## G. §6 mandatory regression checks + +| Check | Status | Evidence | +|---|---|---| +| Simple explicit mutations keep the direct path | **PASS** | `TestAdaptive_DirectTaskKeepsItsPath` (advisor wired; still the approval gate, no proposal compiled) | +| Broad objectives derive justified scopes without filename clarification | **PASS** | `TestAdaptive_InteractionDiffersByDecisionRequirement`, `TestOrchestration_B_ProjectRedesignDerivesMultiFileScope`, `TestCLIBench_ProjectRedesignDerivesMultiFileScope` | +| Proposal acceptance cannot bypass mutation authorization | **PASS** | `TestAdaptive_ProposalDoesNotWidenAuthority`, `TestMigrationFixture_IntegratedWorkflow` (approval gate, nothing written) | +| `$hot` cannot widen its scope | **PASS** | `TestOrchestration_J_HotSurfaceNeverWidensToProjectScope` (pre-existing) | +| Consequential decision changes invalidate dependent state | **PASS** | `TestMigrationFixture_DecisionChangeInvalidatesDependentPhase` (invalidated set is exactly the dependent phase + its criterion) | +| Stale approvals cannot authorize a changed candidate | **PASS** | same test: held candidate dropped, `CandidateHeld` false, `ResumeApprove` lands no byte, compiled fingerprint advanced | +| Cross-process restoration preserves decision provenance and authority boundaries | **PASS** | `TestProposalSurvivesProcessRestartThroughTheDurableStore`, `TestRestoredProposalCompilesAndReachesTheExistingApprovalGate`, `TestRestoreRefusesADecisionWithoutHumanLineage` | +| Skipped or failed verification cannot produce unsupported PROVEN outcomes | **PASS** | `TestRendered_*`, `TestOrchestration_B_ProjectRedesignUnverifiedIsNotProven` (pre-existing); the CLI migration test asserts `unsubstantiated`/`GATE_UNVERIFIED` | +| Existing kernel and execution architecture constraints remain enforced | **PASS** | `go vet ./...`, `golangci-lint` 0 issues, `internal/architecture`, `test/architecture`, `architecture_lock_test.go`, `test/forensics` all green | + +Diff hygiene, as required: no duplicated state ownership (the restore path reuses +`dropStaleDerivedProposalState` rather than copying the invalidation); no alternate +execution path (the only new consumer is `continueRestoredProposal`, which calls +the pre-existing `Compile`/`applyCompiledProposal`/approval gate); no relaxed +architecture test; no unrelated change; no leftover cleanup (the one stray +`os.Remove` in a draft was removed, and `hasKind` is deleted rather than +orphaned). + +--- + +## H. Remaining limitations + +1. **The restore is automatic in `Run` but only for a proposal a previous process + recorded.** It is not wired into `izen resume`, which has its own (unrelated) + durable-task continuation. If the same objective was run more than once in a + process, the restored proposal is the one the run-request identity maps to; a + second process reaching the objective on a later run index would re-form + rather than adopt. This is fail-safe (never fail-open) and is the documented + boundary of the deterministic identity scheme, but it is a real limit on how + many interruptions one objective survives. +2. **Retention is bounded (512 custom events per task).** For an objective that + generated more audit lineage than that, the window truncates and + `CustomEventsTruncated` reports it; the restore then refuses rather than + reads a non-newest tail. The full journal remains on disk and authoritative. +3. **Production advisors still ask no consequential question.** + `DefaultProposalAdvisor` recommends and asks nothing, so the consequential + path in production requires a domain/policy advisor. This was already the + documented boundary and is unchanged. +4. **Rendered verification remains policy-gated and DOM-only.** Documented, not + regressed. +5. **The parallel-instability evidence is load-drive reproduction, not + spontaneous reproduction on an idle machine.** On an idle machine the + default-parallelism suite passed 5/5 before the fix as well; the defect was + always latent. I am reporting it as root-caused and repaired rather than + claiming the machine is now immune to every scheduling pattern. + +--- + +## I. Definition of Done + +| # | Criterion | Verdict | +|---|---|---| +| 1 | Proposal state survives a real durable-store process restart and restores safely | **COMPLETE** — `TestProposalSurvivesProcessRestartThroughTheDurableStore` writes, destroys the owner, reopens the store on disk, restores revision + fingerprint + identity, and resumes. | +| 2 | Free-form TUI decisions traverse the same authoritative runtime path | **COMPLETE** — `resumeAutonomousAdaptiveProposal` routes free-form through `Driver.ResumeAdaptiveProposal` with the parked fingerprint; validated, durable, re-parks, recompiles. | +| 3 | Multi-phase fixture demonstrates proposal, execution, verification, interruption and recovery | **COMPLETE** — five tests, two real process boundaries, per-phase execution + verification + terminal `PROVEN`, plus the CLI-boundary counterpart. | +| 4 | Parallel instability reproduced and resolved, or documented with evidence | **COMPLETE** — reproduced on demand (9/15 under CPU load, 15/15 under `GOMAXPROCS=1`), root-caused to unsynchronised reads of asynchronous bus delivery, fixed in six places, verified 0 failures under the same contention. | +| 5 | Authorization, revision and truthful-completion invariants remain intact | **COMPLETE** — approval gate preserved on every path; stale candidate/approval cannot authorize a changed proposal; a restored proposal grants nothing; nothing written before approval in any new test; unverified objectives still refuse PROVEN. | +| 6 | All claimed validation results correspond to actual commands and observed results | **COMPLETE** — §F lists the exact commands and the counts actually observed; §C and §D quote actual test output. | + +**Overall: COMPLETE.** + +--- + +## J. Where the work lives + +``` +internal/runtime/durable/custom_events.go NEW audit-lineage read-back + bounded retention +internal/runtime/durable/store.go MOD retention fields, replay reset, retainCustom call +internal/runtime/proposal/state.go MOD Restore refuses revision<1 and strategy-less +internal/runtime/autonomy/proposal_recovery.go NEW cross-process restore + reconciliation +internal/runtime/autonomy/proposal_recovery_test.go NEW round trip + refusal matrix +internal/runtime/autonomy/proposal_multiphase_test.go NEW migration fixture +internal/runtime/autonomy/adaptive_proposal.go MOD restore-aware seam, shared invalidation +internal/runtime/autonomy/driver.go MOD restore + fail-closed refusal in Run +internal/runtime/autonomy/adaptive_proposal_test.go MOD blank free-form refused at the runtime seam +internal/runtime/autonomy/execution_trace_test.go MOD wait for delivery (2 places) +internal/runtime/autonomy/r5_nonprogress_test.go MOD wait for PROVEN classification +internal/runtime/autonomy/r3_target_proposal_test.go MOD wait for authorization record +internal/runtime/autonomy/phase12_portfolio_integration_test.go MOD wait for context telemetry +internal/ui/autonomous.go MOD free-form answers + pure renderer +internal/ui/keys.go MOD free-form editor key handling +internal/ui/model.go MOD free-form presentation state +internal/ui/autonomous_test.go MOD fake records the resume fingerprint +internal/ui/adaptive_proposal_test.go MOD free-form UI tests +test/benchmark/cli_multiphase_test.go NEW CLI-boundary migration test +test/benchmark/ledger_inspect.go NEW ledger reader for the boundary assertions +``` + +Dynamic Path. Static Authority. Truthful State Transition. diff --git a/docs/report/IZEN_EXECUTION_CONTRACT_AUDIT.md b/docs/report/IZEN_EXECUTION_CONTRACT_AUDIT.md new file mode 100644 index 00000000..ca21d5e5 --- /dev/null +++ b/docs/report/IZEN_EXECUTION_CONTRACT_AUDIT.md @@ -0,0 +1,293 @@ +# IZEN — Execution Contract Audit (black-box traces → first causes → fixes) + +Date: 2026-10-09 · Branch: `fix/execution` · Base commit `0ddadd6`. + +This audit was driven from the two supplied black-box traces, not from prior +hypotheses. Every claim below is backed by a reproducible test or a real +execution. The report distinguishes **OBSERVED**, **CAUSAL**, **FIXED**, +**VERIFIED** and **REMAINING**. + +--- + +## 0. The two traces, restated + +1. **Inside `/build`** — `/build` then `Create a new file named `zuru.md` with + the content "Hello everyone".` produced a planning/analysis result + (`Prepared findings`) and mutated nothing. +2. **Outside `/build` with `$prompt`** — `$prompt Create a new file named + `zuru.md` ...` reported `intent=ask`, then "proceeding as build", but the + execution context carried `command_mode=(not-carried)` and compiled to + `operation=MODIFY scope=UNRESOLVED target=DEFERRED`, discovered `README.md` + and `testfile.md`, and asked for target selection. + +--- + +## 1. First causal divergence — `$prompt` CREATE regression + +### OBSERVED +An explicitly named target (`` `zuru.md` ``) was never extracted, so a CREATE +compiled as a targetless MODIFY and discovery was asked to resolve a scope the +user had already stated. + +### CAUSAL +Both target-extraction authorities require a token boundary (start, whitespace +or comma) before a filename. A backtick (or straight/curly quote) is not one, so +the delimiter **hid the target**: + +- `internal/autonomy.extractTargets` (classifier) — regex anchored at + `(?:^|[\s,])`. +- `internal/execution/strategy.extractBareTargets` (the strategy gateway, which + is the `$prompt` driver's scope authority) — `strings.Trim` cutset omitted the + backtick, so `` `zuru.md` `` kept a trailing `` ` `` and `.md` never matched. + +With no target, `strategy.Select` fell through to repository-level planning +(backtick) and `DeriveObjectiveSemantics` produced +`MODIFY/UNRESOLVED/DEFERRED/REQUIRED`; discovery then observed the unrelated +markdown files and parked at candidate selection. This is the **first** +divergence; nothing downstream is at fault. + +### FIXED +- `internal/autonomy/intent.go` — `normalizeTargetDelimiters` replaces quote / + code-span delimiters with spaces before scanning (`extractTargets`). +- `internal/execution/strategy/selector.go` — `normalizeBareTargetDelimiters` + does the same for the prose-target pass (`extractBareTargets`). + +Both fixes touch only delimiters, never path characters, so a quoted name +presents exactly the boundaries of a bare one. + +### VERIFIED (real execution, local `qwen2.5-coder:7b`) +``` +IZEN_LIVE_FORENSICS=1 IZEN_BENCH_PROVIDER=ollama \ + go test ./test/realworld/ -run TestBenchmark_CreateCodeQuotedPrompt +``` +Trace (excerpt): +``` +EVIDENCE + mutation: target=zuru.md outcome=created artifact=true apply=true fs_changed=true +OBJECTIVE EVALUATIONS + PROVEN granted=true mutations=1 +termination: state=completed reason=objective satisfied: created; objective PROVEN by evidence +``` +The unrelated `testfile.md` was **not** mutated; no clarify boundary was opened. + +Deterministic driver-arm proof: +`internal/runtime/autonomy/code_quoted_create_test.go` +(`TestCreate_CodeQuotedTargetBindsBeforeDiscovery` — real workspace, real +kernel, real filesystem bytes, `objective=PROVEN`). + +Unit proofs: `internal/execution/strategy/code_quoted_target_test.go`, +`TestClassify_CodeQuotedTargetExtracted`. + +--- + +## 2. First causal divergence — `/build` ordinary prompt + +### OBSERVED +Inside `/build`, an ordinary prompt produced a read-only `Prepared findings` +artifact instead of a CREATE. + +### CAUSAL +`/build` was not represented as an execution authority. The parser grants +`ScopeProvenance` **only** for explicit `$prompt`/`$hot` directives, so a plain +goal inside `/build` parses as `ScopeNone`. `runRuntimePrompt` refuses mutation +without an authorizing scope and falls back to `runGatedLine`, whose +`IntentGateway.selectScopedStrategy` downgrades a mutation to a read-only +`RepositoryInvestigation` (`ArtifactKind="investigation"` → +`ClassifyArtifact` → `ArtifactInspection` → `"Prepared findings"`). + +This is a **wrong interaction contract**, at the `/build` seam, not a planner or +strategy bug. + +### FIXED +`internal/ui/runtime_cutover.go` — `runRuntimePrompt` now treats `/build` itself +as the execution contract: when the active mode is `ModeBuild` and no explicit +directive bound a scope, it binds the runtime-resolved mutation scope +(`ScopeDynamic`). It deliberately does **not** override an explicit `$hot` +(`ScopeDeclared`) binding, so `/build`, `$prompt` and `$hot` stay distinct at +the command layer and converge on the same mutation authority at admission. + +### VERIFIED +`internal/ui/build_interaction_contract_test.go`: +- `TestBuildModeOrdinaryPromptIsAnExecutionRequest` — drives the exact backtick + prompt in `/build` with `ScopeNone`; asserts mutation authority, strategy + `targeted_mutation` / artifact `create_file`, and executor target `zuru.md`. +- `TestBuildModePromptDoesNotOverrideHotfixScope` — a pre-bound `$hot` + (`ScopeDeclared`) is not replaced by the `/build` grant. + +The executor arm of `/build` is the same `RuntimeExecutor` proven by the +real-world CREATE benchmark in §1, so the request proven there is the request +`/build` now dispatches. + +--- + +## 3. Command-mode propagation (`command_mode=(not-carried)`) + +### OBSERVED +A `$prompt` run reported `command_mode=(not-carried)` even though the directive +was the authority that authorized it. + +### CAUSAL +`intentAxes` read `d.subcommand`, a **composition-time preflight policy** field +that the per-input `SetScope` binding never touches. The authoritative carried +value lives on the loop request (`d.req.Scope`) and was already populated for +`$prompt`/`$hot`. + +### FIXED +`internal/runtime/autonomy/objective_lifecycle.go` — `intentAxes` reads the +authoritative per-run `d.req.Scope` (falling back to the composition +`d.subcommand`, then the explicit `(not-carried)` marker). No parallel authority +state was added: `d.req.Scope` is the single carried representation of the +command surface; `d.subcommand` remains only the preflight policy selector. + +### VERIFIED +`internal/runtime/autonomy/command_mode_axis_test.go` +(`TestIntentAxes_CommandSurfaceIsCarried`) — `SetScope("$prompt")` / +`SetScope("$hot")` render the exact surface; an unbound run still reports +`(not-carried)`. + +--- + +## 4. `$hot` contract + +### OBSERVED / AUDITED +`$hot` is a directive handled before mode routing +(`dispatchDirectives` → `routeHotfixThroughAutonomy`), binding +`ScopeDeclared` and routing through the autonomy driver. It does not require +`$prompt`, and it is permitted inside `/build` (`ParseInWorkspace` validates the +directive against the active build workspace). + +### CAUSAL (no defect found) +Target resolution is deterministic (`IntentGateway`/`strategy.Select`); the model +never resolves a target. `ScopeDeclared` is a distinct authority from +`ScopeDynamic`, so the human-declared scope cannot be silently widened by +inference. + +### FIXED / REMAINING +No change required. Regression pinned by: +- `internal/ui/scope_control_test.go::TestControl_ExplicitScopeDirectives` + (`/build$hot refactor @target.go` → `ScopeDeclared` + `targeted_mutation`). +- `internal/ui/build_interaction_contract_test.go::TestBuildModePromptDoesNotOverrideHotfixScope` + (the `/build` grant never replaces a declared `$hot` scope). +- `internal/runtime/autonomy/behavior_test.go` (scope→grant vector). + +--- + +## 5. Cancellation + +### OBSERVED +The UI printed `[ ABORT ] Execution force-cancelled by user` but the provider +invocation appeared to continue. + +### CAUSAL +The cancellation chain is complete in code: `handleCtrlC` → +`handleEmergencyInterrupt` → `activeOp.Cancel()` + `cancelAllBackgroundContexts` ++ `streamCancel`; workers derive their context from +`m.operationContext()`; the executor closes the raw stream on `ctx.Done`; every +production provider builds its HTTP request with +`http.NewRequestWithContext`. Go cannot force-kill a goroutine that ignores its +context, so a hostile provider is the only way the call "continues"; real +providers do not. No first-cause defect was found in the cancellation chain. + +### VERIFIED (real provider termination) +``` +IZEN_LIVE_FORENSICS=1 go test ./test/live_r6/ \ + -run TestLiveR6_RealProviderCancellationIsBounded +``` +Result against the real local provider: +``` +status: aborted +termination: state=aborted reason=context cancelled +loop: executing → verifying (execution consumed: cancelled) → interpreting → aborted +workspace byte-identical (225 bytes) +no PROVEN objective +``` +Deterministic coverage: `internal/runtime/autonomy/r6_cancellation_test.go`, +`driver_test.go` (`TestDriver_ProviderIgnoresCancellation` documents the +honest boundary), `internal/execution/r6_cancellation_test.go`, +`execution_stream_test.go`. + +### Conversation lifecycle independence +`/new` does not terminate a run; the execution remains parked. This is pinned by +`internal/ui/execution_admission.go` and +`internal/ui/execution_lifecycle_e2e_test.go` / `execution_lifecycle_invariants_test.go` +(“remains parked”, “A conversation boundary does not terminate an execution +run.”). The driver has a separate run identity and terminal state from the +conversation. + +--- + +## 6. Awaiting-human / ambiguity barrier + +### OBSERVED +The CREATE example parked at candidate selection although it was not ambiguous. + +### CAUSAL +Not an ambiguity-barrier defect: the target had been lost upstream (§1), so the +runtime genuinely had no scope. The ambiguity barrier itself is correct and was +not weakened. + +### FIXED +Fixed by §1: the explicit target now reaches scope derivation before discovery, +so the CREATE path never enters discovery/candidate selection. + +### VERIFIED +- `internal/runtime/autonomy/acceptance_matrix_test.go::TestAcceptance_AmbiguousTargetParksBeforeProvider` + — an intentionally ambiguous MODIFY still parks with `AWAITING_HUMAN`, zero + provider calls, zero mutation. +- `internal/runtime/autonomy/code_quoted_create_test.go` — the explicit CREATE + never opens a clarify boundary. + +--- + +## Acceptance matrix status + +| Arm | Expected | Status | Evidence | +|---|---|---|---| +| `$prompt` CREATE (code-quoted) | CREATE/zuru.md/created/verified/PROVEN | **VERIFIED** | real benchmark trace + driver test | +| `/build` ordinary prompt | same semantics | **VERIFIED (admission)** | UI contract test + shared executor | +| `/build` + `$hot` | human-declared scope honored | **VERIFIED** | scope_control + build contract tests | +| Ambiguous MODIFY | `AWAITING_HUMAN`, no mutation | **VERIFIED** | acceptance matrix | +| Cancellation | provider terminated, aborted | **VERIFIED** | live R6 probe | +| Conversation reset `/new` | conversation reset, execution parked | **VERIFIED** | execution_admission + e2e | + +--- + +## REMAINING (disclosed, not fixed) + +1. **`/build` uses the single-shot executor, `$prompt` uses the bounded driver.** + They now converge on scope, operation and target at admission and on the + same `RuntimeExecutor` for the mutation, but `/build` does not emit the + driver's `objective=PROVEN` machine trace (it reports through + `ExecutionProof`). A future change could route `/build` ordinary prompts + through `runAutonomyRoutedCmdExplicit` for full trace convergence; this was + deliberately not done here to preserve existing `/build` behavior and tests. +2. **`d.subcommand` (preflight policy) is not synced from `SetScope`.** The + `command_mode` axis is now truthful from `d.req.Scope`, but the preflight + DecisionSurface policy still sees the composition-time value. This affects + recovery option shaping only, never authority or the command-mode axis. +3. **No live run of the interactive TUI `/build` path.** The `/build` fix is + proven at the UI seam deterministically and its executor request is the same + one proven live for `$prompt`; a full interactive live `/build` run was not + scripted. +4. **Two target-extraction implementations remain.** `internal/autonomy` and + `internal/execution/strategy` each own a delimiter-normalization helper. They + now agree by construction and by test, but the duplication is a standing + consolidation candidate. + +--- + +## Files changed + +``` +internal/autonomy/intent.go (+25) +internal/execution/strategy/selector.go (+24) +internal/runtime/autonomy/objective_lifecycle.go (command_mode axis) +internal/ui/runtime_cutover.go (+17) +test/realworld/benchmark_test.go (+58, live code-quoted arm) +internal/runtime/autonomy/code_quoted_create_test.go (new) +internal/runtime/autonomy/command_mode_axis_test.go (new) +internal/execution/strategy/code_quoted_target_test.go (new) +internal/ui/build_interaction_contract_test.go (new) +``` + +`go test ./...` → **exit 0**, 212 packages `ok`, 0 failures. diff --git a/docs/report/IZEN_EXECUTION_TOPOLOGY_AUDIT.md b/docs/report/IZEN_EXECUTION_TOPOLOGY_AUDIT.md new file mode 100644 index 00000000..ef1f77a7 --- /dev/null +++ b/docs/report/IZEN_EXECUTION_TOPOLOGY_AUDIT.md @@ -0,0 +1,770 @@ +# IZEN — Execution Topology Audit + +Date: 2026-10-09 · Branch: `fix/execution` · Base commit `0ddadd6`. + +Method: code-and-call-graph reconstruction, not documentation. Every claim carries a +`file:line`. Observations are separated from causal inference. Where a sub-investigation +could not use the graph index (`check_index_coverage`), that is disclosed in §14. + +This audit answers one question first: + +> **How many execution/orchestration owners currently exist, what does each own, and which +> one is actually authoritative for a single user request?** + +--- + +## 1. Executive finding + +``` +MULTIPLE EXECUTION OWNERS +``` + +For one user request there is a single **mutation authority** +(`internal/execution.RuntimeExecutor`) but **no single owner of the LLM execution +lifecycle**. The lifecycle is owned by different components depending on the command surface, +the active mode, and composition-time wiring: + +* `$prompt` (production, build workspace) → `internal/runtime/autonomy.Driver` drives a bounded + agent loop which calls the executor. `internal/runtime/autonomy/driver.go:587`, + `internal/runtime/autonomy/driver.go:2256`, `internal/runtime/autonomy/adapter.go:588`. +* `/build` ordinary prompt and staged per-task build → the UI calls the executor **directly**, + with no Driver. `internal/ui/runtime_cutover.go:350`, `internal/ui/runtime_cutover.go:301`, + `internal/ui/runtime_cutover.go:57`. +* `izen prompt` / `izen orchestrate` → `internal/cli.Stack` + `internal/runtime/orchestrator`, + a second, self-contained proposal loop that "never enters autonomy.Driver and never loops + internally". `cmd/izen/orchestrate.go:43`, `internal/cli/cli.go:397`, + `internal/runtime/orchestrator/engine.go:101`. +* `izen run` → `internal/app.Pipeline`, a third full loop (compile → generate → extract → + align → validate → repair → plan → substrate). `cmd/izen/runtime.go:198`, + `internal/app/pipeline.go:290`. +* `runtime/kernel` self-declares as "the single authoritative substrate that decides what + executes", and `runtime/headless.Runner` drives it end-to-end with no UI, but it is currently + reached only through `internal/kernelbridge` at the mutation-commit seam. + `runtime/kernel/doc.go:1-40`, `runtime/headless/headless.go:63`, `internal/kernelbridge/kernelbridge.go`. +* `internal/engine` (layers 0–4, `control/loop.go`, `retry.go`) is a fourth legacy loop, still + reachable from the TUI (`internal/ui/model_stream.go:9`, `internal/ui/commands.go:33`) and from + the executor's mutation path (`internal/execution/mutationset.go:8`, + `internal/execution/patch.go:18`). + +The repo's own tests document this state honestly. The kernel strangler lock says: + +> "the three legacy execution stacks keep working while the slice-by-slice traffic moves onto +> the kernel" +> `test/architecture/kernel_lock_test.go:38-42` + +and the P0-2 convergence test proves only that one **mutation** authority is wired +(`internal/architecture/phase1_authorization_boundary_test.go:323`, +`TestPhase1_SingleProductionExecutionAuthority`). It does **not** assert a single LLM-lifecycle +owner; the code it inspects has no concept of one. + +The result is exactly the failure class in the brief: planning, requirement derivation, context +compilation, provider invocation, artifact repair, authorization, verification and terminal +truth are split across components that are each individually defensible and collectively +incoherent. The observed symptoms follow causally (§13). + +--- + +## 2. Actual execution graph + +### 2.1 Flow A — `$prompt Create a new file named zuru.md …` + +```text +handleInput internal/ui/commands.go:187 + └─ intentFromInput internal/ui/intent_dispatch.go:31 + └─ dispatchDirectives internal/ui/intent_dispatch.go:213 + └─ routePromptDirective internal/ui/intent_dispatch.go:287 + ├─ admitNewExecutionRun internal/ui/execution_admission.go:155 + ├─ bindScopeProvenance(ScopeDynamic) internal/ui/intent_dispatch.go:329 + └─ runAutonomyRoutedCmdExplicit internal/ui/autonomy_route.go:56 + └─ autonomy.Engine.Decide internal/autonomy/engine.go (deterministic) + └─ dispatchAutonomyTrace internal/ui/autonomy_route.go:91 + └─ executeAutonomyWorkspace internal/ui/autonomy_route.go:163 + └─ executeAutonomyViaDriver internal/ui/autonomous.go:43 + └─ Driver.Run internal/runtime/autonomy/driver.go:587 + ├─ deriveObjectiveRequirements driver.go:833 + │ └─ InvokeRequirementPass internal/execution/requirement_pass.go:254 ← PROVIDER CALL #1 + └─ observeAndRun / loop driver.go:2146 / 2256 + └─ ExecutorAdapter.Execute adapter.go:383 + └─ RuntimeExecutor.Execute executor.go:1641 + └─ invokeMutation executor.go:2174/3329 + └─ invokeStream executor.go:4652 + └─ provider.ExecuteStream/Execute executor.go:4729/4737 ← PROVIDER CALL #2(+) + (recovery re-enters RuntimeExecuting) driver.go:2528 → provider +``` + +Terminal truth: `Driver` owns the loop and produces `RuntimeCompleted / +RuntimeUnsubstantiated / RuntimeAborted` (`internal/autonomy/runtime_loop.go:912-1112`); +`RuntimeExecutor` owns the per-invocation `ExecutionProof.Outcome` +(`internal/execution/executor.go:411-432`). Two terminal vocabularies, two owners. + +### 2.2 Flow B — `/build` then ordinary prompt + +```text +handleInput commands.go:187 + └─ parseModeShorthand commands.go:350 → setMode(ModeBuild) commands.go:1677 + └─ handleMessageContent commands.go:588 + └─ (ModeBuild) runRuntimePrompt commands.go:1015 → runtime_cutover.go:350 + └─ runRuntimeExecuteCmd runtime_cutover.go:39 + └─ m.executor.Execute runtime_cutover.go:57 + └─ invokeMutation … executor.go:2174 … (same executor, NO Driver) +``` + +Staged `/build` plan: + +```text +handleBuildRun commands.go:2885 + └─ dispatchStagedTask commands.go:2988 + └─ runRuntimeTaskRequest runtime_cutover.go:301 + └─ runRuntimeExecuteCmd runtime_cutover.go:57 → executor +``` + +`/build`'s queue advancement, verification and fix loop are owned by the **UI**: +`projectBuildQueueFromProof` (`internal/ui/gateway.go:780`) → `runTestEngine` +(`commands.go:3138`) → `testResultMsg` → `runFixCmd` (`update.go:1614`). This is a second, +UI-owned recovery loop that can re-invoke the provider via `streamCmd` +(`internal/ui/update.go:2044`). + +### 2.3 The other three top-level owners + +| Entry | Owner | Loop | Terminal owner | +|---|---|---|---| +| `$prompt` / `$hot` (autonomy wired) | `runtime/autonomy.Driver` | `driver.go:2256` | `internal/autonomy.RuntimeLoop` | +| `$prompt` fallback (autonomy nil) | `internal/ui` gateway | none (single dispatch) | `res.Proof.Outcome` | +| `/build` ordinary + staged | `internal/ui` + `RuntimeExecutor` | UI queue + executor internals | `ExecutionProof` + UI test loop | +| `izen prompt` / `orchestrate` | `cli.Stack` → `runtime/orchestrator` | `RunCycle` single cycle | `orchestrator.ExecutionResult` | +| `izen run` | `app.Pipeline` | `for {}` `pipeline.go:394` | `app.Result` | +| `runtime/headless` | `kernel.Engine` | `engine.Run` `runtime/kernel/engine.go` | `kernel.Outcome` | +| `izen` TUI `/ask` | `internal/ui` stream | role-fallback | `streamDoneMsg` | +| `/plan` | `modes/plan.Engine` | synthesis loop + retry | plan contract | +| `/investigate` | `modes/investigate.Engine` | dispatcher/toolrunner | investigation evidence | + +--- + +## 3. Execution-owner table + +`Provider Call` = can directly invoke an LLM. `Loop` = owns an iteration that can reach the +provider. `Retry` = can cause a second provider call. `Budget` = can set/override output +budget. `Repair` = can re-prompt on artifact/diagnostic failure. `Termination` = owns a +terminal state. `Authority` = what it is authoritative for. + +| Component | Provider Call | Loop | Retry | Budget | Repair | Termination | Authority | +|---|---:|---:|---:|---:|---:|---:|---| +| `runtime/autonomy.Driver` (`driver.go:587`) | indirect | **Y** `:2256` | **Y** | **Y** `:751` | **Y** `:2528` | **Y** loop state | agent lifecycle (on `$prompt`) | +| `internal/autonomy.RuntimeLoop` (`runtime_loop.go:809`) | no | **Y** `:948` | bounds only | **Y** `RunTokenBudget:647` | no | **Y** `:1079-1112` | loop progression/bounds | +| `internal/execution.RuntimeExecutor` (`executor.go:1641`) | **Y** `:4729` | **Y** per-target `:3429` | **Y** `:2186` | **Y** `llmstep` `:3403` | **Y** ingestion `:4798` | **Y** `ExecutionProof` | mutation + verification | +| `internal/ui` gateway (`gateway.go:44`) | no | no | no | no | no | **Y** projection | presentation of result | +| `internal/ui` build queue (`gateway.go:780`) | indirect | **Y** `:850` | **Y** `runFixCmd` | no | **Y** test-fix `update.go:1602` | **Y** build ledger | `/build` queue | +| `internal/ui` ASK stream (`stream.go:649`) | **Y** `role_fallback.go:133` | fallback | **Y** `role_fallback.go:152` | **Y** `stream.go:567` | no | **Y** stream | `/ask` chat | +| `app.Pipeline` (`pipeline.go:290`) | **Y** `pipeline.go:517` | **Y** `:394` | **Y** (`maxAttempts`, `maxRepairs`) | **Y** via prompt builder | **Y** `:408/:450` | **Y** `Result` | `izen run` | +| `cli.Stack` + `runtime/orchestrator` (`cli.go:397`, `engine.go:101`) | **Y** `cli.go:78` | single cycle | no | **Y** `TokenBudget` `cli.go:404` | no | **Y** `ExecutionResult` | `izen prompt/orchestrate` | +| `runtime/kernel.Engine` (`engine.go`) | no (no LLM capability) | **Y** | budget | **Y** `budget.go` | no | **Y** `Outcome` | claims to be *the* substrate | +| `runtime/headless.Runner` (`headless.go:63`) | no | **Y** | no | kernel | no | **Y** `kernel.Result` | headless kernel driver | +| `modes/plan.Engine` (`plan/engine.go:725`) | **Y** `:763/:828` | **Y** `:1735` | **Y** `maxSilentRetries=2` | **Y** `synthesis_step.go:44` | **Y** synthesis step | plan contract | `/plan` | +| `modes/investigate` (`dispatcher.go:169`, `toolrunner.go:201`) | **Y** | dispatcher | no | no | tool loop | evidence | `/investigate` | +| `internal/engine` (`control/loop.go`, `pipeline/engine.go:539`) | **Y** `engine.go:539` | **Y** decision loop | **Y** `retry.go` | no | no | node states | legacy layered engine | +| `ProviderRepairProposer` (`evidence_reasoner.go:175`) | **Y** | behavioral loop | no | **Y** 4096 | **Y** | no | behavioral repair | + +**Nine components can directly invoke the provider.** Five of them own a loop that can +re-invoke it. There is no component whose single responsibility is "the execution lifecycle". + +--- + +## 4. Provider-call inventory + +Two provider interfaces exist; only one is live. + +* Live: `ai.Provider` — `internal/ai/provider.go:545` (`Execute` `:547`, `ExecuteStream` `:548`). +* Dormant: `llm.LLMProvider` — `internal/llm/provider.go:104`; constructors are called only from + `internal/llm/*_test.go`, so its HTTP clients are dead in the current binary. + +Live concrete implementations: Ollama, Groq, Gemini, OpenCode, NineRouter, OpenRouter, Claude, +OpenAI (`internal/providers/*.go`), all wrapped by `contextcompiler.PreparedProvider` +(`internal/contextcompiler/provider.go:104/112`, wired at `compose.go:591`, `:604`). + +Every production LLM call site (non-test): + +| # | Site | Function | Reachable from | +|---|---|---|---| +| 1 | `cmd/izen/runtime.go:65` | `cliGenerator.Complete` | `izen run` (via `app.Pipeline`) | +| 2 | `cmd/izen/runtime.go:91` | `semanticExtractorAdapter.Extract` | `izen run` | +| 3 | `cmd/izen/orchestrate.go:181` | `orchestrateAdapter.Complete` | `izen prompt`, `izen orchestrate` | +| 4 | `internal/cli/cli.go:78` | `ProposalProviderCLI.GenerateProposal` | `cli.Stack` | +| 5 | `internal/runtime/orchestrator/engine.go:170` | `RunCycle` | `cli.Stack` | +| 6 | `internal/execution/executor.go:4409` | `InvokeManifestPass` | `$prompt`/autonomy (preflight) | +| 7 | `internal/execution/requirement_pass.go:254` | `InvokeRequirementPass` | **every `$prompt`/autonomy run** | +| 8 | `internal/execution/executor.go:4729/4737` | `invokeStream` | all execution paths | +| 9 | `internal/execution/artifact_step.go:225` | bounded full-artifact step | `/build`, `$prompt` mutations | +| 10 | `internal/execution/executor.go:3657` | patch-only mutation | `/build`, `$prompt` | +| 11 | `internal/execution/executor.go:4550` | `invokeReadOnly` | ask/read-only | +| 12 | `internal/execution/evidence_reasoner.go:175` | behavioral repair proposer | autonomy behavioral stage | +| 13 | `internal/runtime/autonomy/driver.go:2394` | Driver executing step | `$prompt` | +| 14 | `internal/runtime/autonomy/decomposition.go:1069` | DAG sub-task | autonomy | +| 15 | `internal/modes/investigate/dispatcher.go:169` | `llmClassify` | `/investigate` | +| 16 | `internal/modes/investigate/toolrunner.go:201` | `runDiagnose` | `/investigate $diagnose` | +| 17 | `internal/modes/plan/engine.go:725` | plan synthesis | `/plan` | +| 18 | `internal/ui/stream.go:649` → `role_fallback.go:133/152` | ASK stream + fallback | TUI `/ask` | +| 19 | `internal/ui/session_title.go:97` | title refinement | TUI (background) | +| 20 | `internal/ui/commands.go:4373` | `runDiagnoseCmd` | TUI `$diagnose` | +| 21 | `internal/ui/agents.go:781` | commit-message generation | TUI `/commit` | +| 22 | `internal/runtime/compose/compose.go:1226` → `engine/pipeline/client.go:28` → `layer3/worker.go:244` | layered pipeline | TUI facade | + +**Second-call engines (one logical action → ≥2 provider calls):** + +1. `invokeStream` falls back from `ExecuteStream` to `Execute` on stream failure + (`executor.go:4729` → `:4737`). +2. Full-artifact bounded continuation, up to `DefaultMaxContinuationSteps = 3` + (`internal/llmstep/step.go:56`; `artifact_step.go:195/225`). +3. Read-only continuation loop (`executor.go:4514/4550`). +4. Hallucinated-anchor retry, a second `invokeMutation` (`executor.go:2175-2202`). +5. `app.Pipeline` extraction/validation/alignment repair loop + (`internal/app/pipeline.go:394-469`, `maxAttempts`/`maxRepairs` `:226`). +6. Plan synthesis retry (`maxSilentRetries=2`, `internal/modes/plan/engine.go:1735-1755`). +7. UI cross-provider role fallback (`internal/ui/role_fallback.go:133/152`). +8. OpenRouter provider-internal retry (400-reasoning once; 429 backoff, + `internal/providers/openrouter.go:959-985`). +9. OpenRouter read-only tool loop, up to 4 turns (`internal/ai/toolloop.go:9/45`). +10. Autonomy DAG: one call per sub-task (`internal/runtime/autonomy/decomposition.go:1069`). + +--- + +## 5. Token-flow analysis + +For one `$prompt` CREATE, production wiring: + +```text +USER INPUT TOKENS : objective text (small) +RUNTIME PROMPT TOKENS : strict artifact contract + manifest/requirement prompts +CONTEXT TOKENS : executor compileContext (observeSnapshot) +OBJECTIVE TOKENS : requirement pass user message (objective + resolved targets) + +call #1 purpose: objective requirement derivation owner: autonomy.Driver → executor + budget: RequirementPassMaxTokens = 512 requirement_pass.go:47/232 + max_output: 512 actual: model-dependent reject ceiling 1024 requirement_pass.go:52 + → on non-JSON: "requirement derivation unavailable" objective_lifecycle.go:204 + (non-fatal; objective stays UNDERSTOOD, never REQUIREMENTS_DERIVED) + +call #2 purpose: manifest pass (only if preflight requires; usually 0 for CREATE) + budget: manifestPassMaxTokens = 200 executor.go:4298/4381 + max_output: 200 reject ceiling 512 + +call #3+ purpose: main CREATE generation owner: executor invokeMutation + budget: llmstep.ResolveMaxTokens (mutation requested 1200; ASK 1536) + executor.go:3403/3611, llmstep/step.go:48/68 + max_output: min(requested, provider ceiling, constrained 980) + continuations: up to 3 further calls artifact_step.go:195, step.go:56 + +call #n purpose: recovery re-generation (optional) owner: Driver repair → executor + up to MaxContractRecoveryAttempts = 2, recovery cycles 2, attempts 3 + driver.go:356, recovery.go:285/484 + +call #m purpose: behavioral repair (optional) owner: ProviderRepairProposer + budget: behaviorDefaultOutputBudget = 4096 evidence_reasoner.go:144/160 + +RETRY/FALLBACK TOKENS : own re-billing on every attempt (model context is re-projected) +REPAIR TOKENS : the model's semantic content can be rewritten by ingestion repair + without a provider call (see §6), so "repair" is not uniformly billed +``` + +**At least eight independent code paths construct or overwrite the per-request output +budget**, none subordinate to a single owner: + +| # | Setter | Site | +|---|---|---| +| 1 | `llmstep.ResolveMaxTokens` (documented "the ONE budget resolution") | `internal/llmstep/step.go:59-68` | +| 2 | executor `effectiveMaxOutput` / constrained clamp 980 | `internal/execution/executor.go:3312/3421` | +| 3 | context compiler overwrites `req.MaxTokens` | `internal/contextcompiler/request.go:189` | +| 4 | requirement pass fixed 512 | `internal/execution/requirement_pass.go:47/232` | +| 5 | manifest pass fixed 200 | `internal/execution/executor.go:4298/4381` | +| 6 | behavioral repair 4096 | `internal/execution/evidence_reasoner.go:144/160` | +| 7 | UI ASK `resolveASKMaxTokens` (4096 / casual 2048) | `internal/ui/stream.go:42/467/567` | +| 8 | plan synthesis `synthesisBudget` | `internal/modes/plan/synthesis_step.go:44`, `plan/engine.go:1462-1476` | +| 9 | driver `retry_with_explicit_budget` override | `internal/runtime/autonomy/driver.go:1218`, `adapter.go:446` | +| 10 | provider transport clamps (OpenRouter 4096/8192/980, OpenAI 4096/8192, Ollama 4096) | `internal/providers/openrouter.go:158-167`, `openai.go:79-83`, `ollama.go:100` | + +The intent of `llmstep` is explicit — *"This is the ONE budget resolution … modes and the +executor MUST NOT ship a separate model-output-budget resolver"* (`internal/llmstep/step.go:13`, +`:59-68`) — and the codebase violates it in at least five places. The `OUTPUT_EXHAUSTED` +continuation is therefore computed against budgets that different components disagree about. + +--- + +## 6. Proposal lifecycle + +### 6.1 Producer → transport → consumer + +```text +PRODUCER RuntimeExecutor.invokeMutation builds patch + diff + executor.go:3962-3968 (compileDiff), :2433-2438 (res.Diff = diffs[0]) + held at approval gate: executor.go:2465-2471 (pending map, PendingPatchID, + OutcomePendingApproval) + +TRANSPORT NO channel/event is the carrier. The gate emits + events.ApprovalRequired (graph.WaitApproval, executor.go:2473), which the UI + CONSUMES AS A LOG LINE ONLY (model.go:3411-3412). The actual payload travels + as the return value of a Bubble-Tea command closure: + runtime_cutover.go:56-62 → gatedExecutionMsg. + +CONSUMER executionResultUpdate internal/ui/gateway.go:378 + stages m.pendingProposals at gateway.go:641-650 (Diff: res.Diff) + enters approval at gateway.go:652 + rendered at view.go:511-513 +``` + +### 6.2 The orphaned wait + +`view.go:512` renders `"Waiting for proposal payload..."` whenever +`state == StateAwaitingApproval && pendingProposals[0].Diff == ""`. There is exactly **one** +non-test writer of `pendingProposals` (`gateway.go:641`), and it writes `Diff: res.Diff` once. +No code path ever fills `Diff` later. + +So if `res.Diff == ""`, the wait is **orphaned**: producer and consumer are the same +synchronous expression, and the consumer is waiting for a producer that will never run. + +### 6.3 Why `res.Diff` is empty while the patch is valid + +`res.Diff` is the best-effort compiled diff: + +* `compileDiff` (`executor.go:4256-4265`): *"a failure (output the pipeline cannot map) leaves + Diff empty"*; returns `""` if `changeset.NewPipeline().Run` errors or yields 0 changes. +* For a plain-text `create_file` against an **empty original**, `changeset.classifyBlock` + (`internal/changeset/extractor.go:300-333`) cannot anchor/match and returns + `ErrAmbiguousChange`/`ErrFullFileRejected`, so `compileDiff` returns `""`. +* The artifact parser accepts the same bytes through a *different* resolver: + `ResolveModifiedContent(original="", raw)` returns the trimmed, de-fenced input verbatim + (`internal/execution/patch.go:2145-2162`; called at `executor.go:3826`). +* Result: `res.PendingPatchID != ""` and `res.Diff == ""`. + +The executor's own candidate preview computes a fallback diff when `pm.diffs` is empty +(`wholeFileAdditionDiff`, `executor.go:3107-3126`), but `res.Diff` at `executor.go:2438` never +gets that treatment. The two approval projections disagree about the same artifact. + +### 6.4 The simultaneous "Connecting…" footer + +Staging the proposal opens a **new** foreground operation without finalizing it: + +* `gateway.go:651` `m.beginOperation(OpHotfix)` → `operation.go:196-203` sets + `executionStartedAt`, `activeOp`, `agentRunning = true`. +* `isExecuting()` keys off `activeOp != nil` (`internal/ui/footer.go:110-117`); + `firstTokenReceived` is false after the reset (`footer.go:388-405`); therefore + `renderExecutingFooter` prints `"Connecting... Ns [provider/model]"` (`footer.go:491-508`). +* The `StateWorkspacePatch` skeleton (`model.go:3361-3370`) is released only by + `MutationStarted` (`model.go:3382`) or `finalizeOperation` (`operation.go:281`); the proposal + branch calls neither. Hence `[mutation] Staging edit @ zuru.md...` persists too. + +This is a UI state machine reporting progress that does not correspond to any in-flight +execution — a direct instance of the "UI reports progress that does not correspond to the +authoritative execution state" failure in the brief. + +**Verdict for the `/build` stall: Case D (a proposal consumer with no producer), triggered by +a parser/diff-producer disagreement, with a Case-G presentation overlay (two UI state machines +not released at the gate).** It is not a slow provider call, not a second provider call, and not +a lost stream completion. + +--- + +## 7. Authorization lifecycle + +### 7.1 Sequence as implemented + +```text +CREATE authorizeExecutorApproval internal/ui/runtime_executor.go:101-124 + └─ AuthorizeBuildCandidate(humanApproved=true, candidateID) + └─ AuthorizeBuildCandidateContent core/authorization/engine.go:289 + ├─ SingleUse = !mutBudget.IsMultiStepPlan() engine.go:333; budget.go:119 + └─ mint MutationAuthorization (ID, CandidateID, Digest, +5m) engine.go:335-344 + +ATTACH m.executor.SetAuthorization(auth) runtime_executor.go:123 → executor.go:1015 + on Approve: patches.SetAuthorization executor.go:2614 + verifier.SetAuthorization executor.go:2622 + identity gate: x.auth.Authorizes(patchID, digest) executor.go:2496-2505 + +VALIDATE candidate identity only (no consume) +CONSUME (*Runner).run → markAuthConsumed(r.auth) internal/execution/runner.go:330 + (the ONLY non-test caller; auth.go:23-30) +GATE 1 FILE_MUTATE patch.go:634 checkAuthorization (validates, never consumes) +GATE 2 SHELL_EXEC runner.go:222 checkAuthorization +REFUSE runner.go:222 / patch.go:634 → auth.go:52-55 + "authorization token is single-use and has already been consumed" +``` + +### 7.2 What the token actually authorizes + +The token is minted as a **mutation** authorization (it is bound to the candidate ID and digest, +`engine.go:335-344`) but the only function that consumes it is the **shell** runner. Therefore, +in practice, a single-use token authorizes: + +> **one capability invocation — whichever of {file write, first shell command} reaches its gate +> first — not "the mutation", and not "the run".** + +### 7.3 The verification trap + +`Approve` applies the patch and then runs verification with the **same** token: + +1. Apply held patches: `executor.go:2656-2659` (`PatchManager.apply`, `checkAuthorization` only). +2. Verification loop: `verify.go:466-477`. +3. Each step: `runStep` creates a fresh `Runner` and attaches `v.auth` + (`verify.go:482-488`). +4. First verification command runs → `runner.go:330` `markAuthConsumed`. +5. Second verification command → `checkAuthorization` → `auth.go:52-55` → **"single-use and has + already been consumed"**. + +Because `runStep` treats any error as `Passed=false` (`verify.go:494-506`), a successful +mutation is reported as a **failed verification**, and the apply gate may roll back +(`executor.go:2616-2621` wires the verifier as the apply gate). This is exactly the observed +"authorization token … consumed" during verification after accept. + +### 7.4 Intended authority + +The code names two different intended authorities on the same object: + +* `core/authorization` intends: "authorization for **mutation**" (`engine.go:335-344`, + `CandidateID`/`CandidateDigest` binding). +* `internal/execution/runner.go` treats it as: "authorization for **one shell capability + invocation**" (`markAuthConsumed` at `:330`). + +The defect is not that the token is single-use; it is that **one single-use token is shared by +two capability families** (file write and shell exec) and by a multi-step verifier, so the +first consumer starves the rest. Do not change token semantics until the owner of +"the mutation operation" is unified — §11. + +--- + +## 8. `$prompt` vs `/build` + +They converge on scope/operation/target at admission and on the same mutation authority +(`RuntimeExecutor.Execute`) — but they do **not** converge on the execution lifecycle owner. + +| Stage | `$prompt` | `/build` ordinary prompt | Divergence | +|---|---|---|---| +| Admission | `routePromptDirective` → `ScopeDynamic` | `runRuntimePrompt` → `ScopeDynamic` (`runtime_cutover.go:353`) | converges | +| Decision | `autonomy.Engine.Decide` | none (straight to executor) | **diverges** | +| Loop owner | `Driver` (`driver.go:2256`) | none (single dispatch) | **diverges** | +| Objective contract | derived, requirement pass, PROVEN machine | none | **diverges** | +| Requirement pass | 1 provider call per run | **0** | **diverges** (budget + context differ) | +| Provider owner | `Driver → adapter → executor` | `UI → executor` | **diverges** | +| Mutation authority | `RuntimeExecutor` | `RuntimeExecutor` | converges | +| Verification | driver objective verify + executor apply gate | UI `runTestEngine` + executor apply gate | **diverges** | +| Terminal truth | `RuntimeLoop` state | `ExecutionProof` + UI build ledger | **diverges** | + +The predecessor audit already disclosed this: + +> "`/build` uses the single-shot executor, `$prompt` uses the bounded driver." +> `docs/report/IZEN_EXECUTION_CONTRACT_AUDIT.md:257-263` + +and + +> "A bare text typed while in `/build` is routed into autonomy (→ Driver), whereas +> `/build ` and staged `/build` go direct to the executor." +> (Subagent trace; see `internal/ui/commands.go:503-544`, `commands.go:1015`, +> `runtime_cutover.go:350`.) + +So the *same semantic request* reaches the model through two different prompt budgets, two +different context-compilation calls, two different recovery policies, and two different terminal +vocabularies. This is the primary answer to "why does `$prompt` work through one execution path +while `/build` behaves differently": **they are different runtimes that happen to share a +mutation primitive.** + +--- + +## 9. Root architectural defect + +**The smallest seam that explains the largest number of symptoms:** + +> Admission (the `IntentGateway` / `ExecuteRequest`) decides *intent, operation, scope, target, +> and mutation authority* — but **not the execution owner**. The lifecycle owner is selected +> afterwards by composition-time wiring (`m.autonomy != nil`, `m.autonomousDriver != nil`), +> presentation mode, and CLI subcommand — never by the contract. + +Evidence: + +* `runGatedLine` says "The UI NEVER decides the execution path … it submits the request and + renders the canonical runtime events" (`internal/ui/gateway.go:20-28`) — yet the decision + `if m.autonomy != nil` / `if m.autonomousDriver != nil` happens one layer up in + `intent_dispatch.go:321-325` and `autonomy_route.go:196-199`. +* `ExecuteRequest` (`internal/execution/executor.go` type, constructed at + `runtime_cutover.go:159-171`) carries scope, mode, prompt, targets, strategy, intent, evidence, + model — but **no execution-contract identity, no lifecycle owner, no budget envelope, no + termination policy**. +* There is no object that says "this request will be executed by exactly one owner with this + budget and this termination condition". Each caller re-derives its own. + +This seam explains, in one stroke: + +1. `$prompt` vs `/build` semantic divergence (§8). +2. Two terminal vocabularies (`RuntimeLoop` states vs `ExecutionProof.Outcome`). +3. The requirement pass executing on one path and not the other (§5). +4. Multiple budget resolvers (§5) — no contract carries the budget. +5. The authorization-consumed-during-verification trap (§7) — no contract says which capability + family the grant covers. +6. The orphaned `Diff` wait (§6) — the artifact/approval contract is not part of admission, so + producer and consumer disagree. +7. Four headless/TUI runtimes, each with its own admission front-end. + +Not a bug in any one of those components. A missing boundary between admission and execution. + +--- + +## 10. Target architecture + +Make **one** component the single execution owner for a request, downstream of one admission +seam: + +```text +HUMAN + │ + ▼ +COMMAND / WORKSPACE ────────► ADMISSION (IntentGateway + scope + grant) + │ │ + │ ▼ + │ EXECUTION CONTRACT + │ - request id / objective id + │ - operation · scope · target set + │ - authority (capability grant + candidate binding) + │ - budget envelope (input/output/tokens/attempts) + │ - termination policy (what "done" means) + │ │ + │ ▼ + │ SINGLE RUNTIME OWNER + │ - context compilation + │ - provider invocation (ONE seam) + │ - output budget (ONE resolver) + │ - proposal production + │ - continuation / recovery policy + │ - cancellation + │ - terminal truth + │ │ + │ ▼ + │ EXECUTION PLANE (subordinate capabilities) + │ - artifact parse/normalize/repair (pure) + │ - mutation apply (kernel capability) + │ - verification (capability) + │ - checkpoint / rollback + │ │ + └──────────────────────────────► TRUTHFUL TERMINAL STATE +``` + +Concretely for IZEN, the only viable owner is a **single driver** that subsumes the +`Driver` loop and the `RuntimeExecutor` internals behind one contract: + +* `$prompt`, `/build`, `$hot`, `izen prompt`, `izen orchestrate`, `izen run` all **construct the + same `ExecutionContract`** and hand it to **one** runtime. +* The runtime may call the model more than once (continuations, explicit recovery) — but every + call must be declared by the contract: an explicit owner, reason, budget, state transition, and + termination condition. +* `runtime/kernel` should be the **execution plane** (capabilities + evidence + truthful + terminal state), not a competing owner; its `Outcome` vocabulary should be the terminal + vocabulary. +* `internal/engine`, `internal/app.Pipeline`, `cli.Stack`, `runtime/orchestrator`, + `runtime/executor` (unreachable coordinator) and `runtime/scopeguard` should not be + independent lifecycle owners. + +The LLM is `stateless compute + untrusted proposal generator`. The runtime owns state, +authority, budget, workflow, execution, termination and truth. + +--- + +## 11. Migration boundary + +| Component | Disposition | Why | +|---|---|---| +| `internal/execution.RuntimeExecutor` | **merge into** the single runtime owner | It already owns provider + patch + verify; it is the natural core. | +| `internal/runtime/autonomy.Driver` | **remain**, become the *only* lifecycle owner | It owns the bounded loop and truthful terminal state; have it wrap the executor core instead of being an optional wrapper. | +| `internal/autonomy.RuntimeLoop` | **remain** (pure state machine/bounds) | Deterministic; no provider. Feed it from the contract. | +| `internal/ui` gateway/build-queue | **become subordinate** | It must project the runtime's state, not own queue/fix loops (`gateway.go:780`, `update.go:1602`). | +| `internal/execution/ingestion` | **become pure** | Deterministic; keep it, but gate repair by target type and contract, never content-only. | +| `core/authorization` + `execution/auth.go` | **remain**, redefine authority | One grant must cover the whole authorized mutation operation, or capability families need separate grants; do not reuse one single-use token across write+verify. | +| `internal/llmstep` budget resolver | **remain as the single budget owner** | Enforce the documented rule; delete the five bypass resolvers. | +| `internal/app.Pipeline` | **remove or become a thin client** of the runtime | It duplicates the full loop (`pipeline.go:290`). | +| `cli.Stack` + `runtime/orchestrator` | **remove or become a thin client** | It is a second proposal loop (`cli.go:397`, `engine.go:101`). | +| `runtime/orchestrator.PhaseManager` | **remain** (deterministic phase manager) | No provider of its own; it is a state manager. | +| `internal/runtime/executor` (unreachable coordinator) | **remove** | Documented unreachable rival (`executor.go:18-30`). | +| `internal/runtime/scopeguard` | **remain as subordinate** | Subordinate cursor per its own docs (`gateway.go:198`). | +| `internal/engine` layers + `control/loop.go` + `retry.go` | **remove after parity** | Legacy fourth loop. | +| `runtime/kernel` + `kernelbridge` | **remain**; promote to execution plane | It is the intended single substrate; stop letting it compete at the same level. | +| `modes/plan.Engine`, `modes/investigate` | **become capability stages** | They call the provider directly today; route them through the runtime's provider seam. | +| UI `StreamCallback`/`pendingProposals` | **become projections** | Always render from authoritative runtime events, never from a locally-produced empty payload. | + +--- + +## 12. Fixes (only after the above) + +These are the minimum corrections that follow causally from §9. They are ordered so that the +topology fix enables the symptom fixes. None is a new abstraction; each removes a duplicate +owner or makes an existing owner truthful. + +1. **Introduce `ExecutionContract` at admission** and make every entry point + (`$prompt`, `/build`, `$hot`, `izen prompt/orchestrate/run`) hand off to exactly one runtime. + This is the enabling change; without it the other fixes conflict. +2. **One budget owner.** Route requirement/manifest/behavioral/plan/UI budgets through + `llmstep.ResolveMaxTokens`; delete the duplicated resolvers. The contract carries the + envelope. +3. **One authorization scope per operation.** Either (a) issue one grant that covers apply + + all verification steps, or (b) issue distinct grants per capability family. Stop sharing one + single-use token between `patch.go` and `runner.go`. Fix by contract, not by making tokens + reusable. +4. **Define the grant's meaning explicitly** (mutation-operation vs one-capability-invocation) + and enforce it in `checkAuthorization`; keep single-use where intended. +5. **Make proposal production part of the artifact contract.** `res.Diff` must be derived by the + same resolver that produced the patch (use `wholeFileAdditionDiff` when `diffs` is empty), + so producer and consumer cannot disagree. Gate the proposal on a non-empty payload. +6. **Gate ingestion repair by target/contract, not content only.** `rule_html_tag_balance` must + not apply to a target whose contract is Markdown/plain; and never repair a payload that + literal-copied the instruction placeholder. +7. **Separate terminal vocabularies into one.** `RuntimeLoop` state is authoritative; project + `ExecutionProof.Outcome` into it rather than reporting both. +8. **Make the UI a pure projection.** Release the skeleton and the operation at the approval + gate; never `beginOperation` on a gate that is not executing; consume the runtime's + `ApprovalRequired` event as the authoritative gate signal. +9. **Delete the duplicate loops** (`app.Pipeline`, `cli.Stack`'s own loop, `internal/engine` + control loop, unreachable `runtime/executor`) once 1–8 hold. + +Do not increase `max_tokens`, do not remove artifact validation, do not remove verification, do +not make tokens reusable without §11, and do not merge `$prompt`/`/build`/`$hot` semantics — +those constraints remain. + +--- + +## 13. Causal explanation of the current evidence + +### A. `$prompt` CREATE (`objective requirement derivation unavailable / invalid JSON`, then `CREATE progress=UNDERSTOOD`, then `repair candidate accepted rule=rule_html_tag_balance`) + +1. **Who generated the original model output?** The provider, invoked by + `RuntimeExecutor.invokeStream` (`executor.go:4729/4737`), driven by the `Driver` through + `ExecutorAdapter.Execute` (`adapter.go:588`). +2. **Raw provider output?** Not persisted verbatim by this path beyond the ingestion trace. + The relevant fact is the model's response was treated as HTML-like by the ingestion + classifier. +3. **Who parsed it?** `ingestion.Process` → `Classify` (`internal/execution/ingestion/ingestion.go:39`, + `internal/execution/ingestion/classify.go:44`), called at `executor.go:4798` and `:5183`. +4. **Who decided it was malformed?** `ingestion.Classify` returned `ClassSyntaxInvalid`; + `ProposeRepair` produced a candidate (`repair.go:88`). +5. **Who invoked repair?** The executor accepted the candidate inside `invokeStream` + (`executor.go:4801-4812` and `:5198-5209`). +6. **Is repair deterministic?** Yes — pure string/stack logic, no provider, no I/O + (`internal/execution/ingestion/repair.go:3-8`, `:88-131`). +7. **Did repair invoke an LLM?** No. +8. **Did repair modify semantic content?** Yes — it appends synthetic closing tags, bounded by + `maxAddedTags=2` / `maxAddedBytesAbs=512` / 20% ratio (`repair.go:32-43`, `:295-321`). + It never rewrites; but for a Markdown file, appended HTML closers *are* semantic corruption. +9. **Is `rule_html_tag_balance` applicable to Markdown?** Not by design. The classifier is + **content-only and not extension/contract aware** (`classify.go:44`; `ProposeRepair` gates on + `isHTMLLike`, `repair.go:134-143`). It applies whenever the payload contains `<`, `>`, and a + tag-like sequence — including the literal instruction placeholder + `` (`executor.go:4256` is the diff; the + placeholder is defined at `internal/execution/parser.go:111-113` and injected into the CREATE + system prompt at `executor.go:3488-3489`). +10. **Was the `` wrapper produced by the model or the runtime?** + It originates in the runtime's **system instruction** (`parser.go:112`). The evidence is + consistent with the model **copying the instruction placeholder into its answer** (the + instruction explicitly shows that envelope as the required shape); the runtime then appended + `` via repair. Neither the runtime nor a parser injects the placeholder into written + bytes in normal operation (no non-test writer of that literal into content exists). The + concrete target `.md` is therefore content-corrupted at the ingestion seam, before the + artifact parser. +11. **How many provider calls for this request?** ≥2 (requirement pass + main generation), + plus up to 3 continuations, plus bounded recovery re-runs (§5). The requirement pass itself + returned a non-JSON payload → `ParseProposedRequirements` → `invalid JSON` / "payload carried + no requirement text" (`requirement_pass.go:102/109/129`), surfaced as + `"[objective] requirement derivation unavailable: …"` (`objective_lifecycle.go:204`). + That failure is non-fatal, which is why the progress still shows `UNDERSTOOD` + (`objective_conditions.go:55`) and never reaches `REQUIREMENTS_DERIVED`. +12. **Per-call token usage?** Not separately recorded by this path in the supplied trace; the + code records `ProviderExecution` per pass (`requirement_pass.go:270`), so the per-call + sequence is recoverable from the telemetry ledger rather than only as an aggregate. The + budgets differ per call (§5), which is the architectural point. + +### B. `/build` proposal-payload stall + +See §6. Concretely: producer and consumer are the same synchronous value; `compileDiff` returned +`""` for a valid create patch; `gateway.go:641` copied the empty diff into the proposal; no late +producer exists; `view.go:513` waits forever. The `Connecting…` footer is a second UI state +machine (operation) not released at the gate. This is **Case D**, with a presentation overlay, +not Case A/B/C/E/F. + +### C. Authorization consumed during verification + +See §7. The single-use token is consumed by the first verification shell command +(`runner.go:330`) and the second command is refused (`auth.go:52-55`). The token was minted as a +mutation grant but functions as a one-shell-invocation grant. The exact consumer is +`(*Runner).run` via `markAuthConsumed`. + +--- + +## 14. Acceptance-criteria answers + +1. **How many components can cause an LLM call?** Nine can directly; the authoritative list is + §4 (22 sites). The live provider seam is `internal/execution/executor.go:4729`. +2. **How many can cause a second LLM call?** Ten independent second-call engines (§4), plus the + executor's stream→non-stream fallback (`:4737`). +3. **Who owns the output-token budget?** No single owner. `llmstep.ResolveMaxTokens` + (`step.go:59`) is documented as the one resolver; at least eight other paths set or overwrite + the budget (§5). The contract does not carry it. +4. **Who owns provider cancellation?** The UI operation context in the TUI + (`operation.go:196-230`, `gateway.go:284`); the `Driver` run context and inter-step + select in autonomy (`driver.go:607`, `:2275`); `cli.Stack`'s `context.WithTimeout` + (`cmd/izen/orchestrate.go:103`). Three owners. +5. **Who owns proposal completion?** The producer is `RuntimeExecutor` (`executor.go:2465-2471`); + the consumer is `executionResultUpdate` (`gateway.go:641`). There is no completion signal — + the value is copied once; the UI-side event `ApprovalRequired` is logged, not consumed + (`model.go:3411-3412`). Broken ownership. +6. **Who owns artifact repair?** `internal/execution/ingestion` (deterministic) for transport + repair; `internal/runtime/autonomy/recovery.go` (provider re-prompt) for contract repair; the + UI test-fix loop for `/build` post-verification (`update.go:1602`). Three owners. +7. **Who owns authorization consumption?** `(*Runner).run` → `markAuthConsumed` + (`runner.go:330`), for shell execution. Minted as mutation authority + (`engine.go:335-344`). Mismatch. +8. **Who owns mutation?** `internal/execution.RuntimeExecutor` via `PatchManager` + (`executor.go:2656`, `patch.go`); the P0-2 test pins this + (`phase1_authorization_boundary_test.go:323`). This is the one genuinely converged owner. +9. **Who owns verification?** `executor.apply`'s verifier gate (`executor.go:2616-2621`, + `verify.go`), the `Driver`'s objective verify (`objective_verify.go:55`), and the UI build + test loop (`commands.go:3138`). Three owners. +10. **Who owns terminal truth?** `internal/autonomy.RuntimeLoop` (autonomy), + `ExecutionProof.Outcome` (executor), `app.Result` (pipeline), + `orchestrator.ExecutionResult` (CLI), `kernel.Outcome` (kernel). Five vocabularies. +11. **Why does `$prompt` differ from `/build`?** Different lifecycle owner: `Driver` vs direct + executor (§8). +12. **Is there more than one agent loop?** Yes. At least: `Driver` (`driver.go:2256`), + `RuntimeExecutor` internal loops (`executor.go:3429`, `artifact_step.go:195`), + `app.Pipeline` (`pipeline.go:394`), `cli.Stack`/`RunCycle` (`engine.go:101`), + `engine/control/loop.go`, plan synthesis (`plan/engine.go:1735`), UI build queue + (`gateway.go:780`), autonomy DAG (`decomposition.go:1069`). +13. **Is any subsystem secretly acting as a second agent?** Yes: the UI build queue runs a + provider-backed fix loop (`update.go:1602-1615` → `update.go:2044`); `app.Pipeline` is a + full agent loop reachable via `izen run`; `runtime/kernel` advertises itself as the sole + substrate while remaining a parallel owner. +14. **Minimum architectural correction?** Introduce the `ExecutionContract` at admission and + make all entry points hand off to one runtime owner that owns budget, provider seam, + proposal, recovery and terminal truth; then subordinate/delete the duplicate loops and + duplicate budget resolvers (§10–§12). + +--- + +## 15. Method, evidence and limitations + +* Evidence was gathered by exact source reads and ripgrep on branch `fix/execution` + (base `0ddadd6`), plus the codebase-memory graph for structure. +* Two sub-investigations (the `$prompt` trace and the `/build` trace) ran **without** the + codebase-memory MCP tools and therefore did **not** run `check_index_coverage`; their claims + are source-verified (grep/read) but not coverage-validated. The citied files are listed in + those reports; a coverage batch over `internal/ui/*`, `internal/execution/*`, + `internal/runtime/autonomy/*`, `internal/autonomy/*`, `internal/app/*`, `internal/cli/*`, + `internal/modes/*`, `runtime/*` is recommended before treating §4/§8 as exhaustive. +* Provider-call counts are static upper bounds derived from loop bounds, not runtime + measurements; aggregate token numbers in the supplied traces cannot be decomposed per call + without the telemetry ledger (`ProviderExecution` events are emitted per pass). +* This audit did not modify any code. It intentionally does not fix symptoms; §12 names fixes + only after ownership is established. + +--- + +## 16. Conclusion + +IZEN currently has **multiple execution owners** over one request. The mutation write is the only +converged authority. Everything else — requirement derivation, context/budget compilation, +provider invocation, proposal production, artifact repair, retry/recovery, verification and +terminal truth — is owned by different components that each individually work and collectively +contradict. The smallest correction is to make admission emit a single `ExecutionContract` and +put exactly one runtime owner behind it; the observed `$prompt`/`/build` divergences, +the orphaned proposal wait, the consumed-token verification failure and the fragmented output +budget all follow from that missing boundary. diff --git a/docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE.md b/docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE.md new file mode 100644 index 00000000..05ffd394 --- /dev/null +++ b/docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE.md @@ -0,0 +1,489 @@ +# IZEN — Execution Topology Convergence + +Date: 2026-10-09 · Branch: `fix/execution` · Base for this change: `d5fe1f2`. +Evidence baseline: `docs/report/IZEN_EXECUTION_TOPOLOGY_AUDIT.md` (branch base `0ddadd6`). + +This report records an **implementation**, not another audit. Every finding from the +baseline audit was re-verified against the current source (HEAD `d5fe1f2`) before any +edit. The correction is deliberately staged: it establishes one authoritative runtime +owner for the two primary TUI execution surfaces, corrects the three causally-linked +execution-plane defects, and explicitly fences and discloses the remaining owners. + +Distinctions used throughout: + +* **VERIFIED** — proven by a test that ran on this branch, or by a live provider run. +* **OBSERVED** — read in the current source, with a citation. +* **REMAINING** — a known deviation, disclosed honestly and not claimed converged. + +--- + +## 1. Root cause + +**OBSERVED → ROOT CAUSE → OWNERSHIP CHANGE → REGRESSION TEST → EXECUTION EVIDENCE** + +The audit's smallest seam was: admission resolves intent, operation, scope, target and +mutation authority, but **not the execution lifecycle owner**. The owner was selected +afterwards by command surface and composition wiring. + +Re-verified at HEAD: `ExecuteRequest` (`internal/execution/executor.go:85`) carries +scope, mode, prompt, targets, strategy, intent, evidence and model, but no field that +selects the lifecycle owner. The owner is still chosen by `if m.autonomy != nil` / +`if m.autonomousDriver != nil` in `internal/ui/autonomy_route.go:196` and by the +`/build` branch of `internal/ui/commands.go:1015` → `runRuntimePrompt`. + +The concrete defect corrected here: + +> `$prompt` and `$hot` entered the bounded autonomy `Driver` (the lifecycle owner), +> while an **ordinary `/build` prompt dispatched `RuntimeExecutor.Execute` directly** +> from the UI (`runtime_cutover.go` → `runRuntimeExecuteCmd`), so the UI composition +> owned a second LLM execution lifecycle. + +Ownership change: `/build` ordinary prompts now cross the **same** admission → +autonomy-party seam as `$prompt` when the runtime is wired, and the command surface is +carried explicitly (`"$build"`) so `/build` remains distinct from `$prompt`. + +--- + +## 2. Before / after execution graph (actual call graph) + +### Before + +```text +$prompt + handleInput → dispatchDirectives → routePromptDirective commands.cs / intent_dispatch.go:287 + → admitNewExecutionRun / bindScopeProvenance(ScopeDynamic) + → runAutonomyRoutedCmdExplicit autonomy_route.go:56 + → autonomy.Engine.Decide → dispatchAutonomyTrace + → executeAutonomyWorkspace autonomy_route.go:163 + → executeAutonomyViaDriver → Driver.Run autonomous.go:43 / driver.go:587 + → ExecutorAdapter.Execute → RuntimeExecutor.Execute adapter.go:588 / executor.go:1641 + → invokeMutation → invokeStream → provider.ExecuteStream + ⇒ LIFE-OWNER: Driver + +/build ordinary prompt (SECOND, COMPETING OWNER) + handleInput → handleMessageContent → runRuntimePrompt commands.go:1015 / runtime_cutover.go:350 + → runRuntimeExecuteCmd → RuntimeExecutor.Execute (DIRECT) runtime_cutover.go:57 + → invokeMutation → invokeStream → provider.ExecuteStream + ⇒ LIFE-OWNER: UI + executor (no Driver; no objective lifecycle) +``` + +Terminal truth was produced by two different owners on the two paths (`RuntimeLoop` +state vs `ExecutionProof.Outcome`), and `/build` performed no requirement pass and no +run-level budget envelope. + +### After + +```text +$prompt /build ordinary prompt $hot + | | | + routePromptDirective runRuntimePrompt routeHotfixThroughAutonomy + (bind ScopeDynamic, (bind ScopeDynamic, (bind ScopeDeclared, + surface="$prompt") surface="$build", surface="$hot") + | admitNewExecutionRun) | + +------------------------------+-------------------------------+ + | + runAutonomyRoutedCmdExplicit autonomy_route.go:56 + | + autonomy.Engine.Decide → dispatchAutonomyTrace + | + executeAutonomyWorkspace (handoff context, mode) + | + executeAutonomyViaDriver → Driver.Run autonomous.go:43 / driver.go:587 + | (owns loop, continuation, budget bound, terminal truth) + ExecutorAdapter.Execute → RuntimeExecutor.Execute adapter.go:588 / executor.go:1655 + | (execution plane: context, provider seam, patch, verify) + invokeMutation → invokeStream → provider.ExecuteStream +``` + +`/build` and `$prompt` now share the execution **authority** (both `ScopeDynamic`, +`objective_completion.go:309` maps `$build` → `ScopeDynamic`) while remaining distinct +command **surfaces** (`d.req.Scope` carries the literal surface; the `command_mode` +axis renders it). `$hot` remains `ScopeDeclared`. + +The only remaining direct-executor edge from the UI is the explicit **harness +fallback** (`m.autonomy == nil || m.autonomousDriver == nil`), which starts no loop and +is pinned by `TestExecutionOwnership_DirectExecutorDispatchIsFencedToHarnessFallback`. + +--- + +## 3. Authoritative runtime owner + +**VERIFIED** — the bounded autonomy `Driver` is the lifecycle owner for the migrated +paths. + +* Entry point: `internal/runtime/autonomy/driver.go:587` `func (d *Driver) Run(ctx, objective)`. +* The loop: `driver.go` (`for !d.loop.State().IsTerminal()`), with per-step + `RuntimeDeciding/Executing/Interpreting/Recovering` transitions. +* It invokes the provider only through its execution-plane capability: + `internal/runtime/autonomy/adapter.go:588` `a.executor.Execute(ctx, execReq)`. +* The UI reaches it through the structural interface `autonomousDriver` + (`internal/ui/autonomous.go:67`) — the UI never imports the runtime autonomy package. + +The migration is **not** a new runtime abstraction: it is the existing proven Driver, +now also reached by `/build`. + +--- + +## 4. Execution contract: fields, invariants, authoritative writers + +The authoritative contract in the current architecture is the pair +(`autonomy.LoopRequest`, `execution.ExecuteRequest`) resolved once by admission and +carried to the runtime. This change adds the missing **owner and surface** identity at +the admission seam rather than inventing a parallel type. + +| Field | Meaning | Authoritative writer | Transition rule | +|---|---|---|---| +| `ExecuteRequest.ScopeProvenance` | mutation authority (`ScopeNone/Dynamic/Declared`) | admission (`bindScopeProvenance`) | replaced per input; never inferred downstream | +| `ExecuteRequest.Strategy` | execution path decision | `IntentGateway.SelectStrategy` / autonomy | set once before dispatch | +| `ExecuteRequest.Targets` | resolved target set | admission | never re-derived by the model | +| `ExecuteRequest.MaxOutputTokens` | requested output budget | driver/adapter | clamped by `llmstep.ResolveMaxTokens` | +| `LoopRequest.Scope` (`d.req.Scope`) | command surface that authorized the run | UI `SetScope` (`$prompt`/`$build`/`$hot`) | copied verbatim to the proof/axis | +| `d.executionSurface` (UI) | recorded surface at the entry point | each entry point | consumed by `runAutonomousDriver` | +| `ContractID` (`execution.contract.go`) | immutable intent identity | `ContractRegistry.Resolve` | content-addressed; retries increment `AttemptID` | +| `MutationAuthorization.Scope` (new) | capability family / consumption boundary | `AuthorizeBuildCandidateContent` | consumed once per operation | + +New field added by this change: `MutationAuthorization.Scope` +(`internal/core/authorization/types.go:70`) and `model.executionSurface` +(`internal/ui/model.go`). Both have a single writer and a named consumption/transition +rule; neither duplicates an existing owner field. + +Invariant enforced: a downstream phase may not re-select intent, operation, target, +authority or budget. If it discovers the contract incomplete, it returns a typed +failure (e.g. `ErrArtifactPlaceholderEcho`, `ErrEmptyProposalPayload`, +`ErrArtifactRejected`) rather than continuing under a competing interpretation. + +--- + +## 5. Removed or subordinated owners + +| Component | Disposition in this change | Reason | +|---|---|---| +| UI direct-executor `/build` dispatch | **subordinated**: now the Driver path when wired | it owned a second lifecycle; no loop/continuation/objective policy | +| Driver | **confirmed** as lifecycle owner | owns bounded loop, budget bound, recovery, terminal truth | +| `RuntimeExecutor` | **confirmed** as execution plane | provider seam, artifact boundary, patch/apply/verify capability | +| `MutationAuthorization` per-shell consumption | **corrected** (scoped) | one grant no longer starves a multi-step verifier | +| `runAutonomyRoutedCmdExplicit` | **confirmed** as the single admission→runtime seam | all three surfaces cross it | +| `app.Pipeline`, `cli.Stack`/`orchestrator.RunCycle`, `internal/engine` layered loop, `runtime/kernel` competing substrate | **not touched; explicitly disclosed as remaining** | out of the migrated TUI surface; see §14 | + +**Not** done: no new planner, driver, retry loop, or compatibility executor was added. +The only new types are two value fields with explicit consumption rules. + +--- + +## 6. Provider-call inventory (as it stands) + +Live provider seam (the only one the migrated paths use): `invokeStream` +(`internal/execution/executor.go:4737`) → `provider.ExecuteStream` (`:`, with a +non-stream `provider.Execute` fallback). It is reached by exactly one logical route on +the migrated paths: `Driver → ExecutorAdapter.Execute → RuntimeExecutor.Execute → +invokeMutation/invokeReadOnly → invokeStream`. + +Every provider call is now an explicit lifecycle step under the Driver: + +| Call | Trigger | Budget policy | +|---|---|---| +| requirement pass | `Driver.Run` objective derivation | fixed 512 (unchanged; see §7 debt) | +| main generation / bounded continuation | `invokeMutation` / `invokeArtifactBoundedStep` | `effectiveMaxOutput` → `llmstep.ResolveMaxTokens` | +| bounded full-artifact continuation (≤3) | `llmstep.DefaultMaxContinuationSteps` | same authoritative clamp | +| read-only continuation (≤3) | `invokeReadOnly` | same authoritative clamp | +| recovery re-prompt | `Driver` recovery, bounded by `MaxRecoveryChainDepth` | `Driver` explicit budget override | + +The live run below reports **2 provider calls** for the CREATE scenario +(requirement pass + main generation), with the summary budgets `call #1 requested=512`, +`call #2 requested=4096` and `effective=unobserved` — the latter is an honest gap, not a +claim. + +Remaining direct provider sites outside the migrated TUI surface are disclosed in §14. + +--- + +## 7. Budget authority + +Status: **partially corrected; ownership documented, not fully unified.** + +* The Driver derives and owns the run-level bound: + `driver.go` `d.loop.WidenBounds(…, autonomy.RunTokenBudget(d.resolved.Profile.MaxOutputTokens, …), …)`. +* The execution plane resolves and enforces the per-invocation effective budget: + `effectiveMaxOutput` → `llmstep.ResolveMaxTokens(model, requested)` (`executor.go` + in `invokeMutation`; same in `invokeReadOnly`). `llmstep` remains the documented + single clamp. +* Requested/effective/actual accounting exists: `ModelInvocation` carries + `TokenInput/TokenOutput/Known`; the live forensics summary renders + `budgets: call #N requested=… effective=…` and aggregate tokens. +* Actual usage is aggregated by the Driver (`AggregatedUsage`), with a `Known` flag so + "unavailable" is never conflated with zero. + +**REMAINING (not corrected here):** the audit's five bypass resolvers still exist — +requirement pass (`512`), manifest pass (`200`), behavioral repair (`4096`), UI ASK +(`resolveASKMaxTokens`), and plan synthesis. Unifying them behind one owned envelope is +a larger change that touches non-migrated surfaces (`/plan`, `/ask`, behavioral stage); +it is disclosed rather than half-done. The run-level bound already prevents an +unbounded sequence: the loop terminates on the token bound. + +--- + +## 8. Proposal lifecycle + +**OBSERVED → ROOT CAUSE → OWNERSHIP CHANGE → REGRESSION TEST → EXECUTION EVIDENCE** + +* Producer: `RuntimeExecutor` `compileDiff` (`executor.go:4341`) could legitimately + return `""` for a CREATE (no baseline to anchor), yet the pending candidate was still + staged with `res.Diff == ""`. The UI consumer then waited forever for a payload no + producer filled. +* Correction (`executor.go` in `Execute`): when the compiled diff is empty, derive the + whole-file-addition diff from the **same artifact bytes** the apply writes + (`wholeFileAdditionDiff`), exactly as the candidate preview already does; if still no + payload, fail **explicitly** with `ErrEmptyProposalPayload` before entering + `OutcomePendingApproval`. +* Producer, consumer, completion, failure and cancellation now agree: the proposal + payload is non-empty by construction, or the execution terminates with a typed + failure. The UI renders `res.Diff` — it no longer maintains a competing producer. + +Regression tests: `TestCreateProposalCarriesWholeFileDiff` (asserts a whole-file-addition +payload carrying `+Hello everyone`), plus the existing approval/verification tests. + +--- + +## 9. Authorization lifecycle + +**OBSERVED → ROOT CAUSE → OWNERSHIP CHANGE → REGRESSION TEST → EXECUTION EVIDENCE** + +* Observed: `Runner.run` consumed the single-use token on every guarded invocation + (`runner.go:330`); `Verifier.runStep` attached the **same** mutation token to every + verification step. The first `sh`-command consumed it and the second was refused + ("single-use and has already been consumed"), turning a successful mutation into a + failed verification. +* Root cause: one grant shared by two capability families (file write + shell + verification) and by a multi-step verifier — a consumption-boundary mismatch, not a + need to make tokens reusable. +* Correction: `MutationAuthorization.Scope` (`types.go`). The engine mints + `ScopeMutationOperation` for a build candidate (`engine.go:343`). The runner consumes + only `ConsumesPerInvocation()` grants (`runner.go`); a mutation-operation grant is + consumed **once**, at the operation's terminal state, via a `defer` in `Approve` + (`executor.go:2523`). Single-use semantics are preserved and now scoped. +* Human approval and the candidate identity/digest binding (`Authorizes`) are unchanged; + verification still cannot start without a valid, unexpired grant, and a rejected + candidate never applies. + +Regression tests: `TestMutationOperationGrantNotConsumedByRunner`, +`TestCapabilityInvocationGrantRemainsSingleUse`, +`TestMutationOperationGrantSurvivesMultiStepVerification` (two-step verifier passes under +one grant). + +--- + +## 10. Artifact integrity + +**OBSERVED → ROOT CAUSE → OWNERSHIP CHANGE → REGRESSION TEST → EXECUTION EVIDENCE** + +* Observed: a `$prompt` CREATE of a Markdown target was corrupted by + `rule_html_tag_balance` appending synthetic closing tags. +* Root cause (causal chain): the CREATE system instruction wrapped its content slot in + angle brackets — `` — making it + indistinguishable from an HTML element. A model that copied the placeholder produced a + payload the **content-only** ingestion classifier judged as an unbalanced HTML tag and + "repaired". +* Correction, at the actual owners: + 1. `StrictArtifactContractInstruction` (`parser.go:140`) no longer angle-brackets any + slot; placeholders are square-bracketed and cannot be read as markup. + 2. A placeholder echo is an **explicit** rejection: `isArtifactPlaceholderEcho` + (`parser.go:115`), enforced in `invokeMutation` → `ErrArtifactPlaceholderEcho` + (wrapping `ErrArtifactRejected`) before validation or any approval surface. + 3. Repair is contract-aware: `ingestion.ProcessWithPolicy(raw, allowMarkupRepair)` + (`ingestion.go:39`). `invokeStream` receives `allowMarkupRepair` derived from the + resolved target type (`isMarkupTarget`, `executor.go`), so a Markdown/plain target + never proposes HTML tag balancing. Repair acceptance additionally refuses any + payload that literal-copied the placeholder. + +Raw output is preserved by the `IngestionTrace`, so corruption origin remains +attributable. Validation, verification and human approval were **not** weakened. + +Regression tests: `TestArtifactContractPlaceholdersAreNotMarkup`, +`TestPlaceholderEchoDetectionIsPrecise`, `TestCreatePlaceholderEchoIsRejectedNotRepaired`, +`TestProcessWithPolicyGatesMarkupRepair`. + +Live evidence: `zuru.md` content is exactly `Hello everyone` in both live scenarios +(§13). + +--- + +## 11. Terminal truth + +The runtime owns the terminal result on the migrated paths. The invariant is unchanged +from the audit's target: only the Driver's loop state is authoritative, and the executor's +`ExecutionProof.Outcome` is its per-invocation evidence, projected into the loop. + +* Non-success states are not success: a parked proposal is `awaiting_human`, a launched + verification is not verification success, a mutation is not objective completion. +* The live run shows the authority rewriting the loop's proposal from `complete` to + `ask_human` until the mutation is approved, then `complete` only after + `PROVEN by evidence` (`objective.evaluated` with `granted=true`). +* `CANCELLED` and `ABORTED` remain distinct and never report `PROVEN` (pinned by existing + `r6_*` suites, which still pass). + +**REMAINING:** the CLI/`izen run` (`app.Result`) and `izen prompt/orchestrate` +(`orchestrator.ExecutionResult`) still own their own terminal vocabularies; they are +fenced in §14. + +--- + +## 12. Tests: commands executed and outcomes + +All commands run on this branch, current working tree. + +| Command | Result | +|---|---| +| `go build ./...` | **exit 0** | +| `go test ./internal/core/authorization/ ./internal/execution/...` | **ok** | +| `go test ./internal/ui/` | **ok** | +| `go test ./internal/runtime/autonomy/` | **ok** | +| `go test ./internal/architecture/ -run TestExecutionOwnership` | **PASS** | +| `go test ./...` | **no FAIL lines** (212 packages `ok`/no-test) | + +New tests added: + +* `internal/execution/verification_authorization_test.go` — authorization consumption boundary. +* `internal/execution/artifact_integrity_convergence_test.go` — placeholder + contract-aware repair. +* `internal/execution/ingestion/policy_test.go` — `ProcessWithPolicy` gate. +* `internal/execution/constrained_create_test.go` — `TestCreateProposalCarriesWholeFileDiff`. +* `internal/ui/execution_ownership_convergence_test.go` — `$prompt`/`/build` enter one runtime with distinct surfaces. +* `internal/architecture/execution_ownership_convergence_test.go` — AST lock on the single seam. +* `test/realworld/benchmark_test.go` — live Scenario B (`$build` surface) arm. + +These assert ownership/lifecycle invariants (behavioral call edges, consumption +boundary, payload producer/consumer, artifact bytes), not merely that types compile. + +--- + +## 13. Live execution evidence + +Provider: local **Ollama `qwen2.5-coder:7b`** (real model; no simulation). + +Command (both scenarios): + +```text +IZEN_LIVE_FORENSICS=1 IZEN_BENCH_PROVIDER=ollama \ + IZEN_REALWORLD_ARTIFACTS= \ + go test ./test/realworld/ -run '' -v -count=1 +``` + +The harness drives the **production composition** (`compose.Wire → autonomy.Driver → +RuntimeExecutor`) with the real provider and plays the human at the approval boundary +(`test/realworld/harness.go`). + +### Scenario A — `$prompt Create a new file named \`zuru.md\` with the content "Hello everyone".` + +Arm: `TestBenchmark_CreateCodeQuotedPrompt`. **PASS** (7.9 s). + +* Authoritative runtime owner: autonomy `Driver` (`Driver.Run`). +* Execution contract: `ScopeDynamic`, surface `$prompt`, target `zuru.md`, CREATE. +* Provider-call count: **2** (requirement pass + main generation). +* Per-call: `call #1 requested=512 effective=unobserved`, `call #2 requested=4096 effective=unobserved`; aggregate provider-reported **input 368 / output 8** (`usage known`). +* Proposal/mutation transitions: `executing → verifying → awaiting_human (ask_human, mutation awaiting approval)` → `patch approved` → `observing → deciding → completed`. +* Verification: `applicable=false — no verification configured for language` (NOT_APPLICABLE, not a false pass). +* Final terminal state: `completed — objective satisfied: created; objective PROVEN by evidence`. +* `changed_contents["zuru.md"] == "Hello everyone\n"` — **exactly `Hello everyone`** (trimmed); `testfile.md` untouched. + +### Scenario B — enter `/build`, then submit the same prompt + +Arm: `TestBenchmark_CreateCodeQuotedBuildSurface` (`Surface: "$build"`). **PASS** (2.3 s). + +* Authoritative runtime owner: the **same** autonomy `Driver`. +* Execution contract: `ScopeDynamic`, surface `$build`, target `zuru.md`, CREATE. +* Provider-call count: **2**; aggregate input **368** / output **14** (`usage known`). +* Proposal/mutation transitions: identical park→approve→complete sequence. +* Verification: NOT_APPLICABLE (no language), same as A. +* Final terminal state: `completed — objective PROVEN by evidence`. +* `changed_contents["zuru.md"] == "Hello everyone"` — **exactly `Hello everyone`**; unrelated file untouched. + +### Honest limitation on Scenario B + +Scenario B's **TUI routing** (`/build` ordinary prompt → `runRuntimePrompt` → +`runAutonomyRoutedCmdExplicit`) is proven **deterministically** by +`internal/ui::TestBuildOrdinaryPromptEntersAuthoritativeRuntime` and the AST lock +`internal/architecture::TestExecutionOwnership_AllExecutionSurfacesConvergeOnOneRuntime`. +The live arm exercises the **same runtime owner under the same `$build` surface**; it does +not script a keystroke-level interactive TUI session. This is stated plainly: the live +artifact proves the runtime lifecycle, approval, budget and terminal truth for the +`/build` surface; the interactive TUI wiring is proven by deterministic tests, not by a +live keystroke run. + +Evidence artifacts persisted under the `IZEN_REALWORLD_ARTIFACTS` directory +(`*-events.ndjson`, `*-trace.ndjson`, `*-summary.json`). + +--- + +## 14. Remaining deviations + +Every known entry point/component that still violates the target invariant is listed. +None is claimed converged. + +1. **`izen run` → `internal/app.Pipeline`** (`cmd/izen/runtime.go`, `internal/app/pipeline.go`): + an independent `for{}` loop with its own extraction/repair/terminal `app.Result`. Not + migrated; reachable only via the CLI subcommand, not the TUI. +2. **`izen prompt` / `izen orchestrate` → `internal/cli.Stack` + `internal/runtime/orchestrator.RunCycle`**: + a second self-contained proposal loop (`cmd/izen/orchestrate.go`, + `internal/cli/cli.go`, `internal/runtime/orchestrator/engine.go`). Not migrated. +3. **`internal/engine` layered loop** (`control/loop.go`, `retry.go`, `pipeline`): + imported by the composition root (`internal/runtime/compose/compose.go` imports + `engine/layer1`, `layer3`, `pipeline`) as a legacy facade. Not removed. +4. **`runtime/kernel` + `kernelbridge`**: self-declared substrate with its own `Outcome`; + reached at the mutation-commit seam. Remains a parallel owner at the vocabulary level. +5. **`modes/plan.Engine` and `modes/investigate`**: direct provider callers with their own + retry/tool loops (`/plan`, `/investigate`). Not routed through the runtime seam. +6. **UI `/ask` stream + role fallback** (`internal/ui/stream.go`, `role_fallback.go`): + direct provider calls for conversation. Outside the mutation lifecycle by design, but + still a direct provider caller. +7. **Secondary provider callers**: session-title refinement, `/commit` message generation, + `runDiagnoseCmd`, and provider-internal retries (OpenRouter) — unchanged. +8. **Budget bypass resolvers**: requirement pass, manifest pass, behavioral repair, UI ASK, + plan synthesis (see §7) — unchanged. + +These are **fenced** (they are not reachable from the migrated TUI execution surfaces) and +the positive convergence lock prevents a future silent re-introduction of a second loop on +the `$prompt`/`/build`/`$hot` surfaces. + +--- + +## 15. Migration debt (explicit) + +| Debt | Owner | Reason left | +|---|---|---| +| Unify all budget resolvers behind one owned envelope | execution / autonomy | touches non-migrated `/plan`, `/ask`, behavioral stage; needs comparative token measurements | +| Migrate `izen run` (`app.Pipeline`) onto the runtime | `internal/app` | independent CLI path; large surface; requires parity harness | +| Migrate/remove `cli.Stack` + `orchestrator.RunCycle` | `internal/cli` | second proposal loop; needs proposal-lifecycle parity | +| Remove the `internal/engine` layered loop after parity | `internal/engine` | imported by composition root; legacy facade | +| Promote `runtime/kernel` to execution plane (single `Outcome`) | `runtime/kernel` | vocabulary convergence across all owners | +| Route `modes/plan` + `modes/investigate` through the provider seam | `internal/modes` | capability stages with their own loops | +| Make `/ask`, title, `/commit`, diagnose read-only provider calls explicit lifecycle steps | `internal/ui` | conversational/read-only, not mutation lifecycle | +| Interactive keystroke-level live `/build` run | `internal/ui` | deterministic UI test + live runtime arm used instead | + +Each item names its owner and the reason it was not done in this change; none is hidden +behind a compatibility wrapper that pretends to be the authoritative runtime. + +--- + +## 16. Conclusion + +The corrected invariant holds on the migrated surfaces: + +```text +ONE REQUEST → ONE ADMISSION → ONE AUTHORITATIVE RUNTIME (autonomy Driver) + → CONTROLLED PROVIDER CALLS (execution plane seam) + → ONE EXECUTION STATE MACHINE → ONE TRUTHFUL TERMINAL RESULT +``` + +Verified, not assumed: + +* `$prompt` and ordinary `/build` enter the same runtime owner with distinct surfaces + (`internal/ui` + `internal/architecture` tests). +* The proposal payload producer and consumer agree; an empty payload fails explicitly. +* The authorization grant's consumption boundary matches its documented meaning; a + multi-step verifier no longer starves itself. +* A CREATE produces exactly the requested bytes; a placeholder echo is rejected, and + markup repair is gated by the artifact contract. +* Both live scenarios completed with `PROVEN`, created `zuru.md` containing exactly + `Hello everyone`, in 2 provider calls each. + +Not claimed converged: the CLI, plan/investigate, kernel and legacy-engine owners listed +in §14 remain, explicitly and testably fenced. diff --git a/docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE_PHASE2.md b/docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE_PHASE2.md new file mode 100644 index 00000000..f4a28199 --- /dev/null +++ b/docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE_PHASE2.md @@ -0,0 +1,459 @@ +# IZEN — Execution Topology Convergence, Phase 2 + +Date: 2026-10-09 · Branch: `fix/execution` · HEAD at start: `f9b117c` +Recorded baseline: `d5fe1f2` (the parent of the previous convergence change). + +This is the second phase. The previous change (`f9b117c`, +`docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE.md`) established the bounded +autonomy `Driver` as the authoritative lifecycle for the migrated TUI surfaces +(`$prompt`, ordinary `/build`, `$hot`) and explicitly fenced the remaining +owners in its §14. This phase selects ONE of those remaining owners, migrates it +onto the same authoritative lifecycle, and discloses everything it did not do. + +Distinctions used throughout, unchanged from Phase 1: + +* **VERIFIED** — proven by a test that ran on this working tree, or by a live + provider run. +* **OBSERVED** — read in the current source, with a citation. +* **REMAINING** — a known deviation, disclosed and not claimed converged. + +--- + +## 1. Method and baseline + +1. `git status` → clean tree; branch `fix/execution`; HEAD `f9b117c` + (`feat(exec): execution topology convergence`), one commit above the recorded + baseline `d5fe1f2`. The commit `f9b117c` **is** the previous convergence + change and the source of `IZEN_EXECUTION_TOPOLOGY_CONVERGENCE.md`. +2. Re-read the previous report and its evidence audit + (`docs/report/IZEN_EXECUTION_TOPOLOGY_AUDIT.md`). +3. Re-ran the baseline ownership/authorization tests (all `ok`; see §5.1). +4. Rebuilt the call graph for every remaining owner by exact source reads and + import/call-edge greps. The codebase-memory graph was **not** used for the + final claims: its snapshot was indexed 2026-10-04, before `f9b117c` + (2026-10-09), so it is stale for the changed files. All claims below are + source-verified and each material claim has a test or a live run. + +--- + +## 2. Phase A — current topology + +### 2.1 Entry points and reachability (OBSERVED) + +`cmd/izen/main.go` dispatches four execution-bearing subcommands in addition to +the TUI: + +| Subcommand | Owner | Import proof | Can mutate | Own terminal truth | +|---|---|---|---|---| +| `izen run ""` | `internal/app.Pipeline` | `cmd/izen/runtime.go` + `runtime_ledger.go` are the **only** importers of `internal/app` | yes (via `substrate`) | `app.Result` | +| `izen prompt` / `izen orchestrate` | `internal/cli.Stack` → `orchestrator.RunCycle` | `cmd/izen/orchestrate.go` is the **only** importer of `internal/cli` | yes (via `FileExecutor`) | `orchestrator.ExecutionResult` | +| TUI `$prompt`/`/build`/`$hot` | autonomy `Driver` → `RuntimeExecutor` | `compose.Wire` (previous phase) | yes | `autonomy.RuntimeLoop` | + +Import scans (`grep -rl`, non-test) established: + +* `internal/app` — imported only by `cmd/izen/runtime.go`, + `cmd/izen/runtime_ledger.go` → **`izen run` only**. Not reachable from the TUI. +* `internal/cli` — imported only by `cmd/izen/orchestrate.go` → **`izen + prompt` / `izen orchestrate` only**. +* `internal/runtime/orchestrator` — imported by `cmd/izen`, `internal/cli`, + `internal/ui` (only the deterministic `PhaseManager` + budget helpers), + `internal/runtime/compose` (only `PhaseManager`), `internal/verification`, + and `test/realworld`. The **proposal lifecycle** `Orchestrator.RunCycle` is + reached only from `cli.Stack.Run` and the verification harness. +* `internal/engine` (layered loop) — imported by `internal/ui`, `internal/modes` + (`plan`, `investigate`, `review`), `internal/runtime/compose`, + `internal/runtime/orchestrator/manager.go`, `internal/execution` + (`mutationset.go`, `patch.go`). It is a **facade** consumed by the read-only + planning surfaces (`ExecutePlan` / `ValidatePatch`), not a TUI mutation loop. +* `internal/runtime/kernel` + `internal/kernelbridge` — the execution plane both + CLI paths already cross at mutation time + (`internal/runtime/executor/kernelcommit.go`, + `internal/runtime/substrate/kernelcommit.go`). Not a lifecycle owner. + +### 2.2 The two genuine CLI owners + +Both remaining CLI owners are **not** reachable from the TUI, and both cross the +same kernel at the mutation seam. They differ in lifecycle ownership: + +```text +izen run (internal/app.Pipeline) + cmd/izen/runtime.go:198 app.NewPipeline + → app.Pipeline.Run (pipeline.go:290) + → for {} compile → generate → extract → align → validate → repair → plan + → ConcreteSubstrate.Execute → kernelbridge.Apply → runtime/kernel + ⇒ LIFE-OWNER: app.Pipeline; terminal vocabulary: app.Result + +izen prompt / izen orchestrate (internal/cli.Stack) + cmd/izen/orchestrate.go:106 cli.Wire + → Stack.Run (cli.go:397) + → Orchestrator.RunCycle (engine.go:101) + → preflight → ProposalProviderCLI.GenerateProposal (provider call) + → ProposalValidator.Validate → ApprovalGate (own [y/N] session) + → FileExecutor.Commit → kernelbridge.Apply → runtime/kernel + ⇒ LIFE-OWNER: orchestrator.RunCycle; terminal vocabulary: ExecutionResult +``` + +### 2.3 Per-owner responsibilities (OBSERVED) + +`internal/cli.Stack` / `orchestrator.RunCycle`: + +* reachable entry points: `izen prompt`, `izen orchestrate` (and the + `internal/verification` harness); +* mutates files: **yes**, through its own `FileExecutor` → kernel; +* continuation/retry/recovery/cancellation: **none/single-cycle**; the + `Run` doc explicitly states it "never loops on NeedsContinuation and never + enters autonomy.Driver" (`cli.go:388-396`); +* approval: **its own** `authorization.ApprovalGate` (a separate gate type from + the production `core/authorization.AuthorizationEngine`); +* verification: the executor's own transaction; no objective proof; +* terminal truth: `orchestrator.ExecutionResult` (`Committed`, `Action`); +* provider seam: `ProposalProviderCLI.GenerateProposal` → `LLMProvider.Complete` + (one provider call per cycle); +* tests: `internal/cli/cli_test.go` (`TestStackRunEndToEnd`, + `TestStackRunRejectionEndToEnd`, `TestStackRunIsSingleCycle`), + `internal/runtime/orchestrator/*_test.go`, `internal/verification/harness.go`; +* disposition: **active but fenced** (CLI only). + +`internal/app.Pipeline`: + +* reachable entry points: `izen run` only; +* mutates files: **yes**, through `substrate` → kernel; +* continuation/retry/recovery: **yes** — a real `for{}` loop with + `maxAttempts`/`maxRepairs` and extraction/alignment/validation repair + (`pipeline.go:394-469`); +* approval: **none** — it has no human approval gate; it writes through the + substrate after its internal gates; +* verification: capability validation + semantic alignment (not objective proof); +* terminal truth: `app.Result`; +* tests: `internal/app/*_test.go` (extensive). + +`internal/engine`, `modes/plan`, `modes/investigate`, `modes/review`, +UI `/ask` + role fallback, secondary provider callers, and the budget bypass +resolvers are unchanged from Phase 1 §14 and remain fenced. + +### 2.4 Baseline test run (VERIFIED) + +``` +go build ./... exit 0 +go test ./internal/core/authorization/ ./internal/runtime/autonomy/ \ + ./internal/architecture/ -run 'TestExecutionOwnership|TestMutationOperation|TestCapabilityInvocation' + ok +``` + +--- + +## 3. Phase B — migration decision + +### 3.1 Ranking of remaining deviations + +| Rank | Owner | Competing lifecycle? | Reachable from | Duplication | Migration cost | Risk | +|---|---|---|---|---|---|---| +| 1 | `cli.Stack` + `RunCycle` | **yes** (own provider + validator + approval gate + executor + terminal result) | `izen prompt`/`orchestrate` | duplicates proposal/approval/commit + provider seam | **medium**, self-contained | medium | +| 2 | `app.Pipeline` | **yes** (full `for{}` loop) | `izen run` | duplicates generation/extract/align/plan/commit | high (capability/planner semantics) | high | +| 3 | `internal/engine` facade | read-only via modes | `/plan`,`/investigate`,`/review` | validation/context | high | high | +| 4 | kernel/kernelbridge | no (execution plane) | all mutation seams | none | n/a | n/a | +| 5 | `modes/plan`/`investigate` | read-only loops | TUI | provider seam | medium | medium | +| 6 | UI `/ask` + secondary | read-only conversation | TUI | provider seam | low | low | + +### 3.2 Selected first target + +> **Owner removed/subordinated:** the `izen prompt` / `izen orchestrate` proposal +> lifecycle — `internal/cli.Stack.Run` → `internal/runtime/orchestrator.Orchestrator.RunCycle` +> and its private `ProposalProviderCLI` + `ProposalValidator` + +> `authorization.ApprovalGate` + `FileExecutor` stack. +> +> **Authoritative destination:** the composition-root autonomy `Driver` +> (`internal/runtime/autonomy.Driver`) over the `RuntimeExecutor` execution +> plane — the same owner the TUI drives, reached through `compose.Wire`. + +Rationale (evidence-based): + +* It is a complete, self-contained execution owner: own provider call, own + validation, own approval gate, own executor, own terminal vocabulary. Removing + it from the command path removes a genuine competitor. +* It is the **smallest** competitor whose contract maps onto the authoritative + runtime, so it is the lowest-risk first migration — exactly what the brief + asks for ("the smallest complete migration"). +* Its behavior is directly comparable: the same objective, the same fixture, the + same operator decision can be run through both owners (§5.3). +* It is the only remaining owner whose destination wiring (`compose.Wire` → + `app.Autonomous`) already exists and is exercised by the real-world harness. + +`app.Pipeline` (rank 2) is the next target, not this one: its capability/ +alignment/planner semantics do not map onto the runtime without a rewrite, so +migrating it first would violate "smallest complete migration". + +### 3.3 Behavioral contract that must remain unchanged + +* `izen prompt` / `izen orchestrate` parse `-dir ` and one objective. +* A proposal is rendered and the operator decides with the **same vocabulary**: + `y`/`yes` applies, `i`/`inspect` inspects without applying, anything else + (including Enter) rejects. +* Approve applies the reviewed bytes atomically through the kernel; reject + leaves the workspace untouched. +* The mutation authorization is bound to the reviewed candidate identity and + digest; a refusal refuses the apply. +* Cancellation stays distinct from failure and success. +* Terminal truth is the runtime's, established by evidence, not by model prose + or UI state. + +### 3.4 Parity tests required + +1. `internal/cli` behavioral tests: approve issues the authorization before + resume; reject/inspect never authorize; authorization refusal rejects; + clarify resumes; inform parks; bridge failure aborts; cancellation preserved. +2. `internal/cli` execution-parity test: the legacy `cli.Stack.Run` path and the + authoritative `RuntimeExecutor` plane produce **identical file bytes** for the + same model content and operator decision. +3. `internal/architecture` AST locks: `runOrchestrateCommand` crosses + `cli.RunAuthoritative` + `compose.Wire` and does **not** call + `RunCycle`/`NewOrchestrator`/`NewProposalProvider`/`NewTerminalBridge`; + `cli.RunAuthoritative` never invokes a provider or the filesystem. +4. `internal/runtime/autonomy` scope-provenance test extended for the new + `$orchestrate` surface. + +### 3.5 Rollback boundary + +`cmd/izen/orchestrate.go` is the only production file that changes behavior. To +roll back, restore its `cli.Wire` + `stack.Run` body; `internal/cli` and +`internal/runtime/orchestrator` are **not** deleted and their tests still pass. +The library additions (`internal/cli/authoritative.go`) and the tests are +additive. + +### 3.6 Files/packages expected to change + +`cmd/izen/orchestrate.go`, `internal/cli/authoritative.go` (+tests), +`internal/runtime/autonomy/objective_completion.go` (+ test), +`internal/architecture/execution_ownership_convergence_test.go`. + +--- + +## 4. Phase C — implementation + +### 4.1 What changed (exact) + +| File | Change | +|---|---| +| `internal/cli/authoritative.go` (new) | `RunAuthoritative` — the narrow CLI driver over the authoritative runtime. Defines the structural `AuthoritativeDriver` port (Run/ResumeApprove/ResumeReject/ResumeClarify/Abort/State/Boundary/SetScope/RunID), the `HumanBridge` port, the `AuthorizeFn`, and the terminal `DriverTerminalBridge` (renders the runtime's own `CandidateDiff`/evidence, reads `[y/N]`/inspect, preserves the vocabulary). | +| `cmd/izen/orchestrate.go` (rewritten) | `runOrchestrateCommand` now wires `compose.Wire` (production composition root) + `app.Autonomous` and calls `cli.RunAuthoritative`. It grants the workspace capability vector at admission (read/analyze/propose/mutate/verify), creates the session-start checkpoint, dispatches `SubmitPromptCmd` preflight, issues the mutation authorization on approve, flushes audit, and projects the runtime terminal state. Removed the private `orchestrateAdapter`/`stubNoopProvider` and the `cli.Wire`/`stack.Run` call. | +| `internal/runtime/autonomy/objective_completion.go` | `scopeProvenance()` maps `$orchestrate` (case-insensitive) to `domain.ScopeDynamic`, so the headless CLI build surface carries the same mutation authority as `$prompt`/`$build` while remaining a distinct command surface. No new authority. | +| `internal/cli/authoritative_test.go` (new) | Behavioral ownership tests (10). | +| `internal/cli/authoritative_parity_test.go` (new) | Execution parity legacy-vs-authoritative (2). | +| `internal/runtime/autonomy/behavior_test.go` | Pins `$build`/`$orchestrate` → `ScopeDynamic`. | +| `internal/architecture/execution_ownership_convergence_test.go` | AST locks on the CLI seam and on `RunAuthoritative` purity. | + +No new runtime, planner, driver, compatibility executor, or state machine was +introduced. The CLI no longer constructs any of the following on its production +path: `ProposalProviderCLI`, `ProposalValidator`, `ApprovalGate`, `FileExecutor`, +`Orchestrator.RunCycle`. + +### 4.2 How the requirements are met + +1. **Semantics/scope/target/approval preserved.** Argument parsing, target + resolution (via the runtime's `IntentGateway`), the `[y/N]`/inspect + vocabulary, and the approve/reject boundary are preserved; the surface is + recorded as `$orchestrate` (distinct from `$build`/`$prompt`). +2. **Cancellation preserved and distinct.** The command's context is threaded to + the driver; a canceled run returns the runtime's abort and is never reported + as success (`TestAuthoritativeCancellationIsPreserved`). +3. **Proposal payload integrity / authorization semantics.** The approval review + is rendered from the runtime's own `HumanBoundary.CandidateDiff` (the same + object the mutation records its evidence from) and the authorization is bound + to `CandidateID`/`CandidateDigest`; a refusal rejects, never applies + (`TestAuthoritativeAuthorizationRefusalRejectsNotApplies`). +4. **Objective completion by evidence.** The command reports only the runtime's + terminal state; the live run's `objective.evaluated` event is + `state=PROVEN, granted=true, authority=objective_completion_authority`. +5. **No silent fallback.** The command no longer builds `cli.Wire`/`RunCycle`; + the architecture lock fails if it returns. The previous no-provider stub path + is removed: an execution command without a provider fails closed. +6. **Read-only paths stay separate.** `/ask`, `modes/plan`, `modes/investigate`, + `modes/review`, and the secondary provider callers are untouched; their + lifecycle genuinely differs (read-only conversation/planning) and remains + fenced as in Phase 1 §14. +7. **No premature removal.** `internal/cli`, `internal/runtime/orchestrator`, and + the `FileExecutor` remain in the tree (tests + `internal/verification` + harness); the kernel and legacy engine are untouched. +8. **No weakened tests.** All existing tests still pass (§5.1); only additive + tests were added. + +--- + +## 5. Phase D — verification + +### 5.1 Test commands executed (VERIFIED) + +| Command | Result | +|---|---| +| `go build ./...` | **exit 0** | +| `go vet ./internal/cli/ ./cmd/izen/ ./internal/runtime/autonomy/ ./internal/architecture/` | **exit 0** | +| `go test ./internal/cli/ ./internal/runtime/orchestrator/ ./internal/architecture/ ./cmd/izen/ ./internal/app/ -count=1` | **ok** (all five) | +| `go test ./internal/cli/ -run 'Authoritative\|DriverTerminal\|TestParity' -count=1` | **ok** | +| `go test ./internal/runtime/autonomy/ -run TestScopeProvenanceMapping -count=1` | **ok** | +| `go test ./...` | see §5.5 | + +New tests (all behavioral, not interface-shape): + +* `internal/cli/authoritative_test.go` — approve ordering + authorization + binding; reject/inspect never authorize; authorization refusal rejects; + clarify resumes; inform parks without a resume; bridge failure aborts; + cancellation preserved; terminal vocabulary preserved; clarify selection. +* `internal/cli/authoritative_parity_test.go` — legacy vs authoritative produce + identical bytes on approve and leave the workspace untouched on reject. +* `internal/architecture/execution_ownership_convergence_test.go` — CLI seam lock + + `RunAuthoritative` purity lock. +* `internal/runtime/autonomy/behavior_test.go` — `$orchestrate` provenance. + +### 5.2 Static observations + +* The `internal/app` and `internal/cli` packages remain imported only by their + CLI commands (no TUI reachability) — unchanged. +* `RunCycle` is now unreachable from any production command; it survives only in + tests and the `internal/verification` harness. It is **subordinated, not + deleted** (rollback safety + parity). + +### 5.3 Live execution evidence (VERIFIED — real local model) + +Provider: local **Ollama `qwen2.5-coder:7b`** (real model; no simulation). No +credentials or paid API were used. + +Workspace: isolated `mktemp` git repository containing only `keep.md` +(`# Fixture\n\nUnrelated file that must not change.\n`), committed as baseline. + +Command: + +```text +printf 'y\n' | ./bin/izen orchestrate -dir \ + "Create a new file named hello.md with the content 'Hello everyone'." +``` + +Captured facts (all from the runtime's own ledger and audit, never from model +prose): + +* **Entry point / resolved surface:** `izen orchestrate`, surface `$orchestrate` + (`prompt.admitted`, `capability.granted` with read/analyze/propose/mutate/verify). +* **Selected runtime owner:** autonomy `Driver` (`loop.transition` ×9; + `execution.admission.decision`; `execution.spec.frozen`). +* **Provider-call count:** **2** (`execution.provider.execution` ×2) — + requirement pass + main generation. +* **Requested / effective token limits:** call #1 + `requested_output_tokens=512, effective_output_tokens=512`; + call #2 usage `prompt_tokens=362, completion_tokens=13, + finish_reason=stop`; aggregate usage known. +* **Proposal/approval transitions:** `approval.required` → + `execution.summary status=awaiting_human parked=true boundary_action=approve` + → operator `y` → `execution.mutation.started` → `execution.mutation.completed`. +* **Mutation evidence:** `EXECUTION_COMMITTED` with a postcondition digest; + `.izen/patches/run-1-patch-1.json` = `{"file":"hello.md","original":"", + "modified":"Hello everyone","applied":true}`. +* **Verification:** `execution.verification.completed` = + `NOT_APPLICABLE` ("no verification configured for language") — an honest + not-applicable, not a false pass. +* **Final terminal state:** `objective.evaluated state=PROVEN granted=true + authority=objective_completion_authority`; `execution.finished Success=true + Outcome=created`; `execution.summary status=completed termination_reason= + "objective satisfied: created; objective PROVEN by evidence"`. +* **Changed artifacts / preservation:** `hello.md` created with exactly + `Hello everyone`; `keep.md` byte-for-byte unchanged. + +A first live attempt **before** the admission grant correctly parked at an +`inform` boundary with `objective BEHAVIORALLY UNPROVEN: AUTHORIZATION_BLOCKED +[runtime.serve]`, having already applied the bytes — a truthful unproven +terminal, not a fabricated success. This is the evidence that the objective gate +is real: without the workspace grant the runtime refuses to claim the objective, +even though the mutation landed. + +### 5.4 What the live run does **not** prove + +The live run exercises the migrated **command** end to end (admission → Driver → +RuntimeExecutor → approval → mutation → objective proof). It does not script a +keystroke-level TUI session; the TUI surfaces were proven deterministically in +Phase 1 and are unaffected here. + +### 5.5 Full suite (VERIFIED) + +``` +go test ./... +``` + +**exit 0** — 212 packages `ok`, 29 packages with no test files, **zero `FAIL` +lines and zero panics**. No test was modified to pass; the only test edits are +the additive `$build`/`$orchestrate` provenance rows and the new ownership/parity +tests. + +--- + +## 6. Remaining deviations (REMAINING) + +The selected owner is subordinated, not deleted, and the following remain +explicitly unconverged. None is claimed converged. + +1. **`izen run` → `internal/app.Pipeline`** — still an independent full `for{}` + loop with its own extraction/repair/terminal `app.Result`. CLI-only. +2. **`internal/cli.Stack` + `orchestrator.RunCycle`** — retained for tests and + the `internal/verification` harness; unreachable from production. This is the + subordinated owner, kept for rollback/parity. +3. **`internal/engine` layered loop** — still a facade consumed by `/plan`, + `/investigate`, `/review`. +4. **`runtime/kernel` + `kernelbridge`** — the execution plane; still the + vocabulary at the mutation-commit seam. +5. **`modes/plan` + `modes/investigate`** — direct provider callers with their + own loops (read-only lifecycle, deliberately separate). +6. **UI `/ask` stream + role fallback** — direct read-only provider calls. +7. **Secondary provider callers** — session-title, `/commit`, `$diagnose`, + provider-internal retries. +8. **Budget bypass resolvers** — requirement pass, manifest pass, behavioral + repair, UI ASK, plan synthesis (Phase 1 §7/§15). +9. **Proposal-preview envelope fidelity (newly observed, pre-existing):** when + the model emits a `FILE_CREATE`-style artifact envelope, the rendered + `CandidateDiff` can include the envelope while the applied bytes are the + parsed inner content. The TUI renders the same `CandidateDiff` + (`internal/ui/autonomous.go:920`; `approval_admission.go:181`), so this is a + pre-existing runtime property, not introduced by this migration. It is + disclosed rather than silently "fixed" because it belongs to the + artifact-integrity owner, not the execution-topology owner. + +--- + +## 7. Migration debt (explicit) + +| Debt | Owner | Reason left | +|---|---|---| +| Delete `cli.Stack`/`RunCycle`/`FileExecutor` after parity is permanently pinned | `internal/cli` | kept as the rollback/parity boundary | +| Migrate `izen run` (`app.Pipeline`) onto the runtime | `internal/app` | needs capability/alignment/planner parity harness; larger change | +| Remove the `internal/engine` facade after parity | `internal/engine` | consumed by read-only modes | +| Promote `runtime/kernel` to the single execution plane | `runtime/kernel` | vocabulary convergence across all owners | +| Route `modes/plan` + `modes/investigate` through the provider seam | `internal/modes` | read-only lifecycle | +| Make `/ask`, title, `/commit`, diagnose explicit read-only lifecycle steps | `internal/ui` | conversational/read-only | +| Unify the budget resolvers behind one envelope | execution/autonomy | touches non-migrated surfaces | +| Proposal-preview envelope fidelity | artifact integrity | see §6.9 | + +--- + +## 8. Conclusion + +The migrated invariant now holds on `izen prompt` / `izen orchestrate`: + +```text +ONE REQUEST → ONE ADMISSION (compose.Wire + preflight dispatch) + → ONE AUTHORITATIVE RUNTIME (autonomy Driver, surface $orchestrate) + → CONTROLLED EXECUTION-PLANE CAPABILITIES (RuntimeExecutor) + → ONE AUTHORITATIVE TERMINAL RESULT (runtime ledger: PROVEN) +``` + +Verified, not assumed: + +* The CLI command crosses the authoritative Driver seam and no longer constructs + or runs its private proposal lifecycle (AST lock + source). +* Approve issues the candidate-bound authorization before resume; reject and + inspect never apply; an authorization refusal rejects + (behavioral tests). +* The legacy CLI path and the authoritative execution plane produce identical + bytes for the same decision (execution parity test). +* A live CREATE completed with `objective PROVEN by evidence`, wrote exactly the + requested bytes, preserved the unrelated file, in **2 provider calls**. +* The full test suite result is recorded in §5.5 / §6. + +Not claimed converged: `izen run`, the layered engine, the kernel vocabulary, +`modes/plan`/`investigate`, the UI read-only provider calls, and the budget +resolvers remain, explicitly and testably fenced. diff --git a/docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE_PHASE3.md b/docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE_PHASE3.md new file mode 100644 index 00000000..ea658afb --- /dev/null +++ b/docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE_PHASE3.md @@ -0,0 +1,495 @@ +# IZEN — Execution Topology Convergence, Phase 3 + +Date: 2026-10-09 · Branch: `fix/execution` · HEAD at start: `09636b0` +Reported baseline: `041a344`, branch `fix/execution`, clean tree. + +Distinctions used throughout, unchanged from Phases 1/2: + +* **VERIFIED** — proven by a test that ran on this working tree, or by a live + provider run. +* **OBSERVED** — read in the current source, with a citation. +* **REMAINING** — a known deviation, disclosed and not claimed converged. + +--- + +## 1. Method and baseline + +1. `git status` → clean tree; branch `fix/execution`; `git rev-parse HEAD` → + `09636b0134d515acb2c06fbea30c9dd4eb7bf2fd`. This is `041a344` + (`feat(exec): phase 2 — CLI proposal lifecycle converges on Driver`) plus + `09636b0` (`fix(exec): canonical artifact approval`), i.e. the reported HEAD + plus the Phase 2.1 correction. +2. Re-read `docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE_PHASE2.md` and + `docs/report/IZEN_ARTIFACT_APPROVAL_INTEGRITY.md`. +3. Traced the complete `izen run` path by exact source reads (not by shared + helper names): CLI parse → lifecycle owner → provider seam → mutation + boundary → terminal result. +4. Ran the baseline affected packages before editing (all `ok`). +5. Implemented the migration, then re-ran focused, affected, architecture, + full-suite, vet and build checks, plus three live local-provider runs. +6. The codebase-memory graph was **not** used for final claims (its snapshot + predates the changed files); every material claim below is source-verified + and, where a lifecycle transition matters, test- or live-verified. + +Final change set (uncommitted at report time): + +``` + cmd/izen/authoritative.go (new) shared authoritative CLI lifecycle + cmd/izen/runtime.go rewrite: thin adapter, keeps provider build + cmd/izen/runtime_ledger.go DELETED (obsolete headless ledger) + cmd/izen/runtime_ledger_test.go DELETED + cmd/izen/orchestrate.go delegates to the shared lifecycle + cmd/izen/runtime_test.go (new) argument-grammar tests + internal/runtime/autonomy/driver.go SetTargets (explicit target seam) + internal/runtime/autonomy/objective_completion.go $run scope provenance + internal/runtime/autonomy/behavior_test.go $run provenance case + internal/runtime/autonomy/target_statement_test.go (new) + internal/execution/executor.go canonical envelope + applied-bytes diff + internal/execution/artifact_approval_integrity_test.go 2 new regression tests + internal/changeset/compiler.go UnifiedDiffBetween + internal/architecture/execution_ownership_convergence_test.go Phase 3 locks +``` + +--- + +## 2. Phase A — audit of the `izen run` path (OBSERVED) + +### 2.1 The path before this change + +```text +izen run (cmd/izen/main.go:119) + → runRuntimeCommand (cmd/izen/runtime.go:117) + → newRunLedger (cmd/izen/runtime_ledger.go:68) durable task under .izen/runtime + → app.NewPipeline (runtime.go:198) own generator + intent compiler + → pipeline.Run (internal/app/pipeline.go:290) for{} generate→extract→align→validate→repair→plan + → ConcreteSubstrate.Execute → kernelbridge.Apply → runtime/kernel + → publishRunLifecycle (runtime.go:436) hand-built audit lifecycle + → runLedger.Complete / .Fail (runtime_ledger.go) committed-EXECUTION record + ⇒ LIFE-OWNER: internal/app.Pipeline; terminal vocabulary: app.Result +``` + +`internal/app` was imported **only** by `cmd/izen/runtime.go` and +`cmd/izen/runtime_ledger.go` in production. The command owned its own durable +ledger, audit wiring, pipeline loop and terminal rendering. + +### 2.2 The authoritative destination + +```text +compose.Wire (internal/runtime/compose/compose.go:500) + → autonomy.Engine + RuntimeExecutor + IntentGateway + AuthorizationEngine + → runtimeAutonomy.NewDriver (compose.go:937) + → Driver.Run (internal/runtime/autonomy/driver.go:587) + → driver loop (driver.go:2146) + → ExecutorAdapter.Execute (internal/runtime/autonomy/adapter.go:383) + → RuntimeExecutor.Execute (internal/execution) + → ObjectiveCompletionAuthority (objective_completion.go:199) + ⇒ LIFE-OWNER: autonomy.Driver; terminal vocabulary: autonomy.LoopTermination +``` + +`izen prompt` / `izen orchestrate` already crossed this seam through +`internal/cli.RunAuthoritative` (Phase 2). This phase routes `izen run` through +the same seam. + +### 2.3 Behavior matrix — `app.Pipeline` vs authoritative `Driver` + +Not inferred from shared helper names; each row was read in the cited owner. + +| # | Dimension | `app.Pipeline` (retired `izen run`) | authoritative `Driver` | +|---|---|---|---| +| 1 | Objective derivation / target resolution | `Request{Intent, Targets}`; `Targets` passed verbatim; capability resolution by prompt keywords (`pipeline.go:781`) | `IntentGateway`/`Strategy.Select` resolves `@` targets; evidence-bound `DeriveScope`; ambiguity parks at a clarify boundary (`driver.go:699`, `adapter.go:257`) | +| 2 | CREATE vs PATCH | no approval-bound classification; `Mode` greenfield/brownfield; substrate `OpFileWrite`/`OpFileDelete` (`proposalFromTx`) | executor classifies operation from target pre-state (CREATE/UPDATE/DELETE); artifact contract kinds (create_file/replace_block/search_replace/full_file) (`executor.go` staging) | +| 3 | Scope / mutation authorization | substrate confinement + kernel; **no human approval**; scope only for ledger provenance (`runtime_ledger.go:113`) | session capability ledger (read/analyze/propose/mutate/verify), preflight admission gate, per-candidate authorization bound to `CandidateID`+`CandidateDigest`, checkpoint-gated (`orchestrate.go`/`authoritative.go` authorize fn) | +| 4 | Context discovery / provider sequencing | `IntentCompiler` semantic call, prompt builder, generation loop (`pipeline.go:310,394`) | read-only requirement pass, then preflight admission, then main generation; grant-gated context re-compilation (`driver.go:833`, `compose.go:985`) | +| 5 | Artifact extraction / repair | MarkdownFence+JSON extractors; `maxAttempts`/`maxRepairs`; Semantic Alignment Gate; capability validation (`pipeline.go:394-469`) | executor ingestion/parse/artifact binder/validator; recovery matrix retry/repair; contract-recovery circuit breaker (`runtime_loop.go:948`) | +| 6 | Human approval / rejection | none; only the ambiguity `Clarifier` (`runtime.go:206`) | `HumanBoundary` approval with the runtime's own candidate review; `y`/`i`/reject (`cli/authoritative.go`) | +| 7 | Checkpoint / rollback / cancellation | TxFS + substrate `MutationSet`; ctx timeout; rollback on failure | durable `TaskStore`; loop checkpoints; DAG rollback `RollbackAndVerify`; ctx cancellation gate (`driver.go:2270`); `Abort` | +| 8 | Verification / objective completion | capability validation gate only; no objective proof | `ObjectiveCompletionAuthority` + behavioral gate; terminal PROVEN / UNSUBSTANTIATED / ABORTED (`objective_completion.go`) | +| 9 | Token budgets / usage | no run budget; `maxTokens=0` (provider default) | per-invocation `max_output` from strategy; `RunTokenBudget`; aggregate usage (`runtime_loop.go:647`) | +| 10 | Error class / terminal result | `app.Result` + typed errors; ledger records a committed EXECUTION, explicitly *not* a proven objective (`runtime_ledger.go:164`) | `LoopTermination{State,Reason,Class}`; ledger terminal `VERIFICATION_RESULT` (`ledger.go:336`) | + +The matrix is the basis of the migration contract. It is **not** a claim of +equivalence: rows 6, 8 and 9 are genuine behavioral differences and are treated +as contract changes below. + +--- + +## 3. Artifact-integrity prerequisite + +The brief asked whether the two limitations recorded in +`IZEN_ARTIFACT_APPROVAL_INTEGRITY.md` can violate authorization, overwrite +protection, or the invariant that approved content equals applied content, and +to fix/fence before migrating. + +### 3.1 Exact nested-marker boundary (REMAINING, no approval gap) + +A `FILE_CREATE` body whose own line is *exactly* `>>>>>>> END_FILE` terminates +the envelope at that line. Because the canonical body is resolved at staging and +`Apply` writes those exact bytes verbatim, the **approved bytes and the applied +bytes are the same (truncated) body**. It is a transport-format limitation, not +an authorization or approval-binding gap. No change; disclosed as before. + +### 3.2 Unscoped `FILE_CREATE` against an existing target (VERIFIED — fixed) + +This limitation **was reachable and did violate the invariant.** For an existing +target (`original != ""`), Phase 2.1's canonicalization was scoped to +`original == ""`, so a `FILE_CREATE` envelope aimed at an existing file staged +`modified = ResolveModifiedContent(original, raw)` = the **whole envelope** (no +SEARCH/`@@` markers), while `Patch.Canonical` was false, so the apply boundary +extracted the body. Two owners resolved two artifacts: the human reviewed the +envelope; the mutation wrote the body. The anchor check does not catch it +(`classifyAnchors` sees no SEARCH block and returns `(false, nil)`, +`artifact_step.go:357`). + +**Fix (at the owner):** the staging canonicalization now applies whenever the +parsed artifact is a recognized transported envelope (`artifact.Structural`), +for a new **or** an existing target. `Patch.Canonical = true` makes `Apply` write +the body verbatim; the diff is derived from the body. The overwrite remains +gated by the normal authorization/approval boundary. Regression: +`TestArtifactIntegrity_FileCreateAgainstExistingTargetIsCanonical`. + +### 3.3 PATCH bounded-envelope preview divergence (VERIFIED — found live, fixed) + +While exercising `izen run` live, a third divergence surfaced on the **PATCH** +path. Phase 2.1 §2.2 asserted PATCH was clean, but that assertion holds only for +the full-artifact `ResolveModifiedContent` path. The **bounded-patch** path sets +`raw = verbatim` (the envelope) and compiled the preview/evidence diff with +`compileDiff(raw)` (`executor.go:4110`). `ExtractBoundedPatch` and the changeset +pipeline are two parsers of the same model output; on a SEARCH/REPLACE envelope +with stray blank lines around the markers they disagreed: the extractor resolved +the applied body while the changeset pipeline read the whole envelope as a +full-file replacement. + +Live evidence before the fix (`izen run -dir "change 'world' to 'everyone' +in @greet.txt"`): + +```text +proposal ... operation=UPDATE (+2 -2) +--- a/greet.txt ++++ b/greet.txt +@@ -1,2 +1,7 @@ ++<<<<<<< SEARCH + Hello world ++======= ++Hello everyone ++>>>>>>> REPLACE +... +applied greet.txt = "Hello everyone\n" ← the envelope was reviewed; the body was written +``` + +**Fix (at the owner):** the preview and the mutation-boundary evidence are +compiled from the **canonical bytes the apply will write** (`Patch.Modified`) +against the real baseline, via `changeset.UnifiedDiffBetween` +(`internal/changeset/compiler.go`). The raw-envelope re-parse remains only as a +fallback. Regression: +`TestArtifactIntegrity_MalformedPatchEnvelopePreviewIsTheAppliedArtifact`; live +re-run in §7.2. + +No parsing heuristic was broadened and CREATE/PATCH applied semantics are +unchanged: only the *description* of the mutation was made equal to the bytes +the mutation writes. + +--- + +## 4. Phase B — migration contract + +> **Removed owner:** the `izen run` execution lifecycle — +> `internal/app.Pipeline` + `cmd/izen/runtime_ledger.go` + `publishRunLifecycle`. +> +> **Authoritative destination / admission seam:** `compose.Wire` → +> `autonomy.Driver` over `RuntimeExecutor`, reached through the CLI seam +> `internal/cli.RunAuthoritative`, sharing one production path with +> `izen prompt` / `izen orchestrate`. + +**Behavior preserved (parity required):** + +* `izen run [-dir ] [-target ]... ""` — public syntax and + one-prompt rule unchanged (tested in `cmd/izen/runtime_test.go`). +* Provider selection, config precedence and provider/model fail-fast binding + (`buildActiveProvider`, `validateProviderModelBinding`) unchanged. +* An explicit `-target` set is bound through the same authoritative target seam + as a human-named target; it is resolved, derived and admission-gated, and + never bypasses resolution. New `Driver.SetTargets` (the push form of the + clarification statement), consumed once per run. +* Cancellation is preserved and distinct from failure/success (ctx gate, + `Driver.Abort`; `cli.RunAuthoritative` cancellation test). +* Approval, artifact integrity, authorization and unrelated-file preservation + are owned by the runtime (see §3). +* Audit (`.izen/audit/events.ndjson`), durable execution ledger + (`.izen/runtime/ledger.ndjson`) and session-start checkpoint semantics are + preserved by the runtime; the CLI materializes the derived snapshot at + finalization (see §5.2). + +**Behavior intentionally corrected / rejected:** + +* **Human approval is now required for a mutation.** `izen run` previously wrote + through the substrate with no approval gate. On the authoritative runtime the + per-candidate `[y/N]`/inspect boundary is mandatory; the command plays the + human exactly as `izen orchestrate` does. This is the runtime contract, not a + parallel loop. +* **The bespoke "V3 pipeline audit trail" stdout block is gone.** The audit of + record is the runtime's own `events.ndjson`; the command renders the runtime's + proposal and terminal projection. (The old printout was a lossy re-render.) +* **Direct-chat answers are not surfaced (REMAINING capability gap).** See §8. + +**Rollback boundary:** revert `cmd/izen/authoritative.go`, `runtime.go`, +`orchestrate.go` and restore the deleted `runtime_ledger.go`; the runtime and +`internal/app` are untouched and their tests still pass. The runtime-side +additions (`SetTargets`, `$run` provenance, the two executor integrity fixes) are +small and independently justified. + +**Files expected to change:** `cmd/izen/*`, `internal/runtime/autonomy/driver.go`, +`internal/runtime/autonomy/objective_completion.go`, `internal/execution/*`, +`internal/changeset/compiler.go`, `internal/architecture/*_test.go`. + +--- + +## 5. Phase C — implementation + +### 5.1 One authoritative CLI lifecycle (new `cmd/izen/authoritative.go`) + +`runAuthoritativeCommand(dir, objective, scope, label, targets)` is the single +production wiring for every migrated headless surface: + +1. `config.Load` + `buildActiveProvider` (fail closed without a provider). +2. `compose.Wire(WithRoot, WithConfig, WithProvider, WithAuditDir, + WithLanguageID(detected))`. +3. `app.Autonomy.GrantDefault(read/analyze/propose/mutate/verify)` (admission + grant; the per-candidate `[y/N]` remains a separate gate). +4. `app.Autonomous.SetTargets(targets)` when `-target` was given. +5. session-start checkpoint (`app.Execution.ShadowCP`). +6. preflight dispatch `app.Runtime.Execute(SubmitPromptCmd{Prompt, Mode:"build"})`. +7. `cli.RunAuthoritative(ctx, app.Autonomous, terminalBridge, authorize, scope, objective)`. +8. snapshot finalization + `app.FlushAudit`; render the runtime terminal. + +`runOrchestrateCommand` and `runRuntimeCommand` now only parse arguments and call +this helper (`$orchestrate` / `$run` respectively). No command owns a proposal +loop, a provider call, a planner or a terminal decision. + +### 5.2 Preserved semantics + +* **Terminal truth is the Driver's.** The command switches on + `res.Termination.State` (`RuntimeCompleted` / `RuntimeUnsubstantiated` / + default abort) and prints the runtime's `reason`; it never infers success. +* **Cancellation** resolves to the runtime's abort/terminal, never a fabricated + success. +* **Durable view materialization.** The driver records the terminal transition + into the journal but does not materialize the derived `snapshot.json` at that + transition (it is rebuilt from the journal on the next `Open`). To preserve + the retired headless path's checkpoint-at-terminal behavior, the CLI + materializes the snapshot at finalization (`ledger.Checkpoint(CurrentID(), + "cli-finalize")`). This is a projection of the journal, never a second + terminal decision. Verified live: snapshot status `COMPLETED` (§7). +* **`$run` authority.** `scopeProvenance` maps `$run` to `domain.ScopeDynamic` + (same vector as `$prompt`/`$build`/`$orchestrate`), so the migrated surface is + not silently downgraded to read-only. + +### 5.3 What was removed + +`cmd/izen/runtime_ledger.go` (the bespoke durable ledger) and its test were +deleted: the authoritative `durable.TaskStore` wired by `compose.Wire` is now the +single execution journal for `izen run`, with the same event vocabulary and task +lifecycle. The pipeline-building code, the `cliGenerator`/ +`semanticExtractorAdapter` adapters and `publishRunLifecycle` were removed from +`runtime.go`. `internal/app` is no longer imported by any production `cmd/izen` +file (AST-locked in §6.3). + +--- + +## 6. Phase D — regression, parity and architecture tests + +New/updated tests assert observable lifecycle transitions, filesystem effects and +terminal evidence — not merely that the adapter was called. + +### 6.1 Artifact / approval / applied-bytes integrity (`internal/execution`) + +* `TestArtifactIntegrity_MalformedPatchEnvelopePreviewIsTheAppliedArtifact` + (new) — the live PATCH bug: preview diff carries no envelope; `+qux`/`-bar` + present; applied `foo\nqux\nbaz\n`; `sha256(applied) == preview.ArtifactDigest`. +* `TestArtifactIntegrity_FileCreateAgainstExistingTargetIsCanonical` (new) — + `FILE_CREATE` at an existing target: review is the body; applied == reviewed + digest; overwrite gated normally. +* The existing 12 integrity tests still pass unchanged (14 total). + +### 6.2 Driver target statement (`internal/runtime/autonomy`) + +* `TestSetTargetsBindsTheNextRunThroughTheAuthoritativeSeam` (new) — an explicit + target set is resolved by the run, deduplicated, applied to the held + candidate, nothing written before approval, and the statement is consumed once. +* `TestScopeProvenanceMapping` extended with `$run`/`$RUN` → `ScopeDynamic`. + +### 6.3 CLI parsing and architecture locks + +* `cmd/izen/runtime_test.go` (new) — `parseRunArgs` preserves the public grammar + (`-dir`, repeatable `-target`, one prompt, `-h`) and rejects ambiguous input. +* `internal/architecture/execution_ownership_convergence_test.go`: + * `TestExecutionOwnership_CLIRunDrivesAuthoritativeDriver` — `runRuntimeCommand` + crosses `runAuthoritativeCommand` and does **not** call `NewPipeline`, + `RunCycle`, `NewOrchestrator`, `NewProposalProvider`, `NewTerminalBridge`. + * `TestExecutionOwnership_AuthoritativeCommandWiresRuntimeAndDriver` — the + shared helper crosses `compose.Wire` + `cli.RunAuthoritative` and does not + construct a competing runtime/loop (`NewDriver` forbidden — the Driver is + built by `compose.Wire`). + * `TestExecutionOwnership_CLIProductionFilesDoNotImportApp` — no production + `cmd/izen` file imports `internal/app`; the retired pipeline cannot return. + * `TestExecutionOwnership_CLIOrchestrateDrivesAuthoritativeDriver` updated to + the shared seam. + +Coverage of the remaining brief items is provided by the existing suite, which +the migration did not weaken: target ambiguity/unresolved targets +(`internal/runtime/autonomy/acceptance_matrix_test.go`), approval/rejection/ +cancellation (`internal/cli/authoritative_test.go`), placeholder rejection +(`TestArtifactIntegrity_PlaceholderEchoRejectedBeforeApproval`), verification +observing the file (`TestArtifactIntegrity_PostApplyVerificationObservesTheFile`), +checkpoint/recovery (`internal/runtime/durable/*`), budget exhaustion and +provider failure (`internal/runtime/autonomy/r6c_provider_failure_test.go`, +`r5_nonprogress_test.go`), terminal truth when a mutation succeeds but the +objective is unproven (`objective_verification_test.go`), fail-closed admission/ +authorization (`TestAcceptance_UnauthorizedApprovalLeavesZeroDelta`). + +--- + +## 7. Phase E — verification and live evidence + +### 7.1 Deterministic test results (VERIFIED) + +| Command | Result | +|---|---| +| `go build ./...` | exit 0 | +| `go vet ./...` | exit 0 | +| `go test ./internal/execution/ -run TestArtifactIntegrity -count=1` | ok (14/14) | +| `go test ./internal/execution/ ./internal/changeset/ ./internal/architecture/ ./internal/cli/ ./internal/app/ ./internal/runtime/compose/ ./test/architecture/ -count=1` | ok (all seven) | +| `go test ./... -count=1` | **exit 0 — 212 packages `ok`, 29 with no test files, 0 `FAIL`, 0 panics** | + +**Flake investigation (not a regression).** The first full-suite run after the +artifact fix reported one failure: `TestR5_CaseA_EndToEndMutationReachesProven` +(`internal/runtime/autonomy`), with the message "no continuation record reported +PROVEN progress" and a record whose `SelectedAction` was still empty. This is +the continuation-record snapshot-timing flake already documented in the Phase 2.1 +report. It **passes in isolation**, with `-count=5`, and on every full-suite +re-run (`izen_fulltest2` failed once, `izen_fulltest3` and `izen_fulltest4` both +exit 0). No test was modified to pass. + +### 7.2 Live execution evidence (VERIFIED — real local model) + +Provider: local **Ollama `qwen2.5-coder:7b`** (real model; zero cost, no +credentials, no paid API). Workspaces: isolated `mktemp` git repos containing +only `keep.md` (CREATE) or `keep.md` + `greet.txt` (PATCH), committed as +baseline. Entry point: `./bin/izen run -dir ""` with `printf 'y\n'` +on stdin. + +**CREATE** — `"Create a new file named hello.md with the content 'Hello everyone'."` + +* surface: `$run` (audit `command_mode=$run`, `scope=$run`). +* owner: `autonomy.Driver` over `RuntimeExecutor` (loop.transition, + execution.admission.decision, execution.spec.frozen). +* provider calls: **2** — requirement pass `prompt_tokens=236, completion=56, + requested_output=512, finish=stop`; main generation `prompt_tokens=362, + completion=13, requested_output=4096, finish=stop`. +* transitions: `approval.required` → `execution.summary status=awaiting_human + parked=true boundary_action=approve` → operator `y` → + `execution.mutation.completed Outcome=created DiffAdds=1 DiffRemoves=0 + ApplyExecuted=true FilesystemChanged=true`. +* verification: `execution.verification.completed Outcome=NOT_APPLICABLE` + ("no verification configured for language") — an honest not-applicable. +* terminal: `objective.evaluated state=PROVEN granted=true + authority=objective_completion_authority`; `execution.finished Success=true + Outcome=created`; `run: run=run-1 completed approved=true reason="objective + satisfied: created; objective PROVEN by evidence"`. +* artifacts: `hello.md` = `Hello everyone\n`; `.izen/patches/run-1-patch-1.json` + canonical; `keep.md` byte-for-byte unchanged; `snapshot.json` = `COMPLETED`. + +**PATCH** — `"change 'world' to 'everyone' in @greet.txt"` + +* surface `$run`; provider calls **2** (requirement pass + main). +* proposal: `operation=UPDATE`, preview `-Hello world / +Hello everyone` + (**no envelope**) — the §3.3 fix. +* mutation: `execution.mutation.completed Outcome=changed DiffAdds=1 + DiffRemoves=1 ApplyExecuted=true FilesystemChanged=true`. +* terminal: `objective.evaluated state=PROVEN`; `execution.finished Success=true + Outcome=changed`; reason `"objective satisfied: changed; objective PROVEN by + evidence"`. +* artifacts: `greet.txt` = `Hello everyone\n`; `keep.md` unchanged; + `snapshot.json` = `COMPLETED`. + +**Conversational** — `"say hello in one short sentence"` (no mutation expected) + +* terminal: `objective.evaluated state=UNSUBSTANTIATED granted=false`; + command printed `run: run=run-1 unsubstantiated approved=false reason="objective + UNPROVEN (UNSUBSTANTIATED) … no workspace observation event for (no declared + target) …"`. The execution itself reported `Outcome=completed`, but the + runtime's completion authority refused to prove a workspace objective with no + observation. This is the honest terminal and is disclosed as a deviation in + §8. + +No credentials were assumed, no evidence fabricated, and no paid API was used. + +--- + +## 8. Remaining deviations and migration debt (REMAINING) + +1. **Conversational prompts no longer answer directly.** The retired + `app.Pipeline` short-circuited greetings to a direct chat pass and returned + `app.Result.Answer`. The authoritative runtime has no workspace-independent + completion contract, so a conversational `izen run` terminates + `UNSUBSTANTIATED` (the executor still produces `direct_response`, but the + objective authority cannot PROVE it). Per the brief ("if the authoritative + runtime cannot preserve an existing required behavior, stop and report the + missing capability rather than adding a parallel loop"), this is reported, not + patched with a chat fallback. A future phase could add an explicit read-only + direct-response terminal to the Driver, or route pure conversation to the + fenced `/ask` surface. +2. **`izen run` is now interactive at the approval boundary.** Documented + contract change required by the runtime (§4). +3. **The V3 pipeline audit-trail stdout block is gone**; the audit of record is + `.izen/audit/events.ndjson`. Documented contract change. +4. **`internal/app.Pipeline` remains in the tree**, unreachable from production + CLI (its brownfield/kernel locks still pass). It is retained as the rollback + boundary and for its own tests; it is no longer a lifecycle owner on any + command path. +5. **Derived-view materialization timing.** The CLI materializes `snapshot.json` + at finalization; the driver itself does not checkpoint at the terminal + transition. Recovery is journal-driven and correct regardless (replay + reconstructs the terminal state). Noted for a future convergence of the + driver's own finalization. +6. **`preview.AddedLines/RemovedLines` counts diff headers** (`+++`/`---`) via + `countDiffLines` (`executor.go:3252`), so the preview metrics can read one + higher than the mutation-boundary metrics for a minimal patch. Pre-existing, + cosmetic, and independent of integrity (the digest binds the applied bytes); + disclosed rather than silently "fixed". +7. **Unchanged from Phases 1/2:** `internal/cli.Stack`+`RunCycle`, + `internal/engine`, `runtime/kernel`+`kernelbridge` vocabulary, `modes/plan`/ + `investigate`, UI `/ask`, secondary provider callers and the budget bypass + resolvers remain fenced. + +--- + +## 9. Conclusion + +The migration is complete for its stated scope: + +```text +ONE REQUEST → ONE ADMISSION (compose.Wire + preflight dispatch) + → ONE AUTHORITATIVE RUNTIME (autonomy Driver, surface $run) + → CONTROLLED EXECUTION-PLANE CAPABILITIES (RuntimeExecutor) + → ONE HUMAN APPROVAL BOUNDARY (candidate-bound authorization) + → ONE AUTHORITATIVE TERMINAL RESULT (runtime ledger) +``` + +Verified, not assumed: + +* `izen run` no longer owns an execution loop: it parses arguments and enters the + shared authoritative lifecycle (AST locks + source). +* Explicit `-target` targets are bound through the runtime's canonical target + seam, not a private pipeline. +* Two reachable artifact-integrity violations were found (one by reading, one + live) and fixed at the owner: an envelope reviewed but a body applied, on both + the `FILE_CREATE`-against-existing-target path and the malformed-envelope PATCH + path. Approved bytes now equal applied bytes, bound by digest. +* Approval, rejection, cancellation, authorization and unrelated-file + preservation remain intact (tests + live). +* The full suite passes (212 `ok`, 0 `FAIL`), `go vet ./...` and `go build ./...` + are clean, and a live local model exercised CREATE and PATCH end to end with + `objective PROVEN by evidence` and a `COMPLETED` durable snapshot. +* Remaining deviations — direct chat, interactivity, the removed stdout audit + block, and the fenced owners — are explicitly documented, not claimed + converged. diff --git a/docs/report/IZEN_FINAL_RUNTIME_CONVERGENCE.md b/docs/report/IZEN_FINAL_RUNTIME_CONVERGENCE.md new file mode 100644 index 00000000..b889ecc5 --- /dev/null +++ b/docs/report/IZEN_FINAL_RUNTIME_CONVERGENCE.md @@ -0,0 +1,442 @@ +# IZEN — Final Runtime Convergence + +Date: 2026-10-09 · Branch: `fix/execution` · HEAD: `09636b0134d515acb2c06fbea30c9dd4eb7bf2fd` + +Working tree: **not clean** — the Phase 3 convergence change set plus this pass's +corrections are uncommitted (no commit was created; none was authorized). + +Evidence vocabulary, unchanged from Phases 1–3: + +* **VERIFIED** — proven by a command that ran on this working tree, or by a live + local-provider run whose artifacts are quoted. +* **OBSERVED** — read in the current source with a citation. +* **REMAINING** — a known deviation, disclosed and not claimed converged. + +--- + +## 1. Verified initial repository state + +| Fact | Value | +|---|---| +| Branch | `fix/execution` | +| HEAD | `09636b0134d515acb2c06fbea30c9dd4eb7bf2fd` (`fix(exec): canonical artifact approval`) | +| Parent | `041a344` (`feat(exec): phase 2 — CLI proposal lifecycle converges on Driver`) | +| Remotes | `main` = `f7fc26a`; no push performed | +| Working tree | modified/deleted/untracked as listed in §11; no reset, clean, or revert was applied | + +The starting tree already contained a Phase 3 change set (documented in the +untracked `docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE_PHASE3.md`). That work +was treated as user work and preserved. This report does **not** treat the Phase +3 report as ground truth; its material claims were re-derived from source and +re-executed where feasible. + +Baseline commands run **before** any correction: + +| Command | Result | +|---|---| +| `go build ./...` | exit 0 | +| `go vet ./...` | exit 0 | +| `go test ./internal/execution/ ./internal/changeset/ ./internal/architecture/ ./internal/cli/ ./internal/app/ ./internal/runtime/compose/ ./internal/runtime/autonomy/ ./test/architecture/ -count=1` | all `ok` | +| `go test ./... -count=1` | exit 0 — 212 packages `ok`, 29 with no test files, 0 `FAIL`, 0 panics | + +--- + +## 2. Actual execution topology + +One production lifecycle per headless command, shared by every migrated surface +(OBSERVED, `cmd/izen/main.go`, `cmd/izen/authoritative.go`): + +```text +cmd/izen + run → runRuntimeCommand ─┐ + prompt → runOrchestrateCommand ├→ runAuthoritativeCommand (cmd/izen/authoritative.go) + orchestrate→ runOrchestrateCommand ┘ + → compose.Wire (internal/runtime/compose) + → admission / preflight dispatch (app.Runtime.Execute SubmitPromptCmd) + → autonomy.Driver (internal/runtime/autonomy) + → ExecutorAdapter → RuntimeExecutor (internal/execution) + → authorized capabilities → mutation boundary → verification + → durable journal (.izen/runtime/ledger.ndjson) + audit (.izen/audit) + → cli.RunAuthoritative (human boundary) → terminal projection +``` + +* `izen run` parses `-dir`/repeatable `-target`/one prompt and calls + `runAuthoritativeCommand(dir, prompt, "$run", "run", targets)` + (`cmd/izen/runtime.go:46`). +* `izen prompt` / `izen orchestrate` call the same helper with `$orchestrate` + (`cmd/izen/orchestrate.go:31`). +* The retired `internal/app.Pipeline` lifecycle and the bespoke + `cmd/izen/runtime_ledger.go` are off every command path. No production + `cmd/izen` file imports `internal/app` (AST lock, and `grep` found none). +* `cmd/izen/main.go:45` references `gate.NewPipeline` / `orchestrator.NewLoop` + only as compile-time DI references (never called); they are not an execution + bypass. + +Terminal truth is the Driver's. `runAuthoritativeCommand` switches on +`res.Termination.State` (`RuntimeCompleted` → success, `RuntimeUnsubstantiated` +→ truthful non-proof, default → error) and only materializes the derived +`snapshot.json` from the journal at finalization (`ledger.Checkpoint`). + +--- + +## 3. Critical invariants and their enforcing components + +| Invariant | Enforcing component | Evidence | +|---|---|---| +| Runtime owns authority/state, not the LLM | `autonomy.Driver`, `ObjectiveCompletionAuthority` | OBSERVED; tests §6 | +| Model output is untrusted input, never authorization | `artifactBinder` (fail-closed path/context binding), `authorize` callback | OBSERVED; `binding_test.go`, `TestPhase1_ModelOutputCannotAuthorize` | +| One authoritative mutation path / journal | `compose.Wire` `TaskStore`; `internal/app` off CLI path | OBSERVED; `TestExecutionOwnership_CLIProductionFilesDoNotImportApp` | +| Approval binds the exact canonical mutation | `AuthorizeBuildCandidateContent` (CandidateID+CandidateDigest) + `Approve` re-check + **`Patch.Resolved`** | VERIFIED: `TestArtifactIntegrity_PatchAppliedBytesAreTheReviewedArtifact`; live patch run §7 | +| Preview bytes == authorized digest == applied bytes | `Patch.Resolved` writes staged bytes verbatim; digest over `Patch.Modified` | VERIFIED (this pass's correction) | +| Terminal state from runtime evidence | `ObjectiveCompletionAuthority.Evaluate`; `ledgerTerminal` maps only PROVEN→COMPLETED | OBSERVED; tests §6; live deny run §7 | +| Capability grants explicit and bounded | `GrantDefault(read/analyze/propose/mutate/verify)` + checkpoint-gated `AuthorizeBuildCandidateContent` | OBSERVED; `internal/cli/authoritative_test.go` | + +--- + +## 4. Findings + +### F1 — CRITICAL, confirmed, corrected: approved/reviewed bytes could differ from applied bytes on the ordinary PATCH path + +**Where.** `internal/execution/executor.go` staging + `internal/execution/patch.go` `apply`. + +**Root cause.** The executor resolves a payload to the final apply-ready content +at STAGING (`Patch.Modified`), and the preview diff and the authorization digest +bind exactly those bytes (`CandidatePreview.ArtifactDigest = sha256Hex(Modified)`, +`CandidatePreview.Digest`). But the staged `Patch` was **not** marked resolved, +so `PatchManager.apply` re-ran its own protocol cascade for every patch that was +not an addressed envelope: + +``` +SanitizeLLMResponse(Modified) → SplitAndFilterPatches → resolvePatchContent +``` + +`SanitizeLLMResponse` strips any line matching a hallucinated prefix (`FILE:`, +a lone ```` ``` ````, `>>>>>>> END_FILE`, …). A legitimate reviewed line that +matched one was removed before the write. The Phase 3 correction had fixed the +*description* of the mutation (diff compiled from `Patch.Modified`, and the +envelope case), but not the *applied bytes* for the non-envelope path. + +**Evidence (deterministic, reproduced).** A 40-line target, a SEARCH/REPLACE +whose REPLACE side is `FILE: keep me`: + +``` +preview.ArtifactDigest = a3d842b0… (content still contains "FILE: keep me") +res.Content contains "FILE: keep me" +applied contains "FILE: keep me" = false +applied digest = d7e2d534… != reviewed digest a3d842b0… +``` + +In the small-file variant the same defect surfaced as a hard apply failure of an +already-approved, valid candidate (`ambiguous snippet … for existing file`). + +**Correction.** Added `Patch.Resolved` (`internal/execution/patch.go`), set by +the executor for every staged patch (`internal/execution/executor.go:4102`). +`apply` now writes a resolved patch's `Modified` **verbatim**, using the existing +resolver only as a pure safety validator (ambiguous snippet / raw protocol +residue / truncation) — never as a rewriter. Raw payloads staged by legacy +`PatchManager` callers leave `Resolved` unset and keep the old cascade. + +**Regression.** `TestArtifactIntegrity_PatchAppliedBytesAreTheReviewedArtifact` +(`internal/execution/artifact_approval_integrity_test.go`). + +### F2 — MATERIAL, confirmed, partially corrected: workspace-independent direct answers + +**Where.** `DeriveTaskContract` + `authorRuntimeConditions` +(`internal/execution/objective_authority.go`, `objective_contract.go`). + +**Root cause.** Every read-only objective required a workspace observation, so a +request that has no workspace (a greeting) could never be PROVEN. The +`conversation` intent is the one intent the classifier marks as +`RequiresWorkspace() == false` (`internal/autonomy/intent.go:56`), yet its +contract still demanded `cond-observed`. + +**Correction (implemented and tested).** A `conversation` intent now derives a +`TaskRead` contract with `RequiresResponse=true`, +`RequiresObservation=false`, and authors only the `cond-response-delivered` +obligation. Evidence remains runtime-owned: `ResponseProduced` is set from the +delivered bytes (`ObjectiveEvidence` built from `res.Content != ""`), never from +a model self-report. The clause is scoped to `conversation` only; explanation / +investigation / review objectives still require an observation and still fail +closed without one. + +**Live.** `izen run "hello"` → `completed approved=false reason="objective +satisfied: completed; objective PROVEN by evidence"`. + +**Regression.** `TestConversationContractNeedsNoWorkspaceObservation` +(execution, contract level) and +`TestConversationTerminatesProvenWithoutWorkspaceObservation` (autonomy, driver +level). + +**REMAINING (F2b).** A general workspace-independent *question* is classified +`explanation`, not `conversation` (e.g. the Phase 3 example "say hello in one +short sentence"). It still terminates `UNSUBSTANTIATED`, which is the honest +state given the current contract. See §10 for the precise blocker. + +### F3 — LOW, disclosed, not corrected: nested-marker `FILE_CREATE` boundary + +A `FILE_CREATE` body whose own line is exactly `>>>>>>> END_FILE` terminates the +envelope there. Canonical bytes and applied bytes agree (both the truncated +body); it is a transport-format limitation, not an authorization gap. Unchanged +from Phase 3. + +### F4 — LOW, disclosed, not corrected: preview metric counts diff headers + +`preview.AddedLines/RemovedLines` counts `+++`/`---` via `countDiffLines`, so a +preview can read one higher than the mutation-boundary metric. Cosmetic; the +digest binds the applied bytes. Unchanged from Phase 3. + +### F5 — INFORMATIONAL: `Patch` serialization includes the new marker + +`Patch.Resolved` is `json:"resolved,omitempty"`. It survives the durable patch +record and a resume/restart, so an approved patch still applies verbatim after a +process restart. + +--- + +## 5. Corrections and regression tests + +| Correction | File(s) | Regression | +|---|---|---| +| F1 verbatim apply | `internal/execution/patch.go`, `internal/execution/executor.go` | `TestArtifactIntegrity_PatchAppliedBytesAreTheReviewedArtifact` | +| F2 conversation contract | `internal/execution/objective_authority.go`, `internal/execution/objective_contract.go`, `internal/runtime/autonomy/objective_completion.go`, `internal/runtime/autonomy/objective_lifecycle.go` | `TestConversationContractNeedsNoWorkspaceObservation`, `TestConversationTerminatesProvenWithoutWorkspaceObservation` | + +Both corrections are the smallest coherent change that restores the invariant at +the existing owner. No second execution loop, authority owner, or mutation path +was introduced. Phase 3's envelope/integrity tests pass unchanged. + +--- + +## 6. Behavioral matrix + +Evidence type: **U** = unit/regression, **I** = integration/end-to-end, +**L** = live local provider, **M** = manual source inspection. + +| Scenario | Required property | Result | Evidence | +|---|---|---|---| +| Direct conversational question (`conversation` intent) | truthful completion, no mutation | PASS | U `TestConversationTerminatesProven…`; L `izen run "hello"` → PROVEN/COMPLETED | +| Direct question classified `explanation`, no workspace | truthful response or correctly classified limitation | LIMITATION | L `izen run "say hello…"` → UNSUBSTANTIATED (F2b) | +| Read-only investigation | correct evidence, no mutation | PASS | U `evaluateRead/Review`, `TestConversationContract…` guard clause; I autonomy suite | +| Plan-only request | no unintended execution | PASS | U/I `acceptance_matrix_test.go` | +| Create a new file | correct bytes, approval, verification | PASS | L CREATE run §7; U `TestArtifactIntegrity_FileCreate*` | +| Modify an existing file | exact approved artifact applied | PASS | U `TestArtifactIntegrity_PatchAppliedBytesAreTheReviewedArtifact`; L PATCH run §7 | +| Ambiguous target | clarification, no guessed mutation | PASS | U `scope_ambiguity_test.go` | +| Multiple candidate targets | no implicit authorization expansion | PASS | U `TestAcceptance_UnauthorizedApprovalLeavesZeroDelta` | +| User denies approval | no mutation | PASS | I `TestAuthoritativeRejectNeverAuthorizes`; L deny run §7 | +| Provider failure | truthful failure state | PASS | U `r6c_provider_failure_test.go`, `TestModelFailureProducesNoArtifact` | +| Verification failure | never falsely proven | PASS | U `TestTruthMatrix_VerifierFailureRollsBack` | +| Resume after clarification | fresh request-derived decisions | PASS | U `clarification_invalidation_test.go` | +| Resume after interruption | no unauthorized duplicate mutation | PASS | U `internal/runtime/durable/*` | +| Explicit `-target` | correct target, bound through resolution | PASS | U `TestSetTargetsBindsTheNextRun…`, `TestParseRunArgs` | +| Unrelated workspace files | unchanged unless authorized | PASS | U/L `keep.md` byte-identical in CREATE/PATCH/deny runs | + +Unsupported in this pass: live `$hot` / `/plan` / `/build` UI surfaces (fenced, +unchanged from Phases 1–2); no live provider run was performed for them. + +--- + +## 7. Build, vet, test, race, and live-execution results + +Deterministic (VERIFIED, this working tree): + +| Command | Result | +|---|---| +| `go build ./...` | exit 0 | +| `go vet ./...` | exit 0 | +| `gofmt -l internal/execution internal/runtime/autonomy cmd/izen` | empty | +| `go test ./... -count=1` (after all corrections) | exit 0 — 212 `ok`, 29 no-test-files, 0 `FAIL`, 0 panics | +| `go test -race -count=1 ./internal/execution/ ./internal/runtime/autonomy/ ./internal/cli/ ./internal/runtime/durable/ ./internal/architecture/` | exit 0, no data race | +| `go test ./internal/execution/ -run TestArtifactIntegrity -count=1` | 15/15 `PASS` | +| `go test ./internal/runtime/autonomy/ -run TestR5_CaseA_EndToEndMutationReachesProven -count=5` | `ok` (previously-reported flake did not reproduce) | + +Live local provider (VERIFIED): Ollama `qwen2.5-coder:7b`, isolated `mktemp` git +repos, `printf 'y\n' | ./bin/izen run -dir ""` with a freshly built +binary from this tree. + +* **CREATE** — `"Create a new file named hello.md with the content 'Hello everyone'."` + * proposal `targets=[hello.md] operation=CREATE (+1 -0)`; diff `+Hello everyone` + * approval `y` → terminal `run: run-1 completed approved=true reason="objective satisfied: created; objective PROVEN by evidence"` + * artifacts: `hello.md = "Hello everyone\n"` (`od -c`); `keep.md` byte-identical; + `.izen/runtime/snapshot.json` task `status: COMPLETED`; + `.izen/patches/run-1-patch-1.json` `"modified":"Hello everyone\n"`, + `"canonical":true`, `"resolved":true`; audit `objective.evaluated state=PROVEN granted=true` +* **PATCH** — `"change 'world' to 'everyone' in @greet.txt"` + * proposal `operation=UPDATE (+2 -2)`; preview `-Hello world / +Hello everyone` (no envelope) + * terminal `run: run-1 completed approved=true reason="objective satisfied: changed; objective PROVEN by evidence"` + * artifacts: `greet.txt = "Hello everyone\n"`; `keep.md` unchanged; snapshot `COMPLETED`; `objective.evaluated state=PROVEN` +* **Conversation** — `"hello"` → `run: run-1 completed approved=false reason="objective satisfied: completed; objective PROVEN by evidence"`, audit `state=PROVEN granted=true` +* **Explanation (limitation)** — `"say hello in one short sentence"` → `unsubstantiated … completion condition "cond-observed" is unmet`, audit `state=UNSUBSTANTIATED granted=false` +* **Denial** — PATCH prompt answered `n` → `greet.txt` byte-identical (`Hello world\n`), snapshot task `status: FAILED`, process exit 1 + +No paid API was used; no credentials were assumed; no evidence was fabricated. + +--- + +## 8. Artifact-integrity evidence + +For the corrected ordinary-PATCH scenario the five required artifacts agree +(VERIFIED): + +| Artifact | Value | +|---|---| +| Candidate identity | held `Patch` id (preview `CandidateID == PatchID`) | +| Exact preview bytes | preview diff compiled from `Patch.Modified` (`changeset.UnifiedDiffBetween`) | +| Authorization-bound digest | `sha256(Patch.Modified)` carried as `CandidateDigest`, re-checked in `Approve` | +| Applied bytes | `Patch.Modified` written verbatim (`Patch.Resolved`) | +| Post-apply verification | per-target gate runs inside the mutation boundary | +| Unrelated files | unchanged (`keep.md` byte-identical) | +| Durable objective state | `objective.evaluated state=PROVEN`; task `COMPLETED` | + +The deterministic `TestArtifactIntegrity_PatchAppliedBytesAreTheReviewedArtifact` +asserts `applied == res.Content`, the reviewed line is present in the applied +file, and `sha256(applied) == preview.ArtifactDigest`. The Phase 3 tests +(`…MalformedPatchEnvelopePreviewIsTheAppliedArtifact`, +`…FileCreateAgainstExistingTargetIsCanonical`) and the other 13 integrity tests +still pass. + +--- + +## 9. Objective-state and recovery semantics + +Canonical terminal vocabulary (OBSERVED, `objective_authority.go`): +`PROVEN`, `UNSUBSTANTIATED`, `FAILED`, `REQUIRES_AUTHORIZATION`; projected to +loop states `RuntimeCompleted` / `RuntimeUnsubstantiated` / abort / human +boundary. Only `PROVEN` authorizes completion. + +| Request category | Contract | Proof obligation | Terminal | +|---|---|---|---| +| Conversation | READ | delivered response | PROVEN / UNSUBSTANTIATED | +| Direct question (`explanation`, no workspace) | READ | response **and** workspace observation | UNSUBSTANTIATED (F2b) | +| Read / investigation / review | READ/REVIEW | observation + response | PROVEN / UNSUBSTANTIATED | +| Planning (no execution) | READ/REVIEW | observation + response | PROVEN / UNSUBSTANTIATED | +| Create / modify / delete | CREATE/PATCH/DELETE | artifact + durable mutation (+verifier) | PROVEN / UNSUBSTANTIATED / FAILED | +| Already satisfied | IDEMPOTENT | structural NO-OP confirmation | PROVEN / UNSUBSTANTIATED | +| Ambiguous | human clarify | target decision | parked / AWAITING_HUMAN | +| Blocked authorization | REQUIRES_AUTHORIZATION | human decision | AWAITING_HUMAN | +| Provider error / refusal | FAILED | — | FAILED | +| Truncation | UNSUBSTANTIATED | — | UNSUBSTANTIATED | +| Cancellation | UNSUBSTANTIATED | — | UNSUBSTANTIATED | + +Durability (`internal/runtime/autonomy/ledger.go`): `ledgerTerminal` records +`RuntimeCompleted` as `RecordTerminalResult(ok=true)` → `TaskCompleted`; every +other terminal state records a failure classification and +`RecordTerminalResult(ok=false)` → `TaskFailed`; a parked run is `PAUSED`, not +terminal. The CLI's `snapshot.json` is materialized from that journal, so a +failed/unsubstantiated run cannot read `COMPLETED` (VERIFIED by the live deny +run: task `FAILED`). Audit flush failure invalidates success without rolling back +a committed mutation (`runAuthoritativeCommand`). + +Crash consistency limitation (honest): rollback is the `MutationSet` shadow +backup within one apply transaction; there is no multi-process distributed +transaction. Interrupted tasks replay non-terminal from the journal +(`internal/runtime/durable/*`). + +--- + +## 10. Remaining limitations and explicit non-goals + +1. **F2b — general workspace-independent direct answers.** A non-`conversation` + read-only request with no workspace (e.g. a general knowledge question + classified `explanation`) still requires an observation and terminates + `UNSUBSTANTIATED`. **Precise blocker:** the deterministic discriminator that + exists is the *dispatched strategy* (`strategy.DirectResponse` with + `ContextPolicyNone`), not the intent label; the intent label `explanation` + also covers workspace-dependent explanations ("explain @main.go"), so keying + the workspace-independent contract on the intent would risk proving an + un-observed workspace explanation. Threading the dispatched strategy into + both `DeriveTaskContract` and the *cached* `DeriveObjectiveContract` + (whose conditions author `cond-observed`) is a lifecycle-ordering change: + the objective contract is derived/cached by the Driver before the strategy is + guaranteed authoritative. Recommended decision (not implemented): add a + `WorkspaceIndependent` input sourced from the resolved strategy and refresh + the objective contract's authored conditions when the strategy is a + zero-context direct answer — without weakening the observation obligation for + any strategy that admitted workspace/repository material. +2. `izen run` is interactive at the approval boundary (runtime contract). +3. The bespoke "V3 pipeline audit trail" stdout block is gone; the audit of + record is `.izen/audit/events.ndjson`. +4. `internal/app.Pipeline` remains in the tree, unreachable from production CLI; + retained as the rollback boundary and for its own tests. +5. Derived-view materialization happens at CLI finalization; recovery is + journal-driven. +6. Nested-marker `FILE_CREATE` boundary (F3) and preview metric counts (F4). +7. Unchanged from Phases 1/2: `internal/cli.Stack`+`RunCycle`, the UI `/ask`, + `/plan`, `/build`, `$hot` surfaces, secondary provider callers and budget + bypass resolvers remain fenced and were not converged in this pass. +8. **Not verified in this pass:** live `$hot`/UI mutation runs; live runs for + providers other than local Ollama; multi-process crash interleavings. + +Non-goals: kernel rewrite, package reorganisation, UI redesign, new feature +implementation, commits, pushes. + +--- + +## 11. Final repository state and changed-file list + +HEAD unchanged: `09636b0134d515acb2c06fbea30c9dd4eb7bf2fd`. No commit created. +Working tree not clean (by design — existing user changes preserved). + +Pre-existing (Phase 3, carried in the working tree when this pass began): + +``` + M cmd/izen/orchestrate.go + M cmd/izen/runtime.go + D cmd/izen/runtime_ledger.go + D cmd/izen/runtime_ledger_test.go + M internal/architecture/execution_ownership_convergence_test.go + M internal/changeset/compiler.go + M internal/execution/artifact_approval_integrity_test.go + M internal/execution/executor.go + M internal/runtime/autonomy/behavior_test.go + M internal/runtime/autonomy/driver.go + M internal/runtime/autonomy/objective_completion.go +?? cmd/izen/authoritative.go +?? cmd/izen/runtime_test.go +?? docs/report/IZEN_EXECUTION_TOPOLOGY_CONVERGENCE_PHASE3.md +?? internal/runtime/autonomy/target_statement_test.go +``` + +Added/modified by this pass: + +``` + M internal/execution/patch.go F1 verbatim apply + M internal/execution/executor.go F1 mark staged patches Resolved + M internal/execution/artifact_approval_integrity_test.go F1 regression + M internal/execution/objective_authority.go F2 conversation contract + M internal/execution/objective_contract.go F2 Intent input + condition + M internal/execution/objective_contract_test.go F2 contract-level regression + M internal/runtime/autonomy/objective_completion.go lifecycleIntent() + M internal/runtime/autonomy/objective_lifecycle.go pass Intent +?? internal/runtime/autonomy/conversation_contract_test.go F2 driver-level regression +?? docs/report/IZEN_FINAL_RUNTIME_CONVERGENCE.md this report +``` + +--- + +## 12. Verdict + +**CLOSED WITH EXPLICIT LIMITATIONS.** + +* Exactly one authoritative production execution lifecycle: **yes** (§2, AST + locks). +* Any production mutation bypasses the authorization boundary: **no** — apply is + denied without a token (`checkAuthorization`), and the CLI always issues a + candidate-bound token. +* Approval bound to the exact canonical mutation applied: **yes** (§8; F1 + corrected). +* Stale state / clarification reusing an obsolete authorization: **no** — + `Approve` re-checks candidate identity and digest against the held preview. +* Any failure path producing `PROVEN`: **no** — only + `ObjectiveCompletionAuthority.PROVEN` maps to `COMPLETED`. +* Objective completion meaningful per category: **yes, except** the disclosed + workspace-independent direct-answer gap (F2b). +* Durable state reflects actual outcomes: **yes** (§9; live evidence). +* Critical invariants enforced by tests/architecture locks: **yes** for the + corrected invariant; locks exist for the single lifecycle. +* Remaining deviations explicit and bounded: **yes** (§10). +* Repository state reproducible and change set reviewable: **yes** (§11; all + commands above re-runnable). + +The one material gap (F2b) is a product-contract decision with a stated blocker +and recommended design, not an unresolved correctness, authorization, or +durability defect. A negative answer to a critical safety/correctness invariant +does not remain. diff --git a/docs/report/IZEN_OBJECTIVE_ORCHESTRATION_TRUTHFUL_EXECUTION.md b/docs/report/IZEN_OBJECTIVE_ORCHESTRATION_TRUTHFUL_EXECUTION.md new file mode 100644 index 00000000..6b7d8753 --- /dev/null +++ b/docs/report/IZEN_OBJECTIVE_ORCHESTRATION_TRUTHFUL_EXECUTION.md @@ -0,0 +1,139 @@ +# IZEN — Objective Orchestration & Truthful Execution + +**Status:** implemented, full suite green +**Branch:** `fix/execution` +**Scope:** evidence-bound project scope derivation, truthful completion for broad objectives, durable human decisions. + +This report records the repository-grounded audit and the changes made to close +the objective-orchestration defect. It follows the report convention under +`docs/report/`. + +--- + +## 1. Confirmed root causes + +### RC-1 — A project-level objective with no declared artifact kind is forced to +### clarify by filename (and can be reduced to a single file). + +`$prompt redesign this project help me` on a real static-website workspace +(`index.html`, `styles.css`, `script.js`, `readme.md`) previously parked at +`HumanBoundaryClarify` with every workspace file as an option, before any +provider call. The exact production trace: + +- `strategy.Select` classifies `redesign` via `architecturalSignals` → + `OperationArchitect`, and with no explicit/inferred target returns + `RepositoryInvestigation` (read-only) — + `internal/execution/strategy/selector.go:77-81,248-261`. +- The objective layer still classifies the request as a mutation + (`internal/runtime/autonomy/objective_completion.go:159`, + `internal/autonomy/intent.go:237-243`), so `admissionIntent()` elevates the + read-only strategy back to `IntentMutate` (`driver.go:2068-2077`). +- `execution.DeriveScope` only derives a target when the objective literally + names an artifact kind (`internal/execution/derive.go:286-297`). “redesign + this project” names none, so it returns `UNRESOLVED` with the whole candidate + set as evidence. +- The preflight admission gate turns the resulting unresolved/ambiguous binding + into `AdmissionDisambiguate` + (`internal/runtime/autonomy/preflight_admission.go:351-352`) and the driver + parks (`driver.go:2266-2272`). + +Selecting the offered `index.html` then re-resolved as a single-file +`TargetedMutation`, which is the reported “reduced to selecting index.html”. + +### RC-2 — A skipped verifier satisfied the integrity obligation for broad objectives. + +`ObjectiveEvidence.verifierSatisfied` treated `VerificationSkipped` as +satisfying a required verifier (`objective_authority.go:440-445`), and +`ObligationIntegrityHeld` marked the condition satisfied as +`GATE_NOT_APPLICABLE` (`objective_conditions.go:268-274`). HTML/CSS/JS have no +verification contract (`internal/language/defs.go`), so a broad redesign could +be reported `PROVEN` / CLI `outcome=proven` on the strength of an applied patch +alone. + +### RC-3 — Human decisions were not durably recorded. + +`EventProposalAuthorized` existed but the autonomy driver never appended a +decision record; only mutation/terminal events were durable +(`internal/runtime/autonomy/ledger.go`). A resumed run could not read *what* +the human authorized. + +--- + +## 2. Changes + +| Area | Change | +|---|---| +| `internal/execution/strategy/{strategy,selector}.go` | `ExecutionStrategyProfile.Operation` exposes the gateway's own operation family so a downstream authority reads the scope width instead of re-classifying the prompt. | +| `internal/execution/derive.go` | `DerivationRequest.ProjectLevel`; `deriveProjectScope` binds the workspace's authored implementation artifacts for a project-level no-kind objective, excluding documentation/data/configuration/assets, bounded to 12 files / 5 extension families. | +| `internal/runtime/autonomy/adapter.go` | `DeriveScopeWith(prompt, stated, projectLevel)`; `DeriveScope` delegates with `false` (backward compatible). | +| `internal/runtime/autonomy/driver.go` | `isProjectLevelObjective` (Architect/Refactor, never `$hot`); `deriveEvidenceScope` passes it; resume paths record durable human decisions. | +| `internal/execution/objective_authority.go` | `ObjectiveEvidence.BroadObjective`; `verifierSatisfied`/`VerifierVerdict` refuse to treat a skipped verifier as a pass for a broad objective. | +| `internal/execution/objective_contract.go`, `objective_conditions.go` | New `VerifyGateUnverified` state and the `ObligationIntegrityHeld` branch that produces it. | +| `internal/runtime/durable/types.go` | `EventHumanDecision` (audit lineage). | +| `internal/runtime/autonomy/ledger.go` | `ledgerHumanDecision` appends the action, candidate identity and content digest. | +| `internal/runtime/autonomy/approval_admission.go` | The approval review now carries the evidence-bound scope rationale. | +| `test/architecture/kernel_lock_test.go` | Updated the two line-numbered allowlist entries shifted by the adapter change. | + +### Intentional contract change + +`PROVEN` is now **narrower** for broad project-level objectives: when no +verification contract exists for the target language, a broad transformation +terminates `UNSUBSTANTIATED` with `GATE_UNVERIFIED` instead of `PROVEN`. +Content-determined objectives (an explicit creation, a declared delta) keep +their existing behaviour: a provably not-applicable gate is still not a failure. + +--- + +## 3. Regression tests + +| Mission test | Test | Level | +|---|---|---| +| A explicit create | `TestOrchestration_A_ExplicitCreateExactContent` | driver | +| B project redesign | `TestOrchestration_B_ProjectRedesignDerivesMultiFileScope`, `..._UnverifiedIsNotProven`, `TestCLIBench_ProjectRedesignDerivesMultiFileScope` | driver + subprocess | +| C existing target conflict | `TestOrchestration_C_ExistingTargetNeverSilentlyOverwritten` | driver | +| E no unnecessary clarification | `TestOrchestration_E_ClearLowRiskAndDiscoverableProceed` | driver | +| H skipped/failed verification | `TestOrchestration_H_SkippedVerifierIsDistinctFromPass` | unit | +| J surfaces / static authority | `TestOrchestration_J_HotSurfaceNeverWidensToProjectScope` | driver | +| durable human decision | `TestOrchestration_DurableHumanDecisionRecorded` | driver + durable ledger | +| derivation bounds | `TestProjectDerivation_*` | unit | + +`F`, `G`, `I` are already pinned by the existing +`clarification_invalidation_test.go`, `driver_test.go` and `ledger_test.go` +suites and were not duplicated. + +--- + +## 4. Validation + +``` +go build ./... clean +go test ./... -count=1 exit 0 (all packages ok) +golangci-lint run (changed packages) 0 issues +``` + +End-to-end subprocess evidence for the corrected project redesign: + +``` +exit=5 outcome=unsubstantiated +reason=objective UNPROVEN (UNSUBSTANTIATED): completion condition + "cond-integrity-held" is unmet: the verification gate reported + GATE_UNVERIFIED — refused completion claim over outcome "changed" +index.html, styles.css, script.js: changed +readme.md: byte-identical +``` + +--- + +## 5. Remaining gaps + +- A **general strategy proposal with human questions** (material migration + trade-offs, multi-select/free-form, recommendation, revision dependencies) is + not implemented as a first-class contract. The runtime reuses the existing + `DecisionSurface` and approval-boundary mechanisms; a proposal that names the + interpreted objective, recommends a strategy and asks adaptive questions + remains future work. +- Proposal/boundary **revision numbering** is not introduced; candidate binding + is content-digest based (`CandidateID` + `CandidateDigest`), which is + revision-safe for content but is not a monotonic review generation. +- Rendered-layout verification for visual redesigns is not performed; the + runtime reports `GATE_UNVERIFIED` rather than claiming a visual check. diff --git a/docs/report/IZEN_PRODUCTION_RUNTIME_ACCEPTANCE_REPORT.md b/docs/report/IZEN_PRODUCTION_RUNTIME_ACCEPTANCE_REPORT.md new file mode 100644 index 00000000..cc9419d4 --- /dev/null +++ b/docs/report/IZEN_PRODUCTION_RUNTIME_ACCEPTANCE_REPORT.md @@ -0,0 +1,798 @@ +# IZEN — Production Runtime Acceptance: Multi-Artifact Execution, Terminal-State Ordering, and Objective Integrity + +**Branch:** `fix/execution` +**Baseline:** `f79e6c4` (`feat(autonomy): durable proposal recovery`), clean tree. +**Input under audit:** `$prompt check this project and redesign it` over `index.html`, `script.js`, `styles.css` (`testdata/staticweb`). +**Verdict:** **PARTIALLY COMPLETE** — see §I. The two code-level defects that were +provably wrong are fixed with regression tests; two concerns are documented design +limitations rather than defects; two concerns (retry, intent) required no code change +and are pinned as already-correct. + +Every claim below is backed by source in this working tree and by commands run against +it. Line numbers are from the final tree. + +--- + +## A. Root causes (verified source locations and causal explanations) + +### A1 — Premature terminal objective state (P0) + +The completion authority was NOT the last gate a proposed completion passes, yet it +performed the terminal side-effects. + +- `internal/runtime/autonomy/objective_completion.go:211` `authorizeObjectiveCompletion` + ran the authority and, on `evaluation.Outcome.Proves()`, immediately called + `d.ledgerExecutionCommitted()` and `d.emitObjectiveEvaluated(..., granted=true)`. +- Two gates run **after** it and can revoke a completion: + `driver.go:2491` `authorizeBehavioralCompletion` (a behavioral stage that can mutate + the workspace and downgrade the decision) and `driver.go:2501` + `authorizeContractRecovery`. +- The authority's own durable method documents the hazard: + `internal/runtime/autonomy/ledger.go:128-135` — *"The authority's verdict is one gate; + the behavioral proof gate and the loop's own transition run after it and may still + downgrade the completion."* +- `emitObjectiveEvaluated` additionally stamped `BehaviorsProven = d.lastBehavior.Repairs` + (`internal/runtime/autonomy/forensics.go:480`) **before** the behavioral stage ran, so + the granted event could carry a stale behavioral count. + +**Consequence:** a terminal objective event (`objective.evaluated{granted:true}`) and an +`EXECUTION_COMMITTED` marker could describe a state a later gate then revoked. That is +exactly the invariant violation the mission names: *a terminal objective state committed +while authorized work that can change the outcome is still in flight.* + +### A2 — "First artifact applied, remaining artifacts still mutated" is a delivery-ordering artifact, not an executor lifecycle bug + +I traced the multi-artifact path and did **not** find a genuine "one artifact applied, +rest pending" window: + +- The driver issues **one** `adapter.Execute` per executing step + (`driver.go:2560`); the adapter passes the whole target list through + (`adapter.go`), and the executor loops all targets inside that one call + (`internal/execution/executor.go:3509`) returning the accumulated patch set + (`executor.go:4151`). +- Application is one `MutationSet` loop (`executor.go:2708`) that is + sequential-with-rollback, not atomic: on any apply error the whole boundary rolls back + (`executor.go:2740`). Per-artifact `mutation.completed`/`execution.finished` events are + emitted only after the whole loop. +- A per-target **artifact** failure discards the earlier staged patches + (`executor.go:3754` and the other `return nil, invs, …` sites), so partial artifacts are + never applied on that path. + +So the observed ordering is explained by the **asynchronous, drop-tolerant telemetry bus** +(`internal/events/bus.go`: control events take priority; telemetry can lag) combined with +the premature terminal objective event of A1. The literal `"Execution stopped"` string is +produced from the **aggregate** result (`internal/ui/gateway.go:669-703`), never from a +single artifact event, so it does not by itself claim a sub-operation stopped the task. +Per the mission's warning, I did not infer a lifecycle bug from log ordering alone. + +### A3 — All three derived requirements rejected (P0) + +The admissibility gate is `admitRequirements` (`internal/execution/objective_contract.go:645`). +A proposal is admitted only when BOTH hold: + +1. `groundedInRequest` (`objective_contract.go:744`) — at least `groundingFloor = 1/3` + (`:627`) of its significant words anchor in the request text, the resolved target + vocabulary, or a target basename. +2. `groundTargets` (`objective_contract.go:794`) — it literally names a target inside the + resolved scope. + +For `"check this project and redesign it"` the request vocabulary is tiny +(check/project/redesign) and names no file. A model requirement written in different +vocabulary ("restructure the markup into semantic sections", "improve visual hierarchy") +anchors on none of it and names no scope target, so it is rejected by rule 1 and/or 2. +All three rejections are therefore **deterministic consequences of a terse objective**, not +a silent drop. The defect was **observability**: `publishObjectiveRequirements` +(`objective_lifecycle.go:817`) printed `id=REJECTED` with no reason, so an all-rejected +derivation looked like an unexplained absence. Additionally, `deriveObjectiveRequirements` +contained a dead `for _, p := range proposals { _ = p }` loop that *looked* like it decided +something. + +### A4 — Token aggregate discrepancy (P1) + +- The driver's authoritative aggregate `AggregatedUsage` (`driver.go:1540`) is **cumulative + per run** and is reset only in `Run` (`driver.go:671-673`), never in `ResumeApprove`. +- The UI committed that **cumulative** total at **every** autonomous boundary + (`internal/ui/autonomous.go:882-889`, old): the park AND the terminal each dispatched a + `UsageUpdateMsg`, and `commitTokenUsage` adds to the session baseline + (`internal/ui/model.go:2818-2837`). +- Therefore one park plus one terminal of the same run added the same provider calls to the + footer twice. The provider ledger (per-call `ModelInvocation`, summed once by + `finalizeResult`, `executor.go:5747`) was correct; the **projection** double-counted. + +### A5 — HTML/JS/CSS verification skipped (P1) + +Rendered verification is **opt-in**: `WithRenderedVerificationPolicy` defaults to disabled +(`adaptive_proposal.go:83-92`); production wires it to `IZEN_RENDERED_VERIFY=1` +(`internal/runtime/compose/compose.go:1029-1031`). `renderedRequired` is set only from a +compiled proposal contract (`adaptive_proposal.go:499`), so with the env unset the rendered +stage never runs. There is no source-level verification contract for HTML/JS/CSS, so the +broad objective's integrity condition is truthfully `GATE_UNVERIFIED` +(`internal/execution/objective_conditions.go`; `TestOrchestration_B_ProjectRedesignUnverifiedIsNotProven`). +The defect was that **blocked/skipped rendered checks were silent**: +`ensureRenderedVerification` returned on the no-verifier and `$hot` branches without +publishing (`rendered_verify.go:344-354`), and the opt-in policy itself was never stated. + +### A6 — Intent `ask` vs `modification` (P2) + +Two distinct, independently-owned axes, not an inconsistent transition: + +- `[submit_prompt] intent parsed intent=ask` is the **command/interaction surface**: + `handlers.ClassifyIntent` (`internal/runtime/handlers/handlers.go:546-566`) returns `ask` + for any prompt without the `$prompt` marker. +- `modification` is the **semantic goal**: `autonomy.Classify` held by the + `IntentAuthority` (`internal/autonomy/controller.go`). The elevation is the intentional, + attributed blocking revision in `syncCanonicalIntent` + (`internal/runtime/autonomy/objective_completion.go:494-548`, `authority.Elevate`), and it + is **not** an authorization. The four-axis record is documented and emitted at + `objective_lifecycle.go:1004-1115` (`intentAxes`/`publishIntentAxes`). + +--- + +## B. Terminal lifecycle: before/after ordering + +**Before** (`observeAndRun`, `RuntimeDeciding`): + +``` +authorizeObjectiveCompletion → ledgerExecutionCommitted + objective.evaluated{granted:true} ← terminal side-effect +routeObjectiveContinuation +authorizeBehavioralCompletion → may revoke / may mutate the workspace +authorizeContractRecovery → may revoke +step(decision) → terminal loop transition +``` + +**After:** + +``` +authorizeObjectiveCompletion → STAGE the grant (d.pendingProven); no terminal event, no durable commit +routeObjectiveContinuation +authorizeBehavioralCompletion → may revoke / may mutate +authorizeContractRecovery → may revoke +commitObjectiveCompletion → terminal event + ledgerExecutionCommitted ONLY if still LoopComplete +step(decision) → terminal loop transition +``` + +Implementation: `objective_completion.go:211-260` (stage), `objective_completion.go:286` +(`commitObjectiveCompletion`), called at `driver.go:2528`. The staged grant is cleared at +every decision point (`objective_completion.go:217`) and per run (`driver.go:697`), so a +grant can never be committed against a later decision that did not prove anything. + +Evidence that no objective is finalized while relevant work remains in flight: +`TestObjectiveTerminalGrantIsNotCommittedWhenALaterGateDowngrades` forces the authority to +grant PROVEN and then a later gate to downgrade, and asserts **zero** +`objective.evaluated{granted:true}` events and a discarded grant. The positive control +`TestObjectiveTerminalGrantCommitsExactlyOnceWhenNoGateDowngrades` asserts exactly one +grant. `TestObjectiveTerminalGrantIsClearedOnEveryDecisionPoint` pins the per-decision +staging. + +**On cancellation/failure:** cancellation is the inter-step `ctx.Done()` gate +(`driver.go:2431`) and the executor's in-flight apply honours `ctx`; the deferred commit is +never reached, so no terminal objective state is written for a cancelled run. This is +unchanged and already covered by `TestR6_CancelAtContinuationBoundaryDoesNotStartNextAttempt` +and `TestR6_MutationBoundary_CancelBeforeAuthorizationLeavesZeroDelta`. + +--- + +## C. Requirement integrity + +**Why the three were rejected:** §A3. Each rejection is attributable to a named rule +(`not traceable to the user's request — introduces vocabulary the request never used`, or +`not grounded in any target of the objective's resolved scope`). + +**Determination:** the all-rejected outcome for `"check this project and redesign it"` is +**expected behavior of the current design, not a defect**, because: +- the objective declares no acceptance criteria in its own words; +- the model's proposals were abstract and named no scope target, so the runtime could not + decide their satisfaction deterministically; +- admitting them would make the objective permanently unprovable — the exact failure the + gate exists to prevent. + +The user's objective is still expressed by the **runtime-authored conditions** for the +derived task kind (`authorizeRuntimeConditions`, `objective_contract.go:529`): +`cond-scope-mutated`, `cond-observed`, `cond-post-mutation-reinspected`, +`cond-integrity-held`. Rejecting model requirements never removes these, and a rejected +requirement authors no completion condition (`objective_contract.go:505-519`). + +**Corrected contract/telemetry:** rejected requirements are now published with their reason +(`objective_lifecycle.go:822-836`, `id=REJECTED()`), and the dead no-op loop is +removed (`objective_lifecycle.go:210-216`). The gate itself is **not weakened**: +`TestCaseK_UngroundedRequirementIsRejected` still pins rejection, and +`TestRequirementRejectionCarriesAnAttributableReason` pins that the reason names the rule. + +--- + +## D. Retry behavior + +The bounded-patch rejection path is strict and unchanged: + +- Artifact parse failure at `executor.go:3858-3891` returns the typed + `ErrArtifactRetryableRejected` with the contract detail + (`executor.go:3874`), or `ErrHallucinatedAnchorError`/`ErrAmbiguousAnchorContinuation` + for N=0/N>1 anchors. +- Retry is owned by the recovery matrix, not the executor: + `MaxContractRecoveryAttempts = 2` (`driver.go:397`), consumed only where a re-prompt is + actually dispatched (`driver.go:2801`), read by `authorizeContractRecovery` + (`driver.go:1612`). Sub-task retry is 3 (`subtask_executor.go:35`); strict line-anchor + recovery is 1 (`executor.go:2189`). +- Repair context is the classified `DiagnosticSignal` + `recoveryBrief` + (`objective_lifecycle.go:743`) + zero-artifact directive; the rejected bytes are never + replayed. +- Budget exhaustion is terminal and truthful: zero-artifacts → + `ErrContractRecoveryExhausted` → `RuntimeUnsubstantiated` (`driver.go:1623`); ambiguous + anchors → DecisionSurface park; strict anchor exhaustion → abort. + +No code change was required. Existing tests already cover valid output, malformed→valid +repair, repeated malformed, exhausted budget, and cancellation during repair (see §H). + +--- + +## E. Verification + +Actual checks performed for the observed run: + +| Check | Ran | Evidence | +|---|---|---| +| Source-level verifier (HTML/JS/CSS) | **no contract exists** | `VerificationSkipped` → broad objective → `VerifyGateUnverified` | +| Rendered/browser DOM check | **no** (`IZEN_RENDERED_VERIFY` unset, no entry/checks) | `renderedRequired=false` | +| Structural artifact gate | yes | artifact accepted at `executor.go:4091` | +| Post-mutation re-inspection | yes | `bindStepEvidence` re-reads declared targets | + +- A broad project-level change without rendered verification is truthfully + `GATE_UNVERIFIED`, never PROVEN; `PROVEN` is narrower for broad objectives by design. +- The rendered stage runs the real local headless browser and evaluates deterministic + `dom_contains`/`dom_not_contains` checks on the post-script DOM + (`rendered_verify.go`). A screenshot is **recorded, never used as visual-quality proof**. +- **Fix:** blocked/skipped rendered checks are now visible — `publishRenderedSummary` + (`rendered_verify.go:390`) is called on every terminal path including blocked, and a + one-time `[render] SKIPPED: rendered verification is opt-in …` line is emitted for a broad + objective when the policy is off (`rendered_verify.go:340-356`). Tests: + `TestRendered_BlockedRequiredCheckIsVisible`, `TestRendered_OptInPolicyIsVisibleForBroadObjectives`, + `TestRendered_OptInPolicyIsSilentForBoundedObjectives`. + +--- + +## F. Token accounting + +**Authoritative meaning of each metric:** + +| Metric | Owner | Meaning | +|---|---|---| +| Provider input/output tokens | `ai.ProviderUsage` → `ModelInvocation.TokenInput/Output` (`executor.go:4231`) | provider-reported billing for one logical invocation | +| Per-execution aggregate | `finalizeResult` (`executor.go:5747`) | sum of `ModelCalls` where `inv.Known`; `Known` false if none reported | +| Per-run aggregate | `Driver.AggregatedUsage` (`driver.go:1540`) | cumulative across the run's parks/resumes | +| UI footer | `m.InputTokens/OutputTokens` (`model.go`) | committed session baseline + live estimates while executing | +| Unknown | `Known` flags end-to-end | explicit; never substituted in the authoritative account | + +**The exact accounting rule that explains the discrepancy:** the UI committed the driver's +**cumulative** per-run aggregate at every autonomous boundary, so a park + terminal of the +same run added the same provider calls to the session baseline twice. Provider ledger: +7 calls, 9,122 in / 624 out. UI ≈ 2 × that, consistent with one park and one terminal +commit of the same cumulative aggregate (plus any prior session baseline). The provider +totals were never wrong; the **projection** double-counted. + +**Fix:** `autonomousUsageDeltaCmd` (`internal/ui/autonomous.go:878`) commits only the +**delta** since the last commit for the **same run**, resets the baseline on a new +`RunID()`, and commits nothing for an UNKNOWN account. Tests: +`TestAutonomousUsageIsCommittedOncePerRun` (9122/624 once, not twice), +`TestAutonomousUsageCommitsNewSpendAcrossResume`, `TestAutonomousUsageResetsForANewRun`, +`TestAutonomousUsageUnknownCommitsNothing`. + +**Residual:** the exact observed 17k/1.1k also includes the session baseline from any prior +turn and the live in-flight estimate (`model.go:2860-2879`); the mechanism above accounts +for the material ~2× provider-call portion. Context-size estimates are never added to the +authoritative account. + +--- + +## G. Intent semantics + +| Field | Type | Owner | Authoritative transition | +|---|---|---|---| +| Raw prompt classification | `autonomy.Intent` | `autonomy.Classify` / `IntentAuthority` | `Resolve` then `Elevate` (one-way) | +| Command surface | `ask`/`build`/… | `handlers.ClassifyIntent` | `$prompt`/`/build` marker | +| Objective operation | `execution.Operation` | `objectiveSemantics` | from `taskContract().Kind` | +| Capability set | `autonomy.CapabilitySet` | `RequiredCapabilities` | not an intent | +| Workspace mode | `autonomy.Workspace` | `SelectWorkspace` | — | +| Proposal interaction | `ProposalIntent` | `ParseProposalIntent` | — | +| Mutation authorization | `MutationAuthorization` | authorization engine | separate from intent | + +`ask` (surface) and `modification` (semantic goal) are **not the same concept**, so their +differing labels are correct. `IntentAuthority.Elevate` is an explicit, attributed revision +(`[intent] revision ask -> modification rev=N: …`), and a modification objective does **not** +auto-authorize mutation (the approval gate and grant still apply). No code change required; +the four-axis record is emitted by `publishIntentAxes` (`objective_lifecycle.go:1105`). + +--- + +## H. Tests + +**Added (all deterministic, no sleeps used to hide concurrency; bounded waits only):** + +| Test | File | Pins | +|---|---|---| +| `TestObjectiveTerminalGrantIsNotCommittedWhenALaterGateDowngrades` | `internal/runtime/autonomy/objective_terminal_ordering_test.go` | no terminal objective state while a later gate can revoke | +| `TestObjectiveTerminalGrantCommitsExactlyOnceWhenNoGateDowngrades` | same | exactly one terminal grant | +| `TestObjectiveTerminalGrantIsClearedOnEveryDecisionPoint` | same | per-decision staging | +| `TestRequirementRejectionCarriesAnAttributableReason` | same | rejection reason visible | +| `TestRendered_BlockedRequiredCheckIsVisible` | `internal/runtime/autonomy/rendered_policy_visibility_test.go` | blocked check visible | +| `TestRendered_OptInPolicyIsVisibleForBroadObjectives` | same | opt-in policy visible, once | +| `TestRendered_OptInPolicyIsSilentForBoundedObjectives` | same | no noise for bounded objectives | +| `TestAutonomousUsageIsCommittedOncePerRun` | `internal/ui/autonomous_usage_delta_test.go` | 9122/624 committed once | +| `TestAutonomousUsageCommitsNewSpendAcrossResume` | same | only the delta is committed | +| `TestAutonomousUsageResetsForANewRun` | same | new-run baseline reset | +| `TestAutonomousUsageUnknownCommitsNothing` | same | unknown ≠ zero | + +**Commands and results (final tree):** + +``` +go build ./... clean +go vet ./internal/runtime/autonomy/ ./internal/ui/ clean +gofmt -l internal/ no output +golangci-lint run --timeout=5m ./internal/runtime/autonomy/... ./internal/ui/... 0 issues +go test -count=1 ./... 213 ok, 0 FAIL, exit 0 +go test -race -count=1 -run 'TestObjectiveTerminalGrant|TestRequirementRejection|TestAutonomousUsage|TestE2E_ParkedRun|TestPhase14|TestOrchestration_B' \ + ./internal/runtime/autonomy/ ./internal/ui/ ok, no data race +``` + +**Existing real multi-file / CLI coverage cited (not duplicated):** +`TestMultiFileMutationSingleTransaction`, `TestMultiFileRollbackOnApplyFailure`, +`TestTruthMatrix_MultiFilePartialFailureRollsBackAll`, +`TestMigrationFixture_IntegratedWorkflow` (two real artifacts, terminal `completed`), +`TestCLIBench_ProjectRedesignDerivesMultiFileScope` (real `izen` binary over `staticweb`). +Fake-provider fixtures remain distinct from the real-browser integration test +(`TestRenderedVerifier_RealBrowser`, skipped when no browser). + +**Concurrency behavior:** the new autonomy tests use the real event bus and bounded +`waitGrants`/`waitForActivity` drains (no fixed sleeps as proof); the race run is clean. + +--- + +## I. Verdict — **PARTIALLY COMPLETE** + +Against the six observed defects: + +| # | Observed defect | Status | Evidence | +|---|---|---|---| +| 1 | Premature terminal objective state during multi-artifact work | **FIXED** | §B; deferred commit + 3 tests | +| 2 | All three derived requirements rejected | **EXPLAINED + OBSERVABILITY FIXED** | §C; rejection reason published; gate intact | +| 3 | Artifact rejected by bounded patch contract, retried | **VERIFIED CORRECT (no change)** | §D; bounded, typed, tested | +| 4 | Verification skipped for HTML/JS/CSS | **EXPLAINED + VISIBILITY FIXED** | §E; opt-in policy now stated | +| 5 | UI token aggregate ≠ provider usage | **FIXED** | §F; delta commit + 4 tests | +| 6 | Intent `ask` vs `modification` | **VERIFIED DISTINCT (no change)** | §G; four-axis record | + +**Why not COMPLETE:** the mission's §8 asks for a **new** end-to-end multi-file CLI +integration test; I extended coverage at the exact lifecycle seam and cited the existing +real multi-file/CLI tests rather than adding another subprocess fixture. The all-rejected +requirement outcome and the browser opt-in are **documented design limitations**, not +defects, and were deliberately not "fixed" by weakening validation or inventing DOM +assertions. The exact residual in the footer (beyond the ~2× provider-call double-count) is +attributable to session baseline/estimates and is reported, not hidden. + +Nothing in this change weakens the bounded patch parser, the completion authority, the +approval gate, or any architecture lock. The final diff was reviewed for duplicate terminal +events (single deferred commit), state races (per-decision/per-run staging, race-clean), +double-counted usage (delta commit), and unauthorized mutation paths (none introduced). + +--- + +## J. Final production acceptance (this round) — real CLI lifecycle, adequacy policy, verification binding, terminal cleanup + +No prior fix was repeated without evidence of regression: the staged-commit +(`pendingProven` + `commitObjectiveCompletion`) and the usage-delta commit were +re-verified in place (all prior tests still pass unmodified) and only EXTENDED +where the audit found an uncovered transition. Every claim below names its +source and its test. + +### J1 — Root causes and changes (source-level) + +1. **Staged-grant cleanup had uncovered abandonment paths.** `pendingProven` + was cleared at authorize-entry, per run, and on commit — but not on the + paths that abandon a decision without reaching the next authorize: + operator abort, run-resource release, and the inter-step cancellation gate. + Fix (no behavior change on the happy path): + - `objective_completion.go`: new `discardPendingProven()` — the single + cleanup path for every transition that abandons or replaces a staged + grant. + - `driver.go:Abort`: discards the grant after cancelling the run context. + - `grant_barrier.go:releaseRunResources`: discards the grant so one run's + staged completion can never leak into the next run. + - `driver.go:observeAndRun` cancellation gate: discards the grant before + `terminateAbort`, so a cancelled run writes no terminal objective state. + - Resume paths need no new call: a parked run already passed through + `commitObjectiveCompletion` before `step()` parked it, so + `pendingProven == nil` at every park (pinned by + `TestTerminalCleanup_ParkedRunHoldsNoStagedGrant`); the next + `observeAndRun` re-authorizes through the clearing entry. +2. **Failed requirement derivation published no tally.** `deriveObjectiveRequirements` + returned early on pass error without `publishObjectiveRequirements`, so + "no requirements were derived" was indistinguishable from "the tally was + never emitted". Fix (`objective_lifecycle.go`): the error path now publishes + `admitted=0 rejected=0` with the recorded reason. The runtime's own + obligations still gate completion; the failure loosens nothing and proves + nothing. +3. **Adequacy policy was enforced but unstated.** The gate already implemented + the correct policy (traceable + scope-grounded or rejected; rejected authors + no condition; runtime obligations unconditional). Fix + (`objective_contract.go`): the OBJECTIVE ADEQUACY POLICY block above + `admitRequirements` now states source/authority (user criteria authoritative + via runtime conditions; repository facts require attributable evidence; + model criteria are proposals until validated; rejections retain reasons; + absence never becomes proof), the best-effort execution rule for broad + objectives, and the adaptive-proposal routing for consequential choices. + +### J2 — Production lifecycle trace (real CLI subprocess) + +New: `test/benchmark/cli_terminal_lifecycle_test.go` — three deterministic +subprocess tests over the real `izen` binary (scripted provider is the only +fake; CLI, runtime, verifier, ledger are real; no sleeps): + +- `TestCLIBench_TerminalLifecycleMultiArtifact` (`redesign this project help + me` over index.html/styles.css/script.js + untouched readme.md): derived + scope contains exactly the implementation surface; `approved=true` (a + candidate reached the approval boundary); all three files changed in ONE run + (one report, one ledger task); outcome `unsubstantiated`/exit 5 with + progress `PARTIALLY_SATISFIED` — the mutations landed but the unverified + broad objective is NOT proven; ledger holds exactly one task and its last + TERMINAL `VERIFICATION_RESULT` is non-success, agreeing with the CLI report; + report usage (36/21) equals exactly the 3 mutation-lane calls × 12/7 (the + requirement pass is a read-only planning call outside the execution + aggregate — verified by dumping the provider's system prompts: call 0 is the + requirement-derivation stage, calls 1–3 are the bounded mutation engine). +- `TestCLIBench_TerminalLifecycleDowngradePreventsProven` (empty artifact): + non-zero exit, outcome never `proven`, `ghost.txt` absent, zero + `EXECUTION_COMMITTED` markers, ledger terminal non-success — the downgrade + prevented terminal PROVEN at the process boundary. +- `TestCLIBench_TerminalLifecycleParkResumeUsage` (broken → fixed provider, + `run` then `resume`): first report usage == first-run mutation calls × 12/7; + resume report usage == NEW mutation calls only (the resume did not + re-commit the first run's spend); the summed usage reconciles exactly with + total mutation-lane billing; `resumed_from` is recorded. + +Observation boundary (documented, not hidden): a subprocess cannot observe the +intra-iteration instant between staging and deferred commit. That ordering — +zero `objective.evaluated{granted:true}` and no `EXECUTION_COMMITTED` before +the behavioral and contract-recovery gates — is pinned deterministically by +the in-process seam tests with a granting authority and a forced downgrade +(`TestObjectiveTerminalGrantIsNotCommittedWhenALaterGateDowngrades`, +`TestObjectiveTerminalGrantCommitsExactlyOnceWhenNoGateDowngrades`, +`TestTerminalCleanup_*`), with no timing. The subprocess tests prove every +observable consequence of that ordering. + +### J3 — All-rejected-requirements policy and observed behavior + +New: `internal/runtime/autonomy/objective_adequacy_policy_test.go` — five +tests, all by objective semantics and runtime policy (no prompt-string +special cases; `isProjectLevelObjective` reads the strategy gateway's +`Profile.Operation`, never the prompt text): + +1. `TestAdequacy_ClearObjectiveAdmitsAndProves` — `change bar to qux + @note.txt` with a grounded proposal: 1 admitted requirement, approval, + terminal COMPLETED, progress PROVEN. +2. `TestAdequacy_BroadObjectiveEvidenceSupportsBoundedProposal` — + `DefaultProposalAdvisor` over the observed surface yields one evidence-bound + strategy with repository-attributed observations, acceptance criteria, and + zero questions. +3. `TestAdequacy_BroadObjectiveConsequentialDecisionParks` — an advisor with a + consequential required question parks at `awaiting_human` with the question + on the boundary; nothing executes and no requirement is invented. +4. `TestAdequacy_AllRejectedRetainsReasonsAndProvesNothing` — the exact + observed vocabulary (`restructure the markup…`, `improve visual + hierarchy…`, `worldclass kazakhstan-approved…`) over the staticweb scope: + 0 admitted / 3 rejected, every reason retained, zero `SourceModel` + conditions, runtime obligations intact, and the authority refuses PROVEN + with zero evidence. +5. `TestAdequacy_SuccessfulMutationWithUnsubstantiatedObjective` — a valid + mutation on one of two declared targets: the file really changed, the claim + is rewritten to `LoopUnsubstantiate`, outcome `UNSUBSTANTIATED`. + +Observed behavior for `$prompt check this project and redesign it`: repository +evidence IS sufficient to construct the bounded default proposal (the +evidence-derived implementation surface + `DefaultProposalAdvisor`), execution +proceeds under that explicitly recorded best-effort policy, and completion +stays truthfully `GATE_UNVERIFIED`/`UNSUBSTANTIATED` without rendered proof — +never silently PROVEN. + +### J4 — CLI versus durable-ledger terminal-state consistency + +Per subprocess test above: one CLI run ↔ one ledger task; the report's +`outcome`/`progress` and the journal's last terminal `VERIFICATION_RESULT` +(`terminal=true`, `ok` flag) agree in both directions — proven reports sit +above successful journals, unproven reports sit above non-success journals +(`ledgerLastTerminalSuccess` reads only `terminal=true` records; step-level +results are skipped). A finished objective refuses resume without minting a +second task (pre-existing `TestCLIBench_MultiPhaseMigration…` tail). + +### J5 — Provider usage reconciliation across park/resume + +Per `TestCLIBench_TerminalLifecycleParkResumeUsage`: each process's JSON +`usage` equals its own mutation-lane calls × (12 in / 7 out); the resume does +not re-commit the parked run's spend; the sum reconciles to the provider's +total mutation-lane billing. (The ~2× TUI footer double-count fixed earlier is +a presentation-baseline issue; the CLI report reads `AggregatedUsage` +directly and never held it. The residual session-baseline/live-estimate +portion reported in §F is unchanged and still reported, not hidden.) + +### J6 — Verification evidence and limitations + +Rendered verification stays OPT-IN (`IZEN_RENDERED_VERIFY=1`, +`compose.go:1029`); no DOM assertions were added and no screenshot is claimed +as visual proof. New: `rendered_evidence_binding_test.go` — 4 tests: + +- a failed configured check projects `RENDERED_FAIL` and the authority refuses + PROVEN on it; +- `renderedRevision` is content-bound (same targets, different bytes ⇒ + different 16-hex revision), so a check cannot be replayed against a later + edit; +- missing configuration (no verifier / no entry / no checks) projects + distinct blocked states, never a pass; +- the authority consumes exactly that evidence: passing rendered verification + satisfies a broad objective's `cond-integrity-held`, a skipped gate leaves + `GATE_UNVERIFIED` and unsatisfied. + +Limitation: this environment holds a Chrome binary but CI does not guarantee +one; the real-browser path (`TestRenderedVerifier_RealBrowser`) remains +skip-guarded, and the headless-browser E2E is therefore covered by the blocked/ +unit-bound evidence above rather than a live rendering in this run. + +### J7 — Terminal-state cleanup audit + +New: `objective_terminal_cleanup_test.go` — 6 tests pinning every abandonment +path: cancel discards, abort/release discards, retry decision-point clears, +parked run holds no grant (resume re-authorizes), new run never inherits, +later downgrade discards with zero terminal grants. The durable terminal +transition still has ONE authoritative commit path: `grep` confirms +`emitObjectiveEvaluated(…, granted=true)` and the objective's +`ledgerExecutionCommitted()` occur only in `commitObjectiveCompletion` +(the mutation boundary's `ledgerMutationCommitted` is the OPERATION commit, a +different fact, deduped per-op by `ledgerCommittedOp`). + +### J8 — Validation and final report + +``` +go build ./... clean +go vet ./internal/runtime/autonomy/ ./internal/ui/ \ + ./test/benchmark/ ./internal/execution/ clean +gofmt -l internal/ test/ cmd/ clean +golangci-lint run ./internal/runtime/autonomy/... \ + ./internal/ui/... 0 issues +go test -count=1 ./... 213 ok, 0 FAIL +go test -race -count=1 -run 'TestObjectiveTerminalGrant| + TestRequirementRejection|TestAutonomousUsage|TestE2E_ParkedRun| + TestPhase14|TestOrchestration_B|TestAdequacy_| + TestTerminalCleanup_|TestRendered_' \ + ./internal/runtime/autonomy/ ./internal/ui/ ok, no data race +go test -count=1 -run 'TestCLIBench_TerminalLifecycle' \ + ./test/benchmark/ 3 PASS +``` + +New tests added this round: 5 adequacy + 6 cleanup + 4 rendered-binding + +3 CLI subprocess = 18. All prior tests pass unmodified. + +## K. Verdict — **COMPLETE** + +Against the mission's five sections: + +| § | Requirement | Status | Evidence | +|---|---|---|---| +| 1 | Real CLI subprocess test over the production lifecycle | **DONE** | §J2; 3 subprocess tests, deterministic, no sleeps | +| 2 | Policy for objectives with no admitted requirements + 5 tests | **DONE** | §J1.3, §J3; policy block in `objective_contract.go`, 5 tests | +| 3 | Verification policy without overclaiming | **DONE** | §J6; opt-in kept, 3 prior + 4 new tests | +| 4 | Terminal-state cleanup for all abandonment paths | **DONE** | §J1.1, §J7; `discardPendingProven` + 6 tests, one commit path | +| 5 | Full validation + report | **DONE** | §J8; build/vet/lint/test/race/CLI all green | + +The two earlier code-level fixes were re-verified, not repeated: every prior +test passes unmodified, and the only production-code changes are the three +surgical items in §J1 (grant cleanup on abort/release/cancel, failed-pass +tally visibility, adequacy-policy documentation). The remaining non-defects +are stated where they belong: the intra-iteration instant is observable only +in-process (pinned there deterministically); rendered E2E needs a browser CI +does not guarantee; the TUI footer residual beyond the fixed ~2× is session +baseline/estimates. Vague objectives cannot silently become success: an +all-rejected derivation retains its reasons, executes only under the recorded +best-effort policy, and terminates `UNSUBSTANTIATED` — never `PROVEN` — +without evidence. + +*Dynamic Path. Static Authority. Truthful State Transition.* + +--- + +## L. Follow-up (2026-10-10) — Constrained-model artifact contract match + +**Branch:** `fix/execution`. **Input under audit:** `$prompt check this project and +redesign it` over `index.html`, `script.js`, `styles.css`, provider/model +`openrouter` / `cohere/north-mini-code:free` (the exact observed run). **Verdict:** +**COMPLETE** for the artifact-generation defect named in this round; see §L7. + +This section is a fresh audit of the *artifact-generation and bounded-recovery* +path. It reproduces the production failure, identifies its authoritative owner, +fixes it there, and pins it with deterministic regression tests plus a real CLI +benchmark. + +### L1 — Reproduction (live, then deterministic) + +**Live reproduction of the current tree (before the fix):** the exact objective +with the configured provider/model parked at `PREFLIGHT_INFEASIBLE` (invariant +I5) with **zero provider calls** and zero mutations: + +``` +outcome : parked +reason : invariant I5: preflight infeasible — explicit re-scope required +scope : [index.html, script.js, styles.css] +usage : input_tokens=0 output_tokens=0 known=false +``` + +**Deterministic reproduction of the *reported* artifact rejections** (scripted +provider, unfenced output, constrained model) — this is the failure the mission +names, now pinned by `TestCLIBench_ConstrainedModelInvalidArtifactStopsTruthfully`: + +``` +NON_PROGRESSING_EXECUTION: ARTIFACT_INVALID target=index.html: + executor: mutation artifact rejected with retry directive: index.html: + bounded patch contract requires SEARCH/REPLACE blocks or unified diff hunks; + full-file or unstructured output rejected; + RMAH: RMAH Tier 3: synthesized patch rejected due to destructive truncation + (<60% retention) + (seen 2 time(s) under unchanged evidence) — hard-block: bounded recovery + exhausted for index.html (attempt 2, recovery cycle 1, subtype + "schema_violation") — park at DecisionSurface awaiting_human +``` + +### L2 — Root-cause table + +| # | Symptom | Authoritative owner | Consequence | Correction | +|---|---|---|---|---| +| R1 | Multi-target objective parks at `PREFLIGHT_INFEASIBLE`, zero provider calls, before any artifact exists | `RuntimeExecutor.Execute` Boundary-2 guard (`executor.go:2155`) judging `!patchOnlyArtifact(profile)` | A feasible per-target bounded-patch redesign is refused as an infeasible *full rewrite* the executor was never going to attempt; the whole objective dies before generation | Judge the **dispatch** contract: `willDispatchBoundedPatch` (new, `executor.go:5886`) folds the existing constrained-model resolution into the guard condition | +| R2 | `script.js`: "no extractable code content" | `rmah.Pipeline` Tier 2 + `tier3ASTTarget` | A constrained model that ignores the SEARCH/REPLACE contract is rejected (correct) but the *root* cause (a model under a small ceiling) is the contract, not the parser | Same as R1: the constrained model is dispatched under the contract it can satisfy; a genuine contract miss is recovered/parks truthfully | +| R3 | `index.html`: "synthesized patch … destructive truncation (<60% retention)" | `rmah.Pipeline` Tier 3 retention floor (`rmah.go:245`) | A window-scoped response is measured against the whole baseline, so it is correctly refused as a destructive whole-file rewrite | Retention floor **unchanged** (per the mission); the run is recovered with the failed-approach evidence and parks truthfully | +| R4 | Terminal `NON_PROGRESSING_EXECUTION` | `FailureLedger` + `DecideRecoveryWith` | Correct bounded termination; the CLI and ledger agree it is not a success | No change (verified truthful); regression test added | + +R1 is the verified production defect: a *contract mismatch* between the driver's +own adaptive decision (`AdaptiveSelectStrategy` already returns +`StrategyBoundedPatch` for large targets / small budgets) and the executor's +Boundary-2 guard, which evaluated the request as a full rewrite. + +### L3 — Files and authoritative seams changed + +| File | Seam | Change | +|---|---|---| +| `internal/execution/executor.go` | `RuntimeExecutor.willDispatchBoundedPatch` (new) + Boundary-2 condition | The guard now asks the same question `invokeMutation` asks: "will this dispatch be bounded?" A constrained model (free-tier / low-ceiling) on an existing target is bounded by construction, so the guard is skipped and the bounded patch is dispatched. The full-rewrite refusal is preserved for every model that would genuinely attempt one. | + +No kernel, parser, approval, mutation, verification or ledger code was touched. +No retention threshold, schema check, scope, or completion gate was weakened. +`willDispatchBoundedPatch` reuses the two existing authorities +(`llmstep.ResolveMaxTokens` and `ModelProfile.IsConstrained`) — it does not +re-derive capability. + +### L4 — Why this addresses the root cause + +`invokeMutation` already forces `patchOnly = true` for a constrained model on an +existing target (`executor.go:3501`), and that contract fits any output budget by +construction. Boundary 2 ran *before* that classification and refused the same +request as an infeasible full rewrite. The two owners disagreed about the +contract. `willDispatchBoundedPatch` makes Boundary 2 read the same contract the +dispatch will use, so the guard still refuses a full rewrite that cannot fit and +no longer refuses one the runtime will not attempt. The bounded patch itself +still crosses the *unchanged* canonical artifact boundary (SEARCH/REPLACE / +unified-diff extraction, anchor proof, AST gate, retention floor, approval, +mutation, verification, ledger). + +### L5 — Regression tests and exact outcomes + +| Test | File | Pins | +|---|---|---| +| `TestConstrainedModelSkipsInfeasibleFullRewritePreflight` | `internal/execution/constrained_preflight_test.go` | constrained model on a large target dispatches the bounded patch (1 provider call, staged candidate, bounded-patch system prompt, `max_tokens=980`) | +| `TestUnconstrainedModelStillRefusesInfeasibleFullRewrite` | same | the guard is **not** weakened: the same target/budget still refuses with `ErrPreflightInfeasible` and zero provider calls | +| `TestTier2_RetentionBoundary` | `internal/execution/rmah/rmah_test.go` | retention floor at exactly 60% is admitted; above admitted; below rejected with `destructive truncation` | +| `TestConstrainedBoundedPatchRecoveryCarriesFailedApproach` | `internal/runtime/autonomy/constrained_recovery_evidence_test.go` | a rejected constrained bounded-patch attempt produces a materially-changed continuation carrying `[FAILED APPROACH]` + the SEARCH/REPLACE contract; the corrected second attempt stages through the same canonical validator | +| `TestCLIBench_ConstrainedModelMultiFileRedesignUsesBoundedPatches` | `test/benchmark/cli_constrained_contract_test.go` | real binary: no `PREFLIGHT_INFEASIBLE`; candidate approved; all three files changed through the canonical path; bounded-patch calls ≥ 3; unverified objective not PROVEN; exit non-zero | +| `TestCLIBench_ConstrainedModelInvalidArtifactStopsTruthfully` | same | real binary: unfenced output → workspace byte-identical, zero `EXECUTION_COMMITTED`, non-zero exit, attributable reason, bounded provider calls | + +Exact command outcomes (final tree): + +``` +gofmt -l internal/ test/ cmd/ no output +go build ./... clean +go vet ./internal/execution/... ./internal/runtime/autonomy/ ./test/benchmark/ clean +golangci-lint run --timeout=5m ./internal/execution/... \ + ./internal/runtime/autonomy/... ./test/benchmark/... 0 issues +go test -count=1 ./... 213 ok, 0 FAIL, exit 0 +go test -race -count=1 -run 'TestConstrainedModel|TestUnconstrainedModel|TestConstrainedBoundedPatchRecovery|TestTier2_RetentionBoundary|TestCLIBench_ConstrainedModel' \ + ./internal/execution/ ./internal/execution/rmah/ ./internal/runtime/autonomy/ ./test/benchmark/ ok, no data race +go test -count=1 -run 'TestCLIBench' -timeout 15m ./test/benchmark/ ok +``` + +### L6 — Real CLI benchmark (subprocess, real provider) + +Disposable copy of `testdata/staticweb`, the exact objective, the configured +provider. Raw evidence is stored beside this report: +`docs/report/realworld/evidence/constrained-bounded-redesign-{report.json,ledger.txt,diff.patch}`. + +``` +provider/model : openrouter / cohere/north-mini-code:free +surface : $prompt +outcome : unsubstantiated (truthful: rendered verification is opt-in/off) +reason : cond-integrity-held unmet: GATE_UNVERIFIED — refused completion + claim over outcome "changed" +approved : true +candidate_id : run-1-patch-1 +candidate_digest: 9852d16a1d37943bf5d27627f8fef0aa50e3c83e26c44133cd68e915ed7514a7 +applied : [index.html, script.js, styles.css] +progress : PARTIALLY_SATISFIED +usage : input_tokens=6895 output_tokens=512 known=true +exit_code : 5 +``` + +Durable ledger agrees (16 records, one task): + +``` +TASK_CREATED scope=[index.html, script.js, styles.css] +TASK_PAUSED "mutation awaiting approval" +HUMAN_DECISION approve candidate=run-1-patch-1 digest=9852d16a… targets=[…] +EXECUTION_COMMITTED status=COMMITTED +FAILURE_CLASSIFIED reason=unsubstantiated +VERIFICATION_RESULT(terminal) ok=false failed=true +``` + +Against the mission's Phase-6 acceptance list: + +1. **Discovery evidence-bound to the three implementation files** — `TASK_CREATED.scope` + is exactly `[index.html, script.js, styles.css]`; the out-of-scope `assets/` + was untouched. +2. **Every mutation through the canonical validated artifact path** — three + `search_replace` bounded patches, one approved candidate, one `EXECUTION_COMMITTED`. +3. **Invalid output corrected or stops safely** — deterministic + `TestCLIBench_ConstrainedModelInvalidArtifactStopsTruthfully` parks with an + attributable reason and zero mutation. +4. **No malformed/truncated/destructive artifact bypasses validation** — the + artifact boundary and retention floor are unchanged; invalid output never + stages. +5. **The approved candidate is exactly the candidate applied** — one + `candidate_id`/`candidate_digest`, bound in `HUMAN_DECISION`, applied to all + three targets. +6. **Partial failure is not reported as objective success** — outcome + `unsubstantiated`, `progress=PARTIALLY_SATISFIED`. +7. **CLI and durable ledger agree** — report exit 5 / `unsubstantiated` sits above + a terminal `VERIFICATION_RESULT{ok:false}`. +8. **Usage accounting consistent** — the live report's usage is `known=true` and + carries a single execution-lane aggregate (no double count; see §F for the + presentation-layer note). The exact per-call reconciliation against provider + billing is pinned deterministically by the scripted-provider subprocess tests + (`TestCLIBench_TerminalLifecycleMultiArtifact` / `…ParkResumeUsage`), since a + live free-tier provider does not expose an inspectable billing ledger here. +9. **Original files preserved before approval; mutations inside the workspace** — + the diff touches only the three in-workspace files. +10. **Rendered verification unverified** — `IZEN_RENDERED_VERIFY` is unset, so the + broad objective is truthfully `GATE_UNVERIFIED`; no visual success is claimed. + +The applied diff is preserved in `…-diff.patch`; it is a real (small) change to +each file, not a fabricated success. It is **not** claimed to be a visually +excellent redesign — the model is a free-tier model and the objective is +correctly `UNSUBSTANTIATED`. + +### L7 — Verdict — **COMPLETE** + +| Phase | Status | Evidence | +|---|---|---| +| 1 Audit + reproduction | DONE | §L1, §L2; live + deterministic reproduction | +| 2 Artifact normalization/admission | DONE | §L3; admission now judges the dispatch contract; canonical parser unchanged | +| 3 Recovery converges or stops truthfully | DONE | §L5 recovery test + deterministic park; recovery carries the failed approach and the required contract | +| 4 Multi-file execution strategy | DONE | §L4; per-target bounded patches through the existing path; partial progress explicit | +| 5 Regression tests at the real seams | DONE | §L5; 6 new tests across 4 files | +| 6 Real CLI acceptance benchmark | DONE | §L6; live provider run + ledger + diff | +| 7 Full validation + report | DONE | §L5 commands; this section | + +**What is proven:** the verified artifact-generation defect (the driver/executor +contract mismatch that parked a feasible multi-target redesign before any +generation) is fixed at its authoritative owner; a valid bounded patch becomes a +reviewable, applied candidate; invalid output is recovered with actionable +evidence or parks truthfully; the real CLI benchmark produces auditable evidence +consistent with the durable ledger. + +**Limitations (stated, not hidden):** the live free-tier model's patches are +small and are not a visually excellent redesign (the objective is truthfully +`UNSUBSTANTIATED`); rendered/browser verification is opt-in and was not enabled, +so visual correctness is **unverified**; CSS has no registered structural +validator, so a syntactically broken CSS patch is only caught by verification, +not by the artifact gate — this is a pre-existing coverage limitation, not a +regression, and adding a CSS validator is out of scope for this defect. + +*Dynamic Path. Static Authority. Truthful State Transition.* diff --git a/docs/report/realworld/evidence/constrained-bounded-redesign-diff.patch b/docs/report/realworld/evidence/constrained-bounded-redesign-diff.patch new file mode 100644 index 00000000..a8c8b03f --- /dev/null +++ b/docs/report/realworld/evidence/constrained-bounded-redesign-diff.patch @@ -0,0 +1,41 @@ +diff --git a/index.html b/index.html +index d284fbb..98ad1da 100644 +--- a/index.html ++++ b/index.html +@@ -26,7 +26,7 @@ +