diff --git a/.context/DECISIONS.md b/.context/DECISIONS.md index cabfa0932..22c51450f 100644 --- a/.context/DECISIONS.md +++ b/.context/DECISIONS.md @@ -3,6 +3,7 @@ | Date | Decision | |----|--------| +| 2026-05-30 | Name the add JSON-ingest flag --json-file, not --json | | 2026-05-28 | ctxctl PATH-installed alongside ctx for clean roots and one binary across worktrees | | 2026-05-28 | Memory pressure detection uses OS-native signals (macOS pressure level + Linux PSI), not occupancy | | 2026-05-27 | ctxctl is a separate Go module at tools/ctxctl (own go.mod), not cmd/ctxctl in the same module | @@ -157,6 +158,20 @@ For significant decisions: --> +## [2026-05-30-114429] Name the add JSON-ingest flag --json-file, not --json + +**Status**: Accepted + +**Context**: The CLI-FIX spec specified the literal flag --json , but --json is already a bool output-format flag across the CLI (ctx status/drift/doctor/bootstrap --json all mean 'emit machine-readable output'). + +**Decision**: Name the add JSON-ingest flag --json-file, not --json + +**Rationale**: Overloading --json as a string input-path on the add commands would break that cross-command convention and confuse muscle memory. --json-file is unambiguous, parallels the existing --file/-f source flag, and leaves -j free. Pushed back on the spec's literal wording rather than satisfice. + +**Consequence**: The add commands intentionally diverge from the spec's literal --json; the spec was updated to reflect --json-file. Any future JSON-input flag elsewhere should follow the --json-file naming, reserving --json for bool output. + +--- + ## [2026-05-28-201000] ctxctl PATH-installed alongside ctx for clean roots and one binary across worktrees **Status**: Accepted diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md index 51be7b3fe..8028aead6 100644 --- a/.context/LEARNINGS.md +++ b/.context/LEARNINGS.md @@ -17,6 +17,7 @@ DO NOT UPDATE FOR: | Date | Learning | |----|--------| +| 2026-05-30 | New exported types must live in types.go or TestTypeFileConvention fails | | 2026-05-28 | ctx kb: single topic-enumeration site; life-stage count is consumer-side | | 2026-05-28 | Swap occupancy is not memory pressure — use the kernel's derivative | | 2026-05-28 | A non-root Go module nested under the main module's path CAN import its internal/ packages | @@ -166,6 +167,16 @@ DO NOT UPDATE FOR: --- +## [2026-05-30-114436] New exported types must live in types.go or TestTypeFileConvention fails + +**Context**: Defined Payload and Provenance structs alongside the Load/OverlayFlags funcs in a new payload.go; make test failed in internal/audit on TestTypeFileConvention with '2 NEW type definitions outside types.go'. + +**Lesson**: The audit permits type definitions outside types.go only when the file is a 'pure type impl file' (only type defs + their methods, no standalone funcs) or the package is on the exempt list. A file that mixes struct definitions with standalone functions is a violation. + +**Application**: When adding a new package that has both types and functions, put the type definitions in a dedicated types.go from the start; methods (with receivers) may live beside the behavior. Run 'go test ./internal/audit/ -run TestTypeFileConvention' to check. + +--- + ## [2026-05-28-215214] ctx kb: single topic-enumeration site; life-stage count is consumer-side **Context**: kb reindex blanked the CTX:KB:TOPICS block for grouped kbs (things-wtf-dr regrouped 49 topics into folders); the task speculated a sibling life-stage topic-count glob was also affected. diff --git a/.context/TASKS.md b/.context/TASKS.md index 574497f5b..510e38bb0 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -68,7 +68,7 @@ These have priority because other knowledge ingestion projects depend on them. creation is unaffected — only reindex/enumeration lags. -- [ ] Add `--json ` to `ctx decision/learning/task add` (and `convention` +- [x] Add `--json ` to `ctx decision/learning/task add` (and `convention` if it gains structured fields) for ingesting a JSON payload that populates the typed fields directly. - Driver: this session hit a class of denial we worked around but should fix @@ -127,7 +127,7 @@ These have priority because other knowledge ingestion projects depend on them. 96765858 #branch:feat/pad-undo-snapshot #commit:b9ce72e8 #added: 2026-05-27-183909 -- [ ] Realign the installed plugin's hooks.json with the cwd-anchored binary — +- [x] Realign the installed plugin's hooks.json with the cwd-anchored binary — the LIVE fix for the every-prompt help-dump pollution. - Problem: the cwd-anchored migration (commit fc7db228, spec @@ -223,7 +223,7 @@ These have priority because other knowledge ingestion projects depend on them. exit-0-on-unknown behavior — not wired into hooks.json so out of scope here; capture as its own task if it ever gets hook-wired. -- [ ] Generalize the unknown-subcommand guard beyond `ctx system` (deferred from +- [x] Generalize the unknown-subcommand guard beyond `ctx system` (deferred from the #5 work above). `ctx hook` and any future `parent.Cmd` group still print help + exit 0 on an unknown subcommand — the same latent pollution #5 fixed for `ctx system`. Low priority while no other group is wired into hooks.json; the @@ -231,6 +231,19 @@ These have priority because other knowledge ingestion projects depend on them. + `ctx agent` today. If a `ctx hook ` ever gets hook-wired, either extend the guard's coverage or fold a reusable opt-in into `parent.Cmd` (an optional unknown-subcommand handler groups opt into). #priority:low #added:2026-05-28 + DONE 2026-05-30 (branch feat/add-json-file-ingest, session 53db2521). + Rationale refined: the real justification is not the every-prompt amplification + (unique to hooks.json-wired groups) but making CLI drift LOUD — `ctx hook` is + consumed by name from skills/loops (`ctx hook notify|event|pause|...`), and a + drifted verb silently returns help+exit-0 (agent misreads; for `notify` the + human is never told). Lifted the handler from `system/core/unknown` into a + neutral, parameterized `internal/cli/unknown` (Config + HandlerFor); `system` + and `hook` both opt in via `c.RunE = unknown.HandlerFor(...)`. `ctx hook` is + user-facing (not Hidden) and previously rode the no-RunE PreRunE exemption, so + it needed AnnotationSkipInit to stay reachable without an initialized + context/git (bootstrap regression test added). Did NOT fold into `parent.Cmd` + (would widen every group's deps). Skill/loop `ctx hook ` build-time guard + left out of scope. Spec: specs/unknown-subcommand-relay-generalization.md. ## Important @@ -267,7 +280,7 @@ Important things that agent (or human) yeeted to the future. mentioning domains that don't match their callers. Start with `write/**`, extend to all `internal/`. Spec: `specs/docstring-cross-reference-audit.md` #priority:medium #added:2026-03-17 -- [ ] Split internal/assets/embed_test.go — tests that call read/ packages +- [x] Split internal/assets/embed_test.go — tests that call read/ packages must move to their respective read/ package to avoid import cycles #added:2026-03-18-192914 diff --git a/.context/scratchpad-handoff.md b/.context/scratchpad-handoff.md deleted file mode 100644 index 5789fee84..000000000 --- a/.context/scratchpad-handoff.md +++ /dev/null @@ -1,47 +0,0 @@ -# Session Handoff — 2026-03-28 (session 3) - -## What shipped (not yet committed) - -Everything from session 2 plus: - -6. **JMC.9**: Line-width audit — 1078 violations found, 1005 fixed - (93.2%). 73 remain (DescKey constants, JSONL test fixtures, - regexp patterns, HTML URLs). -7. **EH.0**: Created `internal/log/warn.go` with `Warn()` and - swappable `Sink` (tests use io.Discard). -8. **EH.1**: Full catalog of 117 production + 346 test discards. -9. **EH.2**: Fixed 12 `_ = os.WriteFile` / `f.Write` discards. -10. **EH.3**: Fixed 18 `defer { _ = f.Close() }` discards. -11. **EH.4**: Fixed 17 `os.Remove/Rename/MkdirAll` discards + 1 - `filepath.Walk`. - -## Production code remaining discards (5, all acceptable) - -1. `RegisterFlagCompletionFunc` — Cobra shell completion, non-critical -2. `load.Do("")` — graceful degradation by design -3. `json.Unmarshal` — best-effort parse, uses defaults -4. `filepath.Glob` — nil on error, handled by caller -5. `mcpIO.WriteJSON` — MCP notification, fire-and-forget - -Plus 59 `_, _ = fmt.Fprintf(...)` to stdout/stderr/strings.Builder — -acceptable per Go convention (write to terminal/builder can't fail -meaningfully). - -## Test code (EH not yet applied to tests — 346 discards) - -The user did not request test fixes yet. Catalog is in the EH.1 -section of TASKS.md. Main categories: -- 218 `_ = os.Chdir` in cleanup — fix with `t.Fatal` -- 45 `os.Remove/RemoveAll` — fix with `t.Log` -- 34 `os.WriteFile` — fix with `t.Fatal` - -## Build status - -- `make build` — clean -- `make lint` (golangci-lint) — 0 issues -- `go test ./...` — all pass, 0 failures -- gofmt — clean - -## NOT YET COMMITTED - -All changes from sessions 2+3 are unstaged. Very large diff. diff --git a/docs/cli/context.md b/docs/cli/context.md index 9a43eff50..1e324ddc2 100644 --- a/docs/cli/context.md +++ b/docs/cli/context.md @@ -46,6 +46,7 @@ required-flag rules surface as command errors): | `--lesson` | `-l` | Key insight (required for learnings) | | `--application` | `-a` | How to apply going forward (required for learnings) | | `--file` | `-f` | Read content from file instead of argument | +| `--json-file ` | | Read a JSON payload that populates the typed fields directly (supersedes the content flags) | **Examples**: @@ -72,6 +73,19 @@ ctx learning add "Vitest mocks must be hoisted" \ # Add to specific section ctx convention add "Use kebab-case for filenames" --section "Naming" + +# Ingest a JSON payload (keeps flag-value content off the command line, +# so a value containing a permissions-denied substring still persists) +cat > /tmp/decision.json <<'EOF' +{ + "title": "Install ctx into the system PATH", + "context": "agents invoke ctx by bare name", + "rationale": "the binary belongs at /usr/local/bin so it is on PATH", + "consequence": "ctx resolves from any working directory", + "provenance": {"session_id": "abc12345", "branch": "main", "commit": "68fbc00a"} +} +EOF +ctx decision add --json-file /tmp/decision.json ``` --- diff --git a/internal/assets/claude/skills/ctx-decision-add/SKILL.md b/internal/assets/claude/skills/ctx-decision-add/SKILL.md index 0934c36a8..2a57a363d 100644 --- a/internal/assets/claude/skills/ctx-decision-add/SKILL.md +++ b/internal/assets/claude/skills/ctx-decision-add/SKILL.md @@ -94,6 +94,26 @@ ctx decision add "Use PostgreSQL for primary database" \ --consequence "Single database handles transactions and search. Team needs PostgreSQL-specific training." ``` +**When a flag value would be denied:** if a `--rationale`/`--context`/ +`--consequence` value contains a substring that trips a `permissions.deny` +rule on the literal command string (e.g. a path like ` /usr/local/bin`), +move the fields into a JSON file and pass `--json-file` instead — the +values never appear on the command line. The schema gates (placeholder +rejection, required fields, index maintenance) still apply. + +```bash +cat > /tmp/decision.json <<'EOF' +{ + "title": "Install ctx into the system PATH", + "context": "agents invoke ctx by bare name", + "rationale": "the binary belongs at /usr/local/bin so it is on PATH", + "consequence": "ctx resolves from any working directory", + "provenance": {"session_id": "abc12345", "branch": "main", "commit": "68fbc00a"} +} +EOF +ctx decision add --json-file /tmp/decision.json +``` + ## Authority boundary (vs other skills) This skill records architectural decisions — moments where a diff --git a/internal/assets/claude/skills/ctx-learning-add/SKILL.md b/internal/assets/claude/skills/ctx-learning-add/SKILL.md index ba30d7167..099e7c454 100644 --- a/internal/assets/claude/skills/ctx-learning-add/SKILL.md +++ b/internal/assets/claude/skills/ctx-learning-add/SKILL.md @@ -79,6 +79,25 @@ ctx learning add "ctx init overwrites user content without guard" \ --application "Skip existing files by default, only overwrite with --force" ``` +**When a flag value would be denied:** if a `--context`/`--lesson`/ +`--application` value contains a substring that trips a `permissions.deny` +rule on the literal command string (e.g. a path like ` /usr/local/bin`), +put the fields in a JSON file and pass `--json-file` instead — the values +never reach the command line, and the schema gates still apply: + +```bash +cat > /tmp/learning.json <<'EOF' +{ + "title": "Hooks run in a subprocess", + "context": "env vars set in a hook did not persist to the session", + "lesson": "hook stdout is the only channel back to the agent", + "application": "relay via stdout, never the environment", + "provenance": {"session_id": "abc12345", "branch": "main", "commit": "68fbc00a"} +} +EOF +ctx learning add --json-file /tmp/learning.json +``` + ## Authority boundary (vs other skills) This skill records principle-level lessons discovered through real diff --git a/internal/assets/claude/skills/ctx-task-add/SKILL.md b/internal/assets/claude/skills/ctx-task-add/SKILL.md index 7c464284e..bd2d8395b 100644 --- a/internal/assets/claude/skills/ctx-task-add/SKILL.md +++ b/internal/assets/claude/skills/ctx-task-add/SKILL.md @@ -77,6 +77,15 @@ ctx task add "Add topic-based navigation to blog when post count reaches 15+" \ --priority low ``` +**JSON payload (when content would trip a `permissions.deny` rule):** pass +`--json-file ` instead of the positional content + flags. The +`title` (plus an optional `body`, space-joined) becomes the task text; +`priority`, `section`, and a `provenance` envelope map to the flags: + +```bash +ctx task add --json-file /tmp/task.json # {"title","body","priority","section","provenance"} +``` + **Bad examples (too shallow):** ```bash ctx task add "Fix bug" # What bug? Where? diff --git a/internal/assets/commands/flags.yaml b/internal/assets/commands/flags.yaml index 2b60901a9..b49e3cba4 100644 --- a/internal/assets/commands/flags.yaml +++ b/internal/assets/commands/flags.yaml @@ -20,6 +20,8 @@ add.context: short: 'Context for decisions: what prompted this decision (required for decisions)' add.file: short: Read content from file instead of argument +add.json-file: + short: 'Read a JSON payload that populates typed fields directly (supersedes content flags)' add.lesson: short: 'Lesson for learnings: the key insight (required for learnings)' add.priority: diff --git a/internal/assets/commands/text/errors.yaml b/internal/assets/commands/text/errors.yaml index 88d77dca5..83c3e147b 100644 --- a/internal/assets/commands/text/errors.yaml +++ b/internal/assets/commands/text/errors.yaml @@ -5,6 +5,8 @@ err.add.file-not-found: short: "context file %s not found. Run 'ctx init' first" err.add.index-update: short: 'failed to update index in %s: %w' +err.add.json-parse: + short: 'failed to parse JSON payload %s: %w' err.add.missing-fields: short: '%s missing required fields: %s' err.add.no-content: diff --git a/internal/assets/commands/text/hooks.yaml b/internal/assets/commands/text/hooks.yaml index 4a4b7c819..dfa60d5f0 100644 --- a/internal/assets/commands/text/hooks.yaml +++ b/internal/assets/commands/text/hooks.yaml @@ -575,3 +575,14 @@ system-unknown.body: A Claude Code hook (hooks.json) is calling a ctx command this binary no longer ships — a version skew between the installed plugin and the on-PATH ctx binary. Align the plugin and binary to the same release, or fix the hook command. system-unknown.relay-message: short: 'ctx system: unknown subcommand "%s" (likely plugin/binary version skew)' +hook-unknown.relay-prefix: + short: 'IMPORTANT: Relay this notice to the user VERBATIM before answering their question.' +hook-unknown.box-title: + short: Unknown Hook Subcommand +hook-unknown.body: + short: |- + ctx hook: unknown subcommand "%s". + + A skill, loop script, or hook is calling a ctx hook command this binary no longer ships — likely CLI drift between the caller and the on-PATH ctx binary. Update the caller, or align the binary to the release the caller expects. +hook-unknown.relay-message: + short: 'ctx hook: unknown subcommand "%s" (likely CLI drift)' diff --git a/internal/assets/embed_test.go b/internal/assets/embed_test.go index e1b5ef8d3..a61a3d534 100644 --- a/internal/assets/embed_test.go +++ b/internal/assets/embed_test.go @@ -6,466 +6,24 @@ package assets -import ( - "encoding/json" - "path" - "strings" - "testing" - - "github.com/ActiveMemory/ctx/internal/config/asset" - "github.com/ActiveMemory/ctx/internal/config/file" - - "gopkg.in/yaml.v3" -) - -// TestDescKeysResolve lives in read/desc/desc_test.go where it can -// call lookup.Init() without an import cycle. - -// TestDefaultPermissions lives in read/lookup/perm_test.go where it can -// call Init() without an import cycle. - -func TestGetTemplate(t *testing.T) { - tests := []struct { - name string - template string - wantContain string - wantErr bool - }{ - {"CONSTITUTION.md exists", "CONSTITUTION.md", "Constitution", false}, - {"TASKS.md exists", "TASKS.md", "Tasks", false}, - {"DECISIONS.md exists", "DECISIONS.md", "Decisions", false}, - {"LEARNINGS.md exists", "LEARNINGS.md", "Learnings", false}, - {"CONVENTIONS.md exists", "CONVENTIONS.md", "Conventions", false}, - {"ARCHITECTURE.md exists", "ARCHITECTURE.md", "Architecture", false}, - {"AGENT_PLAYBOOK.md exists", "AGENT_PLAYBOOK.md", "Agent Playbook", false}, - {"AGENT_PLAYBOOK_GATE.md exists", "AGENT_PLAYBOOK_GATE.md", "Agent Playbook (Gate)", false}, - {"GLOSSARY.md exists", "GLOSSARY.md", "Glossary", false}, - {"nonexistent template returns error", "NONEXISTENT.md", "", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - content, err := FS.ReadFile(path.Join(asset.DirContext, tt.template)) - if tt.wantErr { - if err == nil { - t.Errorf("expected error for %q, got nil", tt.template) - } - return - } - if err != nil { - t.Errorf("unexpected error for %q: %v", tt.template, err) - return - } - if !strings.Contains(string(content), tt.wantContain) { - t.Errorf("content of %q does not contain %q", tt.template, tt.wantContain) - } - }) - } -} - -func TestListTemplates(t *testing.T) { - entries, err := FS.ReadDir(asset.DirContext) - if err != nil { - t.Fatalf("ReadDir() unexpected error: %v", err) - } - if len(entries) == 0 { - t.Error("ReadDir() returned empty list") - } - - templateSet := make(map[string]bool) - for _, e := range entries { - templateSet[e.Name()] = true - } - - required := []string{ - "CONSTITUTION.md", "TASKS.md", - "DECISIONS.md", "LEARNINGS.md", - } - for _, req := range required { - if !templateSet[req] { - t.Errorf("missing required template: %s", req) - } - } - for _, ex := range []string{"CLAUDE.md", "Makefile.ctx"} { - if templateSet[ex] { - t.Errorf("should not contain project-root file: %s", ex) - } - } -} - -func TestClaudeMd(t *testing.T) { - content, err := FS.ReadFile(asset.PathCLAUDEMd) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(string(content), "Context") { - t.Error("CLAUDE.md does not contain 'Context'") - } -} - -func TestProjectFile(t *testing.T) { - tests := []struct { - name string - file string - wantContain string - wantErr bool - }{ - {"Makefile.ctx exists", "Makefile.ctx", "ctx", false}, - {"nonexistent returns error", "NONEXISTENT.md", "", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - content, err := FS.ReadFile(path.Join(asset.DirProject, tt.file)) - if tt.wantErr { - if err == nil { - t.Errorf("expected error for %q", tt.file) - } - return - } - if err != nil { - t.Errorf("unexpected error for %q: %v", tt.file, err) - return - } - if !strings.Contains(string(content), tt.wantContain) { - t.Errorf("content of %q does not contain %q", tt.file, tt.wantContain) - } - }) - } -} - -func TestListSkills(t *testing.T) { - entries, err := FS.ReadDir(asset.DirClaudeSkills) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(entries) == 0 { - t.Error("returned empty list") - } - - skillSet := make(map[string]bool) - for _, e := range entries { - if e.IsDir() { - skillSet[e.Name()] = true - } - } - expected := []string{ - "ctx-code-review", "ctx-status", - "ctx-history", "ctx-brainstorm", - } - for _, exp := range expected { - if !skillSet[exp] { - t.Errorf("missing expected skill: %s", exp) - } - } -} - -func TestSkillContent(t *testing.T) { - content, err := FS.ReadFile(path.Join( - asset.DirClaudeSkills, - "ctx-history", - asset.FileSKILLMd, - )) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(string(content), "history") { - t.Error("ctx-history SKILL.md does not contain 'history'") - } - if !strings.HasPrefix(string(content), "---") { - t.Error("ctx-history SKILL.md missing frontmatter") - } -} - -func TestSkillReference(t *testing.T) { - refPath := path.Join( - asset.DirClaudeSkills, "ctx-skill-audit", - asset.DirReferences, - "anthropic-best-practices.md", - ) - content, err := FS.ReadFile(refPath) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !strings.Contains(string(content), "Anthropic") { - t.Error("anthropic-best-practices.md does not contain 'Anthropic'") - } -} - -func TestListSkillReferences(t *testing.T) { - refDir := path.Join( - asset.DirClaudeSkills, - "ctx-skill-audit", - asset.DirReferences, - ) - entries, err := FS.ReadDir(refDir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(entries) == 0 { - t.Error("returned empty list") - } - - found := false - for _, e := range entries { - if e.Name() == "anthropic-best-practices.md" { - found = true - break - } - } - if !found { - t.Error("missing anthropic-best-practices.md") - } -} - -func TestListSkillReferencesNonexistent(t *testing.T) { - noRefDir := path.Join( - asset.DirClaudeSkills, - "ctx-status", - asset.DirReferences, - ) - _, err := FS.ReadDir(noRefDir) - if err == nil { - t.Error("expected error for skill without references") - } -} - -func TestWhyDoc(t *testing.T) { - tests := []struct { - name string - doc string - wantContain string - wantErr bool - }{ - {"manifesto exists", "manifesto", "Manifesto", false}, - {"about exists", "about", "ctx", false}, - {"design-invariants exists", "design-invariants", "Invariants", false}, - {"nonexistent returns error", "nonexistent", "", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - content, err := FS.ReadFile(path.Join(asset.DirWhy, tt.doc+file.ExtMarkdown)) - if tt.wantErr { - if err == nil { - t.Errorf("expected error for %q", tt.doc) - } - return - } - if err != nil { - t.Errorf("unexpected error for %q: %v", tt.doc, err) - return - } - if !strings.Contains(string(content), tt.wantContain) { - t.Errorf("content of %q does not contain %q", tt.doc, tt.wantContain) - } - }) - } -} - -func TestListWhyDocs(t *testing.T) { - entries, err := FS.ReadDir(asset.DirWhy) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - expected := []string{"about", "design-invariants", "manifesto"} - docSet := make(map[string]bool) - for _, e := range entries { - name := e.Name() - if strings.HasSuffix(name, file.ExtMarkdown) { - docSet[strings.TrimSuffix(name, file.ExtMarkdown)] = true - } - } - - for _, exp := range expected { - if !docSet[exp] { - t.Errorf("missing expected doc: %s", exp) - } - } -} - -func TestPluginVersion(t *testing.T) { - data, err := FS.ReadFile(asset.PathPluginJSON) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var manifest map[string]json.RawMessage - if unmarshalErr := json.Unmarshal(data, &manifest); unmarshalErr != nil { - t.Fatalf("parse error: %v", unmarshalErr) - } - raw, ok := manifest[asset.JSONKeyVersion] - if !ok { - t.Fatal("plugin.json missing 'version' key") - } - var ver string - if parseErr := json.Unmarshal(raw, &ver); parseErr != nil { - t.Fatalf("version parse error: %v", parseErr) - } - if ver == "" { - t.Error("version is empty") - } - if !strings.Contains(ver, ".") { - t.Errorf("version = %q, expected semver format", ver) - } -} - -func TestSchema(t *testing.T) { - data, err := FS.ReadFile(asset.PathCtxrcSchema) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - content := string(data) - if !strings.Contains(content, "$schema") { - t.Error("does not contain $schema") - } - if !strings.Contains(content, "ctx.ist") { - t.Error("does not contain ctx.ist $id") - } -} - -func TestSchemaCoversCtxRC(t *testing.T) { - // Parse the schema to get its property keys. - schemaData, readErr := FS.ReadFile(asset.PathCtxrcSchema) - if readErr != nil { - t.Fatalf("read schema: %v", readErr) - } - var schema struct { - Properties map[string]json.RawMessage `json:"properties"` - } - if parseErr := json.Unmarshal(schemaData, &schema); parseErr != nil { - t.Fatalf("parse schema: %v", parseErr) - } - - // Parse a zero-value CtxRC to YAML then back to a map to get yaml tags. - // We marshal a struct with all fields set to get every key emitted. - type ctxRC struct { - Profile string `yaml:"profile"` - TokenBudget int `yaml:"token_budget"` - PriorityOrder []int `yaml:"priority_order"` - AutoArchive bool `yaml:"auto_archive"` - ArchiveAfterDays int `yaml:"archive_after_days"` - ScratchpadEncrypt *bool `yaml:"scratchpad_encrypt"` - EntryCountLearnings int `yaml:"entry_count_learnings"` - EntryCountDecisions int `yaml:"entry_count_decisions"` - ConventionLineCount int `yaml:"convention_line_count"` - InjectionTokenWarn int `yaml:"injection_token_warn"` - ContextWindow int `yaml:"context_window"` - BillingTokenWarn int `yaml:"billing_token_warn"` - EventLog bool `yaml:"event_log"` - KeyRotationDays int `yaml:"key_rotation_days"` - TaskNudgeInterval int `yaml:"task_nudge_interval"` - KeyPathOverride string `yaml:"key_path"` - StaleAgeDays int `yaml:"stale_age_days"` - SessionPrefixes []int `yaml:"session_prefixes"` - CompanionCheck *bool `yaml:"companion_check"` - ClassifyRules []int `yaml:"classify_rules"` - SpecSignalWords []int `yaml:"spec_signal_words"` - SpecNudgeMinLen int `yaml:"spec_nudge_min_len"` - Placeholders []int `yaml:"placeholders"` - Notify *int `yaml:"notify"` - FreshnessFiles []int `yaml:"freshness_files"` - Tool string `yaml:"tool"` - Steering *int `yaml:"steering"` - Hooks *int `yaml:"hooks"` - ProvenanceRequired *int `yaml:"provenance_required"` - } - yamlBytes, marshalErr := yaml.Marshal(ctxRC{}) - if marshalErr != nil { - t.Fatalf("marshal: %v", marshalErr) - } - var structKeys map[string]any - if unmarshalErr := yaml.Unmarshal(yamlBytes, &structKeys); unmarshalErr != nil { - t.Fatalf("unmarshal: %v", unmarshalErr) - } - - // Every struct field must appear in schema. - for key := range structKeys { - if _, ok := schema.Properties[key]; !ok { - t.Errorf("CtxRC field %q has no schema property", key) - } - } - // Every schema property must appear in struct. - for key := range schema.Properties { - if _, ok := structKeys[key]; !ok { - t.Errorf("schema property %q has no CtxRC field", key) - } - } -} - -func TestHookMessageRegistry(t *testing.T) { - data, readErr := FS.ReadFile(asset.PathMessageRegistry) - if readErr != nil { - t.Fatalf("unexpected error: %v", readErr) - } - if len(data) == 0 { - t.Fatal("returned empty data") - } - - var entries []map[string]any - if parseErr := yaml.Unmarshal(data, &entries); parseErr != nil { - t.Fatalf("invalid YAML: %v", parseErr) - } - for i, entry := range entries { - if _, ok := entry["hook"]; !ok { - t.Errorf("entry %d missing 'hook' key", i) - } - if _, ok := entry["variant"]; !ok { - t.Errorf("entry %d missing 'variant' key", i) - } - } -} - -func TestListHookMessages(t *testing.T) { - entries, listErr := FS.ReadDir(asset.DirHooksMessages) - if listErr != nil { - t.Fatalf("unexpected error: %v", listErr) - } - if len(entries) == 0 { - t.Fatal("returned empty list") - } - - hookSet := make(map[string]bool) - for _, h := range entries { - if h.IsDir() { - hookSet[h.Name()] = true - } - } - wantHooks := []string{ - "qa-reminder", - "check-context-size", - "block-non-path-ctx", - } - for _, exp := range wantHooks { - if !hookSet[exp] { - t.Errorf("missing expected hook: %s", exp) - } - } -} - -func TestHookMessage_ReadVariant(t *testing.T) { - gatePath := path.Join( - asset.DirHooksMessages, - "qa-reminder", "gate.txt", - ) - content, readErr := FS.ReadFile(gatePath) - if readErr != nil { - t.Fatalf("unexpected error: %v", readErr) - } - if len(content) == 0 { - t.Fatal("returned empty content") - } -} - -func TestMakefileCtx(t *testing.T) { - content, readErr := FS.ReadFile(asset.PathMakefileCtx) - if readErr != nil { - t.Fatalf("unexpected error: %v", readErr) - } - if len(content) == 0 { - t.Fatal("returned empty content") - } - if !strings.Contains(string(content), "ctx") { - t.Error("content does not contain 'ctx'") - } -} +// Embedded-asset tests are split by concern across this package: +// +// - templates_test.go — context-file templates (TestGetTemplate, +// TestListTemplates) +// - project_test.go — project-root files (TestClaudeMd, +// TestProjectFile, TestMakefileCtx) +// - skills_test.go — Claude skills + references (TestListSkills, +// TestSkillContent, TestSkillReference, …) +// - why_test.go — why-docs (TestWhyDoc, TestListWhyDocs) +// - plugin_test.go — plugin manifest (TestPluginVersion) +// - schema_test.go — .ctxrc JSON schema (TestSchema, +// TestSchemaCoversCtxRC) +// - hooks_test.go — hook message registry (TestHookMessageRegistry, +// TestListHookMessages, TestHookMessage_ReadVariant) +// +// Two tests that exercise read/ subpackages live in those packages +// instead, to avoid an assets → read/X → assets import cycle: +// +// - TestDescKeysResolve → read/desc/desc_test.go (needs lookup.Init) +// - default-permissions → read/lookup/perm_test.go (TestPermAllowListDefault, +// TestPermDenyListDefault — need Init) diff --git a/internal/assets/hooks_test.go b/internal/assets/hooks_test.go new file mode 100644 index 000000000..b28cc8780 --- /dev/null +++ b/internal/assets/hooks_test.go @@ -0,0 +1,80 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package assets + +import ( + "path" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/ActiveMemory/ctx/internal/config/asset" +) + +func TestHookMessageRegistry(t *testing.T) { + data, readErr := FS.ReadFile(asset.PathMessageRegistry) + if readErr != nil { + t.Fatalf("unexpected error: %v", readErr) + } + if len(data) == 0 { + t.Fatal("returned empty data") + } + + var entries []map[string]any + if parseErr := yaml.Unmarshal(data, &entries); parseErr != nil { + t.Fatalf("invalid YAML: %v", parseErr) + } + for i, entry := range entries { + if _, ok := entry["hook"]; !ok { + t.Errorf("entry %d missing 'hook' key", i) + } + if _, ok := entry["variant"]; !ok { + t.Errorf("entry %d missing 'variant' key", i) + } + } +} + +func TestListHookMessages(t *testing.T) { + entries, listErr := FS.ReadDir(asset.DirHooksMessages) + if listErr != nil { + t.Fatalf("unexpected error: %v", listErr) + } + if len(entries) == 0 { + t.Fatal("returned empty list") + } + + hookSet := make(map[string]bool) + for _, h := range entries { + if h.IsDir() { + hookSet[h.Name()] = true + } + } + wantHooks := []string{ + "qa-reminder", + "check-context-size", + "block-non-path-ctx", + } + for _, exp := range wantHooks { + if !hookSet[exp] { + t.Errorf("missing expected hook: %s", exp) + } + } +} + +func TestHookMessage_ReadVariant(t *testing.T) { + gatePath := path.Join( + asset.DirHooksMessages, + "qa-reminder", "gate.txt", + ) + content, readErr := FS.ReadFile(gatePath) + if readErr != nil { + t.Fatalf("unexpected error: %v", readErr) + } + if len(content) == 0 { + t.Fatal("returned empty content") + } +} diff --git a/internal/assets/plugin_test.go b/internal/assets/plugin_test.go new file mode 100644 index 000000000..2da8ad170 --- /dev/null +++ b/internal/assets/plugin_test.go @@ -0,0 +1,40 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package assets + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/config/asset" +) + +func TestPluginVersion(t *testing.T) { + data, err := FS.ReadFile(asset.PathPluginJSON) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var manifest map[string]json.RawMessage + if unmarshalErr := json.Unmarshal(data, &manifest); unmarshalErr != nil { + t.Fatalf("parse error: %v", unmarshalErr) + } + raw, ok := manifest[asset.JSONKeyVersion] + if !ok { + t.Fatal("plugin.json missing 'version' key") + } + var ver string + if parseErr := json.Unmarshal(raw, &ver); parseErr != nil { + t.Fatalf("version parse error: %v", parseErr) + } + if ver == "" { + t.Error("version is empty") + } + if !strings.Contains(ver, ".") { + t.Errorf("version = %q, expected semver format", ver) + } +} diff --git a/internal/assets/project_test.go b/internal/assets/project_test.go new file mode 100644 index 000000000..e4b0e80c5 --- /dev/null +++ b/internal/assets/project_test.go @@ -0,0 +1,69 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package assets + +import ( + "path" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/config/asset" +) + +func TestClaudeMd(t *testing.T) { + content, err := FS.ReadFile(asset.PathCLAUDEMd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(content), "Context") { + t.Error("CLAUDE.md does not contain 'Context'") + } +} + +func TestProjectFile(t *testing.T) { + tests := []struct { + name string + file string + wantContain string + wantErr bool + }{ + {"Makefile.ctx exists", "Makefile.ctx", "ctx", false}, + {"nonexistent returns error", "NONEXISTENT.md", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content, err := FS.ReadFile(path.Join(asset.DirProject, tt.file)) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for %q", tt.file) + } + return + } + if err != nil { + t.Errorf("unexpected error for %q: %v", tt.file, err) + return + } + if !strings.Contains(string(content), tt.wantContain) { + t.Errorf("content of %q does not contain %q", tt.file, tt.wantContain) + } + }) + } +} + +func TestMakefileCtx(t *testing.T) { + content, readErr := FS.ReadFile(asset.PathMakefileCtx) + if readErr != nil { + t.Fatalf("unexpected error: %v", readErr) + } + if len(content) == 0 { + t.Fatal("returned empty content") + } + if !strings.Contains(string(content), "ctx") { + t.Error("content does not contain 'ctx'") + } +} diff --git a/internal/assets/schema_test.go b/internal/assets/schema_test.go new file mode 100644 index 000000000..76431d494 --- /dev/null +++ b/internal/assets/schema_test.go @@ -0,0 +1,100 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package assets + +import ( + "encoding/json" + "strings" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/ActiveMemory/ctx/internal/config/asset" +) + +func TestSchema(t *testing.T) { + data, err := FS.ReadFile(asset.PathCtxrcSchema) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + content := string(data) + if !strings.Contains(content, "$schema") { + t.Error("does not contain $schema") + } + if !strings.Contains(content, "ctx.ist") { + t.Error("does not contain ctx.ist $id") + } +} + +func TestSchemaCoversCtxRC(t *testing.T) { + // Parse the schema to get its property keys. + schemaData, readErr := FS.ReadFile(asset.PathCtxrcSchema) + if readErr != nil { + t.Fatalf("read schema: %v", readErr) + } + var schema struct { + Properties map[string]json.RawMessage `json:"properties"` + } + if parseErr := json.Unmarshal(schemaData, &schema); parseErr != nil { + t.Fatalf("parse schema: %v", parseErr) + } + + // Parse a zero-value CtxRC to YAML then back to a map to get yaml tags. + // We marshal a struct with all fields set to get every key emitted. + type ctxRC struct { + Profile string `yaml:"profile"` + TokenBudget int `yaml:"token_budget"` + PriorityOrder []int `yaml:"priority_order"` + AutoArchive bool `yaml:"auto_archive"` + ArchiveAfterDays int `yaml:"archive_after_days"` + ScratchpadEncrypt *bool `yaml:"scratchpad_encrypt"` + EntryCountLearnings int `yaml:"entry_count_learnings"` + EntryCountDecisions int `yaml:"entry_count_decisions"` + ConventionLineCount int `yaml:"convention_line_count"` + InjectionTokenWarn int `yaml:"injection_token_warn"` + ContextWindow int `yaml:"context_window"` + BillingTokenWarn int `yaml:"billing_token_warn"` + EventLog bool `yaml:"event_log"` + KeyRotationDays int `yaml:"key_rotation_days"` + TaskNudgeInterval int `yaml:"task_nudge_interval"` + KeyPathOverride string `yaml:"key_path"` + StaleAgeDays int `yaml:"stale_age_days"` + SessionPrefixes []int `yaml:"session_prefixes"` + CompanionCheck *bool `yaml:"companion_check"` + ClassifyRules []int `yaml:"classify_rules"` + SpecSignalWords []int `yaml:"spec_signal_words"` + SpecNudgeMinLen int `yaml:"spec_nudge_min_len"` + Placeholders []int `yaml:"placeholders"` + Notify *int `yaml:"notify"` + FreshnessFiles []int `yaml:"freshness_files"` + Tool string `yaml:"tool"` + Steering *int `yaml:"steering"` + Hooks *int `yaml:"hooks"` + ProvenanceRequired *int `yaml:"provenance_required"` + } + yamlBytes, marshalErr := yaml.Marshal(ctxRC{}) + if marshalErr != nil { + t.Fatalf("marshal: %v", marshalErr) + } + var structKeys map[string]any + if unmarshalErr := yaml.Unmarshal(yamlBytes, &structKeys); unmarshalErr != nil { + t.Fatalf("unmarshal: %v", unmarshalErr) + } + + // Every struct field must appear in schema. + for key := range structKeys { + if _, ok := schema.Properties[key]; !ok { + t.Errorf("CtxRC field %q has no schema property", key) + } + } + // Every schema property must appear in struct. + for key := range schema.Properties { + if _, ok := structKeys[key]; !ok { + t.Errorf("schema property %q has no CtxRC field", key) + } + } +} diff --git a/internal/assets/skills_test.go b/internal/assets/skills_test.go new file mode 100644 index 000000000..66b9d92d5 --- /dev/null +++ b/internal/assets/skills_test.go @@ -0,0 +1,111 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package assets + +import ( + "path" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/config/asset" +) + +func TestListSkills(t *testing.T) { + entries, err := FS.ReadDir(asset.DirClaudeSkills) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) == 0 { + t.Error("returned empty list") + } + + skillSet := make(map[string]bool) + for _, e := range entries { + if e.IsDir() { + skillSet[e.Name()] = true + } + } + expected := []string{ + "ctx-code-review", "ctx-status", + "ctx-history", "ctx-brainstorm", + } + for _, exp := range expected { + if !skillSet[exp] { + t.Errorf("missing expected skill: %s", exp) + } + } +} + +func TestSkillContent(t *testing.T) { + content, err := FS.ReadFile(path.Join( + asset.DirClaudeSkills, + "ctx-history", + asset.FileSKILLMd, + )) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(content), "history") { + t.Error("ctx-history SKILL.md does not contain 'history'") + } + if !strings.HasPrefix(string(content), "---") { + t.Error("ctx-history SKILL.md missing frontmatter") + } +} + +func TestSkillReference(t *testing.T) { + refPath := path.Join( + asset.DirClaudeSkills, "ctx-skill-audit", + asset.DirReferences, + "anthropic-best-practices.md", + ) + content, err := FS.ReadFile(refPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(content), "Anthropic") { + t.Error("anthropic-best-practices.md does not contain 'Anthropic'") + } +} + +func TestListSkillReferences(t *testing.T) { + refDir := path.Join( + asset.DirClaudeSkills, + "ctx-skill-audit", + asset.DirReferences, + ) + entries, err := FS.ReadDir(refDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) == 0 { + t.Error("returned empty list") + } + + found := false + for _, e := range entries { + if e.Name() == "anthropic-best-practices.md" { + found = true + break + } + } + if !found { + t.Error("missing anthropic-best-practices.md") + } +} + +func TestListSkillReferencesNonexistent(t *testing.T) { + noRefDir := path.Join( + asset.DirClaudeSkills, + "ctx-status", + asset.DirReferences, + ) + _, err := FS.ReadDir(noRefDir) + if err == nil { + t.Error("expected error for skill without references") + } +} diff --git a/internal/assets/templates_test.go b/internal/assets/templates_test.go new file mode 100644 index 000000000..6782488db --- /dev/null +++ b/internal/assets/templates_test.go @@ -0,0 +1,84 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package assets + +import ( + "path" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/config/asset" +) + +func TestGetTemplate(t *testing.T) { + tests := []struct { + name string + template string + wantContain string + wantErr bool + }{ + {"CONSTITUTION.md exists", "CONSTITUTION.md", "Constitution", false}, + {"TASKS.md exists", "TASKS.md", "Tasks", false}, + {"DECISIONS.md exists", "DECISIONS.md", "Decisions", false}, + {"LEARNINGS.md exists", "LEARNINGS.md", "Learnings", false}, + {"CONVENTIONS.md exists", "CONVENTIONS.md", "Conventions", false}, + {"ARCHITECTURE.md exists", "ARCHITECTURE.md", "Architecture", false}, + {"AGENT_PLAYBOOK.md exists", "AGENT_PLAYBOOK.md", "Agent Playbook", false}, + {"AGENT_PLAYBOOK_GATE.md exists", "AGENT_PLAYBOOK_GATE.md", "Agent Playbook (Gate)", false}, + {"GLOSSARY.md exists", "GLOSSARY.md", "Glossary", false}, + {"nonexistent template returns error", "NONEXISTENT.md", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content, err := FS.ReadFile(path.Join(asset.DirContext, tt.template)) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for %q, got nil", tt.template) + } + return + } + if err != nil { + t.Errorf("unexpected error for %q: %v", tt.template, err) + return + } + if !strings.Contains(string(content), tt.wantContain) { + t.Errorf("content of %q does not contain %q", tt.template, tt.wantContain) + } + }) + } +} + +func TestListTemplates(t *testing.T) { + entries, err := FS.ReadDir(asset.DirContext) + if err != nil { + t.Fatalf("ReadDir() unexpected error: %v", err) + } + if len(entries) == 0 { + t.Error("ReadDir() returned empty list") + } + + templateSet := make(map[string]bool) + for _, e := range entries { + templateSet[e.Name()] = true + } + + required := []string{ + "CONSTITUTION.md", "TASKS.md", + "DECISIONS.md", "LEARNINGS.md", + } + for _, req := range required { + if !templateSet[req] { + t.Errorf("missing required template: %s", req) + } + } + for _, ex := range []string{"CLAUDE.md", "Makefile.ctx"} { + if templateSet[ex] { + t.Errorf("should not contain project-root file: %s", ex) + } + } +} diff --git a/internal/assets/why_test.go b/internal/assets/why_test.go new file mode 100644 index 000000000..271004ad0 --- /dev/null +++ b/internal/assets/why_test.go @@ -0,0 +1,71 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package assets + +import ( + "path" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/config/asset" + "github.com/ActiveMemory/ctx/internal/config/file" +) + +func TestWhyDoc(t *testing.T) { + tests := []struct { + name string + doc string + wantContain string + wantErr bool + }{ + {"manifesto exists", "manifesto", "Manifesto", false}, + {"about exists", "about", "ctx", false}, + {"design-invariants exists", "design-invariants", "Invariants", false}, + {"nonexistent returns error", "nonexistent", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content, err := FS.ReadFile(path.Join(asset.DirWhy, tt.doc+file.ExtMarkdown)) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for %q", tt.doc) + } + return + } + if err != nil { + t.Errorf("unexpected error for %q: %v", tt.doc, err) + return + } + if !strings.Contains(string(content), tt.wantContain) { + t.Errorf("content of %q does not contain %q", tt.doc, tt.wantContain) + } + }) + } +} + +func TestListWhyDocs(t *testing.T) { + entries, err := FS.ReadDir(asset.DirWhy) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := []string{"about", "design-invariants", "manifesto"} + docSet := make(map[string]bool) + for _, e := range entries { + name := e.Name() + if strings.HasSuffix(name, file.ExtMarkdown) { + docSet[strings.TrimSuffix(name, file.ExtMarkdown)] = true + } + } + + for _, exp := range expected { + if !docSet[exp] { + t.Errorf("missing expected doc: %s", exp) + } + } +} diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index d29049ca8..b66bb9ee9 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -360,6 +360,55 @@ func TestInitGuard_AllowsInitializedCommand(t *testing.T) { } } +// TestHookUnknownVerbReachesRelayWithoutContext is the regression guard +// for the AnnotationSkipInit on the `ctx hook` group. The group now has +// a RunE (the unknown-subcommand relay), which would otherwise make +// RootCmd's PersistentPreRunE demand an initialized context + git tree. +// In an uninitialized cwd, an unknown `ctx hook` verb must surface the +// unknown-subcommand error (naming the verb), not the not-initialized +// gate. Spec: specs/unknown-subcommand-relay-generalization.md. +func TestHookUnknownVerbReachesRelayWithoutContext(t *testing.T) { + t.Chdir(t.TempDir()) // empty cwd: no .context, no .git + rc.Reset() + t.Cleanup(rc.Reset) + + cmd := RootCmd() + Initialize(cmd) + cmd.SetOut(&discardWriter{}) + cmd.SetErr(&discardWriter{}) + cmd.SetArgs([]string{"hook", "no-such-verb"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("want non-nil error for an unknown `ctx hook` verb") + } + if !strings.Contains(err.Error(), "no-such-verb") { + t.Errorf("want the unknown-subcommand error naming the verb, got: %v", err) + } + if strings.Contains(err.Error(), "not initialized") { + t.Errorf("the init gate must not fire for the hook group; got: %v", err) + } +} + +// TestHookBareReachesHelpWithoutContext confirms a bare `ctx hook` +// still prints help and exits 0 in an uninitialized cwd (the friendly +// behavior the AnnotationSkipInit preserves). +func TestHookBareReachesHelpWithoutContext(t *testing.T) { + t.Chdir(t.TempDir()) + rc.Reset() + t.Cleanup(rc.Reset) + + cmd := RootCmd() + Initialize(cmd) + cmd.SetOut(&discardWriter{}) + cmd.SetErr(&discardWriter{}) + cmd.SetArgs([]string{"hook"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("bare `ctx hook` without context: want nil error, got %v", err) + } +} + func TestRootCmdToolFlag(t *testing.T) { cmd := RootCmd() diff --git a/internal/cli/add/core/build/build.go b/internal/cli/add/core/build/build.go index 137e92df5..4a402032d 100644 --- a/internal/cli/add/core/build/build.go +++ b/internal/cli/add/core/build/build.go @@ -35,6 +35,7 @@ func Cmd(noun, descKey, useStr string) *cobra.Command { priority string section string fromFile string + jsonFile string sessionID string branch string commit string @@ -59,6 +60,7 @@ func Cmd(noun, descKey, useStr string) *cobra.Command { Priority: priority, Section: section, FromFile: fromFile, + JSONFile: jsonFile, SessionID: sessionID, Branch: branch, Commit: commit, @@ -96,16 +98,17 @@ func Cmd(noun, descKey, useStr string) *cobra.Command { ) flagbind.BindStringFlags(c, []*string{ - &consequence, &sessionID, &branch, &commit, + &consequence, &sessionID, &branch, &commit, &jsonFile, }, []string{ cFlag.Consequence, cFlag.SessionID, - cFlag.Branch, cFlag.Commit, + cFlag.Branch, cFlag.Commit, cFlag.JSONFile, }, []string{ flag.DescKeyAddConsequence, flag.DescKeyAddSessionID, flag.DescKeyAddBranch, flag.DescKeyAddCommit, + flag.DescKeyAddJSONFile, }, ) flagbind.BoolFlag( diff --git a/internal/cli/add/core/extract/content.go b/internal/cli/add/core/extract/content.go index 1a74c7f47..e16a816cd 100644 --- a/internal/cli/add/core/extract/content.go +++ b/internal/cli/add/core/extract/content.go @@ -11,6 +11,7 @@ import ( "os" "strings" + "github.com/ActiveMemory/ctx/internal/cli/add/core/jsonpayload" "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/entity" errAdd "github.com/ActiveMemory/ctx/internal/err/add" @@ -21,18 +22,30 @@ import ( // Content retrieves content from various sources for adding entries. // // Content is extracted in priority order: -// 1. From the file specified by --file flag -// 2. From command line arguments (after the entry type) -// 3. From stdin (if piped) +// 1. From the JSON payload specified by --json-file flag +// 2. From the file specified by --file flag +// 3. From command line arguments (after the entry type) +// 4. From stdin (if piped) // // Parameters: // - args: Command arguments where args[1:] may contain inline content -// - flags: Configuration flags including FromFile path +// - flags: Configuration flags including JSONFile and FromFile paths // // Returns: // - string: Extracted and trimmed content // - error: Non-nil if no content source is available or reading fails func Content(args []string, flags entity.AddConfig) (string, error) { + if flags.JSONFile != "" { + payload, loadErr := jsonpayload.Load(flags.JSONFile) + if loadErr != nil { + return "", loadErr + } + if content := payload.Content(); content != "" { + return content, nil + } + // Empty title/body: fall through to the other sources. + } + if flags.FromFile != "" { // Read from the file fileContent, readErr := ctxIo.SafeReadUserFile(flags.FromFile) diff --git a/internal/cli/add/core/jsonpayload/doc.go b/internal/cli/add/core/jsonpayload/doc.go new file mode 100644 index 000000000..debab90ca --- /dev/null +++ b/internal/cli/add/core/jsonpayload/doc.go @@ -0,0 +1,43 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package jsonpayload decodes the --json-file argument for the add +// command and overlays its fields onto the noun's flags and content. +// +// # Why +// +// The canonical permissions.deny set matches on the literal Bash +// command string, including the content of --rationale/--context/ +// --consequence values. A JSON payload file keeps those values off the +// command line, so a legitimate value that happens to contain a denied +// substring no longer trips the deny rule. +// +// # Shape +// +// [Load] strictly decodes a single JSON object into a [Payload]. +// Unknown keys are an error so typos surface instead of silently +// dropping a field. Every key is optional; each add noun consumes only +// the fields relevant to it (extra keys decode without error and are +// ignored by that noun's formatter). Provenance may be supplied on the +// command line or folded into the "provenance" envelope. +// +// # Overlay +// +// The payload reaches the pipeline through two explicit touchpoints: +// +// 1. [OverlayFlags] runs in each noun's PreRunE. It writes the +// payload's non-empty typed fields (context, rationale, …, +// provenance) onto the cobra flags via flags.Set, so the +// decision/learning placeholder gate validates the effective +// values and run.Run sees them through the bound flag variables. +// JSON values supersede individually-supplied flags. +// 2. [Payload.Content] supplies the entry content. The extract +// subpackage calls it first, ahead of --file/args/stdin, when +// AddConfig.JSONFile is set. +// +// Both touchpoints load the (small) file independently; OverlayFlags +// handles the typed flags, extract handles the positional content. +package jsonpayload diff --git a/internal/cli/add/core/jsonpayload/payload.go b/internal/cli/add/core/jsonpayload/payload.go new file mode 100644 index 000000000..782857b71 --- /dev/null +++ b/internal/cli/add/core/jsonpayload/payload.go @@ -0,0 +1,128 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package jsonpayload + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/spf13/cobra" + + cFlag "github.com/ActiveMemory/ctx/internal/config/flag" + "github.com/ActiveMemory/ctx/internal/config/token" + errAdd "github.com/ActiveMemory/ctx/internal/err/add" + errFs "github.com/ActiveMemory/ctx/internal/err/fs" + ctxIo "github.com/ActiveMemory/ctx/internal/io" +) + +// Load reads and strictly decodes a JSON payload file. +// +// Decoding rejects unknown keys so that a misspelled field surfaces as +// an error instead of being silently dropped. +// +// Parameters: +// - path: Filesystem path to the JSON payload +// +// Returns: +// - Payload: Decoded payload +// - error: Non-nil if the file cannot be read or the JSON is invalid +func Load(path string) (Payload, error) { + var p Payload + + data, readErr := ctxIo.SafeReadUserFile(path) + if readErr != nil { + return p, errFs.FileRead(path, readErr) + } + + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if decErr := dec.Decode(&p); decErr != nil { + return p, errAdd.JSONParse(path, decErr) + } + return p, nil +} + +// Content returns the entry content derived from the payload. +// +// The content is the trimmed Title; for entries that carry a Body +// (tasks), a non-empty Body is space-joined onto the Title since the +// target file stores each entry on a single line. Returns an empty +// string when neither field is set, so callers can fall through to +// other content sources. +// +// Returns: +// - string: The space-joined content, or "" when empty +func (p Payload) Content() string { + var parts []string + if title := strings.TrimSpace(p.Title); title != "" { + parts = append(parts, title) + } + if body := strings.TrimSpace(p.Body); body != "" { + parts = append(parts, body) + } + return strings.Join(parts, token.Space) +} + +// OverlayFlags loads the --json-file payload (if any) and writes its +// non-empty typed fields onto the command's flags, so downstream +// validation and the bound flag variables see the effective values. +// +// JSON values supersede individually-supplied flags, per spec. Only +// flags that exist on the command and whose payload value is non-empty +// are set; absent payload fields leave any CLI-supplied flag untouched. +// A no-op (returns nil) when --json-file is unset. +// +// Wiring is intentionally explicit: each add noun calls this at the top +// of its PreRunE so the overlay is visible at the call site. +// +// Parameters: +// - cmd: The cobra command whose flags receive the overlay +// +// Returns: +// - error: Non-nil if the payload cannot be loaded or a flag set fails +func OverlayFlags(cmd *cobra.Command) error { + flags := cmd.Flags() + + path, getErr := flags.GetString(cFlag.JSONFile) + if getErr != nil { + return getErr + } + if path == "" { + return nil + } + + p, loadErr := Load(path) + if loadErr != nil { + return loadErr + } + + overlay := []struct { + name string + value string + }{ + {cFlag.Context, p.Context}, + {cFlag.Rationale, p.Rationale}, + {cFlag.Consequence, p.Consequence}, + {cFlag.Lesson, p.Lesson}, + {cFlag.Application, p.Application}, + {cFlag.Priority, p.Priority}, + {cFlag.Section, p.Section}, + {cFlag.SessionID, p.Provenance.SessionID}, + {cFlag.Branch, p.Provenance.Branch}, + {cFlag.Commit, p.Provenance.Commit}, + } + for _, f := range overlay { + if f.value == "" || flags.Lookup(f.name) == nil { + continue + } + if setErr := flags.Set(f.name, f.value); setErr != nil { + return setErr + } + } + return nil +} diff --git a/internal/cli/add/core/jsonpayload/payload_test.go b/internal/cli/add/core/jsonpayload/payload_test.go new file mode 100644 index 000000000..a0a2460ad --- /dev/null +++ b/internal/cli/add/core/jsonpayload/payload_test.go @@ -0,0 +1,183 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package jsonpayload + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + + cFlag "github.com/ActiveMemory/ctx/internal/config/flag" +) + +// writePayload writes content to a temp file and returns its path. +func writePayload(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "payload.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write payload: %v", err) + } + return path +} + +// TestLoadDecodesAllFields verifies a full payload, including the +// provenance envelope, decodes into the typed fields. +func TestLoadDecodesAllFields(t *testing.T) { + path := writePayload(t, `{ + "title": "Install into PATH", + "context": "the binary lives at /usr/local/bin/ctx", + "rationale": "system PATH install is the documented route", + "consequence": "users run ctx from anywhere", + "provenance": {"session_id": "abc12345", "branch": "main", "commit": "deadbeef"} + }`) + + p, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if p.Title != "Install into PATH" { + t.Errorf("title = %q", p.Title) + } + if p.Context != "the binary lives at /usr/local/bin/ctx" { + t.Errorf("context = %q", p.Context) + } + if p.Provenance.SessionID != "abc12345" { + t.Errorf("session_id = %q", p.Provenance.SessionID) + } + if p.Provenance.Commit != "deadbeef" { + t.Errorf("commit = %q", p.Provenance.Commit) + } +} + +// TestLoadRejectsUnknownField verifies strict decoding so a typo'd +// key is a hard error instead of a silently dropped field. +func TestLoadRejectsUnknownField(t *testing.T) { + path := writePayload(t, `{"title": "x", "rationail": "typo"}`) + if _, err := Load(path); err == nil { + t.Fatal("expected error on unknown field") + } +} + +// TestLoadMissingFile surfaces a read error rather than a zero value. +func TestLoadMissingFile(t *testing.T) { + if _, err := Load(filepath.Join(t.TempDir(), "absent.json")); err == nil { + t.Fatal("expected error for missing file") + } +} + +// TestContentTitleOnly returns the trimmed title. +func TestContentTitleOnly(t *testing.T) { + p := Payload{Title: " Use Postgres "} + if got := p.Content(); got != "Use Postgres" { + t.Errorf("Content() = %q, want %q", got, "Use Postgres") + } +} + +// TestContentTitleAndBody space-joins a task body onto the title. +func TestContentTitleAndBody(t *testing.T) { + p := Payload{Title: "Add flag", Body: "for JSON ingest"} + if got := p.Content(); got != "Add flag for JSON ingest" { + t.Errorf("Content() = %q", got) + } +} + +// TestContentEmpty returns empty so callers fall through. +func TestContentEmpty(t *testing.T) { + if got := (Payload{}).Content(); got != "" { + t.Errorf("Content() = %q, want empty", got) + } +} + +// newAddCmd builds a cobra command carrying the add string flags +// that OverlayFlags writes to. +func newAddCmd() *cobra.Command { + c := &cobra.Command{Use: "add"} + for _, name := range []string{ + cFlag.JSONFile, cFlag.Context, cFlag.Rationale, + cFlag.Consequence, cFlag.Lesson, cFlag.Application, + cFlag.Priority, cFlag.Section, + cFlag.SessionID, cFlag.Branch, cFlag.Commit, + } { + c.Flags().String(name, "", "") + } + return c +} + +// TestOverlayFlagsSupersedes verifies a JSON payload overrides the +// individually-supplied flags and folds in provenance. +func TestOverlayFlagsSupersedes(t *testing.T) { + path := writePayload(t, `{ + "context": "json context", + "rationale": "json rationale", + "consequence": "json consequence", + "provenance": {"session_id": "s1", "branch": "b1", "commit": "c1"} + }`) + + c := newAddCmd() + if err := c.Flags().Set(cFlag.JSONFile, path); err != nil { + t.Fatalf("set json-file: %v", err) + } + if err := c.Flags().Set(cFlag.Rationale, "cli rationale"); err != nil { + t.Fatalf("set rationale: %v", err) + } + + if err := OverlayFlags(c); err != nil { + t.Fatalf("OverlayFlags: %v", err) + } + + cases := map[string]string{ + cFlag.Context: "json context", + cFlag.Rationale: "json rationale", + cFlag.Consequence: "json consequence", + cFlag.SessionID: "s1", + cFlag.Branch: "b1", + cFlag.Commit: "c1", + } + for name, want := range cases { + got, _ := c.Flags().GetString(name) + if got != want { + t.Errorf("flag %s = %q, want %q", name, got, want) + } + } +} + +// TestOverlayFlagsNoFile is a no-op when --json-file is unset, leaving +// CLI-supplied values intact. +func TestOverlayFlagsNoFile(t *testing.T) { + c := newAddCmd() + if err := c.Flags().Set(cFlag.Rationale, "cli only"); err != nil { + t.Fatalf("set rationale: %v", err) + } + if err := OverlayFlags(c); err != nil { + t.Fatalf("OverlayFlags: %v", err) + } + if got, _ := c.Flags().GetString(cFlag.Rationale); got != "cli only" { + t.Errorf("rationale = %q, want %q", got, "cli only") + } +} + +// TestOverlayFlagsEmptyPayloadKeepsFlag verifies that an absent payload +// field leaves a CLI-supplied flag untouched. +func TestOverlayFlagsEmptyPayloadKeepsFlag(t *testing.T) { + path := writePayload(t, `{"context": "json context"}`) + + c := newAddCmd() + if err := c.Flags().Set(cFlag.JSONFile, path); err != nil { + t.Fatalf("set json-file: %v", err) + } + if err := c.Flags().Set(cFlag.Rationale, "cli rationale"); err != nil { + t.Fatalf("set rationale: %v", err) + } + if err := OverlayFlags(c); err != nil { + t.Fatalf("OverlayFlags: %v", err) + } + if got, _ := c.Flags().GetString(cFlag.Rationale); got != "cli rationale" { + t.Errorf("rationale = %q, want %q (unchanged)", got, "cli rationale") + } +} diff --git a/internal/cli/add/core/jsonpayload/testmain_test.go b/internal/cli/add/core/jsonpayload/testmain_test.go new file mode 100644 index 000000000..7f06c1c94 --- /dev/null +++ b/internal/cli/add/core/jsonpayload/testmain_test.go @@ -0,0 +1,22 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package jsonpayload + +import ( + "os" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" +) + +// TestMain initialises the embedded asset lookup so that the +// error helpers (errAdd.JSONParse, errFs.FileRead) render their +// parsed format strings rather than the empty default. +func TestMain(m *testing.M) { + lookup.Init() + os.Exit(m.Run()) +} diff --git a/internal/cli/add/core/jsonpayload/types.go b/internal/cli/add/core/jsonpayload/types.go new file mode 100644 index 000000000..ca3f9f461 --- /dev/null +++ b/internal/cli/add/core/jsonpayload/types.go @@ -0,0 +1,50 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package jsonpayload + +// Provenance is the optional commit-trail envelope inside a payload. +// +// Fields: +// - SessionID: AI session identifier +// - Branch: Git branch name +// - Commit: Git commit hash +type Provenance struct { + SessionID string `json:"session_id"` + Branch string `json:"branch"` + Commit string `json:"commit"` +} + +// Payload is the decoded shape of a --json-file argument. +// +// All fields are optional; each add noun consumes only the keys that +// are relevant to it. Extra-but-irrelevant keys (e.g. priority on a +// decision) decode without error and are simply ignored by that noun's +// formatter. A genuinely unknown key is a decode error (see [Load]). +// +// Fields: +// - Title: Entry content/heading +// - Body: Extra content appended to Title for tasks (single-line files) +// - Context: Context field for decisions/learnings +// - Rationale: Rationale field for decisions +// - Consequence: Consequence field for decisions +// - Lesson: Lesson field for learnings +// - Application: Application field for learnings +// - Priority: Priority label for tasks +// - Section: Target section for tasks +// - Provenance: Optional session/branch/commit envelope +type Payload struct { + Title string `json:"title"` + Body string `json:"body"` + Context string `json:"context"` + Rationale string `json:"rationale"` + Consequence string `json:"consequence"` + Lesson string `json:"lesson"` + Application string `json:"application"` + Priority string `json:"priority"` + Section string `json:"section"` + Provenance Provenance `json:"provenance"` +} diff --git a/internal/cli/convention/cmd/add/add_test.go b/internal/cli/convention/cmd/add/add_test.go index d5959467c..e934f6e67 100644 --- a/internal/cli/convention/cmd/add/add_test.go +++ b/internal/cli/convention/cmd/add/add_test.go @@ -8,6 +8,7 @@ package add import ( "os" + "path/filepath" "strings" "testing" @@ -52,3 +53,51 @@ func TestConventionAdd(t *testing.T) { t.Error("convention was not added to CONVENTIONS.md") } } + +// TestConventionAddFromJSONFile verifies --json-file supplies the +// convention's content via the title field (convention has no other +// structured fields). +func TestConventionAddFromJSONFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cli-convention-add-json-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + origDir, _ := os.Getwd() + if err = os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + testctx.Declare(t, tmpDir) + + initCmd := initialize.Cmd() + initCmd.SetArgs([]string{}) + if err = initCmd.Execute(); err != nil { + t.Fatalf("init failed: %v", err) + } + + payload := filepath.Join(tmpDir, "convention.json") + if err = os.WriteFile(payload, []byte( + `{"title": "Resolve binaries from /usr/local/bin on PATH"}`, + ), 0o600); err != nil { + t.Fatalf("write payload: %v", err) + } + + addCmd := Cmd() + addCmd.SetArgs([]string{"--json-file", payload}) + if err = addCmd.Execute(); err != nil { + t.Fatalf("ctx convention add --json-file failed: %v", err) + } + + content, err := os.ReadFile(".context/CONVENTIONS.md") + if err != nil { + t.Fatalf("failed to read CONVENTIONS.md: %v", err) + } + if !strings.Contains( + string(content), "Resolve binaries from /usr/local/bin on PATH", + ) { + t.Error("convention content from --json-file was not added") + } +} diff --git a/internal/cli/decision/cmd/add/add_test.go b/internal/cli/decision/cmd/add/add_test.go index 00c7ba2f3..cf9de2c5b 100644 --- a/internal/cli/decision/cmd/add/add_test.go +++ b/internal/cli/decision/cmd/add/add_test.go @@ -8,6 +8,7 @@ package add import ( "os" + "path/filepath" "strings" "testing" @@ -69,6 +70,111 @@ func TestDecisionAdd(t *testing.T) { } } +// TestDecisionAddFromJSONFile verifies that --json-file populates the +// typed fields and provenance from a JSON envelope, including a +// rationale value whose content would trip a literal command-string +// deny rule (the feature's driver). +func TestDecisionAddFromJSONFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cli-decision-add-json-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + origDir, _ := os.Getwd() + if err = os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + testctx.Declare(t, tmpDir) + + initCmd := initialize.Cmd() + initCmd.SetArgs([]string{}) + if err = initCmd.Execute(); err != nil { + t.Fatalf("init failed: %v", err) + } + + payload := filepath.Join(tmpDir, "decision.json") + if err = os.WriteFile(payload, []byte(`{ + "title": "Install ctx into the system PATH", + "context": "agents invoke ctx by bare name", + "rationale": "the binary belongs at /usr/local/bin so it is on PATH", + "consequence": "ctx resolves from any working directory", + "provenance": {"session_id": "json1234", "branch": "main", "commit": "abc123"} + }`), 0o600); err != nil { + t.Fatalf("write payload: %v", err) + } + + addCmd := Cmd() + addCmd.SetArgs([]string{"--json-file", payload}) + if err = addCmd.Execute(); err != nil { + t.Fatalf("ctx decision add --json-file failed: %v", err) + } + + content, err := os.ReadFile(".context/DECISIONS.md") + if err != nil { + t.Fatalf("failed to read DECISIONS.md: %v", err) + } + contentStr := string(content) + for _, want := range []string{ + "Install ctx into the system PATH", + "agents invoke ctx by bare name", + "the binary belongs at /usr/local/bin so it is on PATH", + "ctx resolves from any working directory", + } { + if !strings.Contains(contentStr, want) { + t.Errorf("expected %q in DECISIONS.md", want) + } + } +} + +// TestDecisionAddJSONFileRejectsPlaceholder verifies the placeholder +// gate also fires on JSON-supplied values, so --json-file is not a +// bypass for the schema checks. +func TestDecisionAddJSONFileRejectsPlaceholder(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cli-decision-add-json-ph-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + origDir, _ := os.Getwd() + if err = os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + testctx.Declare(t, tmpDir) + + initCmd := initialize.Cmd() + initCmd.SetArgs([]string{}) + if err = initCmd.Execute(); err != nil { + t.Fatalf("init failed: %v", err) + } + + payload := filepath.Join(tmpDir, "decision.json") + if err = os.WriteFile(payload, []byte(`{ + "title": "Decision body", + "context": "real context", + "rationale": "TBD", + "consequence": "real consequence", + "provenance": {"session_id": "json1234", "branch": "main", "commit": "abc123"} + }`), 0o600); err != nil { + t.Fatalf("write payload: %v", err) + } + + addCmd := Cmd() + addCmd.SetArgs([]string{"--json-file", payload}) + err = addCmd.Execute() + if err == nil { + t.Fatal("expected placeholder rejection for JSON rationale=TBD") + } + if !strings.Contains(err.Error(), "rationale") { + t.Errorf("error should name --rationale: %v", err) + } +} + // TestDecisionAddRequiresFlags verifies that omitting // required body flags produces an error. The placeholder // check fires first (PreRunE runs before cobra's required- diff --git a/internal/cli/decision/cmd/add/cmd.go b/internal/cli/decision/cmd/add/cmd.go index 1d52f50fb..e26d16170 100644 --- a/internal/cli/decision/cmd/add/cmd.go +++ b/internal/cli/decision/cmd/add/cmd.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/cli/add/core/build" + "github.com/ActiveMemory/ctx/internal/cli/add/core/jsonpayload" "github.com/ActiveMemory/ctx/internal/config/embed/cmd" "github.com/ActiveMemory/ctx/internal/config/entry" cFlag "github.com/ActiveMemory/ctx/internal/config/flag" @@ -30,6 +31,9 @@ import ( func Cmd() *cobra.Command { c := build.Cmd(entry.Decision, cmd.DescKeyDecisionAdd, cmd.UseDecisionAdd) c.PreRunE = func(cobraCmd *cobra.Command, _ []string) error { + if overlayErr := jsonpayload.OverlayFlags(cobraCmd); overlayErr != nil { + return overlayErr + } flags := cobraCmd.Flags() names := []string{ cFlag.Context, diff --git a/internal/cli/hook/hook.go b/internal/cli/hook/hook.go index 8e61440ed..584390c6a 100644 --- a/internal/cli/hook/hook.go +++ b/internal/cli/hook/hook.go @@ -15,6 +15,8 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/notify" "github.com/ActiveMemory/ctx/internal/cli/pause" "github.com/ActiveMemory/ctx/internal/cli/resume" + "github.com/ActiveMemory/ctx/internal/cli/unknown" + cliCfg "github.com/ActiveMemory/ctx/internal/config/cli" "github.com/ActiveMemory/ctx/internal/config/embed/cmd" ) @@ -23,6 +25,20 @@ import ( // Consolidates hook-related user-facing commands: message, // notify, pause, resume, and event. // +// An unknown `ctx hook ` fails loud (verbatim relay box + best- +// effort relay event + non-zero exit) instead of printing help at +// exit 0 — a silent failure for the skills and loop scripts that call +// `ctx hook` verbs by name when one drifts out of the binary. A bare +// `ctx hook` still prints help and exits 0. +// +// The AnnotationSkipInit is required: RootCmd's PersistentPreRunE +// exempts grouping commands that have no Run/RunE, but this group now +// has one. Without the annotation, bare `ctx hook` and an unknown verb +// would newly require an initialized context + git tree. The annotation +// is evaluated against the target command, so it exempts only the +// group-level invocation; the real subcommands keep their own +// preconditions. See specs/unknown-subcommand-relay-generalization.md. +// // Returns: // - *cobra.Command: Parent command with hook subcommands func Cmd() *cobra.Command { @@ -32,6 +48,10 @@ func Cmd() *cobra.Command { Short: short, Long: long, Example: desc.Example(cmd.DescKeyHook), + Annotations: map[string]string{ + cliCfg.AnnotationSkipInit: cliCfg.AnnotationTrue, + }, + RunE: unknown.HandlerFor(unknown.HookConfig), } c.AddCommand( event.Cmd(), diff --git a/internal/cli/hook/hook_test.go b/internal/cli/hook/hook_test.go new file mode 100644 index 000000000..5e7037a58 --- /dev/null +++ b/internal/cli/hook/hook_test.go @@ -0,0 +1,83 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package hook_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" + "github.com/ActiveMemory/ctx/internal/cli/hook" + "github.com/ActiveMemory/ctx/internal/config/cli" +) + +func TestMain(m *testing.M) { + lookup.Init() + m.Run() +} + +// newRoot wraps a command in a minimal parent so the wrapped group +// reports HasParent()==true — the condition under which cobra lets an +// unmatched subcommand reach the group's RunE (rather than raising +// "unknown command" as it does only for the root). +func newRoot(child *cobra.Command) (*cobra.Command, *bytes.Buffer) { + root := &cobra.Command{Use: "ctx"} + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.AddCommand(child) + return root, &out +} + +func TestHookUnknownSubcommandFailsLoud(t *testing.T) { + root, out := newRoot(hook.Cmd()) + root.SetArgs([]string{"hook", "no-such-verb"}) + + if err := root.Execute(); err == nil { + t.Fatal("want non-nil error for an unknown `ctx hook` subcommand") + } + got := out.String() + if !strings.Contains(got, "no-such-verb") { + t.Errorf("want a relay box naming the verb; got:\n%s", got) + } + if !strings.Contains(got, "Unknown Hook Subcommand") { + t.Errorf("want the hook-specific box title; got:\n%s", got) + } + // The whole point: no Long-help dump on the unknown path. + if strings.Contains(got, "Available Commands:") { + t.Errorf("unknown subcommand must not dump help; got:\n%s", got) + } +} + +func TestHookBareStillPrintsHelp(t *testing.T) { + root, out := newRoot(hook.Cmd()) + root.SetArgs([]string{"hook"}) + + if err := root.Execute(); err != nil { + t.Fatalf("bare `ctx hook`: want nil error (help), got %v", err) + } + if out.Len() == 0 { + t.Error("bare `ctx hook` should print help") + } +} + +// TestHookOptsIntoUnknownRelay guards that the hook group carries both +// the RunE (the relay) and the AnnotationSkipInit that keeps RootCmd's +// PersistentPreRunE from newly imposing context/git preconditions on a +// group that previously had no RunE. +func TestHookOptsIntoUnknownRelay(t *testing.T) { + c := hook.Cmd() + if c.RunE == nil { + t.Error("hook.Cmd() must set a RunE (the unknown-subcommand relay)") + } + if _, ok := c.Annotations[cli.AnnotationSkipInit]; !ok { + t.Error("hook.Cmd() must carry AnnotationSkipInit so the relay is reachable without an initialized context") + } +} diff --git a/internal/cli/learning/cmd/add/add_test.go b/internal/cli/learning/cmd/add/add_test.go index f81666fc8..b32ed1c4f 100644 --- a/internal/cli/learning/cmd/add/add_test.go +++ b/internal/cli/learning/cmd/add/add_test.go @@ -242,3 +242,60 @@ func TestLearningAddFromFile(t *testing.T) { t.Error("content from file was not added to LEARNINGS.md") } } + +// TestLearningAddFromJSONFile verifies --json-file populates the +// learning's typed fields (context/lesson/application) and provenance. +func TestLearningAddFromJSONFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cli-learning-add-json-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + origDir, _ := os.Getwd() + if err = os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + testctx.Declare(t, tmpDir) + + initCmd := initialize.Cmd() + initCmd.SetArgs([]string{}) + if err = initCmd.Execute(); err != nil { + t.Fatalf("init failed: %v", err) + } + + payload := filepath.Join(tmpDir, "learning.json") + if err = os.WriteFile(payload, []byte(`{ + "title": "Hooks run in a subprocess", + "context": "env vars set in a hook did not persist", + "lesson": "hook output is the only channel back to the session", + "application": "relay via stdout, not environment", + "provenance": {"session_id": "json1234", "branch": "main", "commit": "abc123"} + }`), 0o600); err != nil { + t.Fatalf("write payload: %v", err) + } + + addCmd := Cmd() + addCmd.SetArgs([]string{"--json-file", payload}) + if err = addCmd.Execute(); err != nil { + t.Fatalf("ctx learning add --json-file failed: %v", err) + } + + content, err := os.ReadFile(".context/LEARNINGS.md") + if err != nil { + t.Fatalf("failed to read LEARNINGS.md: %v", err) + } + contentStr := string(content) + for _, want := range []string{ + "Hooks run in a subprocess", + "env vars set in a hook did not persist", + "hook output is the only channel back to the session", + "relay via stdout, not environment", + } { + if !strings.Contains(contentStr, want) { + t.Errorf("expected %q in LEARNINGS.md", want) + } + } +} diff --git a/internal/cli/learning/cmd/add/cmd.go b/internal/cli/learning/cmd/add/cmd.go index 696002b21..d5d09f14d 100644 --- a/internal/cli/learning/cmd/add/cmd.go +++ b/internal/cli/learning/cmd/add/cmd.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/cli/add/core/build" + "github.com/ActiveMemory/ctx/internal/cli/add/core/jsonpayload" "github.com/ActiveMemory/ctx/internal/config/embed/cmd" "github.com/ActiveMemory/ctx/internal/config/entry" cFlag "github.com/ActiveMemory/ctx/internal/config/flag" @@ -30,6 +31,9 @@ import ( func Cmd() *cobra.Command { c := build.Cmd(entry.Learning, cmd.DescKeyLearningAdd, cmd.UseLearningAdd) c.PreRunE = func(cobraCmd *cobra.Command, _ []string) error { + if overlayErr := jsonpayload.OverlayFlags(cobraCmd); overlayErr != nil { + return overlayErr + } flags := cobraCmd.Flags() names := []string{ cFlag.Context, diff --git a/internal/cli/system/core/unknown/doc.go b/internal/cli/system/core/unknown/doc.go deleted file mode 100644 index 1a29ac94f..000000000 --- a/internal/cli/system/core/unknown/doc.go +++ /dev/null @@ -1,35 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -// Package unknown holds the RunE the `ctx system` group installs so -// that an unrecognised subcommand fails loud and legible instead of -// dumping help at exit 0. -// -// # Why this exists -// -// `ctx system` is a grouping command. Cobra raises an -// "unknown command" error only for the *root*; for a non-root group -// an unmatched subcommand falls through to the group's own Run/RunE, -// and a group with neither prints help and returns nil — exit 0. In -// a Claude Code UserPromptSubmit hook, exit 0 reads as "hook -// success", so the ~51-line help blob is injected into the agent's -// context every prompt. That is exactly how a stale `hooks.json` -// wiring `ctx system check-anchor-drift` (a command the binary later -// deleted) polluted sessions silently. -// -// A non-zero exit alone does not fix it: the harness swallows a -// failed hook's exit code, so the signal has to travel on hook -// stdout. [Handler] therefore emits a verbatim-relay box naming the -// unknown verb and hinting at the likely cause (plugin/binary -// version skew), best-effort records the event (event log + webhook) -// when a session is present on stdin, suppresses cobra's help dump, -// and returns a non-nil error. -// -// Scope is `ctx system` only — the single group wired into -// hooks.json. The shared [parent.Cmd] is untouched; other groups -// keep cobra's default behavior. See -// specs/system-unknown-subcommand-relay.md. -package unknown diff --git a/internal/cli/system/core/unknown/unknown.go b/internal/cli/system/core/unknown/unknown.go deleted file mode 100644 index 8d6c4e7ef..000000000 --- a/internal/cli/system/core/unknown/unknown.go +++ /dev/null @@ -1,29 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package unknown - -import ( - "os" - - "github.com/spf13/cobra" -) - -// Handler is the RunE installed on the `ctx system` group. It is -// reached only when cobra finds no matching subcommand (or for a -// bare `ctx system`); a valid subcommand runs its own RunE and never -// reaches here. It delegates to [handle] with the real stdin. -// -// Parameters: -// - cmd: the system command (for output and SilenceUsage) -// - args: leftover args; non-empty means an unknown subcommand -// -// Returns: -// - error: nil for bare `ctx system` (help printed); otherwise the -// unknown-subcommand error after emitting the relay box. -func Handler(cmd *cobra.Command, args []string) error { - return handle(cmd, args, os.Stdin) -} diff --git a/internal/cli/system/system.go b/internal/cli/system/system.go index b25346871..bde752c06 100644 --- a/internal/cli/system/system.go +++ b/internal/cli/system/system.go @@ -36,7 +36,7 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/system/cmd/resume" sessEvent "github.com/ActiveMemory/ctx/internal/cli/system/cmd/sessionevent" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/specsnudge" - "github.com/ActiveMemory/ctx/internal/cli/system/core/unknown" + "github.com/ActiveMemory/ctx/internal/cli/unknown" "github.com/ActiveMemory/ctx/internal/config/embed/cmd" ) @@ -86,8 +86,9 @@ func Cmd() *cobra.Command { // An unknown `ctx system ` must fail loud — emit a verbatim // relay and exit non-zero — instead of dumping help at exit 0, // which a UserPromptSubmit hook reads as success and injects every - // prompt. Scoped here only; the shared parent.Cmd stays untouched. - // See specs/system-unknown-subcommand-relay.md. - c.RunE = unknown.Handler + // prompt. system is Hidden, so RootCmd's PersistentPreRunE + // early-returns and this RunE is reachable without context/git + // preconditions. See specs/system-unknown-subcommand-relay.md. + c.RunE = unknown.HandlerFor(unknown.SystemConfig) return c } diff --git a/internal/cli/system/system_test.go b/internal/cli/system/system_test.go index ce82e6950..fb9b7fc07 100644 --- a/internal/cli/system/system_test.go +++ b/internal/cli/system/system_test.go @@ -60,11 +60,11 @@ func TestSystemBareStillPrintsHelp(t *testing.T) { } } -// TestParentCmdScopeUnchanged documents that the fix is scoped to the -// system group only: the shared parent.Cmd still produces a group -// with no RunE, so other groups keep cobra's default (help + exit 0) -// on an unknown subcommand. If someone moves the fix into parent.Cmd, -// this fails. +// TestParentCmdScopeUnchanged documents that the unknown-subcommand +// relay is an explicit per-group opt-in (system, hook), not a default: +// the shared parent.Cmd still produces a group with no RunE, so groups +// that do not opt in keep cobra's default (help + exit 0) on an unknown +// subcommand. If someone moves the relay into parent.Cmd, this fails. func TestParentCmdScopeUnchanged(t *testing.T) { if system.Cmd().RunE == nil { t.Fatal("system.Cmd() must set a RunE (the unknown-subcommand fix)") diff --git a/internal/cli/task/cmd/add/add_test.go b/internal/cli/task/cmd/add/add_test.go index 5ec3fa126..dbe7fbc8b 100644 --- a/internal/cli/task/cmd/add/add_test.go +++ b/internal/cli/task/cmd/add/add_test.go @@ -95,3 +95,61 @@ func TestTaskAddRequiresProvenance(t *testing.T) { t.Errorf("error should mention --session-id: %v", err) } } + +// TestTaskAddFromJSONFile verifies --json-file overlays the task's +// priority, section, and provenance, and that title+body become the +// single-line content. +func TestTaskAddFromJSONFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "cli-task-add-json-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + origDir, _ := os.Getwd() + if err = os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + testctx.Declare(t, tmpDir) + + initCmd := initialize.Cmd() + initCmd.SetArgs([]string{}) + if err = initCmd.Execute(); err != nil { + t.Fatalf("init failed: %v", err) + } + + payload := filepath.Join(tmpDir, "task.json") + if err = os.WriteFile(payload, []byte(`{ + "title": "Wire the relay", + "body": "into /usr/local/bin handoff", + "priority": "high", + "section": "Misc", + "provenance": {"session_id": "json1234", "branch": "main", "commit": "abc123"} + }`), 0o600); err != nil { + t.Fatalf("write payload: %v", err) + } + + addCmd := Cmd() + addCmd.SetArgs([]string{"--json-file", payload}) + if err = addCmd.Execute(); err != nil { + t.Fatalf("ctx task add --json-file failed: %v", err) + } + + tasksPath := filepath.Join(tmpDir, ".context", "TASKS.md") + content, err := os.ReadFile(filepath.Clean(tasksPath)) + if err != nil { + t.Fatalf("failed to read TASKS.md: %v", err) + } + contentStr := string(content) + for _, want := range []string{ + "Wire the relay into /usr/local/bin handoff", + "#priority:high", + "#session:json1234", + } { + if !strings.Contains(contentStr, want) { + t.Errorf("expected %q in TASKS.md", want) + } + } +} diff --git a/internal/cli/task/cmd/add/cmd.go b/internal/cli/task/cmd/add/cmd.go index f18c1a026..6d8fa98fc 100644 --- a/internal/cli/task/cmd/add/cmd.go +++ b/internal/cli/task/cmd/add/cmd.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/cli/add/core/build" + "github.com/ActiveMemory/ctx/internal/cli/add/core/jsonpayload" "github.com/ActiveMemory/ctx/internal/config/embed/cmd" "github.com/ActiveMemory/ctx/internal/config/entry" ) @@ -18,10 +19,16 @@ import ( // // Adds a new task entry to TASKS.md with provenance flags. // Implementation lives in the shared add core; this thin -// adapter binds the noun and description key. +// adapter binds the noun and description key, plus a PreRunE +// that overlays a --json-file payload onto the typed flags +// (priority/section/provenance) before the run. // // Returns: // - *cobra.Command: Configured task add subcommand func Cmd() *cobra.Command { - return build.Cmd(entry.Task, cmd.DescKeyTaskAdd, cmd.UseTaskAdd) + c := build.Cmd(entry.Task, cmd.DescKeyTaskAdd, cmd.UseTaskAdd) + c.PreRunE = func(cobraCmd *cobra.Command, _ []string) error { + return jsonpayload.OverlayFlags(cobraCmd) + } + return c } diff --git a/internal/cli/unknown/config.go b/internal/cli/unknown/config.go new file mode 100644 index 000000000..37b25266a --- /dev/null +++ b/internal/cli/unknown/config.go @@ -0,0 +1,37 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package unknown + +import ( + "github.com/ActiveMemory/ctx/internal/config/embed/text" + "github.com/ActiveMemory/ctx/internal/config/hook" +) + +// SystemConfig is the unknown-subcommand relay for `ctx system`. Its +// copy frames the failure as a plugin/binary version skew, since +// `ctx system` is the hooks.json-wired group. +var SystemConfig = Config{ + RelayPrefixKey: text.DescKeySystemUnknownRelayPrefix, + BoxTitleKey: text.DescKeySystemUnknownBoxTitle, + BodyKey: text.DescKeySystemUnknownBody, + RelayMessageKey: text.DescKeySystemUnknownRelayMessage, + HookName: hook.System, + Variant: hook.VariantUnknownSubcommand, +} + +// HookConfig is the unknown-subcommand relay for `ctx hook`. Its copy +// frames the failure as CLI drift between a caller (skill, loop script, +// hook) and the on-PATH binary, since `ctx hook` is consumed by name, +// not wired into hooks.json. +var HookConfig = Config{ + RelayPrefixKey: text.DescKeyHookUnknownRelayPrefix, + BoxTitleKey: text.DescKeyHookUnknownBoxTitle, + BodyKey: text.DescKeyHookUnknownBody, + RelayMessageKey: text.DescKeyHookUnknownRelayMessage, + HookName: hook.Hook, + Variant: hook.VariantUnknownSubcommand, +} diff --git a/internal/cli/unknown/doc.go b/internal/cli/unknown/doc.go new file mode 100644 index 000000000..de8113c53 --- /dev/null +++ b/internal/cli/unknown/doc.go @@ -0,0 +1,47 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package unknown provides a per-group, opt-in RunE that makes an +// unrecognised subcommand fail loud and legible instead of dumping help +// at exit 0. A group opts in with `c.RunE = unknown.HandlerFor(cfg)`. +// +// # Why this exists +// +// A cobra grouping command raises an "unknown command" error only for +// the *root*; for a non-root group an unmatched subcommand falls +// through to the group's own Run/RunE, and a group with neither prints +// help and returns nil — exit 0. That silent exit-0 is the failure mode +// this package kills, in two shapes: +// +// - `ctx system` is wired into Claude Code's hooks.json. There, exit 0 +// reads as "hook success", so a stale wiring (e.g. +// `ctx system check-anchor-drift`, a verb the binary later deleted) +// injects the ~51-line help blob into the agent's context every +// prompt. See specs/system-unknown-subcommand-relay.md. +// - `ctx hook` is consumed by name from skills and loop scripts +// (`ctx hook event|message|pause|resume|notify`). If such a verb +// drifts out of the binary, the caller silently receives help at +// exit 0 — the agent misreads or ignores it, and for +// `ctx hook notify` the human is never told. See +// specs/unknown-subcommand-relay-generalization.md. +// +// # Behavior +// +// A non-zero exit alone does not fix the hooks.json case: the harness +// swallows a failed hook's exit code, so the signal must travel on hook +// stdout. [handle] therefore emits a verbatim-relay box naming the +// unknown verb and its likely cause, best-effort records the event +// (event log + webhook) when a session is present on stdin, suppresses +// cobra's help dump, and returns a non-nil error. A bare group +// invocation (no leftover args) still prints help and exits 0. +// +// # Parameterization +// +// [Config] supplies the per-group copy (relay prefix, box title, body, +// relay message) and relay ref. [SystemConfig] and [HookConfig] are the +// two opt-ins today; the shared [parent.Cmd] stays untouched, so groups +// that want cobra's default keep it. +package unknown diff --git a/internal/cli/system/core/unknown/handle.go b/internal/cli/unknown/handle.go similarity index 65% rename from internal/cli/system/core/unknown/handle.go rename to internal/cli/unknown/handle.go index 829c2ad27..e2ce743d0 100644 --- a/internal/cli/system/core/unknown/handle.go +++ b/internal/cli/unknown/handle.go @@ -16,8 +16,6 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/system/core/message" "github.com/ActiveMemory/ctx/internal/cli/system/core/nudge" coreSession "github.com/ActiveMemory/ctx/internal/cli/system/core/session" - "github.com/ActiveMemory/ctx/internal/config/embed/text" - "github.com/ActiveMemory/ctx/internal/config/hook" cfgSession "github.com/ActiveMemory/ctx/internal/config/session" "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/entity" @@ -32,30 +30,34 @@ import ( // [nudge.Relay]. var relay = nudge.Relay -// handle is [Handler] with the stdin source injected for testability. -// Bare invocation prints help; an unknown verb emits the verbatim -// relay box (through the write layer), best-effort records the relay -// event when a session is present, suppresses cobra's help dump, and -// returns the unknown-subcommand error. +// handle is [HandlerFor]'s closure body with the stdin source and the +// per-group [Config] injected for testability. Bare invocation prints +// help; an unknown verb emits the verbatim relay box (through the write +// layer), best-effort records the relay event when a session is +// present, suppresses cobra's help dump, and returns the +// unknown-subcommand error. // // Parameters: -// - cmd: the system command (for output and SilenceUsage) +// - cmd: the group command (for output and SilenceUsage) // - args: leftover args; non-empty means an unknown subcommand // - stdin: hook-input source; ReadID is TTY-safe and timeout-guarded +// - cfg: the group's text keys and relay ref // // Returns: -// - error: nil for a bare `ctx system` (help printed); otherwise the -// unknown-subcommand error from [errCli.UnknownSubcommand]. -func handle(cmd *cobra.Command, args []string, stdin *os.File) error { +// - error: nil for a bare group invocation (help printed); otherwise +// the unknown-subcommand error from [errCli.UnknownSubcommand]. +func handle( + cmd *cobra.Command, args []string, stdin *os.File, cfg Config, +) error { if len(args) == 0 { - // Bare `ctx system`: preserve help + exit 0. + // Bare group (e.g. `ctx hook`): preserve help + exit 0. return cmd.Help() } verb := args[0] - prefix := desc.Text(text.DescKeySystemUnknownRelayPrefix) - title := desc.Text(text.DescKeySystemUnknownBoxTitle) - body := fmt.Sprintf(desc.Text(text.DescKeySystemUnknownBody), verb) + prefix := desc.Text(cfg.RelayPrefixKey) + title := desc.Text(cfg.BoxTitleKey) + body := fmt.Sprintf(desc.Text(cfg.BodyKey), verb) writeSetup.Nudge(cmd, message.NudgeBox(prefix, title, body)) // Best-effort relay leg: only when a hook supplied a session on @@ -64,12 +66,8 @@ func handle(cmd *cobra.Command, args []string, stdin *os.File) error { // failure is logged, not returned: the stdout box already reached // the agent, and the user's real problem is the unknown verb. if sid := coreSession.ReadID(stdin); sid != cfgSession.IDUnknown { - msg := fmt.Sprintf( - desc.Text(text.DescKeySystemUnknownRelayMessage), verb, - ) - ref := entity.NewTemplateRef( - hook.System, hook.VariantUnknownSubcommand, nil, - ) + msg := fmt.Sprintf(desc.Text(cfg.RelayMessageKey), verb) + ref := entity.NewTemplateRef(cfg.HookName, cfg.Variant, nil) if relayErr := relay(msg, sid, ref); relayErr != nil { logWarn.Warn(warn.RelayUnknownSubcommand, relayErr) } diff --git a/internal/cli/system/core/unknown/testmain_test.go b/internal/cli/unknown/testmain_test.go similarity index 100% rename from internal/cli/system/core/unknown/testmain_test.go rename to internal/cli/unknown/testmain_test.go diff --git a/internal/cli/unknown/types.go b/internal/cli/unknown/types.go new file mode 100644 index 000000000..9124da13e --- /dev/null +++ b/internal/cli/unknown/types.go @@ -0,0 +1,27 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package unknown + +// Config parameterizes the unknown-subcommand relay for one command +// group. Each group opts in by setting its RunE to [HandlerFor](cfg). +// +// Fields: +// - RelayPrefixKey: text key for the "relay verbatim" prefix line +// - BoxTitleKey: text key for the NudgeBox title +// - BodyKey: text key for the box body (format string taking the verb) +// - RelayMessageKey: text key for the event-log/webhook message +// (format string taking the verb) +// - HookName: relay-ref hook label (e.g. hook.System, hook.Hook) +// - Variant: relay-ref variant (e.g. hook.VariantUnknownSubcommand) +type Config struct { + RelayPrefixKey string + BoxTitleKey string + BodyKey string + RelayMessageKey string + HookName string + Variant string +} diff --git a/internal/cli/unknown/unknown.go b/internal/cli/unknown/unknown.go new file mode 100644 index 000000000..214844f06 --- /dev/null +++ b/internal/cli/unknown/unknown.go @@ -0,0 +1,32 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package unknown + +import ( + "os" + + "github.com/spf13/cobra" +) + +// HandlerFor returns a cobra RunE that relays an unknown subcommand for +// the group described by cfg. A group opts in by assigning the result +// to its RunE; cobra reaches it only when no subcommand matches (or for +// a bare group invocation) — a valid subcommand runs its own RunE and +// never reaches here. The returned closure delegates to [handle] with +// the real stdin. +// +// Parameters: +// - cfg: the group's text keys and relay ref (e.g. [SystemConfig], +// [HookConfig]) +// +// Returns: +// - func(*cobra.Command, []string) error: a RunE bound to cfg +func HandlerFor(cfg Config) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + return handle(cmd, args, os.Stdin, cfg) + } +} diff --git a/internal/cli/system/core/unknown/unknown_test.go b/internal/cli/unknown/unknown_test.go similarity index 63% rename from internal/cli/system/core/unknown/unknown_test.go rename to internal/cli/unknown/unknown_test.go index 21a62e51c..23d319b2b 100644 --- a/internal/cli/system/core/unknown/unknown_test.go +++ b/internal/cli/unknown/unknown_test.go @@ -51,7 +51,7 @@ func tempStdin(t *testing.T, content string) *os.File { return f } -func TestHandleUnknownEmitsBoxSilencesUsageAndErrors(t *testing.T) { +func TestHandleSystemUnknownEmitsBoxSilencesUsageAndErrors(t *testing.T) { cmd := &cobra.Command{Use: "system"} var out bytes.Buffer cmd.SetOut(&out) @@ -63,7 +63,7 @@ func TestHandleUnknownEmitsBoxSilencesUsageAndErrors(t *testing.T) { })() // Empty stdin → no session → relay leg must be skipped. - err := handle(cmd, []string{"check-anchor-drift"}, tempStdin(t, "")) + err := handle(cmd, []string{"check-anchor-drift"}, tempStdin(t, ""), SystemConfig) if err == nil { t.Fatal("want non-nil error for an unknown subcommand") } @@ -87,7 +87,7 @@ func TestHandleUnknownEmitsBoxSilencesUsageAndErrors(t *testing.T) { } } -func TestHandleUnknownFiresRelayWithSession(t *testing.T) { +func TestHandleSystemUnknownFiresRelayWithSession(t *testing.T) { cmd := &cobra.Command{Use: "system"} cmd.SetOut(&bytes.Buffer{}) @@ -102,7 +102,7 @@ func TestHandleUnknownFiresRelayWithSession(t *testing.T) { err := handle( cmd, []string{"check-anchor-drift"}, - tempStdin(t, `{"session_id":"sess-9"}`), + tempStdin(t, `{"session_id":"sess-9"}`), SystemConfig, ) if err == nil { t.Fatal("want non-nil error") @@ -128,14 +128,14 @@ func TestHandleRelayFailureDoesNotMaskError(t *testing.T) { return errors.New("relay boom") })() - err := handle(cmd, []string{"x"}, tempStdin(t, `{"session_id":"s"}`)) + err := handle(cmd, []string{"x"}, tempStdin(t, `{"session_id":"s"}`), SystemConfig) if err == nil || !strings.Contains(err.Error(), "x") { t.Fatalf("want unknown-subcommand error naming x, got %v", err) } } func TestHandleBareReturnsNilNoRelay(t *testing.T) { - cmd := &cobra.Command{Use: "system"} + cmd := &cobra.Command{Use: "hook"} cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) @@ -145,17 +145,69 @@ func TestHandleBareReturnsNilNoRelay(t *testing.T) { return nil })() - // Bare `ctx system` (no leftover args) prints help and exits 0; - // the help-output itself is covered by the system_test integration - // test against the real command. Here: no error, no relay, no - // SilenceUsage flip. - if err := handle(cmd, nil, tempStdin(t, `{"session_id":"s"}`)); err != nil { - t.Fatalf("bare `ctx system`: want nil error, got %v", err) + // Bare group invocation (no leftover args) prints help and exits 0; + // the help-output itself is covered by integration tests against the + // real command. Here: no error, no relay, no SilenceUsage flip. + if err := handle(cmd, nil, tempStdin(t, `{"session_id":"s"}`), HookConfig); err != nil { + t.Fatalf("bare group: want nil error, got %v", err) } if called { - t.Error("bare `ctx system` must not fire the relay") + t.Error("bare group must not fire the relay") } if cmd.SilenceUsage { - t.Error("bare `ctx system` must not set SilenceUsage") + t.Error("bare group must not set SilenceUsage") + } +} + +func TestHandleHookUnknownEmitsHookCopy(t *testing.T) { + cmd := &cobra.Command{Use: "hook"} + var out bytes.Buffer + cmd.SetOut(&out) + + defer stubRelay(func(string, string, *entity.TemplateRef) error { + return nil + })() + + err := handle(cmd, []string{"notifyy"}, tempStdin(t, ""), HookConfig) + if err == nil || !strings.Contains(err.Error(), "notifyy") { + t.Fatalf("want unknown-subcommand error naming notifyy, got %v", err) + } + + got := out.String() + for _, want := range []string{ + "notifyy", "Unknown Hook Subcommand", "CLI drift", "VERBATIM", + } { + if !strings.Contains(got, want) { + t.Errorf("hook relay box missing %q\n---\n%s", want, got) + } + } + if !cmd.SilenceUsage { + t.Error("want SilenceUsage=true") + } +} + +func TestHandleHookUnknownRelayRefUsesHookLabel(t *testing.T) { + cmd := &cobra.Command{Use: "hook"} + cmd.SetOut(&bytes.Buffer{}) + + var gotRef *entity.TemplateRef + defer stubRelay( + func(_, _ string, ref *entity.TemplateRef) error { + gotRef = ref + return nil + }, + )() + + if err := handle( + cmd, []string{"pausse"}, + tempStdin(t, `{"session_id":"sess-7"}`), HookConfig, + ); err == nil { + t.Fatal("want non-nil error") + } + if gotRef == nil || + gotRef.Hook != hook.Hook || + gotRef.Variant != hook.VariantUnknownSubcommand { + t.Errorf("relay ref = %+v, want hook=%q variant=%q", + gotRef, hook.Hook, hook.VariantUnknownSubcommand) } } diff --git a/internal/config/embed/flag/add.go b/internal/config/embed/flag/add.go index 03ffad6f7..387b8c896 100644 --- a/internal/config/embed/flag/add.go +++ b/internal/config/embed/flag/add.go @@ -20,6 +20,8 @@ const ( DescKeyAddContext = "add.context" // DescKeyAddFile is the description key for the add file flag. DescKeyAddFile = "add.file" + // DescKeyAddJSONFile is the description key for the add json-file flag. + DescKeyAddJSONFile = "add.json-file" // DescKeyAddLesson is the description key for the add lesson flag. DescKeyAddLesson = "add.lesson" // DescKeyAddPriority is the description key for the add priority flag. diff --git a/internal/config/embed/text/err_add.go b/internal/config/embed/text/err_add.go index 496a876c2..dffd2f156 100644 --- a/internal/config/embed/text/err_add.go +++ b/internal/config/embed/text/err_add.go @@ -13,6 +13,9 @@ const ( DescKeyErrAddFileNotFound = "err.add.file-not-found" // DescKeyErrAddIndexUpdate is the text key for err add index update messages. DescKeyErrAddIndexUpdate = "err.add.index-update" + // DescKeyErrAddJSONParse is the text key for err add json payload parse + // messages. + DescKeyErrAddJSONParse = "err.add.json-parse" // DescKeyErrAddMissingFields is the text key for err add missing fields // messages. DescKeyErrAddMissingFields = "err.add.missing-fields" diff --git a/internal/config/embed/text/hook_unknown.go b/internal/config/embed/text/hook_unknown.go new file mode 100644 index 000000000..6a2b9a365 --- /dev/null +++ b/internal/config/embed/text/hook_unknown.go @@ -0,0 +1,25 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package text + +// DescKeys for the `ctx hook` unknown-subcommand verbatim relay. +const ( + // DescKeyHookUnknownRelayPrefix is the text key for the + // "relay this verbatim" prefix above the unknown-subcommand box. + DescKeyHookUnknownRelayPrefix = "hook-unknown.relay-prefix" + // DescKeyHookUnknownBoxTitle is the text key for the + // unknown-subcommand box title. + DescKeyHookUnknownBoxTitle = "hook-unknown.box-title" + // DescKeyHookUnknownBody is the text key for the + // unknown-subcommand box body. It is a format string taking the + // unrecognised subcommand name. + DescKeyHookUnknownBody = "hook-unknown.body" + // DescKeyHookUnknownRelayMessage is the text key for the + // human-readable relay-event description recorded in the event + // log / webhook. Format string taking the subcommand name. + DescKeyHookUnknownRelayMessage = "hook-unknown.relay-message" +) diff --git a/internal/config/flag/flag.go b/internal/config/flag/flag.go index b6b4948b9..aff7a1233 100644 --- a/internal/config/flag/flag.go +++ b/internal/config/flag/flag.go @@ -72,6 +72,7 @@ const ( Full = "full" Hook = "hook" JSON = "json" + JSONFile = "json-file" KeepFrontmatter = "keep-frontmatter" Key = "key" Label = "label" diff --git a/internal/config/hook/hook.go b/internal/config/hook/hook.go index 969798d84..3af1ef68c 100644 --- a/internal/config/hook/hook.go +++ b/internal/config/hook/hook.go @@ -53,6 +53,10 @@ const ( // group itself (used by the unknown-subcommand relay, which is // not a check-* hook but still records a relay event). System = "system" + // Hook is the relay-event hook label for the `ctx hook` group + // itself (used by the unknown-subcommand relay when a `ctx hook` + // verb drifts out of the binary; records a relay event). + Hook = "hook" ) // Supported integration tool names for ctx setup command. diff --git a/internal/entity/add.go b/internal/entity/add.go index 5cb2a9203..eb7782f59 100644 --- a/internal/entity/add.go +++ b/internal/entity/add.go @@ -44,6 +44,7 @@ type EntryParams struct { // - Priority: Priority label flag // - Section: Target section flag // - FromFile: Path to read content from a file +// - JSONFile: Path to a JSON payload that populates typed fields // - SessionID: AI session identifier for task provenance // - Branch: Git branch name for task provenance // - Commit: Git commit hash for task provenance @@ -57,6 +58,7 @@ type AddConfig struct { Priority string Section string FromFile string + JSONFile string SessionID string Branch string Commit string diff --git a/internal/err/add/add.go b/internal/err/add/add.go index b3d8a2e6f..61375f587 100644 --- a/internal/err/add/add.go +++ b/internal/err/add/add.go @@ -39,6 +39,20 @@ func NoContentProvided(fType, examples string) error { ) } +// JSONParse wraps a failure to decode a --json-file payload. +// +// Parameters: +// - path: Path to the JSON payload file +// - cause: Underlying decode error (malformed JSON or unknown field) +// +// Returns: +// - error: "failed to parse JSON payload : " +func JSONParse(path string, cause error) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrAddJSONParse), path, cause, + ) +} + // IndexUpdate wraps a failure to update the index in a context file. // // Parameters: diff --git a/specs/ctx-add-json-ingest.md b/specs/ctx-add-json-ingest.md index 0c8fa461e..b0365525e 100644 --- a/specs/ctx-add-json-ingest.md +++ b/specs/ctx-add-json-ingest.md @@ -1,12 +1,109 @@ -# Spec: `--json ` ingest for `ctx add` +# Spec: `--json-file ` ingest for `ctx add` -**Status:** stub (seed from TASKS.md Phase CLI-FIX, 2026-05-28) +**Status:** accepted (impl 2026-05-30) **Driver session:** 96765858 -Add `--json ` to `ctx decision/learning/task add` (and `convention` if it gains structured fields) for ingesting a JSON payload that populates the typed fields directly. +Add `--json-file ` to `ctx decision/learning/task/convention add` +for ingesting a JSON payload that populates the typed fields directly, +keeping flag-value *content* off the literal Bash command string. -- **Driver**: this session hit a class of denial we worked around but should fix at the root. The project's canonical `permissions.deny` set (`.claude/settings.local.json` lines 119-121) matches on the literal Bash command string — including the *content* of `--rationale`/`--context`/`--consequence` flag values. A decision whose rationale legitimately describes installing a binary into the system PATH (literal substring " /usr/local/bin") gets caught by `Bash(* /usr/local/bin*)` and denied, even though the command's intent has nothing to do with that path. The workaround was Edit-direct into `DECISIONS.md`/`LEARNINGS.md`, which bypasses the ctx command's schema gates and `INDEX:START/END` maintenance. -- **Shape**: `ctx decision add --json /path/to/payload.json` where the JSON is `{"title":"…","context":"…","rationale":"…","consequence":"…"}`. The flag supersedes individual content flags. Provenance (`--session-id`/`--branch`/`--commit`) can stay on the command line OR be folded into the JSON envelope (`{"provenance":{"session_id":"…","branch":"…","commit":"…"}}`). Complements the existing `--file` (which only replaces the title/body positional). -- **Phase 2 (optional)**: array form `[{...},{...}]` for batch persists — useful for `/ctx-wrap-up` writing N decisions+learnings in one call instead of N separate invocations. -- **Mirror per command**: same shape applies to `ctx learning add --json …` (`{title,context,lesson,application}`) and `ctx task add --json …` (`{title,body,priority,section}`). -- **Surfaced by**: this session's persist denials and post-mortem; reference handover `20260528T201500Z-ctxctl-and-native-pressure-shipped.md`. +## Driver + +This class of denial should be fixed at the root. The project's canonical +`permissions.deny` set (`.claude/settings.local.json`) matches on the +literal Bash command string — including the *content* of +`--rationale`/`--context`/`--consequence` flag values. A decision whose +rationale legitimately describes installing a binary into the system PATH +(literal substring `" /usr/local/bin"`) is caught by +`Bash(* /usr/local/bin*)` and denied, even though the command's intent has +nothing to do with that path. The workaround was Edit-direct into +`DECISIONS.md`/`LEARNINGS.md`, which bypasses the ctx command's schema +gates and `INDEX:START/END` maintenance. Moving the values into a JSON file +keeps them out of the command string entirely. + +## Flag name + +`--json-file `, **not** `--json`. Across the rest of the CLI +(`ctx status --json`, `ctx drift --json`, `ctx doctor --json`, …) `--json` +is a *bool* flag meaning "format output as JSON". Overloading it as a +*string input-path* flag on the add commands would break that convention, +so the input-payload flag is named `--json-file` (long-only; `-j` is +already `ShortJSON`). Parallels the existing `--file`/`-f` source flag. + +## Payload shape + +A single JSON object. All keys optional; only the ones relevant to the +noun are consumed: + +```json +{ + "title": "…", + "body": "…", + "context": "…", + "rationale": "…", + "consequence": "…", + "lesson": "…", + "application": "…", + "priority": "…", + "section": "…", + "provenance": { "session_id": "…", "branch": "…", "commit": "…" } +} +``` + +Per-noun field consumption (extra keys are tolerated but ignored by the +noun's formatter): + +| Noun | content | typed fields | +|------------|-----------|------------------------------------| +| decision | `title` | `context`, `rationale`, `consequence` | +| learning | `title` | `context`, `lesson`, `application` | +| task | `title` (+ `body`) | `priority`, `section` | +| convention | `title` | — | + +- **Content** is `title`; for `task`, a non-empty `body` is appended to + `title` (space-joined) since `TASKS.md` entries are single-line. +- **Provenance** (`session_id`/`branch`/`commit`) may stay on the command + line OR be folded into the `provenance` envelope. +- Decoding is **strict** (`DisallowUnknownFields`): a misspelled key is a + hard error so typos surface instead of silently dropping a field. + +## Precedence + +`--json-file` **supersedes** the individual content/typed flags: any +non-empty payload field overrides the corresponding CLI flag. Empty or +absent payload fields leave the CLI flag value intact, so a caller may mix +CLI flags and a partial JSON envelope. For content, `--json-file` outranks +`--file`, positional args, and stdin. + +## Implementation + +The overlay runs in two visible touchpoints; both load the (tiny) file via +the shared `jsonpayload.Load`: + +1. **Typed fields → cobra flags, in `PreRunE`.** `jsonpayload.OverlayFlags` + reads `--json-file`, loads the payload, and `flags.Set`s each non-empty + mapped field that exists on the command (context, rationale, + consequence, lesson, application, priority, section, session-id, branch, + commit). This must happen in `PreRunE` because the decision/learning + `PreRunE` placeholder gate (`validate.RejectPlaceholder`) validates the + *effective* flag values — so JSON-supplied values are placeholder-checked + too, closing the bypass hole. `decision`/`learning`/`task` call + `OverlayFlags` first in their `PreRunE`; `convention` has no typed fields + and needs no overlay. +2. **Content → positional, in `extract.Content`.** When `flags.JSONFile` + is set and the payload yields non-empty content, that content wins; + otherwise extraction falls through to `--file`/args/stdin. + +`run.Run` is unchanged: the bound `--*` variables already reflect the +`flags.Set` overlay, so the assembled `entity.AddConfig` carries the +effective values. + +## Out of scope + +- **Phase 2 (batch array form `[{…},{…}]`)** for N entries in one call + (useful for `/ctx-wrap-up`). Deferred; the object form lands first. + +## Surfaced by + +This session's persist denials and post-mortem; reference handover +`20260528T201500Z-ctxctl-and-native-pressure-shipped.md`. diff --git a/specs/remove-deprecated-handoff-scratchpad.md b/specs/remove-deprecated-handoff-scratchpad.md new file mode 100644 index 000000000..933700f4b --- /dev/null +++ b/specs/remove-deprecated-handoff-scratchpad.md @@ -0,0 +1,45 @@ +# Spec: remove the deprecated session-handoff scratchpad + +**Status:** accepted (impl 2026-05-30) + +## Problem + +`.context/scratchpad-handoff.md` is a one-off, hand-written session +handoff artifact (created in `dcfd3772`, "Add session handoff for +context window continuation"; content dated 2026-03-28). Session +handovers are now produced by `/ctx-wrap-up` → `/ctx-handover` and +live under `.context/handovers/-.md` — timestamped so +concurrent agent runs never overwrite (per the root `CLAUDE.md`). +The root-level scratchpad is the superseded predecessor of that +mechanism. + +It is not merely redundant, it is actively misleading. Its body +still claims "NOT YET COMMITTED … Very large diff" for work +(EH.x error-handling, line-width audit) that has long since +landed; the tree is clean. `ctx status` and `ctx system bootstrap` +enumerate `.context/*.md`, so this stale file is loaded into every +session's start-up context, where a "do you remember?" recall can +resurrect a phantom uncommitted diff. + +No code reads it: a repo-wide search of `*.go`, `*.md`, `*.yaml`, +and `*.json` finds zero references to `scratchpad-handoff` outside +the file itself. Removing it cannot break a code path; it only +stops the context pollution. + +## Design + +`git rm .context/scratchpad-handoff.md`. No code change. + +The file's content is preserved in git history (`dcfd3772`, +`0df91654`), so removal loses no institutional memory — consistent +with the Context Preservation Invariant, which forbids *deleting +history*, not pruning a superseded live artifact whose history +remains in the reflog. Going forward, `.context/handovers/` is the +sole handoff channel. + +## Scope + +- In: remove the single stale file; add this spec as its rationale. +- Out: no change to the `/ctx-wrap-up` → `/ctx-handover` mechanism + or to `.context/handovers/`; the scratchpad's obsolete content is + not migrated anywhere (it is already false). diff --git a/specs/unknown-subcommand-relay-generalization.md b/specs/unknown-subcommand-relay-generalization.md new file mode 100644 index 000000000..5ac7cb4dd --- /dev/null +++ b/specs/unknown-subcommand-relay-generalization.md @@ -0,0 +1,83 @@ +# Spec: generalize the unknown-subcommand relay to `ctx hook` + +**Status:** accepted (impl 2026-05-30) +**Supersedes the deferral in:** `specs/system-unknown-subcommand-relay.md` +(follow-up #1, "scoped to ctx system only") + +## Problem + +The unknown-subcommand relay added for `ctx system` +(`specs/system-unknown-subcommand-relay.md`) makes a missing verb fail +loud: it emits a verbatim NudgeBox, fires a best-effort event-log + +webhook relay, suppresses cobra's help dump, and exits non-zero. It was +deliberately scoped to `ctx system` because that group is the one wired +into `hooks.json`, where a help-dump-at-exit-0 is read by a +UserPromptSubmit hook as success and injected every prompt. + +But the relay's value is not only the every-prompt amplification — it is +also making **drift loud instead of silent**. `ctx hook` is agent- and +script-consumed (the ctx-doctor/ctx-pause/ctx-resume skills run +`ctx hook event|message|pause|resume`; loop scripts bake in +`ctx hook notify --event loop`). Every caller invokes a *known* verb and +reads its output. If such a verb drifts out of the binary, today's +behavior is: cobra prints the `ctx hook` group help and **exits 0**. The +agent "happily ignores" it (or misparses it), the human is not notified, +and nothing is relayed. For `ctx hook notify` that is especially bad: +the loop believes it notified the human when it did not. + +`ctx hook` is *user-facing* (not `Hidden`, unlike `ctx system`), so the +fix must preserve the friendly bare-`ctx hook` help while making an +**unknown** verb loud. + +## Design + +Lift the handler out of `internal/cli/system/core/unknown` into a neutral, +parameterized package `internal/cli/unknown`, so a group opts in by +setting its `RunE`: + +```go +c.RunE = unknown.HandlerFor(unknown.SystemConfig) // ctx system +c.RunE = unknown.HandlerFor(unknown.HookConfig) // ctx hook +``` + +- `unknown.Config` carries the per-group text keys (relay prefix, box + title, body, relay message) and the relay ref (`HookName`, `Variant`). +- `unknown.SystemConfig` reproduces the existing `ctx system` behavior + byte-for-byte; `unknown.HookConfig` uses `ctx hook`-specific copy + (CLI-drift framing, not hooks.json/version-skew framing) and the new + `hook.Hook` relay label. +- Behavior is unchanged: bare group → `cmd.Help()` + exit 0; unknown verb + → box + best-effort session-gated relay + `SilenceUsage` + non-zero exit. +- The `relay` package seam (`= nudge.Relay`) is preserved for tests. + +### PreRunE exemption (the subtle part) + +The root `PersistentPreRunE` (internal/bootstrap/cmd.go) early-returns for +"grouping commands without a Run/RunE." `ctx system` instead relies on its +`Hidden` early-return. `ctx hook` is visible and currently exempt **only** +via the no-RunE rule — so adding a `RunE` would newly subject bare +`ctx hook` and `ctx hook ` to the context/git preconditions, a +regression (bare `ctx hook` help must work outside a project). + +Fix: annotate the `ctx hook` group with `AnnotationSkipInit`. The +annotation is evaluated against the *target* command, so it exempts only +the group-level invocation (bare or unknown-verb); valid subcommands +(`event`, `pause`, …) are evaluated on their own and keep their existing +preconditions. + +## Scope + +- In: `ctx hook` opt-in; shared parameterized package; the move of the + handler + its tests out of `system/core/unknown`. +- Out: a build-time guard that scans skills/loops for `ctx hook ` + references against the cobra tree (the system guard covers only + hooks.json-wired verbs; `ctx hook` is not hooks.json-wired). Noted as a + possible future guard, not built here. + +## Why not fold into `parent.Cmd` + +`parent.Cmd` is imported by many top-level groups; wiring the relay deps +(message/nudge/session) into it would widen every group's dependency +surface and force a behavior choice on groups that do not want it. A +dedicated opt-in handler keeps the choice explicit and per-group, which +is what the deferred task asked for.