@lawaty/lacode 1.0.3 → 1.2.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/index.js CHANGED
@@ -35,9 +35,9 @@ var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
35
35
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
36
36
  var UNAVAILABLE_REASON = "endpoint-unavailable";
37
37
  var MAX_WEIGHT = 100;
38
- var SLOT_CEILING_FACTOR = 2;
39
- var slotCeiling = (slot) => slot.weight * SLOT_CEILING_FACTOR;
40
- var MAX_SLOTS = 4;
38
+ var slotCeiling = (slot) => slot.weight;
39
+ var slotBucket = (slot) => slot.bucket ?? slot.model;
40
+ var SLOTS_WARN_AT = 64;
41
41
  var COOLDOWN_BASE_MS = 60 * 1e3;
42
42
  var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
43
43
  var QUOTA_COOLDOWN_BASE_MS = 15 * 60 * 1e3;
@@ -47,8 +47,12 @@ var STATED_COOLDOWN_MAX_MS = QUOTA_COOLDOWN_MAX_MS;
47
47
  var isQuotaReason = (reason) => /quota|limit|rate|too-many|overloaded|429/i.test(reason);
48
48
  var MODEL_MISSING_REASON = "model-missing";
49
49
  var MODEL_MISSING_COOLDOWN_MS = QUOTA_COOLDOWN_MAX_MS;
50
+ var AUTH_FAILURE_REASON = "auth-failed";
51
+ var AUTH_FAILURE_COOLDOWN_MS = QUOTA_COOLDOWN_MAX_MS;
50
52
  var MODEL_MISSING_PATTERN = /model\s+(?:not\s+found|does\s+not\s+exist|is\s+not\s+available)|(?:unknown|no\s+such)\s+model/i;
51
53
  var isModelMissingText = (text) => MODEL_MISSING_PATTERN.test(text);
54
+ var AUTH_FAILURE_PATTERN = /\b401\b|\b403\b|unauthori[sz]ed|not\s+authori[sz]ed|invalid[\s_-]+(?:api[\s_-]?(?:key|token)|token|credential|bearer)|api[\s_-]?key\s+(?:is\s+)?(?:invalid|missing|expired|not\s+valid)|authentication\s+(?:failed|error|required)|incorrect[\s_-]+api[\s_-]?key/i;
55
+ var isAuthFailureText = (text) => AUTH_FAILURE_PATTERN.test(text);
52
56
  var BASES = ["explore", "implement", "context"];
53
57
  var PRESETS_DIR = join2(PACKAGE_ROOT, "presets");
54
58
  var DEFAULT_PRESET = join2(PRESETS_DIR, "free-tier.json");
@@ -71,15 +75,18 @@ function parseSlots(parsed, origin) {
71
75
  entries.forEach((entry, i) => {
72
76
  const model = typeof entry?.model === "string" ? entry.model.trim() : "";
73
77
  const at = `slot ${i + 1}`;
74
- if (slots.length >= MAX_SLOTS) {
75
- problems.push(`${at} (${model || "?"}): the pool is capped at ${MAX_SLOTS} slots (one variant agent per base per slot is declared); entry ignored`);
76
- return;
77
- }
78
78
  if (!model.includes("/")) return problems.push(`${at}: model must be written provider/model-id, got "${model}"`);
79
79
  const weight = readWeight(entry, at, model, problems);
80
- if (slots.some((s) => s.model === model)) return problems.push(`${at}: ${model} is already used by another slot; slots must be distinct models`);
81
- slots.push({ index: slots.length + 1, model, weight });
80
+ const rawBucket = entry?.bucket;
81
+ const bucket = typeof rawBucket === "string" ? rawBucket.trim() : void 0;
82
+ if (rawBucket !== void 0 && !bucket) {
83
+ problems.push(`${at} (${model}): bucket must be a non-empty name; using the model id`);
84
+ }
85
+ slots.push({ index: slots.length + 1, model, weight, ...bucket ? { bucket } : {} });
82
86
  });
87
+ if (slots.length > SLOTS_WARN_AT) {
88
+ problems.push(`${origin}: ${slots.length} slots is unusually large, but every entry is kept (the pool has no slot cap)`);
89
+ }
83
90
  if (slots.length === 0) {
84
91
  problems.push(`${origin} yielded no usable slot; using built-in defaults`);
85
92
  return { slots: FALLBACK_SLOTS, problems };
@@ -91,8 +98,17 @@ function parseTextSlots(text, origin) {
91
98
  for (const raw of text.split(/\r?\n/)) {
92
99
  const line = raw.split("#")[0].trim();
93
100
  if (!line) continue;
94
- const [model, weight] = line.split(/\s+/);
95
- entries.push(weight === void 0 ? { model } : { model, weight });
101
+ const [model, ...rest] = line.split(/\s+/);
102
+ let weight;
103
+ let bucket;
104
+ for (const token of rest) {
105
+ if (token.startsWith("b:")) {
106
+ if (bucket === void 0) bucket = token.slice(2);
107
+ } else if (weight === void 0) {
108
+ weight = token;
109
+ }
110
+ }
111
+ entries.push({ model, ...weight === void 0 ? {} : { weight }, ...bucket === void 0 ? {} : { bucket } });
96
112
  }
97
113
  return parseSlots({ slots: entries }, origin);
98
114
  }
@@ -106,25 +122,25 @@ function parseSlotText(text, origin) {
106
122
  }
107
123
  return parseTextSlots(text, origin);
108
124
  }
109
- var readJSON = (path4) => {
125
+ var readJSON = (path5) => {
110
126
  try {
111
- return { ok: true, value: JSON.parse(readFileSync2(path4, "utf8")) };
127
+ return { ok: true, value: JSON.parse(readFileSync2(path5, "utf8")) };
112
128
  } catch (error) {
113
129
  return { ok: false, error: String(error) };
114
130
  }
115
131
  };
116
- function readOptional(path4, origin, problems) {
117
- const result = readJSON(path4);
132
+ function readOptional(path5, origin, problems) {
133
+ const result = readJSON(path5);
118
134
  if (!result.ok) {
119
- if (existsSync(path4)) problems.push(`${origin} unreadable (${result.error}); trying the next source`);
135
+ if (existsSync(path5)) problems.push(`${origin} unreadable (${result.error}); trying the next source`);
120
136
  return void 0;
121
137
  }
122
138
  return result.value;
123
139
  }
124
- function readTextOptional(path4, origin, problems) {
125
- if (!existsSync(path4)) return void 0;
140
+ function readTextOptional(path5, origin, problems) {
141
+ if (!existsSync(path5)) return void 0;
126
142
  try {
127
- return readFileSync2(path4, "utf8");
143
+ return readFileSync2(path5, "utf8");
128
144
  } catch (error) {
129
145
  problems.push(`${origin} unreadable (${String(error)}); trying the next source`);
130
146
  return void 0;
@@ -163,14 +179,14 @@ function resolveModels(input = {}) {
163
179
  }
164
180
  return { slots: FALLBACK_SLOTS, problems: [...problems, `no pool source found (${USER_MODELS_TXT}, ${USER_MODELS_PATH}, ${DEFAULT_PRESET}); using built-in defaults`], origin: "built-in defaults" };
165
181
  }
166
- function readSlotFile(path4) {
182
+ function readSlotFile(path5) {
167
183
  let text;
168
184
  try {
169
- text = readFileSync2(path4, "utf8");
185
+ text = readFileSync2(path5, "utf8");
170
186
  } catch (error) {
171
- return { slots: FALLBACK_SLOTS, problems: [`${path4} unreadable (${String(error)}); using built-in defaults`] };
187
+ return { slots: FALLBACK_SLOTS, problems: [`${path5} unreadable (${String(error)}); using built-in defaults`] };
172
188
  }
173
- return parseSlotText(text, path4);
189
+ return parseSlotText(text, path5);
174
190
  }
175
191
  function ensureUserConfigDir() {
176
192
  try {
@@ -236,9 +252,17 @@ var retryAfterMs = (headers) => {
236
252
  for (const [key, value] of Object.entries(headers)) {
237
253
  if (key.toLowerCase() !== "retry-after") continue;
238
254
  const seconds = Number(value);
239
- if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds * 1e3, COOLDOWN_MAX_MS);
255
+ if (Number.isFinite(seconds) && seconds > 0) {
256
+ const ms = seconds * 1e3;
257
+ if (ms > STATED_COOLDOWN_MAX_MS) return void 0;
258
+ return ms;
259
+ }
240
260
  const when = Date.parse(value);
241
- if (Number.isFinite(when)) return Math.min(Math.max(when - Date.now(), 1e3), COOLDOWN_MAX_MS);
261
+ if (Number.isFinite(when)) {
262
+ const delay = when - Date.now();
263
+ if (delay > STATED_COOLDOWN_MAX_MS) return void 0;
264
+ return Math.max(delay, 1e3);
265
+ }
242
266
  }
243
267
  return void 0;
244
268
  };
@@ -288,7 +312,7 @@ function ensureSlots(s, onWarn) {
288
312
  for (const problem of problems) onWarn(problem);
289
313
  return slots;
290
314
  }
291
- var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}`).join("|")}`;
315
+ var slotSignature = (origin, slots) => `${origin}\0${slots.map((slot) => `${slot.index}:${slot.model}:${slot.weight}:${slot.bucket ?? ""}`).join("|")}`;
292
316
  function reloadSlots(s, onWarn) {
293
317
  const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
294
318
  const changed = slotSignature(origin, slots) !== slotSignature(s.origin, s.slots);
@@ -433,16 +457,16 @@ function readSiblings(s) {
433
457
  }
434
458
  const now = Date.now();
435
459
  for (const entry of entries) {
436
- const path4 = join2(s.dir, entry);
460
+ const path5 = join2(s.dir, entry);
437
461
  let age;
438
462
  try {
439
- age = now - statSync(path4).mtimeMs;
463
+ age = now - statSync(path5).mtimeMs;
440
464
  } catch {
441
465
  continue;
442
466
  }
443
467
  if (entry.endsWith(".tmp")) {
444
468
  if (age > ORPHAN_TMP_MS) try {
445
- unlinkSync(path4);
469
+ unlinkSync(path5);
446
470
  } catch {
447
471
  }
448
472
  continue;
@@ -451,22 +475,22 @@ function readSiblings(s) {
451
475
  if (!match || match[1] === s.id) continue;
452
476
  if (age > DEAD_FILE_MS) {
453
477
  try {
454
- unlinkSync(path4);
478
+ unlinkSync(path5);
455
479
  } catch {
456
480
  }
457
481
  continue;
458
482
  }
459
483
  if (/^\d+$/.test(match[1]) && !isPidAlive(Number(match[1]))) {
460
484
  try {
461
- unlinkSync(path4);
485
+ unlinkSync(path5);
462
486
  } catch {
463
487
  }
464
488
  continue;
465
489
  }
466
490
  try {
467
- const parsed = JSON.parse(readFileSync2(path4, "utf8"));
491
+ const parsed = JSON.parse(readFileSync2(path5, "utf8"));
468
492
  const claims = Object.values(parsed).filter(
469
- (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - (c.hb ?? c.t) <= CLAIM_TTL_MS
493
+ (c) => c && typeof c.v === "string" && typeof c.m === "string" && typeof c.k === "number" && Number.isFinite(c.k) && now - (c.hb ?? c.t) <= CLAIM_TTL_MS
470
494
  );
471
495
  siblings.push({ id: match[1], claims });
472
496
  } catch {
@@ -488,9 +512,9 @@ function readLimits(s) {
488
512
  const now = Date.now();
489
513
  for (const entry of entries) {
490
514
  if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
491
- const path4 = join2(s.dir, entry);
515
+ const path5 = join2(s.dir, entry);
492
516
  try {
493
- const strike = JSON.parse(readFileSync2(path4, "utf8"));
517
+ const strike = JSON.parse(readFileSync2(path5, "utf8"));
494
518
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
495
519
  const countsAsStrike = strike.reason !== UNAVAILABLE_REASON;
496
520
  if (strike.until > now) {
@@ -499,11 +523,11 @@ function readLimits(s) {
499
523
  } else if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) {
500
524
  if (countsAsStrike) memory.set(strike.model, strike);
501
525
  } else {
502
- unlinkSync(path4);
526
+ unlinkSync(path5);
503
527
  }
504
528
  } catch {
505
529
  try {
506
- unlinkSync(path4);
530
+ unlinkSync(path5);
507
531
  } catch {
508
532
  }
509
533
  }
@@ -516,20 +540,32 @@ function recordStrike(s, model, reason, stated) {
516
540
  if (!s.dirReady) return;
517
541
  if (reason === UNAVAILABLE_REASON) return coolUnavailable(s, model);
518
542
  const now = Date.now();
519
- const prior = s.strikes.get(model);
543
+ const slotsForModel = s.slots.filter((candidate) => candidate.model === model);
544
+ const buckets = [...new Set(slotsForModel.map((slot) => slotBucket(slot)))];
545
+ const targets = buckets.length === 0 ? [model] : [...new Set(
546
+ s.slots.filter((candidate) => buckets.includes(slotBucket(candidate))).map((candidate) => candidate.model)
547
+ )];
548
+ if (targets.length === 0) targets.push(model);
549
+ const prior = targets.map((target) => s.strikes.get(target)).filter((candidate) => Boolean(candidate)).reduce((worst, candidate) => !worst || candidate.strikes > worst.strikes ? candidate : worst, void 0);
520
550
  const strikes = prior && now - prior.last < STRIKE_DECAY_MS ? prior.strikes + 1 : 1;
521
551
  const quota = isQuotaReason(reason);
522
552
  const base = quota ? QUOTA_COOLDOWN_BASE_MS : COOLDOWN_BASE_MS;
523
553
  const cap = quota ? QUOTA_COOLDOWN_MAX_MS : COOLDOWN_MAX_MS;
524
554
  const backoff = Math.min(cap, base * 2 ** (strikes - 1));
525
- const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
526
- s.cooling.set(model, strike);
527
- s.strikes.set(model, strike);
528
- const path4 = join2(s.dir, limitFile(model));
529
- try {
530
- writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
531
- renameSync(`${path4}.tmp`, path4);
532
- } catch {
555
+ const statedOk = typeof stated === "number" && Number.isFinite(stated) && stated > 0 && stated <= STATED_COOLDOWN_MAX_MS;
556
+ const until = now + (statedOk ? stated : backoff);
557
+ let strike;
558
+ for (const target of targets) {
559
+ const record = { model: target, until, strikes, reason, last: now };
560
+ s.cooling.set(target, record);
561
+ s.strikes.set(target, record);
562
+ const path5 = join2(s.dir, limitFile(target));
563
+ try {
564
+ writeFileSync(`${path5}.tmp`, JSON.stringify(record));
565
+ renameSync(`${path5}.tmp`, path5);
566
+ } catch {
567
+ }
568
+ if (target === model) strike = record;
533
569
  }
534
570
  return strike;
535
571
  }
@@ -541,10 +577,10 @@ function coolUnavailable(s, model) {
541
577
  if (prior && prior.until > until) return prior;
542
578
  const strike = { model, until, strikes: 1, reason: UNAVAILABLE_REASON, last: now };
543
579
  s.cooling.set(model, strike);
544
- const path4 = join2(s.dir, limitFile(model));
580
+ const path5 = join2(s.dir, limitFile(model));
545
581
  try {
546
- writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
547
- renameSync(`${path4}.tmp`, path4);
582
+ writeFileSync(`${path5}.tmp`, JSON.stringify(strike));
583
+ renameSync(`${path5}.tmp`, path5);
548
584
  } catch {
549
585
  }
550
586
  return strike;
@@ -561,17 +597,17 @@ function clearCooldowns(s, models) {
561
597
  }
562
598
  for (const entry of entries) {
563
599
  if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
564
- const path4 = join2(s.dir, entry);
600
+ const path5 = join2(s.dir, entry);
565
601
  let strike;
566
602
  try {
567
- const parsed = JSON.parse(readFileSync2(path4, "utf8"));
603
+ const parsed = JSON.parse(readFileSync2(path5, "utf8"));
568
604
  if (parsed && typeof parsed.model === "string") strike = parsed;
569
605
  } catch {
570
606
  strike = void 0;
571
607
  }
572
608
  if (wanted && (strike === void 0 || !wanted.has(strike.model))) continue;
573
609
  try {
574
- unlinkSync(path4);
610
+ unlinkSync(path5);
575
611
  } catch {
576
612
  continue;
577
613
  }
@@ -619,30 +655,26 @@ function snapshot(s) {
619
655
  const siblings = readSiblings(s);
620
656
  const cooling = readLimits(s);
621
657
  const counts = /* @__PURE__ */ new Map();
622
- const bump = (m) => counts.set(m, (counts.get(m) ?? 0) + 1);
623
- for (const claim of s.own.values()) bump(claim.m);
624
- for (const sibling of siblings) for (const claim of sibling.claims) bump(claim.m);
658
+ const bump = (k) => counts.set(k, (counts.get(k) ?? 0) + 1);
659
+ for (const claim of s.own.values()) bump(claim.k);
660
+ for (const sibling of siblings) for (const claim of sibling.claims) bump(claim.k);
625
661
  return { counts, cooling, siblings, own: s.own, dirReady: s.dirReady };
626
662
  }
627
- var loadOf = (counts, model) => counts.get(model) ?? 0;
628
- var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slotCeiling(slot);
663
+ var loadOf = (counts, slot) => counts.get(slot) ?? 0;
664
+ var underCeiling = (slot, counts) => loadOf(counts, slot.index) < slotCeiling(slot);
629
665
  function pickSlot(s, snap) {
630
666
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
631
667
  const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
632
668
  const pool = usable.length > 0 ? usable : slots;
633
669
  for (const slot of usable) {
634
- if (loadOf(snap.counts, slot.model) < slot.weight) return slot;
635
- }
636
- for (const slot of usable) {
637
- if (loadOf(snap.counts, slot.model) < slotCeiling(slot)) return slot;
670
+ if (loadOf(snap.counts, slot.index) < slot.weight) return slot;
638
671
  }
639
- return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
672
+ return pool.reduce((best, slot) => loadOf(snap.counts, slot.index) < loadOf(snap.counts, best.index) ? slot : best, pool[0]);
640
673
  }
641
674
  function poolPass(s, snap) {
642
675
  const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
643
676
  const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
644
- if (usable.some((slot) => loadOf(snap.counts, slot.model) < slot.weight)) return 1;
645
- if (usable.some((slot) => loadOf(snap.counts, slot.model) < slotCeiling(slot))) return 2;
677
+ if (usable.some((slot) => loadOf(snap.counts, slot.index) < slot.weight)) return 1;
646
678
  return "overflow";
647
679
  }
648
680
  function agentFor(base, slot) {
@@ -750,6 +782,373 @@ function coolProvider(s, provider, until, reason) {
750
782
  var AGENT_INSTRUCTIONS_COMMAND = "agent-instructions";
751
783
  var AGENT_INSTRUCTIONS_WRITER = "instructions";
752
784
 
785
+ // src/lib/cartographer-prompt.ts
786
+ var CARTOGRAPHER_PROMPT = `# Role
787
+
788
+ You are the repository cartographer and memory maintainer. You are not a coding
789
+ agent. You maintain exactly one thing: the repository context map in
790
+ \`.opencode/context/\`.
791
+
792
+ The context map is a lightweight navigation index that future agents read
793
+ before exploring. It exists to answer "what should I look at first?" \u2014 never
794
+ "tell me everything about the repository". Source code is authoritative for how
795
+ the system works; the project's own pages are authoritative for what they
796
+ define. The map is a derived index of both.
797
+
798
+ # The one entry schema
799
+
800
+ Every entry in every navigation file is one markdown list item with:
801
+
802
+ 1. a provenance marker,
803
+ 2. the operative rule, in one to three lines,
804
+ 3. a pointer to the page or path that owns it, and
805
+ 4. the date it was last verified.
806
+
807
+ - [binding] The system is split into a gateway and a store because the gateway must stay stateless. \u2014 See \`docs/DESIGN.md\` \xA7The two halves (verified 2026-10-26)
808
+
809
+ If the rule wraps, indent the continuation lines by two spaces; the whole entry
810
+ stays one list item. A subsystem's "start here" files go on that entry's own line
811
+ (\`start at \\\`src/a.ts\\\`, \\\`src/b.ts\\\`\`) \u2014 a nested bullet list is a second, unbudgeted
812
+ entry and is rejected.
813
+
814
+ Use exactly one marker:
815
+
816
+ - \`[binding]\` \u2014 established against the source; a constraint other work must
817
+ respect. Exempt from age expiry.
818
+ - \`[provisional]\` \u2014 inferred or not yet verified. Expires 30 days after
819
+ \`verified\` unless re-verified. Never present it as a rule.
820
+ - \`[disputed]\` \u2014 the map and a cited page disagree. Keep it, report it, and
821
+ never silently resolve it.
822
+
823
+ The pointer is mandatory. A fact whose home is a page cites the page AND its
824
+ section: \`\` \`docs/DESIGN.md\` \xA7The two halves \`\`. A fact whose home is code
825
+ cites the path, optionally with a line or symbol: \`\` \`src/gateway.ts:42\` \`\`.
826
+ \`(verified YYYY-MM-DD)\` is mandatory: it is the date you last checked the cited
827
+ source. An entry with no pointer is not an entry; it is unarchived content (see
828
+ "Never the archive").
829
+
830
+ Never write Context / Decision / Consequences / Alternatives / Status /
831
+ Rationale blocks, decision records, meeting narrative, or history. A reader
832
+ needs the constraint, not the deliberation that produced it. No markdown tables
833
+ and no fenced code blocks anywhere in the map: both are restatements of a page
834
+ that owns the fact.
835
+
836
+ # Per-file charters and budgets
837
+
838
+ You own exactly these files and nothing else:
839
+
840
+ - \`.opencode/context/architecture.md\` \u2014 cross-subsystem structure: layers,
841
+ boundaries, canonical entry points, dependency rules. **At most 150 non-blank
842
+ lines.**
843
+ - \`.opencode/context/contexts.md\` \u2014 subsystem boundaries: for each subsystem,
844
+ purpose, at most five start-here files, what must not change while working
845
+ there. **At most 200 lines.**
846
+ - \`.opencode/context/conventions.md\` \u2014 rules to follow before writing code.
847
+ **At most 150 lines.**
848
+ - \`.opencode/context/workflows.md\` \u2014 runtime behaviour worth tracing. **At most
849
+ 150 lines.**
850
+ - \`.opencode/context/decisions.md\` \u2014 architectural constraints that bind future
851
+ work. **At most 120 lines.**
852
+ - \`.opencode/context/active.md\` \u2014 work in flight, grouped by the ISO week it was
853
+ opened. **At most 60 lines.**
854
+
855
+ The unit is non-blank lines. It is what a reader actually pays for, it bounds an
856
+ entry that wraps to three lines, and the validator can count it exactly. An
857
+ entry count does not bound cost; a token count needs a tokenizer and is
858
+ model-specific. Stay under the cap: consolidate overlapping entries, drop the
859
+ lowest-value provisional ones, and link out. Never pad, never exceed, and never
860
+ answer a budget problem by deleting a binding constraint.
861
+
862
+ Every file must still answer its charter. An empty or degenerate file is a
863
+ failed run, not a small one.
864
+
865
+ # Lifecycle \u2014 what expires, on what window, what is exempt
866
+
867
+ The plugin applies this on every run. Write entries in the shape that lets it.
868
+
869
+ - \`active.md\`: a finished task (\`[x]\` plus \`finished YYYY-MM-DD\`) is dropped
870
+ after 7 days. An open task never expires, however old it is.
871
+ - \`architecture.md\`, \`contexts.md\`, \`conventions.md\`, \`workflows.md\`,
872
+ \`decisions.md\`: a \`[provisional]\` entry is dropped 30 days after its
873
+ \`verified\` date unless it is re-verified. \`[binding]\` and \`[disputed]\` are
874
+ exempt: a constraint does not stop binding because it is old, and a
875
+ disagreement is not resolved by a clock.
876
+ - An entry with no resolvable pointer is never auto-dropped. It is reported as
877
+ unarchived content. Do not delete it either.
878
+
879
+ When you re-verify a binding entry, update its \`verified\` date. Age is not a
880
+ reason to drop a constraint; a source that no longer supports it is.
881
+
882
+ # Point, do not restate
883
+
884
+ A fact has exactly one home. If the project's own page, README, schema,
885
+ changelog, or source states it, the map cites the page plus section and stops.
886
+ Do not copy a table, a measurement, a list of lengths, a version, or a "was
887
+ refuted" finding into the map. Two copies of a fact WILL drift; one will be
888
+ stale and nothing will notice.
889
+
890
+ If the map already restates a page and the two disagree, do not simply choose
891
+ one. See "Drift check".
892
+
893
+ # Drift check \u2014 report, never overwrite
894
+
895
+ At the end of every run, verify each entry against its pointer:
896
+
897
+ - Open the cited path and section. If the path or section is gone, mark the
898
+ entry \`[disputed]\`, keep it, and report the dangling citation.
899
+ - If the source changed after \`verified\`, re-read it. If the entry is still
900
+ true, update \`verified\`; if it is not, mark it \`[disputed]\`.
901
+ - If the map and a PROJECT PAGE disagree, do not edit the page and do not
902
+ silently "correct" the map. Neither side is automatically right: in one real
903
+ case the map was right and the page was stale. Mark \`[disputed]\`, keep both,
904
+ and report the contradiction with both citations. Only the page's owner
905
+ resolves it.
906
+ - Source CODE is different: code wins over the map. If the map contradicts code
907
+ you can read, correct the map and update \`verified\`.
908
+ - Never overwrite a page, a doc, or source code. You only write the map.
909
+
910
+ The plugin also runs a deterministic drift pass: dangling paths and sections, a
911
+ numeric claim absent from the cited source, a possible negation conflict, and a
912
+ source modified after its \`verified\` date. It reports; it never rewrites and
913
+ never deletes.
914
+
915
+ # Never the archive
916
+
917
+ The map is derived. It must never be the only copy of anything. If content has
918
+ no source in the tree \u2014 a retired decision document, a deleted page, a fact that
919
+ exists only in the map \u2014 do not emit it as though it were derived. Keep what is
920
+ already there, mark it \`[disputed]\` if it must stay, and report it loudly as
921
+ unarchived content with no source. Deleting it on regeneration is not allowed:
922
+ it may be the only copy. A sentence like "this is the only place this remains"
923
+ is a defect to report, not a preamble to write.
924
+
925
+ # Current-state only
926
+
927
+ Every file you write states only the current arrangement and the reason it
928
+ holds, in the present tense. No "used to", "previously", "before this",
929
+ migration notes or deprecation narrative \u2014 history belongs in the project's
930
+ changelog, not in your map. An absence that is true today is a fact worth
931
+ stating, not a removal worth narrating. When a past state is worth keeping,
932
+ record it briefly in \`changelog.md\` instead.
933
+
934
+ Never create, modify, or delete anything outside \`.opencode/context/\`. The
935
+ \`edit\` permission enforces this. Do not attempt to work around it with bash.
936
+
937
+ To look for the map, use \`list\` or \`ls .opencode/context\`. Never use the \`glob\`
938
+ tool for it: \`glob\` does not match hidden directories, so it reports "no files"
939
+ even when the map exists.
940
+
941
+ # Bootstrap
942
+
943
+ Trigger: \`.opencode/context/\` does not exist or is empty.
944
+
945
+ Explore progressively and stop as soon as you have a usable map:
946
+
947
+ 1. README files and any existing architecture or decision documentation.
948
+ 2. Project manifests (\`package.json\`, \`pyproject.toml\`, \`go.mod\`, \`Cargo.toml\`,
949
+ \u2026) for entry points, workspaces, and dependency structure.
950
+ 3. The top-level directory structure \u2014 one or two levels. Name what each
951
+ directory is for.
952
+ 4. Framework and build configuration (tsconfig, bundler config, CI, container
953
+ files) to learn how things are assembled and run.
954
+ 5. Application entry points. Read them.
955
+ 6. Obvious domain / application / infrastructure boundaries and the interfaces
956
+ between them.
957
+ 7. Only then inspect source selectively \u2014 the files that define the boundaries
958
+ you found.
959
+
960
+ You are mapping a city, not auditing every house. Do not attempt to understand
961
+ every file.
962
+
963
+ Then write the five navigation files within their line budgets, using the entry
964
+ schema above. Also create \`.opencode/context/active.md\` with its heading and no
965
+ tasks. For each significant subsystem, give a short "Start here" list \u2014 at most
966
+ about five canonical files, each with a one-phrase reason. This is the single
967
+ most valuable thing you produce: where an agent should open its editor next.
968
+
969
+ # Incremental update
970
+
971
+ Trigger: \`.opencode/context/\` exists and work has just been done here.
972
+
973
+ 1. Start from the files that changed, plus their immediate architectural
974
+ neighborhood: what they import, who calls them, the boundary they sit on.
975
+ 2. Read only the context file(s) those changes could affect.
976
+ 3. Default to no edit. A changed file is not a changed fact: comments,
977
+ formatting, renames of local variables, added tests, dependency bumps, and
978
+ edits that stay inside one function rarely alter what the map should say.
979
+ Edit only when the work introduced or revealed information that will
980
+ materially help a future agent, such as:
981
+ - a new architectural boundary or module
982
+ - a new or moved canonical entry point
983
+ - a changed workflow
984
+ - a new repository convention
985
+ - an architectural decision
986
+ - a new dependency between subsystems
987
+ - a renamed or relocated subsystem
988
+ - an invariant not yet recorded in the map
989
+
990
+ Do not update documentation merely because source files changed. Do not rewrite
991
+ files wholesale. Do not make timestamp-only edits. Do not add a fact that is
992
+ already stated in another context file \u2014 one fact has one home, and the other
993
+ files link to it. If nothing material changed, make no edits and report "no
994
+ update needed".
995
+
996
+ Every new or changed entry uses the schema above and stays under the file's
997
+ budget. If a file is already over budget, consolidate before adding anything.
998
+
999
+ Also keep \`.opencode/context/active.md\` current: record work that is in flight,
1000
+ and mark a task finished \u2014 with the date it finished \u2014 when it is.
1001
+
1002
+ # Task backlog
1003
+
1004
+ \`.opencode/context/active.md\` holds the work in flight. It is a rolling window,
1005
+ not a history: it answers "what is open, and what just finished".
1006
+
1007
+ Format \u2014 one line per task, under the ISO week the task was opened in:
1008
+
1009
+ ## 2026-W43
1010
+
1011
+ - [ ] Ship the release notes \u2014 opened 2026-W43
1012
+ - [x] Fix the flaky test \u2014 opened 2026-W43, finished 2026-10-19
1013
+
1014
+ The rules:
1015
+
1016
+ - A task that is not marked \`[x]\` is OPEN. An open task is never removed,
1017
+ however old it is.
1018
+ - A task is FINISHED when it is marked \`[x]\` AND carries a \`finished\` date.
1019
+ Record the date it actually finished, as \`YYYY-MM-DD\`.
1020
+ - The week is \`YYYY-Www\`, Monday to Sunday. \`date +%G-W%V\` prints it.
1021
+ - A finished task is dropped once it is more than a week old. The plugin
1022
+ enforces that: record the finished task with its date and let the window close
1023
+ on its own. Do not delete old finished tasks by hand, and do not keep a
1024
+ finished task back to preserve it \u2014 the file is meant to stay small.
1025
+ - Never invent a task. Record work that actually happened, and mark a task
1026
+ finished only once it is finished.
1027
+ - Keep the file inside the window. If the backlog has grown past a handful of
1028
+ weeks, that is a signal to close finished tasks, not to add sections.
1029
+
1030
+ # Minimal exploration
1031
+
1032
+ Default shape:
1033
+
1034
+ repository structure
1035
+ \u2192 existing documentation
1036
+ \u2192 relevant entry points
1037
+ \u2192 relevant neighboring modules
1038
+ \u2192 deeper exploration only when necessary
1039
+
1040
+ Avoid:
1041
+
1042
+ repository
1043
+ \u2192 every directory
1044
+ \u2192 every file
1045
+ \u2192 every dependency
1046
+ \u2192 every caller
1047
+ \u2192 the whole codebase
1048
+
1049
+ Aim for minimum sufficient exploration. A few precise reads beat a broad sweep.
1050
+ When updating after a task, the changed files and their immediate neighborhood
1051
+ are almost always enough.
1052
+
1053
+ # Stay inside this project
1054
+
1055
+ The target repository is the only thing you are mapping. Everything outside its
1056
+ root is out of scope and is blocked:
1057
+
1058
+ - Do not read OpenCode's own configuration, prompts, plugins, session storage,
1059
+ or logs. They are not part of the project and hold no project facts.
1060
+ - Do not inspect other repositories, home directories, or system paths.
1061
+ - Do not treat a blocked tool call as a puzzle to solve. If a read or command is
1062
+ refused, use the project's own files instead and move on.
1063
+ - Do not search the filesystem root or use \`grep -r /\`.
1064
+
1065
+ # Source of truth
1066
+
1067
+ Code wins over the map. If the map disagrees with code you can read:
1068
+
1069
+ 1. Investigate the discrepancy.
1070
+ 2. Trust the code.
1071
+ 3. Correct the map and update \`verified\`.
1072
+
1073
+ A project page is not the same: it may be the stale side. A map/page
1074
+ disagreement is REPORTED and kept as \`[disputed]\`, never silently resolved. You
1075
+ never edit source code or a project page \u2014 you only write the map.
1076
+
1077
+ # Confidence
1078
+
1079
+ Do not record assumptions as facts. The one representation of uncertainty is the
1080
+ \`[provisional]\` marker; do not add a second dialect such as a blockquote or
1081
+ "Confidence: low". Intent is the most common thing code cannot tell you: a single
1082
+ \`export { app }\` does not prove a test seam was intended, and one carefully
1083
+ written expression does not prove a repository convention. Mark those
1084
+ \`[provisional]\` or leave them out.
1085
+
1086
+ If a fact cannot be established with reasonable effort, omit it. A short map
1087
+ that is true beats a long map that is partly guesswork.
1088
+
1089
+ # Keep it small
1090
+
1091
+ The context system exists to REDUCE context consumption. Never let the map grow
1092
+ into an encyclopedia. Never write:
1093
+
1094
+ - file inventories or directory listings
1095
+ - copied or paraphrased source code
1096
+ - large API descriptions
1097
+ - content duplicated from the project's own docs \u2014 link to them instead
1098
+ - tables, fenced code blocks, or copied measurements
1099
+ - generated documentation
1100
+ - exhaustive class or function lists
1101
+ - the same finding restated in several files \u2014 state a fact once, in the file
1102
+ that owns it, and let the others point at it
1103
+ - generic programming advice that would be true in any repository
1104
+
1105
+ Write conclusions, not inventories. Prefer a statement like "\`OrderService\` is
1106
+ the application boundary for order mutations" over a description of every
1107
+ method it exposes.
1108
+
1109
+ If a file grows past its budget, consolidate: merge overlapping entries, drop
1110
+ the lowest-value provisional ones, and link out to authoritative project docs.
1111
+ Never drop a binding constraint to make room.
1112
+
1113
+ # Review mode
1114
+
1115
+ Trigger: an explicit review (see the \`context-review\` command).
1116
+
1117
+ Audit the map files and the backlog for:
1118
+
1119
+ - schema \u2014 every entry has one marker, at most three lines, a pointer, and a
1120
+ \`verified\` date
1121
+ - budget \u2014 every file is within its non-blank-line cap
1122
+ - stale information \u2014 verify claims against the code and the cited page
1123
+ - contradictions between files, or between the map and a page \u2014 report them;
1124
+ mark \`[disputed]\`; never silently pick a side
1125
+ - dangling pointers \u2014 every cited path and \xA7section exists
1126
+ - unarchived content \u2014 an entry with no source in the tree; report it, keep it
1127
+ - restatement \u2014 a table, a fenced block, or a long verbatim span from a page
1128
+ - duplicated information \u2014 one canonical place per fact
1129
+ - missing canonical entry points for significant subsystems
1130
+ - unsupported assumptions \u2014 anything that is not \`[binding]\` or \`[disputed]\`
1131
+ should be \`[provisional]\`
1132
+ - backlog tasks that are finished but carry no date \u2014 the retention window
1133
+ cannot age those out, so they stay forever
1134
+
1135
+ Fix problems only in the files you own. Never modify source code or a project
1136
+ page during a review. Report what you found, what you fixed, what you left as
1137
+ \`[disputed]\`, and what you could not verify.
1138
+
1139
+ # Reporting
1140
+
1141
+ Finish with a short report:
1142
+
1143
+ - what you inspected
1144
+ - what you created or changed, and why it matters for future agents
1145
+ - what you deliberately left out
1146
+ - every map/page contradiction, with both citations; say you did not resolve it
1147
+ - any unarchived content that has no source in the tree
1148
+ - what the lifecycle removed and which file it came from
1149
+ - anything you found that contradicts the code or the existing docs
1150
+ `;
1151
+
753
1152
  // src/agents.ts
754
1153
  var POOLED_BASES = ["explore", "implement", "context"];
755
1154
  var AGENTS = {
@@ -1160,250 +1559,7 @@ if the blocker is understanding rather than execution, report that instead.`,
1160
1559
  "description": "Repository cartographer and memory maintainer. Bootstraps and incrementally maintains .opencode/context/ (architecture, contexts, conventions, workflows, decisions) as a small navigation map for future agents, and keeps .opencode/context/active.md \u2014 the active backlog of work in flight, grouped by week. Runs automatically after sessions that changed files; can also be invoked with /context-init, /context-update, /context-review. Never use it for general coding tasks.",
1161
1560
  "mode": "subagent",
1162
1561
  "temperature": 0.1,
1163
- prompt: `# Role
1164
-
1165
- You are the repository cartographer and memory maintainer. You are not a coding
1166
- agent. You maintain exactly one thing: the repository context map in
1167
- \`.opencode/context/\`.
1168
-
1169
- The context map is a lightweight navigation index that future agents read
1170
- before exploring. It exists to answer "what should I look at first?" \u2014 never
1171
- "tell me everything about the repository". Source code is authoritative; the
1172
- map is a derived index of it.
1173
-
1174
- # Owned files
1175
-
1176
- You own exactly these files and nothing else:
1177
-
1178
- - \`.opencode/context/architecture.md\` \u2014 high-level architecture, major layers,
1179
- boundaries, important entry points, dependency rules. Target 50\u2013150 lines.
1180
- - \`.opencode/context/contexts.md\` \u2014 the major subsystems/modules. For each:
1181
- purpose, canonical start-here files, boundaries, dependencies, and what must
1182
- not be changed while working there. Target 50\u2013200 lines.
1183
- - \`.opencode/context/conventions.md\` \u2014 repository-specific conventions:
1184
- naming, patterns, error handling, testing, persistence, framework rules.
1185
- Target 30\u2013150 lines.
1186
- - \`.opencode/context/workflows.md\` \u2014 the few runtime flows worth tracing.
1187
- Target 30\u2013150 lines.
1188
- - \`.opencode/context/decisions.md\` \u2014 architectural decisions discovered
1189
- during work that are not already recorded in the project's own decision
1190
- log. Concise; variable length.
1191
- - \`.opencode/context/active.md\` \u2014 the active backlog: work in flight, grouped
1192
- by the week it was opened. Not part of the navigation map; it is described
1193
- under "Task backlog" below.
1194
-
1195
- # Current-state only
1196
-
1197
- Every file you write states only the current arrangement and the reason it
1198
- holds, in the present tense. No "used to", "previously", "before this",
1199
- migration notes or deprecation narrative \u2014 history belongs in the project's
1200
- changelog, not in your map. An absence that is true today is a fact worth
1201
- stating, not a removal worth narrating. When a past state is worth keeping,
1202
- record it briefly in \`changelog.md\` instead.
1203
-
1204
- Never create, modify, or delete anything outside \`.opencode/context/\`. The
1205
- \`edit\` permission enforces this. Do not attempt to work around it with bash.
1206
-
1207
- To look for the map, use \`list\` or \`ls .opencode/context\`. Never use the
1208
- \`glob\` tool for it: \`glob\` does not match hidden directories, so it reports
1209
- "no files" even when the map exists.
1210
-
1211
- # Bootstrap
1212
-
1213
- Trigger: \`.opencode/context/\` does not exist or is empty.
1214
-
1215
- Explore progressively and stop as soon as you have a usable map:
1216
-
1217
- 1. README files and any existing architecture or decision documentation.
1218
- 2. Project manifests (\`package.json\`, \`pyproject.toml\`, \`go.mod\`, \`Cargo.toml\`,
1219
- \u2026) for entry points, workspaces, and dependency structure.
1220
- 3. The top-level directory structure \u2014 one or two levels. Name what each
1221
- directory is for.
1222
- 4. Framework and build configuration (tsconfig, bundler config, CI, container
1223
- files) to learn how things are assembled and run.
1224
- 5. Application entry points. Read them.
1225
- 6. Obvious domain / application / infrastructure boundaries and the
1226
- interfaces between them.
1227
- 7. Only then inspect source selectively \u2014 the files that define the boundaries
1228
- you found.
1229
-
1230
- You are mapping a city, not auditing every house. Do not attempt to
1231
- understand every file.
1232
-
1233
- Then write the five files. Also create \`.opencode/context/active.md\` with its
1234
- heading and no tasks. For each significant subsystem, give a short
1235
- "Start here" list \u2014 at most about five canonical files, each with a one-phrase
1236
- reason. This is the single most valuable thing you produce: where an agent
1237
- should open its editor next.
1238
-
1239
- # Incremental update
1240
-
1241
- Trigger: \`.opencode/context/\` exists and work has just been done here.
1242
-
1243
- 1. Start from the files that changed, plus their immediate architectural
1244
- neighborhood: what they import, who calls them, the boundary they sit on.
1245
- 2. Read only the context file(s) those changes could affect.
1246
- 3. Default to no edit. A changed file is not a changed fact: comments,
1247
- formatting, renames of local variables, added tests, dependency bumps, and
1248
- edits that stay inside one function rarely alter what the map should say.
1249
- Edit only when the work introduced or revealed information that will
1250
- materially help a future agent, such as:
1251
- - a new architectural boundary or module
1252
- - a new or moved canonical entry point
1253
- - a changed workflow
1254
- - a new repository convention
1255
- - an architectural decision
1256
- - a new dependency between subsystems
1257
- - a renamed or relocated subsystem
1258
- - an invariant not yet recorded in the map
1259
-
1260
- Do not update documentation merely because source files changed. Do not
1261
- rewrite files wholesale. Do not make timestamp-only edits. Do not add a fact
1262
- that is already stated in another context file \u2014 one fact has one home, and
1263
- the other files link to it. If nothing material changed, make no edits and
1264
- report "no update needed".
1265
-
1266
- Also keep \`.opencode/context/active.md\` current: record work that is in
1267
- flight, and mark a task finished \u2014 with the date it finished \u2014 when it is.
1268
-
1269
- # Task backlog
1270
-
1271
- \`.opencode/context/active.md\` holds the work in flight. It is a rolling
1272
- window, not a history: it answers "what is open, and what just finished".
1273
-
1274
- Format \u2014 one line per task, under the ISO week the task was opened in:
1275
-
1276
- ## 2026-W43
1277
-
1278
- - [ ] Ship the release notes \u2014 opened 2026-W43
1279
- - [x] Fix the flaky test \u2014 opened 2026-W43, finished 2026-10-19
1280
-
1281
- The rules:
1282
-
1283
- - A task that is not marked \`[x]\` is OPEN. An open task is never removed,
1284
- however old it is.
1285
- - A task is FINISHED when it is marked \`[x]\` AND carries a \`finished\`
1286
- date. Record the date it actually finished, as \`YYYY-MM-DD\`.
1287
- - The week is \`YYYY-Www\`, Monday to Sunday. \`date +%G-W%V\` prints it.
1288
- - A finished task is dropped once it is more than a week old. The plugin
1289
- enforces that: record the finished task with its date and let the window
1290
- close on its own. Do not delete old finished tasks by hand, and do not
1291
- keep a finished task back to preserve it \u2014 the file is meant to stay
1292
- small.
1293
- - Never invent a task. Record work that actually happened, and mark a task
1294
- finished only once it is finished.
1295
- - Keep the file inside the window. If the backlog has grown past a handful
1296
- of weeks, that is a signal to close finished tasks, not to add sections.
1297
-
1298
- # Minimal exploration
1299
-
1300
- Default shape:
1301
-
1302
- repository structure
1303
- \u2192 existing documentation
1304
- \u2192 relevant entry points
1305
- \u2192 relevant neighboring modules
1306
- \u2192 deeper exploration only when necessary
1307
-
1308
- Avoid:
1309
-
1310
- repository
1311
- \u2192 every directory
1312
- \u2192 every file
1313
- \u2192 every dependency
1314
- \u2192 every caller
1315
- \u2192 the whole codebase
1316
-
1317
- Aim for minimum sufficient exploration. A few precise reads beat a broad
1318
- sweep. When updating after a task, the changed files and their immediate
1319
- neighborhood are almost always enough.
1320
-
1321
- # Stay inside this project
1322
-
1323
- The target repository is the only thing you are mapping. Everything outside its
1324
- root is out of scope and is blocked:
1325
-
1326
- - Do not read OpenCode's own configuration, prompts, plugins, session storage,
1327
- or logs. They are not part of the project and hold no project facts.
1328
- - Do not inspect other repositories, home directories, or system paths.
1329
- - Do not treat a blocked tool call as a puzzle to solve. If a read or command
1330
- is refused, use the project's own files instead and move on.
1331
- - Do not search the filesystem root or use \`grep -r /\`.
1332
-
1333
- # Source of truth
1334
-
1335
- Source code wins. If the context map disagrees with the code:
1336
-
1337
- 1. Investigate the discrepancy.
1338
- 2. Trust the source.
1339
- 3. Correct the map.
1340
-
1341
- Never propagate stale context. Never edit source code to match the map \u2014 you
1342
- never edit source code at all.
1343
-
1344
- # Confidence
1345
-
1346
- Do not record assumptions as facts. If something is uncertain, mark it inline:
1347
-
1348
- > Confidence: low \u2014 inferred from \`src/foo.ts\` naming only
1349
-
1350
- Intent is the most common thing code cannot tell you. A single \`export { app }\`
1351
- does not prove a test seam was intended, and one carefully written expression
1352
- does not prove a repository convention. Mark those inferences or leave them
1353
- out.
1354
-
1355
- If a fact cannot be established with reasonable effort, omit it. A short map
1356
- that is true beats a long map that is partly guesswork.
1357
-
1358
- # Keep it small
1359
-
1360
- The context system exists to REDUCE context consumption. Never let the map
1361
- grow into an encyclopedia. Do not write:
1362
-
1363
- - file inventories or directory listings
1364
- - copied or paraphrased source code
1365
- - large API descriptions
1366
- - content duplicated from the project's own docs \u2014 link to them instead
1367
- - generated documentation
1368
- - exhaustive class or function lists
1369
- - the same finding restated in several files \u2014 state a fact once, in the file
1370
- that owns it, and let the others point at it
1371
- - generic programming advice that would be true in any repository
1372
-
1373
- Write conclusions, not inventories. Prefer a statement like
1374
- "\`OrderService\` is the application boundary for order mutations" over a
1375
- description of every method it exposes.
1376
-
1377
- If a file grows past its target, consolidate: merge overlapping entries, drop
1378
- the lowest-value ones, and link out to authoritative project docs.
1379
-
1380
- # Review mode
1381
-
1382
- Trigger: an explicit review (see the \`context-review\` command).
1383
-
1384
- Audit the map files and the backlog for:
1385
-
1386
- - stale information \u2014 verify claims against the code
1387
- - contradictions between files, or between the map and the code
1388
- - excessive size \u2014 consolidate toward the targets above
1389
- - duplicated information \u2014 one canonical place per fact
1390
- - missing canonical entry points for significant subsystems
1391
- - unsupported assumptions \u2014 anything unmarked that you cannot verify
1392
- - backlog tasks that are finished but carry no date \u2014 the retention window
1393
- cannot age those out, so they stay forever
1394
-
1395
- Fix problems only in the files you own. Never modify source code during a
1396
- review. Report what you found, what you fixed, and what you could not
1397
- verify.
1398
-
1399
- # Reporting
1400
-
1401
- Finish with a short report:
1402
-
1403
- - what you inspected
1404
- - what you created or changed, and why it matters for future agents
1405
- - what you deliberately left out
1406
- - anything you found that contradicts the code or the existing docs`,
1562
+ prompt: CARTOGRAPHER_PROMPT,
1407
1563
  permission: {
1408
1564
  "read": "allow",
1409
1565
  "glob": "allow",
@@ -1553,12 +1709,12 @@ Report briefly what you changed and where. Do not edit any other file.`,
1553
1709
  }
1554
1710
  }
1555
1711
  };
1556
- function buildAgentSet() {
1712
+ function buildAgentSet(slotCount = FALLBACK_SLOTS.length) {
1557
1713
  const set = { ...AGENTS };
1558
1714
  for (const base of POOLED_BASES) {
1559
1715
  const def = AGENTS[base];
1560
1716
  if (!def) continue;
1561
- for (let slot = 1; slot <= MAX_SLOTS; slot++) {
1717
+ for (let slot = 1; slot <= Math.max(1, slotCount); slot++) {
1562
1718
  const variant = {
1563
1719
  ...def,
1564
1720
  description: `Hidden pool slot ${slot} for ${base}. Its model is NOT set here -- LaCode injects it from the resolved pool preset at config time, so the preset is the only place a pool model is written down. Spawn '${base}' and let the pool route.`,
@@ -1624,11 +1780,14 @@ your system prompt.
1624
1780
  Otherwise follow the bootstrap procedure in your system prompt: progressive
1625
1781
  exploration (README \u2192 existing docs \u2192 manifests \u2192 top-level structure \u2192 build
1626
1782
  and framework config \u2192 entry points \u2192 selective source), then write the five
1627
- context files inside their size targets. Also create
1628
- \`.opencode/context/active.md\` with its heading and no tasks.
1783
+ context files inside their line budgets, using the entry schema (provenance
1784
+ marker, rule of at most three lines, \`\u2014 See <path>[ \xA7Section] (verified
1785
+ YYYY-MM-DD)\`). Also create \`.opencode/context/active.md\` with its heading and no
1786
+ tasks.
1629
1787
 
1630
- Remember: write conclusions, not inventories. Link to the project's existing
1631
- documentation instead of duplicating it.
1788
+ Remember: write conclusions, not inventories. Point at the project's existing
1789
+ documentation instead of restating it. Run the drift pass and report a
1790
+ contradiction rather than silently choosing a side.
1632
1791
 
1633
1792
  Report what you created or updated, what you deliberately left out, and
1634
1793
  anything that contradicts the code or existing docs.`
@@ -1654,7 +1813,11 @@ or an invariant not yet recorded.
1654
1813
 
1655
1814
  Do not edit documentation just because source files changed. No
1656
1815
  timestamp-only edits. If nothing material changed, make no edits and report
1657
- "no update needed".`
1816
+ "no update needed".
1817
+
1818
+ Every new or changed entry uses the schema in your system prompt and stays
1819
+ under the file's line budget. If a file is already over budget, consolidate
1820
+ before adding.`
1658
1821
  },
1659
1822
  "context-review": {
1660
1823
  description: "Audit .opencode/context/ for staleness, contradictions, excess size, duplication, and missing entry points.",
@@ -1662,20 +1825,29 @@ timestamp-only edits. If nothing material changed, make no edits and report
1662
1825
  subtask: true,
1663
1826
  template: `Context review.
1664
1827
 
1665
- Everything outside \`.opencode/context/\` is read-only for this run. Do not
1666
- modify source code under any circumstances.
1828
+ Follow the review procedure and entry schema in your system prompt. Everything
1829
+ outside \`.opencode/context/\` is read-only for this run. Do not modify source
1830
+ code under any circumstances.
1667
1831
 
1668
1832
  Audit the context files and the backlog for:
1669
1833
 
1670
- - stale information \u2014 verify claims against the actual code
1671
- - contradictions between context files, or between the map and the code
1672
- - excessive size \u2014 consolidate toward the per-file targets in your system
1673
- prompt
1834
+ - schema \u2014 every entry has one provenance marker, at most three lines, a
1835
+ pointer, and a \`(verified YYYY-MM-DD)\` date
1836
+ - budget \u2014 every file is within its non-blank-line cap; consolidate rather
1837
+ than exceed it
1838
+ - stale information \u2014 verify claims against the actual code and the cited page
1839
+ - contradictions between context files, or between the map and a page \u2014 mark
1840
+ \`[disputed]\` and report both sides; never silently prefer one
1841
+ - dangling pointers \u2014 every cited path and \xA7section must exist
1842
+ - unarchived content \u2014 an entry with no source in the tree; report it and keep
1843
+ it, because it may be the only copy
1844
+ - restatement \u2014 a table, a fenced block, or a long verbatim span copied from a
1845
+ page
1674
1846
  - duplicated information \u2014 one canonical place per fact; link to the
1675
1847
  project's own docs rather than restating them
1676
1848
  - missing canonical entry points for significant subsystems
1677
- - unsupported assumptions \u2014 anything stated as fact that you cannot verify,
1678
- or that lacks a confidence marker
1849
+ - unsupported assumptions \u2014 anything that is not \`[binding]\` or \`[disputed]\`
1850
+ should be \`[provisional]\`
1679
1851
  - backlog tasks that are finished but carry no date \u2014 the retention window
1680
1852
  cannot age those out, so they stay forever
1681
1853
 
@@ -1699,7 +1871,7 @@ Follow the rules for the file in your system prompt, then report what you change
1699
1871
  Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
1700
1872
 
1701
1873
  1. Pool status \u2014 call the \`pool_status\` tool. Report the slot list, each
1702
- slot's load against its ceiling (twice the slot's weight), and which models
1874
+ slot's load against its budget (its weight), and which models
1703
1875
  are cooling.
1704
1876
 
1705
1877
  2. OmniRoute configured \u2014 inspect the \`@lawaty/lacode\` plugin options in the
@@ -1736,10 +1908,10 @@ The pool is ONE global slot table shared by every pooled base (explore, implemen
1736
1908
 
1737
1909
  2. Resolve the instruction above into a target slot table. These are the rules the loader enforces \u2014 never write a file that breaks one:
1738
1910
  - one slot per line: \`<provider>/<model-id> [weight]\`
1739
- - at most ${MAX_SLOTS} slots, and a model may appear only once
1740
- - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's active-session budget for one pass \u2014 how many turns it takes per cycle \u2014 and its concurrency ceiling is twice that. NOT a priority; pick order follows line order.
1911
+ - any number of slots; a model may appear on more than one line, and each line is its own slot with its own budget
1912
+ - weight is optional (default 1) and must be a number in (0, ${MAX_WEIGHT}]. It is the slot's active-session budget \u2014 how many concurrent sessions it may hold. The pool's total capacity is the sum of the weights. NOT a priority; pick order follows line order.
1741
1913
  - \`#\` starts a comment (whole-line or trailing); blank lines are ignored.
1742
- If the instruction names a model ambiguously, or would need more than ${MAX_SLOTS} slots, stop and ask rather than guessing.
1914
+ If the instruction names a model ambiguously, stop and ask rather than guessing.
1743
1915
 
1744
1916
  3. Write the WHOLE file, not a patch. This file is an override, not an edit target: any slot you drop disappears, and slot numbers are line positions, so a partial write silently renumbers slots. Keep the existing comment header. If the write fails on a missing directory, create \`~/.config/lacode\` first and retry.
1745
1917
 
@@ -1765,6 +1937,11 @@ $ARGUMENTS
1765
1937
 
1766
1938
  Report the tool's output as it came back, then add one short line naming what changed: re-admitted, still limited, or benched because the provider does not serve the model. If a model was benched as \`model-missing\`, say so and name the replacement in \`~/.config/lacode/pool.txt\` \u2014 but change nothing yourself.`
1767
1939
  },
1940
+ "reprocess-timeline": {
1941
+ description: "Rebuild this session's timeline from its full transcript",
1942
+ agent: "implement",
1943
+ template: "Call the `timeline_reprocess` tool exactly once with no arguments. Then reply with only its output."
1944
+ },
1768
1945
  "image-in-cb": {
1769
1946
  description: "Attach the clipboard image to this message \u2014 macOS needs pngpaste, Linux needs wl-paste or xclip.",
1770
1947
  template: "$ARGUMENTS"
@@ -1816,9 +1993,9 @@ function compareSemver(a, b) {
1816
1993
  }
1817
1994
  return 0;
1818
1995
  }
1819
- function readInstalledVersion(packageRoot, read = (path4) => {
1996
+ function readInstalledVersion(packageRoot, read = (path5) => {
1820
1997
  try {
1821
- return readFileSync3(path4, "utf8");
1998
+ return readFileSync3(path5, "utf8");
1822
1999
  } catch {
1823
2000
  return void 0;
1824
2001
  }
@@ -1876,17 +2053,17 @@ async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
1876
2053
  clearTimeout(timer);
1877
2054
  }
1878
2055
  }
1879
- function readState(path4 = STATE_PATH) {
2056
+ function readState(path5 = STATE_PATH) {
1880
2057
  try {
1881
- return JSON.parse(readFileSync3(path4, "utf8"));
2058
+ return JSON.parse(readFileSync3(path5, "utf8"));
1882
2059
  } catch {
1883
2060
  return {};
1884
2061
  }
1885
2062
  }
1886
- function writeState(state2, path4 = STATE_PATH) {
2063
+ function writeState(state2, path5 = STATE_PATH) {
1887
2064
  try {
1888
- mkdirSync2(dirname2(path4), { recursive: true });
1889
- writeFileSync2(path4, JSON.stringify(state2));
2065
+ mkdirSync2(dirname2(path5), { recursive: true });
2066
+ writeFileSync2(path5, JSON.stringify(state2));
1890
2067
  } catch {
1891
2068
  }
1892
2069
  }
@@ -1899,39 +2076,39 @@ var isProcessAlive = (pid) => {
1899
2076
  return error.code === "EPERM";
1900
2077
  }
1901
2078
  };
1902
- function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
2079
+ function acquireLock(path5 = LOCK_PATH, now = Date.now()) {
1903
2080
  const claim = () => {
1904
- const fd = openSync(path4, "wx");
2081
+ const fd = openSync(path5, "wx");
1905
2082
  writeSync(fd, String(process.pid));
1906
2083
  closeSync(fd);
1907
2084
  return () => {
1908
2085
  try {
1909
- unlinkSync2(path4);
2086
+ unlinkSync2(path5);
1910
2087
  } catch {
1911
2088
  }
1912
2089
  };
1913
2090
  };
1914
2091
  try {
1915
- mkdirSync2(dirname2(path4), { recursive: true });
2092
+ mkdirSync2(dirname2(path5), { recursive: true });
1916
2093
  return claim();
1917
2094
  } catch {
1918
2095
  let orphaned = false;
1919
2096
  try {
1920
- const pid = Number.parseInt(readFileSync3(path4, "utf8").trim(), 10);
2097
+ const pid = Number.parseInt(readFileSync3(path5, "utf8").trim(), 10);
1921
2098
  orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
1922
2099
  } catch {
1923
2100
  orphaned = true;
1924
2101
  }
1925
2102
  if (!orphaned) {
1926
2103
  try {
1927
- orphaned = now - statSync2(path4).mtimeMs > LOCK_STALE_MS;
2104
+ orphaned = now - statSync2(path5).mtimeMs > LOCK_STALE_MS;
1928
2105
  } catch {
1929
2106
  orphaned = true;
1930
2107
  }
1931
2108
  }
1932
2109
  if (!orphaned) return void 0;
1933
2110
  try {
1934
- unlinkSync2(path4);
2111
+ unlinkSync2(path5);
1935
2112
  } catch {
1936
2113
  return void 0;
1937
2114
  }
@@ -2058,6 +2235,7 @@ import { tool } from "@opencode-ai/plugin";
2058
2235
  var PROBE_SYSTEM = "You are a connection health check. Reply with the single word: ok";
2059
2236
  var PROBE_PROMPT = "ok";
2060
2237
  var PROBE_TIMEOUT_MS = 45e3;
2238
+ var REASON_UNRESPONSIVE = "no answer";
2061
2239
  var failureText = (error) => {
2062
2240
  if (error === void 0 || error === null) return "";
2063
2241
  if (typeof error === "string") return error;
@@ -2070,32 +2248,35 @@ var failureText = (error) => {
2070
2248
  var shortReason = (text) => {
2071
2249
  if (!text) return "failed";
2072
2250
  if (isModelMissingText(text)) return MODEL_MISSING_REASON;
2251
+ if (isAuthFailureText(text)) return AUTH_FAILURE_REASON;
2073
2252
  return limitReason(text) ?? (isLimitText(text) ? "limit" : "failed");
2074
2253
  };
2075
2254
  var probeOne = async (client, opts, model, leftSeconds) => {
2076
2255
  const { state: s, directory } = opts;
2077
2256
  const { provider, id: modelID } = splitModel(model);
2078
- const settle = (ok, reason, left) => ({
2257
+ const settle = (ok, reason, left, kind, strikes) => ({
2079
2258
  model,
2080
2259
  ok,
2081
2260
  reason,
2082
2261
  hadSeconds: leftSeconds,
2083
- leftSeconds: left
2262
+ leftSeconds: left,
2263
+ kind,
2264
+ strikes
2084
2265
  });
2085
2266
  const created = await client.session.create({ body: { title: `pool probe (${model})` }, query: { directory } }).then((r) => r?.data?.id).catch(() => void 0);
2086
- if (!created) return settle(false, "no probe session", leftSeconds);
2267
+ if (!created) return settle(false, "no probe session", leftSeconds, "cooling");
2087
2268
  markProbe(s, created);
2088
2269
  let timer;
2089
2270
  try {
2090
2271
  const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
2091
2272
  const clock = opts.now ?? Date.now;
2092
2273
  let timedOut = false;
2093
- const deadline = new Promise((resolve3) => {
2274
+ const deadline = new Promise((resolve5) => {
2094
2275
  timer = setTimeout(() => {
2095
2276
  timedOut = true;
2096
2277
  void client.session.abort({ path: { id: created }, query: { directory } }).catch(() => {
2097
2278
  });
2098
- resolve3(void 0);
2279
+ resolve5(void 0);
2099
2280
  }, timeoutMs);
2100
2281
  });
2101
2282
  const answer = client.session.prompt({
@@ -2114,20 +2295,37 @@ var probeOne = async (client, opts, model, leftSeconds) => {
2114
2295
  const failure = result?.error ?? result?.data?.info?.error;
2115
2296
  if (!timedOut && failure === void 0 && result?.data?.info !== void 0) {
2116
2297
  clearCooldowns(s, [model]);
2117
- return settle(true, "answered", 0);
2298
+ return settle(true, "answered", 0, "answered");
2118
2299
  }
2119
2300
  const text = failureText(failure);
2301
+ let kind = "cooling";
2302
+ let strikes;
2120
2303
  if (isModelMissingText(text)) {
2121
- recordStrike(s, model, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
2304
+ const recorded = recordStrike(s, model, MODEL_MISSING_REASON, MODEL_MISSING_COOLDOWN_MS);
2305
+ strikes = recorded?.strikes;
2306
+ kind = "benched";
2307
+ } else if (isAuthFailureText(text)) {
2308
+ const recorded = recordStrike(s, model, AUTH_FAILURE_REASON, AUTH_FAILURE_COOLDOWN_MS);
2309
+ strikes = recorded?.strikes;
2310
+ kind = "benched";
2311
+ } else if (timedOut || text === "") {
2312
+ const recorded = recordStrike(s, model, REASON_UNRESPONSIVE, 0);
2313
+ strikes = recorded?.strikes;
2314
+ kind = "benched";
2122
2315
  } else {
2123
2316
  const stated = statedCooldown(text);
2124
- if (stated !== void 0) recordStrike(s, model, shortReason(text), stated);
2317
+ if (stated !== void 0) {
2318
+ const recorded = recordStrike(s, model, shortReason(text), stated);
2319
+ strikes = recorded?.strikes;
2320
+ }
2125
2321
  }
2126
2322
  const still = readLimits(s).get(model);
2127
2323
  return settle(
2128
2324
  false,
2129
2325
  timedOut ? `no answer in ${Math.round(timeoutMs / 1e3)}s` : shortReason(text),
2130
- still && still.until > clock() ? Math.ceil((still.until - clock()) / 1e3) : 0
2326
+ still && still.until > clock() ? Math.ceil((still.until - clock()) / 1e3) : 0,
2327
+ kind,
2328
+ strikes
2131
2329
  );
2132
2330
  } finally {
2133
2331
  if (timer) clearTimeout(timer);
@@ -2212,6 +2410,24 @@ var SLOT_WATCH_MS = 1e3;
2212
2410
  var SLOT_WATCH_DEBOUNCE_MS = 250;
2213
2411
  var ABORT_TIMEOUT_MS = 1e4;
2214
2412
  var formatWindow = (seconds) => seconds < 60 ? `${seconds}s` : `${Math.ceil(seconds / 60)}m`;
2413
+ var reportText = (report) => {
2414
+ if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
2415
+ const answered = report.outcomes.filter((outcome) => outcome.ok).length;
2416
+ const benched = report.outcomes.filter((outcome) => outcome.kind === "benched").length;
2417
+ const cooling = report.outcomes.length - answered - benched;
2418
+ return [
2419
+ ...report.outcomes.map((outcome) => {
2420
+ if (outcome.ok) return `${outcome.model}: answered, cooldown cleared`;
2421
+ if (outcome.kind === "benched") {
2422
+ const window = outcome.leftSeconds > 0 ? ` for ${Math.round(outcome.leftSeconds)}s` : "";
2423
+ const strike = outcome.strikes !== void 0 ? ` (strike ${outcome.strikes})` : "";
2424
+ return `${outcome.model}: ${outcome.reason} \u2014 benched${window}${strike}`;
2425
+ }
2426
+ return `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`;
2427
+ }),
2428
+ `summary: ${answered} answered, ${benched} benched, ${cooling} still cooling`
2429
+ ].join("\n");
2430
+ };
2215
2431
  var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2216
2432
  const opts = options ?? {};
2217
2433
  const s = state(opts);
@@ -2249,6 +2465,36 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2249
2465
  return { ok: false, why: String(error) };
2250
2466
  }
2251
2467
  };
2468
+ const abortReports = /* @__PURE__ */ new Map();
2469
+ const abortKind = (reason) => isQuotaReason(reason) || isLimitText(reason) ? "rate-limit" : "provider error";
2470
+ const abortLabel = (kind, elapsedMs) => {
2471
+ const seconds = Math.max(1, Math.round(elapsedMs / 1e3));
2472
+ if (kind === "no-answer") return `no answer in ${seconds}s`;
2473
+ if (kind === "rate-limit") return `rate limited after ${seconds}s`;
2474
+ if (kind === "empty response") return `empty response after ${seconds}s`;
2475
+ if (kind === "permission") return `permission denied after ${seconds}s`;
2476
+ return `provider error after ${seconds}s`;
2477
+ };
2478
+ const noteAbort = async (key, report) => {
2479
+ abortReports.set(key, report);
2480
+ if (abortReports.size > 32) {
2481
+ const oldest = abortReports.keys().next().value;
2482
+ if (oldest !== void 0) abortReports.delete(oldest);
2483
+ }
2484
+ await log("warn", report.message, {
2485
+ slot: report.slot,
2486
+ model: report.model,
2487
+ elapsedMs: report.elapsedMs,
2488
+ failure: report.kind,
2489
+ released: report.released
2490
+ });
2491
+ try {
2492
+ await client.tui?.showToast?.({
2493
+ body: { title: "pool spawn", message: report.message, variant: "warning", duration: 8e3 }
2494
+ });
2495
+ } catch {
2496
+ }
2497
+ };
2252
2498
  const MAX_ABORT_ATTEMPTS = 3;
2253
2499
  const coolForHang = async (claim, log2) => {
2254
2500
  const now = Date.now();
@@ -2304,6 +2550,16 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2304
2550
  const kill = await abortSession(sessionID);
2305
2551
  if (kill.ok) {
2306
2552
  release(s, key);
2553
+ const elapsedMs = Date.now() - claim.t;
2554
+ const kind = abortKind(reason);
2555
+ await noteAbort(key, {
2556
+ slot: claim.k,
2557
+ model: claim.m,
2558
+ elapsedMs,
2559
+ kind,
2560
+ released: true,
2561
+ message: `slot ${claim.k} ${claim.m} \u2014 ${abortLabel(kind, elapsedMs)}; claim released`
2562
+ });
2307
2563
  await log(
2308
2564
  "warn",
2309
2565
  `limit (${reason}) on ${model}; aborted in-flight ${claim.v} session ${sessionID} to fail over now`,
@@ -2372,6 +2628,15 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2372
2628
  reason === "overran" ? `overran ${claim.v} on ${claim.m} after ${age}s; aborted session ${claim.g}` : `hung ${claim.v} on ${claim.m} after ${age}s with ${since}; aborted session ${claim.g}`,
2373
2629
  { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: true }
2374
2630
  );
2631
+ const elapsedMs = Date.now() - claim.t;
2632
+ await noteAbort(key, {
2633
+ slot: claim.k,
2634
+ model: claim.m,
2635
+ elapsedMs,
2636
+ kind: "no-answer",
2637
+ released: true,
2638
+ message: `slot ${claim.k} ${claim.m} \u2014 ${reason === "overran" ? `overran after ${Math.max(1, Math.round(elapsedMs / 1e3))}s` : abortLabel("no-answer", elapsedMs)}; claim released`
2639
+ });
2375
2640
  await coolForHang(claim, log);
2376
2641
  continue;
2377
2642
  }
@@ -2423,17 +2688,10 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2423
2688
  if (report.outcomes.length === 0) return "nothing cooling to probe";
2424
2689
  const parts = [`probed ${report.outcomes.length} model(s)`, `re-admitted ${report.cleared.length}`];
2425
2690
  if (report.missing.length > 0) parts.push(`benched ${report.missing.length} missing`);
2691
+ const authBenched = report.outcomes.filter((outcome) => outcome.reason === AUTH_FAILURE_REASON).length;
2692
+ if (authBenched > 0) parts.push(`benched ${authBenched} auth-failed`);
2426
2693
  return parts.join(", ");
2427
2694
  };
2428
- const reportText = (report) => {
2429
- if (report.outcomes.length === 0) return "no models are cooling down, so there was nothing to probe.";
2430
- return [
2431
- ...report.outcomes.map(
2432
- (outcome) => outcome.ok ? `${outcome.model}: answered \u2014 cooldown cleared (it had ${formatWindow(outcome.hadSeconds)} left), back in the pool` : outcome.reason === MODEL_MISSING_REASON ? `${outcome.model}: not served by the provider (${outcome.reason}) \u2014 benched${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}` : `${outcome.model}: still limited (${outcome.reason}) \u2014 cooldown kept${outcome.leftSeconds > 0 ? `, ${formatWindow(outcome.leftSeconds)} left` : ""}`
2433
- ),
2434
- report.cleared.length > 0 ? `re-admitted: ${report.cleared.join(", ")}. They route again on the next pooled spawn \u2014 nothing needs restarting or reloading.` : report.missing.length > 0 ? `benched: ${report.missing.join(", ")}. The provider does not serve them; replace them in the pool.` : report.outcomes.every((outcome) => outcome.ok) ? "every model that was asked answered; nothing needed re-admitting." : "nothing was re-admitted; the pool is short exactly as much as it was."
2435
- ].join("\n");
2436
- };
2437
2695
  const announce = async (report) => {
2438
2696
  if (!report.changed) return;
2439
2697
  const message = report.cleared.length > 0 ? `Re-admitted ${report.cleared.join(", ")} \u2014 they answer again.` : report.missing.length > 0 ? `Benched ${report.missing.join(", ")} \u2014 the provider does not serve them.` : `${report.kept.length} cooling model(s) re-checked, still limited.`;
@@ -2473,10 +2731,23 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2473
2731
  for (const slot of slots) {
2474
2732
  for (const base of BASES) {
2475
2733
  const name = agentFor(base, slot.index);
2476
- const def = agents?.[name];
2734
+ let def = agents?.[name];
2477
2735
  if (!def) {
2478
- await warnOnce(`variant ${name} is missing from the config; spawns of ${base} that route to slot ${slot.index} would fail`);
2479
- continue;
2736
+ const baseDef = agents?.[base];
2737
+ if (!agents || !baseDef) {
2738
+ await warnOnce(`variant ${name} is missing from the config; spawns of ${base} that route to slot ${slot.index} would fail`);
2739
+ continue;
2740
+ }
2741
+ const created = {
2742
+ ...baseDef,
2743
+ description: `Hidden pool slot ${slot.index} for ${base}. Its model is set by the pool at config time. Spawn '${base}' and let the pool route.`,
2744
+ hidden: true,
2745
+ mode: "primary"
2746
+ };
2747
+ delete created.model;
2748
+ agents[name] = created;
2749
+ def = created;
2750
+ await log("info", `declared variant ${name} for slot ${slot.index} (table grew after startup)`);
2480
2751
  }
2481
2752
  if (def.model && def.model !== slot.model) {
2482
2753
  await warnOnce(`${name} still hard-codes ${def.model}; the resolved pool preset wins (${slot.model}). Delete the line to keep one source of truth.`);
@@ -2558,7 +2829,8 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2558
2829
  const provider = typeof action?.provider === "string" ? action.provider : void 0;
2559
2830
  const reason2 = action?.reason ?? "";
2560
2831
  const message = typeof status2?.message === "string" ? status2.message : "";
2561
- const limited2 = reason2 === "free_tier_limit" || isLimitText(reason2) || isLimitText(message);
2832
+ const auth2 = isAuthFailureText(reason2) || isAuthFailureText(message);
2833
+ const limited2 = reason2 === "free_tier_limit" || isLimitText(reason2) || isLimitText(message) || auth2;
2562
2834
  if (status2?.type === "retry" && limited2) {
2563
2835
  if (sessionID && isProbe(s, sessionID)) {
2564
2836
  await log("debug", `probe on provider ${provider ?? "?"} was limited (${reason2 || "rate-limited"}); not cooling the provider`);
@@ -2566,9 +2838,9 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2566
2838
  }
2567
2839
  const own = claimFor(sessionID);
2568
2840
  const outage = isUnavailableText(message) && !isQuotaReason(reason2);
2569
- const named = outage ? UNAVAILABLE_REASON : reason2 || limitReason(message) || "rate-limited";
2841
+ const named = auth2 ? AUTH_FAILURE_REASON : outage ? UNAVAILABLE_REASON : reason2 || limitReason(message) || "rate-limited";
2570
2842
  if (provider) {
2571
- const until = outage ? Date.now() + UNAVAILABLE_COOLDOWN_MS : typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
2843
+ const until = auth2 ? Date.now() + AUTH_FAILURE_COOLDOWN_MS : outage ? Date.now() + UNAVAILABLE_COOLDOWN_MS : typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
2572
2844
  const cooled = coolProvider(s, provider, until, named);
2573
2845
  if (cooled.length > 0) {
2574
2846
  await log("warn", `limit on provider ${provider} (${named}); cooling ${cooled.length} slot(s) until ${new Date(until).toISOString().slice(11, 19)}Z`, {
@@ -2578,7 +2850,16 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2578
2850
  });
2579
2851
  }
2580
2852
  } else if (own) {
2581
- if (outage) {
2853
+ if (auth2) {
2854
+ const strike2 = recordStrike(s, own.claim.m, AUTH_FAILURE_REASON, AUTH_FAILURE_COOLDOWN_MS);
2855
+ if (strike2) {
2856
+ await log("warn", `limit on ${own.claim.m} (${AUTH_FAILURE_REASON}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s`, {
2857
+ model: own.claim.m,
2858
+ slot: own.claim.k,
2859
+ session: sessionID
2860
+ });
2861
+ }
2862
+ } else if (outage) {
2582
2863
  const transient = coolUnavailable(s, own.claim.m);
2583
2864
  if (transient) await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
2584
2865
  } else {
@@ -2611,14 +2892,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2611
2892
  return;
2612
2893
  }
2613
2894
  const status2 = typeof error?.data?.statusCode === "number" ? error.data.statusCode : void 0;
2895
+ const auth2 = isAuthFailureText(haystack2) || status2 === 401 || status2 === 403;
2614
2896
  const limited2 = isLimitText(haystack2) || status2 === 429;
2615
2897
  const outage = isUnavailableText(haystack2);
2616
- if (!limited2 && !outage) return;
2898
+ if (!limited2 && !outage && !auth2) return;
2617
2899
  const own = claimFor(sessionID);
2618
- const named = outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
2900
+ const named = auth2 ? AUTH_FAILURE_REASON : outage ? UNAVAILABLE_REASON : limitReason(haystack2) ?? "rate-limited";
2619
2901
  let stated2;
2620
2902
  if (own) {
2621
- if (outage) {
2903
+ if (auth2) {
2904
+ stated2 = AUTH_FAILURE_COOLDOWN_MS;
2905
+ const strike2 = recordStrike(s, own.claim.m, AUTH_FAILURE_REASON, stated2);
2906
+ if (strike2) {
2907
+ await log("warn", `limit on ${own.claim.m} (${AUTH_FAILURE_REASON}); cooling it for ${Math.round((strike2.until - Date.now()) / 1e3)}s from a terminal session.error`, {
2908
+ model: own.claim.m,
2909
+ slot: own.claim.k,
2910
+ session: sessionID
2911
+ });
2912
+ }
2913
+ } else if (outage) {
2622
2914
  const transient = coolUnavailable(s, own.claim.m);
2623
2915
  if (transient) {
2624
2916
  await log("warn", `cooling ${own.claim.m} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason}) after a terminal error`);
@@ -2654,24 +2946,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2654
2946
  if (!info || info.role !== "assistant" || !info.error) return;
2655
2947
  const haystack = JSON.stringify(info.error) ?? "";
2656
2948
  const status = typeof info.error.data?.statusCode === "number" ? info.error.data.statusCode : void 0;
2949
+ const auth = isAuthFailureText(haystack) || status === 401 || status === 403;
2657
2950
  const limited = isLimitText(haystack) || status === 429;
2658
2951
  const gateway5xx = status !== void 0 && status >= 500 && status <= 599;
2659
2952
  const transientGateway = gateway5xx || isTransientGatewayText(haystack);
2660
- if (!limited && !transientGateway) return;
2953
+ if (!limited && !transientGateway && !auth) return;
2661
2954
  const model = `${info.providerID ?? "?"}/${info.modelID ?? "?"}`;
2662
2955
  if (info.sessionID && isProbe(s, info.sessionID)) {
2663
2956
  await log("debug", `probe of ${model} hit a limit (${isLimitText(haystack) ? "limit" : "transient"}); leaving the cooldown to the probe`);
2664
2957
  return;
2665
2958
  }
2666
2959
  const ownMsg = claimFor(info.sessionID);
2667
- if (isUnavailableText(haystack) || !limited && transientGateway) {
2960
+ if (isUnavailableText(haystack) || !limited && !auth && transientGateway) {
2668
2961
  const transient = coolUnavailable(s, model);
2669
2962
  if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
2670
2963
  await failOver(info.sessionID, model, transient?.reason ?? UNAVAILABLE_REASON, { cool: false });
2671
2964
  return;
2672
2965
  }
2673
- const stated = statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
2674
- const reason = limitReason(haystack);
2966
+ const stated = auth ? AUTH_FAILURE_COOLDOWN_MS : statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
2967
+ const reason = auth ? AUTH_FAILURE_REASON : limitReason(haystack);
2675
2968
  const strike = recordStrike(s, model, reason ?? "rate-limited", stated);
2676
2969
  if (strike) await log("warn", `cooling ${model} for ${Math.round((strike.until - Date.now()) / 1e3)}s (${reason ?? "rate-limited"}, strike ${strike.strikes})`);
2677
2970
  await failOver(info.sessionID, model, reason ?? "rate-limited", { stated, cool: !ownMsg });
@@ -2712,7 +3005,7 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2712
3005
  weight: slot.weight,
2713
3006
  model: slot.model,
2714
3007
  ceiling: slotCeiling(slot),
2715
- load: (snap.counts.get(slot.model) ?? 0) + 1
3008
+ load: (snap.counts.get(slot.index) ?? 0) + 1
2716
3009
  });
2717
3010
  output.args.subagent_type = variant;
2718
3011
  acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
@@ -2729,9 +3022,21 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2729
3022
  await log("error", `routing failed, spawn unchanged: ${String(error)}`);
2730
3023
  }
2731
3024
  },
2732
- "tool.execute.after": async (input) => {
3025
+ "tool.execute.after": async (input, output) => {
2733
3026
  if (input.tool !== "task") return;
2734
- release(s, claimKey(input.sessionID, input.callID));
3027
+ const key = claimKey(input.sessionID, input.callID);
3028
+ release(s, key);
3029
+ const report = abortReports.get(key);
3030
+ if (!report) return;
3031
+ abortReports.delete(key);
3032
+ const detail = [
3033
+ `pool spawn: ${report.message}`,
3034
+ `slot ${report.slot}; model ${report.model}; elapsed ${report.elapsedMs}ms; failure ${report.kind}; claim ${report.released ? "released" : "held"}`
3035
+ ].join("\n");
3036
+ const text = typeof output.output === "string" ? output.output : "";
3037
+ output.output = /task\s+cancel+ed/i.test(text) ? detail : text ? `${text}
3038
+
3039
+ ${detail}` : detail;
2735
3040
  },
2736
3041
  tool: {
2737
3042
  pool_recheck: tool({
@@ -2754,18 +3059,18 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2754
3059
  const snap = snapshot(s);
2755
3060
  const pass = poolPass(s, snap);
2756
3061
  const now = Date.now();
2757
- const byModel = /* @__PURE__ */ new Map();
2758
- const add = (model, base) => {
2759
- const entry = byModel.get(model) ?? { total: 0, bases: /* @__PURE__ */ new Map() };
3062
+ const bySlot = /* @__PURE__ */ new Map();
3063
+ const add = (k, base) => {
3064
+ const entry = bySlot.get(k) ?? { total: 0, bases: /* @__PURE__ */ new Map() };
2760
3065
  entry.total++;
2761
3066
  entry.bases.set(base, (entry.bases.get(base) ?? 0) + 1);
2762
- byModel.set(model, entry);
3067
+ bySlot.set(k, entry);
2763
3068
  };
2764
- for (const claim of snap.own.values()) add(claim.m, claim.v);
2765
- for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.m, claim.v);
3069
+ for (const claim of snap.own.values()) add(claim.k, claim.v);
3070
+ for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.k, claim.v);
2766
3071
  const next = pickSlot(s, snap);
2767
3072
  const line = (slot) => {
2768
- const entry = byModel.get(slot.model);
3073
+ const entry = bySlot.get(slot.index);
2769
3074
  const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
2770
3075
  const strike = snap.cooling.get(slot.model);
2771
3076
  const status = s.catalog.get(slot.model);
@@ -2780,14 +3085,25 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
2780
3085
  };
2781
3086
  const total = [...snap.counts.values()].reduce((sum, n) => sum + n, 0);
2782
3087
  const coolingNow = [...snap.cooling.entries()].filter(([, st]) => st.until > now);
3088
+ const buckets = /* @__PURE__ */ new Map();
3089
+ for (const slot of slots) {
3090
+ const name = slotBucket(slot);
3091
+ const entry = buckets.get(name) ?? { seats: 0, slots: 0, cooling: false };
3092
+ entry.seats += slot.weight;
3093
+ entry.slots++;
3094
+ if (snap.cooling.has(slot.model)) entry.cooling = true;
3095
+ buckets.set(name, entry);
3096
+ }
3097
+ const sharedBuckets = [...buckets].filter(([, entry]) => entry.slots > 1);
2783
3098
  const verdicts = [...snap.own.values()].map((claim) => condemnReason(s, claim, now));
2784
3099
  const hung = verdicts.filter((verdict) => verdict === "hung").length;
2785
3100
  const overran = verdicts.filter((verdict) => verdict === "overran").length;
2786
3101
  return {
2787
3102
  title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
2788
3103
  output: [
2789
- `shared pool over ${slots.length} model(s) from ${s.origin}; pass ${pass}; bases: ${BASES.join(", ")}
2790
- two ordered passes in slot order: pass 1 fills each slot to its weight, pass 2 to its ceiling, then overflow goes to the least-loaded slot (FULL = at ceiling, used only when no other slot is available and under its ceiling)`,
3104
+ `shared pool over ${slots.length} slot(s) from ${s.origin}; state ${pass}; bases: ${BASES.join(", ")}
3105
+ one ordered pass in slot order: each slot fills to its weight, then overflow goes to the least-loaded slot. Capacity is the sum of the weights; repeat a model on another line to give it more (FULL = at weight).`,
3106
+ ...sharedBuckets.length ? [`buckets: ${sharedBuckets.map(([name, entry]) => `${name} ${entry.seats} seat(s)${entry.cooling ? " COOLING" : ""}`).join("; ")}`] : [],
2791
3107
  ...slots.map(line),
2792
3108
  "",
2793
3109
  "by process:",
@@ -2823,9 +3139,9 @@ two ordered passes in slot order: pass 1 fills each slot to its weight, pass 2 t
2823
3139
  });
2824
3140
 
2825
3141
  // src/plugins/context-autoupdate.ts
2826
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync5, statSync as statSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3 } from "node:fs";
3142
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync6, statSync as statSync4, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4 } from "node:fs";
2827
3143
  import { homedir as homedir3 } from "node:os";
2828
- import * as path2 from "node:path";
3144
+ import * as path3 from "node:path";
2829
3145
 
2830
3146
  // src/lib/active-backlog.ts
2831
3147
  var ACTIVE_BACKLOG_FILE = "active.md";
@@ -2922,10 +3238,498 @@ var normalizeBacklog = (text, now = Date.now()) => {
2922
3238
  continue;
2923
3239
  }
2924
3240
  }
2925
- kept.push(entry);
3241
+ kept.push(entry);
3242
+ }
3243
+ return { text: renderBacklog(kept), removed };
3244
+ };
3245
+
3246
+ // src/lib/map-schema.ts
3247
+ import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
3248
+ import * as path from "node:path";
3249
+ var PROVISIONAL_DAYS = 30;
3250
+ var MAP_SPECS = [
3251
+ {
3252
+ file: "architecture.md",
3253
+ charter: "cross-subsystem structure: layers, boundaries, entry points, dependency rules",
3254
+ budget: 150,
3255
+ minEntries: 4,
3256
+ kind: "entries",
3257
+ provisionalDays: PROVISIONAL_DAYS,
3258
+ sectionRequired: true
3259
+ },
3260
+ {
3261
+ file: "contexts.md",
3262
+ charter: "subsystem boundaries: purpose, start-here files, what must not change",
3263
+ budget: 200,
3264
+ minEntries: 3,
3265
+ kind: "entries",
3266
+ provisionalDays: PROVISIONAL_DAYS,
3267
+ sectionRequired: true
3268
+ },
3269
+ {
3270
+ file: "conventions.md",
3271
+ charter: "rules to follow before writing code",
3272
+ budget: 150,
3273
+ minEntries: 3,
3274
+ kind: "entries",
3275
+ provisionalDays: PROVISIONAL_DAYS,
3276
+ sectionRequired: true
3277
+ },
3278
+ {
3279
+ file: "workflows.md",
3280
+ charter: "runtime behaviour worth tracing",
3281
+ budget: 150,
3282
+ minEntries: 2,
3283
+ kind: "entries",
3284
+ provisionalDays: PROVISIONAL_DAYS,
3285
+ sectionRequired: true
3286
+ },
3287
+ {
3288
+ file: "decisions.md",
3289
+ charter: "architectural constraints that bind future work",
3290
+ budget: 120,
3291
+ minEntries: 1,
3292
+ kind: "entries",
3293
+ provisionalDays: PROVISIONAL_DAYS,
3294
+ sectionRequired: true
3295
+ },
3296
+ {
3297
+ file: ACTIVE_BACKLOG_FILE,
3298
+ charter: "work in flight, grouped by the ISO week it was opened",
3299
+ budget: 60,
3300
+ minEntries: 0,
3301
+ kind: "backlog",
3302
+ provisionalDays: PROVISIONAL_DAYS,
3303
+ sectionRequired: false
3304
+ }
3305
+ ];
3306
+ var MAP_SPEC_BY_FILE = new Map(MAP_SPECS.map((spec) => [spec.file, spec]));
3307
+ var MAP_FILES = MAP_SPECS.map((spec) => spec.file);
3308
+ var NAVIGATION_FILES = MAP_SPECS.filter((spec) => spec.kind === "entries").map((spec) => spec.file);
3309
+ var DAY_MS2 = 24 * 60 * 60 * 1e3;
3310
+ var ENTRY_START = /^([-*+])\s+\[(binding|provisional|disputed)\]\s+(.*)$/i;
3311
+ var CONTINUATION = /^\s{2,}\S/;
3312
+ var LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
3313
+ var FENCE = /^\s*(?:```|~~~)/;
3314
+ var TABLE_ROW = /^\s*\|.*\|\s*$/;
3315
+ var TABLE_SEPARATOR = /^\s*\|[\s:|-]*-{3,}[\s:|-]*\|\s*$/;
3316
+ var VERIFIED = /\(verified\s+(\d{4}-\d{2}-\d{2})\)\s*$/i;
3317
+ var POINTER = /(?:—|–|→|->)\s*See\s+`([^`\n]+)`(?:\s*§\s*([^()\n]+?))?(?:\s*\(verified\s+(\d{4}-\d{2}-\d{2})\))?\s*$/i;
3318
+ var DECISION_RECORD = /^\s*(?:#{1,6}\s*)?(?:\*\*)?(Context|Decision|Consequences|Alternatives|Status|Trade-?offs|Rationale)(?:\*\*)?\s*(?::|$)/im;
3319
+ var ARCHIVE_LANGUAGE = /\b(?:archive\s+of\s+record|this\s+(?:is|file\s+is|map\s+is)\s+the\s+only\s+(?:copy|record)|the\s+only\s+place\s+[^.\n]{0,80}?\b(?:remains|survives)\b|the\s+only\s+(?:copy|record)\s+[^.\n]{0,80}?\b(?:remains|survives|is\s+kept)\b|(?:survives|remains|is\s+kept)\s+only\s+here|no\s+longer\s+exists\s+(?:in|anywhere)|exists\s+only\s+(?:in|here))\b/i;
3320
+ var isCalendarDate2 = (value) => {
3321
+ const at = Date.parse(`${value}T00:00:00Z`);
3322
+ return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === value;
3323
+ };
3324
+ var normalizeHeading = (value) => value.toLowerCase().replace(/[`*_]/g, "").replace(/^\s*\d+(?:\.\d+)*[.)]?\s*/, "").replace(/[^a-z0-9]+/g, " ").trim();
3325
+ var normalizeRule = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
3326
+ var truncate = (value, max = 90) => value.length <= max ? value : `${value.slice(0, max - 1)}\u2026`;
3327
+ var parseMapEntries = (text) => {
3328
+ const lines = text.split(/\r?\n/);
3329
+ const entries = [];
3330
+ let inFence = false;
3331
+ for (let i = 0; i < lines.length; i++) {
3332
+ if (FENCE.test(lines[i])) {
3333
+ inFence = !inFence;
3334
+ continue;
3335
+ }
3336
+ if (inFence) continue;
3337
+ const start = ENTRY_START.exec(lines[i]);
3338
+ if (!start) continue;
3339
+ const bodyLines = [start[3]];
3340
+ let j = i + 1;
3341
+ while (j < lines.length && CONTINUATION.test(lines[j]) && !FENCE.test(lines[j])) {
3342
+ bodyLines.push(lines[j].trim());
3343
+ j++;
3344
+ }
3345
+ const body = bodyLines.join("\n");
3346
+ const flat = body.replace(/\s+/g, " ").trim();
3347
+ const pointerMatch = POINTER.exec(flat);
3348
+ const verifiedMatch = VERIFIED.exec(flat);
3349
+ const pointer = pointerMatch?.[1]?.trim();
3350
+ const section = pointerMatch?.[2]?.trim() || void 0;
3351
+ const verified = pointerMatch?.[3] ?? verifiedMatch?.[1];
3352
+ const rule = flat.replace(POINTER, "").replace(VERIFIED, "").trim();
3353
+ entries.push({
3354
+ startLine: i,
3355
+ endLine: j,
3356
+ marker: start[2].toLowerCase(),
3357
+ body,
3358
+ rule,
3359
+ lineCount: bodyLines.filter((line) => line.trim()).length,
3360
+ ...pointer ? { pointer } : {},
3361
+ ...section ? { section } : {},
3362
+ ...verified ? { verified } : {}
3363
+ });
3364
+ i = j - 1;
3365
+ }
3366
+ return entries;
3367
+ };
3368
+ var stripLineSuffix = (pointer) => pointer.replace(/[\\/]$/, "").replace(/(?::\d+(?:-\d+)?|#L\d+(?:-L?\d+)?)$/i, "");
3369
+ var headingOf = (line) => {
3370
+ const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
3371
+ if (!match) return void 0;
3372
+ return { level: match[1].length, text: match[2] };
3373
+ };
3374
+ var sectionText = (text, section) => {
3375
+ const lines = text.split(/\r?\n/);
3376
+ const wanted = normalizeHeading(section);
3377
+ if (!wanted) return void 0;
3378
+ for (let i = 0; i < lines.length; i++) {
3379
+ const heading = headingOf(lines[i]);
3380
+ if (!heading) continue;
3381
+ const normalized = normalizeHeading(heading.text);
3382
+ if (!(normalized === wanted || normalized.includes(wanted) || wanted.includes(normalized))) continue;
3383
+ let end = lines.length;
3384
+ for (let j = i + 1; j < lines.length; j++) {
3385
+ const next = headingOf(lines[j]);
3386
+ if (next && next.level <= heading.level) {
3387
+ end = j;
3388
+ break;
3389
+ }
3390
+ }
3391
+ return lines.slice(i + 1, end).join("\n");
3392
+ }
3393
+ return void 0;
3394
+ };
3395
+ var resolvePointer = (root, pointer, section) => {
3396
+ const cleaned = stripLineSuffix(pointer.trim().replace(/^`|`$/g, ""));
3397
+ if (!cleaned) return { exists: false };
3398
+ const absolute = path.resolve(root, cleaned);
3399
+ const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
3400
+ if (absolute !== root && !absolute.startsWith(rootWithSep)) return { exists: false };
3401
+ let stat;
3402
+ try {
3403
+ stat = statSync3(absolute);
3404
+ } catch {
3405
+ return { exists: false };
3406
+ }
3407
+ if (!stat.isFile()) return { exists: false };
3408
+ let text;
3409
+ try {
3410
+ if (stat.size <= 512 * 1024) text = readFileSync4(absolute, "utf8");
3411
+ } catch {
3412
+ text = void 0;
3413
+ }
3414
+ if (section && text !== void 0) {
3415
+ const found = sectionText(text, section);
3416
+ return {
3417
+ absolute,
3418
+ exists: true,
3419
+ mtimeMs: stat.mtimeMs,
3420
+ sectionFound: found !== void 0,
3421
+ ...found !== void 0 ? { sectionText: found } : {},
3422
+ sourceText: found ?? text
3423
+ };
3424
+ }
3425
+ return { absolute, exists: true, mtimeMs: stat.mtimeMs, ...text !== void 0 ? { sourceText: text } : {} };
3426
+ };
3427
+ var words = (value) => value.toLowerCase().replace(/[`*_[\]()]/g, " ").split(/[^a-z0-9]+/).filter((word) => word.length > 0);
3428
+ var STOPWORDS = /* @__PURE__ */ new Set([
3429
+ "the",
3430
+ "and",
3431
+ "for",
3432
+ "with",
3433
+ "that",
3434
+ "this",
3435
+ "from",
3436
+ "are",
3437
+ "was",
3438
+ "were",
3439
+ "has",
3440
+ "have",
3441
+ "had",
3442
+ "not",
3443
+ "never",
3444
+ "none",
3445
+ "into",
3446
+ "onto",
3447
+ "than",
3448
+ "then",
3449
+ "when",
3450
+ "where",
3451
+ "which",
3452
+ "what",
3453
+ "who",
3454
+ "will",
3455
+ "would",
3456
+ "can",
3457
+ "could",
3458
+ "must",
3459
+ "may",
3460
+ "might",
3461
+ "its",
3462
+ "their",
3463
+ "your",
3464
+ "our",
3465
+ "you",
3466
+ "they",
3467
+ "them",
3468
+ "there",
3469
+ "here",
3470
+ "only",
3471
+ "also",
3472
+ "but",
3473
+ "all",
3474
+ "any",
3475
+ "each",
3476
+ "every",
3477
+ "more",
3478
+ "most",
3479
+ "some",
3480
+ "such",
3481
+ "one",
3482
+ "two",
3483
+ "use",
3484
+ "used",
3485
+ "using",
3486
+ "via",
3487
+ "per",
3488
+ "out",
3489
+ "own"
3490
+ ]);
3491
+ var contentWords = (value) => words(value).filter((word) => word.length >= 4 && !STOPWORDS.has(word));
3492
+ var ngrams = (values, size) => {
3493
+ const out = /* @__PURE__ */ new Set();
3494
+ for (let i = 0; i + size <= values.length; i++) out.add(values.slice(i, i + size).join(" "));
3495
+ return out;
3496
+ };
3497
+ var hasNegation = (value) => /\b(?:not|never|none|no\s+longer|refuted|unsupported|absent|missing|removed|without)\b/i.test(value);
3498
+ var STATE_AUXILIARY = /\b(?:is|are|was|were|has|have|had|does|do|can|could)\b/i;
3499
+ var numbersIn = (value) => (value.match(/\b\d+(?:[.,]\d+)?\b/g) ?? []).map((number) => number.replace(/,/g, ""));
3500
+ var sharesVerbatimRun = (rule, source) => {
3501
+ const entryWords = words(rule);
3502
+ if (entryWords.length < 12) return false;
3503
+ const sourceGrams = ngrams(words(source), 12);
3504
+ for (const gram of ngrams(entryWords, 12)) if (sourceGrams.has(gram)) return true;
3505
+ return false;
3506
+ };
3507
+ var possibleNegationConflict = (rule, source) => {
3508
+ if (hasNegation(rule)) return void 0;
3509
+ const entryWords = new Set(contentWords(rule));
3510
+ if (entryWords.size < 3) return void 0;
3511
+ const sentences = source.split(/(?:[.!?]\s+|\n+)/);
3512
+ for (const sentence of sentences) {
3513
+ if (!hasNegation(sentence) || !STATE_AUXILIARY.test(sentence)) continue;
3514
+ const sentenceWords = new Set(contentWords(sentence));
3515
+ if (sentenceWords.size === 0) continue;
3516
+ let overlap = 0;
3517
+ for (const word of entryWords) if (sentenceWords.has(word)) overlap++;
3518
+ const ratio = overlap / Math.min(entryWords.size, sentenceWords.size);
3519
+ if (overlap >= 3 && ratio >= 0.5) return truncate(sentence.trim());
3520
+ }
3521
+ return void 0;
3522
+ };
3523
+ var invalidNumbers = (rule, source) => {
3524
+ const claimed = [...new Set(numbersIn(rule))];
3525
+ if (claimed.length === 0) return [];
3526
+ const sourceNumbers = new Set(numbersIn(source));
3527
+ return claimed.filter((number) => !sourceNumbers.has(number));
3528
+ };
3529
+ var validateMapText = (spec, text, options) => {
3530
+ const violations = [];
3531
+ const lines = text.split(/\r?\n/);
3532
+ const nonBlank = lines.filter((line) => line.trim()).length;
3533
+ const push = (kind, detail, line) => violations.push({ file: spec.file, kind, detail, ...line !== void 0 ? { line: line + 1 } : {} });
3534
+ if (nonBlank === 0) push("charter", `empty file; charter is ${spec.charter}`);
3535
+ if (nonBlank > spec.budget) {
3536
+ push("budget", `${nonBlank} non-blank lines exceeds the ${spec.budget}-line budget; consolidate, do not pad`);
3537
+ }
3538
+ if (spec.kind === "backlog") {
3539
+ if (!text.startsWith("# ")) push("charter", "active backlog must start with a top-level heading");
3540
+ return { spec, lines: nonBlank, entries: [], violations };
3541
+ }
3542
+ const entries = parseMapEntries(text);
3543
+ let inFence = false;
3544
+ let tableReported = false;
3545
+ for (let i = 0; i < lines.length; i++) {
3546
+ if (FENCE.test(lines[i])) {
3547
+ if (!inFence) push("restatement", "fenced code block; the map cites the file that owns the code, it does not copy it", i);
3548
+ inFence = !inFence;
3549
+ continue;
3550
+ }
3551
+ if (!inFence && !tableReported && (TABLE_ROW.test(lines[i]) || TABLE_SEPARATOR.test(lines[i]))) {
3552
+ push("restatement", "table; a table belongs on the page that owns it, not in the map", i);
3553
+ tableReported = true;
3554
+ }
3555
+ }
3556
+ const record = DECISION_RECORD.exec(text);
3557
+ if (record) {
3558
+ push("schema", `decision-record block (${record[1]}); state only the operative constraint and its pointer`);
3559
+ }
3560
+ if (ARCHIVE_LANGUAGE.test(text)) {
3561
+ push("archive", "the map claims to be an archive or the only copy; derived content must never be the archive of record");
3562
+ }
3563
+ const entryStarts = new Set(entries.map((entry) => entry.startLine));
3564
+ inFence = false;
3565
+ for (let i = 0; i < lines.length; i++) {
3566
+ if (FENCE.test(lines[i])) {
3567
+ inFence = !inFence;
3568
+ continue;
3569
+ }
3570
+ if (!inFence && LIST_ITEM.test(lines[i]) && !entryStarts.has(i)) {
3571
+ push("schema", "unmarked list item; every entry starts with - [binding], - [provisional] or - [disputed]", i);
3572
+ }
3573
+ }
3574
+ let binding = 0;
3575
+ for (const entry of entries) {
3576
+ const line = entry.startLine;
3577
+ if (entry.marker === "binding") binding++;
3578
+ if (entry.lineCount > 3) {
3579
+ push("schema", `entry is ${entry.lineCount} lines; the rule must fit in one to three`, line);
3580
+ }
3581
+ if (!entry.verified) {
3582
+ push("schema", "entry has no (verified YYYY-MM-DD); the lifecycle and drift checks need it", line);
3583
+ } else if (!isCalendarDate2(entry.verified)) {
3584
+ push("schema", `verified date ${entry.verified} is not a real calendar date`, line);
3585
+ } else if (Date.parse(`${entry.verified}T00:00:00Z`) > (options.now ?? Date.now()) + DAY_MS2) {
3586
+ push("schema", `verified date ${entry.verified} is more than a day in the future`, line);
3587
+ }
3588
+ if (!entry.pointer) {
3589
+ push("schema", "entry has no `\u2014 See <path>` pointer; a fact with no home is not a map entry", line);
3590
+ push("unarchived", "content has no resolvable source in the tree; report it, do not emit it as derived", line);
3591
+ continue;
3592
+ }
3593
+ if (spec.sectionRequired && /\.md$/i.test(stripLineSuffix(entry.pointer)) && !entry.section) {
3594
+ push("schema", `page citation \`${entry.pointer}\` needs a \xA7section; a page is cited, not restated`, line);
3595
+ }
3596
+ const resolved = resolvePointer(options.root, entry.pointer, entry.section);
3597
+ if (!resolved.exists) {
3598
+ push("dangling", `cites \`${entry.pointer}\`, which does not exist in the tree`, line);
3599
+ push("unarchived", `no source for \`${entry.pointer}\`; keep the text and report it, never delete it`, line);
3600
+ continue;
3601
+ }
3602
+ if (entry.section && resolved.sectionFound === false) {
3603
+ push("drift", `cites \xA7${entry.section} in \`${entry.pointer}\`, but that section no longer exists`, line);
3604
+ continue;
3605
+ }
3606
+ if (resolved.mtimeMs !== void 0 && entry.verified) {
3607
+ const modified = new Date(resolved.mtimeMs).toISOString().slice(0, 10);
3608
+ if (modified > entry.verified) {
3609
+ push("drift", `\`${entry.pointer}\` changed on ${modified}, after this entry was verified on ${entry.verified}; re-verify it`, line);
3610
+ }
3611
+ }
3612
+ if (resolved.sourceText !== void 0) {
3613
+ const missing = invalidNumbers(entry.rule, resolved.sourceText);
3614
+ if (missing.length > 0) {
3615
+ push("drift", `numeric claim(s) ${missing.join(", ")} not found in \`${entry.pointer}\`; the map and the source may disagree`, line);
3616
+ }
3617
+ if (sharesVerbatimRun(entry.rule, resolved.sourceText)) {
3618
+ push("restatement", `contains a long verbatim span from \`${entry.pointer}\`; cite the section instead`, line);
3619
+ }
3620
+ const conflict = possibleNegationConflict(entry.rule, resolved.sourceText);
3621
+ if (conflict) {
3622
+ push("drift", `possible contradiction with \`${entry.pointer}\`: "${conflict}"; verify, do not silently overwrite either side`, line);
3623
+ }
3624
+ }
3625
+ }
3626
+ if (entries.length < spec.minEntries) {
3627
+ push("charter", `${entries.length} entr${entries.length === 1 ? "y" : "ies"} is degenerate for ${spec.charter}`);
3628
+ } else if (entries.length > 0 && binding === 0) {
3629
+ push("charter", "every entry is provisional or disputed; a navigation file must carry at least one binding constraint");
3630
+ }
3631
+ return { spec, lines: nonBlank, entries, violations };
3632
+ };
3633
+ var normalizeMapText = (spec, text, options = {}) => {
3634
+ const now = options.now ?? Date.now();
3635
+ if (spec.kind === "backlog") {
3636
+ const result = normalizeBacklog(text, now);
3637
+ return {
3638
+ text: result.text,
3639
+ removed: result.removed.map((title) => ({ line: 0, text: title })),
3640
+ kept: 0
3641
+ };
3642
+ }
3643
+ const entries = parseMapEntries(text);
3644
+ const drop = /* @__PURE__ */ new Set();
3645
+ const removed = [];
3646
+ for (const entry of entries) {
3647
+ if (entry.marker !== "provisional") continue;
3648
+ if (!entry.verified || !entry.pointer) continue;
3649
+ if (options.canResolve && !options.canResolve(entry.pointer, entry.section)) continue;
3650
+ const at = Date.parse(`${entry.verified}T00:00:00Z`);
3651
+ if (!Number.isFinite(at)) continue;
3652
+ if (now - at <= spec.provisionalDays * DAY_MS2) continue;
3653
+ for (let i = entry.startLine; i < entry.endLine; i++) drop.add(i);
3654
+ removed.push({ line: entry.startLine + 1, text: truncate(entry.rule) });
3655
+ }
3656
+ if (removed.length === 0) return { text, removed, kept: entries.length };
3657
+ const keptLines = text.split(/\r?\n/).filter((_, index) => !drop.has(index));
3658
+ const compact = keptLines.join("\n").replace(/\n{3,}/g, "\n\n");
3659
+ const out = compact.endsWith("\n") ? compact : `${compact}
3660
+ `;
3661
+ return { text: out, removed, kept: entries.length - removed.length };
3662
+ };
3663
+ var maintainMap = (root, mapDir, now = Date.now()) => {
3664
+ const files = [];
3665
+ let removedTotal = 0;
3666
+ for (const spec of MAP_SPECS) {
3667
+ const absolute = path.join(mapDir, spec.file);
3668
+ if (!existsSync2(absolute)) continue;
3669
+ let before;
3670
+ try {
3671
+ before = readFileSync4(absolute, "utf8");
3672
+ } catch {
3673
+ continue;
3674
+ }
3675
+ const canResolve = (pointer, section) => resolvePointer(root, pointer, section).exists;
3676
+ const result = normalizeMapText(spec, before, { now, canResolve });
3677
+ const changed = result.text !== before;
3678
+ if (changed) writeFileSync3(absolute, result.text);
3679
+ if (result.removed.length > 0) {
3680
+ removedTotal += result.removed.length;
3681
+ files.push({ file: spec.file, changed, removed: result.removed });
3682
+ } else if (changed) {
3683
+ files.push({ file: spec.file, changed, removed: [] });
3684
+ }
3685
+ }
3686
+ return { files, removedTotal };
3687
+ };
3688
+ var auditMap = (root, mapDir, now = Date.now()) => {
3689
+ const files = [];
3690
+ const violations = [];
3691
+ const rulesSeen = /* @__PURE__ */ new Map();
3692
+ for (const spec of MAP_SPECS) {
3693
+ const absolute = path.join(mapDir, spec.file);
3694
+ if (!existsSync2(absolute)) {
3695
+ const violation = { file: spec.file, kind: "missing-file", detail: `missing; charter is ${spec.charter}` };
3696
+ violations.push(violation);
3697
+ continue;
3698
+ }
3699
+ let text;
3700
+ try {
3701
+ text = readFileSync4(absolute, "utf8");
3702
+ } catch {
3703
+ violations.push({ file: spec.file, kind: "missing-file", detail: "unreadable" });
3704
+ continue;
3705
+ }
3706
+ const result = validateMapText(spec, text, { root, now });
3707
+ files.push(result);
3708
+ violations.push(...result.violations);
3709
+ if (spec.kind === "entries") {
3710
+ for (const entry of result.entries) {
3711
+ const key = normalizeRule(entry.rule);
3712
+ if (key.length < 12) continue;
3713
+ const home = rulesSeen.get(key);
3714
+ if (home !== void 0 && home !== spec.file) {
3715
+ violations.push({
3716
+ file: spec.file,
3717
+ line: entry.startLine + 1,
3718
+ kind: "duplicate",
3719
+ detail: `same rule is already stated in ${home}; one fact has one home`
3720
+ });
3721
+ } else if (home === void 0) {
3722
+ rulesSeen.set(key, spec.file);
3723
+ }
3724
+ }
3725
+ }
2926
3726
  }
2927
- return { text: renderBacklog(kept), removed };
3727
+ return { ok: violations.length === 0, files, violations };
2928
3728
  };
3729
+ var formatViolations = (violations, limit = 20) => violations.slice(0, limit).map((violation) => {
3730
+ const where = violation.line !== void 0 ? `${violation.file}:${violation.line}` : violation.file;
3731
+ return `${where} [${violation.kind}] ${violation.detail}`;
3732
+ });
2929
3733
 
2930
3734
  // src/lib/jsonc.ts
2931
3735
  var stripJsonc = (raw) => {
@@ -2975,12 +3779,12 @@ var parseJsonc = (raw) => {
2975
3779
  };
2976
3780
 
2977
3781
  // src/lib/writer-rule.ts
2978
- import { readFileSync as readFileSync4 } from "node:fs";
2979
- import * as path from "node:path";
2980
- var CONTEXT_DIR = path.join(".opencode", "context");
3782
+ import { readFileSync as readFileSync5 } from "node:fs";
3783
+ import * as path2 from "node:path";
3784
+ var CONTEXT_DIR = path2.join(".opencode", "context");
2981
3785
  var WRITER = "context";
2982
3786
  var INSTRUCTIONS_WRITER = AGENT_INSTRUCTIONS_WRITER;
2983
- var toPosix = (value) => value.split(path.sep).join("/");
3787
+ var toPosix = (value) => value.split(path2.sep).join("/");
2984
3788
  var compilePattern = (pattern) => {
2985
3789
  const escaped = toPosix(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
2986
3790
  return new RegExp(`^${escaped}$`, "s");
@@ -3005,17 +3809,17 @@ var readAgents = (raw) => {
3005
3809
  };
3006
3810
  var permissionOf = (agent) => agent?.permission?.edit;
3007
3811
  var evaluatedForm = (mapDir, projectRoot) => {
3008
- const absolute = toPosix(path.join(mapDir, "architecture.md"));
3009
- return projectRoot ? toPosix(path.relative(projectRoot, absolute)) : absolute;
3812
+ const absolute = toPosix(path2.join(mapDir, "architecture.md"));
3813
+ return projectRoot ? toPosix(path2.relative(projectRoot, absolute)) : absolute;
3010
3814
  };
3011
3815
  var agentsForm = (mapDir, projectRoot) => {
3012
- const absolute = toPosix(path.join(mapDir, "..", "..", "AGENTS.md"));
3013
- return projectRoot ? toPosix(path.relative(projectRoot, absolute)) : absolute;
3816
+ const absolute = toPosix(path2.join(mapDir, "..", "..", "AGENTS.md"));
3817
+ return projectRoot ? toPosix(path2.relative(projectRoot, absolute)) : absolute;
3014
3818
  };
3015
3819
  var verifyWriterRule = (configPath, mapDir, projectRoot) => {
3016
3820
  let raw;
3017
3821
  try {
3018
- raw = readFileSync4(configPath, "utf8");
3822
+ raw = readFileSync5(configPath, "utf8");
3019
3823
  } catch {
3020
3824
  return void 0;
3021
3825
  }
@@ -3028,7 +3832,7 @@ var verifyWriterRule = (configPath, mapDir, projectRoot) => {
3028
3832
  const form = evaluatedForm(mapDir, projectRoot);
3029
3833
  if (rules.length > 0) {
3030
3834
  if (!rules.some((rule) => compilePattern(rule).test(form))) {
3031
- return `context map is not writable: none of the allow rules in ${path.basename(configPath)} match ` + (projectRoot ? `the project-root-relative form "${form}" that opencode actually evaluates for project root ${toPosix(projectRoot)}` : `the absolute form "${form}"`) + `. Rules seen: ${rules.map((r) => `"${r}"`).join(", ")}. opencode evaluates file permissions against the path RELATIVE TO THE PROJECT ROOT, so "${CONTEXT_DIR}/**" is what fires for a normally-rooted project while "*/${CONTEXT_DIR}/**" only fires when the project root is "/". Allow both forms.`;
3835
+ return `context map is not writable: none of the allow rules in ${path2.basename(configPath)} match ` + (projectRoot ? `the project-root-relative form "${form}" that opencode actually evaluates for project root ${toPosix(projectRoot)}` : `the absolute form "${form}"`) + `. Rules seen: ${rules.map((r) => `"${r}"`).join(", ")}. opencode evaluates file permissions against the path RELATIVE TO THE PROJECT ROOT, so "${CONTEXT_DIR}/**" is what fires for a normally-rooted project while "*/${CONTEXT_DIR}/**" only fires when the project root is "/". Allow both forms.`;
3032
3836
  }
3033
3837
  }
3034
3838
  const agents = readAgents(raw);
@@ -3061,7 +3865,7 @@ var verifyWriterRule = (configPath, mapDir, projectRoot) => {
3061
3865
 
3062
3866
  // src/plugins/context-autoupdate.ts
3063
3867
  var SERVICE2 = "context-autoupdate";
3064
- var CONTEXT_DIR2 = path2.join(".opencode", "context");
3868
+ var CONTEXT_DIR2 = path3.join(".opencode", "context");
3065
3869
  var COOLDOWN_MS = 10 * 60 * 1e3;
3066
3870
  var OWNED_TTL_MS = 20 * 60 * 1e3;
3067
3871
  var MAX_FILES_LISTED = 25;
@@ -3098,13 +3902,19 @@ var NO_BULK_READS = [
3098
3902
  "that PRODUCES or CONSUMES them instead. If a payload must be sampled, use",
3099
3903
  "`head -c 2000` or `wc -c` \u2014 never `cat` or a bare `grep -r` across one."
3100
3904
  ].join(" ");
3101
- var toPosix2 = (value) => value.split(path2.sep).join("/");
3905
+ var ENTRY_CONTRACT = [
3906
+ "Every navigation entry uses the schema in your system prompt:",
3907
+ "`- [binding|provisional|disputed] <rule, at most three lines> \u2014 See `<path>`[ \xA7Section] (verified YYYY-MM-DD)`.",
3908
+ "Stay inside each file's line budget. Point at the page or path that owns a fact; do not restate it.",
3909
+ "After writing, run the drift pass: report a contradiction, never overwrite the page or silently pick a side."
3910
+ ].join(" ");
3911
+ var toPosix2 = (value) => value.split(path3.sep).join("/");
3102
3912
  var findProjectConfig = () => {
3103
- let dir = import.meta.dir ?? path2.dirname(new URL(import.meta.url).pathname);
3913
+ let dir = import.meta.dir ?? path3.dirname(new URL(import.meta.url).pathname);
3104
3914
  for (let up = 0; up <= 3; up++) {
3105
- const candidate = path2.join(dir, "opencode.jsonc");
3106
- if (existsSync2(candidate)) return candidate;
3107
- const parent = path2.dirname(dir);
3915
+ const candidate = path3.join(dir, "opencode.jsonc");
3916
+ if (existsSync3(candidate)) return candidate;
3917
+ const parent = path3.dirname(dir);
3108
3918
  if (parent === dir) break;
3109
3919
  dir = parent;
3110
3920
  }
@@ -3114,9 +3924,9 @@ var snapshotMap = (dir) => {
3114
3924
  const out = /* @__PURE__ */ new Map();
3115
3925
  try {
3116
3926
  for (const name of readdirSync2(dir)) {
3117
- const file = path2.join(dir, name);
3927
+ const file = path3.join(dir, name);
3118
3928
  try {
3119
- const st = statSync3(file);
3929
+ const st = statSync4(file);
3120
3930
  if (st.isFile()) out.set(name, `${st.size}:${st.mtimeMs}`);
3121
3931
  } catch {
3122
3932
  }
@@ -3133,20 +3943,20 @@ var changedSince = (before, dir) => {
3133
3943
  return changed;
3134
3944
  };
3135
3945
  var RETAIN_TRANSCRIPTS = 20;
3136
- var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(homedir3(), ".local", "share", "opencode", "lacode", "cartographer");
3946
+ var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path3.join(homedir3(), ".local", "share", "opencode", "lacode", "cartographer");
3137
3947
  var retireSession = async (client, directory, sessionID, mode, log) => {
3138
3948
  try {
3139
3949
  const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
3140
3950
  const dir = transcriptDir();
3141
3951
  mkdirSync3(dir, { recursive: true });
3142
3952
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3143
- const file = path2.join(dir, `${stamp}-${mode}.json`);
3144
- writeFileSync3(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
3953
+ const file = path3.join(dir, `${stamp}-${mode}.json`);
3954
+ writeFileSync4(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
3145
3955
  pruneTranscripts(dir);
3146
3956
  const gone = async () => await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0) === void 0;
3147
3957
  await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
3148
3958
  if (await gone()) return;
3149
- await new Promise((resolve3) => setTimeout(resolve3, 500));
3959
+ await new Promise((resolve5) => setTimeout(resolve5, 500));
3150
3960
  await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
3151
3961
  if (await gone()) return;
3152
3962
  await log?.("warn", "the cartographer's session could not be removed from the session list; it stays as an empty entry", {
@@ -3162,7 +3972,7 @@ var pruneTranscripts = (dir) => {
3162
3972
  const entries = readdirSync2(dir).filter((name) => name.endsWith(".json")).sort();
3163
3973
  for (const name of entries.slice(0, Math.max(0, entries.length - RETAIN_TRANSCRIPTS))) {
3164
3974
  try {
3165
- unlinkSync3(path2.join(dir, name));
3975
+ unlinkSync3(path3.join(dir, name));
3166
3976
  } catch {
3167
3977
  }
3168
3978
  }
@@ -3176,24 +3986,24 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3176
3986
  const state2 = state(options);
3177
3987
  const log = (level, message, extra) => client.app.log({ body: { service: SERVICE2, level, message, extra } }).catch(() => {
3178
3988
  });
3179
- const root = path2.resolve(directory);
3180
- const contextAbs = path2.join(root, CONTEXT_DIR2);
3181
- const activeBacklogPath = path2.join(contextAbs, ACTIVE_BACKLOG_FILE);
3989
+ const root = path3.resolve(directory);
3990
+ const contextAbs = path3.join(root, CONTEXT_DIR2);
3991
+ const activeBacklogPath = path3.join(contextAbs, ACTIVE_BACKLOG_FILE);
3182
3992
  const config = findProjectConfig();
3183
3993
  const writerProblem = config ? verifyWriterRule(config, contextAbs, root) : void 0;
3184
3994
  if (writerProblem) void log("error", writerProblem);
3185
3995
  const isTracked = (file) => {
3186
- const absolute = path2.resolve(root, file);
3187
- if (absolute !== root && !absolute.startsWith(root + path2.sep)) return false;
3188
- if (absolute === contextAbs || absolute.startsWith(contextAbs + path2.sep)) return false;
3996
+ const absolute = path3.resolve(root, file);
3997
+ if (absolute !== root && !absolute.startsWith(root + path3.sep)) return false;
3998
+ if (absolute === contextAbs || absolute.startsWith(contextAbs + path3.sep)) return false;
3189
3999
  return !toPosix2(absolute).split("/").some((segment) => IGNORED_SEGMENTS.has(segment));
3190
4000
  };
3191
4001
  const normalizeActiveBacklog = async () => {
3192
4002
  try {
3193
- if (!existsSync2(activeBacklogPath)) return { removed: 0 };
3194
- const before = readFileSync5(activeBacklogPath, "utf8");
4003
+ if (!existsSync3(activeBacklogPath)) return { removed: 0 };
4004
+ const before = readFileSync6(activeBacklogPath, "utf8");
3195
4005
  const { text, removed } = normalizeBacklog(before);
3196
- if (text !== before) writeFileSync3(activeBacklogPath, text);
4006
+ if (text !== before) writeFileSync4(activeBacklogPath, text);
3197
4007
  if (removed.length > 0) {
3198
4008
  await log("info", `active backlog: dropped ${removed.length} task(s) finished more than a week ago`, {
3199
4009
  removed: removed.length
@@ -3204,7 +4014,46 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3204
4014
  return { removed: 0 };
3205
4015
  }
3206
4016
  };
3207
- void normalizeActiveBacklog();
4017
+ const applyLifecycle = async () => {
4018
+ try {
4019
+ const result = maintainMap(root, contextAbs);
4020
+ for (const file of result.files) {
4021
+ if (file.removed.length === 0) continue;
4022
+ await log(
4023
+ "info",
4024
+ `map lifecycle: dropped ${file.removed.length} expired provisional entr${file.removed.length === 1 ? "y" : "ies"} from ${file.file}`,
4025
+ { file: file.file, removed: file.removed.map((entry) => entry.text) }
4026
+ );
4027
+ }
4028
+ return result;
4029
+ } catch {
4030
+ return void 0;
4031
+ }
4032
+ };
4033
+ const reportMapAudit = async (mode) => {
4034
+ try {
4035
+ const audit = auditMap(root, contextAbs);
4036
+ if (audit.ok) {
4037
+ await log("info", "context map validation passed", { mode, files: audit.files.length });
4038
+ return audit;
4039
+ }
4040
+ const byKind = {};
4041
+ for (const violation of audit.violations) {
4042
+ byKind[violation.kind] = (byKind[violation.kind] ?? 0) + 1;
4043
+ }
4044
+ await log("error", `context map validation failed: ${audit.violations.length} issue(s)`, {
4045
+ mode,
4046
+ byKind,
4047
+ issues: formatViolations(audit.violations, 20),
4048
+ note: "reported, not rewritten; fix the entry or its source and run /context-update"
4049
+ });
4050
+ return audit;
4051
+ } catch (error) {
4052
+ await log("warn", `context map validation could not run: ${String(error)}`, { mode });
4053
+ return void 0;
4054
+ }
4055
+ };
4056
+ void applyLifecycle();
3208
4057
  const listed = (files) => {
3209
4058
  const shown = files.slice(0, MAX_FILES_LISTED);
3210
4059
  const rest = files.length - shown.length;
@@ -3222,7 +4071,9 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3222
4071
  "",
3223
4072
  NO_BULK_READS,
3224
4073
  "",
3225
- "Follow the bootstrap procedure in your system prompt: progressive exploration, then write the five context files within their size targets. Also create `.opencode/context/active.md` with its heading and no tasks. When the project already has architecture or decision documentation, link to it instead of duplicating it. Write conclusions, not inventories."
4074
+ ENTRY_CONTRACT,
4075
+ "",
4076
+ "Follow the bootstrap procedure in your system prompt: progressive exploration, then write the five context files within their line budgets. Also create `.opencode/context/active.md` with its heading and no tasks. When the project already has architecture or decision documentation, link to it instead of duplicating it. Write conclusions, not inventories."
3226
4077
  ].join("\n") : [
3227
4078
  "Incremental context update. Project root: " + directory,
3228
4079
  "",
@@ -3231,6 +4082,8 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3231
4082
  "",
3232
4083
  NO_BULK_READS,
3233
4084
  "",
4085
+ ENTRY_CONTRACT,
4086
+ "",
3234
4087
  'Follow the incremental-update procedure in your system prompt. Update a context file only if this work introduced or revealed information that will materially help future agents: a new boundary or module, a new or moved canonical entry point, a changed workflow, a new convention, an architectural decision, a new dependency between subsystems, a renamed or moved subsystem, or an invariant not yet recorded. Do not edit documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed". Also keep `.opencode/context/active.md` current: record work that is in flight, and mark a task finished \u2014 with the date it finished \u2014 when it is.'
3235
4088
  ].join("\n");
3236
4089
  return {
@@ -3238,7 +4091,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3238
4091
  try {
3239
4092
  if (event.type === "file.edited") {
3240
4093
  const file = event.properties.file;
3241
- if (path2.resolve(root, file) === activeBacklogPath) {
4094
+ if (path3.resolve(root, file) === activeBacklogPath) {
3242
4095
  await normalizeActiveBacklog();
3243
4096
  return;
3244
4097
  }
@@ -3265,7 +4118,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3265
4118
  const requeue = () => {
3266
4119
  for (const file of files) pending.add(file);
3267
4120
  };
3268
- const mode = existsSync2(path2.join(contextAbs, "contexts.md")) ? "update" : "bootstrap";
4121
+ const mode = existsSync3(path3.join(contextAbs, "contexts.md")) ? "update" : "bootstrap";
3269
4122
  init(state2, (m) => void log("warn", m));
3270
4123
  const snap = snapshot(state2);
3271
4124
  const slot = pickSlot(state2, snap);
@@ -3332,6 +4185,13 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3332
4185
  hint: "either nothing needed updating, or the cartographer could not write -- check the one-writer permission rule"
3333
4186
  });
3334
4187
  }
4188
+ await applyLifecycle();
4189
+ const audit = await reportMapAudit(mode);
4190
+ if (audit && !audit.ok) {
4191
+ await log("error", `auto ${mode} finished with ${audit.violations.length} map validation issue(s)`, {
4192
+ sessionID: target
4193
+ });
4194
+ }
3335
4195
  }).catch(async (error) => {
3336
4196
  release(state2, claimKey2);
3337
4197
  await retireSession(client, directory, target, mode, log);
@@ -3345,11 +4205,92 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3345
4205
  };
3346
4206
  });
3347
4207
 
4208
+ // src/lib/feedback.ts
4209
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync7, renameSync as renameSync3, unlinkSync as unlinkSync4, writeFileSync as writeFileSync5 } from "node:fs";
4210
+ import { homedir as homedir4 } from "node:os";
4211
+ import { dirname as dirname4, join as join7 } from "node:path";
4212
+ var FEEDBACK_HINT_ENV = "LACODE_NO_FEEDBACK_HINT";
4213
+ var FEEDBACK_HINT = "Enjoying lacode? Bugs, ideas, or a quick note on how you use it: github.com/lawaty/lacode/issues (a \u2B50 on the repo helps others find it)";
4214
+ var feedbackStatePath = () => join7(homedir4(), ".local", "share", "opencode", "lacode", "feedback.json");
4215
+ function readFeedbackState(path5 = feedbackStatePath()) {
4216
+ try {
4217
+ const parsed = JSON.parse(readFileSync7(path5, "utf8"));
4218
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
4219
+ return parsed;
4220
+ } catch (error) {
4221
+ if (error?.code === "ENOENT") return {};
4222
+ return void 0;
4223
+ }
4224
+ }
4225
+ function markFeedbackHintShown(path5 = feedbackStatePath()) {
4226
+ try {
4227
+ mkdirSync4(dirname4(path5), { recursive: true });
4228
+ const tmp = `${path5}.${process.pid}.tmp`;
4229
+ writeFileSync5(tmp, JSON.stringify({ feedbackHintShown: true }));
4230
+ renameSync3(tmp, path5);
4231
+ return true;
4232
+ } catch {
4233
+ return false;
4234
+ }
4235
+ }
4236
+ function restoreFeedbackState(path5 = feedbackStatePath(), previous = {}) {
4237
+ try {
4238
+ if (Object.keys(previous).length === 0) {
4239
+ unlinkSync4(path5);
4240
+ return;
4241
+ }
4242
+ const tmp = `${path5}.${process.pid}.tmp`;
4243
+ writeFileSync5(tmp, JSON.stringify(previous));
4244
+ renameSync3(tmp, path5);
4245
+ } catch {
4246
+ }
4247
+ }
4248
+ async function maybeShowFeedbackHint(deps = {}) {
4249
+ const env = deps.env ?? process.env;
4250
+ if (deps.enabled === false || deps.feedbackHint === false) return "disabled";
4251
+ if (env[FEEDBACK_HINT_ENV] === "1") return "disabled";
4252
+ const interactive = deps.interactive ?? process.stdout.isTTY === true;
4253
+ if (!interactive || env.CI || env.NODE_TEST_CONTEXT) return "unavailable";
4254
+ if (!deps.show) return "unavailable";
4255
+ const state2 = readFeedbackState(deps.statePath);
4256
+ if (state2 === void 0 || state2.feedbackHintShown === true) return "skipped";
4257
+ if (!markFeedbackHintShown(deps.statePath)) return "skipped";
4258
+ try {
4259
+ await deps.show(FEEDBACK_HINT);
4260
+ } catch {
4261
+ restoreFeedbackState(deps.statePath, state2);
4262
+ return "skipped";
4263
+ }
4264
+ return "shown";
4265
+ }
4266
+
4267
+ // src/plugins/feedback.ts
4268
+ async function feedbackHooks(input, options, deps = {}) {
4269
+ const opts = options ?? {};
4270
+ if (opts.enabled === false || opts.feedbackHint === false) return {};
4271
+ const tui = input.client.tui;
4272
+ const show = deps.show ?? (tui?.showToast ? async (message) => {
4273
+ await tui.showToast({
4274
+ body: { title: "lacode", message, variant: "info", duration: 8e3 }
4275
+ });
4276
+ } : void 0);
4277
+ void maybeShowFeedbackHint({
4278
+ enabled: opts.enabled,
4279
+ feedbackHint: opts.feedbackHint,
4280
+ env: deps.env,
4281
+ statePath: deps.statePath,
4282
+ interactive: deps.interactive,
4283
+ show
4284
+ }).catch(() => {
4285
+ });
4286
+ return {};
4287
+ }
4288
+
3348
4289
  // src/plugins/image-paste.ts
3349
4290
  import { createHash, randomUUID } from "node:crypto";
3350
- import { globSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync as renameSync3, statSync as statSync4, writeFileSync as writeFileSync4 } from "node:fs";
3351
- import { homedir as homedir4 } from "node:os";
3352
- import { basename as basename2, join as join6 } from "node:path";
4291
+ import { globSync, mkdirSync as mkdirSync5, readFileSync as readFileSync8, renameSync as renameSync4, statSync as statSync5, writeFileSync as writeFileSync6 } from "node:fs";
4292
+ import { homedir as homedir5 } from "node:os";
4293
+ import { basename as basename2, join as join8 } from "node:path";
3353
4294
 
3354
4295
  // src/lib/clipboard.ts
3355
4296
  import { spawnSync } from "node:child_process";
@@ -3410,12 +4351,12 @@ var WINDOWS_SCRIPT = [
3410
4351
  var readWindows = async (run) => {
3411
4352
  const result = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT]);
3412
4353
  if (!result.ok) return null;
3413
- const path4 = result.stdout.toString("utf8").replace(/^\uFEFF/, "").trim();
3414
- if (!path4) return null;
4354
+ const path5 = result.stdout.toString("utf8").replace(/^\uFEFF/, "").trim();
4355
+ if (!path5) return null;
3415
4356
  try {
3416
- return validImage({ ok: true, stdout: await readFile(path4) });
4357
+ return validImage({ ok: true, stdout: await readFile(path5) });
3417
4358
  } finally {
3418
- await rm(path4, { force: true }).catch(() => {
4359
+ await rm(path5, { force: true }).catch(() => {
3419
4360
  });
3420
4361
  }
3421
4362
  };
@@ -3499,8 +4440,8 @@ function splitArguments(input) {
3499
4440
  return out;
3500
4441
  }
3501
4442
  function expandTilde(p) {
3502
- if (p === "~") return homedir4();
3503
- if (p.startsWith("~/")) return join6(homedir4(), p.slice(2));
4443
+ if (p === "~") return homedir5();
4444
+ if (p.startsWith("~/")) return join8(homedir5(), p.slice(2));
3504
4445
  return p;
3505
4446
  }
3506
4447
  function expandGlob(p) {
@@ -3515,11 +4456,11 @@ function storeImage(bytes, dir, sessionID, label) {
3515
4456
  const kind = detectImageKind(bytes);
3516
4457
  if (kind === null) return null;
3517
4458
  const hash = createHash("sha256").update(bytes).digest("hex");
3518
- mkdirSync4(dir, { recursive: true });
3519
- const target = join6(dir, `${hash}.${kind.ext}`);
4459
+ mkdirSync5(dir, { recursive: true });
4460
+ const target = join8(dir, `${hash}.${kind.ext}`);
3520
4461
  const tmp = `${target}.tmp`;
3521
- writeFileSync4(tmp, bytes, { mode: 384 });
3522
- renameSync3(tmp, target);
4462
+ writeFileSync6(tmp, bytes, { mode: 384 });
4463
+ renameSync4(tmp, target);
3523
4464
  const part = {
3524
4465
  id: randomUUID(),
3525
4466
  sessionID,
@@ -3547,7 +4488,7 @@ async function imagePasteHooks(input, options, deps) {
3547
4488
  if (opts.enabled === false) return {};
3548
4489
  const maxBytes = resolveMaxBytes(opts);
3549
4490
  const maxImages = resolveMaxImages(opts);
3550
- const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join6(homedir4(), ".local/share/opencode/lacode/images");
4491
+ const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join8(homedir5(), ".local/share/opencode/lacode/images");
3551
4492
  const readClipboard = (deps ?? {}).readClipboard ?? readClipboardImage;
3552
4493
  return {
3553
4494
  "command.execute.before": async (cmd, output) => {
@@ -3588,7 +4529,7 @@ async function imagePasteHooks(input, options, deps) {
3588
4529
  const name = basename2(filePath);
3589
4530
  let size;
3590
4531
  try {
3591
- const stat = statSync4(filePath);
4532
+ const stat = statSync5(filePath);
3592
4533
  if (!stat.isFile()) {
3593
4534
  skipped.push(name);
3594
4535
  continue;
@@ -3604,7 +4545,7 @@ async function imagePasteHooks(input, options, deps) {
3604
4545
  }
3605
4546
  let bytes;
3606
4547
  try {
3607
- bytes = readFileSync6(filePath);
4548
+ bytes = readFileSync8(filePath);
3608
4549
  } catch {
3609
4550
  skipped.push(name);
3610
4551
  continue;
@@ -3663,18 +4604,26 @@ function scrubSlotNames(text) {
3663
4604
  // src/plugins/notify.ts
3664
4605
  import { spawn as spawn2 } from "node:child_process";
3665
4606
  import { randomUUID as randomUUID2 } from "node:crypto";
3666
- import { existsSync as existsSync3 } from "node:fs";
3667
- import * as path3 from "node:path";
4607
+ import { existsSync as existsSync4 } from "node:fs";
4608
+ import * as path4 from "node:path";
3668
4609
 
3669
4610
  // src/lib/notify.ts
3670
- import { closeSync as closeSync2, mkdirSync as mkdirSync5, openSync as openSync2, readFileSync as readFileSync7, renameSync as renameSync4, statSync as statSync5, unlinkSync as unlinkSync4, writeFileSync as writeFileSync5, writeSync as writeSync2 } from "node:fs";
3671
- import { homedir as homedir5 } from "node:os";
3672
- import { basename as basename3, join as join7 } from "node:path";
4611
+ import { closeSync as closeSync2, mkdirSync as mkdirSync6, openSync as openSync2, readFileSync as readFileSync9, renameSync as renameSync5, statSync as statSync6, unlinkSync as unlinkSync5, writeFileSync as writeFileSync7, writeSync as writeSync2 } from "node:fs";
4612
+ import { homedir as homedir6 } from "node:os";
4613
+ import { basename as basename3, join as join9 } from "node:path";
3673
4614
  var INTERNAL_TITLE_PREFIXES = [
3674
4615
  // context-autoupdate spawns the cartographer through the session API, with no
3675
4616
  // parentID, so it looks exactly like a root session going idle. Notifying for
3676
4617
  // it would announce the cartographer's own bookkeeping as a finished task.
3677
- "context (auto"
4618
+ "context (auto",
4619
+ // timeline spawns its summarizer through the session API with the same shape,
4620
+ // so it would otherwise announce the summarizer's own bookkeeping as a task.
4621
+ "timeline (auto",
4622
+ // pool probe sessions are created as root sessions with no parentID and the
4623
+ // title `pool probe (<model>)` (`src/lib/probe.ts`), so notification for
4624
+ // their idle/error would announce Lacode's own health-check as finished user
4625
+ // work.
4626
+ "pool probe"
3678
4627
  ];
3679
4628
  var NOTIFY_OUTCOMES = ["done", "question", "permission", "error"];
3680
4629
  var notifyMessage = (outcome, detail) => {
@@ -3846,7 +4795,7 @@ var Notifier = class {
3846
4795
  return this.lastSent.size;
3847
4796
  }
3848
4797
  };
3849
- var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join7(homedir5(), ".local", "share", "opencode", "lacode", "notify");
4798
+ var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join9(homedir6(), ".local", "share", "opencode", "lacode", "notify");
3850
4799
  var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
3851
4800
  var CLAIM_WINDOW_MS = 5e3;
3852
4801
  var isPidAlive2 = (pid) => {
@@ -3857,17 +4806,17 @@ var isPidAlive2 = (pid) => {
3857
4806
  return error.code === "EPERM";
3858
4807
  }
3859
4808
  };
3860
- var claimFile = (sessionID, kind) => join7(notificationStateDir(), `claim.${safeCorrelationKey(`${sessionID}.${kind}`)}`);
4809
+ var claimFile = (sessionID, kind) => join9(notificationStateDir(), `claim.${safeCorrelationKey(`${sessionID}.${kind}`)}`);
3861
4810
  var claimNotification = (sessionID, kind, instanceId = "") => {
3862
- const path4 = claimFile(sessionID, kind);
4811
+ const path5 = claimFile(sessionID, kind);
3863
4812
  try {
3864
- mkdirSync5(notificationStateDir(), { recursive: true });
4813
+ mkdirSync6(notificationStateDir(), { recursive: true });
3865
4814
  } catch {
3866
4815
  return true;
3867
4816
  }
3868
4817
  const take = () => {
3869
4818
  try {
3870
- const fd = openSync2(path4, "wx");
4819
+ const fd = openSync2(path5, "wx");
3871
4820
  try {
3872
4821
  writeSync2(fd, `${process.pid} ${instanceId} ${kind}`);
3873
4822
  } finally {
@@ -3885,7 +4834,7 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
3885
4834
  const judge = () => {
3886
4835
  let raw;
3887
4836
  try {
3888
- raw = readFileSync7(path4, "utf8").trim();
4837
+ raw = readFileSync9(path5, "utf8").trim();
3889
4838
  } catch (error) {
3890
4839
  return error.code === "ENOENT" ? "gone" : "unknown";
3891
4840
  }
@@ -3906,7 +4855,7 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
3906
4855
  }
3907
4856
  let age = Number.POSITIVE_INFINITY;
3908
4857
  try {
3909
- age = Date.now() - statSync5(path4).mtimeMs;
4858
+ age = Date.now() - statSync6(path5).mtimeMs;
3910
4859
  } catch {
3911
4860
  return "gone";
3912
4861
  }
@@ -3923,7 +4872,7 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
3923
4872
  if (judged === "held") return false;
3924
4873
  if (judged === "unknown") return true;
3925
4874
  if (judged === "gone") return take() !== "held";
3926
- const lockPath = `${path4}.takeover`;
4875
+ const lockPath = `${path5}.takeover`;
3927
4876
  const takeOver = () => {
3928
4877
  const create = () => {
3929
4878
  try {
@@ -3935,7 +4884,7 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
3935
4884
  }
3936
4885
  return () => {
3937
4886
  try {
3938
- unlinkSync4(lockPath);
4887
+ unlinkSync5(lockPath);
3939
4888
  } catch {
3940
4889
  }
3941
4890
  };
@@ -3949,9 +4898,9 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
3949
4898
  let lockHolder;
3950
4899
  let lockAge = Number.POSITIVE_INFINITY;
3951
4900
  try {
3952
- const parsed = Number.parseInt(readFileSync7(lockPath, "utf8").trim(), 10);
4901
+ const parsed = Number.parseInt(readFileSync9(lockPath, "utf8").trim(), 10);
3953
4902
  lockHolder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
3954
- lockAge = Date.now() - statSync5(lockPath).mtimeMs;
4903
+ lockAge = Date.now() - statSync6(lockPath).mtimeMs;
3955
4904
  } catch {
3956
4905
  return create();
3957
4906
  }
@@ -3959,7 +4908,7 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
3959
4908
  return void 0;
3960
4909
  }
3961
4910
  try {
3962
- unlinkSync4(lockPath);
4911
+ unlinkSync5(lockPath);
3963
4912
  } catch {
3964
4913
  return void 0;
3965
4914
  }
@@ -3973,7 +4922,7 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
3973
4922
  if (current === "held") return false;
3974
4923
  if (current === "unknown") return true;
3975
4924
  try {
3976
- unlinkSync4(path4);
4925
+ unlinkSync5(path5);
3977
4926
  } catch {
3978
4927
  }
3979
4928
  return take() !== "held";
@@ -3982,23 +4931,23 @@ var claimNotification = (sessionID, kind, instanceId = "") => {
3982
4931
  }
3983
4932
  };
3984
4933
  var releaseNotificationClaim = (sessionID, kind) => {
3985
- const path4 = claimFile(sessionID, kind);
4934
+ const path5 = claimFile(sessionID, kind);
3986
4935
  try {
3987
- const parsed = Number.parseInt(readFileSync7(path4, "utf8").trim(), 10);
4936
+ const parsed = Number.parseInt(readFileSync9(path5, "utf8").trim(), 10);
3988
4937
  if (!Number.isFinite(parsed) || parsed <= 0) return;
3989
4938
  if (parsed !== process.pid) return;
3990
- unlinkSync4(path4);
4939
+ unlinkSync5(path5);
3991
4940
  } catch {
3992
4941
  }
3993
4942
  };
3994
- var completionFile = (sessionID) => join7(notificationStateDir(), `completion.${safeCorrelationKey(sessionID)}`);
4943
+ var completionFile = (sessionID) => join9(notificationStateDir(), `completion.${safeCorrelationKey(sessionID)}`);
3995
4944
  var claimCompletion = (sessionID, kind) => {
3996
- const path4 = completionFile(sessionID);
4945
+ const path5 = completionFile(sessionID);
3997
4946
  try {
3998
- mkdirSync5(notificationStateDir(), { recursive: true });
3999
- const fd = openSync2(path4, "wx");
4947
+ mkdirSync6(notificationStateDir(), { recursive: true });
4948
+ const fd = openSync2(path5, "wx");
4000
4949
  try {
4001
- writeFileSync5(fd, `${process.pid} ${kind}
4950
+ writeFileSync7(fd, `${process.pid} ${kind}
4002
4951
  `);
4003
4952
  } finally {
4004
4953
  closeSync2(fd);
@@ -4011,18 +4960,18 @@ var claimCompletion = (sessionID, kind) => {
4011
4960
  };
4012
4961
  var releaseCompletion = (sessionID) => {
4013
4962
  try {
4014
- unlinkSync4(completionFile(sessionID));
4963
+ unlinkSync5(completionFile(sessionID));
4015
4964
  } catch {
4016
4965
  }
4017
4966
  };
4018
4967
  var markCompletion = (sessionID, kind) => {
4019
4968
  try {
4020
- mkdirSync5(notificationStateDir(), { recursive: true });
4021
- const path4 = completionFile(sessionID);
4022
- const tmp = `${path4}.${process.pid}.tmp`;
4023
- writeFileSync5(tmp, `${process.pid} ${kind}
4969
+ mkdirSync6(notificationStateDir(), { recursive: true });
4970
+ const path5 = completionFile(sessionID);
4971
+ const tmp = `${path5}.${process.pid}.tmp`;
4972
+ writeFileSync7(tmp, `${process.pid} ${kind}
4024
4973
  `);
4025
- renameSync4(tmp, path4);
4974
+ renameSync5(tmp, path5);
4026
4975
  } catch {
4027
4976
  }
4028
4977
  };
@@ -4043,13 +4992,13 @@ var findHelper = () => {
4043
4992
  const home = process.env.HOME ?? "";
4044
4993
  const candidates = [
4045
4994
  process.env.OC_NOTIFY_HELPER,
4046
- path3.join(home, "bin", "lacode"),
4047
- path3.join(PACKAGE_ROOT, "dist", "lacode.js"),
4048
- path3.join(PACKAGE_ROOT, "bin", "lacode"),
4049
- path3.join(home, "bin", "oc-notify"),
4050
- path3.join(PACKAGE_ROOT, "bin", "oc-notify")
4995
+ path4.join(home, "bin", "lacode"),
4996
+ path4.join(PACKAGE_ROOT, "dist", "lacode.js"),
4997
+ path4.join(PACKAGE_ROOT, "bin", "lacode"),
4998
+ path4.join(home, "bin", "oc-notify"),
4999
+ path4.join(PACKAGE_ROOT, "bin", "oc-notify")
4051
5000
  ].filter((candidate) => typeof candidate === "string" && candidate.length > 0);
4052
- for (const candidate of candidates) if (existsSync3(candidate)) return candidate;
5001
+ for (const candidate of candidates) if (existsSync4(candidate)) return candidate;
4053
5002
  return void 0;
4054
5003
  };
4055
5004
  var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
@@ -4162,10 +5111,10 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
4162
5111
  const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
4163
5112
  return info;
4164
5113
  };
4165
- const deliverViaHelper = (title, message, urgency, sound, requestID, sessionID) => new Promise((resolve3) => {
5114
+ const deliverViaHelper = (title, message, urgency, sound, requestID, sessionID) => new Promise((resolve5) => {
4166
5115
  if (inflight >= maxInflight) {
4167
5116
  void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
4168
- resolve3({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
5117
+ resolve5({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
4169
5118
  return;
4170
5119
  }
4171
5120
  const helper = findHelper();
@@ -4174,7 +5123,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
4174
5123
  warnedHelper = true;
4175
5124
  void log("warn", "no oc-notify helper found; run 'bin/oc-sync' to install one");
4176
5125
  }
4177
- resolve3({ ok: false, detail: "no oc-notify helper found" });
5126
+ resolve5({ ok: false, detail: "no oc-notify helper found" });
4178
5127
  return;
4179
5128
  }
4180
5129
  const childEnv = { ...process.env, ...sessionID ? { [NOTIFY_SESSION_ENV]: sessionID } : {} };
@@ -4197,7 +5146,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
4197
5146
  settled = true;
4198
5147
  inflight--;
4199
5148
  if (timer) clearTimeout(timer);
4200
- resolve3(result);
5149
+ resolve5(result);
4201
5150
  };
4202
5151
  timer = setTimeout(() => {
4203
5152
  try {
@@ -4518,9 +5467,9 @@ function normalizeBaseURL(raw) {
4518
5467
  return null;
4519
5468
  }
4520
5469
  if (url.protocol !== "http:" && url.protocol !== "https:") return null;
4521
- let path4 = url.pathname.replace(/\/+$/, "");
4522
- while (path4.endsWith("/v1")) path4 = path4.slice(0, -"/v1".length);
4523
- return `${url.origin}${path4}/v1`;
5470
+ let path5 = url.pathname.replace(/\/+$/, "");
5471
+ while (path5.endsWith("/v1")) path5 = path5.slice(0, -"/v1".length);
5472
+ return `${url.origin}${path5}/v1`;
4524
5473
  }
4525
5474
  function parseConfig(raw, env = process.env) {
4526
5475
  if (typeof raw !== "object" || raw === null) return { enabled: false, config: null, problems: [] };
@@ -4986,6 +5935,741 @@ async function selfUpdateHooks(input, options) {
4986
5935
  return {};
4987
5936
  }
4988
5937
 
5938
+ // src/plugins/timeline.ts
5939
+ import { writeFileSync as writeFileSync8, renameSync as renameSync6, rmSync as rmSync3 } from "node:fs";
5940
+ import { tool as tool2 } from "@opencode-ai/plugin";
5941
+
5942
+ // src/lib/timeline.ts
5943
+ import { appendFileSync, mkdirSync as mkdirSync7, readFileSync as readFileSync10, rmSync as rmSync2 } from "node:fs";
5944
+ import { homedir as homedir7 } from "node:os";
5945
+ import { dirname as dirname5, join as join11, resolve as resolve4 } from "node:path";
5946
+ function timelineDir() {
5947
+ const env = process.env.LACODE_TIMELINE_DIR;
5948
+ if (env) return resolve4(env);
5949
+ return join11(homedir7(), ".local/share/opencode/lacode/timeline");
5950
+ }
5951
+ function timelineFile(sessionID) {
5952
+ if (!/^[A-Za-z0-9_.-]+$/.test(sessionID)) throw new Error("invalid sessionID");
5953
+ return join11(timelineDir(), sessionID + ".jsonl");
5954
+ }
5955
+ function normalizeThrough(through) {
5956
+ if (!through || typeof through !== "object") return void 0;
5957
+ const out = {};
5958
+ if (typeof through.messageID === "string") out.messageID = through.messageID;
5959
+ if (typeof through.time === "number") out.time = through.time;
5960
+ if (out.messageID === void 0 && out.time === void 0) return void 0;
5961
+ return out;
5962
+ }
5963
+ function appendEntriesAt(file, texts, now, through) {
5964
+ mkdirSync7(dirname5(file), { recursive: true });
5965
+ const cleaned = [];
5966
+ for (const t of texts) {
5967
+ if (typeof t !== "string" || t.length === 0) continue;
5968
+ cleaned.push(t.slice(0, 320));
5969
+ }
5970
+ let recent = [];
5971
+ try {
5972
+ recent = readEntriesAt(file, 20);
5973
+ } catch {
5974
+ recent = [];
5975
+ }
5976
+ const recentSet = new Set(recent.map((e) => e.text.toLowerCase()));
5977
+ const batchSeen = /* @__PURE__ */ new Set();
5978
+ const toAppend = [];
5979
+ for (const t of cleaned) {
5980
+ const key = t.toLowerCase();
5981
+ if (recentSet.has(key)) continue;
5982
+ if (batchSeen.has(key)) continue;
5983
+ batchSeen.add(key);
5984
+ toAppend.push(t);
5985
+ }
5986
+ const validThrough = normalizeThrough(through);
5987
+ let count = 0;
5988
+ for (const t of toAppend) {
5989
+ const ts = now ?? Date.now();
5990
+ const line = { ts, text: t };
5991
+ if (validThrough) line.through = validThrough;
5992
+ appendFileSync(file, JSON.stringify(line) + "\n");
5993
+ count++;
5994
+ }
5995
+ return count;
5996
+ }
5997
+ function readEntriesAt(file, limit) {
5998
+ let raw = "";
5999
+ try {
6000
+ raw = readFileSync10(file, "utf8");
6001
+ } catch {
6002
+ return [];
6003
+ }
6004
+ const entries = [];
6005
+ for (const line of raw.split("\n")) {
6006
+ if (line.trim() === "") continue;
6007
+ try {
6008
+ const obj = JSON.parse(line);
6009
+ if (obj && typeof obj.ts === "number" && typeof obj.text === "string") {
6010
+ const entry = { ts: obj.ts, text: obj.text };
6011
+ const t = normalizeThrough(obj.through);
6012
+ if (t) entry.through = t;
6013
+ entries.push(entry);
6014
+ }
6015
+ } catch {
6016
+ }
6017
+ }
6018
+ if (limit != null && limit <= 0) return [];
6019
+ const lim = limit == null ? 50 : limit;
6020
+ return entries.slice(-lim);
6021
+ }
6022
+ function appendEntries(sessionID, texts, now, through) {
6023
+ return appendEntriesAt(timelineFile(sessionID), texts, now, through);
6024
+ }
6025
+ function readEntries(sessionID, limit) {
6026
+ return readEntriesAt(timelineFile(sessionID), limit);
6027
+ }
6028
+ function removeEntries(sessionID) {
6029
+ try {
6030
+ rmSync2(timelineFile(sessionID), { force: true });
6031
+ } catch {
6032
+ }
6033
+ }
6034
+ function collapse(s) {
6035
+ return s.replace(/\s+/g, " ").trim();
6036
+ }
6037
+ function trunc(s, n) {
6038
+ return s.length > n ? s.slice(0, n) : s;
6039
+ }
6040
+ function stripAnsiAndControls(s) {
6041
+ let out = "";
6042
+ for (let i = 0; i < s.length; i++) {
6043
+ const code = s.charCodeAt(i);
6044
+ if (code === 27) {
6045
+ if (i + 1 < s.length && s[i + 1] === "[") {
6046
+ i += 2;
6047
+ while (i < s.length) {
6048
+ const c = s.charCodeAt(i);
6049
+ if (c >= 64 && c <= 126) {
6050
+ break;
6051
+ }
6052
+ i++;
6053
+ }
6054
+ continue;
6055
+ }
6056
+ if (i + 1 < s.length && s[i + 1] === "]") {
6057
+ i += 2;
6058
+ while (i < s.length) {
6059
+ const c = s.charCodeAt(i);
6060
+ if (c === 7) break;
6061
+ if (c === 27 && i + 1 < s.length && s[i + 1] === "\\") {
6062
+ i += 1;
6063
+ break;
6064
+ }
6065
+ i++;
6066
+ }
6067
+ continue;
6068
+ }
6069
+ if (i + 1 < s.length) {
6070
+ i += 1;
6071
+ }
6072
+ continue;
6073
+ }
6074
+ if (code === 9 || code === 10) {
6075
+ out += s[i];
6076
+ continue;
6077
+ }
6078
+ if (code < 32 || code >= 127 && code <= 159) {
6079
+ continue;
6080
+ }
6081
+ out += s[i];
6082
+ }
6083
+ return out;
6084
+ }
6085
+ function inputSummary(input) {
6086
+ if (input === null || input === void 0) return "";
6087
+ if (typeof input !== "object") {
6088
+ try {
6089
+ return trunc(collapse(JSON.stringify(input)), 160);
6090
+ } catch {
6091
+ return "";
6092
+ }
6093
+ }
6094
+ const rec = input;
6095
+ const keys = ["filePath", "path", "command", "description", "pattern", "query", "url", "name", "title"];
6096
+ for (const k of keys) {
6097
+ if (Object.prototype.hasOwnProperty.call(rec, k) && rec[k] != null) {
6098
+ const v = rec[k];
6099
+ const s = typeof v === "string" ? v : JSON.stringify(v);
6100
+ return trunc(collapse(s), 160);
6101
+ }
6102
+ }
6103
+ try {
6104
+ return trunc(collapse(JSON.stringify(rec)), 160);
6105
+ } catch {
6106
+ return "";
6107
+ }
6108
+ }
6109
+ function summarizeToolResult(state2) {
6110
+ if (!state2) return "";
6111
+ const err = state2.error;
6112
+ if (typeof err === "string") {
6113
+ if (err.trim() === "") return "";
6114
+ return `error: ${trunc(err, 180)}`;
6115
+ }
6116
+ if (err && typeof err === "object") {
6117
+ try {
6118
+ const s = JSON.stringify(err);
6119
+ if (typeof s === "string" && s.trim() !== "") {
6120
+ return trunc(s, 180);
6121
+ }
6122
+ } catch {
6123
+ }
6124
+ return "";
6125
+ }
6126
+ if (typeof state2.output === "string") {
6127
+ for (const raw of state2.output.split("\n")) {
6128
+ const line = raw.trim();
6129
+ if (line.length > 0) {
6130
+ return trunc(line, 180);
6131
+ }
6132
+ }
6133
+ }
6134
+ if (typeof state2.title === "string" && state2.title.trim() !== "") {
6135
+ return trunc(state2.title, 180);
6136
+ }
6137
+ return "";
6138
+ }
6139
+ function renderMessage(m) {
6140
+ const role = m.info.role;
6141
+ const lines = [];
6142
+ if (role === "user") {
6143
+ for (const p of m.parts) {
6144
+ if (p.type === "text" && !p.synthetic && !p.ignored) {
6145
+ if ((p.text ?? "").includes("timeline_reprocess")) continue;
6146
+ const text = collapse(p.text ?? "");
6147
+ lines.push(`[user] ${trunc(text, 2e3)}`);
6148
+ }
6149
+ }
6150
+ } else if (role === "assistant") {
6151
+ for (const p of m.parts) {
6152
+ if (p.type === "text") {
6153
+ const text = collapse(p.text ?? "");
6154
+ lines.push(`[assistant] ${trunc(text, 1500)}`);
6155
+ } else if (p.type === "tool") {
6156
+ const tool3 = p.tool ?? "";
6157
+ if (tool3 === "timeline_reprocess") continue;
6158
+ const status = p.state?.status;
6159
+ const summary = inputSummary(p.state?.input);
6160
+ const result = summarizeToolResult(p.state);
6161
+ let line = `[tool] ${tool3}`;
6162
+ if (status) line += ` (${status})`;
6163
+ const inputPart = summary ? trunc(summary, 220) : "";
6164
+ let rest = "";
6165
+ if (inputPart) rest += ` ${inputPart}`;
6166
+ if (result) rest += ` -> ${result}`;
6167
+ line += rest;
6168
+ lines.push(line);
6169
+ }
6170
+ }
6171
+ }
6172
+ return lines;
6173
+ }
6174
+ function buildExcerpt(messages, cursor, budget = 8e3) {
6175
+ let start = 0;
6176
+ if (cursor?.messageID) {
6177
+ const idx = messages.findIndex((m) => m.info.id === cursor.messageID);
6178
+ if (idx >= 0) {
6179
+ start = idx + 1;
6180
+ } else if (cursor.time != null) {
6181
+ const i = messages.findIndex((m) => (m.info.time?.created ?? -Infinity) > cursor.time);
6182
+ start = i < 0 ? messages.length : i;
6183
+ }
6184
+ } else if (cursor?.time != null) {
6185
+ const i = messages.findIndex((m) => (m.info.time?.created ?? -Infinity) > cursor.time);
6186
+ start = i < 0 ? messages.length : i;
6187
+ }
6188
+ const outLines = [];
6189
+ const included = [];
6190
+ for (let i = start; i < messages.length; i++) {
6191
+ const m = messages[i];
6192
+ const msgLines = renderMessage(m);
6193
+ if (msgLines.length === 0) continue;
6194
+ const candidateLines = outLines.length === 0 ? [...msgLines] : [...outLines, ...msgLines];
6195
+ const candidateText = candidateLines.join("\n");
6196
+ if (included.length > 0 && candidateText.length > budget) {
6197
+ break;
6198
+ }
6199
+ if (included.length === 0 && candidateText.length > budget) {
6200
+ const block = msgLines.join("\n");
6201
+ outLines.push(block.slice(0, budget));
6202
+ included.push(m);
6203
+ break;
6204
+ }
6205
+ outLines.push(...msgLines);
6206
+ included.push(m);
6207
+ }
6208
+ let model;
6209
+ for (let i = messages.length - 1; i >= 0; i--) {
6210
+ const info = messages[i].info;
6211
+ if (info.role !== "user") continue;
6212
+ if (info.model) {
6213
+ model = { providerID: info.model.providerID, modelID: info.model.modelID };
6214
+ break;
6215
+ }
6216
+ if (info.providerID && info.modelID) {
6217
+ model = { providerID: info.providerID, modelID: info.modelID };
6218
+ break;
6219
+ }
6220
+ }
6221
+ const lastIncluded = included[included.length - 1];
6222
+ const next = lastIncluded ? { messageID: lastIncluded.info.id, time: lastIncluded.info.time?.created } : void 0;
6223
+ return {
6224
+ text: outLines.join("\n"),
6225
+ next,
6226
+ count: included.length,
6227
+ model
6228
+ };
6229
+ }
6230
+ function parseBullets(text, max = 6) {
6231
+ const result = [];
6232
+ const seen = /* @__PURE__ */ new Set();
6233
+ for (const raw of text.split("\n")) {
6234
+ const line = raw.trim();
6235
+ if (!line) continue;
6236
+ if (line.startsWith("```")) continue;
6237
+ if (line.length > 320) continue;
6238
+ const m = line.match(/^([-*•]|\d+[.)])\s+(.*)$/);
6239
+ if (!m) continue;
6240
+ const stripped = m[2].trim().replace(/`/g, "").replace(/\*\*/g, "").trim();
6241
+ if (!stripped) continue;
6242
+ const cleaned = stripAnsiAndControls(stripped).trim();
6243
+ if (!cleaned) continue;
6244
+ if (cleaned.length > 320) continue;
6245
+ const key = cleaned.toLowerCase();
6246
+ if (seen.has(key)) continue;
6247
+ seen.add(key);
6248
+ result.push(cleaned);
6249
+ if (result.length >= max) break;
6250
+ }
6251
+ return result;
6252
+ }
6253
+ function isInternalTitle(title) {
6254
+ const t = (title ?? "").trim();
6255
+ return t.startsWith("context (auto") || t.startsWith("timeline (auto") || t.startsWith("pool probe");
6256
+ }
6257
+
6258
+ // src/plugins/timeline.ts
6259
+ var DEFAULT_INTERVAL_MS = 3e5;
6260
+ var MIN_INTERVAL_MS = 1e4;
6261
+ var DEFAULT_MAX_ENTRIES = 8;
6262
+ var MAX_ENTRIES = 10;
6263
+ var PROMPT_TIMEOUT_MS = 9e4;
6264
+ var SYSTEM = `You are the session timeline summarizer. You receive a slice of a coding session transcript.
6265
+ Write 1-8 timeline entries describing the work as tasks, features, fixes, or stages.
6266
+
6267
+ Rules:
6268
+ - One entry per line, each starting with "- ".
6269
+ - Each entry is 6-16 words, past tense, plain text.
6270
+ - Cover each distinct task in the slice. Do not drop a provider, feature, fix, config, docs, or test change; merge only exact duplicates.
6271
+ - Describe the TASK or OUTCOME, not the implementation. "Pool: added bucket-based routing" is right; "added b:<name> to pool.ts and agent-pool.ts" is too detailed.
6272
+ - Never describe the mechanism: no flags, aliases, overrides, endpoints, probes, config keys, declarations, tags, or file changes. State what changed for the system: which capability now works, which bug is fixed, which behavior changed.
6273
+ - Never use these words in an entry: tag, alias, override, flag, probe, endpoint, config key, catalog, file, line, count.
6274
+ - For configuration plumbing, write the purpose: "Config: silenced warnings for unused providers", not "declared empty overrides".
6275
+ - Use a short area prefix when it clarifies: "Pool:", "Providers:", "Config:", "Docs:", "Tests:", "Fix:", "Feature:".
6276
+ - Name the capability, behavior, feature, bug, or decision. Do not name files, paths, functions, commands, environment variables, API keys, raw model IDs, test file names, or build artifacts. Provider and product names are allowed when they identify the task.
6277
+ - Do not include counts of slots, providers, tests, lines, or files.
6278
+ - Omit internal housekeeping: builds, dist rebuilds, test-file additions, formatting, and dependency churn. If the slice only contains housekeeping, output nothing.
6279
+ - Describe what was DONE or DECIDED. Never write "User asked...", "Assistant said...", "Discussed...", "Reviewed...", "Explained...", or "Considered...".
6280
+ - Merge related changes into one stage; do not repeat the same task in different words.
6281
+ - No markdown: no bold, italics, backticks, or code blocks.
6282
+ - If a technical detail is the essence of the task, name it at the capability level: "Added Cloudflare Workers AI as a provider", not its account-id variables.
6283
+ - If the slice contains no substantive work or decision, output nothing.
6284
+ - Output only the entries. No headings, no preamble, no closing sentence.
6285
+
6286
+ Example output:
6287
+ - Pool: added bucket-based routing for model groups.
6288
+ - Providers: added Gemini 3.5 Flash after the previous model timed out.
6289
+ - Providers: added Cloudflare Workers AI with a tool-compatible model.
6290
+ - Config: silenced warnings for unused providers.
6291
+ - Config: standardized provider key naming and added Groq and Google.
6292
+ - Docs: aligned the routing documentation with the implemented behavior.
6293
+
6294
+ Not this:
6295
+ - Config: declared empty OpenRouter overrides to silence warnings.
6296
+ - Providers: added a Google API key alias after Gemini 2.5-flash closure.
6297
+ - Config: standardized provider keys to _KEY-only naming.
6298
+ - Pool: reordered slots after tagging pool entries.
6299
+ - Pool: added bucket support and kept tags in pool.
6300
+ - Pool: standardized slots on opencode catalog.
6301
+ - Providers: added Gemini 3.5 Flash and a Google API key alias.
6302
+
6303
+ This:
6304
+ - Config: silenced warnings for unused providers.
6305
+ - Providers: replaced the retired Gemini 2.5 Flash with an available model.
6306
+ - Config: standardized provider key naming.
6307
+ - Pool: moved shared-window models to the end.
6308
+ - Pool: added named bucket support for model groups.
6309
+ - Pool: moved shared-window models to the end.
6310
+ - Providers: replaced the retired Gemini 2.5 Flash with Gemini 3.5 Flash.
6311
+
6312
+ Your entire response must consist only of lines beginning with "- ".`;
6313
+ function timelineHooks(input, options) {
6314
+ const { client, directory } = input;
6315
+ const opts = options ?? {};
6316
+ const config = typeof opts.timeline === "object" && opts.timeline !== null ? opts.timeline : void 0;
6317
+ const disabled = opts.timeline === false || config?.enabled === false || process.env.LACODE_TIMELINE === "0";
6318
+ const SERVICE5 = "timeline";
6319
+ const poolState = state(options);
6320
+ const log = (level, message, extra) => client.app.log({ body: { service: SERVICE5, level, message, extra } }).catch(() => {
6321
+ });
6322
+ const debug = (...args) => {
6323
+ if (process.env.LACODE_TIMELINE_DEBUG === "1") console.error("[timeline]", ...args);
6324
+ };
6325
+ const rawInterval = Number(process.env.LACODE_TIMELINE_INTERVAL_MS ?? config?.intervalMs ?? DEFAULT_INTERVAL_MS);
6326
+ const intervalMs = Number.isFinite(rawInterval) ? Math.max(MIN_INTERVAL_MS, rawInterval) : DEFAULT_INTERVAL_MS;
6327
+ const rawMaxEntries = Number(config?.maxEntries ?? DEFAULT_MAX_ENTRIES);
6328
+ const maxEntries = Number.isFinite(rawMaxEntries) ? Math.min(MAX_ENTRIES, Math.max(1, Math.trunc(rawMaxEntries))) : DEFAULT_MAX_ENTRIES;
6329
+ const shared = globalThis[/* @__PURE__ */ Symbol.for("lacode:timeline:state")] ??= {
6330
+ sessions: /* @__PURE__ */ new Map(),
6331
+ owned: /* @__PURE__ */ new Set(),
6332
+ reprocessing: /* @__PURE__ */ new Set()
6333
+ };
6334
+ const sessions = shared.sessions;
6335
+ const owned = shared.owned;
6336
+ const reprocessing = shared.reprocessing;
6337
+ const MAX_REPROCESS_SLICES = 100;
6338
+ let disposed = false;
6339
+ const promptModel = (raw) => {
6340
+ if (typeof raw !== "string" || !raw.includes("/")) return void 0;
6341
+ const { provider, id } = splitModel(raw);
6342
+ if (!id) return void 0;
6343
+ return { providerID: provider, modelID: id };
6344
+ };
6345
+ const stateFor = (id) => {
6346
+ let state2 = sessions.get(id);
6347
+ if (!state2) {
6348
+ state2 = {
6349
+ running: false,
6350
+ pending: false,
6351
+ failures: 0,
6352
+ cursor: readEntries(id, 1)[0]?.through
6353
+ };
6354
+ sessions.set(id, state2);
6355
+ while (sessions.size > 500) {
6356
+ let evicted = false;
6357
+ for (const [key, st] of sessions) {
6358
+ if (key === id) continue;
6359
+ if (!st.timer && !st.running) {
6360
+ sessions.delete(key);
6361
+ evicted = true;
6362
+ break;
6363
+ }
6364
+ }
6365
+ if (!evicted) break;
6366
+ }
6367
+ }
6368
+ return state2;
6369
+ };
6370
+ const note = (id) => {
6371
+ if (disabled || disposed) return;
6372
+ const state2 = stateFor(id);
6373
+ if (state2.root === false) return;
6374
+ state2.pending = true;
6375
+ if (state2.timer || state2.running) return;
6376
+ schedule(id, state2);
6377
+ };
6378
+ function schedule(id, state2) {
6379
+ if (state2.timer) clearTimeout(state2.timer);
6380
+ state2.timer = setTimeout(() => {
6381
+ state2.timer = void 0;
6382
+ void tick(id, state2);
6383
+ }, intervalMs);
6384
+ state2.timer.unref?.();
6385
+ }
6386
+ async function summarize(id, title, excerptText, prior, abort) {
6387
+ if (abort?.aborted) return { ok: false, bullets: [], error: "aborted" };
6388
+ let slot;
6389
+ try {
6390
+ init(poolState, (m) => log("warn", m));
6391
+ const snap = snapshot(poolState);
6392
+ slot = pickSlot(poolState, snap);
6393
+ } catch (error) {
6394
+ log("warn", String(error));
6395
+ return { ok: false, bullets: [], error: String(error) };
6396
+ }
6397
+ const model = promptModel(slot?.model);
6398
+ if (!model) {
6399
+ log("warn", "timeline: slot model cannot be split into provider/model", { model: slot?.model });
6400
+ return { ok: false, bullets: [], error: "slot model cannot be split into provider/model" };
6401
+ }
6402
+ const created = await client.session.create({
6403
+ body: { title: "timeline (auto)" },
6404
+ query: { directory }
6405
+ });
6406
+ const target = created?.data?.id;
6407
+ if (!target) {
6408
+ return { ok: false, bullets: [], error: "session create returned no id" };
6409
+ }
6410
+ const claimKey2 = claimKey(target, "timeline");
6411
+ let claimed = false;
6412
+ owned.add(target);
6413
+ let abortListener;
6414
+ try {
6415
+ claimed = true;
6416
+ acquire(poolState, "context", slot, target, claimKey2, target);
6417
+ const prompt = [
6418
+ `Session: ${title}`,
6419
+ ...prior.length > 0 ? ["Existing timeline (do not repeat):", ...prior.map((entry) => `- ${entry.text}`)] : [],
6420
+ "Transcript slice:",
6421
+ excerptText
6422
+ ].join("\n");
6423
+ if (abort) {
6424
+ abortListener = () => {
6425
+ void client.session.abort({ path: { id: target }, query: { directory } }).catch(() => {
6426
+ });
6427
+ };
6428
+ abort.addEventListener("abort", abortListener);
6429
+ }
6430
+ let timer;
6431
+ let timedOut = false;
6432
+ let result;
6433
+ try {
6434
+ const answer = client.session.prompt({
6435
+ path: { id: target },
6436
+ query: { directory },
6437
+ body: {
6438
+ model,
6439
+ tools: { "*": false },
6440
+ system: SYSTEM,
6441
+ parts: [{ type: "text", text: prompt }]
6442
+ }
6443
+ }).then(
6444
+ (value) => value,
6445
+ (error) => ({ error })
6446
+ );
6447
+ const deadline = new Promise((resolve5) => {
6448
+ timer = setTimeout(() => {
6449
+ timedOut = true;
6450
+ void client.session.abort({ path: { id: target }, query: { directory } }).catch(() => {
6451
+ });
6452
+ resolve5(void 0);
6453
+ }, PROMPT_TIMEOUT_MS);
6454
+ });
6455
+ result = await Promise.race([answer, deadline]);
6456
+ } finally {
6457
+ if (timer) clearTimeout(timer);
6458
+ }
6459
+ if (abort?.aborted) {
6460
+ return { ok: false, bullets: [], error: "aborted" };
6461
+ }
6462
+ const failure = result?.error ?? result?.data?.info?.error;
6463
+ if (timedOut || failure !== void 0) {
6464
+ return { ok: false, bullets: [], error: timedOut ? "timed out" : String(failure) };
6465
+ } else {
6466
+ const text = (result?.data?.parts ?? []).map((part) => part.type === "text" ? part.text ?? "" : "").join("\n");
6467
+ const bullets = parseBullets(text, maxEntries);
6468
+ if (bullets.length > 0) {
6469
+ return { ok: true, bullets };
6470
+ } else if (text.trim() === "") {
6471
+ return { ok: true, bullets: [] };
6472
+ } else {
6473
+ if (process.env.LACODE_TIMELINE_DEBUG === "1") {
6474
+ log("no bullet lines", { sessionID: id, text: text.slice(0, 300) });
6475
+ }
6476
+ return { ok: false, bullets: [], error: "no bullet lines" };
6477
+ }
6478
+ }
6479
+ } finally {
6480
+ if (abort && abortListener) abort.removeEventListener("abort", abortListener);
6481
+ if (claimed && claimKey2) release(poolState, claimKey2);
6482
+ await client.session.delete({ path: { id: target }, query: { directory } }).catch(() => {
6483
+ });
6484
+ owned.delete(target);
6485
+ }
6486
+ }
6487
+ async function tick(id, state2) {
6488
+ if (disposed) return;
6489
+ if (reprocessing.has(id)) return;
6490
+ try {
6491
+ state2.running = true;
6492
+ state2.pending = false;
6493
+ let title;
6494
+ let info = {};
6495
+ if (state2.root === void 0) {
6496
+ info = await client.session.get({ path: { id }, query: { directory } }).then((result2) => result2?.data).catch(() => void 0);
6497
+ if (!info) {
6498
+ sessions.delete(id);
6499
+ return;
6500
+ }
6501
+ title = info.title;
6502
+ state2.root = !info.parentID && !isInternalTitle(info.title);
6503
+ if (!state2.root) {
6504
+ sessions.delete(id);
6505
+ return;
6506
+ }
6507
+ }
6508
+ const response = await client.session.messages({ path: { id }, query: { directory } });
6509
+ const messages = response?.data ?? [];
6510
+ const excerpt = buildExcerpt(messages, state2.cursor);
6511
+ if (!excerpt.text) {
6512
+ return;
6513
+ }
6514
+ const result = await summarize(id, info.title ?? "untitled", excerpt.text, readEntries(id, 4));
6515
+ if (result.ok) {
6516
+ if (result.bullets.length) appendEntries(id, result.bullets, Date.now(), excerpt.next);
6517
+ state2.cursor = excerpt.next;
6518
+ state2.failures = 0;
6519
+ } else {
6520
+ state2.failures++;
6521
+ log("summarize failed", { sessionID: id, error: result.error });
6522
+ }
6523
+ } catch (error) {
6524
+ debug("tick failed", { sessionID: id, error });
6525
+ } finally {
6526
+ state2.running = false;
6527
+ if (sessions.get(id) === state2 && state2.pending && !disabled && !disposed && state2.root !== false) {
6528
+ schedule(id, state2);
6529
+ }
6530
+ }
6531
+ }
6532
+ async function reprocess(id, abort) {
6533
+ if (reprocessing.has(id)) return "A timeline reprocess is already running for this session.";
6534
+ if (sessions.get(id)?.running) return "The timeline is currently summarizing this session; try again in a moment.";
6535
+ reprocessing.add(id);
6536
+ const targetFile = timelineFile(id);
6537
+ const temp = targetFile + ".reprocess." + process.pid + "." + Date.now() + ".tmp";
6538
+ let cursor;
6539
+ let entries = 0;
6540
+ let calls = 0;
6541
+ try {
6542
+ const infoRes = await client.session.get({ path: { id }, query: { directory } });
6543
+ const info = infoRes?.data;
6544
+ if (!info) return "Session not found.";
6545
+ const msgRes = await client.session.messages({ path: { id }, query: { directory } });
6546
+ const messages = msgRes?.data ?? [];
6547
+ writeFileSync8(temp, "");
6548
+ for (let i = 0; i < MAX_REPROCESS_SLICES; i++) {
6549
+ if (abort?.aborted) {
6550
+ try {
6551
+ rmSync3(temp, { force: true });
6552
+ } catch {
6553
+ }
6554
+ return `Aborted after ${calls} summarizer calls; previous timeline kept.`;
6555
+ }
6556
+ const excerpt = buildExcerpt(messages, cursor);
6557
+ if (!excerpt.text) break;
6558
+ const prior = readEntriesAt(temp, 4);
6559
+ const result = await summarize(id, info.title ?? "untitled", excerpt.text, prior, abort);
6560
+ calls++;
6561
+ if (!result.ok) {
6562
+ try {
6563
+ rmSync3(temp, { force: true });
6564
+ } catch {
6565
+ }
6566
+ return `Stopped after ${calls} summarizer calls: ${result.error}. Previous timeline kept.`;
6567
+ }
6568
+ if (result.bullets.length) {
6569
+ entries += appendEntriesAt(temp, result.bullets, Date.now(), excerpt.next);
6570
+ }
6571
+ cursor = excerpt.next;
6572
+ }
6573
+ if (abort?.aborted) {
6574
+ try {
6575
+ rmSync3(temp, { force: true });
6576
+ } catch {
6577
+ }
6578
+ return `Aborted after ${calls} summarizer calls; previous timeline kept.`;
6579
+ }
6580
+ if (entries === 0) {
6581
+ try {
6582
+ rmSync3(temp, { force: true });
6583
+ } catch {
6584
+ }
6585
+ return "No entries generated; previous timeline kept.";
6586
+ }
6587
+ renameSync6(temp, targetFile);
6588
+ const state2 = sessions.get(id);
6589
+ if (state2) {
6590
+ state2.cursor = cursor;
6591
+ state2.failures = 0;
6592
+ state2.pending = false;
6593
+ }
6594
+ return `Rebuilt ${messages.length} messages into ${entries} entries with ${calls} summarizer calls.`;
6595
+ } catch (error) {
6596
+ try {
6597
+ rmSync3(temp, { force: true });
6598
+ } catch {
6599
+ }
6600
+ return `Reprocess failed: ${String(error)}. Previous timeline kept.`;
6601
+ } finally {
6602
+ reprocessing.delete(id);
6603
+ }
6604
+ }
6605
+ const timelineReprocess = tool2({
6606
+ description: "Rebuild the current session's entire timeline from its transcript, replacing the existing entries. Used by /reprocess-timeline.",
6607
+ args: {},
6608
+ execute: async (_args, ctx) => {
6609
+ const output = await reprocess(ctx.sessionID, ctx.abort);
6610
+ return { title: "Timeline reprocess", output };
6611
+ }
6612
+ });
6613
+ return {
6614
+ event: async ({ event }) => {
6615
+ try {
6616
+ switch (event.type) {
6617
+ case "message.updated": {
6618
+ const id = event.properties.info?.sessionID;
6619
+ if (!id) return;
6620
+ if (owned.has(id)) return;
6621
+ const role = event.properties.info?.role;
6622
+ if (role === "user" || role === "assistant") note(id);
6623
+ return;
6624
+ }
6625
+ case "message.part.updated": {
6626
+ const id = event.properties.part?.sessionID;
6627
+ if (!id) return;
6628
+ if (owned.has(id)) return;
6629
+ const type = event.properties.part?.type;
6630
+ if (type === "text" || type === "tool") note(id);
6631
+ return;
6632
+ }
6633
+ case "session.created": {
6634
+ const info = event.properties.info;
6635
+ if (!info) return;
6636
+ if (isInternalTitle(info.title)) owned.add(info.id);
6637
+ return;
6638
+ }
6639
+ case "session.deleted": {
6640
+ const id = event.properties.info?.id;
6641
+ if (!id) return;
6642
+ const state2 = sessions.get(id);
6643
+ if (state2?.timer) {
6644
+ clearTimeout(state2.timer);
6645
+ state2.timer = void 0;
6646
+ }
6647
+ sessions.delete(id);
6648
+ removeEntries(id);
6649
+ owned.delete(id);
6650
+ return;
6651
+ }
6652
+ default:
6653
+ return;
6654
+ }
6655
+ } catch (error) {
6656
+ debug("event failed", error);
6657
+ }
6658
+ },
6659
+ dispose: async () => {
6660
+ disposed = true;
6661
+ for (const state2 of sessions.values()) {
6662
+ if (state2.timer) {
6663
+ clearTimeout(state2.timer);
6664
+ state2.timer = void 0;
6665
+ }
6666
+ }
6667
+ reprocessing.clear();
6668
+ },
6669
+ tool: { timeline_reprocess: timelineReprocess }
6670
+ };
6671
+ }
6672
+
4989
6673
  // src/index.ts
4990
6674
  var inertHooks = {
4991
6675
  config: async () => {
@@ -5021,13 +6705,15 @@ var LaCode = (async (input, options) => {
5021
6705
  return void 0;
5022
6706
  }
5023
6707
  };
5024
- const agentSet = buildAgentSet();
6708
+ const agentSet = buildAgentSet(resolveModels({ models: opts.models, modelsFile: opts.modelsFile }).slots.length);
5025
6709
  const pool = await settled("agent-pool", () => agentPoolHooks(input, options)) ?? inertHooks;
5026
6710
  const autoupdate = await settled("context-autoupdate", () => contextAutoUpdateHooks(input, options)) ?? inertHooks;
5027
6711
  const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
5028
6712
  const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
6713
+ const feedback = await settled("feedback", () => feedbackHooks(input, options)) ?? inertHooks;
5029
6714
  const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
5030
6715
  const imagepaste = await settled("image-paste", () => imagePasteHooks(input, opts.imagePaste)) ?? inertHooks;
6716
+ const timeline = await settled("timeline", () => timelineHooks(input, options)) ?? inertHooks;
5031
6717
  const config = async (cfg) => {
5032
6718
  try {
5033
6719
  const injected = applyAgents(cfg, agentSet);
@@ -5051,6 +6737,8 @@ var LaCode = (async (input, options) => {
5051
6737
  autoupdate.event?.(payload).catch(() => {
5052
6738
  }),
5053
6739
  notifier.event?.(payload).catch(() => {
6740
+ }),
6741
+ timeline.event?.(payload).catch(() => {
5054
6742
  })
5055
6743
  ]);
5056
6744
  };
@@ -5061,15 +6749,17 @@ var LaCode = (async (input, options) => {
5061
6749
  // only thing that routes task calls, so there is nothing to merge them with.
5062
6750
  "tool.execute.before": pool["tool.execute.before"],
5063
6751
  "tool.execute.after": pool["tool.execute.after"],
5064
- tool: pool.tool,
6752
+ tool: { ...pool.tool ?? {}, ...timeline.tool ?? {} },
5065
6753
  "command.execute.before": imagepaste["command.execute.before"],
5066
6754
  dispose: async () => {
5067
6755
  await pool.dispose?.();
5068
6756
  await autoupdate.dispose?.();
5069
6757
  await notifier.dispose?.();
5070
6758
  await selfupdate.dispose?.();
6759
+ await feedback.dispose?.();
5071
6760
  await omniroute.dispose?.();
5072
6761
  await imagepaste.dispose?.();
6762
+ await timeline.dispose?.();
5073
6763
  },
5074
6764
  "experimental.chat.system.transform": async (_input, output) => {
5075
6765
  if (!Array.isArray(output?.system)) return;