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
28 changes: 28 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
> (`// sin-debt: <ceiling>, upgrade: <trigger>`) adopted from ponytail;
> 41st subcommand, `cmd/sin-code/internal/sindept/` package with byte-stable
> scanner + aggregator + report + policy gate.
> **Last verified against main:** v3.19.0 (2026-06-16) —
> `autoactivate` hooklife subpackage (issue #176) wired into `sin-code chat`
> with `--activate <rule>` + `--no-trigger` flags; project-local
> `.sin-code/autoactivate.toml`; deterministic byte-stable rule rendering;
> race-safe per-session state (mandate M7). Branch protection on `main`
> permanently relaxed to `required_approving_review_count: 0` for solo-
> maintainer workflow.

---

Expand Down Expand Up @@ -444,6 +451,7 @@ Headless JSON contract (stable API — never break without major bump):

| v3.18.0 | ACTIVE | `sin-code debt` (issue #177) — `// sin-debt: <ceiling>, upgrade: <trigger>` marker convention (ponytail adoption), byte-stable `internal/sindept/` scanner + report, policy gate via `sin-code debt check`, 41st subcommand; alongside `sin-code install` (issue #170), `eval` / `trace` (issue #75), `evalset` / `prp` / `instinct` / `assets` / `hooks` / `rtk` / `codegraph` / `spec` v0 work |
| (next) | TBD | eval/trace infra hardening + first-party golden-dataset CI gate (issue #75 phase 2) |
| v3.19.0 | ACTIVE | `autoactivate` hooklife subpackage (#176): `cmd/sin-code/internal/hooklife/autoactivate` — per-session rule injection on `SessionStart` + `UserPrompt`. `--activate <rule>` + `--no-trigger` flags on `sin-code chat`; project-local `.sin-code/autoactivate.toml`; deterministic byte-stable `RuleSet.Render()`; race-safe (mandate M7). Hooks register against any `*hooklife.Registry` via `Activator.Register(reg)`. |

Each release tag ⇒ goreleaser builds linux/darwin/windows × amd64/arm64,
updates `homebrew-sin` formula, and ships to GitHub Releases.
Expand Down Expand Up @@ -665,6 +673,26 @@ Every **intentional** shortcut in source code is marked in-line with:
can pin its golden snapshot.
- Policy file: `.sin-code/debt-policy.toml` (see
`cmd/sin-code/debt_cmd.doc.md`).
### Hook events (verified `internal/hooklife/event.go`, v3.19.0)

Seven phases for the second, programmatic hooking system:

| Phase | Verdict caps | Used by |
|---|---|---|
| `PreToolUse` | Block allowed | `block-no-verify`, `config-protection`, `quality-gate` |
| `PostToolUse` | Warn (aggregated) | `post-edit-format`, `post-edit-typecheck`, `cost-tracker` |
| `Stop` | Warn (aggregated) | `suggest-compact` |
| `SessionStart` | Warn (aggregated) | `autoactivate-session-start` (issue #176) |
| `SessionEnd` | Warn (aggregated) | (reserved) |
| `PreCompact` | Warn (aggregated) | `suggest-compact` (privacy-first) |
| `UserPrompt` | Warn (aggregated) | `autoactivate-user-prompt` (issue #176) |

Auto-activation hooks (v3.19.0, issue #176) wire through
`Activator.Register(reg)` and emit their rule body via the `Decision.Message`
field, returning `Warn` so the runner's aggregation surfaces it. Off by
default — privacy-first activation via `--activate` or
`.sin-code/autoactivate.toml`. See
`cmd/sin-code/internal/hooklife/autoactivate/activator.doc.md`.

---

Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,27 @@ reports them; `debt check` gates them.
through the same shape regardless of consumer.

---
### Added — Auto-Activation Hook (issue #176, v3.19.0)
- **`internal/hooklife/autoactivate/`** — per-session rule injection subpackage.
Two Phase hooks (`autoactivate-session-start` / `autoactivate-user-prompt`)
register against any `*hooklife.Registry` via `Activator.Register(reg)`.
Privacy-first: off by default; activated by `sin-code chat --activate <rule>`
or a project-local `.sin-code/autoactivate.toml` file.
- **`AutoActivate.Activator`** tracks per-session state under a single
`sync.RWMutex` (mandate M7 — race-safe under `go test -race -count=1`).
`OnSessionStart(sid, opts)` is idempotent; `OnUserPrompt(sid, prompt)`
returns the rule set re-emittable for this turn, with trigger-phrase
substring matching for natural-language activation. `EndSession(sid)`
drops state on exit.
- **`RuleSet.Render()`** is byte-stable: any two RuleSets with the same
name+body+trigger tuples produce identical bytes regardless of insertion
order (prerequisite for the system-prompt hash metric, issue #2).
- **`sin-code chat --activate terse,skill-x`** comma-separated rule list;
**`--no-trigger`** suppresses per-prompt phrase matching; reads
`.sin-code/autoactivate.toml` silently when present.
- Tests: 35 race-safe unit tests + 8 chat-wiring integration tests, 91.5%
statement coverage on the autoactivate package. New package follows
the existing `hooklife` Phase contract; no new external deps.

### Added — Loop Engineering (decoupled completion authority)
### Added — MCP tool-manifest compression (issue #173, v3.19.0)
Expand Down
125 changes: 123 additions & 2 deletions cmd/sin-code/chat_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import (

"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal"
"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/agentloop"
"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/hooklife"
"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/hooklife/autoactivate"
"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/hooks"
"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/llm"
"github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/mcpclient"
Expand All @@ -41,6 +43,11 @@ type chatOptions struct {
verifyCmd string
maxTurns int
dbPath string
// activate is a comma-separated list of rule names to auto-activate
// for this session (issue #176). Empty = no CLI-level activation;
// a project-local .sin-code/autoactivate.toml may still apply.
activate string
noTrigger bool
}

func NewChatCmd() *cobra.Command {
Expand All @@ -54,7 +61,9 @@ func NewChatCmd() *cobra.Command {
sin-code chat -p "..." --json headless one-shot (stable JSON contract)
sin-code chat --resume <session-id> continue an existing session
sin-code chat --agent <name> use a specific agent profile
sin-code chat --yolo bypass 'ask' permissions (M4)`,
sin-code chat --yolo bypass 'ask' permissions (M4)
sin-code chat --activate terse,skill-x auto-activate the named rules (issue #176)
sin-code chat --no-trigger disable prompt-phrase activation`,
RunE: func(cmd *cobra.Command, args []string) error {
return runChat(cmd.Context(), opts)
},
Expand All @@ -71,6 +80,8 @@ func NewChatCmd() *cobra.Command {
f.StringVar(&opts.verifyCmd, "verify-cmd", os.Getenv("SIN_VERIFY_CMD"), "shell command used as verification runner (exit 0 = pass)")
f.IntVar(&opts.maxTurns, "max-turns", 0, "max agent turns (default 80)")
f.StringVar(&opts.dbPath, "db", "", "sessions db path (default ~/.local/share/sin-code/sessions.db)")
f.StringVar(&opts.activate, "activate", "", "comma-separated rule names to auto-activate for this session (issue #176)")
f.BoolVar(&opts.noTrigger, "no-trigger", false, "disable prompt-phrase activation (issue #176)")
return cmd
}

Expand Down Expand Up @@ -104,6 +115,26 @@ func runChat(ctx context.Context, opts *chatOptions) error {
}
hookEngine := hooks.New(loadHooks(workspace))

// --- auto-activation hook (issue #176) ------------------------------
// Off by default. Privacy-first: only opens when the operator sets
// `--activate` or ships `.sin-code/autoactivate.toml`. The activator
// keeps a per-session state and emits the rule body via hooklife
// Decision.Message — informative stderr output today; LLM system-
// prompt injection is tracked separately.
act := newChatActivator(workspace, opts)
hooklifeReg := hooklife.NewRegistry()
// Wire the activator's two hooks. Defaults + AutoOn are baked into
// the SessionStartHook at registration time so the hook handles
// per-session OnSessionStart internally when Dispatch fires.
autoOn := act.Def.AutoOn || len(act.Rules) > 0 || len(act.Defaults) > 0
hooklifeReg.Register(autoactivate.SessionStartHook{
Act: act.Act,
Defaults: act.Defaults,
AutoOn: autoOn,
})
hooklifeReg.Register(autoactivate.UserPromptHook{Act: act.Act})
hooklifeRunner := hooklife.NewRunner(hooklifeReg).WithTimeout(2 * time.Second)

// --- External MCP servers (mandate C5, ecosystem skills) -------------
mcpMgr := mcpclient.NewManager(mcpclient.LoadConfigs(workspace))
if err := mcpMgr.ConnectAll(ctx); err != nil {
Expand Down Expand Up @@ -136,6 +167,26 @@ func runChat(ctx context.Context, opts *chatOptions) error {
return err
}

// Dispatch SessionStart once the session id is known. The hook
// itself initialises the activator's per-session state, including
// any CLI `--activate <rule>` names added after the fact.
d := hooklifeRunner.Dispatch(ctx, hooklife.Event{
Phase: hooklife.SessionStart,
Workdir: workspace,
Meta: map[string]string{
"session_id": sess.ID,
"no_trigger": boolStr(opts.noTrigger),
},
})
if d.Message != "" {
fmt.Fprintln(os.Stderr, "[autoactivate] session start rules:\n"+d.Message)
}
// Apply the CLI `--activate` list now that the hook has wired the
// state for this session id.
for _, name := range act.Rules {
act.Act.Activate(sess.ID, autoactivate.Rule{Name: name})
}

var ask agentloop.AskFunc
if !headless {
ask = terminalAsk
Expand All @@ -154,15 +205,37 @@ func runChat(ctx context.Context, opts *chatOptions) error {
Ask: ask,
}

dispatchUserPrompt := func(prompt string) {
pd := hooklifeRunner.Dispatch(ctx, hooklife.Event{
Phase: hooklife.UserPrompt,
Tool: "ChatPrompt",
Workdir: workspace,
Meta: map[string]string{
"session_id": sess.ID,
"prompt": prompt,
},
})
if pd.Message != "" {
fmt.Fprintln(os.Stderr, "[autoactivate] per-turn rules:\n"+pd.Message)
}
}

if headless {
dispatchUserPrompt(opts.prompt)
res, err := loop.Run(ctx, sess, opts.prompt)
if err != nil {
act.Act.EndSession(sess.ID)
return err
}
act.Act.EndSession(sess.ID)
return printResult(res, opts.jsonOut)
}

fmt.Printf("sin-code chat — session %s (verify=%s). Type 'exit' to quit.\n", sess.ID, gate.Mode())
fmt.Printf("sin-code chat — session %s (verify=%s).", sess.ID, gate.Mode())
if st, ok := act.Act.Snapshot(sess.ID); ok && len(st.ActiveRules.Names()) > 0 {
fmt.Printf(" Active rules: %s", strings.Join(st.ActiveRules.Names(), ", "))
}
fmt.Println(" Type 'exit' to quit.")
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for {
Expand All @@ -177,13 +250,15 @@ func runChat(ctx context.Context, opts *chatOptions) error {
if line == "exit" || line == "quit" {
break
}
dispatchUserPrompt(line)
res, err := loop.Run(ctx, sess, line)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
continue
}
_ = printResult(res, opts.jsonOut)
}
act.Act.EndSession(sess.ID)
return scanner.Err()
}

Expand Down Expand Up @@ -257,3 +332,49 @@ func firstNonEmpty(vals ...string) string {
}
return ""
}

// chatActivator bundles the autoactivate.Activator with the CLI flags
// that should be applied when a real session id is known. A single
// instance is created per chat invocation; it is GC'd on exit.
type chatActivator struct {
Act *autoactivate.Activator
Defaults autoactivate.RuleSet
Def autoactivate.Default
Rules []string // CLI --activate list (names only — bodies come from TOML)
}

// newChatActivator constructs a chatActivator from workspace +
// the optional `--activate <list>` and `--no-trigger` flags. Reads
// `.sin-code/autoactivate.toml` silently when present (privacy-first).
func newChatActivator(workspace string, opts *chatOptions) *chatActivator {
defaults, def, _ := autoactivate.LoadFile(filepath.Join(workspace, ".sin-code", "autoactivate.toml"))
return &chatActivator{
Act: autoactivate.NewActivator(defaults),
Defaults: defaults,
Def: def,
Rules: parseActivateFlag(opts.activate),
}
}

// parseActivateFlag splits a comma-separated rule list into trimmed
// non-empty names. Empty input returns nil.
func parseActivateFlag(s string) []string {
if s == "" {
return nil
}
var out []string
for _, raw := range strings.Split(s, ",") {
n := strings.TrimSpace(raw)
if n != "" {
out = append(out, n)
}
}
return out
}

func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
Loading
Loading