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
2 changes: 1 addition & 1 deletion docs/agent-kernel-adapter-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ before broad adapter use:

| Gap | Impact | Follow-up |
| --- | --- | --- |
| Not every mutating dry-run emits the shared approval evidence envelope yet. | `agent-kernel` can use `gira-approval-plan/v1` for ticket lifecycle, core config/registry, workspace repo-sync, repo/issue adoption, and milestone dry-runs, but sprint, Jira transition, and cache mutation plans may still need command-specific normalization. | Extend the shared `approval` object to the remaining non-ticket dry-run mutation reports. |
| Not every mutating dry-run emits the shared approval evidence envelope yet. | `agent-kernel` can use `gira-approval-plan/v1` for ticket lifecycle, core config/registry, workspace repo-sync, repo/issue adoption, milestone, and cache prune dry-runs, but sprint and Jira transition plans may still need command-specific normalization. | Extend the shared `approval` object to the remaining non-ticket dry-run mutation reports. |
| Some command families remain text-first or partially JSON-covered. | Automation confidence drops and adapters need fragile parsing. | Add JSON contracts or mark those commands unsupported for adapters. |
| No explicit post-apply verification link in every apply report. | Adapters need command-specific knowledge to know which read command proves completion. | Add `post_apply_verification` fields to apply reports. |

Expand Down
4 changes: 4 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -2165,6 +2165,10 @@ func runCachePrune(args []string, stdout io.Writer, stderr io.Writer) int {
return 1
}
if *jsonOutput {
gira.EnsureCachePruneReportSchema(&report)
if report.DryRun {
report.Approval = gira.CachePruneApprovalEvidence(report)
}
out, err := json.MarshalIndent(report, "", " ")
if err != nil {
fmt.Fprintf(stderr, "encode cache prune JSON: %v\n", err)
Expand Down
52 changes: 52 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,22 @@ func TestCachePruneJSON(t *testing.T) {
t.Fatalf("cache prune JSON missing %q:\n%s", want, stdout.String())
}
}
var report gira.CachePruneReport
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("decode cache prune JSON: %v\n%s", err, stdout.String())
}
if report.SchemaVersion != gira.CachePruneReportSchemaVersion || report.Approval == nil {
t.Fatalf("cache prune dry-run JSON missing schema or approval:\n%s", stdout.String())
}
if report.Approval.SchemaVersion != gira.ApprovalPlanSchemaVersion || report.Approval.CanonicalCommand != "gira cache prune" || report.Approval.OutputSchema != gira.CachePruneReportSchemaVersion {
t.Fatalf("unexpected cache prune approval evidence: %+v", report.Approval)
}
if report.Approval.ApplyCommand != "gira cache prune --root /tmp/gira-cache --apply" || report.Approval.PostApplyVerification != "gira cache prune --root /tmp/gira-cache --dry-run --json" {
t.Fatalf("unexpected cache prune approval commands: %+v", report.Approval)
}
if report.Approval.Blockers == nil || report.Approval.Warnings == nil {
t.Fatalf("approval blockers and warnings must be stable arrays: %+v", report.Approval)
}
}

func TestCachePruneApplyBuilderWiring(t *testing.T) {
Expand Down Expand Up @@ -616,6 +632,42 @@ func TestCachePruneApplyBuilderWiring(t *testing.T) {
}
}

func TestCachePruneApplyJSONOmitsApprovalEvidence(t *testing.T) {
restore := newCachePruneReport
t.Cleanup(func() { newCachePruneReport = restore })
newCachePruneReport = func(options gira.CachePruneOptions) (gira.CachePruneReport, error) {
if options.Root != "/tmp/gira-cache" || options.DryRun || !options.Apply {
t.Fatalf("unexpected cache prune options: %#v", options)
}
return gira.CachePruneReport{
Command: "cache prune",
Root: "/tmp/gira-cache",
ActiveVersion: "v1.2.0",
Apply: true,
Counts: gira.CachePruneCounts{Applied: 1},
Actions: []gira.CachePruneAction{
{Action: "prune", Status: "applied", Name: "v1.1.0", Path: "/tmp/gira-cache/v1.1.0", Reason: "removed stale version directory"},
},
}, nil
}

var stdout, stderr bytes.Buffer
code := Run([]string{"cache", "prune", "--root", "/tmp/gira-cache", "--apply", "--json"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
var report gira.CachePruneReport
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("decode cache prune JSON: %v\n%s", err, stdout.String())
}
if report.SchemaVersion != gira.CachePruneReportSchemaVersion || !report.Apply {
t.Fatalf("unexpected cache prune apply report: %+v", report)
}
if report.Approval != nil {
t.Fatalf("apply output should not include dry-run approval evidence: %+v", report.Approval)
}
}

func TestGuideQuickstartDefault(t *testing.T) {
var stdout, stderr bytes.Buffer
code := Run([]string{"guide"}, &stdout, &stderr)
Expand Down
56 changes: 56 additions & 0 deletions internal/gira/approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -1043,3 +1043,59 @@ func milestoneApprovalBlockers(report MilestoneReport) []string {
}
return stableStringSlice(blockers)
}

func CachePruneApprovalEvidence(report CachePruneReport) *ApprovalEvidence {
applyCommand := cachePruneApprovalCommand(report, "--apply")
dryRunCommand := cachePruneApprovalCommand(report, "--dry-run")
return &ApprovalEvidence{
SchemaVersion: ApprovalPlanSchemaVersion,
Capability: AdapterCapabilityApplyMutation,
CanonicalCommand: "gira cache prune",
DryRunCommand: dryRunCommand,
ApplyCommand: applyCommand,
OutputSchema: CachePruneReportSchemaVersion,
PlannedActions: cachePruneApprovalActions(report),
Blockers: cachePruneApprovalBlockers(report),
Warnings: []string{},
PostApplyVerification: dryRunCommand + " --json",
}
}

func cachePruneApprovalCommand(report CachePruneReport, mode string) string {
args := []string{"gira cache prune"}
if strings.TrimSpace(report.Root) != "" {
args = append(args, "--root", QuoteShellArg(report.Root))
}
args = append(args, mode)
return strings.Join(args, " ")
}

func cachePruneApprovalActions(report CachePruneReport) []ApprovalPlannedAction {
actions := []ApprovalPlannedAction{}
for _, action := range report.Actions {
if action.Status != "planned" || action.Action != "prune" {
continue
}
detail := strings.TrimSpace(action.Reason)
if strings.TrimSpace(action.Version) != "" {
detail = appendApprovalDetail(detail, "version="+action.Version)
}
actions = append(actions, ApprovalPlannedAction{
Action: "cache:prune",
Target: action.Path,
Detail: detail,
})
}
return actions
}

func cachePruneApprovalBlockers(report CachePruneReport) []string {
blockers := []string{}
if report.DryRun && report.Counts.Planned == 0 {
blockers = appendUniqueStrings(blockers, "cache_prune_no_planned_actions")
}
if report.Counts.Errors > 0 {
blockers = appendUniqueStrings(blockers, "cache_prune_action_error")
}
return stableStringSlice(blockers)
}
18 changes: 18 additions & 0 deletions internal/gira/cache_prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
)

const cachePruneCommand = "cache prune"
const CachePruneReportSchemaVersion = "cache-prune-report/v1"

type CachePruneOptions struct {
Root string
Expand All @@ -18,13 +19,22 @@ type CachePruneOptions struct {
}

type CachePruneReport struct {
SchemaVersion string `json:"schema_version,omitempty"`
Command string `json:"command"`
Root string `json:"root"`
ActiveVersion string `json:"active_version"`
ActiveComparable bool `json:"active_comparable"`
DryRun bool `json:"dry_run"`
Apply bool `json:"apply"`
Counts CachePruneCounts `json:"counts"`
Actions []CachePruneAction `json:"actions"`
Approval *ApprovalEvidence `json:"approval,omitempty"`
}

func EnsureCachePruneReportSchema(report *CachePruneReport) {
if report != nil && strings.TrimSpace(report.SchemaVersion) == "" {
report.SchemaVersion = CachePruneReportSchemaVersion
}
}

type CachePruneCounts struct {
Expand Down Expand Up @@ -76,16 +86,21 @@ func BuildCachePruneReport(options CachePruneOptions) (CachePruneReport, error)
activeVersion := normalizeBuildValue(options.ActiveVersion, "dev")
_, activeComparable := semverParts(activeVersion)
report := CachePruneReport{
SchemaVersion: CachePruneReportSchemaVersion,
Command: cachePruneCommand,
Root: root,
ActiveVersion: activeVersion,
ActiveComparable: activeComparable,
DryRun: options.DryRun,
Apply: options.Apply,
}

entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
if options.DryRun {
report.Approval = CachePruneApprovalEvidence(report)
}
return report, nil
}
return CachePruneReport{}, fmt.Errorf("read cache root: %w", err)
Expand Down Expand Up @@ -117,6 +132,9 @@ func BuildCachePruneReport(options CachePruneOptions) (CachePruneReport, error)
}
}

if options.DryRun {
report.Approval = CachePruneApprovalEvidence(report)
}
return report, nil
}

Expand Down
17 changes: 17 additions & 0 deletions internal/gira/cache_prune_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gira
import (
"os"
"path/filepath"
"strings"
"testing"
)

Expand All @@ -25,6 +26,19 @@ func TestBuildCachePruneReportSelectsOnlyOlderStableVersionDirs(t *testing.T) {
if report.Counts.Planned != 1 {
t.Fatalf("planned = %d, want 1; actions=%#v", report.Counts.Planned, report.Actions)
}
if report.SchemaVersion != CachePruneReportSchemaVersion || report.Approval == nil {
t.Fatalf("expected cache prune schema and approval evidence: %+v", report)
}
expectedApply := "gira cache prune --root " + QuoteShellArg(root) + " --apply"
if report.Approval.SchemaVersion != ApprovalPlanSchemaVersion || report.Approval.CanonicalCommand != "gira cache prune" || report.Approval.OutputSchema != CachePruneReportSchemaVersion {
t.Fatalf("unexpected cache prune approval identity: %+v", report.Approval)
}
if report.Approval.ApplyCommand != expectedApply || report.Approval.PostApplyVerification != strings.Replace(expectedApply, " --apply", " --dry-run --json", 1) {
t.Fatalf("unexpected cache prune approval commands: %+v", report.Approval)
}
if report.Approval.Blockers == nil || report.Approval.Warnings == nil || !approvalHasAction(report.Approval.PlannedActions, "cache:prune") {
t.Fatalf("unexpected cache prune approval plan: %+v", report.Approval)
}
assertAction(t, report, "v1.0.0", "prune", "planned")
assertAction(t, report, "v1.2.0", "skip", "skipped")
assertAction(t, report, "v1.3.0", "skip", "skipped")
Expand Down Expand Up @@ -73,6 +87,9 @@ func TestBuildCachePruneReportApplyDeletesOnlyPlannedStaleDirs(t *testing.T) {
if report.Counts.Applied != 1 {
t.Fatalf("applied = %d, want 1; actions=%#v", report.Counts.Applied, report.Actions)
}
if report.SchemaVersion != CachePruneReportSchemaVersion || !report.Apply || report.Approval != nil {
t.Fatalf("apply report should have schema/apply and omit dry-run approval: %+v", report)
}
assertMissing(t, stale)
assertExists(t, active)
assertExists(t, newer)
Expand Down