agentsmesh 0.33.0 → 0.34.0

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.
package/dist/lessons.js CHANGED
@@ -280,6 +280,23 @@ function todayIso() {
280
280
  }
281
281
 
282
282
  // src/lessons/add-errors.ts
283
+ var EmptyRuleError = class extends Error {
284
+ code = "EMPTY_RULE";
285
+ constructor() {
286
+ super("Lesson rule must not be empty \u2014 pass one imperative sentence.");
287
+ this.name = "EmptyRuleError";
288
+ }
289
+ };
290
+ var BroadCommandPatternError = class extends Error {
291
+ constructor(pattern) {
292
+ super(
293
+ `Command pattern ${JSON.stringify(pattern)} matches nearly every command, so it would fire on every recall. Key it on the action instead \u2014 a word-bounded program + subcommand (e.g. "\\bgit commit\\b", "\\brm\\b").`
294
+ );
295
+ this.pattern = pattern;
296
+ this.name = "BroadCommandPatternError";
297
+ }
298
+ code = "BROAD_COMMAND_PATTERN";
299
+ };
283
300
  var UnknownTopicError = class extends Error {
284
301
  constructor(topic) {
285
302
  super(`Unknown topic: ${topic}. Pass allowNewTopic + topicSummary to create it.`);
@@ -319,992 +336,1144 @@ var UnrecallableLessonError = class extends Error {
319
336
  code = "UNRECALLABLE_LESSON";
320
337
  };
321
338
 
322
- // src/lessons/ranking-text.ts
323
- var K1 = 1.5;
324
- var B = 0.75;
325
- var STOP = /* @__PURE__ */ new Set([
326
- "the",
327
- "a",
328
- "an",
329
- "to",
330
- "of",
331
- "in",
332
- "and",
333
- "or",
334
- "for",
335
- "is",
336
- "on",
337
- "at",
338
- "with",
339
- "be",
340
- "as",
341
- "it",
342
- "that",
343
- "this",
344
- "its",
345
- "must"
346
- ]);
347
- function tokenize(text) {
348
- return text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 2 && !STOP.has(t));
349
- }
350
- function queryTerms(query) {
351
- const parts = [];
352
- if (query.keyword !== void 0) parts.push(query.keyword);
353
- if (query.file !== void 0) parts.push(query.file);
354
- if (query.command !== void 0) parts.push(query.command);
355
- return tokenize(parts.join(" "));
356
- }
357
- function buildCorpus(graph) {
358
- const docs = [];
359
- const df = /* @__PURE__ */ new Map();
360
- let total = 0;
361
- let n = 0;
362
- for (const lesson of Object.values(graph.lessons)) {
363
- if (lesson.status !== "active") continue;
364
- const toks = tokenize(lesson.rule);
365
- n += 1;
366
- total += toks.length;
367
- docs.push(toks.length);
368
- for (const t of new Set(toks)) df.set(t, (df.get(t) ?? 0) + 1);
339
+ // src/lessons/regex-linear/nfa-compile.ts
340
+ var MAX_NFA_STATES = 2e3;
341
+ var Builder = class {
342
+ states = [];
343
+ alloc() {
344
+ if (this.states.length >= MAX_NFA_STATES) {
345
+ throw new Error(`NFA state limit exceeded (${MAX_NFA_STATES}); pattern expands too large`);
346
+ }
347
+ this.states.push({ eps: [], asserts: [], chars: [] });
348
+ return this.states.length - 1;
369
349
  }
370
- const N = Math.max(n, 1);
371
- const idf = /* @__PURE__ */ new Map();
372
- for (const [t, f] of df) idf.set(t, Math.log(1 + (N - f + 0.5) / (f + 0.5)));
373
- return { idf, avgdl: total / N || 1 };
350
+ };
351
+ function isNonLineTerminator(c) {
352
+ return c !== "\n" && c !== "\r" && c !== "\u2028" && c !== "\u2029";
374
353
  }
375
- function bm25(terms, ruleText, corpus) {
376
- const toks = tokenize(ruleText);
377
- const dl = toks.length || 1;
378
- const tf = /* @__PURE__ */ new Map();
379
- for (const t of toks) tf.set(t, (tf.get(t) ?? 0) + 1);
380
- let score = 0;
381
- for (const t of new Set(terms)) {
382
- const f = tf.get(t) ?? 0;
383
- if (f === 0) continue;
384
- const idf = corpus.idf.get(t);
385
- score += idf * (f * (K1 + 1)) / (f + K1 * (1 - B + B * dl / corpus.avgdl));
354
+ function compileNode(b, node) {
355
+ switch (node.k) {
356
+ case "empty":
357
+ case "assert": {
358
+ const s = b.alloc();
359
+ const e = b.alloc();
360
+ if (node.k === "assert") b.states[s].asserts.push({ kind: node.kind, target: e });
361
+ else b.states[s].eps.push(e);
362
+ return { start: s, end: e };
363
+ }
364
+ case "char":
365
+ case "any":
366
+ case "class": {
367
+ const s = b.alloc();
368
+ const e = b.alloc();
369
+ const test = node.k === "char" ? (c) => c === node.ch : node.k === "any" ? isNonLineTerminator : node.test;
370
+ b.states[s].chars.push({ test, target: e });
371
+ return { start: s, end: e };
372
+ }
373
+ case "concat": {
374
+ if (node.items.length === 0) return compileNode(b, { k: "empty" });
375
+ let first = null;
376
+ let prevEnd = -1;
377
+ for (const item of node.items) {
378
+ const frag = compileNode(b, item);
379
+ if (first === null) first = frag;
380
+ else b.states[prevEnd].eps.push(frag.start);
381
+ prevEnd = frag.end;
382
+ }
383
+ return { start: first.start, end: prevEnd };
384
+ }
385
+ case "alt": {
386
+ const s = b.alloc();
387
+ const e = b.alloc();
388
+ for (const opt of node.opts) {
389
+ const frag = compileNode(b, opt);
390
+ b.states[s].eps.push(frag.start);
391
+ b.states[frag.end].eps.push(e);
392
+ }
393
+ return { start: s, end: e };
394
+ }
395
+ case "opt": {
396
+ const s = b.alloc();
397
+ const e = b.alloc();
398
+ const frag = compileNode(b, node.node);
399
+ b.states[s].eps.push(frag.start, e);
400
+ b.states[frag.end].eps.push(e);
401
+ return { start: s, end: e };
402
+ }
403
+ case "star": {
404
+ const s = b.alloc();
405
+ const e = b.alloc();
406
+ const frag = compileNode(b, node.node);
407
+ b.states[s].eps.push(frag.start, e);
408
+ b.states[frag.end].eps.push(frag.start, e);
409
+ return { start: s, end: e };
410
+ }
411
+ case "plus": {
412
+ const e = b.alloc();
413
+ const frag = compileNode(b, node.node);
414
+ b.states[frag.end].eps.push(frag.start, e);
415
+ return { start: frag.start, end: e };
416
+ }
386
417
  }
387
- return score;
418
+ }
419
+ function compileNfa(ast) {
420
+ const b = new Builder();
421
+ const { start, end } = compileNode(b, ast);
422
+ return { states: b.states, start, accept: end };
388
423
  }
389
424
 
390
- // src/lessons/keyword-signal.ts
391
- var MAX_RECOMMENDED_KEYWORD_TOKENS = 5;
392
- function isLowSignalKeyword(pattern) {
393
- return tokenize(pattern).length > MAX_RECOMMENDED_KEYWORD_TOKENS;
425
+ // src/lessons/regex-linear/nfa.ts
426
+ function wordBoundary(input, pos) {
427
+ const before = pos > 0 && /[A-Za-z0-9_]/.test(input[pos - 1]);
428
+ const after = pos < input.length && /[A-Za-z0-9_]/.test(input[pos]);
429
+ return before !== after;
394
430
  }
395
- function splitRawTokens(pattern) {
396
- return pattern.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
431
+ function assertHolds(kind, input, pos) {
432
+ switch (kind) {
433
+ case "start":
434
+ return pos === 0;
435
+ case "end":
436
+ return pos === input.length;
437
+ case "wordB":
438
+ return wordBoundary(input, pos);
439
+ case "nonWordB":
440
+ return !wordBoundary(input, pos);
441
+ }
397
442
  }
398
- function keywordNeedleLosesTokens(pattern) {
399
- const raw = splitRawTokens(pattern);
400
- if (raw.length < 2) return false;
401
- return tokenize(pattern).length !== raw.length;
443
+ function buildMatcher(ast) {
444
+ const { states, start, accept } = compileNfa(ast);
445
+ const closure = (set, idx, input, pos, budget) => {
446
+ const stack = [idx];
447
+ while (stack.length > 0) {
448
+ if (budget.remaining <= 0) return;
449
+ const cur = stack.pop();
450
+ if (set.has(cur)) continue;
451
+ set.add(cur);
452
+ budget.remaining -= 1;
453
+ for (const t of states[cur].eps) if (!set.has(t)) stack.push(t);
454
+ for (const a of states[cur].asserts) {
455
+ if (assertHolds(a.kind, input, pos) && !set.has(a.target)) stack.push(a.target);
456
+ }
457
+ }
458
+ };
459
+ return {
460
+ // The input is matched in full (no truncation — truncation would miss suffix
461
+ // matches and let `$` falsely match an invented endpoint). Work is bounded by
462
+ // the shared budget instead: when it runs out we report a safe non-match.
463
+ test(input, budget) {
464
+ const b = budget ?? { remaining: Number.POSITIVE_INFINITY };
465
+ if (b.remaining <= 0) return false;
466
+ let current = /* @__PURE__ */ new Set();
467
+ for (let pos = 0; pos <= input.length; pos += 1) {
468
+ closure(current, start, input, pos, b);
469
+ if (current.has(accept)) return true;
470
+ if (b.remaining <= 0) return false;
471
+ if (pos === input.length) break;
472
+ const ch = input[pos];
473
+ const next = /* @__PURE__ */ new Set();
474
+ for (const s of current) {
475
+ b.remaining -= 1;
476
+ for (const t of states[s].chars) {
477
+ if (t.test(ch)) closure(next, t.target, input, pos + 1, b);
478
+ }
479
+ }
480
+ current = next;
481
+ }
482
+ return current.has(accept);
483
+ }
484
+ };
402
485
  }
403
- function activeTriggerIds(graph) {
404
- const ids = /* @__PURE__ */ new Set();
405
- for (const lesson of Object.values(graph.lessons)) {
406
- if (lesson.status !== "active") continue;
407
- for (const t of lesson.triggers) ids.add(t);
486
+
487
+ // src/lessons/regex-linear/ast.ts
488
+ var UnsupportedRegexError = class extends Error {
489
+ constructor(message) {
490
+ super(message);
491
+ this.name = "UnsupportedRegexError";
408
492
  }
409
- return ids;
410
- }
411
- function deadFileGlobIds(graph, knownPaths) {
412
- const active = activeTriggerIds(graph);
413
- const paths = [...knownPaths];
414
- const dead = /* @__PURE__ */ new Set();
415
- for (const [triggerId, trigger] of Object.entries(graph.triggers)) {
416
- if (trigger.kind !== "file_glob") continue;
417
- if (!active.has(triggerId)) continue;
418
- const isMatch = picomatch(trigger.pattern, { dot: true });
419
- if (!paths.some((p) => isMatch(p))) dead.add(triggerId);
493
+ };
494
+
495
+ // src/lessons/regex-linear/parse-helpers.ts
496
+ var MAX_REPEAT = 1e3;
497
+ var isWord = (c) => /[A-Za-z0-9_]/.test(c);
498
+ function expandRepeat(atom, min, max) {
499
+ const items = [];
500
+ for (let k = 0; k < min; k += 1) items.push(atom);
501
+ if (max === Infinity) {
502
+ items.push({ k: "star", node: atom });
503
+ } else {
504
+ for (let k = min; k < max; k += 1) items.push({ k: "opt", node: atom });
420
505
  }
421
- return dead;
506
+ if (items.length === 0) return { k: "empty" };
507
+ return items.length === 1 ? items[0] : { k: "concat", items };
422
508
  }
423
- function collectDeadFileGlobs(graph, findings, knownPaths) {
424
- for (const triggerId of deadFileGlobIds(graph, knownPaths)) {
425
- findings.push({
426
- level: "warning",
427
- code: "DEAD_FILE_GLOB",
428
- message: `file_glob trigger "${triggerId}" (${graph.triggers[triggerId]?.pattern ?? ""}) matches no file in the working tree \u2014 the lesson is unreachable via this trigger (a rename likely moved the path). Re-point it at the current path, or detach it with \`lessons untrigger\`, or run \`lessons prune --apply\`.`,
429
- triggerId
430
- });
509
+ function escapeClass(c) {
510
+ switch (c) {
511
+ case "d":
512
+ return (x) => x >= "0" && x <= "9";
513
+ case "D":
514
+ return (x) => !(x >= "0" && x <= "9");
515
+ case "w":
516
+ return isWord;
517
+ case "W":
518
+ return (x) => !isWord(x);
519
+ case "s":
520
+ return (x) => /\s/.test(x);
521
+ case "S":
522
+ return (x) => !/\s/.test(x);
523
+ default:
524
+ return null;
431
525
  }
432
526
  }
433
- function fileGlobMatchCount(pattern, knownPaths) {
434
- const isMatch = picomatch(pattern, { dot: true });
435
- let n = 0;
436
- for (const p of knownPaths) if (isMatch(p)) n += 1;
437
- return n;
527
+ var HEX2 = /^[0-9a-fA-F]{2}$/;
528
+ var HEX4 = /^[0-9a-fA-F]{4}$/;
529
+ function readUnicodeEscape(src, i, c) {
530
+ if (c === "x") {
531
+ const hex2 = src.slice(i, i + 2);
532
+ return HEX2.test(hex2) ? { ch: String.fromCharCode(parseInt(hex2, 16)), len: 2 } : { ch: "x", len: 0 };
533
+ }
534
+ const hex = src.slice(i, i + 4);
535
+ return HEX4.test(hex) ? { ch: String.fromCharCode(parseInt(hex, 16)), len: 4 } : { ch: "u", len: 0 };
438
536
  }
439
- var RUNNER_ANCHOR = /^\^(pnpm|npm|npx|yarn|bun)\b/;
440
- function collectRunnerAnchoredPatterns(graph, findings) {
441
- const active = activeTriggerIds(graph);
442
- for (const [triggerId, trigger] of Object.entries(graph.triggers)) {
443
- if (trigger.kind !== "command_pattern") continue;
444
- if (!active.has(triggerId)) continue;
445
- if (!RUNNER_ANCHOR.test(trigger.pattern)) continue;
446
- findings.push({
447
- level: "warning",
448
- code: "RUNNER_ANCHORED_PATTERN",
449
- message: `command_pattern trigger "${triggerId}" (${trigger.pattern}) is anchored to one runner \u2014 it won't fire for the same task via another runner (e.g. \`npx\` vs \`pnpm\`). Drop the \`^<runner>\` anchor and key on the task (e.g. \`\\bvitest\\b\`).`,
450
- triggerId
451
- });
537
+ function readControlEscape(src, i) {
538
+ const x = src[i];
539
+ if (x === void 0 || !/[A-Za-z]/.test(x)) {
540
+ throw new UnsupportedRegexError("\\c must be followed by a letter");
452
541
  }
542
+ return { ch: String.fromCharCode(x.charCodeAt(0) & 31), len: 1 };
453
543
  }
454
-
455
- // src/lessons/capture-guardrails.ts
456
- var WIDE_GLOB_MATCH_COUNT = 40;
457
- var MAX_RECOMMENDED_TRIGGERS = 8;
458
- function isBroadGlob(pattern) {
459
- const p = pattern.trim();
460
- if (p === "*" || p === "**") return true;
461
- if (!p.includes("**")) return false;
462
- const basename5 = p.slice(p.lastIndexOf("/") + 1);
463
- return basename5.startsWith("*");
544
+ function classEscapeChar(src, i, e) {
545
+ if (e === "b") return { ch: "\b", len: 0 };
546
+ if (e === "c") return readControlEscape(src, i);
547
+ if (e === "x" || e === "u") return readUnicodeEscape(src, i, e);
548
+ return { ch: escapeLiteral(e), len: 0 };
464
549
  }
465
- function inspectCapturedLesson(graph, lessonId, knownPaths) {
466
- const lesson = graph.lessons[lessonId];
467
- if (lesson === void 0) return [];
468
- const warnings = [];
469
- if (lesson.triggers.length > MAX_RECOMMENDED_TRIGGERS) {
470
- warnings.push({
471
- code: "OVERSIZED_LESSON_TRIGGERS",
472
- message: `Lesson "${lessonId}" has ${lesson.triggers.length} triggers (recommended \u2264 ${MAX_RECOMMENDED_TRIGGERS}); broad trigger sets fire on too many edits and dilute recall \u2014 prefer a few specific triggers.`
473
- });
550
+ function escapeLiteral(c) {
551
+ switch (c) {
552
+ case "t":
553
+ return " ";
554
+ case "n":
555
+ return "\n";
556
+ case "r":
557
+ return "\r";
558
+ case "f":
559
+ return "\f";
560
+ case "v":
561
+ return "\v";
562
+ case "0":
563
+ return "\0";
564
+ default:
565
+ return c;
474
566
  }
475
- const triggers = lesson.triggers.map((id) => graph.triggers[id]).filter((t) => t !== void 0);
476
- const broad = triggers.filter((t) => t.kind === "file_glob" && isBroadGlob(t.pattern)).map((t) => t.pattern);
477
- if (broad.length > 0) {
478
- warnings.push({
479
- code: "BROAD_GLOB_TRIGGER",
480
- message: `Lesson "${lessonId}" has broad file glob(s) (${broad.join(", ")}) that match large swaths of the tree; prefer a path specific to the lesson.`
481
- });
567
+ }
568
+
569
+ // src/lessons/regex-linear/parse.ts
570
+ function parseRegex(src) {
571
+ let i = 0;
572
+ const peek = () => src[i];
573
+ const eat = () => src[i++];
574
+ function parseAlt() {
575
+ const opts = [parseConcat()];
576
+ while (peek() === "|") {
577
+ i += 1;
578
+ opts.push(parseConcat());
579
+ }
580
+ return opts.length === 1 ? opts[0] : { k: "alt", opts };
482
581
  }
483
- if (triggers.length > 0 && triggers.every((t) => t.kind === "keyword")) {
484
- warnings.push({
485
- code: "KEYWORD_ONLY_LESSON",
486
- message: `Lesson "${lessonId}" has only keyword triggers; mandatory --file/--cmd recall surfaces these only when the keyword appears as a path/command token, so it fires less reliably \u2014 add a file_glob or command_pattern trigger for precise recall.`
487
- });
582
+ function parseConcat() {
583
+ const items = [];
584
+ while (i < src.length && peek() !== "|" && peek() !== ")") {
585
+ items.push(parseQuantified());
586
+ }
587
+ if (items.length === 0) return { k: "empty" };
588
+ return items.length === 1 ? items[0] : { k: "concat", items };
488
589
  }
489
- const lowSignal = triggers.filter((t) => t.kind === "keyword" && isLowSignalKeyword(t.pattern)).map((t) => t.pattern);
490
- if (lowSignal.length > 0) {
491
- warnings.push({
492
- code: "LOW_SIGNAL_KEYWORD",
493
- message: `Lesson "${lessonId}" has long keyword trigger(s) (${lowSignal.join(", ")}); recall matches a keyword only as a substring of --keyword or a contiguous token-run in the file/command, so a pattern past ${MAX_RECOMMENDED_KEYWORD_TOKENS} tokens rarely fires \u2014 use a short distinctive phrase.`
494
- });
590
+ function parseQuantified() {
591
+ const atom = parseAtom();
592
+ const q = peek();
593
+ if (q === "*" || q === "+" || q === "?") {
594
+ i += 1;
595
+ if (peek() === "?") i += 1;
596
+ return q === "*" ? { k: "star", node: atom } : q === "+" ? { k: "plus", node: atom } : { k: "opt", node: atom };
597
+ }
598
+ if (q === "{") {
599
+ const repeat = tryParseBrace();
600
+ if (repeat !== null) return expandRepeat(atom, repeat.min, repeat.max);
601
+ }
602
+ return atom;
495
603
  }
496
- const stopworded = triggers.filter((t) => t.kind === "keyword" && keywordNeedleLosesTokens(t.pattern)).map((t) => t.pattern);
497
- if (stopworded.length > 0) {
498
- warnings.push({
499
- code: "STOPWORD_KEYWORD",
500
- message: `Lesson "${lessonId}" has keyword trigger(s) containing stopwords/short words (${stopworded.join(", ")}); recall filters them from the pattern but NOT from the file/command text, so the phrase can never match contiguously on the --file/--cmd path \u2014 drop the stopwords (e.g. "state art" instead of "state of the art").`
501
- });
604
+ function tryParseBrace() {
605
+ const m = /^\{(\d+)(,(\d*)?)?\}/.exec(src.slice(i));
606
+ if (m === null) return null;
607
+ i += m[0].length;
608
+ if (peek() === "?") i += 1;
609
+ const min = Number(m[1]);
610
+ const max = m[2] === void 0 ? min : m[3] === "" || m[3] === void 0 ? Infinity : Number(m[3]);
611
+ if (min > MAX_REPEAT || max !== Infinity && max > MAX_REPEAT) {
612
+ throw new UnsupportedRegexError(`Repeat count over ${MAX_REPEAT} not supported: {${m[1]}\u2026}`);
613
+ }
614
+ return { min, max };
502
615
  }
503
- if (knownPaths !== void 0) {
504
- const dead = deadFileGlobIds(graph, knownPaths);
505
- const deadHere = lesson.triggers.filter((id) => dead.has(id)).map((id) => graph.triggers[id]?.pattern).filter((p) => p !== void 0);
506
- if (deadHere.length > 0) {
507
- warnings.push({
508
- code: "DEAD_GLOB",
509
- 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.`
510
- });
616
+ function parseAtom() {
617
+ const c = peek();
618
+ if (c === "(") return parseGroup();
619
+ if (c === "[") return parseClass();
620
+ if (c === "\\") return parseEscape();
621
+ if (c === ".") {
622
+ i += 1;
623
+ return { k: "any" };
511
624
  }
512
- 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);
513
- if (wide.length > 0) {
514
- warnings.push({
515
- code: "WIDE_GLOB_MATCH",
516
- 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.`
517
- });
625
+ if (c === "^") {
626
+ i += 1;
627
+ return { k: "assert", kind: "start" };
518
628
  }
519
- }
520
- return warnings;
521
- }
522
-
523
- // src/lessons/capture-near-duplicate.ts
524
- var NEAR_DUPLICATE_THRESHOLD = 0.6;
525
- function nearDuplicateWarning(graph, lessonId) {
526
- const subject = graph.lessons[lessonId];
527
- if (subject === void 0) return null;
528
- const subjectTokens = new Set(tokenize(subject.rule));
529
- if (subjectTokens.size === 0) return null;
530
- let best = null;
531
- for (const [id, other] of Object.entries(graph.lessons)) {
532
- if (id === lessonId || other.status !== "active") continue;
533
- const otherTokens = new Set(tokenize(other.rule));
534
- if (otherTokens.size === 0) continue;
535
- const score = jaccard(subjectTokens, otherTokens);
536
- if (score >= NEAR_DUPLICATE_THRESHOLD && (best === null || score > best.score)) {
537
- best = { id, score };
629
+ if (c === "$") {
630
+ i += 1;
631
+ return { k: "assert", kind: "end" };
632
+ }
633
+ if (c === void 0 || c === "*" || c === "+" || c === "?" || c === ")") {
634
+ throw new UnsupportedRegexError(`Unexpected '${c ?? "<end>"}' in pattern`);
538
635
  }
636
+ i += 1;
637
+ return { k: "char", ch: c };
539
638
  }
540
- if (best === null) return null;
541
- return {
542
- code: "NEAR_DUPLICATE_LESSON",
543
- message: `Lesson "${lessonId}" closely resembles active lesson "${best.id}" (~${Math.round(best.score * 100)}% token overlap); consider updating "${best.id}" instead of adding a paraphrase (recall would surface both).`
544
- };
545
- }
546
- function jaccard(a, b) {
547
- let intersection = 0;
548
- for (const t of a) if (b.has(t)) intersection += 1;
549
- return intersection / (a.size + b.size - intersection);
550
- }
551
-
552
- // src/core/errors.ts
553
- var AgentsMeshError = class extends Error {
554
- code;
555
- constructor(code, message, options) {
556
- super(message, options);
557
- this.name = "AgentsMeshError";
558
- this.code = code;
639
+ function parseGroup() {
640
+ i += 1;
641
+ if (peek() === "?") {
642
+ const c2 = src[i + 1];
643
+ if (c2 === "=" || c2 === "!" || c2 === "<") {
644
+ if (!(c2 === "<" && /[A-Za-z]/.test(src[i + 2] ?? ""))) {
645
+ throw new UnsupportedRegexError("Lookaround assertions are not supported");
646
+ }
647
+ }
648
+ if (c2 === ":") i += 2;
649
+ else if (c2 === "<") {
650
+ i += 2;
651
+ while (i < src.length && src[i] !== ">") i += 1;
652
+ i += 1;
653
+ }
654
+ }
655
+ const inner = parseAlt();
656
+ if (peek() !== ")") throw new UnsupportedRegexError("Unbalanced group");
657
+ i += 1;
658
+ return inner;
559
659
  }
560
- };
561
- var LockAcquisitionError = class extends AgentsMeshError {
562
- lockPath;
563
- holder;
564
- /** Human-readable lock name surfaced in the message, e.g. "lessons lock". */
565
- label;
566
- constructor(lockPath, holder, options) {
567
- const label = options?.label ?? "lock";
568
- super(
569
- "AM_LOCK_ACQUISITION_FAILED",
570
- `Could not acquire ${label} at ${lockPath}: currently held by ${holder}. Wait for the other process to finish, or remove ${lockPath} manually if you are sure no agentsmesh process is running.`,
571
- options
572
- );
573
- this.name = "LockAcquisitionError";
574
- this.lockPath = lockPath;
575
- this.holder = holder;
576
- this.label = label;
577
- }
578
- };
579
- var FileSystemError = class extends AgentsMeshError {
580
- path;
581
- errnoCode;
582
- constructor(path, message, options) {
583
- super("AM_FILESYSTEM", message, options);
584
- this.name = "FileSystemError";
585
- this.path = path;
586
- this.errnoCode = options?.errnoCode;
587
- }
588
- };
589
-
590
- // src/utils/filesystem/process-lock.ts
591
- var DEFAULT_STALE_MS = 6e4;
592
- var DEFAULT_RETRIES = 30;
593
- var DEFAULT_RETRY_DELAY_MS = 200;
594
- var YOUNG_LOCK_GRACE_MS = 2e3;
595
- async function acquireProcessLock(lockPath, opts = {}) {
596
- const retries = opts.retries ?? DEFAULT_RETRIES;
597
- const delay = opts.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
598
- const stale = opts.staleMs ?? DEFAULT_STALE_MS;
599
- await mkdir(dirname(lockPath), { recursive: true });
600
- let attempt = 0;
601
- while (true) {
602
- const acquired = await tryAcquire(lockPath);
603
- if (acquired) return acquired;
604
- const existing = await inspectLock(lockPath);
605
- if (existing !== "young" && isStale(existing, stale)) {
606
- await rm(lockPath, { recursive: true, force: true }).catch(() => {
607
- });
608
- continue;
609
- }
610
- if (attempt >= retries) {
611
- const holder = existing === "young" ? null : existing;
612
- throw new LockAcquisitionError(lockPath, describeHolder(holder), { label: opts.label });
660
+ function parseEscape() {
661
+ i += 1;
662
+ const c = peek();
663
+ if (c === void 0) throw new UnsupportedRegexError("Trailing backslash");
664
+ if (/[1-9]/.test(c) || c === "k")
665
+ throw new UnsupportedRegexError("Backreferences are not supported");
666
+ i += 1;
667
+ if (c === "b") return { k: "assert", kind: "wordB" };
668
+ if (c === "B") return { k: "assert", kind: "nonWordB" };
669
+ const cls = escapeClass(c);
670
+ if (cls !== null) return { k: "class", test: cls };
671
+ if (c === "x" || c === "u" || c === "c") {
672
+ const { ch, len } = c === "c" ? readControlEscape(src, i) : readUnicodeEscape(src, i, c);
673
+ i += len;
674
+ return { k: "char", ch };
613
675
  }
614
- attempt++;
615
- await sleep(delay);
676
+ return { k: "char", ch: escapeLiteral(c) };
616
677
  }
617
- }
618
- async function tryAcquire(lockPath) {
619
- try {
620
- await mkdir(lockPath, { recursive: false });
621
- } catch (err) {
622
- if (err.code === "EEXIST") return null;
623
- throw err;
678
+ function parseClass() {
679
+ i += 1;
680
+ const negate = peek() === "^";
681
+ if (negate) i += 1;
682
+ const tests = [];
683
+ while (i < src.length && peek() !== "]") {
684
+ tests.push(parseClassMember());
685
+ }
686
+ if (peek() !== "]") throw new UnsupportedRegexError("Unterminated character class");
687
+ i += 1;
688
+ const base = (c) => tests.some((t) => t(c));
689
+ return { k: "class", test: negate ? (c) => !base(c) : base };
624
690
  }
625
- const metadataPath = join(lockPath, "holder.json");
626
- const metadata = {
627
- pid: process.pid,
628
- started: Date.now(),
629
- hostname: getHostname()
630
- };
631
- await writeFile(metadataPath, JSON.stringify(metadata), "utf-8");
632
- let released = false;
633
- const cleanup = () => {
634
- if (released) return;
635
- released = true;
636
- try {
637
- rmSync(lockPath, { recursive: true, force: true });
638
- } catch {
691
+ function parseClassMember() {
692
+ let lo;
693
+ if (peek() === "\\") {
694
+ i += 1;
695
+ const e = eat();
696
+ const cls = escapeClass(e);
697
+ if (cls !== null) return cls;
698
+ const r = classEscapeChar(src, i, e);
699
+ i += r.len;
700
+ lo = r.ch;
701
+ } else {
702
+ lo = eat();
639
703
  }
640
- };
641
- const signalHandler = (signal) => {
642
- cleanup();
643
- process.kill(process.pid, signal);
644
- };
645
- process.once("SIGINT", signalHandler);
646
- process.once("SIGTERM", signalHandler);
647
- process.once("exit", cleanup);
648
- return async () => {
649
- if (released) return;
650
- released = true;
651
- process.off("SIGINT", signalHandler);
652
- process.off("SIGTERM", signalHandler);
653
- process.off("exit", cleanup);
654
- await rm(lockPath, { recursive: true, force: true }).catch(() => {
655
- });
656
- };
657
- }
658
- async function inspectLock(lockPath) {
659
- try {
660
- const raw = await readFile(join(lockPath, "holder.json"), "utf-8");
661
- const parsed = JSON.parse(raw);
662
- if (!isLockMetadata(parsed)) return null;
663
- return parsed;
664
- } catch {
665
- try {
666
- const info = await stat(lockPath);
667
- const ageMs = Date.now() - info.mtimeMs;
668
- if (ageMs < YOUNG_LOCK_GRACE_MS) return "young";
669
- } catch {
704
+ if (peek() === "-" && src[i + 1] !== void 0 && src[i + 1] !== "]") {
705
+ i += 1;
706
+ let hi;
707
+ if (peek() === "\\") {
708
+ i += 1;
709
+ const e2 = eat();
710
+ const r = classEscapeChar(src, i, e2);
711
+ i += r.len;
712
+ hi = r.ch;
713
+ } else {
714
+ hi = eat();
715
+ }
716
+ const a = lo.codePointAt(0);
717
+ const b = hi.codePointAt(0);
718
+ return (c) => {
719
+ const p = c.codePointAt(0);
720
+ return p >= a && p <= b;
721
+ };
670
722
  }
671
- return null;
723
+ return (c) => c === lo;
672
724
  }
725
+ const ast = parseAlt();
726
+ if (i !== src.length) throw new UnsupportedRegexError(`Unexpected '${peek()}' at ${i}`);
727
+ return ast;
673
728
  }
674
- function isStale(meta, staleMs) {
675
- if (!meta) return true;
676
- const age = Date.now() - meta.started;
677
- if (age > staleMs) return true;
678
- if (meta.hostname && meta.hostname !== getHostname()) return false;
679
- return !isProcessAlive(meta.pid);
680
- }
681
- function isProcessAlive(pid) {
682
- if (!Number.isInteger(pid) || pid <= 0) return false;
729
+
730
+ // src/lessons/regex-linear/index.ts
731
+ var cache = /* @__PURE__ */ new Map();
732
+ function compileLinearMatcher(pattern) {
733
+ const hit = cache.get(pattern);
734
+ if (hit !== void 0 || cache.has(pattern)) return hit ?? null;
735
+ let matcher;
683
736
  try {
684
- process.kill(pid, 0);
685
- return true;
686
- } catch (err) {
687
- return err.code === "EPERM";
737
+ matcher = buildMatcher(parseRegex(pattern));
738
+ } catch {
739
+ matcher = null;
688
740
  }
689
- }
690
- function describeHolder(meta) {
691
- if (!meta) return "unknown (unreadable lock metadata)";
692
- const host = meta.hostname ? `${meta.hostname}:` : "";
693
- return `${host}pid ${meta.pid} (running ${Date.now() - meta.started}ms)`;
694
- }
695
- function isLockMetadata(value) {
696
- if (typeof value !== "object" || value === null) return false;
697
- const v = value;
698
- return typeof v.pid === "number" && typeof v.started === "number";
699
- }
700
- function getHostname() {
701
- return hostname();
702
- }
703
- function sleep(ms) {
704
- return new Promise((resolve8) => setTimeout(resolve8, ms));
741
+ cache.set(pattern, matcher);
742
+ return matcher;
705
743
  }
706
744
 
707
- // src/lessons/lessons-lock.ts
708
- var LESSONS_LOCK_FILENAME = ".lessons.lock";
709
- function lessonsLockPath(projectRoot) {
710
- return resolve(projectRoot, ".agentsmesh/lessons", LESSONS_LOCK_FILENAME);
745
+ // src/lessons/regex-safety.ts
746
+ var MAX_PATTERN_LENGTH = 1e3;
747
+ function isSafeRegexPattern(pattern) {
748
+ if (pattern.length > MAX_PATTERN_LENGTH) return false;
749
+ return compileLinearMatcher(pattern) !== null;
711
750
  }
712
- async function acquireLessonsLock(projectRoot, opts = {}) {
713
- const lockPath = lessonsLockPath(projectRoot);
714
- await mkdir(dirname(lockPath), { recursive: true });
715
- return acquireProcessLock(lockPath, { ...opts, label: "lessons lock" });
751
+ function getCommandMatcher(pattern) {
752
+ if (pattern.length > MAX_PATTERN_LENGTH) return null;
753
+ return compileLinearMatcher(pattern);
716
754
  }
717
755
 
718
- // src/lessons/validate-checks.ts
719
- function collectDanglingRefs(graph, findings) {
720
- for (const [lessonId, lesson] of Object.entries(graph.lessons)) {
721
- for (const topicId of lesson.topics) {
722
- if (graph.topics[topicId] === void 0) {
723
- findings.push({
724
- level: "error",
725
- code: "DANGLING_TOPIC",
726
- message: `Lesson "${lessonId}" references unknown topic "${topicId}".`,
727
- lessonId,
728
- topicId
729
- });
730
- }
731
- }
732
- for (const triggerId of lesson.triggers) {
733
- if (graph.triggers[triggerId] === void 0) {
734
- findings.push({
735
- level: "error",
736
- code: "DANGLING_TRIGGER",
737
- message: `Lesson "${lessonId}" references unknown trigger "${triggerId}".`,
738
- lessonId,
739
- triggerId
740
- });
741
- }
742
- }
743
- if (lesson.supersededBy !== void 0 && graph.lessons[lesson.supersededBy] === void 0) {
744
- findings.push({
745
- level: "error",
746
- code: "DANGLING_SUPERSEDER",
747
- message: `Lesson "${lessonId}" supersededBy unknown lesson "${lesson.supersededBy}".`,
748
- lessonId
749
- });
750
- }
756
+ // src/lessons/command-pattern-breadth.ts
757
+ var COMMAND_PROBE_CORPUS = [
758
+ "git status",
759
+ 'git commit -m "wip"',
760
+ "pnpm test",
761
+ "npx vitest run src/x.test.ts",
762
+ "ls -la",
763
+ "cat README.md",
764
+ "rm -rf dist",
765
+ "mkdir -p build/out",
766
+ "node scripts/build.js",
767
+ "docker compose up -d",
768
+ "curl -s https://example.com",
769
+ "echo hello > out.txt",
770
+ "sed -i 's/a/b/' file.txt",
771
+ "pnpm lint --fix",
772
+ "python3 -m pytest",
773
+ "cargo build --release",
774
+ "make",
775
+ "npm install --global typescript",
776
+ "cp a.txt b.txt",
777
+ "grep -rn TODO src"
778
+ ];
779
+ var BROAD_HIT_RATIO = 0.5;
780
+ var PROBE_BUDGET = 1e5;
781
+ function isBroadCommandPattern(pattern) {
782
+ const matcher = getCommandMatcher(pattern);
783
+ if (matcher === null) return false;
784
+ if (matcher.test("", { remaining: PROBE_BUDGET })) return true;
785
+ let hits = 0;
786
+ for (const command of COMMAND_PROBE_CORPUS) {
787
+ if (matcher.test(command, { remaining: PROBE_BUDGET })) hits += 1;
751
788
  }
789
+ return hits > COMMAND_PROBE_CORPUS.length * BROAD_HIT_RATIO;
752
790
  }
753
- function collectDuplicateRefs(graph, findings) {
754
- for (const [lessonId, lesson] of Object.entries(graph.lessons)) {
755
- for (const topicId of firstDuplicates(lesson.topics)) {
756
- findings.push({
757
- level: "error",
758
- code: "DUPLICATE_TOPIC_REF",
759
- message: `Lesson "${lessonId}" references topic "${topicId}" more than once.`,
760
- lessonId,
761
- topicId
762
- });
763
- }
764
- for (const triggerId of firstDuplicates(lesson.triggers)) {
765
- findings.push({
766
- level: "error",
767
- code: "DUPLICATE_TRIGGER_REF",
768
- message: `Lesson "${lessonId}" references trigger "${triggerId}" more than once.`,
769
- lessonId,
770
- triggerId
771
- });
772
- }
791
+
792
+ // src/lessons/ranking-text.ts
793
+ var K1 = 1.5;
794
+ var B = 0.75;
795
+ var STOP = /* @__PURE__ */ new Set([
796
+ "the",
797
+ "a",
798
+ "an",
799
+ "to",
800
+ "of",
801
+ "in",
802
+ "and",
803
+ "or",
804
+ "for",
805
+ "is",
806
+ "on",
807
+ "at",
808
+ "with",
809
+ "be",
810
+ "as",
811
+ "it",
812
+ "that",
813
+ "this",
814
+ "its",
815
+ "must"
816
+ ]);
817
+ function tokenize(text) {
818
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 2 && !STOP.has(t));
819
+ }
820
+ function queryTerms(query) {
821
+ const parts = [];
822
+ if (query.keyword !== void 0) parts.push(query.keyword);
823
+ if (query.file !== void 0) parts.push(query.file);
824
+ if (query.command !== void 0) parts.push(query.command);
825
+ return tokenize(parts.join(" "));
826
+ }
827
+ function buildCorpus(graph) {
828
+ const docs = [];
829
+ const df = /* @__PURE__ */ new Map();
830
+ let total = 0;
831
+ let n = 0;
832
+ for (const lesson of Object.values(graph.lessons)) {
833
+ if (lesson.status !== "active") continue;
834
+ const toks = tokenize(lesson.rule);
835
+ n += 1;
836
+ total += toks.length;
837
+ docs.push(toks.length);
838
+ for (const t of new Set(toks)) df.set(t, (df.get(t) ?? 0) + 1);
773
839
  }
840
+ const N = Math.max(n, 1);
841
+ const idf = /* @__PURE__ */ new Map();
842
+ for (const [t, f] of df) idf.set(t, Math.log(1 + (N - f + 0.5) / (f + 0.5)));
843
+ return { idf, avgdl: total / N || 1 };
774
844
  }
775
- function firstDuplicates(ids) {
776
- const seen = /* @__PURE__ */ new Set();
777
- const dup = /* @__PURE__ */ new Set();
778
- for (const id of ids) {
779
- if (seen.has(id)) dup.add(id);
780
- else seen.add(id);
845
+ function bm25(terms, ruleText, corpus) {
846
+ const toks = tokenize(ruleText);
847
+ const dl = toks.length || 1;
848
+ const tf = /* @__PURE__ */ new Map();
849
+ for (const t of toks) tf.set(t, (tf.get(t) ?? 0) + 1);
850
+ let score = 0;
851
+ for (const t of new Set(terms)) {
852
+ const f = tf.get(t) ?? 0;
853
+ if (f === 0) continue;
854
+ const idf = corpus.idf.get(t);
855
+ score += idf * (f * (K1 + 1)) / (f + K1 * (1 - B + B * dl / corpus.avgdl));
781
856
  }
782
- return [...dup];
857
+ return score;
783
858
  }
784
- function collectStatusInvariants(graph, findings) {
785
- for (const [lessonId, lesson] of Object.entries(graph.lessons)) {
786
- if (lesson.status === "superseded" && lesson.supersededBy === void 0) {
787
- findings.push({
788
- level: "error",
789
- code: "SUPERSEDED_WITHOUT_TARGET",
790
- message: `Lesson "${lessonId}" has status "superseded" but no supersededBy target.`,
791
- lessonId
792
- });
793
- }
794
- if (lesson.status === "active" && lesson.supersededBy !== void 0) {
795
- findings.push({
796
- level: "error",
797
- code: "ACTIVE_WITH_SUPERSEDER",
798
- message: `Lesson "${lessonId}" has status "active" but declares supersededBy.`,
799
- lessonId
800
- });
801
- }
859
+
860
+ // src/lessons/keyword-signal.ts
861
+ var MAX_RECOMMENDED_KEYWORD_TOKENS = 5;
862
+ function isLowSignalKeyword(pattern) {
863
+ return tokenize(pattern).length > MAX_RECOMMENDED_KEYWORD_TOKENS;
864
+ }
865
+ function splitRawTokens(pattern) {
866
+ return pattern.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
867
+ }
868
+ function keywordNeedleLosesTokens(pattern) {
869
+ const raw = splitRawTokens(pattern);
870
+ if (raw.length < 2) return false;
871
+ return tokenize(pattern).length !== raw.length;
872
+ }
873
+
874
+ // src/lessons/trigger-effectiveness.ts
875
+ function ineffectiveTriggers(graph, triggerIds) {
876
+ const out = [];
877
+ for (const id of triggerIds) {
878
+ const trigger = graph.triggers[id];
879
+ if (trigger === void 0) continue;
880
+ const reason = ineffectiveReason(trigger.kind, trigger.pattern);
881
+ if (reason !== null) out.push({ id, kind: trigger.kind, pattern: trigger.pattern, reason });
802
882
  }
883
+ return out;
803
884
  }
804
- function collectLifecycleInvariants(graph, findings) {
805
- for (const [lessonId, lesson] of Object.entries(graph.lessons)) {
806
- if (lesson.supersededBy === void 0) continue;
807
- if (lesson.supersededBy === lessonId) {
808
- findings.push({
809
- level: "error",
810
- code: "SELF_SUPERSEDED",
811
- message: `Lesson "${lessonId}" is superseded by itself.`,
812
- lessonId
813
- });
814
- continue;
885
+ function ineffectiveReason(kind, pattern) {
886
+ if (kind === "keyword") {
887
+ if (tokenize(pattern).length === 0) {
888
+ return "keyword has no matchable token after stopword filtering \u2014 it cannot fire on the mandatory --file/--cmd recall path";
815
889
  }
816
- const target = graph.lessons[lesson.supersededBy];
817
- if (target !== void 0 && target.status !== "active") {
818
- findings.push({
819
- level: "error",
820
- code: "INACTIVE_SUPERSEDER",
821
- message: `Lesson "${lessonId}" is superseded by "${lesson.supersededBy}", which is itself ${target.status} \u2014 the chain dead-ends with no live replacement.`,
822
- lessonId
823
- });
890
+ if (keywordNeedleLosesTokens(pattern)) {
891
+ return "keyword contains stopwords/short words, so its needle can never appear as a contiguous run on the mandatory --file/--cmd recall path";
824
892
  }
893
+ return null;
825
894
  }
826
- collectSupersedeCycles(graph, findings);
827
- }
828
- function collectSupersedeCycles(graph, findings) {
829
- const reported = /* @__PURE__ */ new Set();
830
- for (const startId of Object.keys(graph.lessons)) {
831
- const seen = /* @__PURE__ */ new Set();
832
- let cur = startId;
833
- while (cur !== void 0) {
834
- if (seen.has(cur)) {
835
- if (cur !== startId || reported.has(cur)) break;
836
- reported.add(cur);
837
- findings.push({
838
- level: "error",
839
- code: "SUPERSEDE_CYCLE",
840
- message: `Lesson "${startId}" is part of a supersededBy cycle.`,
841
- lessonId: startId
842
- });
843
- break;
844
- }
845
- seen.add(cur);
846
- const next = graph.lessons[cur]?.supersededBy;
847
- if (next === cur) break;
848
- cur = next;
895
+ if (kind === "command_pattern") {
896
+ let valid = true;
897
+ try {
898
+ new RegExp(pattern);
899
+ } catch {
900
+ valid = false;
849
901
  }
850
- }
851
- }
852
- function collectReachability(graph, findings) {
853
- for (const [lessonId, lesson] of Object.entries(graph.lessons)) {
854
- if (lesson.status === "active" && lesson.triggers.length === 0) {
855
- findings.push({
856
- level: "warning",
857
- code: "UNREACHABLE_LESSON",
858
- message: `Active lesson "${lessonId}" has no triggers and can never be recalled.`,
859
- lessonId
860
- });
902
+ if (!valid) {
903
+ return "invalid regex \u2014 recall compiles it with new RegExp and swallows the throw as a non-match, so it never fires";
904
+ }
905
+ if (!isSafeRegexPattern(pattern)) {
906
+ return "regex is outside the provably-linear engine \u2014 recall skips it (ReDoS guard), so it never fires";
861
907
  }
908
+ return null;
862
909
  }
910
+ return null;
863
911
  }
864
- function collectOrphans(graph, findings) {
865
- const referencedTopics = /* @__PURE__ */ new Set();
866
- const referencedTriggers = /* @__PURE__ */ new Set();
867
- for (const lesson of Object.values(graph.lessons)) {
868
- for (const t of lesson.topics) referencedTopics.add(t);
869
- for (const t of lesson.triggers) referencedTriggers.add(t);
912
+ function blockingDeadTriggers(graph, triggerIds) {
913
+ return ineffectiveTriggers(graph, triggerIds).filter((t) => t.kind !== "command_pattern");
914
+ }
915
+
916
+ // src/lessons/add-gates.ts
917
+ function assertRuleShape(rule) {
918
+ const trimmed = rule.trim();
919
+ if (trimmed.length === 0) throw new EmptyRuleError();
920
+ if (trimmed.length > MAX_RULE_LENGTH) throw new RuleTooLongError(trimmed.length, MAX_RULE_LENGTH);
921
+ return trimmed;
922
+ }
923
+ function skipsTriggerGates(input, options) {
924
+ return options.allowNoTrigger === true || input.scope === "always";
925
+ }
926
+ function countInputTriggers(triggers) {
927
+ return (triggers.files?.length ?? 0) + (triggers.commands?.length ?? 0) + (triggers.keywords?.length ?? 0);
928
+ }
929
+ function assertTriggerInputs(input, options, existingTriggerCount) {
930
+ if (!skipsTriggerGates(input, options) && countInputTriggers(input.triggers) === 0 && existingTriggerCount === 0) {
931
+ throw new NoTriggerError();
870
932
  }
871
- for (const topicId of Object.keys(graph.topics)) {
872
- if (!referencedTopics.has(topicId)) {
873
- findings.push({
874
- level: "warning",
875
- code: "ORPHAN_TOPIC",
876
- message: `Topic "${topicId}" is not referenced by any lesson.`,
877
- topicId
878
- });
879
- }
933
+ if (options.allowNoTrigger !== true) {
934
+ const broad = (input.triggers.commands ?? []).find(isBroadCommandPattern);
935
+ if (broad !== void 0) throw new BroadCommandPatternError(broad);
880
936
  }
881
- for (const triggerId of Object.keys(graph.triggers)) {
882
- if (!referencedTriggers.has(triggerId)) {
883
- findings.push({
884
- level: "warning",
885
- code: "ORPHAN_TRIGGER",
886
- message: `Trigger "${triggerId}" is not referenced by any lesson.`,
887
- triggerId
888
- });
889
- }
937
+ }
938
+ function assertRecallable(graph, resultingTriggers) {
939
+ const blockingDead = blockingDeadTriggers(graph, resultingTriggers);
940
+ if (resultingTriggers.length > 0 && blockingDead.length === resultingTriggers.length) {
941
+ throw new UnrecallableLessonError(blockingDead);
890
942
  }
891
943
  }
892
944
 
893
- // src/lessons/regex-linear/nfa-compile.ts
894
- var MAX_NFA_STATES = 2e3;
895
- var Builder = class {
896
- states = [];
897
- alloc() {
898
- if (this.states.length >= MAX_NFA_STATES) {
899
- throw new Error(`NFA state limit exceeded (${MAX_NFA_STATES}); pattern expands too large`);
900
- }
901
- this.states.push({ eps: [], asserts: [], chars: [] });
902
- return this.states.length - 1;
945
+ // src/lessons/glob-breadth.ts
946
+ var WILDCARD = /[*?[\]]/;
947
+ function globNarrowness(pattern) {
948
+ const segments = pattern.replaceAll("\\", "/").split("/").filter((segment) => segment !== "" && segment !== ".");
949
+ if (segments.length === 0) return 0;
950
+ let literal = 0;
951
+ let globstars = 0;
952
+ for (const segment of segments) {
953
+ if (segment === "**") globstars += 1;
954
+ else if (!WILDCARD.test(segment)) literal += 1;
903
955
  }
904
- };
905
- function isNonLineTerminator(c) {
906
- return c !== "\n" && c !== "\r" && c !== "\u2028" && c !== "\u2029";
956
+ return literal / (segments.length + globstars);
907
957
  }
908
- function compileNode(b, node) {
909
- switch (node.k) {
910
- case "empty":
911
- case "assert": {
912
- const s = b.alloc();
913
- const e = b.alloc();
914
- if (node.k === "assert") b.states[s].asserts.push({ kind: node.kind, target: e });
915
- else b.states[s].eps.push(e);
916
- return { start: s, end: e };
917
- }
918
- case "char":
919
- case "any":
920
- case "class": {
921
- const s = b.alloc();
922
- const e = b.alloc();
923
- const test = node.k === "char" ? (c) => c === node.ch : node.k === "any" ? isNonLineTerminator : node.test;
924
- b.states[s].chars.push({ test, target: e });
925
- return { start: s, end: e };
926
- }
927
- case "concat": {
928
- if (node.items.length === 0) return compileNode(b, { k: "empty" });
929
- let first = null;
930
- let prevEnd = -1;
931
- for (const item of node.items) {
932
- const frag = compileNode(b, item);
933
- if (first === null) first = frag;
934
- else b.states[prevEnd].eps.push(frag.start);
935
- prevEnd = frag.end;
936
- }
937
- return { start: first.start, end: prevEnd };
938
- }
939
- case "alt": {
940
- const s = b.alloc();
941
- const e = b.alloc();
942
- for (const opt of node.opts) {
943
- const frag = compileNode(b, opt);
944
- b.states[s].eps.push(frag.start);
945
- b.states[frag.end].eps.push(e);
946
- }
947
- return { start: s, end: e };
948
- }
949
- case "opt": {
950
- const s = b.alloc();
951
- const e = b.alloc();
952
- const frag = compileNode(b, node.node);
953
- b.states[s].eps.push(frag.start, e);
954
- b.states[frag.end].eps.push(e);
955
- return { start: s, end: e };
956
- }
957
- case "star": {
958
- const s = b.alloc();
959
- const e = b.alloc();
960
- const frag = compileNode(b, node.node);
961
- b.states[s].eps.push(frag.start, e);
962
- b.states[frag.end].eps.push(frag.start, e);
963
- return { start: s, end: e };
964
- }
965
- case "plus": {
966
- const e = b.alloc();
967
- const frag = compileNode(b, node.node);
968
- b.states[frag.end].eps.push(frag.start, e);
969
- return { start: frag.start, end: e };
970
- }
958
+ var BROAD_GLOB_NARROWNESS = 0.34;
959
+ function isBroadFileGlob(pattern) {
960
+ return globNarrowness(pattern) < BROAD_GLOB_NARROWNESS;
961
+ }
962
+ function activeTriggerIds(graph) {
963
+ const ids = /* @__PURE__ */ new Set();
964
+ for (const lesson of Object.values(graph.lessons)) {
965
+ if (lesson.status !== "active") continue;
966
+ for (const t of lesson.triggers) ids.add(t);
971
967
  }
968
+ return ids;
972
969
  }
973
- function compileNfa(ast) {
974
- const b = new Builder();
975
- const { start, end } = compileNode(b, ast);
976
- return { states: b.states, start, accept: end };
970
+ function deadFileGlobIds(graph, knownPaths) {
971
+ const active = activeTriggerIds(graph);
972
+ const paths = [...knownPaths];
973
+ const dead = /* @__PURE__ */ new Set();
974
+ for (const [triggerId, trigger] of Object.entries(graph.triggers)) {
975
+ if (trigger.kind !== "file_glob") continue;
976
+ if (!active.has(triggerId)) continue;
977
+ const isMatch = picomatch(trigger.pattern, { dot: true });
978
+ if (!paths.some((p) => isMatch(p))) dead.add(triggerId);
979
+ }
980
+ return dead;
981
+ }
982
+ function collectDeadFileGlobs(graph, findings, knownPaths) {
983
+ for (const triggerId of deadFileGlobIds(graph, knownPaths)) {
984
+ findings.push({
985
+ level: "warning",
986
+ code: "DEAD_FILE_GLOB",
987
+ message: `file_glob trigger "${triggerId}" (${graph.triggers[triggerId]?.pattern ?? ""}) matches no file in the working tree \u2014 the lesson is unreachable via this trigger (a rename likely moved the path). Re-point it at the current path, or detach it with \`lessons untrigger\`, or run \`lessons prune --apply\`.`,
988
+ triggerId
989
+ });
990
+ }
991
+ }
992
+ function fileGlobMatchCount(pattern, knownPaths) {
993
+ const isMatch = picomatch(pattern, { dot: true });
994
+ let n = 0;
995
+ for (const p of knownPaths) if (isMatch(p)) n += 1;
996
+ return n;
997
+ }
998
+ var RUNNER_ANCHOR = /^\^(pnpm|npm|npx|yarn|bun)\b/;
999
+ function collectRunnerAnchoredPatterns(graph, findings) {
1000
+ const active = activeTriggerIds(graph);
1001
+ for (const [triggerId, trigger] of Object.entries(graph.triggers)) {
1002
+ if (trigger.kind !== "command_pattern") continue;
1003
+ if (!active.has(triggerId)) continue;
1004
+ if (!RUNNER_ANCHOR.test(trigger.pattern)) continue;
1005
+ findings.push({
1006
+ level: "warning",
1007
+ code: "RUNNER_ANCHORED_PATTERN",
1008
+ message: `command_pattern trigger "${triggerId}" (${trigger.pattern}) is anchored to one runner \u2014 it won't fire for the same task via another runner (e.g. \`npx\` vs \`pnpm\`). Drop the \`^<runner>\` anchor and key on the task (e.g. \`\\bvitest\\b\`).`,
1009
+ triggerId
1010
+ });
1011
+ }
1012
+ }
1013
+ function collectBroadFileGlobs(graph, findings) {
1014
+ const active = activeTriggerIds(graph);
1015
+ for (const [triggerId, trigger] of Object.entries(graph.triggers)) {
1016
+ if (trigger.kind !== "file_glob") continue;
1017
+ if (!active.has(triggerId)) continue;
1018
+ if (!isBroadFileGlob(trigger.pattern)) continue;
1019
+ findings.push({
1020
+ level: "warning",
1021
+ code: "BROAD_FILE_GLOB",
1022
+ message: `file_glob trigger "${triggerId}" (${trigger.pattern}) matches most of the repository, so it outranks nothing and crowds the recall budget. Narrow it to the directory or file class the rule is really about, or detach it with \`lessons untrigger\`.`,
1023
+ triggerId
1024
+ });
1025
+ }
1026
+ }
1027
+ function collectBroadCommandPatterns(graph, findings) {
1028
+ const active = activeTriggerIds(graph);
1029
+ for (const [triggerId, trigger] of Object.entries(graph.triggers)) {
1030
+ if (trigger.kind !== "command_pattern") continue;
1031
+ if (!active.has(triggerId)) continue;
1032
+ if (!isBroadCommandPattern(trigger.pattern)) continue;
1033
+ findings.push({
1034
+ level: "warning",
1035
+ code: "BROAD_COMMAND_PATTERN",
1036
+ message: `command_pattern trigger "${triggerId}" (${trigger.pattern}) matches nearly every command, so the lesson fires on every recall. Key it on the action (e.g. \`\\bgit commit\\b\`), or detach it with \`lessons untrigger\`.`,
1037
+ triggerId
1038
+ });
1039
+ }
977
1040
  }
978
1041
 
979
- // src/lessons/regex-linear/nfa.ts
980
- function wordBoundary(input, pos) {
981
- const before = pos > 0 && /[A-Za-z0-9_]/.test(input[pos - 1]);
982
- const after = pos < input.length && /[A-Za-z0-9_]/.test(input[pos]);
983
- return before !== after;
1042
+ // src/lessons/capture-guardrails.ts
1043
+ var WIDE_GLOB_MATCH_COUNT = 40;
1044
+ var MAX_RECOMMENDED_TRIGGERS = 8;
1045
+ function isBroadGlob(pattern) {
1046
+ const p = pattern.trim();
1047
+ if (p === "*" || p === "**") return true;
1048
+ if (!p.includes("**")) return false;
1049
+ const basename5 = p.slice(p.lastIndexOf("/") + 1);
1050
+ return basename5.startsWith("*");
984
1051
  }
985
- function assertHolds(kind, input, pos) {
986
- switch (kind) {
987
- case "start":
988
- return pos === 0;
989
- case "end":
990
- return pos === input.length;
991
- case "wordB":
992
- return wordBoundary(input, pos);
993
- case "nonWordB":
994
- return !wordBoundary(input, pos);
1052
+ function inspectCapturedLesson(graph, lessonId, knownPaths) {
1053
+ const lesson = graph.lessons[lessonId];
1054
+ if (lesson === void 0) return [];
1055
+ const warnings = [];
1056
+ if (lesson.triggers.length > MAX_RECOMMENDED_TRIGGERS) {
1057
+ warnings.push({
1058
+ code: "OVERSIZED_LESSON_TRIGGERS",
1059
+ message: `Lesson "${lessonId}" has ${lesson.triggers.length} triggers (recommended \u2264 ${MAX_RECOMMENDED_TRIGGERS}); broad trigger sets fire on too many edits and dilute recall \u2014 prefer a few specific triggers.`
1060
+ });
1061
+ }
1062
+ const triggers = lesson.triggers.map((id) => graph.triggers[id]).filter((t) => t !== void 0);
1063
+ const broad = triggers.filter((t) => t.kind === "file_glob" && isBroadGlob(t.pattern)).map((t) => t.pattern);
1064
+ if (broad.length > 0) {
1065
+ warnings.push({
1066
+ code: "BROAD_GLOB_TRIGGER",
1067
+ message: `Lesson "${lessonId}" has broad file glob(s) (${broad.join(", ")}) that match large swaths of the tree; prefer a path specific to the lesson.`
1068
+ });
1069
+ }
1070
+ if (triggers.length > 0 && triggers.every((t) => t.kind === "keyword")) {
1071
+ warnings.push({
1072
+ code: "KEYWORD_ONLY_LESSON",
1073
+ message: `Lesson "${lessonId}" has only keyword triggers; mandatory --file/--cmd recall surfaces these only when the keyword appears as a path/command token, so it fires less reliably \u2014 add a file_glob or command_pattern trigger for precise recall.`
1074
+ });
1075
+ }
1076
+ const lowSignal = triggers.filter((t) => t.kind === "keyword" && isLowSignalKeyword(t.pattern)).map((t) => t.pattern);
1077
+ if (lowSignal.length > 0) {
1078
+ warnings.push({
1079
+ code: "LOW_SIGNAL_KEYWORD",
1080
+ message: `Lesson "${lessonId}" has long keyword trigger(s) (${lowSignal.join(", ")}); recall matches a keyword only as a contiguous token-run in --keyword or the file/command, so a pattern past ${MAX_RECOMMENDED_KEYWORD_TOKENS} tokens rarely fires \u2014 use a short distinctive phrase.`
1081
+ });
1082
+ }
1083
+ const stopworded = triggers.filter((t) => t.kind === "keyword" && keywordNeedleLosesTokens(t.pattern)).map((t) => t.pattern);
1084
+ if (stopworded.length > 0) {
1085
+ warnings.push({
1086
+ code: "STOPWORD_KEYWORD",
1087
+ message: `Lesson "${lessonId}" has keyword trigger(s) containing stopwords/short words (${stopworded.join(", ")}); recall filters them from the pattern but NOT from the file/command text, so the phrase can never match contiguously on the --file/--cmd path \u2014 drop the stopwords (e.g. "state art" instead of "state of the art").`
1088
+ });
995
1089
  }
1090
+ if (knownPaths !== void 0) {
1091
+ const dead = deadFileGlobIds(graph, knownPaths);
1092
+ const deadHere = lesson.triggers.filter((id) => dead.has(id)).map((id) => graph.triggers[id]?.pattern).filter((p) => p !== void 0);
1093
+ if (deadHere.length > 0) {
1094
+ warnings.push({
1095
+ code: "DEAD_GLOB",
1096
+ 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.`
1097
+ });
1098
+ }
1099
+ 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);
1100
+ if (wide.length > 0) {
1101
+ warnings.push({
1102
+ code: "WIDE_GLOB_MATCH",
1103
+ 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.`
1104
+ });
1105
+ }
1106
+ }
1107
+ return warnings;
996
1108
  }
997
- function buildMatcher(ast) {
998
- const { states, start, accept } = compileNfa(ast);
999
- const closure = (set, idx, input, pos, budget) => {
1000
- const stack = [idx];
1001
- while (stack.length > 0) {
1002
- if (budget.remaining <= 0) return;
1003
- const cur = stack.pop();
1004
- if (set.has(cur)) continue;
1005
- set.add(cur);
1006
- budget.remaining -= 1;
1007
- for (const t of states[cur].eps) if (!set.has(t)) stack.push(t);
1008
- for (const a of states[cur].asserts) {
1009
- if (assertHolds(a.kind, input, pos) && !set.has(a.target)) stack.push(a.target);
1010
- }
1109
+
1110
+ // src/lessons/capture-near-duplicate.ts
1111
+ var NEAR_DUPLICATE_THRESHOLD = 0.6;
1112
+ function nearDuplicateWarning(graph, lessonId) {
1113
+ const subject = graph.lessons[lessonId];
1114
+ if (subject === void 0) return null;
1115
+ const subjectTokens = new Set(tokenize(subject.rule));
1116
+ if (subjectTokens.size === 0) return null;
1117
+ let best = null;
1118
+ for (const [id, other] of Object.entries(graph.lessons)) {
1119
+ if (id === lessonId || other.status !== "active") continue;
1120
+ const otherTokens = new Set(tokenize(other.rule));
1121
+ if (otherTokens.size === 0) continue;
1122
+ const score = jaccard(subjectTokens, otherTokens);
1123
+ if (score >= NEAR_DUPLICATE_THRESHOLD && (best === null || score > best.score)) {
1124
+ best = { id, score };
1011
1125
  }
1012
- };
1126
+ }
1127
+ if (best === null) return null;
1013
1128
  return {
1014
- // The input is matched in full (no truncation — truncation would miss suffix
1015
- // matches and let `$` falsely match an invented endpoint). Work is bounded by
1016
- // the shared budget instead: when it runs out we report a safe non-match.
1017
- test(input, budget) {
1018
- const b = budget ?? { remaining: Number.POSITIVE_INFINITY };
1019
- if (b.remaining <= 0) return false;
1020
- let current = /* @__PURE__ */ new Set();
1021
- for (let pos = 0; pos <= input.length; pos += 1) {
1022
- closure(current, start, input, pos, b);
1023
- if (current.has(accept)) return true;
1024
- if (b.remaining <= 0) return false;
1025
- if (pos === input.length) break;
1026
- const ch = input[pos];
1027
- const next = /* @__PURE__ */ new Set();
1028
- for (const s of current) {
1029
- b.remaining -= 1;
1030
- for (const t of states[s].chars) {
1031
- if (t.test(ch)) closure(next, t.target, input, pos + 1, b);
1032
- }
1033
- }
1034
- current = next;
1035
- }
1036
- return current.has(accept);
1037
- }
1129
+ code: "NEAR_DUPLICATE_LESSON",
1130
+ message: `Lesson "${lessonId}" closely resembles active lesson "${best.id}" (~${Math.round(best.score * 100)}% token overlap); consider updating "${best.id}" instead of adding a paraphrase (recall would surface both).`
1038
1131
  };
1039
1132
  }
1133
+ function jaccard(a, b) {
1134
+ let intersection = 0;
1135
+ for (const t of a) if (b.has(t)) intersection += 1;
1136
+ return intersection / (a.size + b.size - intersection);
1137
+ }
1040
1138
 
1041
- // src/lessons/regex-linear/ast.ts
1042
- var UnsupportedRegexError = class extends Error {
1043
- constructor(message) {
1044
- super(message);
1045
- this.name = "UnsupportedRegexError";
1139
+ // src/core/errors.ts
1140
+ var AgentsMeshError = class extends Error {
1141
+ code;
1142
+ constructor(code, message, options) {
1143
+ super(message, options);
1144
+ this.name = "AgentsMeshError";
1145
+ this.code = code;
1146
+ }
1147
+ };
1148
+ var LockAcquisitionError = class extends AgentsMeshError {
1149
+ lockPath;
1150
+ holder;
1151
+ /** Human-readable lock name surfaced in the message, e.g. "lessons lock". */
1152
+ label;
1153
+ constructor(lockPath, holder, options) {
1154
+ const label = options?.label ?? "lock";
1155
+ super(
1156
+ "AM_LOCK_ACQUISITION_FAILED",
1157
+ `Could not acquire ${label} at ${lockPath}: currently held by ${holder}. Wait for the other process to finish, or remove ${lockPath} manually if you are sure no agentsmesh process is running.`,
1158
+ options
1159
+ );
1160
+ this.name = "LockAcquisitionError";
1161
+ this.lockPath = lockPath;
1162
+ this.holder = holder;
1163
+ this.label = label;
1164
+ }
1165
+ };
1166
+ var FileSystemError = class extends AgentsMeshError {
1167
+ path;
1168
+ errnoCode;
1169
+ constructor(path, message, options) {
1170
+ super("AM_FILESYSTEM", message, options);
1171
+ this.name = "FileSystemError";
1172
+ this.path = path;
1173
+ this.errnoCode = options?.errnoCode;
1046
1174
  }
1047
1175
  };
1048
1176
 
1049
- // src/lessons/regex-linear/parse-helpers.ts
1050
- var MAX_REPEAT = 1e3;
1051
- var isWord = (c) => /[A-Za-z0-9_]/.test(c);
1052
- function expandRepeat(atom, min, max) {
1053
- const items = [];
1054
- for (let k = 0; k < min; k += 1) items.push(atom);
1055
- if (max === Infinity) {
1056
- items.push({ k: "star", node: atom });
1057
- } else {
1058
- for (let k = min; k < max; k += 1) items.push({ k: "opt", node: atom });
1177
+ // src/utils/filesystem/process-lock.ts
1178
+ var DEFAULT_STALE_MS = 6 * 60 * 60 * 1e3;
1179
+ var DEFAULT_RETRIES = 30;
1180
+ var DEFAULT_RETRY_DELAY_MS = 200;
1181
+ var YOUNG_LOCK_GRACE_MS = 2e3;
1182
+ async function acquireProcessLock(lockPath, opts = {}) {
1183
+ const retries = opts.retries ?? DEFAULT_RETRIES;
1184
+ const delay = opts.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
1185
+ const stale = opts.staleMs ?? DEFAULT_STALE_MS;
1186
+ await mkdir(dirname(lockPath), { recursive: true });
1187
+ let attempt = 0;
1188
+ while (true) {
1189
+ const acquired = await tryAcquire(lockPath);
1190
+ if (acquired) return acquired;
1191
+ const existing = await inspectLock(lockPath);
1192
+ if (existing !== "young" && isStale(existing, stale)) {
1193
+ await rm(lockPath, { recursive: true, force: true }).catch(() => {
1194
+ });
1195
+ continue;
1196
+ }
1197
+ if (attempt >= retries) {
1198
+ const holder = existing === "young" ? null : existing;
1199
+ throw new LockAcquisitionError(lockPath, describeHolder(holder), { label: opts.label });
1200
+ }
1201
+ attempt++;
1202
+ await sleep(delay);
1059
1203
  }
1060
- if (items.length === 0) return { k: "empty" };
1061
- return items.length === 1 ? items[0] : { k: "concat", items };
1062
1204
  }
1063
- function escapeClass(c) {
1064
- switch (c) {
1065
- case "d":
1066
- return (x) => x >= "0" && x <= "9";
1067
- case "D":
1068
- return (x) => !(x >= "0" && x <= "9");
1069
- case "w":
1070
- return isWord;
1071
- case "W":
1072
- return (x) => !isWord(x);
1073
- case "s":
1074
- return (x) => /\s/.test(x);
1075
- case "S":
1076
- return (x) => !/\s/.test(x);
1077
- default:
1078
- return null;
1205
+ async function tryAcquire(lockPath) {
1206
+ try {
1207
+ await mkdir(lockPath, { recursive: false });
1208
+ } catch (err) {
1209
+ if (err.code === "EEXIST") return null;
1210
+ throw err;
1211
+ }
1212
+ const metadataPath = join(lockPath, "holder.json");
1213
+ const metadata = {
1214
+ pid: process.pid,
1215
+ started: Date.now(),
1216
+ hostname: getHostname()
1217
+ };
1218
+ await writeFile(metadataPath, JSON.stringify(metadata), "utf-8");
1219
+ let released = false;
1220
+ const cleanup = () => {
1221
+ if (released) return;
1222
+ released = true;
1223
+ try {
1224
+ rmSync(lockPath, { recursive: true, force: true });
1225
+ } catch {
1226
+ }
1227
+ };
1228
+ const signalHandler = (signal) => {
1229
+ cleanup();
1230
+ process.kill(process.pid, signal);
1231
+ };
1232
+ process.once("SIGINT", signalHandler);
1233
+ process.once("SIGTERM", signalHandler);
1234
+ process.once("exit", cleanup);
1235
+ return async () => {
1236
+ if (released) return;
1237
+ released = true;
1238
+ process.off("SIGINT", signalHandler);
1239
+ process.off("SIGTERM", signalHandler);
1240
+ process.off("exit", cleanup);
1241
+ await rm(lockPath, { recursive: true, force: true }).catch(() => {
1242
+ });
1243
+ };
1244
+ }
1245
+ async function inspectLock(lockPath) {
1246
+ try {
1247
+ const raw = await readFile(join(lockPath, "holder.json"), "utf-8");
1248
+ const parsed = JSON.parse(raw);
1249
+ if (!isLockMetadata(parsed)) return null;
1250
+ return parsed;
1251
+ } catch {
1252
+ try {
1253
+ const info = await stat(lockPath);
1254
+ const ageMs = Date.now() - info.mtimeMs;
1255
+ if (ageMs < YOUNG_LOCK_GRACE_MS) return "young";
1256
+ } catch {
1257
+ }
1258
+ return null;
1079
1259
  }
1080
1260
  }
1081
- var HEX2 = /^[0-9a-fA-F]{2}$/;
1082
- var HEX4 = /^[0-9a-fA-F]{4}$/;
1083
- function readUnicodeEscape(src, i, c) {
1084
- if (c === "x") {
1085
- const hex2 = src.slice(i, i + 2);
1086
- return HEX2.test(hex2) ? { ch: String.fromCharCode(parseInt(hex2, 16)), len: 2 } : { ch: "x", len: 0 };
1087
- }
1088
- const hex = src.slice(i, i + 4);
1089
- return HEX4.test(hex) ? { ch: String.fromCharCode(parseInt(hex, 16)), len: 4 } : { ch: "u", len: 0 };
1261
+ function isStale(meta, staleMs) {
1262
+ if (!meta) return true;
1263
+ const sameHost = !meta.hostname || meta.hostname === getHostname();
1264
+ if (sameHost && !isProcessAlive(meta.pid)) return true;
1265
+ return Date.now() - meta.started > staleMs;
1090
1266
  }
1091
- function readControlEscape(src, i) {
1092
- const x = src[i];
1093
- if (x === void 0 || !/[A-Za-z]/.test(x)) {
1094
- throw new UnsupportedRegexError("\\c must be followed by a letter");
1267
+ function isProcessAlive(pid) {
1268
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1269
+ try {
1270
+ process.kill(pid, 0);
1271
+ return true;
1272
+ } catch (err) {
1273
+ return err.code === "EPERM";
1095
1274
  }
1096
- return { ch: String.fromCharCode(x.charCodeAt(0) & 31), len: 1 };
1097
1275
  }
1098
- function classEscapeChar(src, i, e) {
1099
- if (e === "b") return { ch: "\b", len: 0 };
1100
- if (e === "c") return readControlEscape(src, i);
1101
- if (e === "x" || e === "u") return readUnicodeEscape(src, i, e);
1102
- return { ch: escapeLiteral(e), len: 0 };
1276
+ function describeHolder(meta) {
1277
+ if (!meta) return "unknown (unreadable lock metadata)";
1278
+ const host = meta.hostname ? `${meta.hostname}:` : "";
1279
+ return `${host}pid ${meta.pid} (running ${Date.now() - meta.started}ms)`;
1103
1280
  }
1104
- function escapeLiteral(c) {
1105
- switch (c) {
1106
- case "t":
1107
- return " ";
1108
- case "n":
1109
- return "\n";
1110
- case "r":
1111
- return "\r";
1112
- case "f":
1113
- return "\f";
1114
- case "v":
1115
- return "\v";
1116
- case "0":
1117
- return "\0";
1118
- default:
1119
- return c;
1120
- }
1281
+ function isLockMetadata(value) {
1282
+ if (typeof value !== "object" || value === null) return false;
1283
+ const v = value;
1284
+ return typeof v.pid === "number" && typeof v.started === "number";
1285
+ }
1286
+ function getHostname() {
1287
+ return hostname();
1288
+ }
1289
+ function sleep(ms) {
1290
+ return new Promise((resolve8) => setTimeout(resolve8, ms));
1121
1291
  }
1122
1292
 
1123
- // src/lessons/regex-linear/parse.ts
1124
- function parseRegex(src) {
1125
- let i = 0;
1126
- const peek = () => src[i];
1127
- const eat = () => src[i++];
1128
- function parseAlt() {
1129
- const opts = [parseConcat()];
1130
- while (peek() === "|") {
1131
- i += 1;
1132
- opts.push(parseConcat());
1293
+ // src/lessons/lessons-lock.ts
1294
+ var LESSONS_LOCK_FILENAME = ".lessons.lock";
1295
+ function lessonsLockPath(projectRoot) {
1296
+ return resolve(projectRoot, ".agentsmesh/lessons", LESSONS_LOCK_FILENAME);
1297
+ }
1298
+ async function acquireLessonsLock(projectRoot, opts = {}) {
1299
+ const lockPath = lessonsLockPath(projectRoot);
1300
+ await mkdir(dirname(lockPath), { recursive: true });
1301
+ return acquireProcessLock(lockPath, { ...opts, label: "lessons lock" });
1302
+ }
1303
+
1304
+ // src/lessons/validate-checks.ts
1305
+ function collectDanglingRefs(graph, findings) {
1306
+ for (const [lessonId, lesson] of Object.entries(graph.lessons)) {
1307
+ for (const topicId of lesson.topics) {
1308
+ if (graph.topics[topicId] === void 0) {
1309
+ findings.push({
1310
+ level: "error",
1311
+ code: "DANGLING_TOPIC",
1312
+ message: `Lesson "${lessonId}" references unknown topic "${topicId}".`,
1313
+ lessonId,
1314
+ topicId
1315
+ });
1316
+ }
1133
1317
  }
1134
- return opts.length === 1 ? opts[0] : { k: "alt", opts };
1135
- }
1136
- function parseConcat() {
1137
- const items = [];
1138
- while (i < src.length && peek() !== "|" && peek() !== ")") {
1139
- items.push(parseQuantified());
1318
+ for (const triggerId of lesson.triggers) {
1319
+ if (graph.triggers[triggerId] === void 0) {
1320
+ findings.push({
1321
+ level: "error",
1322
+ code: "DANGLING_TRIGGER",
1323
+ message: `Lesson "${lessonId}" references unknown trigger "${triggerId}".`,
1324
+ lessonId,
1325
+ triggerId
1326
+ });
1327
+ }
1328
+ }
1329
+ if (lesson.supersededBy !== void 0 && graph.lessons[lesson.supersededBy] === void 0) {
1330
+ findings.push({
1331
+ level: "error",
1332
+ code: "DANGLING_SUPERSEDER",
1333
+ message: `Lesson "${lessonId}" supersededBy unknown lesson "${lesson.supersededBy}".`,
1334
+ lessonId
1335
+ });
1140
1336
  }
1141
- if (items.length === 0) return { k: "empty" };
1142
- return items.length === 1 ? items[0] : { k: "concat", items };
1143
1337
  }
1144
- function parseQuantified() {
1145
- const atom = parseAtom();
1146
- const q = peek();
1147
- if (q === "*" || q === "+" || q === "?") {
1148
- i += 1;
1149
- if (peek() === "?") i += 1;
1150
- return q === "*" ? { k: "star", node: atom } : q === "+" ? { k: "plus", node: atom } : { k: "opt", node: atom };
1338
+ }
1339
+ function collectDuplicateRefs(graph, findings) {
1340
+ for (const [lessonId, lesson] of Object.entries(graph.lessons)) {
1341
+ for (const topicId of firstDuplicates(lesson.topics)) {
1342
+ findings.push({
1343
+ level: "error",
1344
+ code: "DUPLICATE_TOPIC_REF",
1345
+ message: `Lesson "${lessonId}" references topic "${topicId}" more than once.`,
1346
+ lessonId,
1347
+ topicId
1348
+ });
1151
1349
  }
1152
- if (q === "{") {
1153
- const repeat = tryParseBrace();
1154
- if (repeat !== null) return expandRepeat(atom, repeat.min, repeat.max);
1350
+ for (const triggerId of firstDuplicates(lesson.triggers)) {
1351
+ findings.push({
1352
+ level: "error",
1353
+ code: "DUPLICATE_TRIGGER_REF",
1354
+ message: `Lesson "${lessonId}" references trigger "${triggerId}" more than once.`,
1355
+ lessonId,
1356
+ triggerId
1357
+ });
1155
1358
  }
1156
- return atom;
1157
1359
  }
1158
- function tryParseBrace() {
1159
- const m = /^\{(\d+)(,(\d*)?)?\}/.exec(src.slice(i));
1160
- if (m === null) return null;
1161
- i += m[0].length;
1162
- if (peek() === "?") i += 1;
1163
- const min = Number(m[1]);
1164
- const max = m[2] === void 0 ? min : m[3] === "" || m[3] === void 0 ? Infinity : Number(m[3]);
1165
- if (min > MAX_REPEAT || max !== Infinity && max > MAX_REPEAT) {
1166
- throw new UnsupportedRegexError(`Repeat count over ${MAX_REPEAT} not supported: {${m[1]}\u2026}`);
1167
- }
1168
- return { min, max };
1360
+ }
1361
+ function firstDuplicates(ids) {
1362
+ const seen = /* @__PURE__ */ new Set();
1363
+ const dup = /* @__PURE__ */ new Set();
1364
+ for (const id of ids) {
1365
+ if (seen.has(id)) dup.add(id);
1366
+ else seen.add(id);
1169
1367
  }
1170
- function parseAtom() {
1171
- const c = peek();
1172
- if (c === "(") return parseGroup();
1173
- if (c === "[") return parseClass();
1174
- if (c === "\\") return parseEscape();
1175
- if (c === ".") {
1176
- i += 1;
1177
- return { k: "any" };
1178
- }
1179
- if (c === "^") {
1180
- i += 1;
1181
- return { k: "assert", kind: "start" };
1182
- }
1183
- if (c === "$") {
1184
- i += 1;
1185
- return { k: "assert", kind: "end" };
1186
- }
1187
- if (c === void 0 || c === "*" || c === "+" || c === "?" || c === ")") {
1188
- throw new UnsupportedRegexError(`Unexpected '${c ?? "<end>"}' in pattern`);
1368
+ return [...dup];
1369
+ }
1370
+ function collectStatusInvariants(graph, findings) {
1371
+ for (const [lessonId, lesson] of Object.entries(graph.lessons)) {
1372
+ if (lesson.status === "superseded" && lesson.supersededBy === void 0) {
1373
+ findings.push({
1374
+ level: "error",
1375
+ code: "SUPERSEDED_WITHOUT_TARGET",
1376
+ message: `Lesson "${lessonId}" has status "superseded" but no supersededBy target.`,
1377
+ lessonId
1378
+ });
1189
1379
  }
1190
- i += 1;
1191
- return { k: "char", ch: c };
1192
- }
1193
- function parseGroup() {
1194
- i += 1;
1195
- if (peek() === "?") {
1196
- const c2 = src[i + 1];
1197
- if (c2 === "=" || c2 === "!" || c2 === "<") {
1198
- if (!(c2 === "<" && /[A-Za-z]/.test(src[i + 2] ?? ""))) {
1199
- throw new UnsupportedRegexError("Lookaround assertions are not supported");
1200
- }
1201
- }
1202
- if (c2 === ":") i += 2;
1203
- else if (c2 === "<") {
1204
- i += 2;
1205
- while (i < src.length && src[i] !== ">") i += 1;
1206
- i += 1;
1207
- }
1380
+ if (lesson.status === "active" && lesson.supersededBy !== void 0) {
1381
+ findings.push({
1382
+ level: "error",
1383
+ code: "ACTIVE_WITH_SUPERSEDER",
1384
+ message: `Lesson "${lessonId}" has status "active" but declares supersededBy.`,
1385
+ lessonId
1386
+ });
1208
1387
  }
1209
- const inner = parseAlt();
1210
- if (peek() !== ")") throw new UnsupportedRegexError("Unbalanced group");
1211
- i += 1;
1212
- return inner;
1213
1388
  }
1214
- function parseEscape() {
1215
- i += 1;
1216
- const c = peek();
1217
- if (c === void 0) throw new UnsupportedRegexError("Trailing backslash");
1218
- if (/[1-9]/.test(c) || c === "k")
1219
- throw new UnsupportedRegexError("Backreferences are not supported");
1220
- i += 1;
1221
- if (c === "b") return { k: "assert", kind: "wordB" };
1222
- if (c === "B") return { k: "assert", kind: "nonWordB" };
1223
- const cls = escapeClass(c);
1224
- if (cls !== null) return { k: "class", test: cls };
1225
- if (c === "x" || c === "u" || c === "c") {
1226
- const { ch, len } = c === "c" ? readControlEscape(src, i) : readUnicodeEscape(src, i, c);
1227
- i += len;
1228
- return { k: "char", ch };
1389
+ }
1390
+ function collectLifecycleInvariants(graph, findings) {
1391
+ for (const [lessonId, lesson] of Object.entries(graph.lessons)) {
1392
+ if (lesson.supersededBy === void 0) continue;
1393
+ if (lesson.supersededBy === lessonId) {
1394
+ findings.push({
1395
+ level: "error",
1396
+ code: "SELF_SUPERSEDED",
1397
+ message: `Lesson "${lessonId}" is superseded by itself.`,
1398
+ lessonId
1399
+ });
1400
+ continue;
1229
1401
  }
1230
- return { k: "char", ch: escapeLiteral(c) };
1231
- }
1232
- function parseClass() {
1233
- i += 1;
1234
- const negate = peek() === "^";
1235
- if (negate) i += 1;
1236
- const tests = [];
1237
- while (i < src.length && peek() !== "]") {
1238
- tests.push(parseClassMember());
1402
+ const target = graph.lessons[lesson.supersededBy];
1403
+ if (target !== void 0 && target.status !== "active") {
1404
+ findings.push({
1405
+ level: "error",
1406
+ code: "INACTIVE_SUPERSEDER",
1407
+ message: `Lesson "${lessonId}" is superseded by "${lesson.supersededBy}", which is itself ${target.status} \u2014 the chain dead-ends with no live replacement.`,
1408
+ lessonId
1409
+ });
1239
1410
  }
1240
- if (peek() !== "]") throw new UnsupportedRegexError("Unterminated character class");
1241
- i += 1;
1242
- const base = (c) => tests.some((t) => t(c));
1243
- return { k: "class", test: negate ? (c) => !base(c) : base };
1244
1411
  }
1245
- function parseClassMember() {
1246
- let lo;
1247
- if (peek() === "\\") {
1248
- i += 1;
1249
- const e = eat();
1250
- const cls = escapeClass(e);
1251
- if (cls !== null) return cls;
1252
- const r = classEscapeChar(src, i, e);
1253
- i += r.len;
1254
- lo = r.ch;
1255
- } else {
1256
- lo = eat();
1257
- }
1258
- if (peek() === "-" && src[i + 1] !== void 0 && src[i + 1] !== "]") {
1259
- i += 1;
1260
- let hi;
1261
- if (peek() === "\\") {
1262
- i += 1;
1263
- const e2 = eat();
1264
- const r = classEscapeChar(src, i, e2);
1265
- i += r.len;
1266
- hi = r.ch;
1267
- } else {
1268
- hi = eat();
1412
+ collectSupersedeCycles(graph, findings);
1413
+ }
1414
+ function collectSupersedeCycles(graph, findings) {
1415
+ const reported = /* @__PURE__ */ new Set();
1416
+ for (const startId of Object.keys(graph.lessons)) {
1417
+ const seen = /* @__PURE__ */ new Set();
1418
+ let cur = startId;
1419
+ while (cur !== void 0) {
1420
+ if (seen.has(cur)) {
1421
+ if (cur !== startId || reported.has(cur)) break;
1422
+ reported.add(cur);
1423
+ findings.push({
1424
+ level: "error",
1425
+ code: "SUPERSEDE_CYCLE",
1426
+ message: `Lesson "${startId}" is part of a supersededBy cycle.`,
1427
+ lessonId: startId
1428
+ });
1429
+ break;
1269
1430
  }
1270
- const a = lo.codePointAt(0);
1271
- const b = hi.codePointAt(0);
1272
- return (c) => {
1273
- const p = c.codePointAt(0);
1274
- return p >= a && p <= b;
1275
- };
1431
+ seen.add(cur);
1432
+ const next = graph.lessons[cur]?.supersededBy;
1433
+ if (next === cur) break;
1434
+ cur = next;
1276
1435
  }
1277
- return (c) => c === lo;
1278
1436
  }
1279
- const ast = parseAlt();
1280
- if (i !== src.length) throw new UnsupportedRegexError(`Unexpected '${peek()}' at ${i}`);
1281
- return ast;
1282
1437
  }
1283
-
1284
- // src/lessons/regex-linear/index.ts
1285
- var cache = /* @__PURE__ */ new Map();
1286
- function compileLinearMatcher(pattern) {
1287
- const hit = cache.get(pattern);
1288
- if (hit !== void 0 || cache.has(pattern)) return hit ?? null;
1289
- let matcher;
1290
- try {
1291
- matcher = buildMatcher(parseRegex(pattern));
1292
- } catch {
1293
- matcher = null;
1438
+ function collectReachability(graph, findings) {
1439
+ for (const [lessonId, lesson] of Object.entries(graph.lessons)) {
1440
+ if (lesson.status === "active" && lesson.triggers.length === 0) {
1441
+ findings.push({
1442
+ level: "warning",
1443
+ code: "UNREACHABLE_LESSON",
1444
+ message: `Active lesson "${lessonId}" has no triggers and can never be recalled.`,
1445
+ lessonId
1446
+ });
1447
+ }
1294
1448
  }
1295
- cache.set(pattern, matcher);
1296
- return matcher;
1297
- }
1298
-
1299
- // src/lessons/regex-safety.ts
1300
- var MAX_PATTERN_LENGTH = 1e3;
1301
- function isSafeRegexPattern(pattern) {
1302
- if (pattern.length > MAX_PATTERN_LENGTH) return false;
1303
- return compileLinearMatcher(pattern) !== null;
1304
1449
  }
1305
- function getCommandMatcher(pattern) {
1306
- if (pattern.length > MAX_PATTERN_LENGTH) return null;
1307
- return compileLinearMatcher(pattern);
1450
+ function collectOrphans(graph, findings) {
1451
+ const referencedTopics = /* @__PURE__ */ new Set();
1452
+ const referencedTriggers = /* @__PURE__ */ new Set();
1453
+ for (const lesson of Object.values(graph.lessons)) {
1454
+ for (const t of lesson.topics) referencedTopics.add(t);
1455
+ for (const t of lesson.triggers) referencedTriggers.add(t);
1456
+ }
1457
+ for (const topicId of Object.keys(graph.topics)) {
1458
+ if (!referencedTopics.has(topicId)) {
1459
+ findings.push({
1460
+ level: "warning",
1461
+ code: "ORPHAN_TOPIC",
1462
+ message: `Topic "${topicId}" is not referenced by any lesson.`,
1463
+ topicId
1464
+ });
1465
+ }
1466
+ }
1467
+ for (const triggerId of Object.keys(graph.triggers)) {
1468
+ if (!referencedTriggers.has(triggerId)) {
1469
+ findings.push({
1470
+ level: "warning",
1471
+ code: "ORPHAN_TRIGGER",
1472
+ message: `Trigger "${triggerId}" is not referenced by any lesson.`,
1473
+ triggerId
1474
+ });
1475
+ }
1476
+ }
1308
1477
  }
1309
1478
 
1310
1479
  // src/lessons/validate-quality.ts
@@ -1410,6 +1579,11 @@ function collectFanout(graph, findings) {
1410
1579
  });
1411
1580
  }
1412
1581
  }
1582
+ function normalizeRule2(rule) {
1583
+ return rule.trim().replace(/\s+/g, " ").toLowerCase();
1584
+ }
1585
+
1586
+ // src/lessons/validate-keywords.ts
1413
1587
  function collectLowSignalKeywords(graph, findings) {
1414
1588
  const activeTriggerIds2 = /* @__PURE__ */ new Set();
1415
1589
  for (const lesson of Object.values(graph.lessons)) {
@@ -1423,7 +1597,7 @@ function collectLowSignalKeywords(graph, findings) {
1423
1597
  findings.push({
1424
1598
  level: "warning",
1425
1599
  code: "LOW_SIGNAL_KEYWORD",
1426
- message: `Keyword trigger "${triggerId}" carries more than ${MAX_RECOMMENDED_KEYWORD_TOKENS} tokens (${trigger.pattern}); recall matches a keyword only as a substring of --keyword or a contiguous token-run in the file/command, so it rarely fires \u2014 use a short distinctive phrase.`,
1600
+ message: `Keyword trigger "${triggerId}" carries more than ${MAX_RECOMMENDED_KEYWORD_TOKENS} tokens (${trigger.pattern}); recall matches a keyword only as a contiguous token-run in --keyword or the file/command, so it rarely fires \u2014 use a short distinctive phrase.`,
1427
1601
  triggerId
1428
1602
  });
1429
1603
  }
@@ -1448,9 +1622,6 @@ function collectStopwordKeywords(graph, findings) {
1448
1622
  });
1449
1623
  }
1450
1624
  }
1451
- function normalizeRule2(rule) {
1452
- return rule.trim().replace(/\s+/g, " ").toLowerCase();
1453
- }
1454
1625
 
1455
1626
  // src/lessons/validate.ts
1456
1627
  function validateLessonsGraph(graph, options = {}) {
@@ -1478,6 +1649,8 @@ function validateLessonsGraph(graph, options = {}) {
1478
1649
  collectLowSignalKeywords(graph, findings);
1479
1650
  collectStopwordKeywords(graph, findings);
1480
1651
  collectRunnerAnchoredPatterns(graph, findings);
1652
+ collectBroadCommandPatterns(graph, findings);
1653
+ collectBroadFileGlobs(graph, findings);
1481
1654
  if (options.knownPaths !== void 0) collectDeadFileGlobs(graph, findings, options.knownPaths);
1482
1655
  const ok = findings.every((f) => f.level !== "error");
1483
1656
  return { ok, findings };
@@ -1521,52 +1694,7 @@ async function mutateLessonsGraph(projectRoot, mutator, options = {}) {
1521
1694
  return mutateLessonsGraphLocked(projectRoot, mutator, options);
1522
1695
  }
1523
1696
 
1524
- // src/lessons/trigger-effectiveness.ts
1525
- function ineffectiveTriggers(graph, triggerIds) {
1526
- const out = [];
1527
- for (const id of triggerIds) {
1528
- const trigger = graph.triggers[id];
1529
- if (trigger === void 0) continue;
1530
- const reason = ineffectiveReason(trigger.kind, trigger.pattern);
1531
- if (reason !== null) out.push({ id, kind: trigger.kind, pattern: trigger.pattern, reason });
1532
- }
1533
- return out;
1534
- }
1535
- function ineffectiveReason(kind, pattern) {
1536
- if (kind === "keyword") {
1537
- if (tokenize(pattern).length === 0) {
1538
- return "keyword has no matchable token after stopword filtering \u2014 it cannot fire on the mandatory --file/--cmd recall path";
1539
- }
1540
- if (keywordNeedleLosesTokens(pattern)) {
1541
- return "keyword contains stopwords/short words, so its needle can never appear as a contiguous run on the mandatory --file/--cmd recall path";
1542
- }
1543
- return null;
1544
- }
1545
- if (kind === "command_pattern") {
1546
- let valid = true;
1547
- try {
1548
- new RegExp(pattern);
1549
- } catch {
1550
- valid = false;
1551
- }
1552
- if (!valid) {
1553
- return "invalid regex \u2014 recall compiles it with new RegExp and swallows the throw as a non-match, so it never fires";
1554
- }
1555
- if (!isSafeRegexPattern(pattern)) {
1556
- return "regex is outside the provably-linear engine \u2014 recall skips it (ReDoS guard), so it never fires";
1557
- }
1558
- return null;
1559
- }
1560
- return null;
1561
- }
1562
- function blockingDeadTriggers(graph, triggerIds) {
1563
- return ineffectiveTriggers(graph, triggerIds).filter((t) => t.kind !== "command_pattern");
1564
- }
1565
-
1566
1697
  // src/lessons/add.ts
1567
- function countInputTriggers(triggers) {
1568
- return (triggers.files?.length ?? 0) + (triggers.commands?.length ?? 0) + (triggers.keywords?.length ?? 0);
1569
- }
1570
1698
  async function addLesson(projectRoot, input, options = {}) {
1571
1699
  return mutateLessonsGraph(projectRoot, (graph) => addLessonInto(graph, input, options), {
1572
1700
  retries: options.retries
@@ -1574,10 +1702,7 @@ async function addLesson(projectRoot, input, options = {}) {
1574
1702
  }
1575
1703
  function addLessonInto(graph, input, options) {
1576
1704
  const ruleKey = normalizeRule(input.rule);
1577
- const trimmedRule = input.rule.trim();
1578
- if (trimmedRule.length > MAX_RULE_LENGTH) {
1579
- throw new RuleTooLongError(trimmedRule.length, MAX_RULE_LENGTH);
1580
- }
1705
+ const trimmedRule = assertRuleShape(input.rule);
1581
1706
  const existingId = findExistingLessonByRule(graph, ruleKey);
1582
1707
  const isNewTopic = graph.topics[input.topic] === void 0;
1583
1708
  if (isNewTopic) {
@@ -1587,29 +1712,23 @@ function addLessonInto(graph, input, options) {
1587
1712
  }
1588
1713
  graph.topics[input.topic] = { summary: options.topicSummary };
1589
1714
  }
1590
- const skipTriggerGates = options.allowNoTrigger === true || input.scope === "always";
1591
- if (!skipTriggerGates) {
1592
- const existingTriggers = existingId !== null ? graph.lessons[existingId]?.triggers.length ?? 0 : 0;
1593
- if (countInputTriggers(input.triggers) === 0 && existingTriggers === 0) {
1594
- throw new NoTriggerError();
1595
- }
1596
- }
1715
+ const existing = existingId !== null ? graph.lessons[existingId] : void 0;
1716
+ assertTriggerInputs(input, options, existing?.triggers.length ?? 0);
1597
1717
  const { triggerIds, newTriggerIds } = mergeTriggers(graph, input.triggers);
1598
- if (!skipTriggerGates) {
1599
- const resultingTriggers = existingId !== null ? union(graph.lessons[existingId].triggers, triggerIds) : triggerIds;
1600
- const blockingDead = blockingDeadTriggers(graph, resultingTriggers);
1601
- if (resultingTriggers.length > 0 && blockingDead.length === resultingTriggers.length) {
1602
- throw new UnrecallableLessonError(blockingDead);
1603
- }
1718
+ if (!skipsTriggerGates(input, options)) {
1719
+ assertRecallable(
1720
+ graph,
1721
+ existing === void 0 ? triggerIds : union(existing.triggers, triggerIds)
1722
+ );
1604
1723
  }
1605
1724
  if (existingId !== null) {
1606
- const existing = graph.lessons[existingId];
1725
+ const existing2 = graph.lessons[existingId];
1607
1726
  graph.lessons[existingId] = {
1608
- ...existing,
1609
- topics: union(existing.topics, [input.topic]),
1610
- triggers: union(existing.triggers, triggerIds),
1611
- evidence: union(existing.evidence, input.evidence ?? []),
1612
- ...existing.rationale === void 0 && input.rationale !== void 0 ? { rationale: input.rationale } : {},
1727
+ ...existing2,
1728
+ topics: union(existing2.topics, [input.topic]),
1729
+ triggers: union(existing2.triggers, triggerIds),
1730
+ evidence: union(existing2.evidence, input.evidence ?? []),
1731
+ ...existing2.rationale === void 0 && input.rationale !== void 0 ? { rationale: input.rationale } : {},
1613
1732
  // Re-capturing a rule with --scope always promotes it to always-on.
1614
1733
  ...input.scope === "always" ? { scope: "always" } : {}
1615
1734
  };
@@ -1814,14 +1933,16 @@ function deriveHaystackTokens(query) {
1814
1933
  if (query.command !== void 0) parts.push(query.command);
1815
1934
  if (parts.length === 0) return [];
1816
1935
  const out = [];
1817
- for (const raw of parts.join(" ").split(/[^A-Za-z0-9]+/)) {
1818
- if (raw.length === 0) continue;
1936
+ for (const raw of splitTokens(parts.join(" "))) {
1819
1937
  out.push(raw.toLowerCase());
1820
1938
  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);
1821
1939
  if (sub.length > 1) out.push(...sub);
1822
1940
  }
1823
1941
  return out;
1824
1942
  }
1943
+ function splitTokens(text) {
1944
+ return text.split(/[^A-Za-z0-9]+/).filter((t) => t.length > 0);
1945
+ }
1825
1946
  function containsRun(needle, hay) {
1826
1947
  if (needle.length === 0) return false;
1827
1948
  for (let i = 0; i + needle.length <= hay.length; i += 1) {
@@ -1837,10 +1958,11 @@ function containsRun(needle, hay) {
1837
1958
  return false;
1838
1959
  }
1839
1960
  function keywordMatches(pattern, query) {
1840
- if (query.keyword !== void 0 && query.keyword.toLowerCase().includes(pattern.toLowerCase())) {
1961
+ const needle = tokenize(pattern);
1962
+ if (query.keyword !== void 0 && containsRun(needle, splitTokens(query.keyword.toLowerCase()))) {
1841
1963
  return true;
1842
1964
  }
1843
- return containsRun(tokenize(pattern), deriveHaystackTokens(query));
1965
+ return containsRun(needle, deriveHaystackTokens(query));
1844
1966
  }
1845
1967
 
1846
1968
  // src/lessons/query.ts
@@ -2043,6 +2165,15 @@ function buildFanout(graph) {
2043
2165
  }
2044
2166
  return fanout;
2045
2167
  }
2168
+ var KEYWORD_NARROWNESS = 0.4;
2169
+ function buildNarrowness(graph) {
2170
+ const narrowness = /* @__PURE__ */ new Map();
2171
+ for (const [id, trigger] of Object.entries(graph.triggers)) {
2172
+ if (trigger.kind === "file_glob") narrowness.set(id, globNarrowness(trigger.pattern));
2173
+ else narrowness.set(id, trigger.kind === "keyword" ? KEYWORD_NARROWNESS : 1);
2174
+ }
2175
+ return narrowness;
2176
+ }
2046
2177
  function buildTopicCoherence(matches) {
2047
2178
  const topicCount = /* @__PURE__ */ new Map();
2048
2179
  for (const { lesson } of matches) {
@@ -2061,7 +2192,7 @@ function buildTopicCoherence(matches) {
2061
2192
  var DEFAULT_RECALL_LIMIT = 10;
2062
2193
  var DEFAULT_RECALL_MAX_TOKENS = 400;
2063
2194
  var RRF_K = 60;
2064
- var SPECIFICITY_WEIGHT = 3;
2195
+ var SPECIFICITY_WEIGHT = 5;
2065
2196
  var TOPIC_COHERENCE_WEIGHT = 2;
2066
2197
  var BM25_WEIGHT = 1;
2067
2198
  var EFFECTIVENESS_WEIGHT = 1;
@@ -2088,12 +2219,15 @@ function rankLessons(graph, query, matches, options = {}) {
2088
2219
  const terms = queryTerms(query);
2089
2220
  const corpus = buildCorpus(graph);
2090
2221
  const fanout = buildFanout(graph);
2222
+ const narrowness = buildNarrowness(graph);
2091
2223
  const coherence = buildTopicCoherence(matches);
2092
2224
  const matchedTriggerIds = collectMatchedTriggerIds(graph, query);
2093
2225
  const scored = matches.map(({ id, lesson }) => {
2094
2226
  const hitTriggers = lesson.triggers.filter((t) => matchedTriggerIds.has(t));
2095
2227
  let specificity = 0;
2096
- for (const t of hitTriggers) specificity = Math.max(specificity, 1 / fanout.get(t));
2228
+ for (const t of hitTriggers) {
2229
+ specificity = Math.max(specificity, (narrowness.get(t) ?? 1) / fanout.get(t));
2230
+ }
2097
2231
  return {
2098
2232
  id,
2099
2233
  lesson,
@@ -2710,9 +2844,9 @@ var LINE_REFS = String.raw`L\d+(?:\s*,\s*L\d+)*`;
2710
2844
  var LINE_REF_PATTERNS = [
2711
2845
  new RegExp(String.raw`\s*\bSee\s+${LINE_REFS}\.?`, "g"),
2712
2846
  // " See L128." / " See L140, L149"
2713
- new RegExp(String.raw`\s*\((?:${LINE_REFS})\)\.?`, "g"),
2847
+ new RegExp(String.raw`\s*\((?:${LINE_REFS})\)`, "g"),
2714
2848
  // " (L174)" / " (L92, L163)"
2715
- new RegExp(String.raw`\s*\[(?:${LINE_REFS})\]\.?`, "g")
2849
+ new RegExp(String.raw`\s*\[(?:${LINE_REFS})\]`, "g")
2716
2850
  // " [L161, L208]"
2717
2851
  ];
2718
2852
  var ALSO_RELEVANT_PATTERN = /\s*\(also relevant[^)]*\)\s*/g;
@@ -2950,6 +3084,32 @@ function isCoveredByExisting(candidate, existing) {
2950
3084
  }
2951
3085
  return false;
2952
3086
  }
3087
+ var OPENER = /^---[ \t]*\r?\n/;
3088
+ var CLOSER = /^---[ \t]*\r?$/m;
3089
+ function splitFrontmatter(content) {
3090
+ const opener = OPENER.exec(content);
3091
+ if (opener === null) return null;
3092
+ const yamlStart = opener[0].length;
3093
+ const closer = CLOSER.exec(content.slice(yamlStart));
3094
+ if (closer === null) return null;
3095
+ const closeStart = yamlStart + closer.index;
3096
+ const closeEnd = closeStart + closer[0].length;
3097
+ return {
3098
+ yaml: content.slice(yamlStart, closeStart),
3099
+ body: content.slice(closeEnd).trim(),
3100
+ prefix: content.slice(0, closeEnd)
3101
+ };
3102
+ }
3103
+ function serializeFrontmatter(frontmatter, body) {
3104
+ const keys = Object.keys(frontmatter);
3105
+ if (keys.length === 0) return body;
3106
+ const yamlStr = stringify(frontmatter, { lineWidth: 0 }).trimEnd();
3107
+ return `---
3108
+ ${yamlStr}
3109
+ ---
3110
+
3111
+ ${body}`;
3112
+ }
2953
3113
  var LESSONS_CONTRACT_START = "<!-- agentsmesh:lessons-contract:start -->";
2954
3114
  var LESSONS_CONTRACT_END = "<!-- agentsmesh:lessons-contract:end -->";
2955
3115
  function escapeRegExp(value) {
@@ -2962,10 +3122,8 @@ function stripManagedBlock(content, start, end) {
2962
3122
  return content.replace(managedBlockPattern(start, end), "").trim();
2963
3123
  }
2964
3124
  function splitFrontmatterPrefix(content) {
2965
- if (content.indexOf("---") !== 0) return { prefix: "", body: content.trim() };
2966
- const close = content.indexOf("---", 3);
2967
- if (close === -1) return { prefix: "", body: content.trim() };
2968
- return { prefix: content.slice(0, close + 3), body: content.slice(close + 3).trim() };
3125
+ const split = splitFrontmatter(content);
3126
+ return split === null ? { prefix: "", body: content.trim() } : { prefix: split.prefix, body: split.body };
2969
3127
  }
2970
3128
  function insertAtBodyTop(content, block) {
2971
3129
  const { prefix, body } = splitFrontmatterPrefix(content);
@@ -3019,16 +3177,6 @@ ${rule}`, "").replace(rule, ""),
3019
3177
  content
3020
3178
  );
3021
3179
  }
3022
- function serializeFrontmatter(frontmatter, body) {
3023
- const keys = Object.keys(frontmatter);
3024
- if (keys.length === 0) return body;
3025
- const yamlStr = stringify(frontmatter, { lineWidth: 0 }).trimEnd();
3026
- return `---
3027
- ${yamlStr}
3028
- ---
3029
-
3030
- ${body}`;
3031
- }
3032
3180
 
3033
3181
  // src/lessons/skill.ts
3034
3182
  var LESSONS_SKILL_NAME = "lessons";