fix: critical audit findings remediation (epic soc-ab5g) (#279)

* fix(session): sanitize init-step exec and correct BEADS_ACTOR

Two defects in `ao session spawn` runInitSteps, from the 2026-05-16
codebase audit (epic soc-ab5g):

- BEADS_ACTOR was exported as the expanded command string of the first
  init step instead of the session actor identity, so beads attribution
  for every init step was garbage. Thread tmpl.Identity.BeadsActorTemplate
  through runInitSteps and export the var only when non-empty.
- Init steps ran via raw exec.Command("bash","-c",...) (SEC-C1). Route
  init-step exec through shellutil.SanitizedBashCommand and add
  sanitizeHostname() to strip shell metacharacters from {{hostname}}-class
  template vars before substitution.

New regression tests: TestRunInitStepsSetsBeadsActor, TestSanitizeHostname.

Refs: soc-jhxr

* fix(autodev): replace vague 'validation failed' with a concrete summary

outputAutodevValidateResult returned fmt.Errorf("validation failed")
after already printing the detailed INVALID/ERROR lines -- a redundant,
content-free wrapper. Return a summary naming the file and the
validation-error count instead (COPY-C1, epic soc-ab5g).

Refs: soc-mpzu

* docs(eval): strip internal Day-N cadence jargon from user-facing text

eval_task.go exposed internal sprint labels ("Day-2 placeholder",
"Day-3 wires real launch", "Day-4 gate #4") in command Long
descriptions and flag help. Reword to describe current behavior
without the internal cadence (COPY-C2, epic soc-ab5g).

Refs: soc-iy7p

* fix(cli): reject unknown subcommands on group commands

Group-parent commands (daemon, beads, codex, constraint, factory,
goals, hooks, ratchet, rpi, session) had no Args validator, so an
unknown subcommand printed help to stdout and exited 0 -- breaking
`if ao rpi <bad>; then ...` scripting. Add Args: cobra.NoArgs to all
ten; unknown subcommands now exit 1 with the error on stderr
(CLI-C1, epic soc-ab5g).

Refs: soc-mlqe

* docs(cli): regenerate COMMANDS.md after eval-task help copy edit

* fix(daemon): contain dream output_dir against path traversal

DreamRunJobSpec/DreamStageJobSpec/DreamStageManifest Validate() only
TrimSpace-checked output_dir, leaving the operator-supplied job payload
free to redirect summary/log writes outside the intended tree.

validateOutputDir now rejects ".." traversal in all three Validate()
paths; outputDirContained rejects an absolute output_dir resolving
outside the daemon working tree, checked in DreamExecutor.RunJob before
MkdirAll. Containment checks: 1 (symlink only) → 3 (symlink + .. + abs).

Closes soc-ly33 (SEC-C2, epic soc-ab5g).

* fix(cli): wire ao --version flag and unify goals --json with -o

rootCmd had no Version field, so `ao --version` was unsupported even
though an `ao version` subcommand existed. Set rootCmd.Version = version
(the ldflags-injected build var) so the standard --version flag works.

goals registered its own local --json bool, disconnected from the
global -o/--output flag — `ao goals measure -o json` was ignored. Drop
goalsJSON; goalsJSONOutput() now reads GetOutput(), the sibling pattern
used by agentopsd.go, autodev.go, codex.go and ~40 other callsites. The
global --json persistent flag is inherited, so `ao goals --json` still
works; output paths honored by goals: 1 (local bool) → 2 (--json + -o).

Closes soc-nx1o (CLI-C2, epic soc-ab5g).

* perf(cli): collapse tmux probe storm in ao rpi status

checkTmuxSessionAlive forked `tmux has-session` up to 3 times per
non-terminal run, each with a 2s timeout — a status scan over N runs
issued 3N subprocesses and could stall ~6N seconds when tmux was slow
or absent.

probeTmuxSessions now runs one `tmux ls -F #{session_name}`, memoized
per process behind a mutex-guarded cache; tmuxSessionAlive filters the
snapshot in Go. resolveRPIToolchainDefaults collapses from per-run to
once. Subprocesses per status scan: 3N → 1.

Closes soc-d7v5 (PERF-C1, epic soc-ab5g). Mirrors the snapshot-then-
filter shape used elsewhere for batch probes.

* perf(cli): one-pass git capture + walk-once index in ao beads audit

ao beads audit re-shelled git per bead (one `git log --grep` per bead,
one `git log --since` per bead-path pair) and re-walked the worktree
per pattern (recordAuditStaleFinding probes up to 10 patterns/bead, so
up to 10N full-repo walks for N beads).

captureAuditCommits now runs a single `git log --all --name-only`,
parsed into auditCommit records; grepCommitsForID and
fileChangesSinceCommits filter that slice in Go. repoContentCache walks
the scoped roots once (lazily) and memoizes a path->content map shared
across every pattern probe. git subprocesses per audit: O(beads) → 1;
repo walks: O(10*beads) → 1. Mirrors the snapshot-then-filter shape
just applied to ao rpi status.

Closes soc-2grz (PERF-C2, epic soc-ab5g).

* fix(schemas): declare schema_version const in 15 unversioned schemas

15 of 34 schemas under schemas/ carried versioned filenames (or implied
a stable contract) without a machine-readable schema_version, so a
consumer could not detect the version from the payload alone.

Each now declares an optional schema_version integer const, mirroring
the shape in schemas/bead.v1.schema.json: const 1 for every schema
except skill-frontmatter.v2 (const 2). The field is intentionally left
out of "required" so existing documents without it still validate —
non-breaking. scenario.v1 keeps its legacy "version" field alongside.
Schemas declaring schema_version: 19/34 → 34/34.

Closes soc-wzgo (API-C2, epic soc-ab5g).

* docs(contracts): regenerate context-map after merging main

The merge of origin/main pulled a discovery SKILL.md description edit
without its companion context-map regeneration, so
validate-context-map-drift flagged 1 stale line. Regenerated via
scripts/generate-context-map.sh. Drifted lines: 1 → 0.
This commit is contained in:
Bo
2026-05-16 11:05:36 -04:00
committed by GitHub
parent e10722ac5e
commit a4108a5503
56 changed files with 791 additions and 173 deletions
+1
View File
@@ -101,6 +101,7 @@ type agentopsDaemonRunOptions struct {
var daemonCmd = &cobra.Command{
Use: "daemon",
Short: "Run and inspect the AgentOps daemon",
Args: cobra.NoArgs,
}
var daemonRunCmd = &cobra.Command{
+1 -1
View File
@@ -184,7 +184,7 @@ func outputAutodevValidateResult(result autodevValidateResult) error {
for _, err := range result.Errors {
fmt.Printf(" ERROR: %s\n", err)
}
return fmt.Errorf("validation failed")
return fmt.Errorf("%s is invalid: %d validation error(s) listed above", result.Path, len(result.Errors))
}
func displayProgramPath(path string) string {
+1
View File
@@ -57,6 +57,7 @@ var bdAvailable = func() bool {
var beadsCmd = &cobra.Command{
Use: "beads",
Short: "Complementary tooling for the bd (beads) issue tracker",
Args: cobra.NoArgs,
Long: `Commands that help maintain the bd issue tracker alongside the main
bd CLI. These tools focus on catching stale descriptions before a new
session acts on them and harvesting closure reasons into durable learnings.
+159 -40
View File
@@ -13,6 +13,7 @@ import (
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/spf13/cobra"
@@ -124,10 +125,40 @@ var execGitLog = func(args ...string) (string, error) {
return string(out), err
}
// repoPatternExists searches the worktree for a literal pattern. Tests
// override it to keep audit classification deterministic.
var repoPatternExists = func(pattern string) bool {
return patternExistsInRepo(pattern)
// repoPatternExists searches the worktree for a literal pattern, reusing a
// content index so the tree is walked once per audit instead of once per
// pattern (soc-2grz, PERF-C2). Tests override it to keep audit classification
// deterministic.
var repoPatternExists = func(pattern string, cache *repoContentCache) bool {
return patternExistsInIndex(pattern, cache.index())
}
// repoContentCache walks the scoped audit roots at most once and memoizes a
// path -> file-content map. recordAuditStaleFinding probes up to 10 patterns
// per bead; without the cache that was up to 10N full-repo walks for N beads.
type repoContentCache struct {
once sync.Once
data map[string]string
}
// index returns the memoized worktree content map, building it on first use.
func (c *repoContentCache) index() map[string]string {
c.once.Do(func() { c.data = buildRepoContentIndex() })
return c.data
}
// patternExistsInIndex reports whether any indexed file contains the literal
// pattern.
func patternExistsInIndex(pattern string, index map[string]string) bool {
if pattern == "" {
return false
}
for _, content := range index {
if strings.Contains(content, pattern) {
return true
}
}
return false
}
type AuditFinding struct {
@@ -208,32 +239,37 @@ func auditBeads(autoClose bool) (*AuditReport, error) {
fileToBeads := make(map[string]map[string]bool)
consolidatableIDs := make(map[string]bool)
// PERF-C2 (soc-2grz): capture git history once and share a lazily-built
// worktree content index, rather than re-shelling/re-walking per bead.
commits := captureAuditCommits()
cache := &repoContentCache{}
for _, bead := range beads {
if bead.ID == "" {
continue
}
if recordAuditBeadFlow(report, bead, autoClose, fileToBeads) {
if recordAuditBeadFlow(report, bead, autoClose, fileToBeads, commits) {
continue
}
recordAuditBeadPaths(fileToBeads, bead.ID, extractAuditFilePaths(bead.textBody(), 10))
recordAuditStaleFinding(report, bead)
recordAuditStaleFinding(report, bead, cache)
}
finalizeAuditReport(report, fileToBeads, consolidatableIDs)
return report, nil
}
func recordAuditBeadFlow(report *AuditReport, bead beadRecord, autoClose bool, fileToBeads map[string]map[string]bool) bool {
func recordAuditBeadFlow(report *AuditReport, bead beadRecord, autoClose bool, fileToBeads map[string]map[string]bool, commits []auditCommit) bool {
desc := bead.textBody()
if recordLikelyFixedAuditFinding(report, bead, desc, autoClose) {
if recordLikelyFixedAuditFinding(report, bead, desc, autoClose, commits) {
return true
}
recordAuditBeadPaths(fileToBeads, bead.ID, extractAuditFilePaths(desc, 10))
return false
}
func recordLikelyFixedAuditFinding(report *AuditReport, bead beadRecord, desc string, autoClose bool) bool {
if evidence := firstGitLogLines("--all", "--oneline", "--grep="+bead.ID); evidence != "" {
func recordLikelyFixedAuditFinding(report *AuditReport, bead beadRecord, desc string, autoClose bool, commits []auditCommit) bool {
if evidence := grepCommitsForID(commits, bead.ID); evidence != "" {
report.LikelyFixed = append(report.LikelyFixed, AuditFinding{
ID: bead.ID,
Title: bead.displayTitle(),
@@ -247,7 +283,7 @@ func recordLikelyFixedAuditFinding(report *AuditReport, bead beadRecord, desc st
}
paths := extractAuditFilePaths(desc, 10)
if bead.CreatedAt != "" && len(paths) > 0 {
if evidence := fileChangesSince(bead.CreatedAt, paths); evidence != "" {
if evidence := fileChangesSinceCommits(commits, bead.CreatedAt, paths); evidence != "" {
report.LikelyFixed = append(report.LikelyFixed, AuditFinding{
ID: bead.ID,
Title: bead.displayTitle(),
@@ -263,9 +299,9 @@ func recordLikelyFixedAuditFinding(report *AuditReport, bead beadRecord, desc st
return false
}
func recordAuditStaleFinding(report *AuditReport, bead beadRecord) {
func recordAuditStaleFinding(report *AuditReport, bead beadRecord, cache *repoContentCache) {
patterns := extractAuditPatterns(bead.textBody(), 10)
if len(patterns) > 0 && !anyPatternExists(patterns) {
if len(patterns) > 0 && !anyPatternExists(patterns, cache) {
report.LikelyStale = append(report.LikelyStale, AuditFinding{
ID: bead.ID,
Title: bead.displayTitle(),
@@ -361,20 +397,102 @@ func parseBDRecord(raw []byte) (beadRecord, error) {
return record, nil
}
func firstGitLogLines(args ...string) string {
out, err := execGitLog(append([]string{"log"}, args...)...)
if err != nil {
return ""
}
return firstNNonEmptyLines(out, 3)
// auditCommit is one reachable commit's metadata, captured once for the whole
// audit instead of re-shelling to git per bead.
type auditCommit struct {
shortSHA string
subject string
body string
commitAt time.Time
files map[string]struct{}
}
func fileChangesSince(createdAt string, paths []string) string {
// captureAuditCommits runs a single `git log --all` and parses every reachable
// commit's metadata and touched files. The audit previously forked git once
// per bead for --grep matching and once per (bead, path) pair for --since file
// history; this collapses all of it to one subprocess (soc-2grz, PERF-C2).
func captureAuditCommits() []auditCommit {
const recSep, fldSep = "\x1e", "\x1f"
out, err := execGitLog("log", "--all", "--name-only",
"--pretty=format:"+recSep+"%h"+fldSep+"%cI"+fldSep+"%s"+fldSep+"%b"+fldSep)
if err != nil || strings.TrimSpace(out) == "" {
return nil
}
var commits []auditCommit
for _, record := range strings.Split(out, recSep) {
if record = strings.TrimLeft(record, "\n"); record == "" {
continue
}
// parts: short-sha, committer-date, subject, body, "\n<files>".
parts := strings.SplitN(record, fldSep, 5)
if len(parts) < 5 {
continue
}
commit := auditCommit{
shortSHA: strings.TrimSpace(parts[0]),
commitAt: parseGitTime(parts[1]),
subject: strings.TrimSpace(parts[2]),
body: parts[3],
files: map[string]struct{}{},
}
for _, line := range strings.Split(parts[4], "\n") {
if f := strings.TrimSpace(line); f != "" {
commit.files[f] = struct{}{}
}
}
commits = append(commits, commit)
}
return commits
}
// parseGitTime parses a strict-ISO git date, returning the zero time on error.
func parseGitTime(s string) time.Time {
t, err := time.Parse(time.RFC3339, strings.TrimSpace(s))
if err != nil {
return time.Time{}
}
return t
}
// grepCommitsForID returns up to 3 "<short-sha> <subject>" lines for commits
// whose message references the bead ID, mirroring `git log --oneline --grep`.
func grepCommitsForID(commits []auditCommit, id string) string {
if id == "" {
return ""
}
var lines []string
for _, c := range commits {
if strings.Contains(c.subject, id) || strings.Contains(c.body, id) {
lines = append(lines, c.shortSHA+" "+c.subject)
if len(lines) == 3 {
break
}
}
}
return strings.Join(lines, "\n")
}
// fileChangesSinceCommits returns "<short-sha> <subject>" evidence for commits
// after createdAt that touched one of the given paths, mirroring the prior
// per-path `git log --oneline --since=<createdAt> -- <path>` calls.
func fileChangesSinceCommits(commits []auditCommit, createdAt string, paths []string) string {
since := parseGitTime(createdAt)
var chunks []string
for _, path := range paths {
evidence := firstGitLogLines("--oneline", "--since="+createdAt, "--", path)
if evidence != "" {
chunks = append(chunks, evidence)
for _, p := range paths {
var lines []string
for _, c := range commits {
if !since.IsZero() && !c.commitAt.After(since) {
continue
}
if _, ok := c.files[p]; ok {
lines = append(lines, c.shortSHA+" "+c.subject)
if len(lines) == 3 {
break
}
}
}
if len(lines) > 0 {
chunks = append(chunks, strings.Join(lines, "\n"))
}
}
return strings.Join(chunks, "\n")
@@ -430,33 +548,38 @@ func extractAuditPatterns(desc string, limit int) []string {
return out
}
func anyPatternExists(patterns []string) bool {
func anyPatternExists(patterns []string, cache *repoContentCache) bool {
for _, pattern := range patterns {
if repoPatternExists(pattern) {
if repoPatternExists(pattern, cache) {
return true
}
}
return false
}
// patternExistsInRepo reports whether a literal pattern occurs anywhere in the
// scoped audit roots. It builds a fresh index per call; the audit pipeline
// instead shares one repoContentCache across all patterns.
func patternExistsInRepo(pattern string) bool {
if pattern == "" {
return false
}
return patternExistsInIndex(pattern, buildRepoContentIndex())
}
// buildRepoContentIndex walks the scoped audit roots once and returns a
// root-prefixed path -> content map of searchable files under the size cap.
func buildRepoContentIndex() map[string]string {
index := map[string]string{}
roots := []string{"cli", "skills", "skills-codex", "scripts", "docs", "tests"}
for _, root := range roots {
openRoot, err := os.OpenRoot(root)
if err != nil {
continue
}
found := false
_ = fs.WalkDir(openRoot.FS(), ".", func(walkPath string, d fs.DirEntry, err error) error {
if err != nil || found {
if err != nil {
return nil
}
if d.IsDir() {
base := path.Base(walkPath)
switch base {
switch path.Base(walkPath) {
case ".git", ".beads", ".agents", "node_modules", "vendor", "testdata":
return fs.SkipDir
}
@@ -469,18 +592,14 @@ func patternExistsInRepo(pattern string) bool {
if statErr != nil || info.Size() > 1_000_000 {
return nil
}
content, readErr := openRoot.ReadFile(walkPath)
if readErr == nil && strings.Contains(string(content), pattern) {
found = true
if content, readErr := openRoot.ReadFile(walkPath); readErr == nil {
index[path.Join(root, walkPath)] = string(content)
}
return nil
})
_ = openRoot.Close()
if found {
return true
}
}
return false
return index
}
func isAuditSearchFile(path string) bool {
+132
View File
@@ -0,0 +1,132 @@
// Tests for the PERF-C2 (soc-2grz) one-pass git capture + walk-once content
// index that replaced the per-bead `git log` forks and per-pattern repo walks
// in `ao beads audit`.
// practices: [dora-metrics, lean-startup]
package main
import (
"errors"
"testing"
"time"
)
func mustGitTime(t *testing.T, s string) time.Time {
t.Helper()
parsed := parseGitTime(s)
if parsed.IsZero() {
t.Fatalf("parseGitTime(%q) returned zero time", s)
}
return parsed
}
func TestCaptureAuditCommits_ParsesRecords(t *testing.T) {
orig := execGitLog
t.Cleanup(func() { execGitLog = orig })
// Two commits: one with a body + two files, one with an empty body.
out := "\x1eabc123\x1f2026-05-10T00:00:00Z\x1ffix: thing\x1fCloses soc-xyz\x1f\n" +
"cli/a.go\ncli/b.go\n" +
"\x1edef456\x1f2026-05-12T00:00:00Z\x1ffeat: other\x1f\x1f\n" +
"docs/c.md"
execGitLog = func(args ...string) (string, error) { return out, nil }
commits := captureAuditCommits()
if len(commits) != 2 {
t.Fatalf("captureAuditCommits parsed %d commits, want 2", len(commits))
}
if commits[0].shortSHA != "abc123" || commits[0].subject != "fix: thing" {
t.Errorf("commit 0 = %+v, want sha abc123 subject 'fix: thing'", commits[0])
}
if commits[0].body != "Closes soc-xyz" {
t.Errorf("commit 0 body = %q, want 'Closes soc-xyz'", commits[0].body)
}
if _, ok := commits[0].files["cli/a.go"]; !ok {
t.Errorf("commit 0 files = %v, want cli/a.go", commits[0].files)
}
if commits[1].body != "" {
t.Errorf("commit 1 body = %q, want empty", commits[1].body)
}
if !commits[1].commitAt.Equal(mustGitTime(t, "2026-05-12T00:00:00Z")) {
t.Errorf("commit 1 commitAt = %v, want 2026-05-12", commits[1].commitAt)
}
}
func TestCaptureAuditCommits_EmptyOnGitError(t *testing.T) {
orig := execGitLog
t.Cleanup(func() { execGitLog = orig })
execGitLog = func(args ...string) (string, error) { return "", errors.New("git unavailable") }
if commits := captureAuditCommits(); commits != nil {
t.Errorf("captureAuditCommits on git error = %v, want nil", commits)
}
}
func TestGrepCommitsForID(t *testing.T) {
commits := []auditCommit{
{shortSHA: "c1", subject: "fix: soc-aaa in subject"},
{shortSHA: "c2", subject: "feat: thing", body: "also touches soc-aaa here"},
{shortSHA: "c3", subject: "unrelated work"},
{shortSHA: "c4", subject: "soc-aaa again"},
{shortSHA: "c5", subject: "soc-aaa fourth match"},
}
got := grepCommitsForID(commits, "soc-aaa")
want := "c1 fix: soc-aaa in subject\nc2 feat: thing\nc4 soc-aaa again"
if got != want {
t.Errorf("grepCommitsForID = %q, want %q (first 3 matches)", got, want)
}
if grepCommitsForID(commits, "soc-zzz") != "" {
t.Error("grepCommitsForID found a match for an absent ID")
}
if grepCommitsForID(commits, "") != "" {
t.Error("grepCommitsForID matched on an empty ID")
}
}
func TestFileChangesSinceCommits(t *testing.T) {
commits := []auditCommit{
{shortSHA: "new1", subject: "edit a", commitAt: mustGitTime(t, "2026-05-12T00:00:00Z"),
files: map[string]struct{}{"cli/a.go": {}}},
{shortSHA: "old1", subject: "edit a old", commitAt: mustGitTime(t, "2026-04-01T00:00:00Z"),
files: map[string]struct{}{"cli/a.go": {}}},
{shortSHA: "new2", subject: "edit b", commitAt: mustGitTime(t, "2026-05-13T00:00:00Z"),
files: map[string]struct{}{"cli/b.go": {}}},
}
// Bead created 2026-05-01: only commits after that count.
got := fileChangesSinceCommits(commits, "2026-05-01T00:00:00Z", []string{"cli/a.go"})
if got != "new1 edit a" {
t.Errorf("fileChangesSinceCommits = %q, want 'new1 edit a' (old1 predates creation)", got)
}
// A path no commit touched yields no evidence.
if got := fileChangesSinceCommits(commits, "2026-05-01T00:00:00Z", []string{"cli/missing.go"}); got != "" {
t.Errorf("fileChangesSinceCommits for untouched path = %q, want empty", got)
}
}
func TestPatternExistsInIndex(t *testing.T) {
index := map[string]string{
"cli/a.go": "package main\nfunc Alpha() {}\n",
"docs/b.md": "# Heading\nsome prose\n",
"skills/c.sh": "echo hello\n",
}
if !patternExistsInIndex("func Alpha", index) {
t.Error("patternExistsInIndex missed a pattern present in the index")
}
if patternExistsInIndex("func Omega", index) {
t.Error("patternExistsInIndex matched a pattern absent from the index")
}
if patternExistsInIndex("", index) {
t.Error("patternExistsInIndex matched on an empty pattern")
}
}
func TestRepoContentCacheBuildsOnce(t *testing.T) {
cache := &repoContentCache{}
first := cache.index()
// Stamp a sentinel into the backing map. If index() rebuilds, the second
// call drops the sentinel; if it reuses the memoized map, it survives.
first["__sentinel__"] = "marker"
second := cache.index()
if _, ok := second["__sentinel__"]; !ok {
t.Error("repoContentCache rebuilt its index on the second index() call")
}
}
+1 -1
View File
@@ -349,7 +349,7 @@ func TestAuditBeads_ClassifiesStaleAndConsolidatable(t *testing.T) {
execGitLog = func(args ...string) (string, error) {
return "", nil
}
repoPatternExists = func(pattern string) bool {
repoPatternExists = func(pattern string, cache *repoContentCache) bool {
return false
}
+9 -9
View File
@@ -46,7 +46,7 @@ func executeCommand(args ...string) (string, error) {
origSeedForce := seedForce
origNoBeads := noBeads
origMinimal := minimal
origGoalsJSON := goalsJSON
origGoalsJSON := output
origMemorySyncQuiet := memorySyncQuiet
origMemorySyncMaxEntries := memorySyncMaxEntries
origMemorySyncOutput := memorySyncOutput
@@ -148,7 +148,7 @@ func executeCommand(args ...string) (string, error) {
seedForce = origSeedForce
noBeads = origNoBeads
minimal = origMinimal
goalsJSON = origGoalsJSON
output = origGoalsJSON
memorySyncQuiet = origMemorySyncQuiet
memorySyncMaxEntries = origMemorySyncMaxEntries
memorySyncOutput = origMemorySyncOutput
@@ -248,7 +248,7 @@ func executeCommand(args ...string) (string, error) {
seedForce = false
noBeads = false
minimal = false
goalsJSON = false
output = "table"
memorySyncQuiet = false
memorySyncMaxEntries = 10
memorySyncOutput = ""
@@ -884,8 +884,8 @@ Increase coverage
t.Fatal(err)
}
goalsJSON = true
defer func() { goalsJSON = false }()
output = "json"
defer func() { output = "table" }()
out, err := executeCommand("goals", "validate", "--json")
if err != nil {
@@ -1327,7 +1327,7 @@ func TestCobraGlobalFlags(t *testing.T) {
// TestCobraOutputValidateResult exercises outputValidateResult directly.
func TestCobraOutputValidateResult(t *testing.T) {
t.Run("valid_table", func(t *testing.T) {
goalsJSON = false
output = "table"
// Capture stdout
old := os.Stdout
r, w, _ := os.Pipe()
@@ -1357,7 +1357,7 @@ func TestCobraOutputValidateResult(t *testing.T) {
})
t.Run("invalid_table", func(t *testing.T) {
goalsJSON = false
output = "table"
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
@@ -1384,8 +1384,8 @@ func TestCobraOutputValidateResult(t *testing.T) {
})
t.Run("valid_json", func(t *testing.T) {
goalsJSON = true
defer func() { goalsJSON = false }()
output = "json"
defer func() { output = "table" }()
old := os.Stdout
r, w, _ := os.Pipe()
+1
View File
@@ -161,6 +161,7 @@ type codexStatusResult struct {
var codexCmd = &cobra.Command{
Use: "codex",
Short: "Codex lifecycle commands (fallback for pre-v0.115.0; native hooks preferred)",
Args: cobra.NoArgs,
Long: `Codex lifecycle commands for the AgentOps knowledge flywheel.
Codex CLI v0.115.0+ supports native hooks prefer those for automatic lifecycle.
+1
View File
@@ -22,6 +22,7 @@ type (
var constraintCmd = &cobra.Command{
Use: "constraint",
Short: "Manage compiled constraints",
Args: cobra.NoArgs,
Long: `Manage constraints compiled from promoted findings.
Constraints are generated by hooks/finding-compiler.sh into
-1
View File
@@ -763,4 +763,3 @@ func TestTrigramOverlap_OneEmpty(t *testing.T) {
t.Errorf("trigramOverlap(a, empty) = %v, want 0", got)
}
}
+7 -7
View File
@@ -52,7 +52,7 @@ var evalTaskCmd = &cobra.Command{
Long: `Operate on the §3 Task primitive of the eval substrate.
Tasks live under $AGENTOPS_EVALS_ROOT/tasks/<id>/task.yaml and define the
input/output contract a Run will be evaluated against. The Day-2 surface
input/output contract a Run will be evaluated against. The command surface
exposes:
ao eval task add <task.yaml> Register a Task by copying its file
@@ -177,10 +177,10 @@ var evalTaskRunCmd = &cobra.Command{
in pending state, runs §6 manifest-checkable gates 1/6/7/8/9, and on pass
transitions the manifest to running.
This Day-2 command does NOT yet launch Inspect that wiring lands Day 3.
The atomic-write contract, manifest fields, and refusal format are all
fully exercised here. Use --dry-run to refuse-test without creating the run
directory.`,
This command opens and gates the Run manifest; it does not yet launch
Inspect itself. The atomic-write contract, manifest fields, and refusal
format are all fully exercised here. Use --dry-run to refuse-test without
creating the run directory.`,
Args: cobra.ExactArgs(1),
RunE: runEvalTaskRun,
}
@@ -469,8 +469,8 @@ func registerEvalTaskCmd() {
evalTaskRunCmd.Flags().StringVar(&evalTaskRunSampleSplit, "sample-split", "", "Sample split (dev|holdout); default from suite")
evalTaskRunCmd.Flags().IntVar(&evalTaskRunNSamples, "n-samples", 0, "Override Suite.n_samples")
evalTaskRunCmd.Flags().StringVar(&evalTaskRunInspectVersion, "inspect-version", "0.3.216", "Inspect AI version stamped into manifest")
evalTaskRunCmd.Flags().StringVar(&evalTaskRunInspectCommand, "inspect-command", "", "Recorded inspect_command (Day-2 placeholder; Day-3 wires real launch)")
evalTaskRunCmd.Flags().BoolVar(&evalTaskRunCrossSpec, "cross-spec", false, "Allow ModelSpec drift (Day-4 gate #4)")
evalTaskRunCmd.Flags().StringVar(&evalTaskRunInspectCommand, "inspect-command", "", "Inspect command recorded into the Run manifest (not executed yet)")
evalTaskRunCmd.Flags().BoolVar(&evalTaskRunCrossSpec, "cross-spec", false, "Allow ModelSpec drift (gate #4)")
evalTaskRunCmd.Flags().BoolVar(&evalTaskRunAllowWeak, "allow-weak-labels", false, "Allow runs against confidence=weak ground-truth rows (gate #7)")
evalTaskRunCmd.Flags().BoolVar(&evalTaskRunQuickSession, "quick", false, "Mark Run as quick_session=true (excluded from --vs auto-baseline pool)")
evalTaskRunCmd.Flags().BoolVar(&evalTaskRunDryRun, "dry-run", false, "Run gates and exit without writing a Run manifest")
+1
View File
@@ -30,6 +30,7 @@ type factoryStartResult struct {
var factoryCmd = &cobra.Command{
Use: "factory",
Short: "Software-factory operator surface for briefing-first agent work",
Args: cobra.NoArgs,
Long: `Software-factory operator surface for AgentOps.
This surface keeps the operator lane explicit:
+9 -2
View File
@@ -10,6 +10,7 @@ import (
var goalsCmd = &cobra.Command{
Use: "goals",
Short: "Fitness goal measurement and validation",
Args: cobra.NoArgs,
Long: `Track, measure, and validate project fitness goals.
Supports both GOALS.yaml (versions 1-3) and GOALS.md (version 4) formats.
@@ -38,10 +39,17 @@ const defaultGoalsTimeoutSeconds = 240
// Shared flags
var (
goalsFile string // --file, auto-detects GOALS.md then GOALS.yaml
goalsJSON bool // --json
goalsTimeout int // --timeout in seconds, default defaultGoalsTimeoutSeconds
)
// goalsJSONOutput reports whether the goals family should emit JSON. It reads
// the global -o/--output flag (set to "json" by either -o json or --json) so
// the goals subcommands honor the same output flag as the rest of the CLI
// instead of a disconnected local --json bool.
func goalsJSONOutput() bool {
return GetOutput() == "json"
}
func init() {
goalsCmd.AddGroup(
&cobra.Group{ID: "measurement", Title: "Measurement:"},
@@ -49,7 +57,6 @@ func init() {
&cobra.Group{ID: "management", Title: "Management:"},
)
goalsCmd.PersistentFlags().StringVar(&goalsFile, "file", "", "Path to goals file (auto-detects GOALS.md then GOALS.yaml)")
goalsCmd.PersistentFlags().BoolVar(&goalsJSON, "json", false, "Output as JSON")
goalsCmd.PersistentFlags().IntVar(&goalsTimeout, "timeout", defaultGoalsTimeoutSeconds, "Check timeout in seconds")
goalsCmd.GroupID = "workflow"
rootCmd.AddCommand(goalsCmd)
+1 -1
View File
@@ -17,7 +17,7 @@ var goalsDriftCmd = &cobra.Command{
return goals.RunDrift(goals.DriftOptions{
GoalsFile: resolveGoalsFile(),
Timeout: time.Duration(goalsTimeout) * time.Second,
JSON: goalsJSON,
JSON: goalsJSONOutput(),
})
},
}
+1 -1
View File
@@ -18,7 +18,7 @@ var goalsHistoryCmd = &cobra.Command{
return goals.RunHistory(goals.HistoryOptions{
GoalID: goalsHistoryGoalID,
Since: goalsHistorySince,
JSON: goalsJSON,
JSON: goalsJSONOutput(),
})
},
}
+1 -1
View File
@@ -28,7 +28,7 @@ var goalsInitCmd = &cobra.Command{
NonInteractive: goalsInitNonInteractive,
Template: goalsInitTemplate,
GoalsFile: resolveGoalsFile(),
JSON: goalsJSON,
JSON: goalsJSONOutput(),
DryRun: dryRun,
Stdin: os.Stdin,
TemplatesFS: embedded.TemplatesFS,
+1 -1
View File
@@ -28,7 +28,7 @@ var goalsMeasureCmd = &cobra.Command{
GoalsFile: resolveGoalsFile(),
Timeout: time.Duration(goalsTimeout) * time.Second,
TotalTimeout: time.Duration(goalsMeasureTotalTimeout) * time.Second,
JSON: goalsJSON,
JSON: goalsJSONOutput(),
Verbose: verbose,
})
},
+1 -1
View File
@@ -16,7 +16,7 @@ var goalsMetaCmd = &cobra.Command{
return goals.RunMeta(goals.MetaOptions{
GoalsFile: resolveGoalsFile(),
Timeout: time.Duration(goalsTimeout) * time.Second,
JSON: goalsJSON,
JSON: goalsJSONOutput(),
})
},
}
+1 -1
View File
@@ -19,7 +19,7 @@ var goalsPruneCmd = &cobra.Command{
return goals.RunPrune(goals.PruneOptions{
GoalsFile: resolveGoalsFile(),
DryRun: dryRun,
JSON: goalsJSON,
JSON: goalsJSONOutput(),
})
},
}
+9 -9
View File
@@ -121,15 +121,15 @@ Mission.
oldFile := goalsFile
oldDryRun := dryRun
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
dryRun = oldDryRun
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
dryRun = false
goalsJSON = false
output = "table"
err := goalsPruneCmd.RunE(goalsPruneCmd, nil)
if err != nil {
@@ -160,15 +160,15 @@ Mission.
oldFile := goalsFile
oldDryRun := dryRun
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
dryRun = oldDryRun
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
dryRun = true
goalsJSON = false
output = "table"
err := goalsPruneCmd.RunE(goalsPruneCmd, nil)
if err != nil {
@@ -208,15 +208,15 @@ Mission.
oldFile := goalsFile
oldDryRun := dryRun
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
dryRun = oldDryRun
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
dryRun = false
goalsJSON = false
output = "table"
err := goalsPruneCmd.RunE(goalsPruneCmd, nil)
if err != nil {
+3 -3
View File
@@ -33,7 +33,7 @@ var goalsSteerAddCmd = &cobra.Command{
Description: steerAddDescription,
Steer: steerAddSteer,
GoalsFile: resolveGoalsFile(),
JSON: goalsJSON,
JSON: goalsJSONOutput(),
DryRun: dryRun,
})
},
@@ -51,7 +51,7 @@ var goalsSteerRemoveCmd = &cobra.Command{
return goals.RunSteerRemove(goals.SteerRemoveOptions{
Number: num,
GoalsFile: resolveGoalsFile(),
JSON: goalsJSON,
JSON: goalsJSONOutput(),
DryRun: dryRun,
})
},
@@ -74,7 +74,7 @@ var goalsSteerPrioritizeCmd = &cobra.Command{
Number: num,
NewPosition: newPos,
GoalsFile: resolveGoalsFile(),
JSON: goalsJSON,
JSON: goalsJSONOutput(),
DryRun: dryRun,
})
},
+15 -15
View File
@@ -139,19 +139,19 @@ func TestSteerAdd_AppendsDirective(t *testing.T) {
oldSteer := steerAddSteer
oldDesc := steerAddDescription
oldDryRun := dryRun
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
steerAddSteer = oldSteer
steerAddDescription = oldDesc
dryRun = oldDryRun
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
steerAddSteer = "explore"
steerAddDescription = "Try new things"
dryRun = false
goalsJSON = false
output = "table"
err := goalsSteerAddCmd.RunE(goalsSteerAddCmd, []string{"Experiment more"})
if err != nil {
@@ -236,15 +236,15 @@ func TestSteerRemove_RemovesAndRenumbers(t *testing.T) {
oldFile := goalsFile
oldDryRun := dryRun
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
dryRun = oldDryRun
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
dryRun = false
goalsJSON = false
output = "table"
// Remove directive #2 ("Stay secure")
err := goalsSteerRemoveCmd.RunE(goalsSteerRemoveCmd, []string{"2"})
@@ -310,15 +310,15 @@ func TestSteerRemove_DryRun(t *testing.T) {
oldFile := goalsFile
oldDryRun := dryRun
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
dryRun = oldDryRun
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
dryRun = true
goalsJSON = false
output = "table"
err := goalsSteerRemoveCmd.RunE(goalsSteerRemoveCmd, []string{"1"})
if err != nil {
@@ -352,15 +352,15 @@ func TestSteerPrioritize_MovesToNewPosition(t *testing.T) {
oldFile := goalsFile
oldDryRun := dryRun
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
dryRun = oldDryRun
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
dryRun = false
goalsJSON = false
output = "table"
// Move directive #3 ("Reduce debt") to position 1
err := goalsSteerPrioritizeCmd.RunE(goalsSteerPrioritizeCmd, []string{"3", "1"})
@@ -499,15 +499,15 @@ func TestSteerPrioritize_DryRun(t *testing.T) {
oldFile := goalsFile
oldDryRun := dryRun
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
dryRun = oldDryRun
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
dryRun = true
goalsJSON = false
output = "table"
err := goalsSteerPrioritizeCmd.RunE(goalsSteerPrioritizeCmd, []string{"3", "1"})
if err != nil {
+24 -12
View File
@@ -54,21 +54,33 @@ func TestGoalsCmd_HasGroups(t *testing.T) {
func TestGoalsCmd_PersistentFlags(t *testing.T) {
flags := goalsCmd.PersistentFlags()
tests := []struct {
name string
}{
{"file"},
{"json"},
{"timeout"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := flags.Lookup(tt.name)
if f == nil {
t.Errorf("missing persistent flag %q", tt.name)
// CLI-C2 (soc-nx1o): goals no longer registers a local --json bool. The
// goals family reads the global -o/--output flag via goalsJSONOutput() so
// --json and -o json behave identically across the whole CLI.
for _, name := range []string{"file", "timeout"} {
t.Run(name, func(t *testing.T) {
if flags.Lookup(name) == nil {
t.Errorf("missing persistent flag %q", name)
}
})
}
if flags.Lookup("json") != nil {
t.Error("goals still registers a local --json flag; it should inherit the global --json")
}
}
func TestGoalsJSONOutput_ReadsGlobalOutputFlag(t *testing.T) {
prev := output
t.Cleanup(func() { output = prev })
output = "json"
if !goalsJSONOutput() {
t.Error("goalsJSONOutput() = false with output=json, want true")
}
output = "table"
if goalsJSONOutput() {
t.Error("goalsJSONOutput() = true with output=table, want false")
}
}
func TestGoalsCmd_DefaultTimeoutCoversRepoRaceGate(t *testing.T) {
+2 -2
View File
@@ -19,14 +19,14 @@ var goalsValidateCmd = &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
return goals.RunValidate(goals.ValidateOptions{
GoalsFile: resolveGoalsFile(),
JSON: goalsJSON,
JSON: goalsJSONOutput(),
})
},
}
// outputValidateResult delegates to goals.OutputValidateResult (used by tests).
func outputValidateResult(result validateResult) error {
return goals.OutputValidateResult(os.Stdout, goalsJSON, result)
return goals.OutputValidateResult(os.Stdout, goalsJSONOutput(), result)
}
func init() {
+21 -21
View File
@@ -40,13 +40,13 @@ Set up quality gates.
}
oldFile := goalsFile
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
goalsJSON = false
output = "table"
// Redirect stdout to avoid test noise
r, w, _ := os.Pipe()
@@ -84,13 +84,13 @@ Mission.
}
oldFile := goalsFile
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
goalsJSON = false
output = "table"
r, w, _ := os.Pipe()
oldStdout := os.Stdout
@@ -137,13 +137,13 @@ Body.
}
oldFile := goalsFile
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
goalsJSON = true
output = "json"
r, w, _ := os.Pipe()
oldStdout := os.Stdout
@@ -200,13 +200,13 @@ func TestGoalsValidate_WarningsForEmptyMission(t *testing.T) {
}
oldFile := goalsFile
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
goalsJSON = true
output = "json"
r, w, _ := os.Pipe()
oldStdout := os.Stdout
@@ -276,13 +276,13 @@ Body text without steer line.
}
oldFile := goalsFile
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
goalsJSON = true
output = "json"
r, w, _ := os.Pipe()
oldStdout := os.Stdout
@@ -337,13 +337,13 @@ Mission.
t.Chdir(dir)
oldFile := goalsFile
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = goalsPath
goalsJSON = true
output = "json"
r, w, _ := os.Pipe()
oldStdout := os.Stdout
@@ -374,13 +374,13 @@ func TestGoalsValidate_MissingGoalsFile(t *testing.T) {
dir := t.TempDir()
oldFile := goalsFile
oldJSON := goalsJSON
oldJSON := output
defer func() {
goalsFile = oldFile
goalsJSON = oldJSON
output = oldJSON
}()
goalsFile = filepath.Join(dir, "GOALS.md") // does not exist
goalsJSON = true
output = "json"
r, w, _ := os.Pipe()
oldStdout := os.Stdout
+1
View File
@@ -123,6 +123,7 @@ type hooksMapLoadResult struct {
var hooksCmd = &cobra.Command{
Use: "hooks",
Short: "Manage runtime hooks for automatic knowledge flywheel",
Args: cobra.NoArgs,
Long: `The hooks command manages runtime hooks that automate the CASS knowledge flywheel.
Note: Hook install targets Claude Code (~/.claude/settings.json). Codex uses a
native hook install via scripts/install-codex-plugin.sh when available; older
+3 -3
View File
@@ -32,9 +32,9 @@ func withOutputJSON(t *testing.T) {
// withGoalsJSON temporarily sets the goals-specific JSON flag.
func withGoalsJSON(t *testing.T) {
t.Helper()
prev := goalsJSON
goalsJSON = true
t.Cleanup(func() { goalsJSON = prev })
prev := output
output = "json"
t.Cleanup(func() { output = prev })
}
// withDoctorJSON temporarily sets the doctor-specific JSON flag.
+1
View File
@@ -10,6 +10,7 @@ import (
var ratchetCmd = &cobra.Command{
Use: "ratchet",
Short: "Brownian Ratchet workflow tracking",
Args: cobra.NoArgs,
Long: `Track progress through the phased RPI workflow.
The Brownian Ratchet ensures progress can't be lost:
+3 -2
View File
@@ -23,8 +23,9 @@ var (
// rootCmd represents the base command when called without any subcommands.
var rootCmd = &cobra.Command{
Use: "ao",
Short: "AgentOps Knowledge Compounding CLI",
Use: "ao",
Version: version,
Short: "AgentOps Knowledge Compounding CLI",
Long: `ao is the CLI for AgentOps, a software-factory control plane for repo-native agent work.
"Problem in. Value out. Intelligence compounds."
+1
View File
@@ -8,6 +8,7 @@ import (
var rpiCmd = &cobra.Command{
Use: "rpi",
Short: "RPI lifecycle automation",
Args: cobra.NoArgs,
Long: `Commands for automating the RPI lifecycle.
Commands:
+70 -10
View File
@@ -11,6 +11,7 @@ import (
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -956,30 +957,89 @@ func displayPhaseName(state phasedState) string {
return cliRPI.DisplayPhaseName(state.SchemaVersion, state.Phase)
}
// checkTmuxSessionAlive checks if any tmux session matching ao-rpi-<runID>-* exists.
func checkTmuxSessionAlive(runID string) bool {
if runID == "" {
return false
// rpiTmuxSessions memoizes the tmux session snapshot for the lifetime of one
// `ao rpi status` invocation. checkTmuxSessionAlive used to fork
// `tmux has-session` up to 3 times per non-terminal run — 3N subprocesses with
// a 2s timeout each, so a status scan over N runs could stall for ~6N seconds
// if tmux was slow or absent. A single `tmux ls` filtered in Go collapses that
// to one subprocess regardless of run count (soc-d7v5, PERF-C1). The snapshot
// also folds in the single resolveRPIToolchainDefaults call.
var (
rpiTmuxMu sync.Mutex
rpiTmuxLoaded bool
rpiTmuxSessions map[string]struct{}
)
// liveTmuxSessions returns the set of tmux session names visible to the daemon,
// captured once per process. An absent tmux server or any probe error yields
// an empty set, matching the prior has-session "not found" behavior.
func liveTmuxSessions() map[string]struct{} {
rpiTmuxMu.Lock()
defer rpiTmuxMu.Unlock()
if rpiTmuxLoaded {
return rpiTmuxSessions
}
rpiTmuxSessions = probeTmuxSessions()
rpiTmuxLoaded = true
return rpiTmuxSessions
}
// resetTmuxSessionCache clears the memoized snapshot so the next
// liveTmuxSessions call re-probes. Only tests that swap the tmux binary or
// PATH need this; production never calls it.
func resetTmuxSessionCache() {
rpiTmuxMu.Lock()
defer rpiTmuxMu.Unlock()
rpiTmuxLoaded = false
rpiTmuxSessions = nil
}
// probeTmuxSessions runs a single `tmux ls` and parses the session names.
func probeTmuxSessions() map[string]struct{} {
sessions := map[string]struct{}{}
tmuxCommand := "tmux"
if tc, err := resolveRPIToolchainDefaults(); err == nil {
tmuxCommand = tc.TmuxCommand
} else {
VerbosePrintf("Warning: could not resolve RPI toolchain for tmux probe: %v\n", err)
}
ctx, cancel := context.WithTimeout(context.Background(), tmuxProbeTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, tmuxCommand, "ls", "-F", "#{session_name}").Output()
if err != nil {
return sessions // no tmux server or probe failure: empty set
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if name := strings.TrimSpace(line); name != "" {
sessions[name] = struct{}{}
}
}
return sessions
}
// tmuxSessionAlive reports whether any ao-rpi-<runID>-p{1,2,3} session is
// present in the given session set. Split from checkTmuxSessionAlive so the
// matching logic is testable without a tmux server.
func tmuxSessionAlive(runID string, sessions map[string]struct{}) bool {
if runID == "" {
return false
}
for i := 1; i <= 3; i++ {
sessionName := fmt.Sprintf("ao-rpi-%s-p%d", runID, i)
ctx, cancel := context.WithTimeout(context.Background(), tmuxProbeTimeout)
cmd := exec.CommandContext(ctx, tmuxCommand, "has-session", "-t", sessionName)
err := cmd.Run()
cancel()
if err == nil {
if _, ok := sessions[fmt.Sprintf("ao-rpi-%s-p%d", runID, i)]; ok {
return true
}
}
return false
}
// checkTmuxSessionAlive checks if any tmux session matching ao-rpi-<runID>-* exists.
func checkTmuxSessionAlive(runID string) bool {
if runID == "" {
return false
}
return tmuxSessionAlive(runID, liveTmuxSessions())
}
// locateRunMetadata finds the phasedState for a given run ID.
func locateRunMetadata(cwd, runID string) (*phasedState, string, error) {
roots := collectSearchRoots(cwd)
+37 -1
View File
@@ -250,6 +250,36 @@ func TestRPIStatusDetermineRunStatus(t *testing.T) {
}
}
func TestTmuxSessionAlive(t *testing.T) {
// PERF-C1 (soc-d7v5): matching logic is split from the tmux probe so it
// can be exercised against an in-memory session set.
sessions := map[string]struct{}{
"ao-rpi-run-alpha-p2": {},
"ao-rpi-run-beta-p1": {},
"unrelated-session": {},
}
tests := []struct {
name string
runID string
want bool
}{
{"match on phase 2", "run-alpha", true},
{"match on phase 1", "run-beta", true},
{"no session for run", "run-gamma", false},
{"empty run id", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tmuxSessionAlive(tt.runID, sessions); got != tt.want {
t.Errorf("tmuxSessionAlive(%q) = %v, want %v", tt.runID, got, tt.want)
}
})
}
if tmuxSessionAlive("run-alpha", map[string]struct{}{}) {
t.Error("tmuxSessionAlive should be false against an empty session set")
}
}
func TestRPIStatusSiblingDiscovery(t *testing.T) {
// Create a parent directory with cwd and a sibling worktree
parent := t.TempDir()
@@ -997,6 +1027,8 @@ func TestRPIStatusRegistryDiscovery_EmptyDir(t *testing.T) {
// block indefinitely when tmux is unavailable or slow. The test measures elapsed
// time and asserts it stays well below 20 seconds (3 phases x 2s timeout = 6s max).
func TestCheckTmuxSessionAlive_Timeout(t *testing.T) {
resetTmuxSessionCache()
t.Cleanup(resetTmuxSessionCache)
start := time.Now()
alive := checkTmuxSessionAlive("nonexistent-run-id-xyz")
elapsed := time.Since(start)
@@ -1028,7 +1060,9 @@ func TestCheckTmuxSessionAlive_EmptyRunID(t *testing.T) {
func TestCheckTmuxSessionAlive_UsesConfiguredTmuxCommand(t *testing.T) {
tmpBin := t.TempDir()
customTmux := filepath.Join(tmpBin, "tmux-custom")
script := "#!/usr/bin/env bash\nexit 0\n"
// PERF-C1 (soc-d7v5): the probe is now a single `tmux ls`, so the stub
// must emit a matching session name rather than just exiting 0.
script := "#!/usr/bin/env bash\necho ao-rpi-run-custom-tmux-p2\n"
if err := os.WriteFile(customTmux, []byte(script), 0755); err != nil {
t.Fatalf("write custom tmux script: %v", err)
}
@@ -1038,6 +1072,8 @@ func TestCheckTmuxSessionAlive_UsesConfiguredTmuxCommand(t *testing.T) {
t.Setenv("AGENTOPS_RPI_TMUX_COMMAND", "tmux-custom")
t.Setenv("PATH", tmpBin+":"+os.Getenv("PATH"))
resetTmuxSessionCache()
t.Cleanup(resetTmuxSessionCache)
if !checkTmuxSessionAlive("run-custom-tmux") {
t.Fatal("expected run to be considered alive when configured tmux command succeeds")
}
+1
View File
@@ -22,6 +22,7 @@ import (
var sessionCmd = &cobra.Command{
Use: "session",
Short: "Session lifecycle operations",
Args: cobra.NoArgs,
Long: `Session lifecycle operations.
Commands:
+37 -13
View File
@@ -2,6 +2,7 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
@@ -10,19 +11,20 @@ import (
"time"
"github.com/BurntSushi/toml"
"github.com/boshu2/agentops/cli/internal/shellutil"
"github.com/spf13/cobra"
)
type SessionTemplate struct {
SchemaVersion int `toml:"schema_version" json:"schema_version"`
Role string `toml:"role" json:"role"`
Description string `toml:"description" json:"description"`
Identity SessionIdentity `toml:"identity" json:"identity"`
Workspace SessionWorkspace `toml:"workspace" json:"workspace"`
Init SessionInit `toml:"init" json:"init"`
Tmux SessionTmux `toml:"tmux" json:"tmux"`
Heartbeat SessionHeartbeat `toml:"heartbeat" json:"heartbeat"`
OnExit SessionOnExit `toml:"on_exit" json:"on_exit"`
SchemaVersion int `toml:"schema_version" json:"schema_version"`
Role string `toml:"role" json:"role"`
Description string `toml:"description" json:"description"`
Identity SessionIdentity `toml:"identity" json:"identity"`
Workspace SessionWorkspace `toml:"workspace" json:"workspace"`
Init SessionInit `toml:"init" json:"init"`
Tmux SessionTmux `toml:"tmux" json:"tmux"`
Heartbeat SessionHeartbeat `toml:"heartbeat" json:"heartbeat"`
OnExit SessionOnExit `toml:"on_exit" json:"on_exit"`
Invariants map[string]string `toml:"invariants" json:"invariants,omitempty"`
References map[string]string `toml:"references" json:"references,omitempty"`
}
@@ -118,12 +120,29 @@ func loadSessionTemplate(path string) (*SessionTemplate, error) {
return &tmpl, nil
}
// sanitizeHostname strips any character outside the RFC-1123 hostname charset
// so a hostile or unusual hostname cannot inject shell metacharacters when it
// is expanded into init-step commands or tmux session names.
func sanitizeHostname(h string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
return r
case r == '.', r == '-', r == '_':
return r
default:
return -1
}
}, h)
}
func buildTemplateVars(tmpl *SessionTemplate, dateOverride string) map[string]string {
dateVal := time.Now().UTC().Format("2006-01-02")
if dateOverride != "" {
dateVal = dateOverride
}
hostname, _ := os.Hostname()
hostname = sanitizeHostname(hostname)
home, _ := os.UserHomeDir()
sessionName := tmpl.Identity.SessionNameTemplate
@@ -147,7 +166,7 @@ func expandVars(s string, vars map[string]string) string {
return result
}
func runInitSteps(steps []SessionInitStep, vars map[string]string, cwd string, dryRun bool) error {
func runInitSteps(steps []SessionInitStep, vars map[string]string, cwd, beadsActor string, dryRun bool) error {
for i, step := range steps {
expanded := expandVars(step.Cmd, vars)
if dryRun {
@@ -162,9 +181,13 @@ func runInitSteps(steps []SessionInitStep, vars map[string]string, cwd string, d
continue
}
fmt.Printf(" [%d/%d] %s ... ", i+1, len(steps), step.Name)
cmd := exec.Command("bash", "-c", expanded)
cmd := shellutil.SanitizedBashCommand(context.Background(), expanded)
cmd.Dir = cwd
cmd.Env = append(os.Environ(), "BEADS_ACTOR="+expandVars(steps[0].Cmd, vars))
if beadsActor != "" {
// SanitizedBashCommand already populates cmd.Env with the sanitized
// parent environment; append rather than overwrite it.
cmd.Env = append(cmd.Env, "BEADS_ACTOR="+beadsActor)
}
out, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("FAILED\n")
@@ -267,7 +290,8 @@ func runSessionSpawn(cmd *cobra.Command, args []string) error {
fmt.Printf("\nRunning %d init steps:\n", len(tmpl.Init.Steps))
}
if err := runInitSteps(tmpl.Init.Steps, vars, cwd, spawnDryRun); err != nil {
beadsActor := expandVars(tmpl.Identity.BeadsActorTemplate, vars)
if err := runInitSteps(tmpl.Init.Steps, vars, cwd, beadsActor, spawnDryRun); err != nil {
return err
}
+45 -3
View File
@@ -172,7 +172,7 @@ func TestRunInitStepsDryRun(t *testing.T) {
}
vars := map[string]string{"{{date}}": "2026-05-05"}
err := runInitSteps(steps, vars, t.TempDir(), true)
err := runInitSteps(steps, vars, t.TempDir(), "", true)
if err != nil {
t.Fatalf("dry-run init steps: %v", err)
}
@@ -186,7 +186,7 @@ func TestRunInitStepsExecutes(t *testing.T) {
}
vars := map[string]string{}
if err := runInitSteps(steps, vars, dir, false); err != nil {
if err := runInitSteps(steps, vars, dir, "", false); err != nil {
t.Fatalf("init steps: %v", err)
}
data, err := os.ReadFile(marker)
@@ -202,7 +202,7 @@ func TestRunInitStepsFailsOnError(t *testing.T) {
steps := []SessionInitStep{
{Name: "will-fail", Cmd: "exit 1"},
}
err := runInitSteps(steps, map[string]string{}, t.TempDir(), false)
err := runInitSteps(steps, map[string]string{}, t.TempDir(), "", false)
if err == nil {
t.Fatal("expected error from failing init step")
}
@@ -211,6 +211,48 @@ func TestRunInitStepsFailsOnError(t *testing.T) {
}
}
func TestRunInitStepsSetsBeadsActor(t *testing.T) {
dir := t.TempDir()
actorFile := filepath.Join(dir, "actor.txt")
steps := []SessionInitStep{
{Name: "first-step", Cmd: "true"},
{Name: "record-actor", Cmd: `printf '%s' "$BEADS_ACTOR" > ` + actorFile},
}
if err := runInitSteps(steps, map[string]string{}, dir, "claude-validator", false); err != nil {
t.Fatalf("init steps: %v", err)
}
data, err := os.ReadFile(actorFile)
if err != nil {
t.Fatalf("read actor file: %v", err)
}
if got := string(data); got != "claude-validator" {
t.Fatalf("BEADS_ACTOR = %q, want %q (must be the template actor, not a step command)", got, "claude-validator")
}
}
func TestSanitizeHostname(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"plain", "bushido-box", "bushido-box"},
{"dotted fqdn", "host.example.local", "host.example.local"},
{"underscore kept", "my_host", "my_host"},
{"strips shell metachars", "host; rm -rf ~", "hostrm-rf"},
{"strips command substitution", "h$(whoami)", "hwhoami"},
{"strips quotes and spaces", `a b'c"`, "abc"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := sanitizeHostname(tc.in); got != tc.want {
t.Fatalf("sanitizeHostname(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestCreateTmuxSessionDryRun(t *testing.T) {
cfg := SessionTmux{
SessionName: "test-{{date}}",
+3 -3
View File
@@ -227,7 +227,7 @@ func resetCommandState(t *testing.T) {
origSeedForce := seedForce
origNoBeads := noBeads
origMinimal := minimal
origGoalsJSON := goalsJSON
origGoalsJSON := output
origMemorySyncQuiet := memorySyncQuiet
origMemorySyncMaxEntries := memorySyncMaxEntries
origMemorySyncOutput := memorySyncOutput
@@ -308,7 +308,7 @@ func resetCommandState(t *testing.T) {
seedForce = origSeedForce
noBeads = origNoBeads
minimal = origMinimal
goalsJSON = origGoalsJSON
output = origGoalsJSON
memorySyncQuiet = origMemorySyncQuiet
memorySyncMaxEntries = origMemorySyncMaxEntries
memorySyncOutput = origMemorySyncOutput
@@ -390,7 +390,7 @@ func resetCommandState(t *testing.T) {
seedForce = false
noBeads = false
minimal = false
goalsJSON = false
output = "table"
memorySyncQuiet = false
memorySyncMaxEntries = 10
memorySyncOutput = ""
+15
View File
@@ -40,6 +40,21 @@ func TestVersion_RegisteredOnRoot(t *testing.T) {
}
}
func TestVersion_RootVersionFlagWired(t *testing.T) {
// CLI-C2 (soc-nx1o): rootCmd.Version must be set so `ao --version` works,
// not only the `ao version` subcommand.
if rootCmd.Version != version {
t.Errorf("rootCmd.Version = %q, want %q", rootCmd.Version, version)
}
out, err := executeCommand("--version")
if err != nil {
t.Fatalf("ao --version returned error: %v", err)
}
if !strings.Contains(out, version) {
t.Errorf("ao --version output should contain %q, got: %s", version, out)
}
}
func TestVersion_ExecuteOutputContainsVersionString(t *testing.T) {
out, err := executeCommand("version")
if err != nil {
+3 -3
View File
@@ -11,6 +11,7 @@
--json Output as JSON (shorthand for -o json)
-o, --output string Output format (json, table, yaml) (default "table")
-v, --verbose Enable verbose output
--version version for ao
---
@@ -1627,13 +1628,13 @@ ao eval task run <task-id> [flags]
```
--allow-weak-labels Allow runs against confidence=weak ground-truth rows (gate #7)
--cross-spec Allow ModelSpec drift (Day-4 gate #4)
--cross-spec Allow ModelSpec drift (gate #4)
--dry-run Run gates and exit without writing a Run manifest
--ground-truth string Ground-truth row id (head of supersession chain)
--harness string Harness id (recorded into manifest)
--harness-dir string Path to harness source dir for snapshot + gate #8
-h, --help help for run
--inspect-command string Recorded inspect_command (Day-2 placeholder; Day-3 wires real launch)
--inspect-command string Inspect command recorded into the Run manifest (not executed yet)
--inspect-version string Inspect AI version stamped into manifest (default "0.3.216")
--model-spec string ModelSpec id (already captured via ao eval models capture)
--n-samples int Override Suite.n_samples
@@ -1811,7 +1812,6 @@ ao goals [command]
```
--file string Path to goals file (auto-detects GOALS.md then GOALS.yaml)
-h, --help help for goals
--json Output as JSON
--timeout int Check timeout in seconds (default 240)
```
+7
View File
@@ -70,6 +70,13 @@ func (e *DreamExecutor) RunJob(ctx context.Context, claim QueueLease) (JobExecut
return JobExecutionResult{}, err
}
artifacts := dreamRunArtifacts(spec.OutputDir)
// soc-ly33 (SEC-C2): output_dir is operator-supplied via the job payload.
// validateOutputDir already rejected ".." traversal; this rejects an
// absolute output_dir that resolves outside the daemon working tree before
// MkdirAll touches the filesystem.
if err := outputDirContained(e.cwd, spec.OutputDir); err != nil {
return JobExecutionResult{Artifacts: artifacts}, err
}
// soc-58q5.13 (W-C-18): refuse to traverse a symlink at the planned
// output_dir. The path is operator-supplied via the job payload, so an
// attacker who can pre-create a symlink at OutputDir could redirect
+46 -6
View File
@@ -2,6 +2,7 @@ package daemon
import (
"fmt"
"path/filepath"
"strings"
"time"
)
@@ -111,8 +112,8 @@ func (spec DreamRunJobSpec) Validate() error {
if strings.TrimSpace(spec.DreamRunID) == "" {
return fmt.Errorf("dream_run_id is required")
}
if strings.TrimSpace(spec.OutputDir) == "" {
return fmt.Errorf("output_dir is required")
if err := validateOutputDir("output_dir", spec.OutputDir); err != nil {
return err
}
if spec.MaxIterations < 0 {
return fmt.Errorf("max_iterations must be >= 0")
@@ -133,8 +134,8 @@ func (spec DreamStageJobSpec) Validate() error {
if strings.TrimSpace(spec.DreamRunID) == "" {
return fmt.Errorf("dream_run_id is required")
}
if strings.TrimSpace(spec.OutputDir) == "" {
return fmt.Errorf("output_dir is required")
if err := validateOutputDir("output_dir", spec.OutputDir); err != nil {
return err
}
if spec.Iteration < 0 {
return fmt.Errorf("iteration must be >= 0")
@@ -152,8 +153,8 @@ func (manifest DreamStageManifest) Validate() error {
if strings.TrimSpace(manifest.DreamRunID) == "" {
return fmt.Errorf("dream_run_id is required")
}
if strings.TrimSpace(manifest.OutputDir) == "" {
return fmt.Errorf("output_dir is required")
if err := validateOutputDir("output_dir", manifest.OutputDir); err != nil {
return err
}
if err := ValidateDreamMode(manifest.Mode); err != nil {
return err
@@ -246,6 +247,45 @@ func ValidateDreamMode(mode DreamMode) error {
}
}
// validateOutputDir rejects an operator-supplied output_dir that is empty or
// that escapes its parent via ".." traversal. The job payload is
// operator-controlled, so an unvalidated output_dir lets a caller redirect
// dream summary/log writes outside the intended tree (soc-ly33, SEC-C2).
// filepath.Clean collapses interior ".." segments, so any escape survives as
// a leading "..". Absolute-path containment against the daemon working
// directory is enforced separately in the executor (outputDirContained),
// where the daemon cwd is known; the symlink pre-check in dream_executor.go
// covers the complementary symlink-redirect vector.
func validateOutputDir(field, value string) error {
value = strings.TrimSpace(value)
if value == "" {
return fmt.Errorf("%s is required", field)
}
cleaned := filepath.Clean(value)
if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return fmt.Errorf("%s %q escapes its parent via .. traversal", field, value)
}
return nil
}
// outputDirContained verifies that an operator-supplied output_dir resolves to
// a path inside the daemon working directory cwd. It is the absolute-path
// counterpart to validateOutputDir's ".." check (soc-ly33, SEC-C2).
func outputDirContained(cwd, outputDir string) error {
abs := filepath.Clean(outputDir)
if !filepath.IsAbs(abs) {
abs = filepath.Join(cwd, abs)
}
rel, err := filepath.Rel(filepath.Clean(cwd), abs)
if err != nil {
return fmt.Errorf("output_dir %q is not contained within the daemon working directory", outputDir)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("output_dir %q escapes the daemon working directory", outputDir)
}
return nil
}
func validateOptionalDuration(field, value string) error {
value = strings.TrimSpace(value)
if value == "" {
+55
View File
@@ -70,6 +70,61 @@ func TestDreamJobSpecsValidateAndRoundTrip(t *testing.T) {
}
}
func TestDreamSpecsRejectOutputDirPathTraversal(t *testing.T) {
escapes := []string{
"../escape",
"../../etc/agentops",
".agents/../../escape",
"",
" ",
}
for _, dir := range escapes {
run := NewDreamRunJobSpec("dream-20260516", dir)
if err := run.Validate(); err == nil {
t.Errorf("DreamRunJobSpec.Validate accepted escaping output_dir %q", dir)
}
stage := NewDreamStageJobSpec("dream-20260516", dir, DreamStageReduce)
if err := stage.Validate(); err == nil {
t.Errorf("DreamStageJobSpec.Validate accepted escaping output_dir %q", dir)
}
manifest := DefaultDreamStageManifest("dream-20260516", dir)
if err := manifest.Validate(); err == nil {
t.Errorf("DreamStageManifest.Validate accepted escaping output_dir %q", dir)
}
}
// A contained relative path stays valid.
ok := NewDreamRunJobSpec("dream-20260516", ".agents/overnight/dream-20260516")
if err := ok.Validate(); err != nil {
t.Fatalf("DreamRunJobSpec.Validate rejected a contained output_dir: %v", err)
}
}
func TestOutputDirContained(t *testing.T) {
cwd := "/srv/agentops"
contained := []string{
"/srv/agentops/.agents/overnight/run",
".agents/overnight/run",
"/srv/agentops",
}
for _, dir := range contained {
if err := outputDirContained(cwd, dir); err != nil {
t.Errorf("outputDirContained(%q, %q) rejected a contained path: %v", cwd, dir, err)
}
}
escaping := []string{
"/etc/agentops-dream",
"/srv/agentops-evil",
"../escape",
"/tmp/elsewhere",
}
for _, dir := range escaping {
if err := outputDirContained(cwd, dir); err == nil {
t.Errorf("outputDirContained(%q, %q) accepted an escaping path", cwd, dir)
}
}
}
func TestDreamStageManifestRejectsInvalidStageModeAndOrder(t *testing.T) {
manifest := DefaultDreamStageManifest("dream-20260428", ".agents/overnight/dream-20260428")
manifest.Mode = "autonomous"
+4
View File
@@ -12,6 +12,10 @@
"payload"
],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"agent_update_version": {
"type": "integer",
"const": 1
+4
View File
@@ -7,6 +7,10 @@
"additionalProperties": false,
"required": ["name", "plugins"],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"$schema": {
"type": "string"
},
@@ -7,6 +7,10 @@
"additionalProperties": false,
"required": ["name"],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"$schema": {
"type": "string"
},
+4
View File
@@ -5,6 +5,10 @@
"practices": ["dora-metrics", "wiki-knowledge-surface"],
"type": "object",
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"id": {
"type": "string",
"description": "Finding identifier for cross-skill correlation (e.g., f-2026-03-12-001). Maps to finding-artifact.id."
+4
View File
@@ -7,6 +7,10 @@
"additionalProperties": false,
"required": ["$schema", "hooks"],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"$schema": {
"type": "string"
},
+4
View File
@@ -7,6 +7,10 @@
"additionalProperties": false,
"required": ["name", "version"],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"$schema": {
"type": "string"
},
+4
View File
@@ -8,6 +8,10 @@
"additionalProperties": false,
"required": ["criteria", "pass_threshold"],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"version": {
"type": "integer",
"const": 1,
+4
View File
@@ -6,6 +6,10 @@
"practices": ["property-based-testing", "llm-eval-harness"],
"type": "object",
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"id": {
"type": "string",
"pattern": "^(s-\\d{4}-\\d{2}-\\d{2}-\\d{3}|auto-.+)$",
+4
View File
@@ -7,6 +7,10 @@
"additionalProperties": false,
"required": ["schedules"],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"schedules": {
"type": "array",
"items": {
@@ -5,6 +5,10 @@
"type": "object",
"required": ["timestamp", "signal_type", "detail", "session_id"],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"timestamp": { "type": "string", "format": "date-time" },
"signal_type": { "type": "string", "enum": ["repeated_prompt", "correction", "read_only_spiral"] },
"detail": { "type": "string" },
+4
View File
@@ -6,6 +6,10 @@
"type": "object",
"required": ["name", "description", "skill_api_version"],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"name": { "type": "string" },
"description": { "type": "string" },
"skill_api_version": { "type": "integer", "const": 1 },
+4
View File
@@ -4,6 +4,10 @@
"type": "object",
"required": ["name", "description"],
"properties": {
"schema_version": {
"type": "integer",
"const": 2
},
"name": {"type": "string"},
"description": {"type": "string"},
"practices": {"type": "array", "items": {"type": "string"}},
+4
View File
@@ -8,6 +8,10 @@
"additionalProperties": true,
"required": ["status"],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"task": {
"type": "string",
"description": "Task identifier — usually an issue ID, epic-task slug, or short slug. Either 'task' or 'task_id' is required."
+4
View File
@@ -8,6 +8,10 @@
"additionalProperties": true,
"required": ["ts", "event", "worker_id"],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"ts": {
"type": "string",
"format": "date-time",
+4
View File
@@ -8,6 +8,10 @@
"additionalProperties": false,
"required": ["model"],
"properties": {
"schema_version": {
"type": "integer",
"const": 1
},
"version": {
"type": "integer",
"const": 1,