mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
feat(corpus): field-level learning seam schema + classify migration (ag-2srq1 #field-level-seam)
S3 of epic ag-k7tq9 (corpus private/public separation). Formalize the promote-time, field-level seam from the cross-family council verdict: the corpus is lossless and private-by-default; only an abstracted lesson crosses into the public wiki, gated by two fail-closed frontmatter fields. - schemas/learning.v1.schema.json: +sensitivity (enum unknown/private/public, default unknown) +publishable (bool, default false) — allowlist ceiling - docs/contracts/corpus-learning-seam.md: field-boundary SOT (lesson crosses; evidence/provenance/source_session never do); cites the council verdict - ao corpus classify: dry-run-by-default annotator; operates on the YAML fence textually (never parses the body) so one junk record can't abort; idempotent; skips meta docs (CORPUS-POLICY.md, README.md, ...) - L1 + L2 tests for the annotator, the dir walk, and the command Applied to the private corpus (boshu2/agentops-corpus): 342 learnings annotated with safe defaults, 1 meta doc skipped, idempotent. Boundary verified — 0 .agents/learnings changes in the public repo. Closes-scenario: ag-2srq1#field-level-seam Bounded-context: BC1-Corpus Evidence: docs/contracts/corpus-learning-seam.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// practices: [fail-closed-safety, wiki-knowledge-surface]
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/boshu2/agentops/cli/internal/corpus"
|
||||
)
|
||||
|
||||
var (
|
||||
corpusClassifyApply bool
|
||||
corpusClassifyJSON bool
|
||||
)
|
||||
|
||||
// corpusClassifyCmd annotates learning records with the two promote-gate
|
||||
// frontmatter defaults (sensitivity=unknown, publishable=false) — the S3 seam
|
||||
// migration (epic ag-k7tq9). Dry-run by default; --apply writes.
|
||||
var corpusClassifyCmd = &cobra.Command{
|
||||
Use: "classify <dir>",
|
||||
Short: "Annotate learning frontmatter with promote-gate defaults (sensitivity, publishable)",
|
||||
Long: `Ensure every learning record under <dir> carries the two promote-gate
|
||||
frontmatter fields with SAFE defaults:
|
||||
|
||||
sensitivity: unknown # un-triaged ceiling; not a capture property
|
||||
publishable: false # promotion allowlist flag; inclusion is earned
|
||||
|
||||
This is the field-level seam migration from the corpus public/private council
|
||||
verdict (.agents/council/2026-06-15-corpus-private-public-seam-verdict.md): the
|
||||
corpus is lossless and private-by-default, and only sensitivity==public AND
|
||||
publishable==true items may later be promoted to the public wiki (allowlist,
|
||||
fail-closed — default excludes).
|
||||
|
||||
It is malformed-tolerant: it operates on the frontmatter fence textually and
|
||||
never parses the (possibly broken) YAML body, so a single junk record cannot
|
||||
abort the run. An existing real decision (any sensitivity/publishable value) is
|
||||
never overwritten. Meta docs (CORPUS-POLICY.md, README.md, …) are skipped.
|
||||
|
||||
Dry-run by default — prints what WOULD change. Pass --apply to write.
|
||||
|
||||
ao corpus classify .agents/learnings # dry run
|
||||
ao corpus classify .agents/learnings --apply # write defaults`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runCorpusClassify,
|
||||
}
|
||||
|
||||
func init() {
|
||||
corpusCmd.AddCommand(corpusClassifyCmd)
|
||||
corpusClassifyCmd.Flags().BoolVar(&corpusClassifyApply, "apply", false, "Write the changes (default: dry run, report only)")
|
||||
corpusClassifyCmd.Flags().BoolVar(&corpusClassifyJSON, "json", false, "Emit the report as JSON")
|
||||
}
|
||||
|
||||
// runCorpusClassify is the RunE entry point for `ao corpus classify`.
|
||||
func runCorpusClassify(cmd *cobra.Command, args []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
dir := args[0]
|
||||
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||
return fmt.Errorf("corpus classify: %q is not a directory", dir)
|
||||
}
|
||||
|
||||
res, err := corpus.ClassifyDir(dir, corpusClassifyApply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("corpus classify: %w", err)
|
||||
}
|
||||
|
||||
if corpusClassifyJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(res)
|
||||
}
|
||||
|
||||
mode := "dry run — no files written (pass --apply to write)"
|
||||
if res.Applied {
|
||||
mode = "applied"
|
||||
}
|
||||
fmt.Printf("Corpus classify (%s):\n", mode)
|
||||
fmt.Printf(" scanned learnings: %d\n", res.Scanned)
|
||||
fmt.Printf(" skipped meta docs: %d\n", res.Skipped)
|
||||
fmt.Printf(" needing defaults: %d\n", res.Changed)
|
||||
if res.Changed > 0 && !res.Applied {
|
||||
fmt.Println("\nWould annotate:")
|
||||
for _, f := range res.ChangedFiles {
|
||||
fmt.Printf(" - %s\n", f)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRunCorpusClassify_DryRunReportsButDoesNotWrite drives the `ao corpus
|
||||
// classify` command entry point in its default (dry-run) mode against a temp
|
||||
// corpus and asserts it reports the right counts without touching disk.
|
||||
func TestRunCorpusClassify_DryRunReportsButDoesNotWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := "---\ndate: 2026-06-14\n---\nbody\n"
|
||||
p := filepath.Join(dir, "a.md")
|
||||
if err := os.WriteFile(p, []byte(orig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
corpusClassifyApply = false
|
||||
out, err := captureStdout(t, func() error {
|
||||
return runCorpusClassify(corpusClassifyCmd, []string{dir})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runCorpusClassify: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "dry run") {
|
||||
t.Errorf("expected dry-run banner, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "needing defaults: 1") {
|
||||
t.Errorf("expected 1 record needing defaults, got:\n%s", out)
|
||||
}
|
||||
got, _ := os.ReadFile(p)
|
||||
if string(got) != orig {
|
||||
t.Errorf("dry run modified the file:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCorpusClassify_ApplyWritesDefaults drives `ao corpus classify --apply`
|
||||
// and asserts the safe defaults are written to the learning frontmatter.
|
||||
func TestRunCorpusClassify_ApplyWritesDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "a.md")
|
||||
if err := os.WriteFile(p, []byte("---\ndate: 2026-06-14\n---\nbody\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
corpusClassifyApply = true
|
||||
t.Cleanup(func() { corpusClassifyApply = false })
|
||||
if err := runCorpusClassify(corpusClassifyCmd, []string{dir}); err != nil {
|
||||
t.Fatalf("runCorpusClassify --apply: %v", err)
|
||||
}
|
||||
got, _ := os.ReadFile(p)
|
||||
if !strings.Contains(string(got), "sensitivity: unknown") || !strings.Contains(string(got), "publishable: false") {
|
||||
t.Errorf("apply did not write defaults:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCorpusClassify_NonDirRejected asserts a non-directory argument is a
|
||||
// clean error, not a panic.
|
||||
func TestRunCorpusClassify_NonDirRejected(t *testing.T) {
|
||||
corpusClassifyApply = false
|
||||
err := runCorpusClassify(corpusClassifyCmd, []string{filepath.Join(t.TempDir(), "nope")})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for a non-directory argument")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a directory") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -3138,6 +3138,22 @@ ao corpus capture --path <relpath> [--body <text>] [--body-file <file>] [--body-
|
||||
--root string corpus root (default: .agents/learnings/)
|
||||
```
|
||||
|
||||
#### `ao corpus classify`
|
||||
|
||||
Ensure every learning record under <dir> carries the two promote-gate
|
||||
|
||||
```
|
||||
ao corpus classify <dir> [flags]
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
|
||||
```
|
||||
--apply Write the changes (default: dry run, report only)
|
||||
-h, --help help for classify
|
||||
--json Emit the report as JSON
|
||||
```
|
||||
|
||||
#### `ao corpus fitness`
|
||||
|
||||
Compute the corpus-quality fitness vector for the current .agents/
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// practices: [fail-closed-safety, wiki-knowledge-surface]
|
||||
package corpus
|
||||
|
||||
import "strings"
|
||||
|
||||
// The two promote-gate fields S3 (epic ag-k7tq9) adds to every learning's
|
||||
// frontmatter. They are the CEILING the publish pipeline (S5/S6) reads — NOT a
|
||||
// capture property. Per the unanimous cross-family council verdict
|
||||
// (.agents/council/2026-06-15-corpus-private-public-seam-verdict.md): the corpus
|
||||
// is lossless and private-by-default; sensitivity is decided at promote time.
|
||||
//
|
||||
// Allowlist, fail-closed: only sensitivity==SensitivityPublic AND
|
||||
// publishable==true may cross the seam into docs/wiki. Default excludes.
|
||||
const (
|
||||
// SensitivityField is the frontmatter key for the publish-gate ceiling.
|
||||
SensitivityField = "sensitivity"
|
||||
// PublishableField is the frontmatter key for the promotion allowlist flag.
|
||||
PublishableField = "publishable"
|
||||
|
||||
// SensitivityDefault is the safe default for an un-triaged learning: nothing
|
||||
// is publishable until it is affirmatively reviewed and cleared.
|
||||
SensitivityDefault = "unknown"
|
||||
// PublishableDefault is the safe default: inclusion is earned, never assumed.
|
||||
PublishableDefault = "false"
|
||||
)
|
||||
|
||||
// metaFilenames are corpus files that are NOT learning records and must never
|
||||
// be annotated (policy/readme/index docs that live alongside the learnings).
|
||||
var metaFilenames = map[string]bool{
|
||||
"CORPUS-POLICY.md": true,
|
||||
"README.md": true,
|
||||
"MEMORY.md": true,
|
||||
"INDEX.md": true,
|
||||
}
|
||||
|
||||
// IsLearningFile reports whether a corpus file (identified by its base name)
|
||||
// is a learning record eligible for classification. Meta/policy/index docs are
|
||||
// excluded.
|
||||
func IsLearningFile(base string) bool {
|
||||
return !metaFilenames[base]
|
||||
}
|
||||
|
||||
// AnnotateLearning ensures a learning's YAML frontmatter carries the two
|
||||
// promote-gate fields with safe defaults, returning the (possibly) rewritten
|
||||
// content and whether anything changed.
|
||||
//
|
||||
// It is deliberately MALFORMED-TOLERANT: it operates on the `---` frontmatter
|
||||
// fence textually and never parses the (possibly broken) YAML body. The corpus
|
||||
// holds hand-edited and machine-extracted records with inconsistent frontmatter
|
||||
// (the migration must not crash on a single junk record — council risk note).
|
||||
//
|
||||
// Behavior:
|
||||
// - File opens with a `---` fence: any of the two keys that is absent from the
|
||||
// frontmatter region is inserted just before the closing fence (or, if no
|
||||
// closing fence exists, right after the opening fence). An already-present
|
||||
// key (any value) is left untouched — defaults never clobber a real decision.
|
||||
// - File has no opening fence: a minimal frontmatter block carrying both
|
||||
// defaults is prepended.
|
||||
//
|
||||
// Key order is stable (sensitivity, then publishable) for deterministic diffs.
|
||||
func AnnotateLearning(content string) (string, bool) {
|
||||
defaults := []struct{ key, val string }{
|
||||
{SensitivityField, SensitivityDefault},
|
||||
{PublishableField, PublishableDefault},
|
||||
}
|
||||
|
||||
// Normalize on \n for line work; the corpus is unix-newline.
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
if len(lines) == 0 || lines[0] != "---" {
|
||||
// No frontmatter — prepend a minimal block with both defaults.
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n")
|
||||
for _, d := range defaults {
|
||||
b.WriteString(d.key + ": " + d.val + "\n")
|
||||
}
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString(content)
|
||||
return b.String(), true
|
||||
}
|
||||
|
||||
// Find the closing fence (first bare `---` after line 0).
|
||||
closeIdx := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if lines[i] == "---" {
|
||||
closeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// The frontmatter region is lines[1:closeIdx] (or lines[1:] if no close).
|
||||
regionEnd := closeIdx
|
||||
if regionEnd == -1 {
|
||||
regionEnd = len(lines)
|
||||
}
|
||||
present := map[string]bool{}
|
||||
for i := 1; i < regionEnd; i++ {
|
||||
if k, ok := frontmatterKey(lines[i]); ok {
|
||||
present[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, d := range defaults {
|
||||
if !present[d.key] {
|
||||
missing = append(missing, d.key+": "+d.val)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return content, false
|
||||
}
|
||||
|
||||
// Insert before the closing fence, or right after the opening fence when the
|
||||
// file has no closing fence (malformed — keep the keys inside the header zone).
|
||||
insertAt := closeIdx
|
||||
if insertAt == -1 {
|
||||
insertAt = 1
|
||||
}
|
||||
out := make([]string, 0, len(lines)+len(missing))
|
||||
out = append(out, lines[:insertAt]...)
|
||||
out = append(out, missing...)
|
||||
out = append(out, lines[insertAt:]...)
|
||||
return strings.Join(out, "\n"), true
|
||||
}
|
||||
|
||||
// frontmatterKey extracts the bare YAML key from a frontmatter line, if the line
|
||||
// is a top-level `key: ...` (or `key:`) mapping entry. Indented lines (block
|
||||
// values, list items) return ok=false so a nested `sensitivity:` never counts.
|
||||
func frontmatterKey(line string) (string, bool) {
|
||||
if line == "" || line[0] == ' ' || line[0] == '\t' || line[0] == '#' || line[0] == '-' {
|
||||
return "", false
|
||||
}
|
||||
idx := strings.IndexByte(line, ':')
|
||||
if idx <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return line[:idx], true
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// practices: [fail-closed-safety, wiki-knowledge-surface]
|
||||
package corpus
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClassifyResult is the report from a ClassifyDir run.
|
||||
type ClassifyResult struct {
|
||||
// Applied is true when changes were written to disk (false = dry run).
|
||||
Applied bool `json:"applied"`
|
||||
// Scanned counts learning records examined (meta docs excluded).
|
||||
Scanned int `json:"scanned"`
|
||||
// Changed counts records that were (or, in dry run, would be) annotated.
|
||||
Changed int `json:"changed"`
|
||||
// Skipped counts non-learning meta docs that were left alone.
|
||||
Skipped int `json:"skipped"`
|
||||
// ChangedFiles lists the relative paths that need / got annotation (sorted).
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
}
|
||||
|
||||
// ClassifyDir walks root for `.md` learning records and ensures each carries the
|
||||
// two promote-gate frontmatter defaults (sensitivity, publishable). Meta/policy
|
||||
// docs (CORPUS-POLICY.md, README.md, …) are skipped. With apply=false it only
|
||||
// reports what would change; with apply=true it rewrites the changed files in
|
||||
// place.
|
||||
//
|
||||
// It is malformed-tolerant by construction — AnnotateLearning never parses the
|
||||
// YAML body — so a single junk record cannot abort the migration.
|
||||
func ClassifyDir(root string, apply bool) (ClassifyResult, error) {
|
||||
res := ClassifyResult{Applied: apply}
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
// Skip a nested .git dir defensively (the corpus is its own repo).
|
||||
if d.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(d.Name(), ".md") {
|
||||
return nil
|
||||
}
|
||||
if !IsLearningFile(d.Name()) {
|
||||
res.Skipped++
|
||||
return nil
|
||||
}
|
||||
res.Scanned++
|
||||
raw, readErr := os.ReadFile(path) // #nosec G304 -- path from the corpus dir walk
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
out, changed := AnnotateLearning(string(raw))
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
res.Changed++
|
||||
rel, relErr := filepath.Rel(root, path)
|
||||
if relErr != nil {
|
||||
rel = path
|
||||
}
|
||||
res.ChangedFiles = append(res.ChangedFiles, rel)
|
||||
if apply {
|
||||
// Preserve the file mode; learnings are 0644.
|
||||
info, statErr := d.Info()
|
||||
mode := fs.FileMode(0o644)
|
||||
if statErr == nil {
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
if writeErr := os.WriteFile(path, []byte(out), mode); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
sort.Strings(res.ChangedFiles)
|
||||
return res, err
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package corpus
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestClassifyDir is the L2 integration test: it builds a temp corpus with the
|
||||
// real on-disk shapes (fenced, no-fence, already-classified, a meta doc, a
|
||||
// malformed record) and drives the whole walk → annotate → write path.
|
||||
func TestClassifyDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite := func(rel, body string) {
|
||||
p := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
mustWrite("a-fenced.md", "---\ndate: 2026-06-14\n---\nbody\n")
|
||||
mustWrite("b-nofence.md", "# heading\nbody\n")
|
||||
mustWrite("c-classified.md", "---\nsensitivity: public\npublishable: true\n---\nbody\n")
|
||||
mustWrite("CORPUS-POLICY.md", "# policy\n") // meta — skipped
|
||||
mustWrite("nested/d-malformed.md", "---\nbroken: [\n") // no close fence
|
||||
mustWrite("notmarkdown.txt", "ignored\n")
|
||||
|
||||
// Dry run: report only, no writes.
|
||||
dry, err := ClassifyDir(root, false)
|
||||
if err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if dry.Applied {
|
||||
t.Error("dry run reported Applied=true")
|
||||
}
|
||||
if dry.Scanned != 4 { // a, b, c, d — not the .txt, not the meta
|
||||
t.Errorf("Scanned = %d, want 4", dry.Scanned)
|
||||
}
|
||||
if dry.Skipped != 1 { // CORPUS-POLICY.md
|
||||
t.Errorf("Skipped = %d, want 1", dry.Skipped)
|
||||
}
|
||||
if dry.Changed != 3 { // a, b, d need annotation; c already classified
|
||||
t.Errorf("Changed = %d, want 3 (files: %v)", dry.Changed, dry.ChangedFiles)
|
||||
}
|
||||
// Dry run must not have touched disk.
|
||||
if got, _ := os.ReadFile(filepath.Join(root, "a-fenced.md")); string(got) != "---\ndate: 2026-06-14\n---\nbody\n" {
|
||||
t.Error("dry run wrote to a-fenced.md")
|
||||
}
|
||||
|
||||
// Apply: writes the changes.
|
||||
app, err := ClassifyDir(root, true)
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if app.Changed != 3 {
|
||||
t.Errorf("apply Changed = %d, want 3", app.Changed)
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(root, "a-fenced.md"))
|
||||
if !strings.Contains(string(got), "sensitivity: unknown") || !strings.Contains(string(got), "publishable: false") {
|
||||
t.Errorf("a-fenced.md not annotated after apply:\n%s", got)
|
||||
}
|
||||
// The meta doc is untouched.
|
||||
meta, _ := os.ReadFile(filepath.Join(root, "CORPUS-POLICY.md"))
|
||||
if string(meta) != "# policy\n" {
|
||||
t.Errorf("CORPUS-POLICY.md was modified: %q", meta)
|
||||
}
|
||||
|
||||
// Re-run after apply is a clean no-op (idempotent migration).
|
||||
again, err := ClassifyDir(root, true)
|
||||
if err != nil {
|
||||
t.Fatalf("rerun: %v", err)
|
||||
}
|
||||
if again.Changed != 0 {
|
||||
t.Errorf("rerun Changed = %d, want 0 (not idempotent): %v", again.Changed, again.ChangedFiles)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package corpus
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnnotateLearning(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
wantChanged bool
|
||||
// substrings the output frontmatter must contain
|
||||
wantContains []string
|
||||
// substrings the output must NOT contain (e.g. duplicate keys)
|
||||
wantAbsent []string
|
||||
}{
|
||||
{
|
||||
name: "fenced record missing both fields gets both defaults",
|
||||
in: "---\ndate: 2026-06-14\nstatus: reviewed\n---\n\n# Learning\nbody\n",
|
||||
wantChanged: true,
|
||||
wantContains: []string{"sensitivity: unknown", "publishable: false", "date: 2026-06-14"},
|
||||
},
|
||||
{
|
||||
name: "record already carrying a real decision is left untouched",
|
||||
in: "---\nsensitivity: public\npublishable: true\ndate: 2026-06-14\n---\nbody\n",
|
||||
wantChanged: false,
|
||||
// must not flip a real public decision back to the default
|
||||
wantAbsent: []string{"sensitivity: unknown", "publishable: false"},
|
||||
},
|
||||
{
|
||||
name: "partial — only the missing field is added",
|
||||
in: "---\nsensitivity: private\ndate: 2026-06-14\n---\nbody\n",
|
||||
wantChanged: true,
|
||||
wantContains: []string{"sensitivity: private", "publishable: false"},
|
||||
wantAbsent: []string{"sensitivity: unknown"},
|
||||
},
|
||||
{
|
||||
name: "no frontmatter fence — minimal block is prepended",
|
||||
in: "# Orchestration spike\n\nSource session: foo.\n",
|
||||
wantChanged: true,
|
||||
wantContains: []string{"---\nsensitivity: unknown\npublishable: false\n---", "# Orchestration spike"},
|
||||
},
|
||||
{
|
||||
name: "malformed — opening fence but no closing fence still lands keys in header zone",
|
||||
in: "---\ndate: 2026-06-14\nbroken yaml here with no close fence\n",
|
||||
wantChanged: true,
|
||||
wantContains: []string{"sensitivity: unknown", "publishable: false"},
|
||||
},
|
||||
{
|
||||
name: "nested sensitivity key under a block does not count as present",
|
||||
in: "---\ndate: 2026-06-14\nmeta:\n sensitivity: high\n---\nbody\n",
|
||||
wantChanged: true,
|
||||
// the top-level field must still be added despite the nested one
|
||||
wantContains: []string{"date: 2026-06-14"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, changed := AnnotateLearning(tt.in)
|
||||
if changed != tt.wantChanged {
|
||||
t.Fatalf("changed = %v, want %v\noutput:\n%s", changed, tt.wantChanged, got)
|
||||
}
|
||||
for _, sub := range tt.wantContains {
|
||||
if !strings.Contains(got, sub) {
|
||||
t.Errorf("output missing %q\ngot:\n%s", sub, got)
|
||||
}
|
||||
}
|
||||
for _, sub := range tt.wantAbsent {
|
||||
if strings.Contains(got, sub) {
|
||||
t.Errorf("output unexpectedly contains %q\ngot:\n%s", sub, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnnotateLearning_Idempotent guards the migration's re-run safety: applying
|
||||
// twice must be a no-op the second time (round-trips the real first-pass output).
|
||||
func TestAnnotateLearning_Idempotent(t *testing.T) {
|
||||
in := "---\ndate: 2026-06-14\n---\nbody\n"
|
||||
once, changed1 := AnnotateLearning(in)
|
||||
if !changed1 {
|
||||
t.Fatal("first pass should change an unclassified record")
|
||||
}
|
||||
twice, changed2 := AnnotateLearning(once)
|
||||
if changed2 {
|
||||
t.Errorf("second pass changed an already-classified record (not idempotent)\nfirst:\n%s", once)
|
||||
}
|
||||
if once != twice {
|
||||
t.Errorf("re-annotation altered content:\nfirst:\n%s\nsecond:\n%s", once, twice)
|
||||
}
|
||||
// exactly one of each key
|
||||
if n := strings.Count(twice, "sensitivity: "); n != 1 {
|
||||
t.Errorf("sensitivity key count = %d, want 1", n)
|
||||
}
|
||||
if n := strings.Count(twice, "publishable: "); n != 1 {
|
||||
t.Errorf("publishable key count = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLearningFile(t *testing.T) {
|
||||
learnings := []string{"2026-06-14-foo.md", "research/bar.md"}
|
||||
meta := []string{"CORPUS-POLICY.md", "README.md", "MEMORY.md", "INDEX.md"}
|
||||
for _, f := range learnings {
|
||||
base := f
|
||||
if i := strings.LastIndexByte(f, '/'); i >= 0 {
|
||||
base = f[i+1:]
|
||||
}
|
||||
if !IsLearningFile(base) {
|
||||
t.Errorf("IsLearningFile(%q) = false, want true", base)
|
||||
}
|
||||
}
|
||||
for _, f := range meta {
|
||||
if IsLearningFile(f) {
|
||||
t.Errorf("IsLearningFile(%q) = true, want false (meta doc)", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,6 +337,13 @@
|
||||
"kind": "leaf",
|
||||
"reason": "Covered by release smoke tests, direct command tests, or command handler tests."
|
||||
},
|
||||
{
|
||||
"category": "public-tested",
|
||||
"command": "corpus classify",
|
||||
"coverage_status": "covered",
|
||||
"kind": "leaf",
|
||||
"reason": "Covered by release smoke tests, direct command tests, or command handler tests."
|
||||
},
|
||||
{
|
||||
"category": "public-tested",
|
||||
"command": "corpus fitness",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
| `ao contradict` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. |
|
||||
| `ao converge` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. |
|
||||
| `ao corpus capture` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. |
|
||||
| `ao corpus classify` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. |
|
||||
| `ao corpus fitness` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. |
|
||||
| `ao corpus inject` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. |
|
||||
| `ao corpus restore` | `public-stateful-fixture-needed` | `allowlisted` | Restores corpus snapshots and needs a disposable corpus fixture. |
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Corpus learning seam — the field-level public/private boundary
|
||||
|
||||
> **Status:** contract · **Epic:** ag-k7tq9 (corpus private/public separation) · **Slice:** S3 (ag-2srq1)
|
||||
> **Authority:** the unanimous cross-family council verdict
|
||||
> [`.agents/council/2026-06-15-corpus-private-public-seam-verdict.md`](../../.agents/council/2026-06-15-corpus-private-public-seam-verdict.md).
|
||||
> **Schema:** [`schemas/learning.v1.schema.json`](../../schemas/learning.v1.schema.json).
|
||||
> **Migration:** `ao corpus classify` (`cli/internal/corpus/classify.go`).
|
||||
|
||||
The corpus (`boshu2/agentops-corpus`, mounted at `.agents/learnings/`) is **lossless
|
||||
and private by default**. Evidence, provenance, and source pointers live there
|
||||
forever. Sensitivity is a **publish property, not a capture property**: nothing is
|
||||
decided at mine time. The seam is enforced at **promote time**, **field-level**,
|
||||
with the source session's sensitivity as a **ceiling**.
|
||||
|
||||
This contract defines which fields of a learning record may cross that seam.
|
||||
|
||||
## The two promote-gate fields
|
||||
|
||||
Every learning record carries two frontmatter fields that gate promotion. Both
|
||||
default to the most conservative value (`ao corpus classify` backfills them):
|
||||
|
||||
| Field | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `sensitivity` | `unknown` \| `private` \| `public` | `unknown` | The ceiling. `unknown` = un-triaged; `private` = tainted, never publishes; `public` = abstracted, cleared for the wiki. |
|
||||
| `publishable` | boolean | `false` | The allowlist flag. `true` only after the lesson is abstracted **and** passes the leak scanner. |
|
||||
|
||||
**Promote rule (allowlist, fail-closed):** a record may be promoted to `docs/wiki/`
|
||||
only when `sensitivity == "public"` **AND** `publishable == true`. Default excludes.
|
||||
Inclusion is earned, never assumed. A single fat-finger cannot publish the corpus.
|
||||
|
||||
## The field boundary — what crosses, what never does
|
||||
|
||||
| Field(s) | Class | Crosses the seam? |
|
||||
|---|---|---|
|
||||
| the abstracted **lesson** body (the markdown after the frontmatter, once generalized) | publishable | **yes** — and only this |
|
||||
| `source_session` | private provenance | **never** |
|
||||
| `source_bead` / bead ids | private provenance | **never** |
|
||||
| evidence paths (`Evidence:` refs, file:line citations into private repos) | private evidence | **never** |
|
||||
| any fleet / client / peer-agent / private-namespace / mythology / brand reference | private (leak markers) | **never** — hard-fail in the scanner |
|
||||
|
||||
Only the **lesson** crosses. `evidence` / `provenance` / `source_session` stay in the
|
||||
private corpus — which is also what keeps the provenance graph private (dovetails with
|
||||
mesh ag-5qltf). Mine-time labels (`tier`, `maturity`, `category`) are **triage only**,
|
||||
**not** a declassification boundary.
|
||||
|
||||
## Defense in depth (all fail-closed)
|
||||
|
||||
1. **Physical separation** — the corpus is a separate private repo; no
|
||||
`.gitignore`-negation single point of failure.
|
||||
2. **Allowlist, not blocklist** — `ao corpus publish` (S6) emits only records that
|
||||
pass the promote rule above. Default = exclude.
|
||||
3. **Leak scan on rendered output** — `ao corpus scan` / `cli/internal/corpusscan`
|
||||
(S4): any fleet/client/peer/PII/brand/landmine marker hit = hard FAIL, **never
|
||||
auto-redact**.
|
||||
4. **Pre-push guard + CI re-scan** (S1/S7) — reject staged raw corpus paths; re-scan
|
||||
`wiki/**` on push, fail closed.
|
||||
|
||||
## Migration
|
||||
|
||||
`ao corpus classify <dir>` backfills the two defaults onto every learning record,
|
||||
**malformed-tolerant** (operates on the `---` frontmatter fence textually; never parses
|
||||
the YAML body, so one junk record cannot abort the run) and **idempotent** (an existing
|
||||
real decision is never overwritten). Meta docs (`CORPUS-POLICY.md`, `README.md`, …) are
|
||||
skipped. Dry-run by default; `--apply` writes.
|
||||
|
||||
```bash
|
||||
ao corpus classify .agents/learnings # dry run — report only
|
||||
ao corpus classify .agents/learnings --apply # write the safe defaults
|
||||
```
|
||||
@@ -282,6 +282,7 @@ Bridge / framing docs:
|
||||
- [Pawls — the one-way doors](contracts/pawls.md) — The ratchet's static map: the short list of irreversible actions (mutate-shared-trunk · delete · external-send/shared-state-mutation · schema/contract change · credential/authority change · spend) where the cross-family gate fires; everything else runs as ungated chaos
|
||||
- [Operating Discipline (D1–D16)](doctrine/operating-discipline.md) — The general, substrate-neutral fleet-operating rules (admission-first · author≠judge · fail-closed · evidence-bound · single-writer · typed transitions) folded from the mt-olympus triangulated kernel; each rule marked embodied-in-gate (cited to pawls.md / pawl-verdict.sh / reconcile-pr.sh), advisory doctrine, or dropped-as-cathedral
|
||||
- [Lesson Format](contracts/lesson-format.md) — Schema for `.agents/learnings/` entries with frontmatter (id/severity/trigger/verifiable/rule/falsified_by/practice/related) and graduation path (unassigned → proposed → accepted → encoded)
|
||||
- [Corpus Learning Seam](contracts/corpus-learning-seam.md) — Field-level public/private boundary for learning records (epic ag-k7tq9 S3): the `sensitivity` + `publishable` promote-gate fields, what crosses the seam (the abstracted lesson) vs what never does (evidence/provenance/source_session), and the `ao corpus classify` migration; cites the cross-family council verdict
|
||||
- [bd remember Migration Manifest](contracts/bd-remember-migration-manifest.md) — Lineage-preserving manifest contract for classifying `bd remember` notes into bead-scoped, pull-learning, or discard dispositions before migration
|
||||
- [Bounded Contexts (yaml)](contracts/bounded-contexts.yaml) — Canonical BC1-BC5 definitions (id/name/responsibility/ports/center-of-gravity); registry doc prose must match this yaml (drift-checked by `scripts/check-bounded-contexts-drift.sh`, soc-zxia.2)
|
||||
- [add-validate-job scaffolder](https://github.com/boshu2/agentops/blob/main/scripts/add-validate-job.sh) — CI integration scaffolder; emits all 5 touch-points (workflow + summary needs + summary echo + pre-push + bats stub + AGENTS table) atomically when adding a new `validate-*` job (soc-3oij)
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
},
|
||||
"expectations": [
|
||||
{"type": "exit_code", "value": 0},
|
||||
{"type": "stdout_contains", "value": "cli-command-headings: top=88 sub=215 all=303"},
|
||||
{"type": "stdout_contains", "value": "cli-command-headings: top=88 sub=216 all=304"},
|
||||
{"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" != "88" || "$sub_count" != "215" || "$all_count" != "303" ]]; then
|
||||
if [[ "$top_count" != "88" || "$sub_count" != "216" || "$all_count" != "304" ]]; 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 303 ]]; then
|
||||
if [[ "${#commands[@]}" -ne 304 ]]; then
|
||||
printf 'unexpected command matrix size: %s\n' "${#commands[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"generated_at": "2026-06-15T16:17:48Z",
|
||||
"generated_at": "2026-06-15T20:01:15Z",
|
||||
"summary": {
|
||||
"skills": 72,
|
||||
"hooks": 0,
|
||||
|
||||
@@ -63,6 +63,17 @@
|
||||
"default": "pull",
|
||||
"description": "Blast-radius tier, orthogonal to maturity: bead=per-bead context, pull=queried on demand (default), always=auto-injected at session bootstrap (computed from maturity==established INTERSECT canon, never author-set)."
|
||||
},
|
||||
"sensitivity": {
|
||||
"type": "string",
|
||||
"enum": ["unknown", "private", "public"],
|
||||
"default": "unknown",
|
||||
"description": "Promote-gate CEILING — a publish property, NOT a capture property. unknown=un-triaged (default); private=tainted, never crosses the seam; public=abstracted lesson cleared for the wiki. Only sensitivity==public AND publishable==true may be promoted to docs/wiki (allowlist, fail-closed; default excludes). Per council verdict .agents/council/2026-06-15-corpus-private-public-seam-verdict.md; field boundary in docs/contracts/corpus-learning-seam.md."
|
||||
},
|
||||
"publishable": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Promotion allowlist flag. false by default; set true ONLY after the lesson body is abstracted and passes the corpusscan leak detector (cli/internal/corpusscan). Inclusion is earned; default excludes."
|
||||
},
|
||||
"schema_version": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
|
||||
Reference in New Issue
Block a user