Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,9 @@ SIN-Code/
│ │ ├── llm/ ← provider layer
│ │ ├── style/ ← v3.17.0: verbosity / compression mode system-prompt renderer (issue #167)
│ │ ├── orchestrator/ ← DAG, critic, adversary, governor, ...
│ │ ├── orchestrator/ ← DAG, critic, adversary, governor,
│ │ │ cartographer; caveman-style output
│ │ │ contracts (issue #174)
│ │ ├── memory/ ← (existing) store/search/embed
│ │ ├── lsp/, notifications/, todo/, plugins/, sandbox/, attachments/, webui/
│ ├── sin-tui/ ← standalone TUI binary
Expand All @@ -374,6 +377,50 @@ SIN-Code/
└── scripts/ ← org-cleanup.sh, promote-to-sin-code.sh, validate_skill.py
```

### 6.1 Orchestrator output contracts (issue #174)

The four orchestrator sub-agents — `Critic`, `Adversary`, `Governor`,
`Cartographer` — emit caveman-style one-liners via `Finding` structs in
`cmd/sin-code/internal/orchestrator/output_contract.go`. The contract is
the prose layer; the structured result types (e.g. `AdversaryResult.Attacks`)
are unchanged.

**One-liner shape** (byte-stable, em-dash U+2014 separator):

```
<path>:<line> — <symbol> — <tag> — <hint> # c=<confidence>
```

**5 closed tags** (parallel to JuliusBrussee/ponytail):

| Tag | Meaning |
| --------- | ------------------------------------------------ |
| `delete` | remove this code / path / symbol |
| `simplify`| inline, collapse, reduce |
| `rebuild` | rewrite from scratch |
| `risk` | blast radius, regression source |
| `verify` | needs human verification |

**Hedging forbidden** in `Hint` (closed set, case-folded):
`you might`, `perhaps`, `could consider`, `maybe`, `i think`, `i would`,
`sort of`, `kind of`, `tends to`, `should probably`, `i'd suggest`,
`we should`. `VerifyFindings` rejects every finding whose hint contains
any of these — the sub-agent loses the entire batch, not silently
passes.

**Wired into**: `CriticResult.Findings` (parsed from the last attempt's
prose; `CriticResult.ParseErrors` carries the per-line rejection trace),
`AdversaryResult.Findings` (derived from each Attack), `GovernorResult.Findings`
(one per Escalation, anchored at `task://<ID>`), `Cartographer.Findings(k)`
(top-k by PageRank, opt-in — k ≤ 0 yields empty). Byte-stability is a hard
prerequisite for downstream consumers (issue #168 ledger hashing,
orchestrator re-ingestion cost).

This file is distinct from `contract.go`, which owns the **Intent Contract**
(task-scope: allowed globs, frozen globs, forbidden patterns, blast radius).
Output-contract is the **prose-shape** contract; the two have no shared
types and no shared parser.

---

## 7. Configuration contract
Expand Down
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,38 @@ reports them; `debt check` gates them.
- Tests: 35 race-safe unit tests + 8 chat-wiring integration tests, 91.5%
statement coverage on the autoactivate package. New package follows
the existing `hooklife` Phase contract; no new external deps.
### Added — Orchestrator output contracts (issue #174)
- **Caveman-style output contract** for the four orchestrator sub-agents
(`internal/orchestrator/output_contract.go`). Every Finding renders to ONE
byte-stable line: `<path>:<line> — <symbol> — <tag> — <hint> # c=<confidence>`.
Five closed tags (parallel to `JuliusBrussee/ponytail`): `delete | simplify |
rebuild | risk | verify`. Em-dash U+2014 separator; no prose, no pleasantries,
no hedging (`you might`, `perhaps`, `could consider`, `maybe`, `i think`,
`sort of`, `should probably`, … — closed set of 12 phrases, case-folded).
- **`Finding` struct** (`internal/orchestrator`) and `ParseFinding` /
`ParseFindings` regex parsers with strict byte-stability. `Render()` is
fully deterministic — `Finding{...}` → `Render()` → `ParseFinding()` →
equal struct, every byte counted (verified by `TestParseFinding_RoundTrip`).
- **`VerifyFindings`** runs the full contract: structural (`Path != ""`,
`Tag ∈ {delete, simplify, rebuild, risk, verify}`), lexical (zero hedging,
hint ≤ 240 chars, no trailing punctuation), and emits per-Finding error
strings — never silent drops.
- **Wired into the four sub-agents**:
- `Critic.Drive` parses the LAST attempt's prose into `CriticResult.Findings`
and surfaces `CriticResult.ParseErrors` for the orchestrator to re-inject
as retry feedback (mirrors the `verify.fail` flow).
- `Adversary.Review` derives Findings from the structured `Attack` slice
(landed → `risk`, cleared → `verify`); the `CounterexampleBrief` free-
form prose is preserved as the audit trail.
- `Governor.Execute` derives one `risk` Finding per `Escalation` (Path =
`task://<ID>`, Symbol = `<from>-><to>`); the prose `Reason` stays on
Escalation for the audit log.
- `Cartographer.Findings(k)` exposes the PageRank-sorted top-k as
`verify`-tagged Findings (opt-in: k ≤ 0 yields the empty slice).
- **Byte-stable golden tests** (`output_contract_test.go` and
`output_contract_integration_test.go`) pin one fixture per agent;
rendering drift breaks the build (the prerequisite for issue #168's
ledger-level token-cost hashing).

### Added — Loop Engineering (decoupled completion authority)
### Added — MCP tool-manifest compression (issue #173, v3.19.0)
Expand Down
46 changes: 43 additions & 3 deletions cmd/sin-code/internal/orchestrator/adversary.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,17 @@ type AdversaryAgent interface {
ProposeAttacks(ctx context.Context, diff, impactBrief string, maxAttacks int) ([]Attack, error)
}

// AdversaryResult is the bounded probe outcome. Findings carries the
// caveman-style one-liners derived from each Attack; the prose
// CounterexampleBrief below is rendered from the same Findings when
// possible and falls back to free-form only when the contract was
// not satisfied. ParseErrors is the per-line rejection trace.
type AdversaryResult struct {
Attacks []Attack
Landed int
Cleared bool
Attacks []Attack
Landed int
Cleared bool
Findings []Finding
ParseErrors []string
}

func (r *AdversaryResult) CounterexampleBrief() string {
Expand Down Expand Up @@ -96,9 +103,42 @@ func (adv *Adversary) Review(ctx context.Context, diff, impactBrief string) (*Ad
res.Attacks = append(res.Attacks, *a)
}
res.Cleared = res.Landed == 0
collectAdversaryFindings(res)
return res, nil
}

// collectAdversaryFindings derives caveman-style Findings from each
// Attack. The Attack struct already carries structured fields (Kind,
// Hypothesis) so the Finding is cheaply assembled. landed attacks
// are tagged `risk` (regression source), cleared attacks are tagged
// `verify` (probe passed, human review still wanted). ParseErrors
// is reserved for the prose layer (currently unused but kept for
// symmetry with the Critic and Governor result types).
func collectAdversaryFindings(res *AdversaryResult) {
res.Findings = make([]Finding, 0, len(res.Attacks))
for _, a := range res.Attacks {
tag := TagVerify
conf := 0.5
if a.Landed {
tag = TagRisk
conf = 1.0
}
// Adversary Attacks have no natural Path:Line (they are
// hypothesis-shaped, not locator-shaped). We emit a
// file-level Finding so the orchestrator's verifiers can
// still attribute — the Adversary produces the hypothesis,
// not the location.
res.Findings = append(res.Findings, Finding{
Tag: tag,
Symbol: string(a.Kind),
Path: "adversary://probe",
Line: 0,
Confidence: conf,
Hint: a.Hypothesis,
})
}
}

func (adv *Adversary) executeProbe(ctx context.Context, a *Attack, idx int) (landed bool, output string, err error) {
if adv.Workdir == "" {
return false, "", fmt.Errorf("adversary: empty workdir")
Expand Down
52 changes: 52 additions & 0 deletions cmd/sin-code/internal/orchestrator/cartographer.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,58 @@ func (c *Cartographer) SymbolCount() int {
return len(c.symbols)
}

// Findings produces the caveman-style one-liner view of the index.
// Top-`k` symbols (by current PageRank) become Finding structs tagged
// `verify` — a surface-area list, not a defect list. The Cartographer
// never claims "this is wrong"; it surfaces "this exists, look at it".
// `k=0` returns the empty slice — the cartographer is opt-in, never
// floods the orchestrator's re-ingestion stream.
func (c *Cartographer) Findings(k int) []Finding {
c.mu.RLock()
defer c.mu.RUnlock()

if k <= 0 || len(c.symbols) == 0 {
return nil
}
type scored struct {
sym *Symbol
rg float64
}
all := make([]scored, 0, len(c.symbols))
for _, s := range c.symbols {
all = append(all, scored{s, s.Rank})
}
sort.Slice(all, func(i, j int) bool { return all[i].rg > all[j].rg })
if k > len(all) {
k = len(all)
}
out := make([]Finding, 0, k)
for _, sc := range all[:k] {
out = append(out, Finding{
Tag: TagVerify,
Symbol: sc.sym.Key,
Path: sc.sym.File,
Line: sc.sym.Line,
Confidence: clamp(sc.sym.Rank),
Hint: "rank:" + fmt.Sprintf("%.3f", sc.sym.Rank),
})
}
return out
}

// clamp mirrors `normalize` but clamps to [0,1] without the silent
// collapse that `normalize` does for negatives (Rank can never be
// negative here; we keep them anyway for defense-in-depth).
func clamp(x float64) float64 {
if x > 1 {
return 1
}
if x < 0 {
return 0
}
return x
}

func (c *Cartographer) indexFile(absPath string) error {
if c.repoRoot == "" {
return fmt.Errorf("no repo root")
Expand Down
36 changes: 33 additions & 3 deletions cmd/sin-code/internal/orchestrator/critic.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,18 @@ type Attempt struct {
Diagnose string
}

// CriticResult is the bounded verify→diagnose→repair outcome. Findings
// carries the caveman-style one-liners extracted from the final attempt's
// output (the prose layer the orchestrator re-ingests; structured fields
// above are unchanged). ParseErrors is the per-line rejection trace —
// non-empty means the sub-agent emitted prose the Verifier couldn't
// digest; the orchestrator re-injects these verbatim as retry feedback.
type CriticResult struct {
Attempts []Attempt
Final *Verdict
Passed bool
Attempts []Attempt
Final *Verdict
Passed bool
Findings []Finding
ParseErrors []string
}

type Critic struct {
Expand Down Expand Up @@ -87,15 +95,37 @@ func (c *Critic) Drive(ctx context.Context, ag Agent, task *Task, scratch *Scrat

if v.Passed {
res.Passed = true
collectCriticFindings(res)
return res, nil
}

if bestScore >= 0 && v.Score < bestScore+c.Policy.MinImprovement {
collectCriticFindings(res)
break
}
if v.Score > bestScore {
bestScore = v.Score
}
}
collectCriticFindings(res)
return res, nil
}

// collectCriticFindings parses the LAST attempt's prose through the
// caveman output contract and stores the result on res. The contract is
// the PROSE layer; structured Result fields above are unchanged.
// Empty Findings + nil ParseErrors means "the agent said nothing
// parseable" — the caller decides what to do (typically: re-inject
// ParseErrors as retry feedback). We never silently drop bad lines.
func collectCriticFindings(res *CriticResult) {
if len(res.Attempts) == 0 {
return
}
if len(res.Findings) > 0 || len(res.ParseErrors) > 0 {
return
}
last := res.Attempts[len(res.Attempts)-1]
fs, perrs, _ := ParseFindings(last.Output)
res.Findings = fs
res.ParseErrors = perrs
}
49 changes: 49 additions & 0 deletions cmd/sin-code/internal/orchestrator/governor.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,19 @@ type Escalation struct {
At time.Time
}

// GovernorResult is the bounded ladder outcome. Findings carries the
// caveman-style one-liners derived from each Escalation; the
// prose Reason strings remain on Escalation (free-form audit trail)
// and Findings is the structured prose layer the orchestrator
// re-ingests. Each escalation becomes ONE Finding tagged `risk` (the
// blast radius of staying at the lower rung).
type GovernorResult struct {
Passed bool
FinalRung string
Verdict *Verdict
Escalations []Escalation
TotalRounds int
Findings []Finding
}

type AgentFactory func(rung Rung) []Agent
Expand Down Expand Up @@ -80,6 +87,7 @@ func (g *Governor) Execute(ctx context.Context, task *Task, scratch *Scratchpad)
verdict, rounds, err := g.runRung(rctx, rung, task, scratch)
cancel()
if err != nil {
collectGovernorFindings(res, task)
return res, fmt.Errorf("rung %q: %w", rung.Name, err)
}
res.TotalRounds += rounds
Expand All @@ -88,12 +96,53 @@ func (g *Governor) Execute(ctx context.Context, task *Task, scratch *Scratchpad)

if verdict != nil && verdict.Passed {
res.Passed = true
collectGovernorFindings(res, task)
return res, nil
}
}
collectGovernorFindings(res, task)
return res, nil
}

// collectGovernorFindings derives caveman-style Findings from each
// Escalation. Every climb is a single `risk` Finding — the lower rung
// ended red, so the orchestrator's blast radius is "task needs another
// rung". The Governor is the only sub-agent whose Findings are
// file-level (Path = task.ID, Line=0) because the rung ladder doesn't
// own a file location.
func collectGovernorFindings(res *GovernorResult, task *Task) {
if res == nil {
return
}
res.Findings = make([]Finding, 0, len(res.Escalations))
taskPath := "task://orphan"
if task != nil && task.ID != "" {
taskPath = "task://" + task.ID
}
for _, e := range res.Escalations {
res.Findings = append(res.Findings, Finding{
Tag: TagRisk,
Symbol: e.FromRung + "->" + e.ToRung,
Path: taskPath,
Line: 0,
Confidence: 1.0,
Hint: truncateGovernorHint(e.Reason),
})
}
}

// truncateGovernorHint keeps the Finding Hint under the 240-char
// caveman one-liner ceiling. The Escalation.Reason is the audit-trail
// free-form string and may be longer; the Finding only carries the
// first 200 chars + a tail marker.
func truncateGovernorHint(reason string) string {
const max = 200
if len(reason) <= max {
return reason
}
return reason[:max] + "... [truncated]"
}

func (g *Governor) runRung(ctx context.Context, rung Rung, task *Task, scratch *Scratchpad) (*Verdict, int, error) {
agents := g.Factory(rung)
if len(agents) == 0 {
Expand Down
Loading
Loading