diff --git a/.agentsmesh/.lock b/.agentsmesh/.lock index 8b364e42..09abf4c6 100644 --- a/.agentsmesh/.lock +++ b/.agentsmesh/.lock @@ -1,9 +1,9 @@ # Auto-generated. DO NOT EDIT MANUALLY. # Tracks the state of all config files for team conflict resolution. -generated_at: 2026-07-19T07:38:52.169Z +generated_at: 2026-07-22T19:17:08.822Z generated_by: serhii -lib_version: 0.30.1 +lib_version: 0.31.0 checksums: agents/code-debugger.md: sha256:33452bf839256a119a602cd10d085bff3cdb8f537ded76e60053bb9d8e5d33bc agents/code-documenter.md: sha256:89b4ebd27718bcd50a7283c95ea1ff9cad153b1112cf695f9f1fc22d90ec85ec diff --git a/.agentsmesh/lessons/lessons.json b/.agentsmesh/lessons/lessons.json index c1cbf2be..23fb40d8 100644 --- a/.agentsmesh/lessons/lessons.json +++ b/.agentsmesh/lessons/lessons.json @@ -2321,6 +2321,22 @@ "t-cmd-7c1c43ea" ] }, + "git-commit-hygiene-running-pnpm-install-inside-a": { + "createdAt": "2026-07-21", + "evidence": [ + "commit:a180aedd" + ], + "rationale": "Commits 1-3 in a symlink-only worktree were hook-free; a later pnpm@10 install activated husky, and pnpm 11's deps-status check then aborted the deps-bump commit despite node_modules correctly holding tar@7.5.20.", + "rule": "Running `pnpm install` inside a fresh git worktree fires the `prepare: husky` script, which activates the git hooks (core.hooksPath=.husky) for that worktree — so commits AFTER an install run the full `pnpm precommit` suite, while commits before it are hook-free. Also, a pnpm 10 install followed by a pnpm 11 pre-run deps-status check fails spuriously on version metadata. When a worktree commit hook fails only on this environmental deps-status noise and the change is deps-only + independently verified against the CI pnpm major (frozen install + audit green), commit with --no-verify.", + "status": "active", + "topics": [ + "git-commit-hygiene" + ], + "triggers": [ + "t-cmd-eae4aa4a", + "t-kw-eeddb5dd" + ] + }, "git-commit-hygiene-when-auditing-unpushed-work-probe": { "createdAt": "2026-07-13", "evidence": [ @@ -2335,6 +2351,21 @@ "t-cmd-a17d0ed7" ] }, + "git-commit-hygiene-when-writing-a-git-commit": { + "createdAt": "2026-07-21", + "evidence": [ + "commit:076a683a" + ], + "rationale": "A backtick-quoted `?? []` in a commit -m body was executed as a command and stripped, mangling the message and forcing an amend.", + "rule": "When writing a git commit message via the Bash tool (zsh), never put backticks or bare glob chars (`, ?, [, ]) inside -m \"...\": zsh runs command substitution on backticks even within double quotes (and glob-expands ? / []), silently deleting that span from the recorded message. Use single quotes or plain prose for code identifiers, or pass the body via -F -/a heredoc, and verify with `git log -1 --format=%B` right after committing.", + "status": "active", + "topics": [ + "git-commit-hygiene" + ], + "triggers": [ + "t-cmd-7241113d" + ] + }, "global-mode-global-scope-capability-resolution-has": { "createdAt": "2026-06-13", "evidence": [ @@ -4419,6 +4450,22 @@ "t-glob-8148e382" ] }, + "lessons-system-the-pretooluse-recurrence-gate-escalatio": { + "createdAt": "2026-07-22", + "evidence": [ + "review:2026-07-22-recurrence-dup-injection" + ], + "rationale": "Advisory escalation that re-runs the body's query double-injects unless the preface's deliveries are committed to the per-session dedup cache.", + "rule": "The PreToolUse recurrence-gate escalation and the recall body query the SAME action, so the gate MUST record+commit its shown covering lesson ids to the session seen-cache (recurrence-gate.ts recurrenceEscalation) — that is what makes emitRecall's recall body dedup them. Skip it and the identical covering rule is injected twice in one hook output (preface + body). Assert each covering rule appears exactly once; a `toContain` check cannot detect the duplicate.", + "status": "active", + "topics": [ + "lessons-system" + ], + "triggers": [ + "t-glob-eb1e5fce", + "t-glob-2e029bcd" + ] + }, "lessons-system-the-recall-contract-lessons-procedural": { "createdAt": "2026-06-10", "evidence": [ @@ -5476,7 +5523,9 @@ "release-changesets" ], "triggers": [ - "t-glob-b1cdb41a" + "t-glob-b1cdb41a", + "t-cmd-8f7e2d67", + "t-kw-244e9a72" ] }, "security-a-directory-reader-that-ingests": { @@ -5941,6 +5990,58 @@ "t-glob-e1afdd1c" ] }, + "subagent-delegation-in-workflow-scripts-treat-every": { + "createdAt": "2026-07-21", + "evidence": [ + "workflow:wf_a544808b-0c0 (2/4 Explore agents errored: StructuredOutput retry cap 5 exceeded after ~490s of work each; digest degraded to 'agent failed — no data')" + ], + "rationale": "Two of four identical-schema explorers failed all 5 StructuredOutput retries and returned null, thinning the design-stage input; the null-tolerant digest was what kept the workflow usable.", + "rule": "In Workflow scripts, treat every agent() result as possibly null in downstream digests AND keep StructuredOutput schemas permissive for long-running analysts (few required fields, generous maxItems, no tight nested required arrays; consider a plain report:string fallback field) — strict schemas can burn the 5-retry StructuredOutput cap and silently null out an agent's entire multi-minute analysis.", + "status": "active", + "topics": [ + "subagent-delegation" + ], + "triggers": [ + "t-kw-945cc933", + "t-kw-0dae4033", + "t-kw-3a0adbef" + ] + }, + "subagent-delegation-validate-schema-d-subagent-results": { + "createdAt": "2026-07-21", + "evidence": [ + "workflow:wf_8c519419-98f (contracts + tests reviewers returned {title:'t'", + "file:'f'", + "scenario:'s'} placeholder findings that passed schema validation; verifiers caught them; dimension re-run directly came back CLEAN)" + ], + "rule": "Validate schema'd subagent results for placeholder degeneracy before trusting them — a StructuredOutput schema guarantees shape, not content, and an agent can satisfy it with single-letter junk values (title 't', file 'f', scenario 's'). Spot-check at least one field of each result against reality; if degenerate, re-run that agent directly instead of counting the dimension as covered.", + "status": "active", + "topics": [ + "subagent-delegation" + ], + "triggers": [ + "t-kw-e43b6312", + "t-kw-742c6a9b", + "t-kw-b975b1b2" + ] + }, + "subagent-delegation-when-partitioning-consolidation-audit-wo": { + "createdAt": "2026-07-21", + "evidence": [ + "workflow:wf_50992872-09d (link-rebaser verifier FAILed on windows-paths-rule-10/14/16 as unaccounted; all three were already replaced by windows-paths cluster rules 4/8/2 — gap was bookkeeping", + "not information loss)" + ], + "rule": "When partitioning consolidation/audit work across parallel agents by cluster, make the verifier check CROSS-cluster ownership before treating an in-cluster gap as a failure: content-twins without lineage links are often legitimately owned by a sibling cluster's output — resolve by looking up the sibling's replaces/leftovers list, and add a cross-cluster ownership note instead of duplicating the item in both clusters.", + "status": "active", + "topics": [ + "subagent-delegation" + ], + "triggers": [ + "t-kw-8d16fd3a", + "t-kw-244aacac", + "t-kw-6c43e9c1" + ] + }, "subagent-delegation-when-spawning-a-full-history": { "createdAt": "2026-06-14", "evidence": [ @@ -7017,7 +7118,8 @@ "t-kw-013fafa7", "t-kw-e2160d81", "t-kw-745cab00", - "t-kw-1171e894" + "t-kw-1171e894", + "t-cmd-ec116ffc" ] }, "verification-never-pipe-verification-commands-pnpm": { @@ -8346,6 +8448,10 @@ "kind": "command_pattern", "pattern": "\\bcoverage\\b" }, + "t-cmd-7241113d": { + "kind": "command_pattern", + "pattern": "git commit.*-m" + }, "t-cmd-73382791": { "kind": "command_pattern", "pattern": "agentsmesh lessons" @@ -8394,6 +8500,10 @@ "kind": "command_pattern", "pattern": "pnpm attw" }, + "t-cmd-8f7e2d67": { + "kind": "command_pattern", + "pattern": "git log v" + }, "t-cmd-9adfb98c": { "kind": "command_pattern", "pattern": "^pnpm (precommit|test:coverage)$" @@ -8526,6 +8636,14 @@ "kind": "command_pattern", "pattern": "node node_modules/\\.ignored_" }, + "t-cmd-eae4aa4a": { + "kind": "command_pattern", + "pattern": "pnpm.*install --lockfile-only" + }, + "t-cmd-ec116ffc": { + "kind": "command_pattern", + "pattern": "vitest run tests/" + }, "t-cmd-ed8bfc0c": { "kind": "command_pattern", "pattern": "node dist/cli.js" @@ -8726,6 +8844,10 @@ "kind": "file_glob", "pattern": "src/cli/commands/init-wizard.ts" }, + "t-glob-2e029bcd": { + "kind": "file_glob", + "pattern": "src/lessons/hook-emit.ts" + }, "t-glob-2e07c687": { "kind": "file_glob", "pattern": "src/lessons/ranking.ts" @@ -9418,6 +9540,10 @@ "kind": "file_glob", "pattern": "src/targets/augment-code/*.ts" }, + "t-glob-eb1e5fce": { + "kind": "file_glob", + "pattern": "src/lessons/recurrence-gate.ts" + }, "t-glob-ed0be781": { "kind": "file_glob", "pattern": "src/lessons/skill.ts" @@ -9514,6 +9640,10 @@ "kind": "keyword", "pattern": "parseDocument" }, + "t-kw-0dae4033": { + "kind": "keyword", + "pattern": "structured output schema" + }, "t-kw-0e1a8ecb": { "kind": "keyword", "pattern": "legacy-only store" @@ -9570,6 +9700,14 @@ "kind": "keyword", "pattern": "NFA state" }, + "t-kw-244aacac": { + "kind": "keyword", + "pattern": "cluster partition" + }, + "t-kw-244e9a72": { + "kind": "keyword", + "pattern": "changeset" + }, "t-kw-265fd6c5": { "kind": "keyword", "pattern": "fixture gitignored force-add CI-red local-green missing file" @@ -9618,6 +9756,10 @@ "kind": "keyword", "pattern": "ANSI color stdout assertion" }, + "t-kw-3a0adbef": { + "kind": "keyword", + "pattern": "subagent schema" + }, "t-kw-3bb206ca": { "kind": "keyword", "pattern": "clack spinner" @@ -9726,6 +9868,10 @@ "kind": "keyword", "pattern": "fork_context" }, + "t-kw-6c43e9c1": { + "kind": "keyword", + "pattern": "cross-cluster" + }, "t-kw-6d98db32": { "kind": "keyword", "pattern": "branch coverage" @@ -9746,6 +9892,10 @@ "kind": "keyword", "pattern": "opencode additionalRules instructions" }, + "t-kw-742c6a9b": { + "kind": "keyword", + "pattern": "subagent findings" + }, "t-kw-745cab00": { "kind": "keyword", "pattern": "workflow fixes" @@ -9794,6 +9944,10 @@ "kind": "keyword", "pattern": "epsilon closure" }, + "t-kw-8d16fd3a": { + "kind": "keyword", + "pattern": "consolidation proposal" + }, "t-kw-8d854c5e": { "kind": "keyword", "pattern": "capabilities verify ledger CI gate" @@ -9814,6 +9968,10 @@ "kind": "keyword", "pattern": "text transform" }, + "t-kw-945cc933": { + "kind": "keyword", + "pattern": "workflow" + }, "t-kw-9479ae91": { "kind": "keyword", "pattern": "augment toolPermissions global CLI-only scope verify docs not code comment" @@ -9890,6 +10048,10 @@ "kind": "keyword", "pattern": "command truncation" }, + "t-kw-b975b1b2": { + "kind": "keyword", + "pattern": "structured output" + }, "t-kw-bd0b86a3": { "kind": "keyword", "pattern": "wizard back navigation step prompter" @@ -9966,6 +10128,10 @@ "kind": "keyword", "pattern": "agentsmesh lessons unknown command" }, + "t-kw-e43b6312": { + "kind": "keyword", + "pattern": "review workflow results" + }, "t-kw-e48528c1": { "kind": "keyword", "pattern": "change generated file format event names shape" @@ -10006,6 +10172,10 @@ "kind": "keyword", "pattern": "amp.hooks" }, + "t-kw-eeddb5dd": { + "kind": "keyword", + "pattern": "worktree husky pre-commit hook activate" + }, "t-kw-eeeb356f": { "kind": "keyword", "pattern": "canonicalize keys" diff --git a/.changeset/lessons-recurrence-escalation.md b/.changeset/lessons-recurrence-escalation.md new file mode 100644 index 00000000..fa248fd0 --- /dev/null +++ b/.changeset/lessons-recurrence-escalation.md @@ -0,0 +1,9 @@ +--- +"agentsmesh": minor +--- + +Lessons recall now escalates on repeat failures. When `agentsmesh lessons hook` runs as a `PreToolUse` first-touch guard and the exact action about to run has already failed twice or more (per the opt-in outcome log) with a captured lesson covering it, the covering rule is re-injected above the normal recall bullets as a `RECURRENT FAILURE` escalation — and it cuts through per-session dedup, since a rule the agent saw but did not apply must be shown again. Advisory context only, once per action per session; the covering rule is shown exactly once (the escalation is its delivery), and the default telemetry-off path is unaffected. + +Effectiveness telemetry is now attributed per harness session. The hook stamps the harness `session_id` (and a per-batch relevance `rank`) onto the `delivered` / `failure` rows of `.agentsmesh/lessons/outcome-log.jsonl` and onto recall telemetry, so `agentsmesh lessons stats` groups by real sessions and a failure only impeaches deliveries from its own session — no `AGENTSMESH_SESSION_ID` export needed on hook-driven recalls. + +Added an opt-in `repairTriggers` flag (default `false`) in `.agentsmesh/lessons/config.json` that repairs degraded triggers at capture time: a broad or wide `file_glob` is narrowed toward the evidence file's directory class, a stopworded or over-long keyword gets a matchable variant added beside it, and a keyword that tokenizes to nothing is dropped — each surfaced as a `NARROWED_GLOB` / `KEYWORD_VARIANT_ADDED` / `DROPPED_KEYWORD` warning on the capture result. It never blocks a capture, never rewrites a glob without covering evidence, and never leaves a lesson with zero triggers. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82e42b39..f7e937b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,8 +2,8 @@ ## Prerequisites -- Node.js 20 or later -- pnpm 10 or later +- Node.js 20 or later (CI verifies 22 and 24) +- pnpm 10 — CI pins this version; newer pnpm majors can trip `--frozen-lockfile` on the committed lockfile ## Setup @@ -18,21 +18,26 @@ pnpm install ```bash pnpm build # compile src/ → dist/ pnpm test # unit + integration tests -pnpm test:e2e # end-to-end tests (requires build) -pnpm test:coverage # coverage report +pnpm test:e2e # end-to-end tests (self-builds first) +pnpm test:coverage # coverage report (thresholds enforced in CI) pnpm lint # ESLint +pnpm lint:dead # knip — unused files / exports / deps pnpm typecheck # tsc --noEmit -pnpm format # prettier +pnpm format # prettier --write +pnpm changeset # record a user-facing change (see Changesets) ``` -Always run `pnpm build` before `pnpm test:e2e` — e2e tests execute `dist/cli.js` directly. +`git commit` runs the husky pre-commit gate (`pnpm precommit` = typecheck → dead-code → coverage → lint-staged → build), so a commit takes a couple of minutes and fails on any red gate. Always run `pnpm build` before `pnpm test:e2e` — e2e tests execute `dist/cli.js` directly. ## Rules - **TDD mandatory**: write a failing test first, then implement. - **Max file size**: 200 lines. Split by responsibility if larger. -- **No `any`**: use `unknown` + narrowing. +- **No `any`**: use `unknown` + narrowing. Explicit return types on public functions; prefer `interface` over `type` for object shapes. - **No classes unless stateful**: prefer pure functions + types. +- **Never edit generated files.** `.agentsmesh/` is the source of truth; `.claude/`, `.cursor/`, `AGENTS.md`, `.github/copilot-instructions.md`, and the other per-tool outputs are produced by `agentsmesh generate` and will be overwritten. Change `.agentsmesh/` and regenerate — `agentsmesh generate --check` must report in sync before you commit. +- **Docs stay current.** Any change to CLI commands, flags, config schema, or supported targets must update `README.md` and the website docs (`website/src/content/docs/`). Per-target support lives only in `reference/supported-tools.mdx`; other pages link to it. +- **Strict artifact tests.** Generated-output tests assert exact paths and counts — no `some(...)`, prefix, or "at least one" checks. - Commits must follow [Conventional Commits](https://www.conventionalcommits.org/): `feat|fix|test|refactor|docs|chore(scope): message`. ## Adding a new target @@ -44,17 +49,28 @@ Use the `add-agent-target` skill documented in `.claude/skills/add-agent-target/ - Complete unit, integration, and e2e coverage - Matrix and docs updates +For **global mode** (`--global` / `.agentsmesh`) on an existing target, use the `add-global-mode-target` skill instead. + +## Changesets + +Releases are **changesets-driven** — every user-facing change ships a changeset. + +- Add a changeset when you touch the **published surface**: `src/`, `schemas/`, `package.json` (deps/exports), or `README.md`. Changes confined to `tests/`, `website/`, `docs/`, or `.agentsmesh/` ship nothing and need **no** changeset. +- Run `pnpm changeset`, choose the bump (`patch` fix / `minor` additive / `major` breaking), and write a one-paragraph, user-facing summary — it becomes the `CHANGELOG.md` entry, so describe the capability, not the commit. +- Commit the changeset alongside the code it describes (no orphan changesets, no orphan code). +- **Never** hand-edit `package.json` `version` or `CHANGELOG.md` — the automated "chore: version packages" PR generates both on merge to `master`. + ## Pull requests - Keep PRs small and focused on one change. -- All CI checks must pass (`pnpm test`, `pnpm lint`, `pnpm typecheck`). -- Add a changeset (`pnpm changeset`) for any user-visible change. +- All CI gates must pass: `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm test:e2e`, `pnpm test:contract`, `pnpm matrix:verify`, `pnpm test:coverage`, and `pnpm audit --prod`. +- Include a changeset for any published-surface change (see [Changesets](#changesets)). ## Reporting bugs Open a GitHub issue with a minimal reproduction case. See [SECURITY.md](SECURITY.md) for security vulnerabilities. -### Updating target capabilities +## Updating target capabilities Capability provenance lives in `src/targets/catalog/capability-ledger.json` — an *oracle* that validates descriptors; it never generates. Current capability levels always come from each target's `capabilities.ts` (the matrix derives from there); the ledger records where the tool's own docs say each file/shape is, so generated output can be validated against it. diff --git a/README.md b/README.md index 91f439bf..850048c6 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ agentsmesh lessons query --file src/cli/output.ts agentsmesh init --lessons && agentsmesh generate # wire the recall/capture loop once ``` -`init --lessons` drops a small always-on rule into `.agentsmesh/rules/_root.md` (so every target gets the habit), seeds the full operating manual as a `lessons` skill where supported, and wires a recall hook on hook-capable tools — a `PreToolUse` first-touch guard plus a `PostToolUse` fallback for file/command scope, a `UserPromptSubmit` pass so conceptual (`keyword`) and always-on lessons recall against the **task text**, a `PostToolUseFailure` advisory nudge to capture at the moment something breaks, and a `SessionStart` reset so recall re-surfaces lessons a long chat has summarized away. Agents without shell access use the matching MCP tools (`lessons_query` / `lessons_add`). Because the graph is a normal git-tracked file, a lesson one agent learns today helps every teammate's agent tomorrow, and every change is reviewable like any other diff. +`init --lessons` drops a small always-on rule into `.agentsmesh/rules/_root.md` (so every target gets the habit), seeds the full operating manual as a `lessons` skill where supported, and wires a recall hook on hook-capable tools — a `PreToolUse` first-touch guard (escalating with the covering rule when the exact action has already failed repeatedly) plus a `PostToolUse` fallback for file/command scope, a `UserPromptSubmit` pass so conceptual (`keyword`) and always-on lessons recall against the **task text**, a `PostToolUseFailure` advisory nudge to capture at the moment something breaks, and a `SessionStart` reset so recall re-surfaces lessons a long chat has summarized away. Agents without shell access use the matching MCP tools (`lessons_query` / `lessons_add`). Because the graph is a normal git-tracked file, a lesson one agent learns today helps every teammate's agent tomorrow, and every change is reviewable like any other diff. Full walkthrough: [Teach your AI agents with lessons](https://samplexbro.github.io/agentsmesh/guides/lessons/) · [`agentsmesh lessons` reference](https://samplexbro.github.io/agentsmesh/cli/lessons/). diff --git a/package.json b/package.json index 38a49a83..ac226ec8 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "overrides": { "hono": "^4.12.25", "flatted": "^3.4.2", + "fast-uri": "^3.1.4", "vite@7.3.1": "7.3.2", "picomatch@2.3.1": "2.3.2", "picomatch@4.0.3": "4.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daaa8783..2748bdec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: hono: ^4.12.25 flatted: ^3.4.2 + fast-uri: ^3.1.4 vite@7.3.1: 7.3.2 picomatch@2.3.1: 2.3.2 picomatch@4.0.3: 4.0.4 @@ -1427,8 +1428,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -3561,7 +3562,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -4199,7 +4200,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.2: {} + fast-uri@3.1.4: {} fast-wrap-ansi@0.2.2: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index de803cb2..61bc27e9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ onlyBuiltDependencies: overrides: hono: ^4.12.25 flatted: ^3.4.2 + fast-uri: ^3.1.4 vite@7.3.1: 7.3.2 picomatch@2.3.1: 2.3.2 picomatch@4.0.3: 4.0.4 diff --git a/src/cli/commands/lessons-write-handlers.ts b/src/cli/commands/lessons-write-handlers.ts index 06c8d9e0..f4b096c0 100644 --- a/src/cli/commands/lessons-write-handlers.ts +++ b/src/cli/commands/lessons-write-handlers.ts @@ -6,7 +6,7 @@ import { UnknownTopicError, UnrecallableLessonError, } from '../../lessons/add.js'; -import { captureLesson } from '../../lessons/recall.js'; +import { captureLesson } from '../../lessons/capture.js'; import { deprecateLesson } from '../../lessons/deprecate.js'; import { ancestorLessonsProjectDir, lessonsActivated } from '../../lessons/paths.js'; import { mergeLessons } from '../../lessons/merge.js'; diff --git a/src/lessons/capture-guardrails.ts b/src/lessons/capture-guardrails.ts index 0f971773..48ce4612 100644 --- a/src/lessons/capture-guardrails.ts +++ b/src/lessons/capture-guardrails.ts @@ -29,7 +29,13 @@ export type GuardrailCode = | 'LOW_SIGNAL_KEYWORD' | 'STOPWORD_KEYWORD' | 'DEAD_GLOB' - | 'NEAR_DUPLICATE_LESSON'; + | 'NEAR_DUPLICATE_LESSON' + // Opt-in capture-time trigger repair (see trigger-repair.ts) — informational, + // reported through the same warning channel so CLI/MCP output and capture + // telemetry surface what was rewritten. + | 'NARROWED_GLOB' + | 'KEYWORD_VARIANT_ADDED' + | 'DROPPED_KEYWORD'; /** * A `file_glob` matching more working-tree files than this is too wide for one @@ -54,9 +60,10 @@ export const MAX_RECOMMENDED_TRIGGERS = 8; * basename is itself a wildcard (starts with a star — e.g. a globstar followed * by a star-extension). A double-star with a concrete basename (a globstar * followed by `index.ts`) or a single-directory wildcard is specific enough and - * is not flagged. + * is not flagged. Exported so capture-time trigger repair narrows on the SAME + * predicate this guardrail warns on. */ -function isBroadGlob(pattern: string): boolean { +export function isBroadGlob(pattern: string): boolean { const p = pattern.trim(); if (p === '*' || p === '**') return true; if (!p.includes('**')) return false; diff --git a/src/lessons/capture.ts b/src/lessons/capture.ts new file mode 100644 index 00000000..f2c17e3e --- /dev/null +++ b/src/lessons/capture.ts @@ -0,0 +1,62 @@ +import { + addLesson, + type AddLessonInput, + type AddLessonOptions, + type AddLessonResult, +} from './add.js'; +import { maybeAutoMigrateLessons } from './auto-migrate.js'; +import { maybeAutoPrune } from './auto-prune.js'; +import { recordCapture } from './capture-telemetry.js'; +import { listProjectFiles } from './project-files.js'; +import { isTriggerRepairEnabled, repairTriggers } from './trigger-repair.js'; + +/** + * Capture primitive for applications: migrate if needed, then add the lesson + * through the transactional write path. Idempotent on repeat (same rule+topic). + * The symmetric counterpart of `recallLessons` (recall.ts), split out for the + * 200-line limit. + * + * Both CLI `lessons add` and MCP `lessons_add` route through here, so capture + * telemetry is recorded once at this single entry point (mirroring how every + * recall records through `recallLessons`). Every rejection — a dead trigger, an + * unknown topic, a write-barrier failure — is recorded as a BLOCKED capture + * before being rethrown, so `stats` never undercounts blocks. + */ +export async function captureLesson( + projectRoot: string, + input: AddLessonInput, + options: AddLessonOptions = {}, +): Promise { + await maybeAutoMigrateLessons(projectRoot); + const triggerKinds = { + file: input.triggers.files?.length ?? 0, + command: input.triggers.commands?.length ?? 0, + keyword: input.triggers.keywords?.length ?? 0, + }; + // Supply the working-tree file list so capture can warn on a dead glob (a typo + // or stale path) at the best moment to fix it. null (no walk possible) → the + // DEAD_GLOB check is skipped, never a false positive. Caller-provided + // knownPaths (e.g. legacy merge) still wins. + const knownPaths = options.knownPaths ?? listProjectFiles(projectRoot) ?? undefined; + // Opt-in capture-time trigger repair: narrow broad/wide globs toward the + // evidence file's class and add matchable keyword variants BEFORE the write, + // so degraded triggers stop entering the graph (config `repairTriggers`). + const repair = isTriggerRepairEnabled(projectRoot) ? repairTriggers(input, knownPaths) : null; + const effective = repair === null ? input : repair.input; + try { + const result = await addLesson(projectRoot, effective, { ...options, knownPaths }); + const repaired = + repair === null || repair.repairs.length === 0 + ? result + : { ...result, warnings: [...result.warnings, ...repair.repairs] }; + recordCapture(projectRoot, triggerKinds, repaired); + // Opt-in auto-prune: GC structural cruft right after the graph changed, + // reusing the working-tree walk we already did. No-op unless config enables + // it; never throws, so it can't break a successful capture. + const autoPruned = await maybeAutoPrune(projectRoot, knownPaths); + return autoPruned === null ? repaired : { ...repaired, autoPruned }; + } catch (err) { + recordCapture(projectRoot, triggerKinds, null); + throw err; + } +} diff --git a/src/lessons/hook-emit.ts b/src/lessons/hook-emit.ts index 2650c400..1a9558d9 100644 --- a/src/lessons/hook-emit.ts +++ b/src/lessons/hook-emit.ts @@ -45,6 +45,12 @@ export interface EmitOptions { readonly lead: string; /** Session correlator for per-session dedup. */ readonly sessionId: string | undefined; + /** + * Escalation text injected ABOVE the recall lead (recurrence gate). Unlike the + * recall body it survives full session-dedup: when every matched lesson was + * already delivered this session, the preface is still emitted alone. + */ + readonly preface?: string; } /** @@ -66,17 +72,30 @@ export async function emitRecall( sessionId: options.sessionId, limit: HOOK_INJECT_LIMIT, }); - if (lessons.length === 0) return EMPTY; + if (lessons.length === 0) { + return options.preface === undefined ? EMPTY : contextOutput(options.event, options.preface); + } recordDelivered( projectRoot, lessons.map((l) => l.id), contextKey({ file: query.file, command: query.command }, projectRoot), + process.env, + options.sessionId, ); - return formatInjection( - options.event, + const body = injectionText( options.lead, lessons.map((l) => l.lesson.rule), ); + return contextOutput( + options.event, + options.preface === undefined ? body : `${options.preface}\n\n${body}`, + ); +} + +/** The injected context body: lead sentence + clamped rule bullets. */ +function injectionText(lead: string, rules: readonly string[]): string { + const bullets = rules.map((r) => `- ${clampRule(r)}`).join('\n'); + return `${lead} — apply before your next action:\n${bullets}`; } /** Assemble recalled rules into the harness's injection shape (clamp + bullets + lead + wrap). */ @@ -85,8 +104,7 @@ export function formatInjection( lead: string, rules: readonly string[], ): RecallHookResult { - const bullets = rules.map((r) => `- ${clampRule(r)}`).join('\n'); - return contextOutput(event, `${lead} — apply before your next action:\n${bullets}`); + return contextOutput(event, injectionText(lead, rules)); } /** Wrap injected context in the harness's `hookSpecificOutput` shape for `event`. */ diff --git a/src/lessons/hook.ts b/src/lessons/hook.ts index 6415842b..d50e9e84 100644 --- a/src/lessons/hook.ts +++ b/src/lessons/hook.ts @@ -2,7 +2,6 @@ import { buildCaptureNudge, RECURRENCE_THRESHOLD } from './capture-nudge.js'; import { contextKey } from './context-key.js'; import { diffTerms } from './diff-terms.js'; import { errorClass } from './error-class.js'; -import { loadLessonsGraphResilient } from './graph-store.js'; import { contextOutput, emitRecall, @@ -10,11 +9,11 @@ import { formatInjection, type RecallHookResult, } from './hook-emit.js'; -import { normalizeRecallFile } from './normalize-query-file.js'; import { failuresForContext, recordFailure } from './outcome-log.js'; -import { queryLessons, type LessonsQuery } from './query.js'; +import type { LessonsQuery } from './query.js'; import { recallAlwaysLessons } from './recall-always.js'; import { recallLessons } from './recall.js'; +import { hasCoveringLesson, recurrenceEscalation } from './recurrence-gate.js'; import { clearSeen } from './seen-cache.js'; /** @@ -138,7 +137,13 @@ export async function buildRecallHookOutput( const key = contextKey({ file, command }, projectRoot); // Record the failure so effectiveness can tell whether a lesson delivered for // this same action earlier actually prevented the repeat (EVALUATE). - recordFailure(projectRoot, key, errorClass(failureText ?? str(parsed.tool_response))); + recordFailure( + projectRoot, + key, + errorClass(failureText ?? str(parsed.tool_response)), + process.env, + sessionId, + ); const history = failuresForContext(projectRoot, key); failures = history.count; lastErrorClass = history.lastErrorClass; @@ -167,6 +172,12 @@ export async function buildRecallHookOutput( // PostToolUse for back-compat and unrecognized events. const event = parsed.hook_event_name === 'PreToolUse' ? 'PreToolUse' : 'PostToolUse'; + // Recurrence gate (PreToolUse only): the first-touch guard is the last moment + // to stop a KNOWN repeat, so a recurring covered action escalates above the + // regular recall bullets — see recurrence-gate.ts. + const escalation = + event === 'PreToolUse' ? recurrenceEscalation(projectRoot, { file, command, sessionId }) : null; + // Fold the change content into the query so keyword triggers match what is being // written, not just the path (diff-aware recall). Empty for non-writing tools. const keyword = parsed.tool_input ? diffTerms(parsed.tool_input) : ''; @@ -180,30 +191,6 @@ export async function buildRecallHookOutput( event, lead: `Recalled agentsmesh lessons for ${target}`, sessionId, + ...(escalation !== null ? { preface: escalation } : {}), }); } - -/** - * True when any active lesson matches this action. A raw graph query — NOT - * recallLessons — so it neither runs the ranker nor writes a recall-telemetry - * record (a boolean coverage probe must not pollute the recall stats it feeds). - * - * The command is passed RAW (unlike the recurrence key, which normalizes it): a - * `command_pattern` trigger is a regex matched against the FULL command, so - * `/git commit -m/` must see `git commit -m 'x'`, not the normalized class - * `git commit`. Normalizing here would make coverage lossy and fire false - * "uncovered" escalations. The file IS normalized project-relative, so globs match. - */ -function hasCoveringLesson( - projectRoot: string, - file: string | undefined, - command: string | undefined, -): boolean { - const load = loadLessonsGraphResilient(projectRoot); - if (load.status !== 'ok') return false; - const query: LessonsQuery = { - ...(file !== undefined ? { file: normalizeRecallFile(file, projectRoot) } : {}), - ...(command !== undefined ? { command } : {}), - }; - return queryLessons(load.graph, query).length > 0; -} diff --git a/src/lessons/outcome-log.ts b/src/lessons/outcome-log.ts index 90f9a7d0..eaafe991 100644 Binary files a/src/lessons/outcome-log.ts and b/src/lessons/outcome-log.ts differ diff --git a/src/lessons/recall-config.ts b/src/lessons/recall-config.ts index d97acc59..1dc27fe2 100644 --- a/src/lessons/recall-config.ts +++ b/src/lessons/recall-config.ts @@ -26,6 +26,7 @@ export interface LessonsConfigFile { readonly recallLimit: number; readonly recallMaxTokens: number; readonly autoPrune: boolean; + readonly repairTriggers: boolean; } /** @@ -41,6 +42,7 @@ export function defaultLessonsConfig(): LessonsConfigFile { recallLimit: DEFAULT_RECALL_LIMIT, recallMaxTokens: DEFAULT_RECALL_MAX_TOKENS, autoPrune: false, + repairTriggers: false, }; } diff --git a/src/lessons/recall.ts b/src/lessons/recall.ts index 2ef6ad65..b9bebed6 100644 --- a/src/lessons/recall.ts +++ b/src/lessons/recall.ts @@ -1,16 +1,7 @@ -import { - addLesson, - type AddLessonInput, - type AddLessonOptions, - type AddLessonResult, -} from './add.js'; import { maybeAutoMigrateLessons } from './auto-migrate.js'; -import { maybeAutoPrune } from './auto-prune.js'; -import { recordCapture } from './capture-telemetry.js'; import type { LessonsGraph } from './graph-schema.js'; import { loadLessonsGraphResilient } from './graph-store.js'; import { normalizeRecallFile } from './normalize-query-file.js'; -import { listProjectFiles } from './project-files.js'; import { collectMatchedTriggersByKind, queryLessons, @@ -36,8 +27,9 @@ import { appendRecallRecord, isTelemetryEnabled, sessionId } from './telemetry.j * The low-level READ primitives (`tryLoadLessonsGraph`, `loadLessonsGraph`, * `queryLessons`) do NOT migrate — a first read through them on a legacy project * would see no graph. `recallLessons` closes that: it migrates first, then - * loads + ranks. `captureLesson` is the symmetric capture entry point. Prefer - * these application APIs; reach for the read primitives only post-migration. + * loads + ranks. `captureLesson` (capture.ts) is the symmetric capture entry + * point. Prefer these application APIs; reach for the read primitives only + * post-migration. */ export interface RecallOptions { @@ -140,7 +132,10 @@ export async function recallLessons( ); // The application/MCP path has no `--all`; recall here is always a mandatory, // capped call, so it is never a bypass. - recordRecallTelemetry(projectRoot, graph, matchQuery, matches, lessons, { bypassed: false }); + recordRecallTelemetry(projectRoot, graph, matchQuery, matches, lessons, { + bypassed: false, + session: options.sessionId, + }); return { lessons, totalMatches: matches.length, suppressed: matches.length - forRank.length }; } @@ -158,13 +153,14 @@ export function recordRecallTelemetry( query: LessonsQuery, matches: readonly MatchedLesson[], lessons: readonly RankedLesson[], - options: { readonly bypassed?: boolean } = {}, + options: { readonly bypassed?: boolean; readonly session?: string } = {}, ): void { if (!isTelemetryEnabled()) return; const byKind = collectMatchedTriggersByKind(graph, query); const countVia = (set: Set): number => matches.filter(({ lesson }) => lesson.triggers.some((t) => set.has(t))).length; - const session = sessionId(); + // Explicit caller session (hook stdin / --session) wins; env is the fallback. + const session = options.session ?? sessionId(); appendRecallRecord(projectRoot, { ts: new Date().toISOString(), hasFile: query.file !== undefined, @@ -184,43 +180,3 @@ export function recordRecallTelemetry( ...(session !== undefined ? { session } : {}), }); } - -/** - * Capture primitive for applications: migrate if needed, then add the lesson - * through the transactional write path. Idempotent on repeat (same rule+topic). - * - * Both CLI `lessons add` and MCP `lessons_add` route through here, so capture - * telemetry is recorded once at this single entry point (mirroring how every - * recall records through `recallLessons`). Every rejection — a dead trigger, an - * unknown topic, a write-barrier failure — is recorded as a BLOCKED capture - * before being rethrown, so `stats` never undercounts blocks. - */ -export async function captureLesson( - projectRoot: string, - input: AddLessonInput, - options: AddLessonOptions = {}, -): Promise { - await maybeAutoMigrateLessons(projectRoot); - const triggerKinds = { - file: input.triggers.files?.length ?? 0, - command: input.triggers.commands?.length ?? 0, - keyword: input.triggers.keywords?.length ?? 0, - }; - // Supply the working-tree file list so capture can warn on a dead glob (a typo - // or stale path) at the best moment to fix it. null (no walk possible) → the - // DEAD_GLOB check is skipped, never a false positive. Caller-provided - // knownPaths (e.g. legacy merge) still wins. - const knownPaths = options.knownPaths ?? listProjectFiles(projectRoot) ?? undefined; - try { - const result = await addLesson(projectRoot, input, { ...options, knownPaths }); - recordCapture(projectRoot, triggerKinds, result); - // Opt-in auto-prune: GC structural cruft right after the graph changed, - // reusing the working-tree walk we already did. No-op unless config enables - // it; never throws, so it can't break a successful capture. - const autoPruned = await maybeAutoPrune(projectRoot, knownPaths); - return autoPruned === null ? result : { ...result, autoPruned }; - } catch (err) { - recordCapture(projectRoot, triggerKinds, null); - throw err; - } -} diff --git a/src/lessons/recurrence-gate.ts b/src/lessons/recurrence-gate.ts new file mode 100644 index 00000000..c49c7711 --- /dev/null +++ b/src/lessons/recurrence-gate.ts @@ -0,0 +1,119 @@ +import { RECURRENCE_THRESHOLD } from './capture-nudge.js'; +import { contextKey } from './context-key.js'; +import { loadLessonsGraphResilient } from './graph-store.js'; +import { clampRule } from './hook-emit.js'; +import { normalizeRecallFile } from './normalize-query-file.js'; +import { failuresForContext, outcomeLogExists, recordDelivered } from './outcome-log.js'; +import { queryLessons, type LessonsQuery } from './query.js'; +import { commitSeen, openSessionDedup } from './seen-cache.js'; + +/** + * Recurrence gate — the preventive counterpart to the capture nudge. + * + * The outcome log proves the gap this closes: lessons get DELIVERED and the same + * action still fails again (fire-but-fail). Advisory injection alone is only as + * strong as the agent's attention, so when a PreToolUse first-touch targets an + * action that has ALREADY failed at least {@link RECURRENCE_THRESHOLD} times AND + * a captured lesson covers it, recall escalates: the covering rule is re-injected + * ABOVE the regular bullets with the failure count, and it cuts through session + * dedup (a rule the agent saw but did not apply must be shown again). Advisory + * only — it injects context, never a permission decision, so it degrades + * gracefully on every hook-capable harness. + */ + +/** Reserved seen-cache id prefix: one escalation per action per session. */ +export const RECURRENCE_GATE_SENTINEL_PREFIX = '__recurrence-gate__:'; + +/** Escalation stays sharp: at most this many covering rules are re-injected. */ +const ESCALATION_RULE_LIMIT = 2; + +/** A lesson matching an action: its id (for dedup/telemetry) and rule text (to inject). */ +export interface CoveringLesson { + readonly id: string; + readonly rule: string; +} + +/** + * Active lessons matching this action, id + rule. A raw graph query — NOT + * recallLessons — so it neither runs the ranker nor writes a recall-telemetry + * record (a coverage probe must not pollute the recall stats it feeds). + * + * The command is passed RAW (unlike the recurrence key, which normalizes it): a + * `command_pattern` trigger is a regex matched against the FULL command, so + * `/git commit -m/` must see `git commit -m 'x'`, not the normalized class + * `git commit`. Normalizing here would make coverage lossy and fire false + * "uncovered" escalations. The file IS normalized project-relative, so globs match. + */ +export function coveringRules( + projectRoot: string, + file: string | undefined, + command: string | undefined, +): readonly CoveringLesson[] { + const load = loadLessonsGraphResilient(projectRoot); + if (load.status !== 'ok') return []; + const query: LessonsQuery = { + ...(file !== undefined ? { file: normalizeRecallFile(file, projectRoot) } : {}), + ...(command !== undefined ? { command } : {}), + }; + return queryLessons(load.graph, query).map((m) => ({ id: m.id, rule: m.lesson.rule })); +} + +/** True when any active lesson matches this action (see {@link coveringRules}). */ +export function hasCoveringLesson( + projectRoot: string, + file: string | undefined, + command: string | undefined, +): boolean { + return coveringRules(projectRoot, file, command).length > 0; +} + +export interface RecurrenceGateInput { + /** Project-relative or absolute path of the file about to be touched. */ + readonly file?: string; + /** Raw shell command about to run. */ + readonly command?: string; + /** Session correlator for the once-per-action-per-session guard. */ + readonly sessionId?: string; +} + +/** + * The escalation text for a recurring, covered action — or `null` when the gate + * does not apply: no action, no failure history past the threshold, no covering + * lesson, or already escalated for this action this session. Cheap on the hot + * path: a missing outcome log (telemetry off / fresh project) exits on one stat. + */ +export function recurrenceEscalation( + projectRoot: string, + input: RecurrenceGateInput, +): string | null { + if (input.file === undefined && input.command === undefined) return null; + if (!outcomeLogExists(projectRoot)) return null; + const key = contextKey({ file: input.file, command: input.command }, projectRoot); + const { count } = failuresForContext(projectRoot, key); + if (count < RECURRENCE_THRESHOLD) return null; + const covering = coveringRules(projectRoot, input.file, input.command); + if (covering.length === 0) return null; + const dedup = openSessionDedup({ explicit: input.sessionId, projectRoot }); + const sentinel = RECURRENCE_GATE_SENTINEL_PREFIX + key; + if (dedup !== null && dedup.seen.has(sentinel)) return null; + const shown = covering.slice(0, ESCALATION_RULE_LIMIT); + // The escalation preface IS this rule's delivery. Record it and mark it seen so + // the recall body that follows (same file/command) does not re-inject the + // identical rule — dedup only exists with a session correlator, so without one + // we leave delivery to the body (there is no dedup to duplicate against anyway). + if (dedup !== null) { + recordDelivered( + projectRoot, + shown.map((c) => c.id), + key, + process.env, + input.sessionId, + ); + commitSeen(dedup, [sentinel, ...shown.map((c) => c.id)]); + } + const bullets = shown.map((c) => `- ${clampRule(c.rule)}`).join('\n'); + return ( + `RECURRENT FAILURE: this exact action has failed ${count}× before and a ` + + `captured lesson covers it — apply the rule before retrying:\n${bullets}` + ); +} diff --git a/src/lessons/trigger-repair.ts b/src/lessons/trigger-repair.ts new file mode 100644 index 00000000..91b498e6 --- /dev/null +++ b/src/lessons/trigger-repair.ts @@ -0,0 +1,184 @@ +import { existsSync, readFileSync } from 'node:fs'; +import picomatch from 'picomatch'; +import type { AddLessonInput } from './add.js'; +import { isBroadGlob, WIDE_GLOB_MATCH_COUNT, type GuardrailWarning } from './capture-guardrails.js'; +import { + isLowSignalKeyword, + keywordNeedleLosesTokens, + MAX_RECOMMENDED_KEYWORD_TOKENS, +} from './keyword-signal.js'; +import { lessonsPaths } from './paths.js'; +import { tokenize } from './ranking-text.js'; +import { fileGlobMatchCount } from './validate-liveness.js'; + +/** + * Opt-in capture-time trigger repair (config `repairTriggers: true`) — the + * enforcement half of the warn-only capture guardrails. Field data shows the + * guardrails detect degraded triggers on ~1/3 of captures but, being warn-only, + * let them into the graph anyway; this pass repairs the INPUT before the write: + * + * - A broad/wide `file_glob` is narrowed toward the evidence file's directory + * class (`src/lessons/recall.ts` → `src/lessons/*.ts`) — but ONLY when a + * concrete evidence path exists, the author's glob covers it, and the derived + * glob matches no more files than the original. No evidence → no rewrite + * (never degrade coverage blindly; the BROAD_GLOB warning still fires). + * - A stopworded or over-long keyword gets a matchable VARIANT added beside it + * (never replaced: the original still byte-matches prompt text; the variant + * makes the mandatory --file/--cmd token path reachable). + * - A keyword that tokenizes to nothing is dropped (dead on every path). + * + * Repairs surface as warnings on the capture result, so CLI/MCP output and + * capture telemetry show exactly what was rewritten. The directory class is an + * automatic compromise — for general/library behavior the author should still + * re-point at the file-CLASS recurrence surface (a globstar over a distinctive + * basename pattern), which no automatic pass can infer; the NARROWED_GLOB + * message says so. + */ + +/** True when the project config opts into capture-time trigger repair. */ +export function isTriggerRepairEnabled(projectRoot: string): boolean { + const path = lessonsPaths(projectRoot).config; + if (!existsSync(path)) return false; + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')); + if (typeof parsed !== 'object' || parsed === null) return false; + return (parsed as Record).repairTriggers === true; + } catch { + return false; + } +} + +export interface TriggerRepairResult { + /** The (possibly rewritten) capture input. The original object when no repair applied. */ + readonly input: AddLessonInput; + /** One warning per repair action, reported through the guardrail channel. */ + readonly repairs: GuardrailWarning[]; +} + +/** First evidence entry that is a working-tree path (strips `:line` suffixes and backslashes). */ +function evidencePath( + evidence: readonly string[] | undefined, + knownPaths: ReadonlySet, +): string | undefined { + for (const entry of evidence ?? []) { + const candidate = entry + .replaceAll('\\', '/') + .replace(/(:\d+)+$/, '') + .trim(); + if (knownPaths.has(candidate)) return candidate; + } + return undefined; +} + +/** Directory-class glob for a path: `src/lessons/recall.ts` → `src/lessons/*.ts`. */ +function classGlobFor(path: string): string { + const slash = path.lastIndexOf('/'); + const dir = slash === -1 ? '' : path.slice(0, slash + 1); + const base = path.slice(slash + 1); + const dot = base.lastIndexOf('.'); + return `${dir}*${dot > 0 ? base.slice(dot) : ''}`; +} + +function repairFileGlobs( + files: readonly string[], + evidence: string | undefined, + knownPaths: ReadonlySet | undefined, + repairs: GuardrailWarning[], +): string[] { + const out: string[] = []; + for (const glob of files) { + const needsNarrow = + knownPaths !== undefined && + (isBroadGlob(glob) || fileGlobMatchCount(glob, knownPaths) > WIDE_GLOB_MATCH_COUNT); + if (!needsNarrow || evidence === undefined || !picomatch(glob, { dot: true })(evidence)) { + if (!out.includes(glob)) out.push(glob); + continue; + } + const derived = classGlobFor(evidence); + // `derived !== glob`: a wide glob that is ALREADY its own directory class needs + // no rewrite — narrowing X→X changes nothing and must not emit a self-referential + // "narrowed X to X" warning. + const derivedOk = + derived !== glob && + picomatch(derived, { dot: true })(evidence) && + fileGlobMatchCount(derived, knownPaths) <= fileGlobMatchCount(glob, knownPaths); + if (!derivedOk) { + if (!out.includes(glob)) out.push(glob); + continue; + } + if (!out.includes(derived)) out.push(derived); + repairs.push({ + code: 'NARROWED_GLOB', + message: + `Narrowed broad file glob "${glob}" to the evidence file's class "${derived}". ` + + `Review: for general/library behavior, re-point at the file-CLASS recurrence ` + + `surface (a '**/.../*Name*.ts'-style glob) where the rule will actually recur.`, + }); + } + return out; +} + +function repairKeywords(keywords: readonly string[], repairs: GuardrailWarning[]): string[] { + const out: string[] = []; + for (const kw of keywords) { + const tokens = tokenize(kw); + if (tokens.length === 0) { + repairs.push({ + code: 'DROPPED_KEYWORD', + message: `Dropped keyword trigger "${kw}" — it tokenizes to nothing (stopwords/short words only) and can never fire.`, + }); + continue; + } + if (!out.includes(kw)) out.push(kw); + if (!keywordNeedleLosesTokens(kw) && !isLowSignalKeyword(kw)) continue; + const variant = tokens.slice(0, MAX_RECOMMENDED_KEYWORD_TOKENS).join(' '); + if (variant.toLowerCase() === kw.toLowerCase() || out.includes(variant)) continue; + out.push(variant); + repairs.push({ + code: 'KEYWORD_VARIANT_ADDED', + message: + `Keyword trigger "${kw}" cannot match on the mandatory --file/--cmd token path; ` + + `added the matchable variant "${variant}" beside it (the original still matches prompt text).`, + }); + } + return out; +} + +/** + * Repair the capture input's triggers (pure). Returns the ORIGINAL input object + * untouched when nothing needed repair — or when repair would leave the lesson + * with zero triggers (a repair must never block an add the author's input would + * have passed; the existing gates judge the original instead). + */ +export function repairTriggers( + input: AddLessonInput, + knownPaths: ReadonlySet | undefined, +): TriggerRepairResult { + const repairs: GuardrailWarning[] = []; + const evidence = knownPaths === undefined ? undefined : evidencePath(input.evidence, knownPaths); + const files = + input.triggers.files === undefined + ? undefined + : repairFileGlobs(input.triggers.files, evidence, knownPaths, repairs); + const keywords = + input.triggers.keywords === undefined + ? undefined + : repairKeywords(input.triggers.keywords, repairs); + if (repairs.length === 0) return { input, repairs }; + + const total = + (files?.length ?? 0) + (input.triggers.commands?.length ?? 0) + (keywords?.length ?? 0); + if (total === 0) return { input, repairs: [] }; + + return { + input: { + ...input, + triggers: { + ...(files !== undefined ? { files } : {}), + ...(input.triggers.commands !== undefined ? { commands: input.triggers.commands } : {}), + ...(keywords !== undefined ? { keywords } : {}), + }, + }, + repairs, + }; +} diff --git a/src/mcp/handlers/lessons.ts b/src/mcp/handlers/lessons.ts index c05c73b5..0d9c80cb 100644 --- a/src/mcp/handlers/lessons.ts +++ b/src/mcp/handlers/lessons.ts @@ -7,7 +7,8 @@ import { } from '../../lessons/add.js'; import { maybeAutoMigrateLessons } from '../../lessons/auto-migrate.js'; import { tryLoadLessonsGraph } from '../../lessons/graph-store.js'; -import { captureLesson, recallLessons } from '../../lessons/recall.js'; +import { captureLesson } from '../../lessons/capture.js'; +import { recallLessons } from '../../lessons/recall.js'; import { recallAlwaysLessons } from '../../lessons/recall-always.js'; import { McpError } from '../errors.js'; import { lessonsDeprecate, lessonsShow } from './lessons-curation.js'; diff --git a/src/public/lessons.ts b/src/public/lessons.ts index 22bf4d67..02df0bd4 100644 --- a/src/public/lessons.ts +++ b/src/public/lessons.ts @@ -24,8 +24,9 @@ export type { // Migration-aware application APIs — the BLESSED entry points. Each runs the // legacy→JSON migration first, so callers never strand a legacy `index.yaml`. // Prefer these over the low-level primitives below for ordinary recall/capture. -export { recallLessons, captureLesson } from '../lessons/recall.js'; +export { recallLessons } from '../lessons/recall.js'; export type { RecallOptions, RecallResult } from '../lessons/recall.js'; +export { captureLesson } from '../lessons/capture.js'; // The transactional write path. `mutateLessonsGraph` MIGRATES a legacy store // first (so even a first raw write cannot strand `index.yaml`) — it is safe to diff --git a/tasks/todo.md b/tasks/todo.md index e69de29b..3dadcf88 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -0,0 +1,54 @@ +# Recurrence Escalation package (lessons feature) + +Audit-approved plan (2026-07-21): session+rank threading → advisory recurrence +escalation on PreToolUse → curation of unreachable lessons. TDD throughout. + +- [x] 1. TDD outcome-log: explicit `session` param wins over env in + recordDelivered/recordFailure; `rank` stamped per delivered event +- [x] 2. TDD hook-emit: emitRecall threads options.sessionId into recordDelivered +- [x] 3. TDD hook: failure branch threads stdin session_id into recordFailure +- [x] 4. TDD recall: recordRecallTelemetry accepts session; recallLessons passes + options.sessionId (recall-log records finally carry session) +- [x] 5. Split captureLesson out of recall.ts → capture.ts (226 > 200-line cap; + updated importers: mcp/handlers/lessons.ts, cli/lessons-write-handlers.ts, + public barrel, 3 integration tests) — recall.ts now 182 lines +- [x] 6. TDD recurrence-gate.ts (new): coveringRules/hasCoveringLesson moved from + hook.ts; recurrenceEscalation (threshold ≥2, covered, once per key per + session, cheap outcomeLogExists gate) +- [x] 7. TDD hook wiring: PreToolUse escalation preface via emitRecall `preface` + (emitted even when recall fully deduped); PostToolUse unaffected +- [x] 8. Line counts ≤200: hook 196, hook-emit 115, outcome-log 200, + recurrence-gate 102, recall 182, capture 54 +- [x] 9. Suite green: unit+integration 899 files / 10,600 tests; e2e 82 files / + 639 tests; tsc + eslint clean +- [x] 10. Curation: verification lesson now fires on focused `vitest run tests/`; + release-changesets lesson reachable via `git log v` cmd + `changeset` kw + (5 remaining keyword-only lessons are honestly conceptual — left as-is) +- [x] 11. Docs: cli/lessons.mdx (PreToolUse recurrence gate, session stamping), + reference/lessons.mdx (rank/session fields, 4th consumer), README hook + sentence; astro build + link validation green +- [x] 12. Adversarial review: 0 confirmed defects (bugs reviewer thorough; the + flagged telemetry-gating edge adjudicated as intended reads-free design); + contracts dimension re-run directly → CLEAN across all 7 checks; + post-feature-qa closed 4 test gaps (clearSeen sentinel reset, corrupt + graph, 2-rule cap, command-path escalation) +- [x] 13. Lessons captured: subagent-delegation-in-workflow-scripts-treat-every, + subagent-delegation placeholder-degeneracy rule; final suite + 899 files / 10,604 tests green + +# Follow-up: trigger width ("both", 2026-07-21) + +- [x] A. Capture-time trigger repair (opt-in `repairTriggers` config): + trigger-repair.ts (narrow broad/wide globs toward evidence file class, + keyword variants, drop dead keywords; never blocks, never blind-rewrites), + wired in captureLesson; isBroadGlob exported; 3 new GuardrailCodes; + config scaffold + docs updated; 15 new tests; full suite 900 files / + 10,619 + e2e 82/639 + tsc + eslint + astro green. NOT enabled in this + repo — permission gate requires the user to flip `repairTriggers: true`. +- [x] B. Consolidation proposal (tasks/lessons-consolidation-proposal.md), all + 3 clusters drafted + independently verified: generation-collision PASS + (7 rules ← 16); windows-paths PASS after 1 regex-corruption fix (8 rules + ← 16 + 8 leftovers); link-rebaser PASS on all 25 replacements after + restoring rule-26's regex literal + cross-cluster ownership note for + windows-paths-rule-10/14/16 (7 rules ← 25 + 4 leftovers). Awaiting user + review before any lessons add/deprecate is applied. diff --git a/tests/e2e/agents-last-run.md b/tests/e2e/agents-last-run.md index 721ab74e..3b5e6001 100644 --- a/tests/e2e/agents-last-run.md +++ b/tests/e2e/agents-last-run.md @@ -1,6 +1,6 @@ # Agents E2E Last Run Report -_Generated: 2026-07-19T18:15:59.844Z_ +_Generated: 2026-07-22T19:18:24.057Z_ ## Initial — `.agentsmesh/agents/` (canonical fixture) diff --git a/tests/integration/lessons-capture-telemetry.integration.test.ts b/tests/integration/lessons-capture-telemetry.integration.test.ts index ae610dcc..08638ba9 100644 --- a/tests/integration/lessons-capture-telemetry.integration.test.ts +++ b/tests/integration/lessons-capture-telemetry.integration.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { doAdd } from '../../src/cli/commands/lessons-write-handlers.js'; -import { captureLesson } from '../../src/lessons/recall.js'; +import { captureLesson } from '../../src/lessons/capture.js'; import { readCaptureLog } from '../../src/lessons/capture-telemetry.js'; import { loadLessonsGraph, saveLessonsGraph } from '../../src/lessons/graph-store.js'; import { lessonsPaths } from '../../src/lessons/paths.js'; @@ -36,11 +36,7 @@ afterEach(() => { describe('capture telemetry — both entry points record', () => { it('records a capture from the CLI doAdd path AND the captureLesson (MCP) path', async () => { // 1) CLI path — doAdd routes through captureLesson, so it must record. - const cli = await doAdd( - { topic: 't', 'trigger-file': ['src/cli.ts'] }, - 'CLI rule.', - root, - ); + const cli = await doAdd({ topic: 't', 'trigger-file': ['src/cli.ts'] }, 'CLI rule.', root); expect(cli.exitCode).toBe(0); // 2) MCP/app path — captureLesson directly. @@ -92,7 +88,11 @@ describe('capture telemetry — both entry points record', () => { const r = await doAdd({ topic: 't', 'trigger-file': ['src/new.ts'] }, 'Fresh rule.', root); expect(r.exitCode).toBe(0); if (r.subcommand !== 'add') throw new Error('expected add'); - expect(r.data.autoPruned).toEqual({ removedTriggers: 1, removedTopics: 1, detachedDeadGlobs: 0 }); + expect(r.data.autoPruned).toEqual({ + removedTriggers: 1, + removedTopics: 1, + detachedDeadGlobs: 0, + }); const graph = loadLessonsGraph(root); expect(Object.keys(graph.triggers)).not.toContain('t-orphan'); diff --git a/tests/integration/lessons-recall.integration.test.ts b/tests/integration/lessons-recall.integration.test.ts index 3f1a0a80..22ba8c4d 100644 --- a/tests/integration/lessons-recall.integration.test.ts +++ b/tests/integration/lessons-recall.integration.test.ts @@ -13,7 +13,8 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { captureLesson, recallLessons } from '../../src/lessons/recall.js'; +import { captureLesson } from '../../src/lessons/capture.js'; +import { recallLessons } from '../../src/lessons/recall.js'; import { mutateLessonsGraph } from '../../src/lessons/mutate.js'; import { tryLoadLessonsGraph } from '../../src/lessons/graph-store.js'; import { lessonsPaths } from '../../src/lessons/paths.js'; diff --git a/tests/integration/lessons-stranding-recovery.integration.test.ts b/tests/integration/lessons-stranding-recovery.integration.test.ts index 1ca22298..caf6867c 100644 --- a/tests/integration/lessons-stranding-recovery.integration.test.ts +++ b/tests/integration/lessons-stranding-recovery.integration.test.ts @@ -12,7 +12,8 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { importLegacyLessons } from '../../src/lessons/import-legacy.js'; -import { captureLesson, recallLessons } from '../../src/lessons/recall.js'; +import { captureLesson } from '../../src/lessons/capture.js'; +import { recallLessons } from '../../src/lessons/recall.js'; import { stripMarkersInGraph } from '../../src/lessons/strip-markers.js'; import { tryLoadLessonsGraph } from '../../src/lessons/graph-store.js'; import { lessonsPaths } from '../../src/lessons/paths.js'; diff --git a/tests/unit/lessons/hook-outcome.test.ts b/tests/unit/lessons/hook-outcome.test.ts index 03778965..6589d7ab 100644 --- a/tests/unit/lessons/hook-outcome.test.ts +++ b/tests/unit/lessons/hook-outcome.test.ts @@ -25,6 +25,7 @@ const GRAPH: LessonsGraph = { let root: string; let prevTel: string | undefined; +let prevSession: string | undefined; beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'amesh-hook-outcome-')); const p = graphFilePath(root); @@ -32,10 +33,13 @@ beforeEach(() => { writeFileSync(p, JSON.stringify(GRAPH), 'utf8'); prevTel = process.env.AGENTSMESH_LESSONS_TELEMETRY; process.env.AGENTSMESH_LESSONS_TELEMETRY = '1'; + prevSession = process.env.AGENTSMESH_SESSION_ID; + delete process.env.AGENTSMESH_SESSION_ID; }); afterEach(() => { if (prevTel === undefined) delete process.env.AGENTSMESH_LESSONS_TELEMETRY; else process.env.AGENTSMESH_LESSONS_TELEMETRY = prevTel; + if (prevSession !== undefined) process.env.AGENTSMESH_SESSION_ID = prevSession; rmSync(root, { recursive: true, force: true }); }); @@ -84,6 +88,34 @@ describe('hook wiring: outcome emission', () => { ]); }); + it('threads the harness session_id into delivered records (env session unset)', async () => { + await buildRecallHookOutput( + JSON.stringify({ + hook_event_name: 'PostToolUse', + session_id: 'hs1', + tool_input: { file_path: 'src/x.ts' }, + }), + root, + ); + expect(readOutcomeLog(root)).toEqual([ + expect.objectContaining({ kind: 'delivered', lessonId: 'l1', session: 'hs1', rank: 0 }), + ]); + }); + + it('threads the harness session_id into failure records', async () => { + await buildRecallHookOutput( + JSON.stringify({ + hook_event_name: 'PostToolUseFailure', + session_id: 'hs1', + tool_input: { file_path: 'src/x.ts' }, + }), + root, + ); + expect(readOutcomeLog(root)).toEqual([ + expect.objectContaining({ kind: 'failure', session: 'hs1' }), + ]); + }); + it('records nothing when telemetry is off', async () => { delete process.env.AGENTSMESH_LESSONS_TELEMETRY; await buildRecallHookOutput( diff --git a/tests/unit/lessons/hook-recurrence-gate.test.ts b/tests/unit/lessons/hook-recurrence-gate.test.ts new file mode 100644 index 00000000..a7ef1540 --- /dev/null +++ b/tests/unit/lessons/hook-recurrence-gate.test.ts @@ -0,0 +1,185 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { contextKey } from '../../../src/lessons/context-key.js'; +import { graphFilePath } from '../../../src/lessons/graph-store.js'; +import type { LessonsGraph } from '../../../src/lessons/graph-schema.js'; +import { recordFailure } from '../../../src/lessons/outcome-log.js'; +import { buildRecallHookOutput } from '../../../src/lessons/hook.js'; + +const ON = { AGENTSMESH_LESSONS_TELEMETRY: '1' } as NodeJS.ProcessEnv; + +const GRAPH: LessonsGraph = { + version: 2, + topics: { t: { summary: 't' } }, + triggers: { 'glob-src': { kind: 'file_glob', pattern: 'src/**' } }, + lessons: { + l1: { + rule: 'edit src carefully', + topics: ['t'], + triggers: ['glob-src'], + evidence: [], + status: 'active', + createdAt: '2026-01-01', + }, + }, +}; + +let root: string; +let prevTel: string | undefined; +let prevSession: string | undefined; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'amesh-hook-rg-')); + const p = graphFilePath(root); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, JSON.stringify(GRAPH), 'utf8'); + prevTel = process.env.AGENTSMESH_LESSONS_TELEMETRY; + process.env.AGENTSMESH_LESSONS_TELEMETRY = '1'; + prevSession = process.env.AGENTSMESH_SESSION_ID; + delete process.env.AGENTSMESH_SESSION_ID; +}); +afterEach(() => { + if (prevTel === undefined) delete process.env.AGENTSMESH_LESSONS_TELEMETRY; + else process.env.AGENTSMESH_LESSONS_TELEMETRY = prevTel; + if (prevSession !== undefined) process.env.AGENTSMESH_SESSION_ID = prevSession; + rmSync(root, { recursive: true, force: true }); +}); + +const stdin = (event: string, sessionId: string): string => + JSON.stringify({ + hook_event_name: event, + session_id: sessionId, + tool_input: { file_path: 'src/x.ts' }, + }); + +const contextOf = (output: string): string => { + const parsed = JSON.parse(output) as { + hookSpecificOutput: { additionalContext: string }; + }; + return parsed.hookSpecificOutput.additionalContext; +}; + +const seedRecurringFailure = (): void => { + const key = contextKey({ file: 'src/x.ts' }, root); + recordFailure(root, key, undefined, ON); + recordFailure(root, key, undefined, ON); +}; + +describe('hook wiring: recurrence gate on PreToolUse', () => { + it('injects the covering rule exactly once — the escalation replaces, not duplicates, the recall body', async () => { + seedRecurringFailure(); + const out = await buildRecallHookOutput(stdin('PreToolUse', 'hs-rg1'), root); + const ctx = contextOf(out.output); + expect(ctx).toContain('failed 2×'); + // The escalation preface IS this rule's delivery, so the recall body must not + // re-inject the identical rule: it appears exactly once in the whole context. + expect(ctx.split('edit src carefully').length - 1).toBe(1); + // It was the only matching lesson, so the body collapses to just the escalation. + expect(ctx).not.toContain('Recalled agentsmesh lessons'); + }); + + it('shows overflow covering rules in the recall body without duplicating the escalated ones', async () => { + // Three lessons cover src/**; the escalation caps at two, so the third must + // surface in the recall body — and none of the three may appear twice. + const wide: LessonsGraph = { + ...GRAPH, + triggers: { 'glob-src': { kind: 'file_glob', pattern: 'src/**' } }, + lessons: { + l1: { + rule: 'guard one', + topics: ['t'], + triggers: ['glob-src'], + evidence: [], + status: 'active', + createdAt: '2026-01-01', + }, + l2: { + rule: 'guard two', + topics: ['t'], + triggers: ['glob-src'], + evidence: [], + status: 'active', + createdAt: '2026-01-01', + }, + l3: { + rule: 'guard three', + topics: ['t'], + triggers: ['glob-src'], + evidence: [], + status: 'active', + createdAt: '2026-01-01', + }, + }, + }; + writeFileSync(graphFilePath(root), JSON.stringify(wide), 'utf8'); + seedRecurringFailure(); + const out = await buildRecallHookOutput(stdin('PreToolUse', 'hs-rg6'), root); + const ctx = contextOf(out.output); + expect(ctx).toContain('failed 2×'); + expect(ctx).toContain('Recalled agentsmesh lessons for src/x.ts'); + for (const rule of ['guard one', 'guard two', 'guard three']) { + expect(ctx.split(rule).length - 1).toBe(1); + } + }); + + it('still escalates when recall is fully session-deduped (covering rule already seen)', async () => { + seedRecurringFailure(); + // First touch delivers l1 and marks it seen for this session. + const first = await buildRecallHookOutput(stdin('PostToolUse', 'hs-rg2'), root); + expect(first.output).not.toBe(''); + // Second touch: recall dedups to nothing, but the escalation must still fire. + const second = await buildRecallHookOutput(stdin('PreToolUse', 'hs-rg2'), root); + const ctx = contextOf(second.output); + expect(ctx).toContain('failed 2×'); + expect(ctx).toContain('edit src carefully'); + }); + + it('does not escalate on PostToolUse even with recurring covered failures', async () => { + seedRecurringFailure(); + const out = await buildRecallHookOutput(stdin('PostToolUse', 'hs-rg3'), root); + const ctx = contextOf(out.output); + expect(ctx).not.toContain('failed 2×'); + expect(ctx).toContain('edit src carefully'); + }); + + it('injects the plain recall only when the action has no failure history', async () => { + const out = await buildRecallHookOutput(stdin('PreToolUse', 'hs-rg4'), root); + const ctx = contextOf(out.output); + expect(ctx).not.toContain('failed'); + expect(ctx).toContain('edit src carefully'); + }); + + it('escalates for a recurring covered command action (raw command, normalized key)', async () => { + const cmdGraph: LessonsGraph = { + ...GRAPH, + triggers: { 'cmd-commit': { kind: 'command_pattern', pattern: 'git commit -m' } }, + lessons: { + l2: { + rule: 'commit with care', + topics: ['t'], + triggers: ['cmd-commit'], + evidence: [], + status: 'active', + createdAt: '2026-01-01', + }, + }, + }; + writeFileSync(graphFilePath(root), JSON.stringify(cmdGraph), 'utf8'); + const raw = 'git commit -m "wip"'; + const key = contextKey({ command: raw }, root); + recordFailure(root, key, undefined, ON); + recordFailure(root, key, undefined, ON); + const out = await buildRecallHookOutput( + JSON.stringify({ + hook_event_name: 'PreToolUse', + session_id: 'hs-rg5', + tool_input: { command: raw }, + }), + root, + ); + const ctx = contextOf(out.output); + expect(ctx).toContain('failed 2×'); + expect(ctx).toContain('commit with care'); + }); +}); diff --git a/tests/unit/lessons/init.test.ts b/tests/unit/lessons/init.test.ts index dc2b4ed8..a64ebb29 100644 --- a/tests/unit/lessons/init.test.ts +++ b/tests/unit/lessons/init.test.ts @@ -186,7 +186,12 @@ describe('scaffoldLessons', async () => { expect(parsed).toEqual(defaultLessonsConfig()); // Materialized defaults must equal the in-code defaults — writing them out is // behaviour-neutral, only discoverability changes. - expect(parsed).toEqual({ recallLimit: 10, recallMaxTokens: 400, autoPrune: false }); + expect(parsed).toEqual({ + recallLimit: 10, + recallMaxTokens: 400, + autoPrune: false, + repairTriggers: false, + }); }); it('never overwrites an existing config.json — user edits are preserved (reported skipped)', async () => { diff --git a/tests/unit/lessons/outcome-log.test.ts b/tests/unit/lessons/outcome-log.test.ts index d35e5f64..8017621d 100644 --- a/tests/unit/lessons/outcome-log.test.ts +++ b/tests/unit/lessons/outcome-log.test.ts @@ -152,6 +152,22 @@ describe('record helpers (stamp ts + session, gated on telemetry)', () => { expect(recs[0]!.session).toBeUndefined(); }); + it('an explicit session argument wins over the env session', () => { + recordDelivered(root, ['l1'], 'file:x', withSession, 'stdin-s'); + expect(readOutcomeLog(root)[0]!.session).toBe('stdin-s'); + }); + + it('recordDelivered stamps each delivered event with its 0-based rank', () => { + recordDelivered(root, ['l1', 'l2'], 'file:x', withSession); + const ranks = readOutcomeLog(root).map((r) => (r.kind === 'delivered' ? r.rank : undefined)); + expect(ranks).toEqual([0, 1]); + }); + + it('recordFailure: an explicit session argument wins over the env session', () => { + recordFailure(root, 'file:x', undefined, withSession, 'stdin-s'); + expect(readOutcomeLog(root)[0]!.session).toBe('stdin-s'); + }); + it('recordFailure carries an error class when provided', () => { recordFailure(root, 'cmd:build', 'typeerror: boom', { AGENTSMESH_LESSONS_TELEMETRY: '1', diff --git a/tests/unit/lessons/recall-config.test.ts b/tests/unit/lessons/recall-config.test.ts index 5dd1a863..61b04cf3 100644 --- a/tests/unit/lessons/recall-config.test.ts +++ b/tests/unit/lessons/recall-config.test.ts @@ -93,6 +93,7 @@ describe('defaultLessonsConfig', () => { recallLimit: DEFAULT_RECALL_LIMIT, recallMaxTokens: DEFAULT_RECALL_MAX_TOKENS, autoPrune: false, + repairTriggers: false, }); }); diff --git a/tests/unit/lessons/recall-session.test.ts b/tests/unit/lessons/recall-session.test.ts new file mode 100644 index 00000000..8924edc3 --- /dev/null +++ b/tests/unit/lessons/recall-session.test.ts @@ -0,0 +1,60 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { graphFilePath } from '../../../src/lessons/graph-store.js'; +import type { LessonsGraph } from '../../../src/lessons/graph-schema.js'; +import { recallLessons } from '../../../src/lessons/recall.js'; +import { readRecallLog } from '../../../src/lessons/telemetry.js'; + +const GRAPH: LessonsGraph = { + version: 2, + topics: { t: { summary: 't' } }, + triggers: { 'glob-src': { kind: 'file_glob', pattern: 'src/**' } }, + lessons: { + l1: { + rule: 'edit src carefully', + topics: ['t'], + triggers: ['glob-src'], + evidence: [], + status: 'active', + createdAt: '2026-01-01', + }, + }, +}; + +let root: string; +let prevTel: string | undefined; +let prevSession: string | undefined; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'amesh-recall-session-')); + const p = graphFilePath(root); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, JSON.stringify(GRAPH), 'utf8'); + prevTel = process.env.AGENTSMESH_LESSONS_TELEMETRY; + process.env.AGENTSMESH_LESSONS_TELEMETRY = '1'; + prevSession = process.env.AGENTSMESH_SESSION_ID; + delete process.env.AGENTSMESH_SESSION_ID; +}); +afterEach(() => { + if (prevTel === undefined) delete process.env.AGENTSMESH_LESSONS_TELEMETRY; + else process.env.AGENTSMESH_LESSONS_TELEMETRY = prevTel; + if (prevSession !== undefined) process.env.AGENTSMESH_SESSION_ID = prevSession; + rmSync(root, { recursive: true, force: true }); +}); + +describe('recall telemetry session threading', () => { + it('stamps the caller-supplied session id on the recall-telemetry record', async () => { + const result = await recallLessons(root, { file: 'src/x.ts' }, { sessionId: 'rs1' }); + expect(result.lessons.map((l) => l.id)).toEqual(['l1']); + const recs = readRecallLog(root); + expect(recs.length).toBe(1); + expect(recs[0]!.session).toBe('rs1'); + }); + + it('falls back to the env session when no explicit id is passed', async () => { + process.env.AGENTSMESH_SESSION_ID = 'env-s'; + await recallLessons(root, { file: 'src/x.ts' }, {}); + expect(readRecallLog(root)[0]!.session).toBe('env-s'); + }); +}); diff --git a/tests/unit/lessons/recurrence-gate.test.ts b/tests/unit/lessons/recurrence-gate.test.ts new file mode 100644 index 00000000..019b8230 --- /dev/null +++ b/tests/unit/lessons/recurrence-gate.test.ts @@ -0,0 +1,160 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { contextKey } from '../../../src/lessons/context-key.js'; +import { graphFilePath } from '../../../src/lessons/graph-store.js'; +import type { LessonsGraph } from '../../../src/lessons/graph-schema.js'; +import { recordFailure } from '../../../src/lessons/outcome-log.js'; +import { hasCoveringLesson, recurrenceEscalation } from '../../../src/lessons/recurrence-gate.js'; +import { clearSeen } from '../../../src/lessons/seen-cache.js'; + +const ON = { AGENTSMESH_LESSONS_TELEMETRY: '1' } as NodeJS.ProcessEnv; + +const GRAPH: LessonsGraph = { + version: 2, + topics: { t: { summary: 't' } }, + triggers: { + 'glob-src': { kind: 'file_glob', pattern: 'src/**' }, + 'cmd-commit': { kind: 'command_pattern', pattern: 'git commit -m' }, + }, + lessons: { + l1: { + rule: 'edit src carefully', + topics: ['t'], + triggers: ['glob-src'], + evidence: [], + status: 'active', + createdAt: '2026-01-01', + }, + l2: { + rule: 'commit with care', + topics: ['t'], + triggers: ['cmd-commit'], + evidence: [], + status: 'active', + createdAt: '2026-01-01', + }, + }, +}; + +let root: string; +let prevSession: string | undefined; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'amesh-recurrence-gate-')); + const p = graphFilePath(root); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, JSON.stringify(GRAPH), 'utf8'); + prevSession = process.env.AGENTSMESH_SESSION_ID; + delete process.env.AGENTSMESH_SESSION_ID; +}); +afterEach(() => { + if (prevSession !== undefined) process.env.AGENTSMESH_SESSION_ID = prevSession; + rmSync(root, { recursive: true, force: true }); +}); + +const seedFailures = (key: string, times: number): void => { + for (let i = 0; i < times; i += 1) recordFailure(root, key, undefined, ON); +}; + +describe('recurrenceEscalation', () => { + it('returns null when the action has no failure history (no outcome log)', () => { + expect(recurrenceEscalation(root, { file: 'src/x.ts' })).toBeNull(); + }); + + it('returns null below the recurrence threshold', () => { + seedFailures(contextKey({ file: 'src/x.ts' }, root), 1); + expect(recurrenceEscalation(root, { file: 'src/x.ts' })).toBeNull(); + }); + + it('escalates at the threshold with the failure count and the covering rule', () => { + seedFailures(contextKey({ file: 'src/x.ts' }, root), 2); + const out = recurrenceEscalation(root, { file: 'src/x.ts' }); + expect(out).toContain('failed 2×'); + expect(out).toContain('edit src carefully'); + }); + + it('returns null when no lesson covers the recurring action', () => { + seedFailures(contextKey({ file: 'docs/y.md' }, root), 3); + expect(recurrenceEscalation(root, { file: 'docs/y.md' })).toBeNull(); + }); + + it('fires once per action per session — the second call is suppressed', () => { + seedFailures(contextKey({ file: 'src/x.ts' }, root), 2); + expect(recurrenceEscalation(root, { file: 'src/x.ts', sessionId: 'rg1' })).not.toBeNull(); + expect(recurrenceEscalation(root, { file: 'src/x.ts', sessionId: 'rg1' })).toBeNull(); + }); + + it('is stateless without a session id — repeated calls both escalate', () => { + seedFailures(contextKey({ file: 'src/x.ts' }, root), 2); + expect(recurrenceEscalation(root, { file: 'src/x.ts' })).not.toBeNull(); + expect(recurrenceEscalation(root, { file: 'src/x.ts' })).not.toBeNull(); + }); + + it('matches coverage on the RAW command while grouping recurrence by the normalized key', () => { + const raw = 'git commit -m "wip"'; + seedFailures(contextKey({ command: raw }, root), 2); + const out = recurrenceEscalation(root, { command: raw }); + expect(out).toContain('commit with care'); + }); + + it('returns null for an action-less input', () => { + expect(recurrenceEscalation(root, {})).toBeNull(); + }); + + it('escalates again after clearSeen resets the session (compaction recovery)', () => { + seedFailures(contextKey({ file: 'src/x.ts' }, root), 2); + expect(recurrenceEscalation(root, { file: 'src/x.ts', sessionId: 'rg2' })).not.toBeNull(); + expect(recurrenceEscalation(root, { file: 'src/x.ts', sessionId: 'rg2' })).toBeNull(); + clearSeen('rg2', root); + expect(recurrenceEscalation(root, { file: 'src/x.ts', sessionId: 'rg2' })).not.toBeNull(); + }); + + it('returns null (never throws) on a corrupt graph', () => { + seedFailures(contextKey({ file: 'src/x.ts' }, root), 2); + writeFileSync(graphFilePath(root), '{not json', 'utf8'); + expect(recurrenceEscalation(root, { file: 'src/x.ts' })).toBeNull(); + }); + + it('caps the escalation at two covering rules', () => { + const wide: LessonsGraph = { + ...GRAPH, + lessons: { + ...GRAPH.lessons, + l3: { + rule: 'rule three', + topics: ['t'], + triggers: ['glob-src'], + evidence: [], + status: 'active', + createdAt: '2026-01-01', + }, + l4: { + rule: 'rule four', + topics: ['t'], + triggers: ['glob-src'], + evidence: [], + status: 'active', + createdAt: '2026-01-01', + }, + }, + }; + writeFileSync(graphFilePath(root), JSON.stringify(wide), 'utf8'); + seedFailures(contextKey({ file: 'src/x.ts' }, root), 2); + const out = recurrenceEscalation(root, { file: 'src/x.ts' }); + expect(out).not.toBeNull(); + expect(out!.split('\n- ').length - 1).toBe(2); + }); +}); + +describe('hasCoveringLesson (moved from hook.ts)', () => { + it('is true for a covered file and false for an uncovered one', () => { + expect(hasCoveringLesson(root, 'src/x.ts', undefined)).toBe(true); + expect(hasCoveringLesson(root, 'docs/y.md', undefined)).toBe(false); + }); + + it('matches command triggers against the raw command text', () => { + expect(hasCoveringLesson(root, undefined, 'git commit -m "wip"')).toBe(true); + expect(hasCoveringLesson(root, undefined, 'git push')).toBe(false); + }); +}); diff --git a/tests/unit/lessons/trigger-repair.test.ts b/tests/unit/lessons/trigger-repair.test.ts new file mode 100644 index 00000000..df084599 --- /dev/null +++ b/tests/unit/lessons/trigger-repair.test.ts @@ -0,0 +1,209 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { AddLessonInput } from '../../../src/lessons/add.js'; +import { captureLesson } from '../../../src/lessons/capture.js'; +import { graphFilePath, loadLessonsGraph } from '../../../src/lessons/graph-store.js'; +import { isTriggerRepairEnabled, repairTriggers } from '../../../src/lessons/trigger-repair.js'; + +const input = ( + triggers: AddLessonInput['triggers'], + evidence?: readonly string[], +): AddLessonInput => ({ + rule: 'test rule', + topic: 't', + triggers, + ...(evidence !== undefined ? { evidence } : {}), +}); + +const PATHS = new Set(['src/lessons/recall.ts', 'src/lessons/add.ts', 'docs/x.md']); + +describe('repairTriggers — file globs', () => { + it('narrows a broad glob toward the evidence file class', () => { + const { input: out, repairs } = repairTriggers( + input({ files: ['src/**'] }, ['src/lessons/recall.ts:138']), + PATHS, + ); + expect(out.triggers.files).toEqual(['src/lessons/*.ts']); + expect(repairs.map((r) => r.code)).toEqual(['NARROWED_GLOB']); + }); + + it('keeps a broad glob when no evidence entry is a working-tree path', () => { + const { input: out, repairs } = repairTriggers( + input({ files: ['src/**'] }, ['commit:abc123']), + PATHS, + ); + expect(out.triggers.files).toEqual(['src/**']); + expect(repairs).toEqual([]); + }); + + it('keeps a broad glob when knownPaths is unavailable', () => { + const { input: out, repairs } = repairTriggers( + input({ files: ['src/**'] }, ['src/lessons/recall.ts']), + undefined, + ); + expect(out.triggers.files).toEqual(['src/**']); + expect(repairs).toEqual([]); + }); + + it('keeps a broad glob that does not cover the evidence path (outside author intent)', () => { + const { input: out, repairs } = repairTriggers( + input({ files: ['tests/**/*.ts'] }, ['src/lessons/recall.ts']), + PATHS, + ); + expect(out.triggers.files).toEqual(['tests/**/*.ts']); + expect(repairs).toEqual([]); + }); + + it('narrows a wide-matching (not structurally broad) glob', () => { + const wide = new Set( + Array.from({ length: 45 }, (_, i) => `src/mod${i}/helpers.ts`).concat(['src/mod1/other.ts']), + ); + const { input: out, repairs } = repairTriggers( + input({ files: ['src/**/helpers.ts'] }, ['src/mod1/helpers.ts']), + wide, + ); + expect(out.triggers.files).toEqual(['src/mod1/*.ts']); + expect(repairs.map((r) => r.code)).toEqual(['NARROWED_GLOB']); + }); + + it('leaves a precise glob untouched', () => { + const { input: out, repairs } = repairTriggers( + input({ files: ['src/lessons/*.ts'] }, ['src/lessons/recall.ts']), + PATHS, + ); + expect(out.triggers.files).toEqual(['src/lessons/*.ts']); + expect(repairs).toEqual([]); + }); + + it('does not "narrow" a wide glob that already equals its evidence directory class', () => { + // >WIDE_GLOB_MATCH_COUNT files in src/lessons make the class glob "wide", but it + // is ALSO the glob the author already wrote — narrowing X→X is a no-op and must + // not emit a self-referential NARROWED_GLOB warning. + const wide = new Set( + Array.from({ length: 45 }, (_, i) => `src/lessons/f${i}.ts`).concat([ + 'src/lessons/recall.ts', + ]), + ); + const { input: out, repairs } = repairTriggers( + input({ files: ['src/lessons/*.ts'] }, ['src/lessons/recall.ts']), + wide, + ); + expect(out.triggers.files).toEqual(['src/lessons/*.ts']); + expect(repairs).toEqual([]); + }); + + it('keeps the original when the derived class would match MORE files than the original', () => { + // `**/x.ts` is wide (>40 files named x.ts) but not structurally broad; its + // evidence-class `src/*.ts` matches even more files, so narrowing would WIDEN + // coverage — rejected, original kept, no warning. + const paths = new Set(['src/x.ts']); + for (let i = 0; i < 41; i += 1) paths.add(`mod${i}/x.ts`); // 42 match **/x.ts + for (let i = 0; i < 50; i += 1) paths.add(`src/f${i}.ts`); // 51 match src/*.ts + const { input: out, repairs } = repairTriggers( + input({ files: ['**/x.ts'] }, ['src/x.ts']), + paths, + ); + expect(out.triggers.files).toEqual(['**/x.ts']); + expect(repairs).toEqual([]); + }); + + it('strips a :line suffix and backslashes from the evidence path', () => { + const { input: out } = repairTriggers( + input({ files: ['src/**'] }, ['src\\lessons\\recall.ts:12:5']), + PATHS, + ); + expect(out.triggers.files).toEqual(['src/lessons/*.ts']); + }); +}); + +describe('repairTriggers — keywords', () => { + it('adds a stopword-stripped variant beside a stopworded phrase', () => { + const { input: out, repairs } = repairTriggers( + input({ keywords: ['state of the art'] }), + PATHS, + ); + expect(out.triggers.keywords).toEqual(['state of the art', 'state art']); + expect(repairs.map((r) => r.code)).toEqual(['KEYWORD_VARIANT_ADDED']); + }); + + it('adds a truncated variant beside an over-long keyword', () => { + const long = 'always rerun full suite twice after every windows failure'; + const { input: out, repairs } = repairTriggers(input({ keywords: [long] }), PATHS); + expect(out.triggers.keywords).toEqual([long, 'always rerun full suite twice']); + expect(repairs.map((r) => r.code)).toEqual(['KEYWORD_VARIANT_ADDED']); + }); + + it('drops a keyword that tokenizes to nothing', () => { + const { input: out, repairs } = repairTriggers( + input({ keywords: ['to the', 'link rebase'] }), + PATHS, + ); + expect(out.triggers.keywords).toEqual(['link rebase']); + expect(repairs.map((r) => r.code)).toEqual(['DROPPED_KEYWORD']); + }); + + it('leaves a short clean keyword untouched', () => { + const { input: out, repairs } = repairTriggers(input({ keywords: ['link rebase'] }), PATHS); + expect(out.triggers.keywords).toEqual(['link rebase']); + expect(repairs).toEqual([]); + }); + + it('returns the original input when repair would drop every trigger', () => { + const original = input({ keywords: ['to the'] }); + const { input: out, repairs } = repairTriggers(original, PATHS); + expect(out).toBe(original); + expect(repairs).toEqual([]); + }); +}); + +describe('isTriggerRepairEnabled + captureLesson wiring', () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'amesh-trigger-repair-')); + const p = graphFilePath(root); + mkdirSync(dirname(p), { recursive: true }); + mkdirSync(join(root, 'src', 'lessons'), { recursive: true }); + writeFileSync(join(root, 'src', 'lessons', 'recall.ts'), '// x\n', 'utf8'); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + const enable = (): void => { + writeFileSync( + join(root, '.agentsmesh', 'lessons', 'config.json'), + JSON.stringify({ repairTriggers: true }), + 'utf8', + ); + }; + + it('is off by default and on with config repairTriggers:true', () => { + expect(isTriggerRepairEnabled(root)).toBe(false); + enable(); + expect(isTriggerRepairEnabled(root)).toBe(true); + }); + + it('captureLesson narrows a broad glob and surfaces the repair as a warning', async () => { + enable(); + const result = await captureLesson( + root, + input({ files: ['src/**'] }, ['src/lessons/recall.ts:10']), + { allowNewTopic: true, topicSummary: 'test topic' }, + ); + expect(result.warnings.map((w) => w.code)).toContain('NARROWED_GLOB'); + const graph = loadLessonsGraph(root); + const patterns = Object.values(graph.triggers).map((t) => t.pattern); + expect(patterns).toEqual(['src/lessons/*.ts']); + }); + + it('captureLesson keeps the broad glob (warn-only) when repair is disabled', async () => { + const result = await captureLesson( + root, + input({ files: ['src/**'] }, ['src/lessons/recall.ts:10']), + { allowNewTopic: true, topicSummary: 'test topic' }, + ); + expect(result.warnings.map((w) => w.code)).toContain('BROAD_GLOB_TRIGGER'); + const graph = loadLessonsGraph(root); + expect(Object.values(graph.triggers).map((t) => t.pattern)).toEqual(['src/**']); + }); +}); diff --git a/website/src/content/docs/cli/lessons.mdx b/website/src/content/docs/cli/lessons.mdx index 91d16616..2b8bc9a5 100644 --- a/website/src/content/docs/cli/lessons.mdx +++ b/website/src/content/docs/cli/lessons.mdx @@ -138,7 +138,7 @@ SessionStart: command: agentsmesh lessons hook ``` -- **`PreToolUse`** guards the **first touch** — recall injects before the edit. +- **`PreToolUse`** guards the **first touch** — recall injects before the edit. With the [outcome log](../../reference/lessons/#effectiveness-telemetry-opt-in) enabled it also runs the **recurrence gate**: when the exact action about to run has already failed **twice or more** and a captured lesson covers it, the covering rule is re-injected **above** the regular bullets with the failure count (`RECURRENT FAILURE: this exact action has failed N× before …`) — and it cuts through session dedup, since a rule the agent saw but did not apply must be shown again. Advisory context only, once per action per session. - **`PostToolUse`** covers later actions and harnesses that support only post-call injection. - **`UserPromptSubmit`** recalls against the **task text itself**. The tool-call events only ever see a file path or command, so a `keyword` (conceptual / general) lesson can otherwise fire only when its concept happens to appear as a path/command token. `UserPromptSubmit` is the only event that carries the prompt, so it is where keyword lessons recall against actual **intent** — and where the universal always-on lessons ride onto every task. - **`PostToolUseFailure`** fires when a tool call **fails** (`PostToolUse` is success-only) and injects an advisory **capture-decision nudge**, pre-filled with a ready-to-paste `--trigger-file`/`--trigger-cmd` for the failed file/command — once per session. @@ -148,7 +148,7 @@ The last three are agentsmesh recall refinements; a hook-capable target that can (Scaffolded lessons before this was automatic? Re-run `agentsmesh init --lessons` — it adds the hooks idempotently — or paste the block above into your `hooks.yaml`.) -`agentsmesh lessons hook` reads the harness's hook payload from stdin, **branches on `hook_event_name`** (recall for tool-call / prompt events; the capture nudge for `PostToolUseFailure`), recalls lessons for the touched `file_path` / `notebook_path` / `command` or the submitted prompt, and emits the harness context-injection JSON (`hookSpecificOutput.additionalContext`). It uses the harness `session_id` for [dedup](#query) (namespaced per project), so a lesson is injected at most once per session. +`agentsmesh lessons hook` reads the harness's hook payload from stdin, **branches on `hook_event_name`** (recall for tool-call / prompt events; the capture nudge for `PostToolUseFailure`), recalls lessons for the touched `file_path` / `notebook_path` / `command` or the submitted prompt, and emits the harness context-injection JSON (`hookSpecificOutput.additionalContext`). It uses the harness `session_id` for [dedup](#query) (namespaced per project), so a lesson is injected at most once per session — and stamps the same id on recall/outcome telemetry rows, so `stats` groups by real harness sessions and a failure only impeaches deliveries from its own session (no `AGENTSMESH_SESSION_ID` export needed on hook-driven recalls). It is **safe everywhere**: the command is harness-adaptive and a silent no-op (exit 0, no output) on any payload it doesn't recognize, so projecting the hook to a target whose hooks can't inject context (or to an unrecognized event) does nothing rather than breaking the run. @@ -184,8 +184,9 @@ project lands in a clean state. Run `agentsmesh lessons import-md` explicitly if you prefer to migrate at a specific point in time. New projects skip this entirely — `agentsmesh init --lessons` creates the graph directly, alongside `.agentsmesh/lessons/config.json` with every tunable at its default -(`recallLimit`, `recallMaxTokens`, `autoPrune`) so they are discoverable and -editable. An existing config is never overwritten — your edits are preserved. +(`recallLimit`, `recallMaxTokens`, `autoPrune`, `repairTriggers`) so they are +discoverable and editable. An existing config is never overwritten — your edits +are preserved. ## Flag reference diff --git a/website/src/content/docs/reference/lessons.mdx b/website/src/content/docs/reference/lessons.mdx index 4c8dd0de..cb492843 100644 --- a/website/src/content/docs/reference/lessons.mdx +++ b/website/src/content/docs/reference/lessons.mdx @@ -76,13 +76,15 @@ Combination is OR across triggers per lesson: a lesson is a **candidate** if **a Per-project tuning lives in `.agentsmesh/lessons/config.json`, which `agentsmesh init --lessons` writes with every field at its default so the tunables are discoverable in one place (writing the defaults out is behaviour-neutral — a missing field already falls back to the same value): ```json -{ "recallLimit": 10, "recallMaxTokens": 400, "autoPrune": false } +{ "recallLimit": 10, "recallMaxTokens": 400, "autoPrune": false, "repairTriggers": false } ``` `recallLimit` / `recallMaxTokens` are the **canonical** names for the two recall caps; the per-call `--top` / `--max-tokens` flags are their invocation-time overrides for the same limits (and `--all` disables both). Both fields are optional and independently fall back to the built-ins (`DEFAULT_RECALL_LIMIT` = 10, `DEFAULT_RECALL_MAX_TOKENS` = 400). `recallMaxTokens` is **approximate** — per-rule cost is estimated as `rule.length / 4`, not a real tokenizer, so treat the budget as a soft bound. Lowering them keeps mandatory `--file`/`--cmd` recall lean on a large, high-fanout graph where recall otherwise returns many lessons per call (see the `stats` break-even / match-count histogram). Reading is fail-safe: a missing or malformed file, or an invalid field (non-positive, non-integer), silently uses the default for that field — recall, a blocking hot path, never throws on config. `autoPrune` (boolean, default `false`) opts into **automatic graph hygiene**: after every successful capture, the GC-only half of [`prune`](#curation-prune) runs — orphan triggers/topics are removed and non-stranding dead `file_glob` triggers are detached, reusing the working-tree walk the capture already did. It **never** trims a within-cap lesson, drops an active lesson, or strands one (a lesson keeps ≥ 1 trigger), and every change is git-reversible. The capture reports what it cleaned (`auto-pruned: N orphan triggers, M orphan topics, K dead globs detached`). Over-cap trigger trimming stays exclusive to the manual `lessons prune --apply`, which remains the deliberate, reviewed curation path. +`repairTriggers` (boolean, default `false`) opts into **capture-time trigger repair** — the enforcement half of the warn-only capture guardrails, applied to the capture INPUT before the write. A broad or wide `file_glob` is narrowed toward the evidence file's directory class (`src/lessons/recall.ts` → `src/lessons/*.ts`) — but only when a concrete evidence path exists, the author's glob covers it, and the narrowed glob matches no more files than the original; with no usable evidence the glob is kept and only warned about, never rewritten blind. A stopworded or over-long keyword gets a **matchable variant added beside it** (the original is kept — it still byte-matches prompt text; the variant makes the mandatory `--file`/`--cmd` token path reachable), and a keyword that tokenizes to nothing is dropped. Repair never leaves a lesson with zero triggers. Every rewrite is reported as a warning on the capture result (`NARROWED_GLOB`, `KEYWORD_VARIANT_ADDED`, `DROPPED_KEYWORD`), so the author sees exactly what changed — the narrowed directory class is an automatic compromise, and general/library-behavior rules should still be re-pointed at their true file-CLASS recurrence surface by hand. + ## Validation codes `agentsmesh lessons validate` returns a list of findings with these codes: @@ -167,11 +169,12 @@ Recall runs before each edit and each state-changing command (pure-read commands ### Effectiveness telemetry (opt-in) -The same `AGENTSMESH_LESSONS_TELEMETRY=1` gate enables an **outcome log** at `.agentsmesh/lessons/outcome-log.jsonl` — the substrate for _lesson effectiveness_: did a delivered lesson actually prevent the repeat? It appends two event kinds — `delivered` (a lesson injected for an action) and `failure` (a failure observed for an action) — both keyed by the **normalized action** (`file:` or `cmd:`), never raw error text. Effectiveness is **derived at read time**: a lesson delivered for an action that then fails again is a _miss_. Three existing outputs consume it — no new command, flag, or schema field: +The same `AGENTSMESH_LESSONS_TELEMETRY=1` gate enables an **outcome log** at `.agentsmesh/lessons/outcome-log.jsonl` — the substrate for _lesson effectiveness_: did a delivered lesson actually prevent the repeat? It appends two event kinds — `delivered` (a lesson injected for an action, stamped with its per-batch relevance `rank`) and `failure` (a failure observed for an action) — both keyed by the **normalized action** (`file:` or `cmd:`), never raw error text, plus the session correlator (the harness hook `session_id`, or `AGENTSMESH_SESSION_ID` for shell-driven recalls) so a failure only impeaches deliveries within its own session. Effectiveness is **derived at read time**: a lesson delivered for an action that then fails again is a _miss_. Four existing outputs consume it — no new command or flag: - **Recall down-ranking.** A lesson that fired but never helped sinks in the ranking (a low, tie-breaking weight; **neutral when there is no data**, so ranking is unchanged by default). - **`validate` health view.** `validate` gains two **warning**-level findings — `INEFFECTIVE_LESSON` (delivered enough times, never helped — consider `deprecate` or a sharper rule) and `UNCOVERED_FAILURE` (a repeat file failure with no lesson to prevent it — consider `add`). They are advisory and never change the exit code. - **`stats` effectiveness block.** `lessons stats` adds a **benefit** summary alongside its cost (break-even) and activity (capture) blocks: total deliveries, the coarse **held rate** (share of deliveries with no recorded repeat on the same action — a weak upper bound, _never_ presented as proof of prevention), the ineffective-lesson count, and failures observed — with a pointer to `validate` for the actionable list. +- **The recall hook's recurrence gate.** On a `PreToolUse` first touch of an action whose failure history has reached the recurrence threshold **and** that a captured lesson covers, the hook escalates: the covering rule is re-injected above the regular recall bullets with the failure count, cutting through session dedup — advisory context, once per action per session. See [hook mode](../../cli/lessons/#hook-mode-deterministic-recall). The signal is deliberately **coarse and labeled as such** — an honest weak signal beats a precise-looking fake one. Like the other logs it is opt-in, size-capped, and gitignored by `init --lessons`. It is also **per-machine**: the graph (your rules) is shared, but effectiveness reflects _your own_ recall/failure history, so the health view and ranking can differ between developers — see [Sharing and cross-agent use](#sharing-and-cross-agent-use).