mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
fix(cli): stop advertising removed commands in fresh-install output; add cobra-tree guard (#907)
## Summary
A fresh-install UX audit found `ao` emitting commands that do not exist
in the default binary. Verified live by building the CLI and running `ao
quick-start` in a fresh temp git repo with a sandboxed HOME.
- **repo_readiness**: `bd init` → tracker-agnostic `br init --prefix
<prefix> (or bd init --prefix <prefix>)` (product supports both; br
leads for guidance consistency). Dropped the hooks/program/schedule
readiness items — they advertised archived machinery (hookless 3.0,
ADR-0009/0012: `ao init --hooks`, `ao autodev init`, `ao init
--with-schedule`). Replaced literal unexpanded `$product`/`$readme`
actions with real next steps. Tracking presence check now also
recognizes a br `_beads` ledger.
- **CLAUDE.md seed section**: rewrote the "Knowledge Flywheel" block
("session hooks extract learnings", "knowledge compounds automatically",
`ao flywheel status`) to the hookless operating-loop story. Old markers
kept as legacy so re-seeding never duplicates the section.
- **quick-start**: `ao beads ready` (never existed) → `ao beads exec
ready`.
- **root help**: advertised `ao lookup`, which is pruned from the
default spine → `ao verify`.
- **doctor**: br install hint pointed end users at an AGENTS.md their
repos do not have → beads_rust URL + `ao beads dir`.
- **bridge**: deleted dead `FactoryRecommendedCommands` (`ao factory
start --goal`, `ao orchestrate status`).
## Guard
`cli/cmd/ao/advertised_commands{,_test}.go`: extracts every `ao ...`
string from user-facing output (readiness actions, quick-start golden
paths, seeded CLAUDE.md, starter pack, root/quick-start help) and
resolves each against the cobra tree **pruned to the production spine**
— the existing "Stale References" doctor check missed these because the
test binary keeps archived commands registered. Stricter than
`cobra.Find`: group commands reject positional args masquerading as
subcommands (`ao beads ready`), and unknown flags fail (`ao init
--with-schedule`).
## Verification
- `go build` / `go vet` / `golangci-lint` clean; `go test` green (7802
in the four touched packages; full cmd/ao suite green)
- Sandboxed-HOME UAT: every command printed by `ao quick-start` answers
`--help` without "unknown command"
- Pre-push cockpit gate: 31/31 fast/head checks pass (after regen of the
CLI command-surface matrix)
Co-authored-by: boshu <241868352+boshu2@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// practices: [pragmatic-programmer]
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// advertisedAoInvocationRE extracts `ao <subcommand>...` invocations from
|
||||
// user-facing text (help output, seeded CLAUDE.md sections, readiness
|
||||
// actions). A token run ends at the first thing that is not a lowercase
|
||||
// command word or a --flag, so placeholders like <topic> and prose punctuation
|
||||
// never leak into the parsed invocation.
|
||||
var advertisedAoInvocationRE = regexp.MustCompile(
|
||||
"(?:^|[\\s`\"'($])ao ([a-z][a-z0-9-]*(?: (?:[a-z][a-z0-9-]*|--[a-z][a-z0-9-]*(?:=\\S+)?))*)")
|
||||
|
||||
// extractAdvertisedAoInvocations returns every `ao ...` command string
|
||||
// advertised in text, without the leading "ao ".
|
||||
func extractAdvertisedAoInvocations(text string) []string {
|
||||
matches := advertisedAoInvocationRE.FindAllStringSubmatch(text, -1)
|
||||
out := make([]string, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
out = append(out, m[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateAdvertisedAoInvocation checks that an advertised invocation (the
|
||||
// part after "ao ") resolves against the live cobra command tree: every token
|
||||
// must reach a registered command, group commands (no Run) must be followed
|
||||
// by a real subcommand rather than a positional arg, and every --flag must be
|
||||
// defined on the resolved command. This is the guard that keeps removed
|
||||
// commands (ao factory, ao orchestrate, ao autodev, ...) from being
|
||||
// advertised in fresh-install output again.
|
||||
func validateAdvertisedAoInvocation(root *cobra.Command, invocation string) error {
|
||||
tokens := strings.Fields(invocation)
|
||||
cmd := root
|
||||
i := 0
|
||||
for ; i < len(tokens); i++ {
|
||||
tok := tokens[i]
|
||||
if strings.HasPrefix(tok, "-") {
|
||||
break
|
||||
}
|
||||
next := findAdvertisedSubcommand(cmd, tok)
|
||||
if next == nil {
|
||||
if isCommandGroup(cmd) {
|
||||
return fmt.Errorf("%q is not a subcommand of %q", tok, cmd.CommandPath())
|
||||
}
|
||||
// Runnable command: the remaining word tokens are positional args.
|
||||
break
|
||||
}
|
||||
cmd = next
|
||||
}
|
||||
for ; i < len(tokens); i++ {
|
||||
tok := tokens[i]
|
||||
if !strings.HasPrefix(tok, "--") {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(strings.TrimPrefix(tok, "--"), "=")
|
||||
if cmd.Flags().Lookup(name) == nil && cmd.InheritedFlags().Lookup(name) == nil {
|
||||
return fmt.Errorf("flag --%s is not defined on %q", name, cmd.CommandPath())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findAdvertisedSubcommand(cmd *cobra.Command, name string) *cobra.Command {
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == name || c.HasAlias(name) {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isCommandGroup reports whether cmd only routes to subcommands (it has no
|
||||
// Run of its own), so a following token must be a real subcommand.
|
||||
func isCommandGroup(cmd *cobra.Command) bool {
|
||||
return cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boshu2/agentops/cli/internal/lifecycle"
|
||||
)
|
||||
|
||||
// withProductionSpine narrows the test binary's fully-registered command tree
|
||||
// to the ADR-0012 default spine, so advertised-command validation matches what
|
||||
// a fresh-install `ao` binary actually serves (the test binary deliberately
|
||||
// keeps archived registrations; see zzz_default_spine.go).
|
||||
func withProductionSpine(t *testing.T) {
|
||||
t.Helper()
|
||||
removed := pruneToDefaultSpine(rootCmd)
|
||||
t.Cleanup(func() { restorePrunedCommands(rootCmd, removed) })
|
||||
}
|
||||
|
||||
// advertisedProseStopwords are English words that can follow "ao" in help
|
||||
// prose ("ao is the CLI for ...") without naming a subcommand. Extraction
|
||||
// matches them; the sweep skips them instead of failing. Keep this list to
|
||||
// function words only — a removed command name must never be added here.
|
||||
var advertisedProseStopwords = map[string]bool{
|
||||
"a": true, "an": true, "and": true, "are": true, "as": true, "by": true,
|
||||
"can": true, "command": true, "commands": true, "does": true, "for": true,
|
||||
"if": true, "in": true, "is": true, "of": true, "on": true, "or": true,
|
||||
"that": true, "the": true, "this": true, "to": true, "was": true, "with": true,
|
||||
}
|
||||
|
||||
// TestExtractAdvertisedAoInvocations pins the extraction contract: command
|
||||
// runs stop at placeholders, punctuation, and quotes, and prose without a
|
||||
// following command word never matches.
|
||||
func TestExtractAdvertisedAoInvocations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want []string
|
||||
}{
|
||||
{"backtick fenced", "run `ao session bootstrap` first", []string{"session bootstrap"}},
|
||||
{"placeholder stops the run", "ao verify <change-slug> # review", []string{"verify"}},
|
||||
{"flags included", "ao lookup --query \"<topic>\"", []string{"lookup --query"}},
|
||||
{"flag with value and trailing word", "ao gate check --fast --scope head", []string{"gate check --fast --scope head"}},
|
||||
{"quoted single command", "the same check as 'ao doctor' runs", []string{"doctor"}},
|
||||
{"no match inside word", "ciao status", nil},
|
||||
{"no match without subcommand", "the ao CLI", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractAdvertisedAoInvocations(tt.text)
|
||||
if len(got) == 0 && len(tt.want) == 0 {
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("extractAdvertisedAoInvocations(%q) = %v, want %v", tt.text, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAdvertisedAoInvocation pins the resolution contract against the
|
||||
// live command tree: removed commands fail, group commands reject positional
|
||||
// args masquerading as subcommands, and unknown flags fail.
|
||||
func TestValidateAdvertisedAoInvocation(t *testing.T) {
|
||||
withProductionSpine(t)
|
||||
valid := []string{
|
||||
"status",
|
||||
"session bootstrap",
|
||||
"quick-start --no-beads",
|
||||
"beads exec ready", // exec is runnable; "ready" is a forwarded arg
|
||||
"verify my-first-change",
|
||||
}
|
||||
for _, inv := range valid {
|
||||
if err := validateAdvertisedAoInvocation(rootCmd, inv); err != nil {
|
||||
t.Errorf("ao %s should resolve: %v", inv, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"factory start --goal", // removed (ADR-0012)
|
||||
"orchestrate status", // removed (ADR-0012)
|
||||
"autodev init", // removed (ADR-0012)
|
||||
"flywheel status", // archived behind the flywheel build tag
|
||||
"beads ready", // beads is a group; ready is not a subcommand
|
||||
"init --with-schedule", // flag never existed on ao init
|
||||
"quick-start --no-such", // unknown flag
|
||||
}
|
||||
for _, inv := range invalid {
|
||||
if err := validateAdvertisedAoInvocation(rootCmd, inv); err == nil {
|
||||
t.Errorf("ao %s should NOT resolve in the default build", inv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUserFacingOutputAdvertisesOnlyLiveCommands is the fresh-install UX
|
||||
// guard (the "Stale References" doctor check missed these): every `ao ...`
|
||||
// string emitted by repo readiness, the quick-start golden paths, the seeded
|
||||
// CLAUDE.md sections, and the starter knowledge pack must parse against the
|
||||
// actual cobra command tree, so a removed command can never be advertised to
|
||||
// a new user again.
|
||||
func TestUserFacingOutputAdvertisesOnlyLiveCommands(t *testing.T) {
|
||||
withProductionSpine(t)
|
||||
sources := map[string]string{
|
||||
"root help": rootCmd.Long,
|
||||
"quick-start help": quickstartCmd.Long,
|
||||
"claude-md seed": lifecycle.ClaudeMDSeedSection,
|
||||
"first-verdict step": firstVerdictCommand,
|
||||
}
|
||||
|
||||
// Repo readiness actions (what quick-start / ao init print as "next: ...").
|
||||
report, err := lifecycle.InspectRepoReadiness(t.TempDir(), lifecycle.ReadinessOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectRepoReadiness: %v", err)
|
||||
}
|
||||
for _, item := range report.Items {
|
||||
sources["readiness action for "+item.Name] = item.Action
|
||||
}
|
||||
|
||||
// Quick-start LIVE PATH journey, both tracked and untracked.
|
||||
for _, tracked := range []bool{false, true} {
|
||||
for _, step := range quickstartJourney(tracked) {
|
||||
for _, command := range step.Commands {
|
||||
sources["journey step "+step.Title] = command
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The terminal first-verdict output, both reviewer-reachable and
|
||||
// install-needed variants.
|
||||
for name, info := range map[string]*firstVerdictInfo{
|
||||
"first-verdict reachable": {LedgerReady: true, ReviewerLive: []string{"codex"}, NextCommand: firstVerdictCommand},
|
||||
"first-verdict install": {LedgerReady: true, ReviewerInstall: []string{"codex: npm i -g @openai/codex"}},
|
||||
} {
|
||||
out, _ := captureStdout(t, func() error {
|
||||
printFirstVerdictStep(info)
|
||||
return nil
|
||||
})
|
||||
sources[name] = out
|
||||
}
|
||||
|
||||
// The CLAUDE.md quick-start writes into user repos, and the starter
|
||||
// knowledge pack files.
|
||||
seedDir := t.TempDir()
|
||||
if err := createProjectClaudeMd(seedDir); err != nil {
|
||||
t.Fatalf("createProjectClaudeMd: %v", err)
|
||||
}
|
||||
claudeMD, err := os.ReadFile(filepath.Join(seedDir, "CLAUDE.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read seeded CLAUDE.md: %v", err)
|
||||
}
|
||||
sources["seeded CLAUDE.md"] = string(claudeMD)
|
||||
|
||||
if _, err := captureStdout(t, func() error { return createStarterPack(seedDir) }); err != nil {
|
||||
t.Fatalf("createStarterPack: %v", err)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
".agents/patterns/context-boundaries.md",
|
||||
".agents/patterns/pre-mortem-first.md",
|
||||
".agents/learnings/session-hygiene.md",
|
||||
} {
|
||||
data, err := os.ReadFile(filepath.Join(seedDir, rel))
|
||||
if err != nil {
|
||||
t.Fatalf("read starter pack %s: %v", rel, err)
|
||||
}
|
||||
sources["starter pack "+rel] = string(data)
|
||||
}
|
||||
|
||||
total := 0
|
||||
for name, text := range sources {
|
||||
for _, invocation := range extractAdvertisedAoInvocations(text) {
|
||||
if advertisedProseStopwords[strings.Fields(invocation)[0]] {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if err := validateAdvertisedAoInvocation(rootCmd, invocation); err != nil {
|
||||
t.Errorf("%s advertises `ao %s`, which does not resolve in the default build: %v", name, invocation, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The sweep must actually see commands — an empty extraction means the
|
||||
// regex or a source regressed, not that everything is clean.
|
||||
if total < 10 {
|
||||
t.Fatalf("expected the sweep to find at least 10 advertised ao invocations, found %d", total)
|
||||
}
|
||||
}
|
||||
@@ -2138,7 +2138,7 @@ func TestCobraQuickstartHelpers(t *testing.T) {
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = io.Copy(&buf, r)
|
||||
if !strings.Contains(buf.String(), "ao beads ready") {
|
||||
if !strings.Contains(buf.String(), "ao beads exec ready") {
|
||||
t.Error("expected selected-tracker ready route in next steps with beads")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -283,8 +283,6 @@ func printReadinessSummary(report *lifecycle.ReadinessReport) {
|
||||
lifecycle.LayerInstructions,
|
||||
lifecycle.LayerTracking,
|
||||
lifecycle.LayerProduct,
|
||||
lifecycle.LayerProgram,
|
||||
lifecycle.LayerSchedule,
|
||||
} {
|
||||
present, total, action := readinessLayerStatus(report, layer)
|
||||
status := "ready"
|
||||
@@ -533,7 +531,7 @@ func createProjectClaudeMd(cwd string) error {
|
||||
`+"```bash"+`
|
||||
ao quick-start # Repair or inspect the repo seed
|
||||
ao session bootstrap # Orient the agent in this repository
|
||||
ao beads ready # See unblocked issues when tracking is enabled
|
||||
ao beads exec ready # See unblocked issues when tracking is enabled
|
||||
`+"```"+`
|
||||
|
||||
## Session Protocol
|
||||
@@ -541,7 +539,7 @@ ao beads ready # See unblocked issues when tracking is enabled
|
||||
`+"```bash"+`
|
||||
# Start
|
||||
ao status # Check AgentOps state
|
||||
ao beads ready # Find available work through the selected tracker
|
||||
ao beads exec ready # Find available work through the selected tracker
|
||||
|
||||
# End
|
||||
git add .
|
||||
@@ -575,7 +573,7 @@ func quickstartJourney(hasBeads bool) []quickstartJourneyStep {
|
||||
if hasBeads {
|
||||
steps = append(steps, quickstartJourneyStep{
|
||||
Title: "Select tracked work",
|
||||
Commands: []string{"ao beads tracker", "ao beads ready"},
|
||||
Commands: []string{"ao beads tracker", "ao beads exec ready"},
|
||||
})
|
||||
} else {
|
||||
steps = append(steps, quickstartJourneyStep{
|
||||
|
||||
@@ -103,8 +103,8 @@ func TestQuickstart_CreateTasksFile_ValidJSON(t *testing.T) {
|
||||
|
||||
func TestQuickstart_ShowNextSteps_WithBeads(t *testing.T) {
|
||||
out, _ := captureStdout(t, func() error { showNextSteps(true); return nil })
|
||||
if !strings.Contains(out, "ao beads ready") {
|
||||
t.Errorf("with beads=true, expected selected-tracker route 'ao beads ready' in output:\n%s", out)
|
||||
if !strings.Contains(out, "ao beads exec ready") {
|
||||
t.Errorf("with beads=true, expected selected-tracker route 'ao beads exec ready' in output:\n%s", out)
|
||||
}
|
||||
for _, tombstone := range []string{"ao factory", "ao orchestrate", "ao codex", "/rpi"} {
|
||||
if strings.Contains(out, tombstone) {
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ produces validated output with proof — no verdict = not done.
|
||||
|
||||
The operating loop:
|
||||
ao session bootstrap Orient any agent in this repo (run first)
|
||||
ao lookup --query "<topic>" Pull decay-ranked prior context
|
||||
ao verify <change-slug> Independent cross-family review of your latest commit
|
||||
ao gate check --fast --scope head The release gate before any push
|
||||
|
||||
For AI agents:
|
||||
|
||||
@@ -431,11 +431,11 @@ func TestSeed_ClaudeMDCreated(t *testing.T) {
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "ao flywheel status") {
|
||||
t.Error("expected CLAUDE.md to contain 'ao flywheel status' instruction")
|
||||
if !strings.Contains(content, "ao session bootstrap") {
|
||||
t.Error("expected CLAUDE.md to contain 'ao session bootstrap' instruction")
|
||||
}
|
||||
if !strings.Contains(content, "MEMORY.md") {
|
||||
t.Error("expected CLAUDE.md to contain MEMORY.md reference")
|
||||
if !strings.Contains(content, "nothing runs automatically") {
|
||||
t.Error("expected CLAUDE.md to state the hookless contract")
|
||||
}
|
||||
if !strings.Contains(content, claudeMDSeedMarker) {
|
||||
t.Error("expected CLAUDE.md to contain seed section marker")
|
||||
|
||||
@@ -127,24 +127,6 @@ func TestEnsureStopReason(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryRecommendedCommands(t *testing.T) {
|
||||
noGoal := FactoryRecommendedCommands("")
|
||||
if len(noGoal) == 0 {
|
||||
t.Fatal("expected commands for empty goal")
|
||||
}
|
||||
if !strings.Contains(noGoal[0], "Set a concrete goal") {
|
||||
t.Errorf("first command should suggest setting a goal, got %q", noGoal[0])
|
||||
}
|
||||
|
||||
withGoal := FactoryRecommendedCommands("ship v3")
|
||||
if len(withGoal) == 0 {
|
||||
t.Fatal("expected commands with goal")
|
||||
}
|
||||
if !strings.Contains(withGoal[0], "ship v3") {
|
||||
t.Errorf("first command should contain goal, got %q", withGoal[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSemverParts(t *testing.T) {
|
||||
tests := []struct {
|
||||
version string
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package bridge
|
||||
|
||||
import "fmt"
|
||||
|
||||
// FactoryRecommendedCommands returns the recommended next-step commands for the factory lane.
|
||||
func FactoryRecommendedCommands(goal string) []string {
|
||||
if goal == "" {
|
||||
return []string{
|
||||
"Set a concrete goal, then run `ao factory start --goal \"your goal\"` for a briefing-first startup.",
|
||||
"Run `/rpi \"your goal\"` for the skill-first delivery lane, or use NTM/Agent Mail for out-of-session execution.",
|
||||
"Use `ao orchestrate status` to inspect orchestration readiness.",
|
||||
"Run `ao codex stop` when the session ends so the flywheel closes explicitly.",
|
||||
}
|
||||
}
|
||||
|
||||
quotedGoal := fmt.Sprintf("%q", goal)
|
||||
return []string{
|
||||
fmt.Sprintf("Run `/rpi %s` for the skill-first software-factory lane.", quotedGoal),
|
||||
"Use NTM/Agent Mail for out-of-session execution when this must outlive the current session.",
|
||||
"Use `ao orchestrate status` to inspect orchestration readiness.",
|
||||
"Run `ao codex stop` when the session ends so the flywheel closes explicitly.",
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,9 @@ func installHintFor(name string) string {
|
||||
switch name {
|
||||
case "br":
|
||||
if runtime.GOOS == "windows" {
|
||||
return "br: install beads_rust from its Windows release or use WSL/Homebrew"
|
||||
return "br: install beads_rust from its Windows release or use WSL/Homebrew — https://github.com/Dicklesworthstone/beads_rust"
|
||||
}
|
||||
return "br: install beads_rust; see AGENTS.md for the BEADS_DIR workflow"
|
||||
return "br: install beads_rust — https://github.com/Dicklesworthstone/beads_rust ('ao beads dir' prints the resolved ledger)"
|
||||
case "git":
|
||||
if runtime.GOOS == "windows" {
|
||||
return "git: choco install git | https://git-scm.com/download/win"
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/boshu2/agentops/cli/internal/autodev"
|
||||
"github.com/boshu2/agentops/cli/internal/goals"
|
||||
"github.com/boshu2/agentops/cli/internal/paths"
|
||||
)
|
||||
@@ -18,12 +17,9 @@ const (
|
||||
LayerCore ReadinessLayer = "core"
|
||||
LayerGoals ReadinessLayer = "goals"
|
||||
LayerInstructions ReadinessLayer = "instructions"
|
||||
LayerHooks ReadinessLayer = "hooks"
|
||||
LayerTracking ReadinessLayer = "tracking"
|
||||
LayerProduct ReadinessLayer = "product"
|
||||
LayerReadme ReadinessLayer = "readme"
|
||||
LayerProgram ReadinessLayer = "program"
|
||||
LayerSchedule ReadinessLayer = "schedule"
|
||||
)
|
||||
|
||||
// ReadinessItem is one inspectable artifact or capability in a repo setup.
|
||||
@@ -189,54 +185,29 @@ func InspectRepoReadiness(root string, opts ReadinessOptions) (*ReadinessReport,
|
||||
Action: claudeAction,
|
||||
})
|
||||
|
||||
// The product supports both trackers (br and bd); br leads for guidance
|
||||
// consistency, and both init commands are real. Presence checks both
|
||||
// ledger layouts so a br repo is not reported as tracker-less.
|
||||
addOptional(ReadinessItem{
|
||||
Layer: LayerTracking,
|
||||
Name: "beads tracker",
|
||||
Path: filepath.Join(absRoot, ".beads"),
|
||||
Present: isDir(filepath.Join(absRoot, ".beads")),
|
||||
Action: "bd init --prefix <prefix>",
|
||||
})
|
||||
addOptional(ReadinessItem{
|
||||
Layer: LayerHooks,
|
||||
Name: "session hooks",
|
||||
Present: false,
|
||||
Action: "ao init --hooks",
|
||||
Present: isDir(filepath.Join(absRoot, ".beads")) || isDir(filepath.Join(absRoot, "_beads")),
|
||||
Action: "br init --prefix <prefix> (or bd init --prefix <prefix>)",
|
||||
})
|
||||
addOptional(ReadinessItem{
|
||||
Layer: LayerProduct,
|
||||
Name: "PRODUCT.md",
|
||||
Path: filepath.Join(absRoot, "PRODUCT.md"),
|
||||
Present: isFile(filepath.Join(absRoot, "PRODUCT.md")),
|
||||
Action: "$product",
|
||||
Action: "write PRODUCT.md (the /product skill drafts it)",
|
||||
})
|
||||
addOptional(ReadinessItem{
|
||||
Layer: LayerReadme,
|
||||
Name: "README.md",
|
||||
Path: filepath.Join(absRoot, "README.md"),
|
||||
Present: isFile(filepath.Join(absRoot, "README.md")),
|
||||
Action: "$readme",
|
||||
})
|
||||
|
||||
programRel := autodev.ResolveProgramPath(absRoot)
|
||||
programPath := filepath.Join(absRoot, "PROGRAM.md")
|
||||
programPresent := false
|
||||
if programRel != "" {
|
||||
programPath = filepath.Join(absRoot, programRel)
|
||||
programPresent = true
|
||||
}
|
||||
addOptional(ReadinessItem{
|
||||
Layer: LayerProgram,
|
||||
Name: "PROGRAM.md or AUTODEV.md",
|
||||
Path: programPath,
|
||||
Present: programPresent,
|
||||
Action: `ao autodev init "your objective"`,
|
||||
})
|
||||
addOptional(ReadinessItem{
|
||||
Layer: LayerSchedule,
|
||||
Name: ".agents/schedule.yaml",
|
||||
Path: filepath.Join(statePaths.AgentsDir, "schedule.yaml"),
|
||||
Present: isFile(filepath.Join(statePaths.AgentsDir, "schedule.yaml")),
|
||||
Action: "ao init --with-schedule",
|
||||
Action: "write README.md",
|
||||
})
|
||||
|
||||
return report, nil
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestInspectRepoReadinessEmptyRepo(t *testing.T) {
|
||||
if report.Template != "generic" {
|
||||
t.Fatalf("Template = %q, want generic", report.Template)
|
||||
}
|
||||
for _, layer := range []ReadinessLayer{LayerCore, LayerGoals, LayerInstructions, LayerTracking, LayerProduct, LayerProgram, LayerSchedule} {
|
||||
for _, layer := range []ReadinessLayer{LayerCore, LayerGoals, LayerInstructions, LayerTracking, LayerProduct, LayerReadme} {
|
||||
if !readinessHasLayer(report, layer) {
|
||||
t.Fatalf("expected readiness layer %q in report", layer)
|
||||
}
|
||||
|
||||
@@ -118,43 +118,54 @@ func BuildSeedGoalFile(root string, template string) *goals.GoalFile {
|
||||
}
|
||||
}
|
||||
|
||||
// ClaudeMDSeedSection is the section appended to CLAUDE.md by ao seed.
|
||||
// ClaudeMDSeedSection is the section appended to CLAUDE.md by repo seeding.
|
||||
// AgentOps 3.0 is hookless: nothing runs automatically, so this section must
|
||||
// only describe explicit commands that exist in the default `ao` build.
|
||||
const ClaudeMDSeedSection = `
|
||||
## AgentOps Knowledge Flywheel
|
||||
## AgentOps Operating Loop
|
||||
|
||||
Knowledge compounds automatically across sessions:
|
||||
|
||||
- **MEMORY.md** is auto-loaded by your AI coding tool every session
|
||||
- **Session hooks** extract learnings, update MEMORY.md, and prune stale knowledge
|
||||
- **Skills** invoke flywheel commands at the right moments (no manual ao commands needed)
|
||||
|
||||
Verify the flywheel any time:
|
||||
AgentOps is hookless: nothing runs automatically. Work moves through one
|
||||
explicit loop — shape the intent, track it, implement against a failing test,
|
||||
then prove the change with an independent verdict. No verdict = not done.
|
||||
|
||||
` + "```bash" + `
|
||||
ao flywheel status # escape velocity check
|
||||
ao status # current knowledge inventory
|
||||
ao session bootstrap # orient the agent at session start
|
||||
ao status # repo readiness and current state
|
||||
ao beads exec ready # unblocked tracked work (br or bd)
|
||||
ao verify <change-slug> # independent cross-family review of your latest commit
|
||||
` + "```" + `
|
||||
|
||||
Learnings persist in .agents/ because a session writes them there (the
|
||||
/post-mortem skill), not because a hook extracts them.
|
||||
`
|
||||
|
||||
// ClaudeMDSeedMarker is used to detect if the seed section was already added.
|
||||
const ClaudeMDSeedMarker = "## AgentOps Knowledge Flywheel"
|
||||
const ClaudeMDSeedMarker = "## AgentOps Operating Loop"
|
||||
|
||||
// ClaudeMDSeedMarkerLegacy is the legacy marker for backward compatibility.
|
||||
// ClaudeMDSeedMarkerLegacyFlywheel is the pre-3.0 "knowledge compounds
|
||||
// automatically" marker, kept so seeding never duplicates the section in
|
||||
// repos seeded by older builds.
|
||||
const ClaudeMDSeedMarkerLegacyFlywheel = "## AgentOps Knowledge Flywheel"
|
||||
|
||||
// ClaudeMDSeedMarkerLegacy is the oldest legacy marker for backward compatibility.
|
||||
const ClaudeMDSeedMarkerLegacy = "## AgentOps Session Protocol"
|
||||
|
||||
// HasSeedMarker returns true if content contains the current or legacy seed marker.
|
||||
// seedMarkers lists every marker that identifies an already-seeded CLAUDE.md,
|
||||
// current first.
|
||||
var seedMarkers = []string{ClaudeMDSeedMarker, ClaudeMDSeedMarkerLegacyFlywheel, ClaudeMDSeedMarkerLegacy}
|
||||
|
||||
// HasSeedMarker returns true if content contains the current or a legacy seed marker.
|
||||
func HasSeedMarker(content string) bool {
|
||||
return strings.Contains(content, ClaudeMDSeedMarker) || strings.Contains(content, ClaudeMDSeedMarkerLegacy)
|
||||
return FindSeedMarker(content) != ""
|
||||
}
|
||||
|
||||
// FindSeedMarker returns the marker string found in content (current or
|
||||
// legacy), or empty string if neither is present.
|
||||
// legacy), or empty string if none is present.
|
||||
func FindSeedMarker(content string) string {
|
||||
if strings.Contains(content, ClaudeMDSeedMarker) {
|
||||
return ClaudeMDSeedMarker
|
||||
}
|
||||
if strings.Contains(content, ClaudeMDSeedMarkerLegacy) {
|
||||
return ClaudeMDSeedMarkerLegacy
|
||||
for _, marker := range seedMarkers {
|
||||
if strings.Contains(content, marker) {
|
||||
return marker
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ func TestHasSeedMarker(t *testing.T) {
|
||||
want bool
|
||||
}{
|
||||
{"empty", "", false},
|
||||
{"current marker", "before\n## AgentOps Knowledge Flywheel\nafter", true},
|
||||
{"current marker", "before\n## AgentOps Operating Loop\nafter", true},
|
||||
{"legacy flywheel marker", "before\n## AgentOps Knowledge Flywheel\nafter", true},
|
||||
{"legacy marker", "before\n## AgentOps Session Protocol\nafter", true},
|
||||
{"no marker", "some random content", false},
|
||||
}
|
||||
@@ -101,9 +102,10 @@ func TestFindSeedMarker(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"empty returns empty", "", ""},
|
||||
{"current marker", "x\n## AgentOps Knowledge Flywheel\ny", ClaudeMDSeedMarker},
|
||||
{"current marker", "x\n## AgentOps Operating Loop\ny", ClaudeMDSeedMarker},
|
||||
{"legacy flywheel marker only", "x\n## AgentOps Knowledge Flywheel\ny", ClaudeMDSeedMarkerLegacyFlywheel},
|
||||
{"legacy marker only", "x\n## AgentOps Session Protocol\ny", ClaudeMDSeedMarkerLegacy},
|
||||
{"current wins over legacy when both present", "## AgentOps Knowledge Flywheel\n## AgentOps Session Protocol", ClaudeMDSeedMarker},
|
||||
{"current wins over legacy when both present", "## AgentOps Operating Loop\n## AgentOps Knowledge Flywheel\n## AgentOps Session Protocol", ClaudeMDSeedMarker},
|
||||
{"no marker", "nothing here", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
},
|
||||
"expectations": [
|
||||
{"type": "exit_code", "value": 0},
|
||||
{"type": "stdout_contains", "value": "cli-command-headings: top=32 sub=120 all=152"},
|
||||
{"type": "stdout_contains", "value": "cli-command-headings: top=32 sub=112 all=144"},
|
||||
{"type": "stdout_contains", "value": "cli-help-matrix-ok"}
|
||||
],
|
||||
"dimensions": ["correctness", "runtime_compatibility", "artifact_quality"],
|
||||
|
||||
@@ -17,7 +17,7 @@ top_count="$(rg -c '^### `ao ' "$DOCS_PATH")"
|
||||
sub_count="$(rg -c '^#### `ao ' "$DOCS_PATH")"
|
||||
all_count="$(rg -c '^#{3,4} `ao ' "$DOCS_PATH")"
|
||||
|
||||
if [[ "$top_count" != "32" || "$sub_count" != "120" || "$all_count" != "152" ]]; then
|
||||
if [[ "$top_count" != "32" || "$sub_count" != "112" || "$all_count" != "144" ]]; then
|
||||
printf 'unexpected command heading counts: top=%s sub=%s all=%s\n' "$top_count" "$sub_count" "$all_count" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -25,7 +25,7 @@ fi
|
||||
# shellcheck disable=SC2016 # literal backticks delimit generated Markdown command headings.
|
||||
mapfile -t commands < <(rg '^#{3,4} `ao ' "$DOCS_PATH" | sed -E 's/^.*`([^`]+)`.*/\1/')
|
||||
|
||||
if [[ "${#commands[@]}" -ne 152 ]]; then
|
||||
if [[ "${#commands[@]}" -ne 144 ]]; then
|
||||
printf 'unexpected command matrix size: %s\n' "${#commands[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user