From 173d254b8bad199d73a4cb66c09cd6b50f53c3f0 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Sun, 19 Jul 2026 19:16:36 +0200 Subject: [PATCH 1/5] sdd: wip start 20260719-190740-d-tac-ptn (Christopher) SDD-Mutation: wip-start-20260719-191635-christopher --- .sdd/graph/wip/20260719-191635-christopher.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .sdd/graph/wip/20260719-191635-christopher.md diff --git a/.sdd/graph/wip/20260719-191635-christopher.md b/.sdd/graph/wip/20260719-191635-christopher.md new file mode 100644 index 00000000..66be02f6 --- /dev/null +++ b/.sdd/graph/wip/20260719-191635-christopher.md @@ -0,0 +1,7 @@ +--- +entry: 20260719-190740-d-tac-ptn +participant: Christopher +exclusive: true +--- + +Implement derived base-fact index plan slice by slice From 60adf472f8345f0826aef2756c100d248fea0baf Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Sun, 19 Jul 2026 19:53:49 +0200 Subject: [PATCH 2/5] feat: add fact index metadata and derived read --- application/session_runtime_test.go | 20 +- application/workflow_registry.go | 9 +- application/write_api.go | 15 +- cmd/sdd/main.go | 33 +++ cmd/sdd/main_test.go | 21 ++ .../baseprocedures/baseprocedures_test.go | 23 ++ .../entries/20260703-094500-d-prc-cap.md | 13 +- internal/command/new_entry.go | 15 ++ internal/command/new_entry_test.go | 31 +++ internal/engine/engine_test.go | 58 +++++ internal/engine/schema.go | 25 +- internal/engine/schema_test.go | 42 +++ internal/engine/store.go | 8 + internal/engine/store_test.go | 32 +++ internal/engine/types.go | 35 +++ internal/engine/types_test.go | 15 ++ internal/model/entry.go | 4 + internal/model/fact_index.go | 139 ++++++++++ internal/model/fact_index_test.go | 241 ++++++++++++++++++ internal/model/graph.go | 7 + internal/model/topic_path.go | 19 +- internal/model/topic_path_test.go | 14 + internal/presenters/show.go | 38 +-- internal/presenters/show_test.go | 16 ++ 24 files changed, 842 insertions(+), 31 deletions(-) create mode 100644 internal/engine/store_test.go create mode 100644 internal/model/fact_index.go create mode 100644 internal/model/fact_index_test.go diff --git a/application/session_runtime_test.go b/application/session_runtime_test.go index fff8b401..5c513bcb 100644 --- a/application/session_runtime_test.go +++ b/application/session_runtime_test.go @@ -4,12 +4,15 @@ import ( "context" "encoding/json" "errors" + "os" + "path/filepath" "strings" "sync" "testing" "time" sdd "github.com/networkteam/sdd/application" + "github.com/networkteam/sdd/internal/model" localadapter "github.com/networkteam/sdd/local" ) @@ -390,7 +393,8 @@ func TestPreparedTransitionRejectsEmptyTargetAndStructuredDivergence(t *testing. } func TestCreateEntryResolvesConcreteDefaultWithoutCWDAndReleasesAroundLLM(t *testing.T) { - graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: t.TempDir()}) + graphDir := t.TempDir() + graph, err := localadapter.NewFilesystemGraphStore(localadapter.FilesystemGraphStoreOptions{Project: "example", GraphDir: graphDir}) if err != nil { t.Fatal(err) } @@ -432,7 +436,8 @@ func TestCreateEntryResolvesConcreteDefaultWithoutCWDAndReleasesAroundLLM(t *tes binding := openBinding(t, sessions, identity.Subject, "create-default") t.Chdir(t.TempDir()) created, err := application.CreateEntry(t.Context(), identity, "example", binding, sdd.EntryDraft{ - Kind: "gap", Layer: "tactical", Body: "Concrete target resolution must remain independent of the process working directory.", Confidence: "high", + Kind: "fact", Layer: "tactical", Body: "Concrete target resolution must remain independent of the process working directory.", Confidence: "high", + Topics: []string{"implementation/engine"}, Index: &sdd.FactIndex{Title: "Concrete target resolution", Topic: "implementation/engine"}, }) if err != nil || created.EntryID == "" { t.Fatalf("CreateEntry = %+v, %v", created, err) @@ -450,6 +455,17 @@ func TestCreateEntryResolvesConcreteDefaultWithoutCWDAndReleasesAroundLLM(t *tes if !sessionHasEvent(stored.Events, "mutation_intent", `"Target":{"project":"example","branch":"main"}`) { t.Fatalf("ordinary capture did not persist its concrete default target: %+v", stored.Events) } + rel, err := model.IDToRelPath(created.EntryID) + if err != nil { + t.Fatal(err) + } + written, err := os.ReadFile(filepath.Join(graphDir, rel)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(written), "index:\n title: Concrete target resolution\n topic: implementation/engine\n") { + t.Fatalf("written entry missing nested index:\n%s", written) + } } func TestPreparedRevalidationToleratesJSONRoundTripScalarTypes(t *testing.T) { diff --git a/application/workflow_registry.go b/application/workflow_registry.go index 5f67b6d7..f972bcb1 100644 --- a/application/workflow_registry.go +++ b/application/workflow_registry.go @@ -130,7 +130,7 @@ func (w *WorkflowSession) registerWorkflowWrites(registry *engine.Registry) erro if err := registry.RegisterCommand(engine.Command{ Doc: engine.FuncDoc{ Name: "newEntry", Doc: "Creates the entry from the capture state fields (pre-flight inside; staged attachments materialized from handles; a recorded override skips pre-flight, durably logged).", - Reads: []string{"body", "entryKind", "layer", "refs", "topics", "confidence", "intent", "attachments", "participants", "supersedes", "closes", "preflightOverride"}, Writes: []string{"entryId", "findings"}, + Reads: []string{"body", "entryKind", "layer", "refs", "topics", "index", "confidence", "intent", "attachments", "participants", "supersedes", "closes", "preflightOverride"}, Writes: []string{"entryId", "findings"}, }, MutatesGraph: true, Fn: w.runWorkflowNewEntry, @@ -241,6 +241,13 @@ func (w *WorkflowSession) runWorkflowNewEntry(ctx *engine.Context) error { } draft.Confidence, _ = workflowStoreString(ctx.Store, "confidence") draft.Intent, _ = workflowStoreString(ctx.Store, "intent") + if value, ok := ctx.Store.Get("index"); ok { + index, valid := value.(engine.FactIndex) + if !valid { + return fmt.Errorf("newEntry: index has invalid stored type") + } + draft.Index = &FactIndex{Title: index.Title, Topic: index.Topic} + } if override, ok := ctx.Store.Get("preflightOverride"); ok { draft.SkipPreflight, _ = override.(bool) } diff --git a/application/write_api.go b/application/write_api.go index ff34455a..a573abca 100644 --- a/application/write_api.go +++ b/application/write_api.go @@ -28,6 +28,11 @@ type EntryRef struct { Desc string } +type FactIndex struct { + Title string `json:"title"` + Topic string `json:"topic"` +} + type EntryDraft struct { Target MutationTarget Kind string @@ -40,6 +45,7 @@ type EntryDraft struct { Participants []string Confidence string Topics []string + Index *FactIndex AttachmentHandles []string SkipPreflight bool } @@ -122,10 +128,17 @@ func (a *Application) CreateEntry(ctx context.Context, identity RequestIdentity, } topics = append(topics, topic) } + var index *model.FactIndex + if draft.Index != nil { + index, err = model.NewFactIndex(draft.Index.Title, draft.Index.Topic) + if err != nil { + return CreateEntryResult{}, err + } + } entry := &model.Entry{ ID: id, Type: entryType, Kind: kind, Layer: layer, Intent: model.Intent(draft.Intent), Content: draft.Body, Participants: append([]string(nil), draft.Participants...), - Confidence: draft.Confidence, Topics: topics, Time: runtime.options.Now(), + Confidence: draft.Confidence, Topics: topics, Index: index, Time: runtime.options.Now(), } if len(entry.Participants) == 0 { if principal.Participant != "" { diff --git a/cmd/sdd/main.go b/cmd/sdd/main.go index 969a824c..adf8151b 100644 --- a/cmd/sdd/main.go +++ b/cmd/sdd/main.go @@ -554,6 +554,10 @@ func newCmd() *cli.Command { Name: "topics", Usage: "Comma-separated topic labels (any kind) — inline topic membership written as `topics:` strings", }, + &cli.StringFlag{ + Name: "index", + Usage: "Fact retrieval cue — JSON object {\"title\":\"\",\"topic\":\"\"}", + }, &cli.StringSliceFlag{ Name: "topic", Usage: "Topic cluster (kind: annotation only) — JSON object {\"label\":\"path\",\"members\":[\"id\",...]}; repeatable. A plain string in --topics works too for whole-refs membership.", @@ -700,6 +704,10 @@ func newCmd() *cli.Command { if err != nil { return err } + factIndex, err := parseFactIndexFlag(cmd.String("index")) + if err != nil { + return err + } refs, err := parseRefFlags(cmd.StringSlice("refs")) if err != nil { return err @@ -725,6 +733,7 @@ func newCmd() *cli.Command { Class: strings.TrimSpace(cmd.String("class")), Actor: strings.TrimSpace(cmd.String("actor")), TopicLabels: splitCSV(cmd.String("topics")), + Index: factIndex, AnnotationTopics: annotationTopics, FocusActors: focusActors, FocusWhen: focusWhen, @@ -2203,6 +2212,30 @@ func parseWhenFlag(s string) (*model.FocusWhen, error) { return &w, nil } +func parseFactIndexFlag(s string) (*model.FactIndex, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + decoder := json.NewDecoder(strings.NewReader(s)) + decoder.DisallowUnknownFields() + var raw struct { + Title string `json:"title"` + Topic string `json:"topic"` + } + if err := decoder.Decode(&raw); err != nil { + return nil, fmt.Errorf("--index: invalid JSON object: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return nil, fmt.Errorf("--index: expected one JSON object") + } + index, err := model.NewFactIndex(raw.Title, raw.Topic) + if err != nil { + return nil, fmt.Errorf("--index: %w", err) + } + return index, nil +} + // parseInvolvementFlags parses each --involvement JSON value into a // model.Involvement, preserving the actors-omitted vs explicit-empty // distinction (the latter declares pull-available involvement). diff --git a/cmd/sdd/main_test.go b/cmd/sdd/main_test.go index a0e3094b..8435b7f9 100644 --- a/cmd/sdd/main_test.go +++ b/cmd/sdd/main_test.go @@ -390,6 +390,27 @@ func TestParseRefFlags_UnknownKindRejectedAtCapture(t *testing.T) { } } +func TestParseFactIndexFlag(t *testing.T) { + index, err := parseFactIndexFlag(`{"title":"How to compose graph views","topic":"cli/view"}`) + if err != nil { + t.Fatal(err) + } + if index.Title != "How to compose graph views" || index.Topic.String() != "cli/view" { + t.Fatalf("index = %+v", index) + } + for _, input := range []string{ + `{"title":"Missing topic"}`, + `{"title":" ","topic":"cli/view"}`, + `{"title":"Title","topic":"cli view"}`, + `{"title":"Title","topic":"cli/view","extra":true}`, + `{"title":"Title","topic":"cli/view"} {}`, + } { + if _, err := parseFactIndexFlag(input); err == nil { + t.Errorf("parseFactIndexFlag(%q) accepted invalid object", input) + } + } +} + func TestParseRefFlags_EmptyInput(t *testing.T) { got, err := parseRefFlags(nil) if err != nil { diff --git a/internal/baseprocedures/baseprocedures_test.go b/internal/baseprocedures/baseprocedures_test.go index 85090300..eea82a66 100644 --- a/internal/baseprocedures/baseprocedures_test.go +++ b/internal/baseprocedures/baseprocedures_test.go @@ -110,3 +110,26 @@ func TestEntries_EmbeddedSetLoads(t *testing.T) { t.Errorf("embedded set must ship the capture procedure, got canonicals %v", canonicals) } } + +func TestCaptureCarriesFactIndexThroughPlaybackAndWrite(t *testing.T) { + entries, err := Entries() + if err != nil { + t.Fatal(err) + } + var capture string + for _, entry := range entries { + if entry.Canonical == "capture" { + capture = entry.Content + } + } + for _, want := range []string{ + "{{if .index}}- index:", + "title: {{.index.Title}}", + "topic: {{.index.Topic}}", + "optionally enrolls a `fact` in the retrieval index", + } { + if !strings.Contains(capture, want) { + t.Errorf("capture procedure missing %q", want) + } + } +} diff --git a/internal/baseprocedures/entries/20260703-094500-d-prc-cap.md b/internal/baseprocedures/entries/20260703-094500-d-prc-cap.md index 91d6a7c9..71f8daa5 100644 --- a/internal/baseprocedures/entries/20260703-094500-d-prc-cap.md +++ b/internal/baseprocedures/entries/20260703-094500-d-prc-cap.md @@ -23,6 +23,7 @@ state: layer: {type: layer, desc: strategic | conceptual | tactical | operational | process} refs: {type: list, desc: "each {id, kind, desc?}; kind chosen from what the body asserts"} topics: {type: list