agentsmesh 0.32.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
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, renameSync, readdirSync, realpathSync, appendFileSync, statSync } from 'fs';
2
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, statSync, renameSync, readdirSync, realpathSync, appendFileSync } from 'fs';
3
3
  import { resolve, dirname, join, relative, sep, basename, extname } from 'path';
4
4
  import { stringify, parse, parseDocument, YAMLSeq, YAMLMap } from 'yaml';
5
5
  import { createHash } from 'crypto';
@@ -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;
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 };
675
+ }
676
+ return { k: "char", ch: escapeLiteral(c) };
577
677
  }
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;
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 };
587
690
  }
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;
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();
609
703
  }
610
- if (attempt >= retries) {
611
- const holder = existing === "young" ? null : existing;
612
- throw new LockAcquisitionError(lockPath, describeHolder(holder), { label: opts.label });
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
+ };
613
722
  }
614
- attempt++;
615
- await sleep(delay);
616
- }
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;
723
+ return (c) => c === lo;
624
724
  }
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 {
639
- }
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
- };
725
+ const ast = parseAlt();
726
+ if (i !== src.length) throw new UnsupportedRegexError(`Unexpected '${peek()}' at ${i}`);
727
+ return ast;
657
728
  }
658
- async function inspectLock(lockPath) {
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;
659
736
  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;
737
+ matcher = buildMatcher(parseRegex(pattern));
664
738
  } 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 {
670
- }
671
- return null;
739
+ matcher = null;
672
740
  }
741
+ cache.set(pattern, matcher);
742
+ return matcher;
673
743
  }
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);
744
+
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;
680
750
  }
681
- function isProcessAlive(pid) {
682
- if (!Number.isInteger(pid) || pid <= 0) return false;
683
- try {
684
- process.kill(pid, 0);
685
- return true;
686
- } catch (err) {
687
- return err.code === "EPERM";
751
+ function getCommandMatcher(pattern) {
752
+ if (pattern.length > MAX_PATTERN_LENGTH) return null;
753
+ return compileLinearMatcher(pattern);
754
+ }
755
+
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;
688
788
  }
789
+ return hits > COMMAND_PROBE_CORPUS.length * BROAD_HIT_RATIO;
689
790
  }
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)`;
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));
694
819
  }
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";
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(" "));
699
826
  }
700
- function getHostname() {
701
- return hostname();
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);
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 };
702
844
  }
703
- function sleep(ms) {
704
- return new Promise((resolve7) => setTimeout(resolve7, ms));
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));
856
+ }
857
+ return score;
705
858
  }
706
859
 
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);
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;
711
864
  }
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" });
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;
716
872
  }
717
873
 
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
- });
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 });
882
+ }
883
+ return out;
884
+ }
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";
889
+ }
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";
750
892
  }
893
+ return null;
751
894
  }
752
- }
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
- });
895
+ if (kind === "command_pattern") {
896
+ let valid = true;
897
+ try {
898
+ new RegExp(pattern);
899
+ } catch {
900
+ valid = false;
763
901
  }
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
- });
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";
772
907
  }
908
+ return null;
773
909
  }
910
+ return null;
774
911
  }
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);
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();
932
+ }
933
+ if (options.allowNoTrigger !== true) {
934
+ const broad = (input.triggers.commands ?? []).find(isBroadCommandPattern);
935
+ if (broad !== void 0) throw new BroadCommandPatternError(broad);
781
936
  }
782
- return [...dup];
783
937
  }
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
- }
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);
802
942
  }
803
943
  }
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;
815
- }
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
- });
824
- }
944
+
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;
825
955
  }
826
- collectSupersedeCycles(graph, findings);
956
+ return literal / (segments.length + globstars);
827
957
  }
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;
849
- }
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);
850
967
  }
968
+ return ids;
851
969
  }
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
- });
861
- }
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);
862
979
  }
980
+ return dead;
863
981
  }
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);
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
+ });
870
990
  }
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
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
+ }
1040
+ }
1041
+
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("*");
1051
+ }
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
+ });
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.`
878
1097
  });
879
1098
  }
880
- }
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
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.`
888
1104
  });
889
1105
  }
890
1106
  }
1107
+ return warnings;
891
1108
  }
892
1109
 
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`);
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 };
900
1125
  }
901
- this.states.push({ eps: [], asserts: [], chars: [] });
902
- return this.states.length - 1;
903
1126
  }
904
- };
905
- function isNonLineTerminator(c) {
906
- return c !== "\n" && c !== "\r" && c !== "\u2028" && c !== "\u2029";
1127
+ if (best === null) return null;
1128
+ return {
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).`
1131
+ };
907
1132
  }
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 };
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
+ }
1138
+
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;
1174
+ }
1175
+ };
1176
+
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;
964
1196
  }
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 };
1197
+ if (attempt >= retries) {
1198
+ const holder = existing === "young" ? null : existing;
1199
+ throw new LockAcquisitionError(lockPath, describeHolder(holder), { label: opts.label });
970
1200
  }
1201
+ attempt++;
1202
+ await sleep(delay);
971
1203
  }
972
1204
  }
973
- function compileNfa(ast) {
974
- const b = new Builder();
975
- const { start, end } = compileNode(b, ast);
976
- return { states: b.states, start, accept: end };
977
- }
978
-
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;
984
- }
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);
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;
995
1211
  }
996
- }
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
- }
1011
- }
1212
+ const metadataPath = join(lockPath, "holder.json");
1213
+ const metadata = {
1214
+ pid: process.pid,
1215
+ started: Date.now(),
1216
+ hostname: getHostname()
1012
1217
  };
1013
- 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);
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 {
1037
1226
  }
1038
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
+ };
1039
1244
  }
1040
-
1041
- // src/lessons/regex-linear/ast.ts
1042
- var UnsupportedRegexError = class extends Error {
1043
- constructor(message) {
1044
- super(message);
1045
- this.name = "UnsupportedRegexError";
1046
- }
1047
- };
1048
-
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 });
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;
1059
1259
  }
1060
- if (items.length === 0) return { k: "empty" };
1061
- return items.length === 1 ? items[0] : { k: "concat", items };
1062
1260
  }
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;
1079
- }
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;
1080
1266
  }
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 };
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";
1087
1274
  }
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 };
1090
1275
  }
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");
1095
- }
1096
- return { ch: String.fromCharCode(x.charCodeAt(0) & 31), len: 1 };
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)`;
1097
1280
  }
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 };
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";
1103
1285
  }
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
- }
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" };
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
+ });
1178
1379
  }
1179
- if (c === "^") {
1180
- i += 1;
1181
- return { k: "assert", kind: "start" };
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
+ });
1182
1387
  }
1183
- if (c === "$") {
1184
- i += 1;
1185
- return { k: "assert", kind: "end" };
1388
+ }
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;
1186
1401
  }
1187
- if (c === void 0 || c === "*" || c === "+" || c === "?" || c === ")") {
1188
- throw new UnsupportedRegexError(`Unexpected '${c ?? "<end>"}' in pattern`);
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
+ });
1189
1410
  }
1190
- i += 1;
1191
- return { k: "char", ch: c };
1192
1411
  }
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;
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;
1207
1430
  }
1431
+ seen.add(cur);
1432
+ const next = graph.lessons[cur]?.supersededBy;
1433
+ if (next === cur) break;
1434
+ cur = next;
1208
1435
  }
1209
- const inner = parseAlt();
1210
- if (peek() !== ")") throw new UnsupportedRegexError("Unbalanced group");
1211
- i += 1;
1212
- return inner;
1213
1436
  }
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 };
1437
+ }
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
+ });
1229
1447
  }
1230
- return { k: "char", ch: escapeLiteral(c) };
1231
1448
  }
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());
1239
- }
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 };
1449
+ }
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);
1244
1456
  }
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();
1269
- }
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
- };
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
+ });
1276
1465
  }
1277
- return (c) => c === lo;
1278
1466
  }
1279
- const ast = parseAlt();
1280
- if (i !== src.length) throw new UnsupportedRegexError(`Unexpected '${peek()}' at ${i}`);
1281
- return ast;
1282
- }
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;
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
+ }
1294
1476
  }
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
- }
1305
- function getCommandMatcher(pattern) {
1306
- if (pattern.length > MAX_PATTERN_LENGTH) return null;
1307
- return compileLinearMatcher(pattern);
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 };
@@ -1509,64 +1682,19 @@ async function mutateLessonsGraphLocked(projectRoot, mutator, options = {}) {
1509
1682
  `mutateLessonsGraph: refusing to write \u2014 this change introduces ${errors}. (Pre-existing graph issues are not blocking; run \`agentsmesh lessons validate\` to review and \`lessons untrigger\`/\`prune\` to repair them.)`
1510
1683
  );
1511
1684
  }
1512
- graph.version = CURRENT_GRAPH_VERSION;
1513
- saveLessonsGraph(projectRoot, graph);
1514
- return result;
1515
- } finally {
1516
- await release();
1517
- }
1518
- }
1519
- async function mutateLessonsGraph(projectRoot, mutator, options = {}) {
1520
- await maybeAutoMigrateLessons(projectRoot);
1521
- return mutateLessonsGraphLocked(projectRoot, mutator, options);
1522
- }
1523
-
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;
1685
+ graph.version = CURRENT_GRAPH_VERSION;
1686
+ saveLessonsGraph(projectRoot, graph);
1687
+ return result;
1688
+ } finally {
1689
+ await release();
1559
1690
  }
1560
- return null;
1561
1691
  }
1562
- function blockingDeadTriggers(graph, triggerIds) {
1563
- return ineffectiveTriggers(graph, triggerIds).filter((t) => t.kind !== "command_pattern");
1692
+ async function mutateLessonsGraph(projectRoot, mutator, options = {}) {
1693
+ await maybeAutoMigrateLessons(projectRoot);
1694
+ return mutateLessonsGraphLocked(projectRoot, mutator, options);
1564
1695
  }
1565
1696
 
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
  };
@@ -1702,7 +1821,7 @@ var LESSONS_PROCEDURAL_RULE = `## Lessons (BLOCKING)
1702
1821
 
1703
1822
  Graph \`.agentsmesh/lessons/lessons.json\` is canonical; never hand-edit it. Manual: \`lessons\` skill.
1704
1823
 
1705
- **Recall:** before every file edit or state-changing command, MUST run \`agentsmesh lessons query --file <path> --cmd <command>\` and obey matches; at task start, ALSO run \`agentsmesh lessons query --keyword "<task terms>" --always\` for conceptual + universal rules no path/command names. Pure-read commands and recall itself are exempt.
1824
+ **Recall:** before every file edit or state-changing command, MUST run \`agentsmesh lessons query --file <path> --cmd <command> --session auto\` and obey matches; at task start, ALSO run \`agentsmesh lessons query --keyword "<task terms>" --always --session auto\` for conceptual + universal rules no path/command names. Pure-read commands and recall itself are exempt.
1706
1825
 
1707
1826
  **Capture:** after any failure, user correction, regression, wrong assumption, useful surprise, repeated friction, or non-obvious fix, MUST self-critique and run \`agentsmesh lessons add "<imperative rule>" --topic <id> --trigger-file <glob> --evidence <sha|lesson-id>\`.
1708
1827
 
@@ -1806,26 +1925,6 @@ async function maybeAutoMigrateLessons(projectRoot) {
1806
1925
  throw err;
1807
1926
  }
1808
1927
  }
1809
- function normalizeRecallFile(file, projectRoot) {
1810
- const forward = file.replaceAll("\\", "/");
1811
- const direct = relativize(projectRoot, forward);
1812
- if (!direct.startsWith("../")) return direct;
1813
- const viaReal = relativize(safeRealpath(projectRoot), safeRealpath(resolve(projectRoot, forward)));
1814
- return viaReal.startsWith("../") ? direct : viaReal;
1815
- }
1816
- function relativize(root, forward) {
1817
- const rel = relative(root, resolve(root, forward)).replaceAll("\\", "/");
1818
- return rel === "" ? forward.replaceAll("\\", "/") : rel;
1819
- }
1820
- function safeRealpath(path) {
1821
- try {
1822
- return realpathSync(path);
1823
- } catch {
1824
- const parent = dirname(path);
1825
- if (parent === path) return path;
1826
- return resolve(safeRealpath(parent), basename(path));
1827
- }
1828
- }
1829
1928
 
1830
1929
  // src/lessons/keyword-match.ts
1831
1930
  function deriveHaystackTokens(query) {
@@ -1834,14 +1933,16 @@ function deriveHaystackTokens(query) {
1834
1933
  if (query.command !== void 0) parts.push(query.command);
1835
1934
  if (parts.length === 0) return [];
1836
1935
  const out = [];
1837
- for (const raw of parts.join(" ").split(/[^A-Za-z0-9]+/)) {
1838
- if (raw.length === 0) continue;
1936
+ for (const raw of splitTokens(parts.join(" "))) {
1839
1937
  out.push(raw.toLowerCase());
1840
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);
1841
1939
  if (sub.length > 1) out.push(...sub);
1842
1940
  }
1843
1941
  return out;
1844
1942
  }
1943
+ function splitTokens(text) {
1944
+ return text.split(/[^A-Za-z0-9]+/).filter((t) => t.length > 0);
1945
+ }
1845
1946
  function containsRun(needle, hay) {
1846
1947
  if (needle.length === 0) return false;
1847
1948
  for (let i = 0; i + needle.length <= hay.length; i += 1) {
@@ -1857,10 +1958,11 @@ function containsRun(needle, hay) {
1857
1958
  return false;
1858
1959
  }
1859
1960
  function keywordMatches(pattern, query) {
1860
- 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()))) {
1861
1963
  return true;
1862
1964
  }
1863
- return containsRun(tokenize(pattern), deriveHaystackTokens(query));
1965
+ return containsRun(needle, deriveHaystackTokens(query));
1864
1966
  }
1865
1967
 
1866
1968
  // src/lessons/query.ts
@@ -1911,6 +2013,148 @@ function triggerMatches(trigger, query, budget) {
1911
2013
  return keywordMatches(trigger.pattern, query);
1912
2014
  }
1913
2015
  }
2016
+ function appendJsonl(path, record, opts) {
2017
+ mkdirSync(dirname(path), { recursive: true });
2018
+ appendFileSync(path, `${JSON.stringify(record)}
2019
+ `, "utf8");
2020
+ if (statSync(path).size > opts.trimTriggerBytes) capJsonl(path, opts.maxRecords);
2021
+ }
2022
+ function capJsonl(path, maxRecords) {
2023
+ if (!existsSync(path)) return;
2024
+ const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim().length > 0);
2025
+ if (lines.length <= maxRecords) return;
2026
+ const kept = lines.slice(lines.length - maxRecords);
2027
+ const tmp = `${path}.${process.pid}.tmp`;
2028
+ writeFileSync(tmp, `${kept.join("\n")}
2029
+ `, "utf8");
2030
+ renameSync(tmp, path);
2031
+ }
2032
+ function readJsonl(path) {
2033
+ if (!existsSync(path)) return [];
2034
+ const out = [];
2035
+ for (const line of readFileSync(path, "utf8").split("\n")) {
2036
+ if (line.trim().length === 0) continue;
2037
+ try {
2038
+ out.push(JSON.parse(line));
2039
+ } catch {
2040
+ }
2041
+ }
2042
+ return out;
2043
+ }
2044
+
2045
+ // src/lessons/telemetry.ts
2046
+ var MAX_RECALL_LOG_RECORDS = 5e3;
2047
+ var RECALL_LOG_TRIM_TRIGGER_BYTES = 2e6;
2048
+ var TELEMETRY_ENV = "AGENTSMESH_LESSONS_TELEMETRY";
2049
+ var SESSION_ENV = "AGENTSMESH_SESSION_ID";
2050
+ function sessionId(env = process.env) {
2051
+ const raw = env[SESSION_ENV];
2052
+ return raw !== void 0 && raw.trim().length > 0 ? raw : void 0;
2053
+ }
2054
+ function recallLogPath(projectRoot) {
2055
+ return join(lessonsPaths(projectRoot).base, "recall-log.jsonl");
2056
+ }
2057
+ function isTelemetryEnabled(env = process.env) {
2058
+ return env[TELEMETRY_ENV] === "1";
2059
+ }
2060
+ function appendRecallRecord(projectRoot, record, env = process.env) {
2061
+ if (!isTelemetryEnabled(env)) return;
2062
+ appendJsonl(recallLogPath(projectRoot), record, {
2063
+ maxRecords: MAX_RECALL_LOG_RECORDS,
2064
+ trimTriggerBytes: RECALL_LOG_TRIM_TRIGGER_BYTES
2065
+ });
2066
+ }
2067
+
2068
+ // src/lessons/cmd-fastpath.ts
2069
+ var FASTPATH_DIR = "agentsmesh-lessons-cmdidx";
2070
+ function shortHash(value) {
2071
+ let h = 5381;
2072
+ for (let i = 0; i < value.length; i += 1) h = (h << 5) + h + value.charCodeAt(i) >>> 0;
2073
+ return h.toString(36);
2074
+ }
2075
+ function commandFastpathCachePath(projectRoot) {
2076
+ return join(tmpdir(), FASTPATH_DIR, `${shortHash(resolve(projectRoot))}.json`);
2077
+ }
2078
+ function currentGraphStamp(projectRoot) {
2079
+ try {
2080
+ const s = statSync(graphFilePath(projectRoot));
2081
+ return { mtimeMs: s.mtimeMs, size: s.size };
2082
+ } catch {
2083
+ return null;
2084
+ }
2085
+ }
2086
+ function isStringArray(v) {
2087
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
2088
+ }
2089
+ function readCache(path) {
2090
+ try {
2091
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
2092
+ if (typeof parsed !== "object" || parsed === null) return null;
2093
+ const c = parsed;
2094
+ const stamp = c.stamp;
2095
+ if (typeof stamp?.mtimeMs !== "number" || typeof stamp.size !== "number" || !isStringArray(c.commandPatterns) || !isStringArray(c.keywordPatterns)) {
2096
+ return null;
2097
+ }
2098
+ return {
2099
+ stamp: { mtimeMs: stamp.mtimeMs, size: stamp.size },
2100
+ commandPatterns: c.commandPatterns,
2101
+ keywordPatterns: c.keywordPatterns
2102
+ };
2103
+ } catch {
2104
+ return null;
2105
+ }
2106
+ }
2107
+ function refreshCommandFastpath(projectRoot, graph, preReadStamp) {
2108
+ try {
2109
+ if (preReadStamp === null) return;
2110
+ const stamp = currentGraphStamp(projectRoot);
2111
+ if (stamp === null) return;
2112
+ if (stamp.mtimeMs !== preReadStamp.mtimeMs || stamp.size !== preReadStamp.size) return;
2113
+ const path = commandFastpathCachePath(projectRoot);
2114
+ const existing = readCache(path);
2115
+ if (existing !== null && existing.stamp.mtimeMs === stamp.mtimeMs && existing.stamp.size === stamp.size) {
2116
+ return;
2117
+ }
2118
+ const reachable = /* @__PURE__ */ new Set();
2119
+ for (const lesson of Object.values(graph.lessons)) {
2120
+ if (lesson.status !== "active" || lesson.scope === "always") continue;
2121
+ for (const t of lesson.triggers) reachable.add(t);
2122
+ }
2123
+ const commandPatterns = [];
2124
+ const keywordPatterns = [];
2125
+ for (const [id, trigger] of Object.entries(graph.triggers)) {
2126
+ if (!reachable.has(id)) continue;
2127
+ if (trigger.kind === "command_pattern") commandPatterns.push(trigger.pattern);
2128
+ else if (trigger.kind === "keyword") keywordPatterns.push(trigger.pattern);
2129
+ }
2130
+ const cache2 = { stamp, commandPatterns, keywordPatterns };
2131
+ mkdirSync(dirname(path), { recursive: true });
2132
+ const tmp = `${path}.${process.pid}.tmp`;
2133
+ writeFileSync(tmp, JSON.stringify(cache2), "utf8");
2134
+ renameSync(tmp, path);
2135
+ } catch {
2136
+ }
2137
+ }
2138
+ function normalizeRecallFile(file, projectRoot) {
2139
+ const forward = file.replaceAll("\\", "/");
2140
+ const direct = relativize(projectRoot, forward);
2141
+ if (!direct.startsWith("../")) return direct;
2142
+ const viaReal = relativize(safeRealpath(projectRoot), safeRealpath(resolve(projectRoot, forward)));
2143
+ return viaReal.startsWith("../") ? direct : viaReal;
2144
+ }
2145
+ function relativize(root, forward) {
2146
+ const rel = relative(root, resolve(root, forward)).replaceAll("\\", "/");
2147
+ return rel === "" ? forward.replaceAll("\\", "/") : rel;
2148
+ }
2149
+ function safeRealpath(path) {
2150
+ try {
2151
+ return realpathSync(path);
2152
+ } catch {
2153
+ const parent = dirname(path);
2154
+ if (parent === path) return path;
2155
+ return resolve(safeRealpath(parent), basename(path));
2156
+ }
2157
+ }
1914
2158
 
1915
2159
  // src/lessons/ranking-signals.ts
1916
2160
  function buildFanout(graph) {
@@ -1921,6 +2165,15 @@ function buildFanout(graph) {
1921
2165
  }
1922
2166
  return fanout;
1923
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
+ }
1924
2177
  function buildTopicCoherence(matches) {
1925
2178
  const topicCount = /* @__PURE__ */ new Map();
1926
2179
  for (const { lesson } of matches) {
@@ -1939,7 +2192,7 @@ function buildTopicCoherence(matches) {
1939
2192
  var DEFAULT_RECALL_LIMIT = 10;
1940
2193
  var DEFAULT_RECALL_MAX_TOKENS = 400;
1941
2194
  var RRF_K = 60;
1942
- var SPECIFICITY_WEIGHT = 3;
2195
+ var SPECIFICITY_WEIGHT = 5;
1943
2196
  var TOPIC_COHERENCE_WEIGHT = 2;
1944
2197
  var BM25_WEIGHT = 1;
1945
2198
  var EFFECTIVENESS_WEIGHT = 1;
@@ -1966,12 +2219,15 @@ function rankLessons(graph, query, matches, options = {}) {
1966
2219
  const terms = queryTerms(query);
1967
2220
  const corpus = buildCorpus(graph);
1968
2221
  const fanout = buildFanout(graph);
2222
+ const narrowness = buildNarrowness(graph);
1969
2223
  const coherence = buildTopicCoherence(matches);
1970
2224
  const matchedTriggerIds = collectMatchedTriggerIds(graph, query);
1971
2225
  const scored = matches.map(({ id, lesson }) => {
1972
2226
  const hitTriggers = lesson.triggers.filter((t) => matchedTriggerIds.has(t));
1973
2227
  let specificity = 0;
1974
- 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
+ }
1975
2231
  return {
1976
2232
  id,
1977
2233
  lesson,
@@ -2054,57 +2310,6 @@ function loadRecallConfig(projectRoot) {
2054
2310
  return fallback;
2055
2311
  }
2056
2312
  }
2057
- function appendJsonl(path, record, opts) {
2058
- mkdirSync(dirname(path), { recursive: true });
2059
- appendFileSync(path, `${JSON.stringify(record)}
2060
- `, "utf8");
2061
- if (statSync(path).size > opts.trimTriggerBytes) capJsonl(path, opts.maxRecords);
2062
- }
2063
- function capJsonl(path, maxRecords) {
2064
- if (!existsSync(path)) return;
2065
- const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim().length > 0);
2066
- if (lines.length <= maxRecords) return;
2067
- const kept = lines.slice(lines.length - maxRecords);
2068
- const tmp = `${path}.${process.pid}.tmp`;
2069
- writeFileSync(tmp, `${kept.join("\n")}
2070
- `, "utf8");
2071
- renameSync(tmp, path);
2072
- }
2073
- function readJsonl(path) {
2074
- if (!existsSync(path)) return [];
2075
- const out = [];
2076
- for (const line of readFileSync(path, "utf8").split("\n")) {
2077
- if (line.trim().length === 0) continue;
2078
- try {
2079
- out.push(JSON.parse(line));
2080
- } catch {
2081
- }
2082
- }
2083
- return out;
2084
- }
2085
- var MAX_RECALL_LOG_RECORDS = 5e3;
2086
- var RECALL_LOG_TRIM_TRIGGER_BYTES = 2e6;
2087
- var TELEMETRY_ENV = "AGENTSMESH_LESSONS_TELEMETRY";
2088
- var SESSION_ENV = "AGENTSMESH_SESSION_ID";
2089
- function sessionId(env = process.env) {
2090
- const raw = env[SESSION_ENV];
2091
- return raw !== void 0 && raw.trim().length > 0 ? raw : void 0;
2092
- }
2093
- function recallLogPath(projectRoot) {
2094
- return join(lessonsPaths(projectRoot).base, "recall-log.jsonl");
2095
- }
2096
- function isTelemetryEnabled(env = process.env) {
2097
- return env[TELEMETRY_ENV] === "1";
2098
- }
2099
- function appendRecallRecord(projectRoot, record, env = process.env) {
2100
- if (!isTelemetryEnabled(env)) return;
2101
- appendJsonl(recallLogPath(projectRoot), record, {
2102
- maxRecords: MAX_RECALL_LOG_RECORDS,
2103
- trimTriggerBytes: RECALL_LOG_TRIM_TRIGGER_BYTES
2104
- });
2105
- }
2106
-
2107
- // src/lessons/outcome-log.ts
2108
2313
  function outcomeLogPath(projectRoot) {
2109
2314
  return join(lessonsPaths(projectRoot).base, "outcome-log.jsonl");
2110
2315
  }
@@ -2141,48 +2346,116 @@ function loadEffectiveness(projectRoot) {
2141
2346
  return map;
2142
2347
  }
2143
2348
  var SEEN_DIR = "agentsmesh-lessons-seen";
2144
- function openSessionDedup(options = {}) {
2145
- if (options.disabled === true) return null;
2146
- const id = options.explicit !== void 0 && options.explicit.trim().length > 0 ? options.explicit.trim() : sessionId(options.env);
2147
- if (id === void 0) return null;
2148
- const path = seenPath(id, options.projectRoot);
2149
- return { sessionId: id, seen: loadSeen(path), path };
2150
- }
2151
- function shortHash(value) {
2349
+ function shortHash2(value) {
2152
2350
  let h = 5381;
2153
2351
  for (let i = 0; i < value.length; i += 1) h = (h << 5) + h + value.charCodeAt(i) >>> 0;
2154
2352
  return h.toString(36);
2155
2353
  }
2156
- function seenPath(id, projectRoot) {
2354
+ function seenStorePath(id, projectRoot) {
2157
2355
  const safe = id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 200);
2158
- const scoped = projectRoot === void 0 ? safe : `${safe}__${shortHash(resolve(projectRoot))}`;
2356
+ const scoped = projectRoot === void 0 ? safe : `${safe}__${shortHash2(resolve(projectRoot))}`;
2159
2357
  return join(tmpdir(), SEEN_DIR, `${scoped}.json`);
2160
2358
  }
2161
- function loadSeen(path) {
2162
- if (!existsSync(path)) return /* @__PURE__ */ new Set();
2359
+ function readSeenStore(path) {
2360
+ if (!existsSync(path)) return { ids: /* @__PURE__ */ new Set(), stamps: null };
2163
2361
  try {
2164
2362
  const parsed = JSON.parse(readFileSync(path, "utf8"));
2165
- if (!Array.isArray(parsed)) return /* @__PURE__ */ new Set();
2166
- return new Set(parsed.filter((x) => typeof x === "string"));
2363
+ if (Array.isArray(parsed)) {
2364
+ const ids = new Set(parsed.filter((x) => typeof x === "string"));
2365
+ return { ids, stamps: null };
2366
+ }
2367
+ if (typeof parsed === "object" && parsed !== null) {
2368
+ const seen = parsed.seen;
2369
+ if (typeof seen === "object" && seen !== null) {
2370
+ const stamps = /* @__PURE__ */ new Map();
2371
+ for (const [id, ms] of Object.entries(seen)) {
2372
+ if (typeof ms === "number") stamps.set(id, ms);
2373
+ }
2374
+ const lastAt = parsed.lastAt;
2375
+ return {
2376
+ ids: new Set(stamps.keys()),
2377
+ stamps,
2378
+ ...typeof lastAt === "number" ? { lastAt } : {}
2379
+ };
2380
+ }
2381
+ }
2382
+ return { ids: /* @__PURE__ */ new Set(), stamps: null };
2383
+ } catch {
2384
+ return { ids: /* @__PURE__ */ new Set(), stamps: null };
2385
+ }
2386
+ }
2387
+ function writeSeenStore(path, data, lastAt) {
2388
+ try {
2389
+ mkdirSync(dirname(path), { recursive: true });
2390
+ const body = data instanceof Map ? JSON.stringify({ v: 2, lastAt: lastAt ?? Date.now(), seen: Object.fromEntries(data) }) : JSON.stringify(data);
2391
+ const tmp = `${path}.${process.pid}.tmp`;
2392
+ writeFileSync(tmp, body, "utf8");
2393
+ renameSync(tmp, path);
2167
2394
  } catch {
2168
- return /* @__PURE__ */ new Set();
2169
2395
  }
2170
2396
  }
2397
+ var AUTO_SESSION_IDLE_MS = 30 * 60 * 1e3;
2398
+ var FUTURE_TOLERANCE_MS = 6e4;
2399
+ function stampAgeMs(stamp, now = Date.now()) {
2400
+ if (stamp - now > FUTURE_TOLERANCE_MS) return Number.POSITIVE_INFINITY;
2401
+ return Math.max(0, now - stamp);
2402
+ }
2403
+ function isIdleSession(stamps, lastAt) {
2404
+ if (stamps === null || stamps.size === 0) return false;
2405
+ let newest = lastAt ?? 0;
2406
+ for (const ms of stamps.values()) if (ms > newest) newest = ms;
2407
+ return stampAgeMs(newest) > AUTO_SESSION_IDLE_MS;
2408
+ }
2409
+
2410
+ // src/lessons/seen-cache.ts
2411
+ function openSessionDedup(options = {}) {
2412
+ if (options.disabled === true) return null;
2413
+ const id = options.explicit !== void 0 && options.explicit.trim().length > 0 ? options.explicit.trim() : sessionId(options.env);
2414
+ if (id === void 0) return null;
2415
+ const path = seenStorePath(id, options.projectRoot);
2416
+ const store = readSeenStore(path);
2417
+ const stale = options.ttlMs !== void 0 && isIdleSession(store.stamps, store.lastAt);
2418
+ const stamps = stale ? null : store.stamps;
2419
+ return {
2420
+ sessionId: id,
2421
+ seen: stale ? /* @__PURE__ */ new Set() : visibleSeen(store.ids, stamps, options.ttlMs),
2422
+ path,
2423
+ stamps,
2424
+ ...options.ttlMs !== void 0 ? { ttlMs: options.ttlMs } : {}
2425
+ };
2426
+ }
2427
+ function visibleSeen(ids, stamps, ttlMs) {
2428
+ if (ttlMs === void 0) return ids;
2429
+ if (stamps === null) return /* @__PURE__ */ new Set();
2430
+ const now = Date.now();
2431
+ const fresh = /* @__PURE__ */ new Set();
2432
+ for (const [id, ms] of stamps) if (stampAgeMs(ms, now) <= ttlMs) fresh.add(id);
2433
+ return fresh;
2434
+ }
2171
2435
  function filterUnseen(dedup, matches) {
2172
2436
  return matches.filter((m) => !dedup.seen.has(m.id));
2173
2437
  }
2174
2438
  function commitSeen(dedup, returnedIds) {
2175
- if (returnedIds.length === 0) return;
2439
+ if (returnedIds.length === 0) {
2440
+ if (dedup.ttlMs !== void 0 && dedup.stamps !== null) {
2441
+ writeSeenStore(dedup.path, dedup.stamps);
2442
+ }
2443
+ return;
2444
+ }
2445
+ if (dedup.ttlMs !== void 0 || dedup.stamps !== null) {
2446
+ const now = Date.now();
2447
+ const merged = /* @__PURE__ */ new Map();
2448
+ for (const [id, ms] of dedup.stamps ?? []) {
2449
+ if (dedup.ttlMs === void 0 || now - ms <= dedup.ttlMs) merged.set(id, ms);
2450
+ }
2451
+ for (const id of returnedIds) merged.set(id, now);
2452
+ writeSeenStore(dedup.path, merged);
2453
+ return;
2454
+ }
2176
2455
  const union3 = new Set(dedup.seen);
2177
2456
  for (const id of returnedIds) union3.add(id);
2178
2457
  if (union3.size === dedup.seen.size) return;
2179
- try {
2180
- mkdirSync(dirname(dedup.path), { recursive: true });
2181
- const tmp = `${dedup.path}.${process.pid}.tmp`;
2182
- writeFileSync(tmp, JSON.stringify([...union3]), "utf8");
2183
- renameSync(tmp, dedup.path);
2184
- } catch {
2185
- }
2458
+ writeSeenStore(dedup.path, [...union3]);
2186
2459
  }
2187
2460
 
2188
2461
  // src/lessons/recall.ts
@@ -2191,6 +2464,7 @@ async function recallLessons(projectRoot, query, options = {}) {
2191
2464
  await maybeAutoMigrateLessons(projectRoot);
2192
2465
  } catch {
2193
2466
  }
2467
+ const preReadStamp = currentGraphStamp(projectRoot);
2194
2468
  const load = loadLessonsGraphResilient(projectRoot);
2195
2469
  if (load.status === "corrupt") {
2196
2470
  return { lessons: [], totalMatches: 0, suppressed: 0, corrupt: true };
@@ -2200,12 +2474,14 @@ async function recallLessons(projectRoot, query, options = {}) {
2200
2474
  }
2201
2475
  if (load.status === "absent") return { lessons: [], totalMatches: 0, suppressed: 0 };
2202
2476
  const graph = load.graph;
2477
+ refreshCommandFastpath(projectRoot, graph, preReadStamp);
2203
2478
  const matchQuery = query.file === void 0 ? query : { ...query, file: normalizeRecallFile(query.file, projectRoot) };
2204
2479
  const matches = queryLessons(graph, matchQuery);
2205
2480
  const dedup = openSessionDedup({
2206
2481
  explicit: options.sessionId,
2207
2482
  disabled: options.noDedup,
2208
- projectRoot
2483
+ projectRoot,
2484
+ ...options.ttlMs !== void 0 ? { ttlMs: options.ttlMs } : {}
2209
2485
  });
2210
2486
  const forRank = dedup === null ? matches : filterUnseen(dedup, matches);
2211
2487
  const cfg = loadRecallConfig(projectRoot);
@@ -2213,8 +2489,10 @@ async function recallLessons(projectRoot, query, options = {}) {
2213
2489
  limit: options.limit ?? cfg.limit,
2214
2490
  maxTokens: options.maxTokens === null ? void 0 : options.maxTokens ?? cfg.maxTokens,
2215
2491
  // Down-rank proven fire-but-fail lessons (empty ⇒ neutral, so recall is
2216
- // unchanged until the outcome log has real signal). Read from the side-channel.
2217
- effectiveness: loadEffectiveness(projectRoot)
2492
+ // unchanged until the outcome log has real signal). Read from the side-channel
2493
+ // only when something survived matching+dedup — a no-match recall must not pay
2494
+ // the (up to 2MB) outcome-log read for a ranking of nothing.
2495
+ effectiveness: forRank.length === 0 ? /* @__PURE__ */ new Map() : loadEffectiveness(projectRoot)
2218
2496
  });
2219
2497
  if (dedup !== null)
2220
2498
  commitSeen(
@@ -2566,9 +2844,9 @@ var LINE_REFS = String.raw`L\d+(?:\s*,\s*L\d+)*`;
2566
2844
  var LINE_REF_PATTERNS = [
2567
2845
  new RegExp(String.raw`\s*\bSee\s+${LINE_REFS}\.?`, "g"),
2568
2846
  // " See L128." / " See L140, L149"
2569
- new RegExp(String.raw`\s*\((?:${LINE_REFS})\)\.?`, "g"),
2847
+ new RegExp(String.raw`\s*\((?:${LINE_REFS})\)`, "g"),
2570
2848
  // " (L174)" / " (L92, L163)"
2571
- new RegExp(String.raw`\s*\[(?:${LINE_REFS})\]\.?`, "g")
2849
+ new RegExp(String.raw`\s*\[(?:${LINE_REFS})\]`, "g")
2572
2850
  // " [L161, L208]"
2573
2851
  ];
2574
2852
  var ALSO_RELEVANT_PATTERN = /\s*\(also relevant[^)]*\)\s*/g;
@@ -2806,6 +3084,32 @@ function isCoveredByExisting(candidate, existing) {
2806
3084
  }
2807
3085
  return false;
2808
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
+ }
2809
3113
  var LESSONS_CONTRACT_START = "<!-- agentsmesh:lessons-contract:start -->";
2810
3114
  var LESSONS_CONTRACT_END = "<!-- agentsmesh:lessons-contract:end -->";
2811
3115
  function escapeRegExp(value) {
@@ -2818,10 +3122,8 @@ function stripManagedBlock(content, start, end) {
2818
3122
  return content.replace(managedBlockPattern(start, end), "").trim();
2819
3123
  }
2820
3124
  function splitFrontmatterPrefix(content) {
2821
- if (content.indexOf("---") !== 0) return { prefix: "", body: content.trim() };
2822
- const close = content.indexOf("---", 3);
2823
- if (close === -1) return { prefix: "", body: content.trim() };
2824
- 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 };
2825
3127
  }
2826
3128
  function insertAtBodyTop(content, block) {
2827
3129
  const { prefix, body } = splitFrontmatterPrefix(content);
@@ -2835,6 +3137,17 @@ ${placed}` : placed;
2835
3137
 
2836
3138
  // src/targets/projection/lessons-paragraph.ts
2837
3139
  var LEGACY_RAW_FORMS = [
3140
+ // Pre-`--session auto` wording (2026-07): strip sentinel-less copies so a
3141
+ // project scaffolded before the dedup correlator dedups on the next scaffold.
3142
+ `## Lessons (BLOCKING)
3143
+
3144
+ Graph \`.agentsmesh/lessons/lessons.json\` is canonical; never hand-edit it. Manual: \`lessons\` skill.
3145
+
3146
+ **Recall:** before every file edit or state-changing command, MUST run \`agentsmesh lessons query --file <path> --cmd <command>\` and obey matches; at task start, ALSO run \`agentsmesh lessons query --keyword "<task terms>" --always\` for conceptual + universal rules no path/command names. Pure-read commands and recall itself are exempt.
3147
+
3148
+ **Capture:** after any failure, user correction, regression, wrong assumption, useful surprise, repeated friction, or non-obvious fix, MUST self-critique and run \`agentsmesh lessons add "<imperative rule>" --topic <id> --trigger-file <glob> --evidence <sha|lesson-id>\`.
3149
+
3150
+ **Before final:** report \`Lesson: captured <id>\` or \`Lesson: none\`. No recall/capture gate = task incomplete. No shell: use \`lessons_query\` / \`lessons_add\`.`,
2838
3151
  `## Lessons (BLOCKING REQUIREMENT \u2014 MUST run both, no exceptions; the user will check)
2839
3152
 
2840
3153
  Graph \`.agentsmesh/lessons/lessons.json\` is canonical \u2014 never hand-edit. Manual: the \`lessons\` skill.
@@ -2864,16 +3177,6 @@ ${rule}`, "").replace(rule, ""),
2864
3177
  content
2865
3178
  );
2866
3179
  }
2867
- function serializeFrontmatter(frontmatter, body) {
2868
- const keys = Object.keys(frontmatter);
2869
- if (keys.length === 0) return body;
2870
- const yamlStr = stringify(frontmatter, { lineWidth: 0 }).trimEnd();
2871
- return `---
2872
- ${yamlStr}
2873
- ---
2874
-
2875
- ${body}`;
2876
- }
2877
3180
 
2878
3181
  // src/lessons/skill.ts
2879
3182
  var LESSONS_SKILL_NAME = "lessons";
@@ -2892,14 +3195,18 @@ regression / wrong assumption / surprise and you have not captured (nor stated
2892
3195
 
2893
3196
  ## Recall \u2014 before each edit/command, and at task start
2894
3197
 
2895
- \`agentsmesh lessons query --file <path> --cmd <command>\`, then apply every rule.
2896
- Pure-read commands (read-only) and the query itself are exempt. **keyword-only recall
3198
+ \`agentsmesh lessons query --file <path> --cmd <command> --session auto\`, then apply every
3199
+ rule. Pure-read commands (read-only) and the query itself are exempt. **keyword-only recall
2897
3200
  for a specific edit is the anti-pattern** \u2014 anchor those to \`--file\`/\`--cmd\`. But at the
2898
3201
  START of a task (or when planning), run \`agentsmesh lessons query --keyword "<the task's
2899
- key terms>" --always\`: that surfaces the conceptual rules no file/command names PLUS the
2900
- universal always-on lessons \u2014 the manual equivalent of the automatic prompt recall on
2901
- hook-capable tools. Author a \`keyword\` trigger beside a \`file_glob\` on conceptual lessons
2902
- so they are reachable both ways. No shell \u2192 MCP \`lessons_query\` (\`file\`/\`command\`/\`keyword\`/\`always\`).
3202
+ key terms>" --always --session auto\`: that surfaces the conceptual rules no file/command
3203
+ names PLUS the universal always-on lessons \u2014 the manual equivalent of the automatic prompt
3204
+ recall on hook-capable tools. **Always pass \`--session auto\`**: it suppresses rules already
3205
+ shown this session so repeat recalls stay quiet (without it every recall re-delivers the
3206
+ whole matched set); \`--no-dedup\` re-shows everything after a context reset. Author a
3207
+ \`keyword\` trigger beside a \`file_glob\` on conceptual lessons so they are reachable both
3208
+ ways. No shell \u2192 MCP \`lessons_query\` (\`file\`/\`command\`/\`keyword\`/\`always\`; session dedup
3209
+ is on by default there \u2014 \`no_dedup:true\` to re-show).
2903
3210
 
2904
3211
  ## Capture \u2014 Gate Function (before any completion claim)
2905
3212