agentsmesh 0.29.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,9 +34,10 @@ declare const LessonSchema: z.ZodObject<{
34
34
  }>;
35
35
  supersededBy: z.ZodOptional<z.ZodString>;
36
36
  createdAt: z.ZodString;
37
+ scope: z.ZodOptional<z.ZodLiteral<"always">>;
37
38
  }, z.core.$strict>;
38
39
  declare const LessonsGraphSchema: z.ZodObject<{
39
- version: z.ZodLiteral<1>;
40
+ version: z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>;
40
41
  lessons: z.ZodRecord<z.ZodString, z.ZodObject<{
41
42
  rule: z.ZodString;
42
43
  rationale: z.ZodOptional<z.ZodString>;
@@ -50,6 +51,7 @@ declare const LessonsGraphSchema: z.ZodObject<{
50
51
  }>;
51
52
  supersededBy: z.ZodOptional<z.ZodString>;
52
53
  createdAt: z.ZodString;
54
+ scope: z.ZodOptional<z.ZodLiteral<"always">>;
53
55
  }, z.core.$strict>>;
54
56
  topics: z.ZodRecord<z.ZodString, z.ZodObject<{
55
57
  summary: z.ZodString;
@@ -91,7 +93,7 @@ interface AutoPruneSummary {
91
93
  * because that lesson is captured then silently never recalled. These guardrails
92
94
  * are the warn-only complement to that single hard block.
93
95
  */
94
- type GuardrailCode = 'OVERSIZED_LESSON_TRIGGERS' | 'BROAD_GLOB_TRIGGER' | 'KEYWORD_ONLY_LESSON' | 'LOW_SIGNAL_KEYWORD' | 'STOPWORD_KEYWORD' | 'DEAD_GLOB' | 'NEAR_DUPLICATE_LESSON';
96
+ type GuardrailCode = 'OVERSIZED_LESSON_TRIGGERS' | 'BROAD_GLOB_TRIGGER' | 'WIDE_GLOB_MATCH' | 'KEYWORD_ONLY_LESSON' | 'LOW_SIGNAL_KEYWORD' | 'STOPWORD_KEYWORD' | 'DEAD_GLOB' | 'NEAR_DUPLICATE_LESSON';
95
97
  interface GuardrailWarning {
96
98
  readonly code: GuardrailCode;
97
99
  readonly message: string;
@@ -120,6 +122,8 @@ interface AddLessonInput {
120
122
  readonly evidence?: readonly string[];
121
123
  readonly rationale?: string;
122
124
  readonly createdAt?: string;
125
+ /** `'always'` = a universal always-on lesson (no trigger needed; gates skipped). */
126
+ readonly scope?: 'always';
123
127
  }
124
128
  interface AddLessonOptions {
125
129
  readonly allowNewTopic?: boolean;
@@ -197,6 +201,14 @@ interface RankOptions {
197
201
  * worse for the agent than one slightly-over-budget rule.
198
202
  */
199
203
  readonly maxTokens?: number;
204
+ /**
205
+ * Per-lesson effectiveness score in [0,1] from the outcome log (1 = always
206
+ * helped; absent = neutral). Fed as a LOW-weight RRF signal so a proven
207
+ * fire-but-fail lesson sinks below an equally-matched effective one — a
208
+ * corrective nudge, never a driver. Empty/absent ⇒ every lesson ties on this
209
+ * signal ⇒ ordering is unchanged from the pre-effectiveness ranker.
210
+ */
211
+ readonly effectiveness?: ReadonlyMap<string, number>;
200
212
  }
201
213
  /** Default recall cap: a broad trigger match returns the most-relevant few, not the whole topic. */
202
214
  declare const DEFAULT_RECALL_LIMIT = 10;
@@ -457,7 +469,7 @@ declare function toRelPath(projectRoot: string, absolute: string): string;
457
469
  * survives generate → import → generate round-trip; only the wording inside each
458
470
  * block is tightened for maximum agent compliance.
459
471
  */
460
- declare const LESSONS_PROCEDURAL_RULE = "## Lessons (BLOCKING)\n\nGraph `.agentsmesh/lessons/lessons.json` is canonical; never hand-edit it. Manual: `lessons` skill.\n\n**Recall:** before every file edit or state-changing command, MUST run `agentsmesh lessons query --file <path> --cmd <command>` and obey matches. Pure-read commands and recall itself are exempt.\n\n**Capture:** after any failure, user correction, regression, wrong assumption, useful surprise, repeated friction, or non-obvious fix, MUST self-critique and run `agentsmesh lessons add \"<imperative rule>\" --topic <id> --trigger-file <glob> --evidence <sha|lesson-id>`.\n\n**Before final:** report `Lesson: captured <id>` or `Lesson: none`. No recall/capture gate = task incomplete. No shell: use `lessons_query` / `lessons_add`.";
472
+ declare const LESSONS_PROCEDURAL_RULE = "## Lessons (BLOCKING)\n\nGraph `.agentsmesh/lessons/lessons.json` is canonical; never hand-edit it. Manual: `lessons` skill.\n\n**Recall:** before every file edit or state-changing command, MUST run `agentsmesh lessons query --file <path> --cmd <command>` and obey matches; at task start, ALSO run `agentsmesh lessons query --keyword \"<task terms>\" --always` for conceptual + universal rules no path/command names. Pure-read commands and recall itself are exempt.\n\n**Capture:** after any failure, user correction, regression, wrong assumption, useful surprise, repeated friction, or non-obvious fix, MUST self-critique and run `agentsmesh lessons add \"<imperative rule>\" --topic <id> --trigger-file <glob> --evidence <sha|lesson-id>`.\n\n**Before final:** report `Lesson: captured <id>` or `Lesson: none`. No recall/capture gate = task incomplete. No shell: use `lessons_query` / `lessons_add`.";
461
473
 
462
474
  interface ScaffoldLessonsResult {
463
475
  readonly created: string[];
@@ -467,6 +479,8 @@ interface ScaffoldLessonsResult {
467
479
  readonly rootRuleUpdated: boolean;
468
480
  /** True when the recall-log gitignore entry was added to `.gitignore`. */
469
481
  readonly gitignoreUpdated: boolean;
482
+ /** True when the lessons.json merge-driver entry was added to `.gitattributes`. */
483
+ readonly gitattributesUpdated: boolean;
470
484
  /** True when the PostToolUse recall hook was injected into `hooks.yaml`. */
471
485
  readonly recallHookInjected: boolean;
472
486
  }
package/dist/lessons.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { o as RankedLesson, A as AddLessonInput, a as AddLessonOptions, b as AddLessonResult, j as LessonsQuery } from './init-B0aI-g8W.js';
2
- export { c as AddLessonTriggers, D as DEFAULT_RECALL_LIMIT, X as DEFAULT_RECALL_MAX_TOKENS, I as ImportLegacyOptions, d as ImportLegacyReport, Y as LESSONS_LOCK_FILENAME, L as LESSONS_PROCEDURAL_RULE, e as Lesson, f as LessonStatus, g as LessonsGraph, Z as LessonsGraphExistsError, h as LessonsGraphSchema, i as LessonsPaths, M as MatchedLesson, k as MergeLessonsOptions, l as MergeLessonsResult, m as MutateOptions, R as RankOptions, n as RankReason, S as ScaffoldLessonsResult, p as StripMarkersOptions, q as StripMarkersReport, T as Topic, r as Trigger, s as TriggerKind, U as UnknownTopicError, V as ValidationFinding, t as ValidationLevel, u as ValidationReport, v as acquireLessonsLock, w as addLesson, x as graphFilePath, y as importLegacyLessons, _ as lessonsLockPath, z as lessonsPaths, B as loadLessonsGraph, C as mergeLessons, E as mutateLessonsGraph, F as parseGraph, G as queryLessons, H as rankLessons, J as scaffoldLessons, K as serializeGraph, N as stripLegacyMarkers, O as stripMarkersInGraph, P as toRelPath, Q as tryLoadLessonsGraph, W as validateLessonsGraph } from './init-B0aI-g8W.js';
1
+ import { o as RankedLesson, A as AddLessonInput, a as AddLessonOptions, b as AddLessonResult, j as LessonsQuery } from './init-PvpXanVd.js';
2
+ export { c as AddLessonTriggers, D as DEFAULT_RECALL_LIMIT, X as DEFAULT_RECALL_MAX_TOKENS, I as ImportLegacyOptions, d as ImportLegacyReport, Y as LESSONS_LOCK_FILENAME, L as LESSONS_PROCEDURAL_RULE, e as Lesson, f as LessonStatus, g as LessonsGraph, Z as LessonsGraphExistsError, h as LessonsGraphSchema, i as LessonsPaths, M as MatchedLesson, k as MergeLessonsOptions, l as MergeLessonsResult, m as MutateOptions, R as RankOptions, n as RankReason, S as ScaffoldLessonsResult, p as StripMarkersOptions, q as StripMarkersReport, T as Topic, r as Trigger, s as TriggerKind, U as UnknownTopicError, V as ValidationFinding, t as ValidationLevel, u as ValidationReport, v as acquireLessonsLock, w as addLesson, x as graphFilePath, y as importLegacyLessons, _ as lessonsLockPath, z as lessonsPaths, B as loadLessonsGraph, C as mergeLessons, E as mutateLessonsGraph, F as parseGraph, G as queryLessons, H as rankLessons, J as scaffoldLessons, K as serializeGraph, N as stripLegacyMarkers, O as stripMarkersInGraph, P as toRelPath, Q as tryLoadLessonsGraph, W as validateLessonsGraph } from './init-PvpXanVd.js';
3
3
  import 'zod';
4
4
 
5
5
  /**
package/dist/lessons.js CHANGED
@@ -9,7 +9,8 @@ import { tmpdir, hostname } from 'os';
9
9
  import 'timers/promises';
10
10
 
11
11
  // src/lessons/graph-schema.ts
12
- var CURRENT_GRAPH_VERSION = 1;
12
+ var CURRENT_GRAPH_VERSION = 2;
13
+ var VersionSchema = z.union([z.literal(1), z.literal(2)]);
13
14
  var MAX_RULE_LENGTH = 2e3;
14
15
  var IdSchema = z.string().regex(/^[a-z0-9-]+$/, "id must be kebab-case");
15
16
  var DateSchema = z.string().regex(
@@ -33,10 +34,17 @@ var LessonSchema = z.object({
33
34
  evidence: z.array(z.string().min(1)),
34
35
  status: LessonStatusSchema,
35
36
  supersededBy: IdSchema.optional(),
36
- createdAt: DateSchema
37
+ createdAt: DateSchema,
38
+ /**
39
+ * `'always'` marks an ALWAYS-ON lesson: a universal standard delivered on
40
+ * every task (via the UserPromptSubmit hook / a `--always` recall) rather than
41
+ * matched by triggers. Such a lesson needs no trigger and is excluded from
42
+ * triggered recall. Absent = a normal triggered lesson.
43
+ */
44
+ scope: z.literal("always").optional()
37
45
  }).strict();
38
46
  var LessonsGraphSchema = z.object({
39
- version: z.literal(CURRENT_GRAPH_VERSION),
47
+ version: VersionSchema,
40
48
  lessons: z.record(IdSchema, LessonSchema),
41
49
  topics: z.record(IdSchema, TopicSchema),
42
50
  triggers: z.record(IdSchema, TriggerSchema)
@@ -271,6 +279,12 @@ function collectDeadFileGlobs(graph, findings, knownPaths) {
271
279
  });
272
280
  }
273
281
  }
282
+ function fileGlobMatchCount(pattern, knownPaths) {
283
+ const isMatch = picomatch(pattern, { dot: true });
284
+ let n = 0;
285
+ for (const p of knownPaths) if (isMatch(p)) n += 1;
286
+ return n;
287
+ }
274
288
  var RUNNER_ANCHOR = /^\^(pnpm|npm|npx|yarn|bun)\b/;
275
289
  function collectRunnerAnchoredPatterns(graph, findings) {
276
290
  const active = activeTriggerIds(graph);
@@ -288,7 +302,7 @@ function collectRunnerAnchoredPatterns(graph, findings) {
288
302
  }
289
303
 
290
304
  // src/lessons/capture-guardrails.ts
291
- var NEAR_DUPLICATE_THRESHOLD = 0.6;
305
+ var WIDE_GLOB_MATCH_COUNT = 40;
292
306
  var MAX_RECOMMENDED_TRIGGERS = 8;
293
307
  function isBroadGlob(pattern) {
294
308
  const p = pattern.trim();
@@ -344,9 +358,19 @@ function inspectCapturedLesson(graph, lessonId, knownPaths) {
344
358
  message: `Lesson "${lessonId}" has file_glob trigger(s) (${deadHere.join(", ")}) that match no file in the working tree \u2014 likely a rename. Re-point them at the current path, or the lesson is unreachable via those globs.`
345
359
  });
346
360
  }
361
+ const wide = triggers.filter((t) => t.kind === "file_glob" && !isBroadGlob(t.pattern)).filter((t) => fileGlobMatchCount(t.pattern, knownPaths) > WIDE_GLOB_MATCH_COUNT).map((t) => t.pattern);
362
+ if (wide.length > 0) {
363
+ warnings.push({
364
+ code: "WIDE_GLOB_MATCH",
365
+ message: `Lesson "${lessonId}" has file glob(s) (${wide.join(", ")}) matching more than ${WIDE_GLOB_MATCH_COUNT} files in the working tree; narrow to the file-CLASS where the rule actually applies so it does not fire on unrelated edits.`
366
+ });
367
+ }
347
368
  }
348
369
  return warnings;
349
370
  }
371
+
372
+ // src/lessons/capture-near-duplicate.ts
373
+ var NEAR_DUPLICATE_THRESHOLD = 0.6;
350
374
  function nearDuplicateWarning(graph, lessonId) {
351
375
  const subject = graph.lessons[lessonId];
352
376
  if (subject === void 0) return null;
@@ -575,7 +599,7 @@ var LESSONS_PROCEDURAL_RULE = `## Lessons (BLOCKING)
575
599
 
576
600
  Graph \`.agentsmesh/lessons/lessons.json\` is canonical; never hand-edit it. Manual: \`lessons\` skill.
577
601
 
578
- **Recall:** before every file edit or state-changing command, MUST run \`agentsmesh lessons query --file <path> --cmd <command>\` and obey matches. Pure-read commands and recall itself are exempt.
602
+ **Recall:** before every file edit or state-changing command, MUST run \`agentsmesh lessons query --file <path> --cmd <command>\` and obey matches; at task start, ALSO run \`agentsmesh lessons query --keyword "<task terms>" --always\` for conceptual + universal rules no path/command names. Pure-read commands and recall itself are exempt.
579
603
 
580
604
  **Capture:** after any failure, user correction, regression, wrong assumption, useful surprise, repeated friction, or non-obvious fix, MUST self-critique and run \`agentsmesh lessons add "<imperative rule>" --topic <id> --trigger-file <glob> --evidence <sha|lesson-id>\`.
581
605
 
@@ -644,7 +668,7 @@ async function importLegacyLessons(projectRoot, options) {
644
668
  if (options.force !== true && populated) {
645
669
  throw new LessonsGraphExistsError();
646
670
  }
647
- g.version = 1;
671
+ g.version = CURRENT_GRAPH_VERSION;
648
672
  g.lessons = lessons;
649
673
  g.topics = topics;
650
674
  g.triggers = triggers;
@@ -832,7 +856,7 @@ function getHostname() {
832
856
  return hostname();
833
857
  }
834
858
  function sleep(ms) {
835
- return new Promise((resolve6) => setTimeout(resolve6, ms));
859
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
836
860
  }
837
861
 
838
862
  // src/lessons/lessons-lock.ts
@@ -1616,7 +1640,7 @@ function validateLessonsGraph(graph, options = {}) {
1616
1640
 
1617
1641
  // src/lessons/mutate.ts
1618
1642
  function emptyGraph() {
1619
- return { version: 1, lessons: {}, topics: {}, triggers: {} };
1643
+ return { version: CURRENT_GRAPH_VERSION, lessons: {}, topics: {}, triggers: {} };
1620
1644
  }
1621
1645
  function findingKey(f) {
1622
1646
  return `${f.code}|${f.triggerId ?? ""}|${f.lessonId ?? ""}`;
@@ -1640,6 +1664,7 @@ async function mutateLessonsGraphLocked(projectRoot, mutator, options = {}) {
1640
1664
  `mutateLessonsGraph: refusing to write \u2014 this change introduces ${errors}. (Pre-existing graph issues are not blocking; run \`agentsmesh lessons validate\` to review and \`lessons untrigger\`/\`prune\` to repair them.)`
1641
1665
  );
1642
1666
  }
1667
+ graph.version = CURRENT_GRAPH_VERSION;
1643
1668
  saveLessonsGraph(projectRoot, graph);
1644
1669
  return result;
1645
1670
  } finally {
@@ -1717,14 +1742,15 @@ function addLessonInto(graph, input, options) {
1717
1742
  }
1718
1743
  graph.topics[input.topic] = { summary: options.topicSummary };
1719
1744
  }
1720
- if (options.allowNoTrigger !== true) {
1745
+ const skipTriggerGates = options.allowNoTrigger === true || input.scope === "always";
1746
+ if (!skipTriggerGates) {
1721
1747
  const existingTriggers = existingId !== null ? graph.lessons[existingId]?.triggers.length ?? 0 : 0;
1722
1748
  if (countInputTriggers(input.triggers) === 0 && existingTriggers === 0) {
1723
1749
  throw new NoTriggerError();
1724
1750
  }
1725
1751
  }
1726
1752
  const { triggerIds, newTriggerIds } = mergeTriggers(graph, input.triggers);
1727
- if (options.allowNoTrigger !== true) {
1753
+ if (!skipTriggerGates) {
1728
1754
  const resultingTriggers = existingId !== null ? union(graph.lessons[existingId].triggers, triggerIds) : triggerIds;
1729
1755
  const blockingDead = blockingDeadTriggers(graph, resultingTriggers);
1730
1756
  if (resultingTriggers.length > 0 && blockingDead.length === resultingTriggers.length) {
@@ -1738,7 +1764,9 @@ function addLessonInto(graph, input, options) {
1738
1764
  topics: union(existing.topics, [input.topic]),
1739
1765
  triggers: union(existing.triggers, triggerIds),
1740
1766
  evidence: union(existing.evidence, input.evidence ?? []),
1741
- ...existing.rationale === void 0 && input.rationale !== void 0 ? { rationale: input.rationale } : {}
1767
+ ...existing.rationale === void 0 && input.rationale !== void 0 ? { rationale: input.rationale } : {},
1768
+ // Re-capturing a rule with --scope always promotes it to always-on.
1769
+ ...input.scope === "always" ? { scope: "always" } : {}
1742
1770
  };
1743
1771
  return {
1744
1772
  id: existingId,
@@ -1758,7 +1786,8 @@ function addLessonInto(graph, input, options) {
1758
1786
  evidence: input.evidence === void 0 ? [] : [...input.evidence],
1759
1787
  status: "active",
1760
1788
  createdAt: input.createdAt ?? todayIso(),
1761
- ...input.rationale === void 0 ? {} : { rationale: input.rationale }
1789
+ ...input.rationale === void 0 ? {} : { rationale: input.rationale },
1790
+ ...input.scope === "always" ? { scope: "always" } : {}
1762
1791
  };
1763
1792
  const warnings = inspectCapturedLesson(graph, id, options.knownPaths);
1764
1793
  const nearDup = nearDuplicateWarning(graph, id);
@@ -1920,6 +1949,18 @@ function capJsonl(path, maxRecords) {
1920
1949
  `, "utf8");
1921
1950
  renameSync(tmp, path);
1922
1951
  }
1952
+ function readJsonl(path) {
1953
+ if (!existsSync(path)) return [];
1954
+ const out = [];
1955
+ for (const line of readFileSync(path, "utf8").split("\n")) {
1956
+ if (line.trim().length === 0) continue;
1957
+ try {
1958
+ out.push(JSON.parse(line));
1959
+ } catch {
1960
+ }
1961
+ }
1962
+ return out;
1963
+ }
1923
1964
  var MAX_RECALL_LOG_RECORDS = 5e3;
1924
1965
  var RECALL_LOG_TRIM_TRIGGER_BYTES = 2e6;
1925
1966
  var TELEMETRY_ENV = "AGENTSMESH_LESSONS_TELEMETRY";
@@ -2018,14 +2059,19 @@ function listProjectFiles(projectRoot) {
2018
2059
  }
2019
2060
 
2020
2061
  // src/lessons/keyword-match.ts
2021
- function splitTokens(text) {
2022
- return text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
2023
- }
2024
2062
  function deriveHaystackTokens(query) {
2025
2063
  const parts = [];
2026
2064
  if (query.file !== void 0) parts.push(query.file);
2027
2065
  if (query.command !== void 0) parts.push(query.command);
2028
- return parts.length === 0 ? [] : splitTokens(parts.join(" "));
2066
+ if (parts.length === 0) return [];
2067
+ const out = [];
2068
+ for (const raw of parts.join(" ").split(/[^A-Za-z0-9]+/)) {
2069
+ if (raw.length === 0) continue;
2070
+ out.push(raw.toLowerCase());
2071
+ const sub = raw.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().split(" ").filter((t) => t.length > 0);
2072
+ if (sub.length > 1) out.push(...sub);
2073
+ }
2074
+ return out;
2029
2075
  }
2030
2076
  function containsRun(needle, hay) {
2031
2077
  if (needle.length === 0) return false;
@@ -2058,6 +2104,7 @@ function queryLessons(graph, query) {
2058
2104
  const matched = [];
2059
2105
  for (const [id, lesson] of Object.entries(graph.lessons)) {
2060
2106
  if (lesson.status !== "active") continue;
2107
+ if (lesson.scope === "always") continue;
2061
2108
  if (lesson.triggers.some((t) => matchedTriggerIds.has(t))) {
2062
2109
  matched.push({ id, lesson });
2063
2110
  }
@@ -2103,6 +2150,7 @@ var RRF_K = 60;
2103
2150
  var SPECIFICITY_WEIGHT = 3;
2104
2151
  var TOPIC_COHERENCE_WEIGHT = 2;
2105
2152
  var BM25_WEIGHT = 1;
2153
+ var EFFECTIVENESS_WEIGHT = 1;
2106
2154
  function rankMap(items) {
2107
2155
  const sorted = [...items].sort(
2108
2156
  (a, b) => b.value !== a.value ? b.value - a.value : a.id < b.id ? -1 : 1
@@ -2139,16 +2187,20 @@ function rankLessons(graph, query, matches, options = {}) {
2139
2187
  specificity,
2140
2188
  // `id` is a matched lesson, and buildTopicCoherence keys every matched id.
2141
2189
  topicCoherence: coherence.get(id),
2142
- matchedTriggers: hitTriggers
2190
+ matchedTriggers: hitTriggers,
2191
+ // Absent from the log ⇒ neutral 1, so a lesson with no outcome data never
2192
+ // sinks below one that has merely been recorded.
2193
+ effectiveness: options.effectiveness?.get(id) ?? 1
2143
2194
  };
2144
2195
  });
2145
2196
  const bm25Ranks = rankMap(scored.map((s) => ({ id: s.id, value: s.bm25 })));
2146
2197
  const specRanks = rankMap(scored.map((s) => ({ id: s.id, value: s.specificity })));
2147
2198
  const topicRanks = rankMap(scored.map((s) => ({ id: s.id, value: s.topicCoherence })));
2199
+ const effRanks = rankMap(scored.map((s) => ({ id: s.id, value: s.effectiveness })));
2148
2200
  const ranked = scored.map((s) => ({
2149
2201
  id: s.id,
2150
2202
  lesson: s.lesson,
2151
- score: SPECIFICITY_WEIGHT / (RRF_K + specRanks.get(s.id)) + TOPIC_COHERENCE_WEIGHT / (RRF_K + topicRanks.get(s.id)) + BM25_WEIGHT / (RRF_K + bm25Ranks.get(s.id)),
2203
+ score: SPECIFICITY_WEIGHT / (RRF_K + specRanks.get(s.id)) + TOPIC_COHERENCE_WEIGHT / (RRF_K + topicRanks.get(s.id)) + BM25_WEIGHT / (RRF_K + bm25Ranks.get(s.id)) + EFFECTIVENESS_WEIGHT / (RRF_K + effRanks.get(s.id)),
2152
2204
  reason: {
2153
2205
  matchedTriggers: s.matchedTriggers,
2154
2206
  bm25: s.bm25,
@@ -2209,17 +2261,58 @@ function loadRecallConfig(projectRoot) {
2209
2261
  return fallback;
2210
2262
  }
2211
2263
  }
2264
+ function outcomeLogPath(projectRoot) {
2265
+ return join(lessonsPaths(projectRoot).base, "outcome-log.jsonl");
2266
+ }
2267
+ function readOutcomeLog(projectRoot) {
2268
+ return readJsonl(outcomeLogPath(projectRoot));
2269
+ }
2270
+ function scopeKey(ev) {
2271
+ return `${ev.session ?? ""}\0${ev.contextKey}`;
2272
+ }
2273
+ function effectiveness(events) {
2274
+ const lastFailure = /* @__PURE__ */ new Map();
2275
+ events.forEach((ev, i) => {
2276
+ if (ev.kind === "failure") lastFailure.set(scopeKey(ev), i);
2277
+ });
2278
+ const out = /* @__PURE__ */ new Map();
2279
+ events.forEach((ev, i) => {
2280
+ if (ev.kind !== "delivered") return;
2281
+ const cur = out.get(ev.lessonId) ?? { delivered: 0, missed: 0 };
2282
+ cur.delivered += 1;
2283
+ const lf = lastFailure.get(scopeKey(ev));
2284
+ if (lf !== void 0 && lf > i) cur.missed += 1;
2285
+ out.set(ev.lessonId, cur);
2286
+ });
2287
+ return out;
2288
+ }
2289
+ function effectivenessScore(o) {
2290
+ return o.delivered === 0 ? 1 : 1 - o.missed / o.delivered;
2291
+ }
2292
+ function loadEffectiveness(projectRoot) {
2293
+ const map = /* @__PURE__ */ new Map();
2294
+ for (const [id, o] of effectiveness(readOutcomeLog(projectRoot))) {
2295
+ map.set(id, effectivenessScore(o));
2296
+ }
2297
+ return map;
2298
+ }
2212
2299
  var SEEN_DIR = "agentsmesh-lessons-seen";
2213
2300
  function openSessionDedup(options = {}) {
2214
2301
  if (options.disabled === true) return null;
2215
2302
  const id = options.explicit !== void 0 && options.explicit.trim().length > 0 ? options.explicit.trim() : sessionId(options.env);
2216
2303
  if (id === void 0) return null;
2217
- const path = seenPath(id);
2304
+ const path = seenPath(id, options.projectRoot);
2218
2305
  return { sessionId: id, seen: loadSeen(path), path };
2219
2306
  }
2220
- function seenPath(id) {
2307
+ function shortHash(value) {
2308
+ let h = 5381;
2309
+ for (let i = 0; i < value.length; i += 1) h = (h << 5) + h + value.charCodeAt(i) >>> 0;
2310
+ return h.toString(36);
2311
+ }
2312
+ function seenPath(id, projectRoot) {
2221
2313
  const safe = id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 200);
2222
- return join(tmpdir(), SEEN_DIR, `${safe}.json`);
2314
+ const scoped = projectRoot === void 0 ? safe : `${safe}__${shortHash(resolve(projectRoot))}`;
2315
+ return join(tmpdir(), SEEN_DIR, `${scoped}.json`);
2223
2316
  }
2224
2317
  function loadSeen(path) {
2225
2318
  if (!existsSync(path)) return /* @__PURE__ */ new Set();
@@ -2265,14 +2358,25 @@ async function recallLessons(projectRoot, query, options = {}) {
2265
2358
  const graph = load.graph;
2266
2359
  const matchQuery = query.file === void 0 ? query : { ...query, file: normalizeRecallFile(query.file, projectRoot) };
2267
2360
  const matches = queryLessons(graph, matchQuery);
2268
- const dedup = openSessionDedup({ explicit: options.sessionId, disabled: options.noDedup });
2361
+ const dedup = openSessionDedup({
2362
+ explicit: options.sessionId,
2363
+ disabled: options.noDedup,
2364
+ projectRoot
2365
+ });
2269
2366
  const forRank = dedup === null ? matches : filterUnseen(dedup, matches);
2270
2367
  const cfg = loadRecallConfig(projectRoot);
2271
2368
  const lessons = rankLessons(graph, matchQuery, forRank, {
2272
2369
  limit: options.limit ?? cfg.limit,
2273
- maxTokens: options.maxTokens === null ? void 0 : options.maxTokens ?? cfg.maxTokens
2370
+ maxTokens: options.maxTokens === null ? void 0 : options.maxTokens ?? cfg.maxTokens,
2371
+ // Down-rank proven fire-but-fail lessons (empty ⇒ neutral, so recall is
2372
+ // unchanged until the outcome log has real signal). Read from the side-channel.
2373
+ effectiveness: loadEffectiveness(projectRoot)
2274
2374
  });
2275
- if (dedup !== null) commitSeen(dedup, lessons.map((l) => l.id));
2375
+ if (dedup !== null)
2376
+ commitSeen(
2377
+ dedup,
2378
+ lessons.map((l) => l.id)
2379
+ );
2276
2380
  recordRecallTelemetry(projectRoot, graph, matchQuery, matches, lessons, { bypassed: false });
2277
2381
  return { lessons, totalMatches: matches.length, suppressed: matches.length - forRank.length };
2278
2382
  }
@@ -2400,18 +2504,27 @@ async function stripMarkersInGraph(projectRoot, options = {}) {
2400
2504
  return { changedIds, changedCount: changedIds.length };
2401
2505
  }
2402
2506
  var RECALL_HOOK_COMMAND = "agentsmesh lessons hook";
2403
- var RECALL_HOOK_MATCHER = "Edit|Write|Bash";
2404
- var RECALL_EVENTS = ["PreToolUse", "PostToolUse"];
2405
- function injectEvent(doc, event) {
2507
+ var RECALL_HOOK_TOOL_MATCHER = "Edit|Write|Bash";
2508
+ var RECALL_EVENTS = [
2509
+ { event: "PreToolUse", matcher: RECALL_HOOK_TOOL_MATCHER },
2510
+ { event: "PostToolUse", matcher: RECALL_HOOK_TOOL_MATCHER },
2511
+ { event: "UserPromptSubmit", matcher: "*" },
2512
+ // Capture-on-failure nudge (see capture-nudge.ts). BEST-EFFORT: only Claude
2513
+ // Code's passthrough hooks emit it; whitelist targets drop it without warning
2514
+ // (BEST_EFFORT_HOOK_EVENTS). PostToolUse is success-only, so failures need this.
2515
+ { event: "PostToolUseFailure", matcher: "*" },
2516
+ // Reset recall dedup after a context compaction/clear (see hook.ts SessionStart).
2517
+ // BEST-EFFORT: targets that can't represent SessionStart just keep dedup as-is.
2518
+ { event: "SessionStart", matcher: "*" }
2519
+ ];
2520
+ function injectEvent(doc, event, matcher) {
2406
2521
  const existing = doc.get(event);
2407
2522
  const seq = existing instanceof YAMLSeq ? existing : new YAMLSeq();
2408
2523
  const present = seq.items.some(
2409
2524
  (item) => item instanceof YAMLMap && item.get("command") === RECALL_HOOK_COMMAND
2410
2525
  );
2411
2526
  if (present) return false;
2412
- seq.add(
2413
- doc.createNode({ matcher: RECALL_HOOK_MATCHER, type: "command", command: RECALL_HOOK_COMMAND })
2414
- );
2527
+ seq.add(doc.createNode({ matcher, type: "command", command: RECALL_HOOK_COMMAND }));
2415
2528
  doc.set(event, seq);
2416
2529
  return true;
2417
2530
  }
@@ -2420,12 +2533,16 @@ function injectRecallHook(projectRoot) {
2420
2533
  if (!existsSync(path)) return false;
2421
2534
  const doc = parseDocument(readFileSync(path, "utf8"));
2422
2535
  let changed = false;
2423
- for (const event of RECALL_EVENTS) {
2424
- if (injectEvent(doc, event)) changed = true;
2536
+ for (const { event, matcher } of RECALL_EVENTS) {
2537
+ if (injectEvent(doc, event, matcher)) changed = true;
2425
2538
  }
2426
2539
  if (changed) writeFileSync(path, String(doc), "utf8");
2427
2540
  return changed;
2428
2541
  }
2542
+
2543
+ // src/lessons/merge-driver-setup.ts
2544
+ var LESSONS_MERGE_DRIVER = "agentsmesh-lessons";
2545
+ var LESSONS_GITATTRIBUTES_ENTRY = `.agentsmesh/lessons/lessons.json merge=${LESSONS_MERGE_DRIVER}`;
2429
2546
  var UTF8_BOM = "\uFEFF";
2430
2547
  var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
2431
2548
  ".md",
@@ -2553,7 +2670,19 @@ async function writeFileAtomic(path, content, options) {
2553
2670
  }
2554
2671
  }
2555
2672
 
2556
- // src/utils/filesystem/gitignore.ts
2673
+ // src/utils/filesystem/gitattributes.ts
2674
+ async function ensureGitattributesEntries(projectRoot, entries) {
2675
+ const path = join(projectRoot, ".gitattributes");
2676
+ const current = await readFileSafe(path) ?? "";
2677
+ const existing = new Set(
2678
+ current.split("\n").map((s) => s.trim()).filter((s) => s.length > 0 && !s.startsWith("#"))
2679
+ );
2680
+ const toAdd = entries.filter((e) => !existing.has(e.trim()));
2681
+ if (toAdd.length === 0) return false;
2682
+ const suffix = current.endsWith("\n") || current === "" ? "" : "\n";
2683
+ await writeFileAtomic(path, current + suffix + toAdd.join("\n") + "\n");
2684
+ return true;
2685
+ }
2557
2686
  async function ensureGitignoreEntries(projectRoot, entries) {
2558
2687
  const gitignorePath = join(projectRoot, ".gitignore");
2559
2688
  const current = await readFileSafe(gitignorePath) ?? "";
@@ -2662,23 +2791,37 @@ regression / wrong assumption / surprise and you have not captured (nor stated
2662
2791
  \`Lesson: none\`)? The task is INCOMPLETE \u2014 and the user will check. The graph
2663
2792
  \`.agentsmesh/lessons/lessons.json\` is canonical \u2014 never hand-edit.
2664
2793
 
2665
- ## Recall \u2014 before each file edit and each state-changing command
2794
+ ## Recall \u2014 before each edit/command, and at task start
2666
2795
 
2667
2796
  \`agentsmesh lessons query --file <path> --cmd <command>\`, then apply every rule.
2668
2797
  Pure-read commands (read-only) and the query itself are exempt. **keyword-only recall
2669
- is the anti-pattern** \u2014 lessons are keyed to a \`file_glob\`/\`command_pattern\`.
2798
+ for a specific edit is the anti-pattern** \u2014 anchor those to \`--file\`/\`--cmd\`. But at the
2799
+ START of a task (or when planning), run \`agentsmesh lessons query --keyword "<the task's
2800
+ key terms>" --always\`: that surfaces the conceptual rules no file/command names PLUS the
2801
+ universal always-on lessons \u2014 the manual equivalent of the automatic prompt recall on
2802
+ hook-capable tools. Author a \`keyword\` trigger beside a \`file_glob\` on conceptual lessons
2803
+ so they are reachable both ways. No shell \u2192 MCP \`lessons_query\` (\`file\`/\`command\`/\`keyword\`/\`always\`).
2670
2804
 
2671
2805
  ## Capture \u2014 Gate Function (before any completion claim)
2672
2806
 
2673
2807
  1. **SELF-CRITIQUE**: any failure, correction, regression, wrong assumption,
2674
2808
  useful surprise, repeated friction, or non-obvious fix? Failing
2675
2809
  tests/lint/typecheck and user/review corrections \u2014 yours or anyone's \u2014 all count.
2810
+ PROCESS/TOOLING surprises count too (a sandbox command that silently no-op'd, a port
2811
+ already in use, a library's argument shape) \u2014 trigger those with \`--trigger-cmd\`. So
2812
+ does a review finding you REVIEWED then deliberately REJECTED: capture it as a "do NOT
2813
+ refactor" guard so the reasoning survives the session.
2676
2814
  2. **CAPTURE** a reusable imperative rule with an effective trigger (else say so):
2677
- \`agentsmesh lessons add "<rule>" --topic <id> --trigger-file <glob>\`
2815
+ \`agentsmesh lessons add "<rule>" --topic <id> --trigger-file <glob>\`. Trigger on the
2816
+ file-CLASS where it will RECUR (e.g. \`**/lib/use*Form*.ts\`), NOT the single file you
2817
+ discovered it in \u2014 and NOT a broad \`src/**\`. If the rule is a UNIVERSAL standard that
2818
+ applies to EVERY task (a comment/test/style convention no file or command names),
2819
+ capture it with \`--scope always\` instead \u2014 it needs no trigger and is delivered on
2820
+ every task.
2678
2821
  3. **RECEIPT**: emit \`Lesson: captured <id>\` or \`Lesson: none\`.
2679
2822
 
2680
- At least one _effective_ trigger is required or the capture is rejected
2681
- (\`UNRECALLABLE_LESSON\`); prefer \`--trigger-file\`. No shell \u2192 MCP \`lessons_query\`,
2823
+ At least one _effective_ trigger is required (or \`--scope always\` for a universal rule) or
2824
+ the capture is rejected (\`UNRECALLABLE_LESSON\`); prefer \`--trigger-file\`. No shell \u2192 MCP \`lessons_query\`,
2682
2825
  \`lessons_add\`, \`lessons_topics\`, \`lessons_show\`, \`lessons_deprecate\`. Run
2683
2826
  \`agentsmesh lessons --help\` for every subcommand and flag: query, add, topics, show,
2684
2827
  deprecate, merge, untrigger, strip-markers, prune, journal, validate, stats, import-md.
@@ -2724,9 +2867,21 @@ async function scaffoldLessons(projectRoot) {
2724
2867
  const recallHookInjected = injectRecallHook(projectRoot);
2725
2868
  const gitignoreUpdated = await ensureGitignoreEntries(projectRoot, [
2726
2869
  toRelPath(projectRoot, recallLogPath(projectRoot)),
2727
- toRelPath(projectRoot, captureLogPath(projectRoot))
2870
+ toRelPath(projectRoot, captureLogPath(projectRoot)),
2871
+ toRelPath(projectRoot, outcomeLogPath(projectRoot))
2872
+ ]);
2873
+ const gitattributesUpdated = await ensureGitattributesEntries(projectRoot, [
2874
+ LESSONS_GITATTRIBUTES_ENTRY
2728
2875
  ]);
2729
- return { created, updated, skipped, rootRuleUpdated, gitignoreUpdated, recallHookInjected };
2876
+ return {
2877
+ created,
2878
+ updated,
2879
+ skipped,
2880
+ rootRuleUpdated,
2881
+ gitignoreUpdated,
2882
+ gitattributesUpdated,
2883
+ recallHookInjected
2884
+ };
2730
2885
  }
2731
2886
  function seedLessonsSkill(projectRoot, created, updated, skipped) {
2732
2887
  const skillPath = join(projectRoot, ".agentsmesh/skills", LESSONS_SKILL_NAME, "SKILL.md");