From bb904a68bf108657dd6b3b74f1004dedd2da8c6a Mon Sep 17 00:00:00 2001 From: Luis Dourado Date: Wed, 24 Jun 2026 20:37:51 -0300 Subject: [PATCH 1/5] fix(skills): sync managed session guidance --- .agents/skills/fabric-session/SKILL.md | 18 ++++++++++-------- .../skills/fabric-session/agents/openai.yaml | 6 +++--- AGENTS.md | 5 +++-- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.agents/skills/fabric-session/SKILL.md b/.agents/skills/fabric-session/SKILL.md index 6c7382b..8b42bba 100644 --- a/.agents/skills/fabric-session/SKILL.md +++ b/.agents/skills/fabric-session/SKILL.md @@ -1,18 +1,20 @@ --- name: fabric-session -description: Initialize, resume, and synchronize Fabric agent threads. Use before starting repository work, resuming a PR or issue, changing approach, opening a PR, or checking for new project direction shared by other threads and worktrees. +description: Prepare and synchronize Fabric threads for substantive, multi-step repository changes that may depend on shared direction. Use when beginning or resuming implementation, changing an implementation approach, continuing review-driven code work, or when the user explicitly asks to synchronize Fabric. Do not use for read-only inspection, simple questions, release or tag creation, one-off git or gh commands, or routine status checks. --- # Fabric Session -Use Fabric as the repository decision protocol before acting. +Use Fabric when the work can create or consume repository direction. Skip this +workflow entirely for read-only inspection and one-off operational commands. A +stale or unknown current-thread pointer alone is not a reason to create a thread. -1. Run fabric status. If sandbox policy blocks access to .git/fabric, request scoped approval for the fabric command and retry. Do not use another runtime store. -2. If there is no suitable current thread, run fabric thread start with the known PR, issue, and areas. -3. Run fabric preflight with the task and the same scope. Use --json when a provider adapter needs the projection ID. Read .fabric/generated/TASK_DIRECTION.md. +1. For substantive work, run fabric status once. If sandbox policy blocks access to .git/fabric, request scoped approval for that command and retry. Do not use another runtime store. +2. If the ongoing implementation needs shared state and there is no suitable thread, run fabric thread start with the known issue, PR, areas, and paths. +3. Run fabric preflight "" with matching --issue, --pr, --area, and --path flags before implementation. Use --json only when an adapter needs the projection ID. Read .fabric/generated/TASK_DIRECTION.md. 4. Follow active direction. If the planned approach conflicts, use $fabric-record-direction to record a challenge instead of silently diverging. 5. After projected records actually enter model context, use $fabric-provenance to acknowledge exposure. Delivery alone is not exposure. -6. Before changing approach, opening a PR, or resuming later, run fabric sync and read .fabric/generated/SYNC_DELTA.md. -7. When continuing PR or issue work, run fabric continue and read .fabric/generated/CONTINUATION_CONTEXT.md. +6. Run fabric sync before a meaningful implementation checkpoint, approach change, or explicit handoff. Do not sync after every command. Read .fabric/generated/SYNC_DELTA.md. +7. Use fabric continue only when resuming interrupted or review-driven PR/issue implementation. Read .fabric/generated/CONTINUATION_CONTEXT.md. -Treat shared findings, rationale, rejected paths, and preferred paths as inputs to the current thread, not as optional background. +Keep user updates proportional to the task; do not narrate routine Fabric plumbing. Treat shared findings, rationale, rejected paths, and preferred paths as inputs to substantive work. diff --git a/.agents/skills/fabric-session/agents/openai.yaml b/.agents/skills/fabric-session/agents/openai.yaml index d855bd9..7cf1754 100644 --- a/.agents/skills/fabric-session/agents/openai.yaml +++ b/.agents/skills/fabric-session/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Fabric Session" - short_description: "Start and synchronize Fabric agent sessions" - default_prompt: "Use $fabric-session to prepare this repository task with the current Fabric direction." + short_description: "Sync direction for substantive repository work" + default_prompt: "Use $fabric-session to prepare this multi-step repository implementation with shared Fabric direction." policy: - allow_implicit_invocation: true + allow_implicit_invocation: false diff --git a/AGENTS.md b/AGENTS.md index fb9ba53..94cacaf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,9 @@ Fabric is the repository decision and provenance protocol for agent threads and worktrees. The CLI manages the protocol; agents are its primary clients. -- Before work, run fabric status and fabric preflight, then read .fabric/generated/TASK_DIRECTION.md. -- Before changing approach, opening a PR, or resuming work, run fabric sync and read .fabric/generated/SYNC_DELTA.md. +- Before substantive multi-step implementation, use $fabric-session to inspect shared direction. +- Skip session setup for read-only inspection, simple questions, release or tag creation, and one-off git or gh commands. +- Before a meaningful implementation checkpoint, approach change, or handoff, run fabric sync and read .fabric/generated/SYNC_DELTA.md. - When corrected by a human, preserve the direction and rationale with Fabric. - Never silently violate active direction; align, ask for an exception, or record a challenge. - Treat .fabric/ledger/events/ and the shared .git/fabric runtime as shared repository state. From a8f36c14177a0801da354b5d1d8faffd7e2d816b Mon Sep 17 00:00:00 2001 From: Luis Dourado Date: Wed, 24 Jun 2026 20:37:56 -0300 Subject: [PATCH 2/5] chore(repo): add contribution and CI safeguards --- .github/pull_request_template.md | 21 ++++++++++++ .github/workflows/ci.yml | 56 ++++++++++++++++++++++++++++++++ .gitignore | 3 ++ CONTRIBUTING.md | 43 ++++++++++++++++++++++++ README.md | 4 ++- SECURITY.md | 20 ++++++++++++ 6 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..add57e0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Why + + + +## Protocol impact + + + +## Verification + + + +## Direction changes + + + +## Checklist + +- [ ] I kept unrelated changes out of this PR. +- [ ] I updated protocol docs, schemas, and fixtures when required. +- [ ] I preserved immutable event and provider-neutral behavior. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..554c98d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Install evaluation tools + run: sudo apt-get update && sudo apt-get install -y jq ripgrep + + - name: Check formatting + shell: bash + run: | + unformatted="$(gofmt -l $(git ls-files '*.go'))" + if [[ -n "${unformatted}" ]]; then + printf 'Run gofmt on:\n%s\n' "${unformatted}" + exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: Test with race detector + run: go test -race ./... + + - name: Run Local V1 evaluation + run: ./evals/run-local-v1.sh + + - name: Verify root installation + shell: bash + run: | + GOBIN="${RUNNER_TEMP}/bin" go install . + "${RUNNER_TEMP}/bin/fabric" version --json diff --git a/.gitignore b/.gitignore index 80772d1..cfbd93d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ # shared mirror out of Git. .fabric/active/ .fabric/generated/ + +# Local IDE state. +.idea/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5be114e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing to Fabric + +Fabric welcomes focused bug fixes, protocol improvements, provider adapters, +documentation, and executable scenarios. + +## Development + +Fabric requires Go 1.22 or newer. Clone the repository, then run: + +```bash +go test ./... +go test -race ./... +./evals/run-local-v1.sh +``` + +Before opening a pull request, also run: + +```bash +gofmt -w $(git ls-files '*.go') +go vet ./... +go install . +``` + +## Protocol changes + +Treat `PROTOCOL.md` as normative. A wire-contract change should update the +public Go types and validation, corresponding JSON Schemas, conformance +fixtures, and protocol documentation together. Preserve unknown extensions and +provider-neutral semantics. + +Do not rewrite immutable ledger events or commit runtime state from +`.fabric/active/`, `.fabric/generated/`, or `.git/fabric/`. + +## Pull requests + +Keep pull requests narrowly scoped and explain why the change is needed. Use +Conventional Commit messages such as `fix(cli): ...`, `feat(protocol): ...`, or +`docs: ...`. Include tests proportional to the behavior and call out breaking +or migration-sensitive changes explicitly. + +Fabric direction should remain sparse. Commit candidate or durable records only +when they capture reusable repository guidance, not routine implementation +notes. diff --git a/README.md b/README.md index 9a1de0c..1d8a9d0 100644 --- a/README.md +++ b/README.md @@ -342,4 +342,6 @@ go test -race ./... ``` Schemas live in `schemas/v1/`, fixtures in `conformance/`, and the public Go -protocol package in `protocol/`. +protocol package in `protocol/`. See [CONTRIBUTING.md](CONTRIBUTING.md) for the +development and protocol-change workflow, and [SECURITY.md](SECURITY.md) for +private vulnerability reporting. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..72ddaf5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Supported versions + +Security fixes target the latest released version of Fabric. Upgrade to the +latest release before reporting behavior that may already be fixed. + +## Reporting a vulnerability + +Do not open a public issue for a suspected vulnerability. Report it privately +through a GitHub security advisory for this repository, or email +`lfaddourado@gmail.com` if private reporting is unavailable. + +Include the affected version, impact, reproduction steps, and any suggested +mitigation. Do not include real repository credentials, private source code, +prompts, transcripts, or decrypted Fabric payloads. + +Fabric Local V1 trust claims are declarative rather than cryptographic. Reports +should distinguish a documented trust limitation from an implementation flaw +that violates the protocol or exposes data unexpectedly. From 48cb011b4ce0078c4a374bd4bc2770d04c1d0bf9 Mon Sep 17 00:00:00 2001 From: Luis Dourado Date: Wed, 24 Jun 2026 21:08:17 -0300 Subject: [PATCH 3/5] feat(cli): add direction recall and runtime cleanup --- internal/cli/app.go | 84 ++++++++++++++++-- internal/cli/clean.go | 182 ++++++++++++++++++++++++++++++++++++++ internal/cli/list.go | 95 ++++++++++++++++++++ internal/cli/list_test.go | 88 ++++++++++++++++++ internal/cli/output.go | 4 +- internal/cli/storage.go | 32 +++++++ 6 files changed, 474 insertions(+), 11 deletions(-) create mode 100644 internal/cli/clean.go create mode 100644 internal/cli/list.go create mode 100644 internal/cli/list_test.go diff --git a/internal/cli/app.go b/internal/cli/app.go index 1af467c..ac83dd3 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "github.com/lutefd/fabric/internal/skills" @@ -47,6 +48,10 @@ func runCommand(args []string) error { return runSync(args[1:]) case "status": return runStatus(args[1:]) + case "list": + return runList(args[1:]) + case "clean": + return runClean(args[1:]) case "preflight": return runPreflight(args[1:]) case "continue": @@ -86,7 +91,13 @@ Usage: fabric init fabric install-agents fabric thread start --id thread-b --issue VS-123 --area virtual-store/listing + fabric thread list + fabric thread use thread-b + fabric thread clear fabric status + fabric list --durability live + fabric clean live + fabric clean runtime --thread thread-id fabric note "Don't repeat this path" fabric note --candidate "Direction that may matter later" fabric note --durable "Long-term project guidance" @@ -347,8 +358,58 @@ func agentSkillNames() []string { } func runThread(args []string) error { - if len(args) == 0 || args[0] != "start" { - return errors.New(`expected "fabric thread start"`) + if len(args) == 0 { + return errors.New(`expected "fabric thread start", "fabric thread list", "fabric thread use", or "fabric thread clear"`) + } + switch args[0] { + case "list": + if len(args) != 1 { + return errors.New("thread list accepts no arguments") + } + threads, err := loadThreads() + if err != nil { + return err + } + ids := make([]string, 0, len(threads)) + for id := range threads { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + fmt.Printf("%s %s\n", id, compactThreadScope(threads[id])) + } + setMachineResult(map[string]any{"threads": threads, "count": len(threads)}) + return nil + case "use": + if len(args) != 2 { + return errors.New("thread use requires a thread id") + } + threads, err := loadThreads() + if err != nil { + return err + } + if _, ok := threads[args[1]]; !ok { + return fmt.Errorf("unknown thread %q", args[1]) + } + if err := saveCurrentThreadID(args[1]); err != nil { + return err + } + fmt.Printf("Current thread: %s.\n", args[1]) + setMachineResult(map[string]any{"current_thread": args[1]}) + return nil + case "clear": + if len(args) != 1 { + return errors.New("thread clear accepts no arguments") + } + if err := os.Remove(currentThreadPath); err != nil && !os.IsNotExist(err) { + return err + } + fmt.Println("Current thread cleared.") + setMachineResult(map[string]any{"current_thread": nil}) + return nil + case "start": + default: + return errors.New(`expected "fabric thread start", "fabric thread list", "fabric thread use", or "fabric thread clear"`) } fs := flag.NewFlagSet("thread start", flag.ContinueOnError) @@ -396,6 +457,10 @@ func runThread(args []string) error { return nil } +func compactThreadScope(thread ThreadRecord) string { + return compactScope(EventScope{Issue: thread.Issue, PR: thread.PR, Areas: thread.Areas, Paths: thread.Paths}) +} + func runNote(args []string) error { fs := flag.NewFlagSet("note", flag.ContinueOnError) fs.SetOutput(os.Stderr) @@ -673,7 +738,9 @@ func runStatus(args []string) error { return err } - result := map[string]any{"current_thread": current, "record_count": len(events)} + active := filterActionableEvents(events) + counts := eventDurabilityCounts(active) + result := map[string]any{"current_thread": current, "record_count": len(events), "active_record_count": len(active), "durability": counts} fmt.Println("Current thread:") if current == "" { fmt.Println("none") @@ -711,12 +778,6 @@ func runStatus(args []string) error { } result["pending_records"] = matches fmt.Println() - counts := eventDurabilityCounts(events) - result["durability"] = counts - fmt.Println("Direction durability:") - fmt.Printf("- live: %d\n", counts[DurabilityLive]) - fmt.Printf("- candidate: %d\n", counts[DurabilityCandidate]) - fmt.Printf("- durable: %d\n", counts[DurabilityDurable]) } else if current != "" { fmt.Printf("unknown current thread %q\n", current) fmt.Println() @@ -732,6 +793,11 @@ func runStatus(args []string) error { fmt.Println("Run: fabric thread start --issue ... --area ...") } fmt.Println() + fmt.Println("Actionable direction:") + fmt.Printf("- live: %d\n", counts[DurabilityLive]) + fmt.Printf("- candidate: %d\n", counts[DurabilityCandidate]) + fmt.Printf("- durable: %d\n", counts[DurabilityDurable]) + fmt.Println() fmt.Println("Generated files:") for _, path := range generatedFiles() { fmt.Printf("- %s\n", path) diff --git a/internal/cli/clean.go b/internal/cli/clean.go new file mode 100644 index 0000000..2a15ce1 --- /dev/null +++ b/internal/cli/clean.go @@ -0,0 +1,182 @@ +package cli + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "sort" +) + +func runClean(args []string) error { + if len(args) == 0 { + return errors.New(`expected "fabric clean live" or "fabric clean runtime"`) + } + switch args[0] { + case "live": + return runCleanLive(args[1:]) + case "runtime": + return runCleanRuntime(args[1:]) + default: + return errors.New(`expected "fabric clean live" or "fabric clean runtime"`) + } +} + +func runCleanLive(args []string) error { + fs := flag.NewFlagSet("clean live", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + apply := fs.Bool("apply", false, "delete the previewed runtime files") + issue := fs.String("issue", "", "select live records for an issue") + records := stringListFlag{} + fs.Var(&records, "record", "select a live record, repeatable") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return errors.New("clean live accepts flags only") + } + events, err := loadEvents() + if err != nil { + return err + } + explicit := map[string]bool{} + for _, id := range records { + explicit[id] = true + } + resolved := map[string]bool{} + for _, event := range events { + if event.Kind == "challenge_resolution" && event.Challenges != "" { + resolved[event.Challenges] = true + } + } + selected := map[string]bool{} + for _, event := range events { + if normalizeDurability(event.Durability) != DurabilityLive { + continue + } + eligible := !isActiveEvent(event) || event.Kind == "challenge_resolution" || (event.Kind == "challenge" && resolved[event.ID]) + if explicit[event.ID] || (*issue != "" && event.Scope.Issue == *issue) || (len(explicit) == 0 && *issue == "" && eligible) { + selected[event.ID] = true + } + } + for id := range explicit { + if !selected[id] { + return fmt.Errorf("record %s is not a live record", id) + } + } + return cleanSharedFiles("live record", selected, *apply, true) +} + +func runCleanRuntime(args []string) error { + fs := flag.NewFlagSet("clean runtime", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + apply := fs.Bool("apply", false, "delete the previewed runtime files") + threads := stringListFlag{} + fs.Var(&threads, "thread", "obsolete thread id, repeatable") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 || len(threads) == 0 { + return errors.New("clean runtime requires at least one --thread") + } + selected := map[string]bool{} + for _, id := range threads { + selected[id] = true + } + return cleanSharedFiles("thread", selected, *apply, false) +} + +func cleanSharedFiles(label string, selected map[string]bool, apply, includeEvents bool) error { + if len(selected) == 0 { + fmt.Println("Nothing eligible for cleanup.") + setMachineResult(map[string]any{"applied": apply, "files": []string{}, "selected": []string{}}) + return nil + } + common, err := gitCommonDir() + if err != nil { + return err + } + roots := []string{} + if includeEvents { + roots = append(roots, activeEventsPath) + } + if common != "" { + if includeEvents { + roots = append(roots, filepath.Join(common, sharedEventsRel)) + } + roots = append(roots, filepath.Join(common, sharedRuntimeRel)) + } + var files []string + for _, root := range roots { + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + if os.IsNotExist(walkErr) { + return nil + } + return walkErr + } + if entry.IsDir() || filepath.Ext(path) != ".json" { + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + var value any + if json.Unmarshal(data, &value) != nil { + return nil + } + if containsSelected(value, selected) { + files = append(files, path) + } + return nil + }) + if err != nil && !os.IsNotExist(err) { + return err + } + } + sort.Strings(files) + ids := make([]string, 0, len(selected)) + for id := range selected { + ids = append(ids, id) + } + sort.Strings(ids) + fmt.Printf("Selected %d %s(s); %d runtime file(s).\n", len(ids), label, len(files)) + for _, id := range ids { + fmt.Printf("- %s\n", id) + } + if !apply { + fmt.Println("Preview only. Re-run with --apply to delete these ephemeral files.") + } else { + for _, path := range files { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + fmt.Println("Cleanup applied.") + } + setMachineResult(map[string]any{"applied": apply, "files": files, "selected": ids}) + return nil +} + +func containsSelected(value any, selected map[string]bool) bool { + switch typed := value.(type) { + case string: + return selected[typed] + case []any: + for _, item := range typed { + if containsSelected(item, selected) { + return true + } + } + case map[string]any: + for _, item := range typed { + if containsSelected(item, selected) { + return true + } + } + } + return false +} diff --git a/internal/cli/list.go b/internal/cli/list.go new file mode 100644 index 0000000..b80151f --- /dev/null +++ b/internal/cli/list.go @@ -0,0 +1,95 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "os" + "sort" + "strings" +) + +func runList(args []string) error { + fs := flag.NewFlagSet("list", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + durability := fs.String("durability", "", "live, candidate, or durable") + status := fs.String("status", "active", "active, inactive, or any") + issue := fs.String("issue", "", "issue key") + pr := fs.String("pr", "", "pull request number") + areas := stringListFlag{} + paths := stringListFlag{} + fs.Var(&areas, "area", "area, repeatable") + fs.Var(&paths, "path", "repository path or glob, repeatable") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return errors.New("list accepts filters only") + } + if *durability != "" && *durability != DurabilityLive && *durability != DurabilityCandidate && *durability != DurabilityDurable { + return errors.New("--durability must be live, candidate, or durable") + } + if *status != "active" && *status != "inactive" && *status != "any" { + return errors.New("--status must be active, inactive, or any") + } + + events, err := loadEvents() + if err != nil { + return err + } + actionable := actionableRecordIDs(events) + filtered := make([]DirectionEvent, 0, len(events)) + for _, event := range events { + active := actionable[event.ID] + if (*status == "active" && !active) || (*status == "inactive" && active) { + continue + } + if *durability != "" && normalizeDurability(event.Durability) != *durability { + continue + } + if (*issue != "" || *pr != "" || len(areas) != 0 || len(paths) != 0) && + !reasonForScope(event, *issue, *pr, areas, paths).matched() { + continue + } + filtered = append(filtered, event) + } + sort.SliceStable(filtered, func(i, j int) bool { + if filtered[i].CreatedAt == filtered[j].CreatedAt { + return filtered[i].ID < filtered[j].ID + } + return filtered[i].CreatedAt < filtered[j].CreatedAt + }) + + setMachineResult(map[string]any{"records": filtered, "count": len(filtered), "status": *status}) + if len(filtered) == 0 { + fmt.Println("No matching direction.") + return nil + } + for _, event := range filtered { + fmt.Printf("%s %s/%s %s\n", event.ID, normalizeDurability(event.Durability), normalizeStatus(event.Status), event.Text) + if scope := compactScope(event.Scope); scope != "" { + fmt.Printf(" scope: %s\n", scope) + } + } + return nil +} + +func compactScope(scope EventScope) string { + parts := []string{} + if scope.Global { + parts = append(parts, "global") + } + if scope.Issue != "" { + parts = append(parts, "issue="+scope.Issue) + } + if scope.PR != "" { + parts = append(parts, "pr="+scope.PR) + } + if len(scope.Areas) != 0 { + parts = append(parts, "areas="+strings.Join(scope.Areas, ",")) + } + if len(scope.Paths) != 0 { + parts = append(parts, "paths="+strings.Join(scope.Paths, ",")) + } + return strings.Join(parts, " ") +} diff --git a/internal/cli/list_test.go b/internal/cli/list_test.go new file mode 100644 index 0000000..6528c61 --- /dev/null +++ b/internal/cli/list_test.go @@ -0,0 +1,88 @@ +package cli + +import ( + "os" + "testing" +) + +func TestListShowsActionableLiveDirection(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "thread-a", "--issue", "FAB-1") + mustRun(t, "note", "--live", "Remember the local TODO") + mustRun(t, "note", "--candidate", "A later candidate") + + output := captureStdout(t, func() { mustRun(t, "list", "--durability", "live") }) + assertContains(t, output, "Remember the local TODO") + assertNotContains(t, output, "A later candidate") +} + +func TestCleanLivePreviewsThenRemovesOnlySelectedLiveRecord(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "thread-a", "--issue", "TEST-1") + mustRun(t, "note", "--live", "Temporary test note") + liveID := recordIDAt(t, 0) + mustRun(t, "note", "--candidate", "Keep candidate") + + preview := captureStdout(t, func() { mustRun(t, "clean", "live", "--record", liveID) }) + assertContains(t, preview, "Preview only") + assertContains(t, captureStdout(t, func() { mustRun(t, "list", "--status", "any") }), "Temporary test note") + + mustRun(t, "clean", "live", "--record", liveID, "--apply") + all := captureStdout(t, func() { mustRun(t, "list", "--status", "any") }) + assertNotContains(t, all, "Temporary test note") + assertContains(t, all, "Keep candidate") + if _, err := os.Stat(ledgerEventsPath); err != nil { + t.Fatal(err) + } +} + +func TestThreadCanBeListedSelectedAndCleared(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "thread-a", "--issue", "FAB-1") + mustRun(t, "thread", "start", "--id", "thread-b", "--area", "runtime") + + listed := captureStdout(t, func() { mustRun(t, "thread", "list") }) + assertContains(t, listed, "thread-a") + assertContains(t, listed, "thread-b") + mustRun(t, "thread", "use", "thread-a") + if current, err := loadCurrentThreadID(); err != nil || current != "thread-a" { + t.Fatalf("current thread = %q, %v", current, err) + } + mustRun(t, "thread", "clear") + if current, err := loadCurrentThreadID(); err != nil || current != "" { + t.Fatalf("current thread after clear = %q, %v", current, err) + } +} + +func TestCleanRuntimeDoesNotDeleteDirectionFromThread(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "obsolete", "--issue", "TEST-1") + mustRun(t, "note", "--live", "Preserve this direction") + + mustRun(t, "clean", "runtime", "--thread", "obsolete", "--apply") + listed := captureStdout(t, func() { mustRun(t, "list", "--durability", "live") }) + assertContains(t, listed, "Preserve this direction") +} + +func TestListTreatsResolvedChallengeAsHistory(t *testing.T) { + chdirTemp(t) + mustRun(t, "init") + mustRun(t, "thread", "start", "--id", "thread-a", "--issue", "FAB-1") + mustRun(t, "note", "--candidate", "Original direction") + directionID := recordIDAt(t, 0) + mustRun(t, "challenge", "--direction", directionID, "--issue", "FAB-1", "Alternative") + challengeID := recordIDAt(t, 1) + mustRun(t, "challenge", "resolve", challengeID, "--rejected", "--reason", "Keep original") + + active := captureStdout(t, func() { mustRun(t, "list", "--status", "active") }) + assertNotContains(t, active, "Alternative") + assertNotContains(t, active, "Challenge "+challengeID) + + history := captureStdout(t, func() { mustRun(t, "list", "--status", "inactive") }) + assertContains(t, history, "Alternative") + assertContains(t, history, "Challenge "+challengeID) +} diff --git a/internal/cli/output.go b/internal/cli/output.go index 57d1e7e..a041bb7 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -199,8 +199,8 @@ func runCapabilities(args []string) error { "transports": []string{"local-git"}, "output_formats": []string{"human", "json"}, "operations": []string{ - "thread.start", "projection.create", "projection.acknowledge", - "record.create", "record.state_change", "relation.create", "graph.explain", + "thread.start", "thread.list", "thread.select", "thread.clear", "projection.create", "projection.acknowledge", + "record.create", "record.list", "record.state_change", "relation.create", "graph.explain", }, "explanation": map[string]bool{ "causal_relations": true, "availability_relations": true, diff --git a/internal/cli/storage.go b/internal/cli/storage.go index 9fc4525..84e7203 100644 --- a/internal/cli/storage.go +++ b/internal/cli/storage.go @@ -164,6 +164,38 @@ func filterActiveEvents(events []DirectionEvent) []DirectionEvent { return active } +// actionableRecordIDs excludes historical resolution records and challenges that +// already have a resolution. They remain queryable with `fabric list --status any`. +func actionableRecordIDs(events []DirectionEvent) map[string]bool { + resolved := map[string]bool{} + for _, event := range events { + if event.Kind == "challenge_resolution" && event.Challenges != "" { + resolved[event.Challenges] = true + } + } + active := map[string]bool{} + for _, event := range events { + if event.Kind == "challenge_resolution" || (event.Kind == "challenge" && resolved[event.ID]) { + continue + } + if isActiveEvent(event) { + active[event.ID] = true + } + } + return active +} + +func filterActionableEvents(events []DirectionEvent) []DirectionEvent { + ids := actionableRecordIDs(events) + active := make([]DirectionEvent, 0, len(ids)) + for _, event := range events { + if ids[event.ID] { + active = append(active, event) + } + } + return active +} + func normalizeDurability(d string) string { if d == "" { return DurabilityDurable From 8e5555af63fe99f20a64866f7ab3beb204fb266c Mon Sep 17 00:00:00 2001 From: Luis Dourado Date: Wed, 24 Jun 2026 21:08:30 -0300 Subject: [PATCH 4/5] feat(skills): add focused Fabric note recall --- .agents/skills/fabric-recall/SKILL.md | 14 ++++++++++ .../skills/fabric-recall/agents/openai.yaml | 6 +++++ AGENTS.md | 1 + internal/cli/agent_skills_test.go | 4 +++ internal/cli/app.go | 1 + internal/skills/templates.go | 27 +++++++++++++++++++ 6 files changed, 53 insertions(+) create mode 100644 .agents/skills/fabric-recall/SKILL.md create mode 100644 .agents/skills/fabric-recall/agents/openai.yaml diff --git a/.agents/skills/fabric-recall/SKILL.md b/.agents/skills/fabric-recall/SKILL.md new file mode 100644 index 0000000..14ff256 --- /dev/null +++ b/.agents/skills/fabric-recall/SKILL.md @@ -0,0 +1,14 @@ +--- +name: fabric-recall +description: Find current Fabric notes, tasks, decisions, and unresolved direction without starting or synchronizing a work session. Use when the user asks what they needed to do, asks to check Fabric notes or memory, or wants to inspect live, candidate, or durable direction. +--- + +# Recall Fabric Notes + +Answer repository-memory questions directly and keep the workflow read-only. + +1. Run `fabric list --durability live --json` when the user asks about local notes or tasks. +2. Use `--candidate` or `--durable` only through the equivalent `--durability` filter when the request names those classes. Add issue, PR, area, or path filters when known. +3. Summarize actionable records first. Use `fabric list --status inactive --json` only when the user asks about history, rejected paths, or resolved work. +4. Do not run status, start a thread, preflight, sync, or read generated checkpoint files merely to recall notes. +5. Explain when no matching direction exists. Never search arbitrary repository files as a substitute for Fabric notes unless the user also asks for a general codebase search. diff --git a/.agents/skills/fabric-recall/agents/openai.yaml b/.agents/skills/fabric-recall/agents/openai.yaml new file mode 100644 index 0000000..2b520a4 --- /dev/null +++ b/.agents/skills/fabric-recall/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Recall Fabric Notes" + short_description: "Find current repository notes and tasks" + default_prompt: "Use $fabric-recall to inspect current Fabric notes without starting a work session." +policy: + allow_implicit_invocation: true diff --git a/AGENTS.md b/AGENTS.md index 94cacaf..6a9b3ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ Fabric is the repository decision and provenance protocol for agent threads and Use the repository skills for detailed workflows: +- $fabric-recall - $fabric-session - $fabric-provenance - $fabric-record-direction diff --git a/internal/cli/agent_skills_test.go b/internal/cli/agent_skills_test.go index b792371..326e6bb 100644 --- a/internal/cli/agent_skills_test.go +++ b/internal/cli/agent_skills_test.go @@ -27,6 +27,7 @@ func TestGeneratedAgentSkillsHaveMetadataAndFocusedWorkflows(t *testing.T) { mustRun(t, "init") expected := []string{ + "fabric-recall", "fabric-session", "fabric-provenance", "fabric-record-direction", @@ -61,6 +62,9 @@ func TestGeneratedAgentSkillsHaveMetadataAndFocusedWorkflows(t *testing.T) { assertContains(t, session, "stale or unknown current-thread pointer alone is not a reason") assertContains(t, session, "Do not sync after every command") assertContains(t, mustRead(t, ".agents/skills/fabric-session/agents/openai.yaml"), "allow_implicit_invocation: false") + recall := mustRead(t, ".agents/skills/fabric-recall/SKILL.md") + assertContains(t, recall, "fabric list --durability live --json") + assertContains(t, recall, "Do not run status, start a thread, preflight, sync") provenance := mustRead(t, ".agents/skills/fabric-provenance/SKILL.md") assertContains(t, provenance, "context acknowledge") assertContains(t, provenance, "informed_by") diff --git a/internal/cli/app.go b/internal/cli/app.go index ac83dd3..0323289 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -348,6 +348,7 @@ func ensureSkillLink(source, destination string) error { func agentSkillNames() []string { return []string{ + "fabric-recall", "fabric-session", "fabric-provenance", "fabric-record-direction", diff --git a/internal/skills/templates.go b/internal/skills/templates.go index a3b77ad..c9ef28a 100644 --- a/internal/skills/templates.go +++ b/internal/skills/templates.go @@ -9,6 +9,7 @@ type File struct { func Dirs() []string { return []string{ + "fabric-recall/agents", "fabric-session/agents", "fabric-provenance/agents", "fabric-record-direction/agents", @@ -21,6 +22,13 @@ func Dirs() []string { func Files() []File { return []File{ + {Path: "fabric-recall/SKILL.md", Content: fabricRecallSkill()}, + {Path: "fabric-recall/agents/openai.yaml", Content: skillOpenAIYAML( + "Recall Fabric Notes", + "Find current repository notes and tasks", + "Use $fabric-recall to inspect current Fabric notes without starting a work session.", + true, + )}, {Path: "fabric-session/SKILL.md", Content: fabricSessionSkill()}, {Path: "fabric-session/agents/openai.yaml", Content: skillOpenAIYAML( "Fabric Session", @@ -101,6 +109,24 @@ Keep user updates proportional to the task; do not narrate routine Fabric plumbi ` } +func fabricRecallSkill() string { + return `--- +name: fabric-recall +description: Find current Fabric notes, tasks, decisions, and unresolved direction without starting or synchronizing a work session. Use when the user asks what they needed to do, asks to check Fabric notes or memory, or wants to inspect live, candidate, or durable direction. +--- + +# Recall Fabric Notes + +Answer repository-memory questions directly and keep the workflow read-only. + +1. Run ` + "`fabric list --durability live --json`" + ` when the user asks about local notes or tasks. +2. Use ` + "`--candidate`" + ` or ` + "`--durable`" + ` only through the equivalent ` + "`--durability`" + ` filter when the request names those classes. Add issue, PR, area, or path filters when known. +3. Summarize actionable records first. Use ` + "`fabric list --status inactive --json`" + ` only when the user asks about history, rejected paths, or resolved work. +4. Do not run status, start a thread, preflight, sync, or read generated checkpoint files merely to recall notes. +5. Explain when no matching direction exists. Never search arbitrary repository files as a substitute for Fabric notes unless the user also asks for a general codebase search. +` +} + func fabricProvenanceSkill() string { return `--- name: fabric-provenance @@ -286,6 +312,7 @@ Fabric is the repository decision and provenance protocol for agent threads and Use the repository skills for detailed workflows: +- $fabric-recall - $fabric-session - $fabric-provenance - $fabric-record-direction From 9c0fd4880c187db2d608f6c2b2bb16335e002031 Mon Sep 17 00:00:00 2001 From: Luis Dourado Date: Wed, 24 Jun 2026 21:09:05 -0300 Subject: [PATCH 5/5] chore(ledger): remove dogfood test direction --- ..._019efb8f-23e4-745a-a3c0-cfaa4a5f41c1.json | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 .fabric/ledger/events/evt_019efb8f-23e4-745a-a3c0-cfaa4a5f41c1.json diff --git a/.fabric/ledger/events/evt_019efb8f-23e4-745a-a3c0-cfaa4a5f41c1.json b/.fabric/ledger/events/evt_019efb8f-23e4-745a-a3c0-cfaa4a5f41c1.json deleted file mode 100644 index a11efe7..0000000 --- a/.fabric/ledger/events/evt_019efb8f-23e4-745a-a3c0-cfaa4a5f41c1.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "schema_version": "fabric/1.0", - "event_id": "evt_019efb8f-23e4-745a-a3c0-cfaa4a5f41c1", - "event_type": "record.created", - "occurred_at": "2026-06-24T18:35:22.596762-03:00", - "actor": { - "kind": "reviewer" - }, - "trust": { - "level": "reviewer_confirmed", - "basis": "pr_ingest" - }, - "payload": { - "record": { - "record_id": "rec_019efb8f-23e4-7fc7-92ba-bf5ee913ef5e", - "kind": "review_direction", - "created_at": "2026-06-24T17:01:48-03:00", - "scope": { - "repo": "fabric", - "issue": "FAB-3", - "pr": "1", - "areas": [ - "review-ingest" - ] - }, - "source": { - "type": "pr_ingest", - "pr": "1" - }, - "text": "Do not ingest every review comment as direction.", - "confidence": "reviewer_confirmed", - "ttl": "until_pr_closed", - "status": "active", - "durability": "durable", - "review_type": "rejection", - "reason": "The ledger should stay useful and sparse.", - "rejected_paths": [ - "Treating all review comments as candidate project direction", - "Replaying raw review threads into continuation context" - ], - "preferred_paths": [ - "Ingest only feedback that changes what another agent should do next", - "Keep nits/checklist items live or ignored" - ], - "evidence": [ - { - "type": "note", - "text": "simulated review feedback during V3 dogfood" - } - ], - "lifecycle_reason": "Reusable review-ingest product direction", - "reviewed_at": "2026-06-24T17:16:47-03:00" - } - } -}