Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Parent directory context merges with child directory context. Entries from paren

Each entry has a `content` string and optional filters:

- **match** - glob patterns for files this applies to (default: `["**"]`, everything)
- **match** - glob patterns for files or directories this applies to (default: `["**"]`, everything). Patterns ending with `/` target directories.
- **exclude** - glob patterns to skip
- **on** - when the file is being `read`, `edit`ed, `create`d, or `all` (default)
- **when** - deliver `before` or `after` the file content in the prompt, or `all` for both (default: `before`)
Expand Down Expand Up @@ -183,9 +183,9 @@ Response without New Zealand reference.

**sctx hook** - Reads agent hook input from stdin, returns matching context entries. This is the main integration point. Decisions are excluded from hook output.

**sctx context \<path\>** - Query context entries for a file. Supports `--on <action>`, `--when <timing>`, and `--json`.
**sctx context \<path\>** - Query context entries for a file or directory. Supports `--on <action>`, `--when <timing>`, and `--json`.

**sctx decisions \<path\>** - Query decisions for a file. Supports `--json`.
**sctx decisions \<path\>** - Query decisions for a file or directory. Supports `--json`.

**sctx validate [\<dir\>]** - Checks all context files in a directory tree for schema errors and invalid globs.

Expand Down Expand Up @@ -232,6 +232,6 @@ sctx pi disable

## Agent-agnostic design

The core engine knows nothing about Claude Code, pi, or any other agent. It takes a file path, an action, and a timing, and returns matched context. Agent-specific bits live in thin adapter layers that translate stdin JSON into those universal inputs.
The core engine knows nothing about Claude Code, pi, or any other agent. It takes a file or directory path, an action, and a timing, and returns matched context. Agent-specific bits live in thin adapter layers that translate stdin JSON into those universal inputs.

Other agents can use `sctx context` directly, or new adapters can be added without touching the core.
51 changes: 39 additions & 12 deletions cmd/sctx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ const usage = `sctx — Structured Context CLI
Usage:
sctx hook Read agent hook input from stdin, return matching context
sctx context <path> [--on <action>] [--when <timing>] [--json]
Query context entries for a file
sctx decisions <path> [--json] Query decisions for a file
Query context entries for a file or directory
sctx decisions <path> [--json] Query decisions for a file or directory
sctx validate [<dir>] Validate all context files in a directory tree
sctx init Create a starter AGENTS.yaml in the current directory
sctx claude enable Enable sctx hooks in Claude Code
Expand Down Expand Up @@ -148,11 +148,18 @@ func cmdContext(args []string, out, errOut io.Writer) error {
return fmt.Errorf("resolving path: %w", err)
}

result, warnings, err := core.Resolve(core.ResolveRequest{
FilePath: absPath,
Action: action,
Timing: timing,
})
req := core.ResolveRequest{
Action: action,
Timing: timing,
}

if isDir(absPath, filePath) {
req.DirPath = absPath
} else {
req.FilePath = absPath
}

result, warnings, err := core.Resolve(req)
if err != nil {
return err
}
Expand Down Expand Up @@ -199,11 +206,18 @@ func cmdDecisions(args []string, out, errOut io.Writer) error {
return fmt.Errorf("resolving path: %w", err)
}

result, warnings, err := core.Resolve(core.ResolveRequest{
FilePath: absPath,
Action: core.ActionAll,
Timing: core.TimingBefore,
})
req := core.ResolveRequest{
Action: core.ActionAll,
Timing: core.TimingBefore,
}

if isDir(absPath, filePath) {
req.DirPath = absPath
} else {
req.FilePath = absPath
}

result, warnings, err := core.Resolve(req)
if err != nil {
return err
}
Expand Down Expand Up @@ -331,6 +345,19 @@ decisions:
return nil
}

// isDir reports whether the path refers to a directory.
// It checks if the path exists as a directory on disk, or ends with a path separator.
// When the path doesn't exist, it falls back to the trailing slash convention.
func isDir(absPath, originalPath string) bool {
info, err := os.Stat(absPath) //nolint:gosec // os.Stat is read-only, safe on user-provided paths
if err == nil {
return info.IsDir()
}

// Path doesn't exist on disk. Use trailing slash as the signal.
return strings.HasSuffix(originalPath, "/") || strings.HasSuffix(originalPath, string(filepath.Separator))
}

func cmdClaude(args []string) error {
if len(args) < 1 {
return errClaudeSubcommand
Expand Down
8 changes: 6 additions & 2 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ The Write tool gets special treatment: `sctx` checks whether the target file exi

## sctx context \<path\>

Query context entries for a file. Useful for debugging and testing your context files. The current working directory is used as the project root — only `AGENTS.yaml` files at or below it are considered.
Query context entries for a file or directory. Useful for debugging and testing your context files. The current working directory is used as the project root — only `AGENTS.yaml` files at or below it are considered.

If the path exists on disk as a directory, sctx automatically runs a directory query. For paths that don't exist on disk, append a trailing `/` to force a directory query. Directory patterns like `match: ["tests/"]` only match directory queries, not file queries. File-glob patterns like `match: ["**/*.py"]` match a directory query if they could produce hits inside that directory.

```bash
sctx context src/api/handler.py
sctx context src/api/handler.py --on edit --when before
sctx context src/api/ # directory query
sctx context src/api/handler.py --json
```

Expand All @@ -41,10 +44,11 @@ sctx context src/api/handler.py --json

## sctx decisions \<path\>

Query decisions for a file. Shows architectural decisions that apply based on glob matching.
Query decisions for a file or directory. Shows architectural decisions that apply based on glob matching. Directory queries work the same way as `sctx context` -- pass a directory path to see decisions scoped to that directory.

```bash
sctx decisions src/api/handler.py
sctx decisions src/api/ # directory query
sctx decisions src/api/handler.py --json
```

Expand Down
31 changes: 29 additions & 2 deletions docs/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ When an agent edits a Python file, it sees that instruction. When it edits a SQL
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `content` | string | yes | -- | The guidance to deliver |
| `match` | list of globs | no | `["**"]` | File patterns this applies to |
| `exclude` | list of globs | no | `[]` | File patterns to skip |
| `match` | list of globs | no | `["**"]` | File or directory patterns this applies to |
| `exclude` | list of globs | no | `[]` | File or directory patterns to skip |
| `on` | string or list | no | `all` | Action filter: `read`, `edit`, `create`, or `all` |
| `when` | string | no | `before` | Prompt positioning: `before`, `after`, or `all` |

Expand Down Expand Up @@ -81,6 +81,33 @@ exclude: ["**/vendor/**"]

The default match is `["**"]` (recursive, everything). `exclude` is applied after `match`. A file must match at least one `match` pattern and zero `exclude` patterns.

### Directory patterns

A pattern ending with `/` targets a directory instead of files. This follows the same convention as `.gitignore` and `rsync`.

```yaml
# Applies to the tests/ directory itself, not its subdirectories
match: ["tests/"]

# Matches any tests/ directory anywhere under src/
match: ["src/**/tests/"]

# Matches any directory named api/ at any depth
match: ["**/api/"]
```

When a file-glob pattern (like `**/*.py`) appears in a directory query, `match` and `exclude` use different strictness levels. Match is **generous**: if the pattern *could* produce hits inside the directory, the entry is included. Exclude is **strict**: it only removes the directory when the pattern clearly targets it. This means `exclude: ["**/vendor/**"]` won't exclude `src/` (good — vendor files aren't in `src/`), but `match: ["**/vendor/**"]` *will* match `src/` (acceptable — it errs on the side of showing extra context). The asymmetry prevents accidental over-exclusion.

Directory patterns never match file queries. They only match when an agent (or the CLI) queries a directory directly:

```bash
sctx context tests/ # directory query -- tests/ pattern matches
sctx context tests/unit/ # directory query -- tests/ pattern does NOT match
sctx context tests/foo.py # file query -- tests/ pattern does NOT match
```

This gives you precision that recursive file globs can't. A decision like "test fixtures live in conftest.py at this level" can target `tests/` without leaking into `tests/unit/` or `tests/integration/`.

## on

What the agent is doing with the file.
Expand Down
15 changes: 14 additions & 1 deletion docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The most expensive agent mistake isn't writing bad code. It's confidently propos
| `alternatives` | list | no | -- | Options that were considered and rejected |
| `revisit_when` | string | no | -- | Condition under which this should be reconsidered |
| `date` | date | no | -- | When it was made (YYYY-MM-DD) |
| `match` | list of globs | no | `["**"]` | Scope to specific files |
| `match` | list of globs | no | `["**"]` | Scope to specific files or directories |

```yaml
decisions:
Expand Down Expand Up @@ -117,4 +117,17 @@ decisions:

This decision only shows up when an agent is working in `src/api/`. It won't clutter context for someone editing frontend code.

### Directory-scoped decisions

A pattern ending with `/` targets a directory without leaking into its subdirectories. This is useful when a decision applies to a specific level of the project but not to everything underneath it.

```yaml
decisions:
- decision: "Test fixtures live in conftest.py at this level"
rationale: "Subdirectories import from here, don't duplicate fixtures"
match: ["tests/"]
```

This decision shows up when querying `tests/` but not when querying `tests/unit/` or editing `tests/unit/test_thing.py`. Full glob syntax works too: `match: ["**/tests/"]` targets any `tests/` directory at any depth.

See [Examples](examples.md) for complete `AGENTS.yaml` files showing decisions alongside context entries in real projects.
4 changes: 4 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,10 @@ decisions:
revisit_when: "Stripe pricing becomes prohibitive or we need multi-PSP"
date: 2025-08-20
match: ["**/*.ts"]

- decision: "Webhook handlers in this package, not in the API gateway"
rationale: "Payment webhooks need access to payment domain logic for validation"
match: ["*"] # files directly in this directory, not inherited by subdirectories
```

When an agent edits `packages/payments/src/checkout.ts`, it gets the shared monorepo conventions *and* the payments-specific context. The payments context appears last, giving it stronger influence.
3 changes: 2 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,11 @@ You can also test from the command line:
sctx context README.md --on read --when before
```

Check what decisions apply:
Check what decisions apply to a file or directory:

```bash
sctx decisions src/main.py
sctx decisions src/api/ # directory query
```

Validate all context files in your project:
Expand Down
17 changes: 13 additions & 4 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ See [Context entries](context.md) and [Decisions](decisions.md) for field detail
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `content` | string | yes | -- | The guidance to deliver |
| `match` | list of globs | no | `["**"]` | File patterns this applies to |
| `exclude` | list of globs | no | `[]` | File patterns to skip |
| `match` | list of globs | no | `["**"]` | File or directory patterns this applies to |
| `exclude` | list of globs | no | `[]` | File or directory patterns to skip |
| `on` | string or list | no | `all` | Action filter: `read`, `edit`, `create`, `all` |
| `when` | string | no | `before` | Prompt positioning: `before`, `after`, `all` |

Expand All @@ -72,20 +72,29 @@ See [Context entries](context.md) and [Decisions](decisions.md) for field detail
| `alternatives` | list | no | -- | Rejected options and constraints |
| `revisit_when` | string | no | -- | Condition to reconsider |
| `date` | date | no | -- | When decided (YYYY-MM-DD) |
| `match` | list of globs | no | `["**"]` | Scope to specific files |
| `match` | list of globs | no | `["**"]` | Scope to specific files or directories |

## Resolution algorithm

### File queries

Given a file path, an action, and a timing:

1. **Discover** -- Walk from the target file's directory up to the project root, collecting all context files at each level
2. **Parse** -- Parse each file. Emit warnings for invalid files but continue processing valid ones
3. **Filter by match/exclude** -- Test each entry's glob patterns against the target file path. Globs are relative to the context file's directory
3. **Filter by match/exclude** -- Test each entry's glob patterns against the target file path. Globs are relative to the context file's directory. Directory patterns (trailing `/`) are skipped during file queries.
4. **Filter by action** -- Keep entries where `on` includes the requested action (or is `all`)
5. **Filter by timing** -- Keep entries where `when` matches the requested timing
6. **Merge** -- Combine all matching entries. Parent directory entries come first, child directory entries come last
7. **Return** -- The ordered list of matching context entries and decisions

### Directory queries

Given a directory path, an action, and a timing. The algorithm is the same with two differences:

- **Discovery starts from the directory itself**, not its parent. This ensures entries in the queried directory's own `AGENTS.yaml` are included.
- **Matching handles two pattern types.** Directory patterns (trailing `/`) match if the queried directory matches the pattern exactly. File-glob patterns match if they could produce hits inside the queried directory (e.g. `src/**` matches a query for `src/` but not for `tests/`).

## Merge order

Parent directories come before child directories.
Expand Down
Loading