@lawaty/lacode 0.4.0 → 0.4.2

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
@@ -30,6 +30,7 @@ var ORPHAN_TMP_MS = 60 * 1e3;
30
30
  var CATALOG_TTL_MS = 10 * 60 * 1e3;
31
31
  var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
32
32
  var STUCK_IDLE_MS = 3 * 60 * 1e3;
33
+ var MAX_CLAIM_AGE_MS = 30 * 60 * 1e3;
33
34
  var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
34
35
  var UNAVAILABLE_COOLDOWN_MS = 60 * 1e3;
35
36
  var MAX_WEIGHT = 100;
@@ -203,6 +204,8 @@ var LIMIT_PATTERNS = [
203
204
  ];
204
205
  var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text));
205
206
  var isUnavailableText = (text) => UNAVAILABLE_PATTERN.test(text);
207
+ var TRANSIENT_GATEWAY_PATTERNS = [/queue budget/i, /exceeded queue/i, /aborted due to timeout/i, /timeout/i];
208
+ var isTransientGatewayText = (text) => TRANSIENT_GATEWAY_PATTERNS.some((re) => re.test(text));
206
209
  var statedCooldown = (text) => {
207
210
  const hm = /resets?\s+in\s+(\d+)\s*(?:h|hr|hour)s?\s*(\d+)?\s*(?:m|min|minute)?s?/i.exec(text);
208
211
  if (hm) return (Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3;
@@ -291,7 +294,7 @@ function publish(s) {
291
294
  if (!s.dirReady) return;
292
295
  const now = Date.now();
293
296
  const payload = {};
294
- for (const [key, claim] of s.own) if (now - claim.t <= CLAIM_TTL_MS) payload[key] = claim;
297
+ for (const [key, claim] of s.own) if (now - (claim.hb ?? claim.t) <= CLAIM_TTL_MS) payload[key] = claim;
295
298
  try {
296
299
  writeFileSync(join2(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
297
300
  renameSync(join2(s.dir, `${selfFile(s.id)}.tmp`), join2(s.dir, selfFile(s.id)));
@@ -510,6 +513,11 @@ function pickSlot(s, snap, options) {
510
513
  function agentFor(base, slot) {
511
514
  return `${base}-${slot}`;
512
515
  }
516
+ function manualVariant(name) {
517
+ const direct = /^(.*)-([1-9][0-9]*)$/.exec(name);
518
+ if (!direct) return void 0;
519
+ return BASES.includes(direct[1]) ? direct[1] : void 0;
520
+ }
513
521
  function acquire(s, base, slot, ownerSession, key, target) {
514
522
  const now = Date.now();
515
523
  s.own.set(key, { v: base, k: slot.index, m: slot.model, t: now, hb: now, s: ownerSession, g: target });
@@ -567,12 +575,17 @@ function idleFor(s, claim, now) {
567
575
  const seen = s.activity.get(target);
568
576
  return seen ? now - seen : Infinity;
569
577
  }
578
+ function condemnReason(s, claim, now = Date.now()) {
579
+ const age = now - claim.t;
580
+ if (age > MAX_CLAIM_AGE_MS) return "overran";
581
+ if (age > STUCK_MIN_AGE_MS && idleFor(s, claim, now) >= STUCK_IDLE_MS) return "hung";
582
+ return void 0;
583
+ }
570
584
  function findStuck(s, now = Date.now()) {
571
585
  const stuck = [];
572
586
  for (const [key, claim] of s.own) {
573
- if (now - claim.t <= STUCK_MIN_AGE_MS) continue;
574
- if (idleFor(s, claim, now) < STUCK_IDLE_MS) continue;
575
- stuck.push({ key, claim });
587
+ const reason = condemnReason(s, claim, now);
588
+ if (reason) stuck.push({ key, claim, reason });
576
589
  }
577
590
  return stuck;
578
591
  }
@@ -626,15 +639,9 @@ Every question of the form "what does this file do", "where is X handled", or
626
639
  that proves it. Never one whole refactor handed over in a single piece \u2014
627
640
  see \`# Task size\` below.
628
641
 
629
- The agents you spawn are the pooled \`explore\` / \`implement\` / \`context\`
630
- bases. **Always spawn the BARE base name and let the plugin choose the slot;
631
- never name a \`-N\` variant** (\`explore-1\`..\`explore-4\`, \`implement-1\`..,
632
- \`context-1\`..). The plugin owns which model every pooled spawn runs on: it
633
- picks the next slot from the pool's weights, current load and cooldowns, and
634
- rewrites your spawn to that variant for you. Naming a variant bypasses that
635
- decision, defeats failover, and can pin work to a cooling or unhealthy model.
636
- If you catch yourself choosing a slot number, you are doing the plugin's job \u2014
637
- spawn \`explore\` and let it decide.
642
+ Spawn the BARE pooled base \u2014 \`explore\`, \`implement\` or \`context\`. The pool
643
+ refuses a \`-N\` variant, and a refusal means spawn the bare base, never a
644
+ different number.
638
645
 
639
646
  # When the pool is unavailable
640
647
 
@@ -754,10 +761,8 @@ When you need to understand the codebase:
754
761
  2. Invoke @explore with specific, focused instructions (e.g. "read src/auth.ts", "grep for 'migration' in *.py files", "list the src/ directory")
755
762
  3. @explore will return its findings \u2014 you process them and plan accordingly
756
763
 
757
- Always spawn the BARE pooled base (\`explore\`, \`implement\`, \`context\`) and let
758
- the plugin choose the slot. Never name a \`-N\` variant (\`explore-1\`..\`explore-4\`);
759
- doing so bypasses the pool's weights, load and cooldowns, which is the one thing
760
- the plugin exists to decide.
764
+ Spawn the BARE pooled base (\`explore\`, \`implement\`, \`context\`); the pool
765
+ refuses \`-N\` variants, and a refusal means spawn the bare base, not another number.
761
766
 
762
767
  If the pool cannot serve a spawn \u2014 every slot cooling or exhausted, or repeated
763
768
  failures \u2014 do NOT fall back to reading or planning from your own inspection.
@@ -1015,6 +1020,30 @@ You own exactly these five files and nothing else:
1015
1020
  during work that are not already recorded in the project's own decision
1016
1021
  log. Concise; variable length.
1017
1022
 
1023
+ A sixth file sits in the same directory and is not yours:
1024
+ \`.opencode/context/manager.md\`. It is the client's own statement of intent
1025
+ \u2014 what is being worked on, what comes next, what they have settled, and
1026
+ what every agent should keep in mind. It deliberately says nothing about
1027
+ where code lives: your five files and the source itself are the only
1028
+ authority on that, and this file never contradicts them. It has no fixed
1029
+ schema \u2014 read the whole file, because every heading in it is direction.
1030
+
1031
+ You never create, edit, rename, or delete it \u2014 not at bootstrap, not in an
1032
+ update, not in a review, and not when a run of your own concludes the work
1033
+ has moved on. The client edits it, or asks a live agent to edit it on
1034
+ their explicit instruction. Either way it is their hand, never yours.
1035
+
1036
+ Read it before you write anything. Its \`## Current\` and \`## Next\`
1037
+ sections tell you what is actually being worked on, and \`## Confirmed\`
1038
+ tells you which of your facts the client has ratified. A fact you are
1039
+ writing that appears under \`## Confirmed\` may be stated plainly, without
1040
+ a confidence marker; anything else follows your normal rules \u2014 infer
1041
+ carefully, mark uncertainty. Never copy its content into the five files.
1042
+
1043
+ A missing or empty section is not an instruction. It means the client has
1044
+ not said, not that you may assume anything \u2014 proceed exactly as you would
1045
+ without the file.
1046
+
1018
1047
  Never create, modify, or delete anything outside \`.opencode/context/\`. The
1019
1048
  \`edit\` permission enforces this. Do not attempt to work around it with bash.
1020
1049
 
@@ -1049,6 +1078,11 @@ Then write the five files. For each significant subsystem, give a short
1049
1078
  reason. This is the single most valuable thing you produce: where an agent
1050
1079
  should open its editor next.
1051
1080
 
1081
+ manager.md is created for the client by the plugin, not by you; if it is
1082
+ missing anyway, mention once in your final report that they can add it
1083
+ themselves to record what is being worked on, what comes next, and what
1084
+ they have ratified. Never create it.
1085
+
1052
1086
  # Incremental update
1053
1087
 
1054
1088
  Trigger: \`.opencode/context/\` exists and work has just been done here.
@@ -1076,6 +1110,14 @@ that is already stated in another context file \u2014 one fact has one home, and
1076
1110
  the other files link to it. If nothing material changed, make no edits and
1077
1111
  report "no update needed".
1078
1112
 
1113
+ Before editing, re-read manager.md: its \`## Current\` and \`## Next\`
1114
+ sections tell you what the client is actually working on, and its
1115
+ \`## Confirmed\` section settles which of your facts are ratified. If what
1116
+ you are about to write would place the code somewhere the client never
1117
+ described, trust the code, make the edit the code justifies, and report
1118
+ the gap in your final message \u2014 never reconcile the two sides silently,
1119
+ and never edit manager.md to close it.
1120
+
1079
1121
  # Minimal exploration
1080
1122
 
1081
1123
  Default shape:
@@ -1170,9 +1212,14 @@ Audit the five files for:
1170
1212
  - duplicated information \u2014 one canonical place per fact
1171
1213
  - missing canonical entry points for significant subsystems
1172
1214
  - unsupported assumptions \u2014 anything unmarked that you cannot verify
1215
+ - manager.md, if present: is \`## Current\` and \`## Next\` still true of
1216
+ recent work, is every \`## Confirmed\` entry still true of the code, has
1217
+ it outgrown roughly 150 lines \u2014 report findings, but never edit, prune,
1218
+ or reorganize it; that is the client's call
1173
1219
 
1174
- Fix problems only in \`.opencode/context/\`. Never modify source code during a
1175
- review. Report what you found, what you fixed, and what you could not verify.
1220
+ Fix problems only in the five files you own \u2014 never in manager.md, which
1221
+ belongs to the client. Never modify source code during a review. Report
1222
+ what you found, what you fixed, and what you could not verify.
1176
1223
 
1177
1224
  # Reporting
1178
1225
 
@@ -1190,7 +1237,9 @@ Finish with a short report:
1190
1237
  "edit": {
1191
1238
  "*": "deny",
1192
1239
  "*/.opencode/context/**": "allow",
1193
- ".opencode/context/**": "allow"
1240
+ ".opencode/context/**": "allow",
1241
+ "*/.opencode/context/manager.md": "deny",
1242
+ ".opencode/context/manager.md": "deny"
1194
1243
  },
1195
1244
  "bash": {
1196
1245
  "*": "deny",
@@ -1292,7 +1341,13 @@ Remember: write conclusions, not inventories. Link to the project's existing
1292
1341
  documentation instead of duplicating it.
1293
1342
 
1294
1343
  Report what you created or updated, what you deliberately left out, and
1295
- anything that contradicts the code or existing docs.`
1344
+ anything that contradicts the code or existing docs.
1345
+
1346
+ If \`.opencode/context/manager.md\` does not exist, mention in your final
1347
+ report that the client can add it themselves \u2014 their own file with
1348
+ \`## Current\`, \`## Next\`, \`## Confirmed\` and \`## Instructions\` \u2014 for what
1349
+ is being worked on, what comes next, what they have ratified, and standing
1350
+ direction. Never create it.`
1296
1351
  },
1297
1352
  "context-update": {
1298
1353
  description: "Incrementally update .opencode/context/ after recent work via the context agent.",
@@ -1323,8 +1378,9 @@ timestamp-only edits. If nothing material changed, make no edits and report
1323
1378
  subtask: true,
1324
1379
  template: `Context review.
1325
1380
 
1326
- Everything outside \`.opencode/context/\` is read-only for this run. Do not
1327
- modify source code under any circumstances.
1381
+ Everything outside the five context files is read-only for this run \u2014
1382
+ including \`manager.md\`, which belongs to the client. Do not modify source
1383
+ code under any circumstances.
1328
1384
 
1329
1385
  Audit the five context files for:
1330
1386
 
@@ -1337,6 +1393,9 @@ Audit the five context files for:
1337
1393
  - missing canonical entry points for significant subsystems
1338
1394
  - unsupported assumptions \u2014 anything stated as fact that you cannot verify,
1339
1395
  or that lacks a confidence marker
1396
+ - \`manager.md\`, if present: is \`## Current\` and \`## Next\` still true of
1397
+ recent work, is every \`## Confirmed\` entry still true of the code, has it
1398
+ outgrown roughly 150 lines \u2014 report findings, but never edit it
1340
1399
 
1341
1400
  Fix what you find in \`.opencode/context/\` only, then report: findings, fixes,
1342
1401
  and anything you could not verify.`
@@ -1367,7 +1426,8 @@ Print one PASS/FAIL line per check below, with the exact fix for any FAIL.
1367
1426
  trap: an id that is a valid gateway route but is NOT in the plugin's
1368
1427
  \`omniroute.models\` allowlist fails with
1369
1428
  \`ProviderModelNotFoundError: Model not found\`; the fix is to add that id to
1370
- the \`models\` array, or use the always-registered \`auto\`.
1429
+ the \`models\` array, or set it as \`combo\` \u2014 the combo id is registered
1430
+ whether or not the gateway advertises it, so it needs no allowlist entry.
1371
1431
 
1372
1432
  Change nothing. Print a final one-line summary of failures.`
1373
1433
  }
@@ -1386,22 +1446,43 @@ function applyCommands(cfg, commands = COMMANDS) {
1386
1446
  }
1387
1447
  return { added, kept };
1388
1448
  }
1449
+ var INSTRUCTION_MARKER = "<!-- lacode:context-map-instruction:v1 -->";
1450
+ var INSTRUCTION_OPT_OUT = "<!-- lacode:context-map-instruction:off -->";
1389
1451
  var STANDING_INSTRUCTION = `## Repository context map
1390
1452
 
1391
1453
  This project may keep a navigation map in \`.opencode/context/\`. Before broad
1392
1454
  exploration, check whether it exists (\`ls .opencode/context\` \u2014 never the \`glob\`
1393
- tool, which skips hidden directories). If it does, read only the file relevant to
1394
- the task \u2014 architecture.md, contexts.md, conventions.md, workflows.md,
1395
- decisions.md \u2014 then start from the canonical entry points it lists.
1396
-
1397
- Source code is authoritative: if the map disagrees with the code, trust the code,
1398
- say so, and never edit source to match the map. Never edit \`.opencode/context/\`
1399
- yourself \u2014 the context agent owns it. Say in your final report if your
1400
- work revealed something that belongs in the map.`;
1455
+ tool, which skips hidden directories). If \`manager.md\` exists there, read it
1456
+ first: it is the client's own statement of intent \u2014 what is being worked on,
1457
+ what comes next, \`## Confirmed\` lists what the client has ratified, and
1458
+ \`## Instructions\` holds standing managerial directions. It outranks every
1459
+ other context file and every inference of yours about what matters right now.
1460
+ It says nothing about where code lives, so it cannot contradict the code;
1461
+ treat its \`## Confirmed\` entries as settled decisions, not as a licence to
1462
+ skip reading the code. Any heading in it is direction, so read the whole
1463
+ file rather than only the sections above. The rest of the map remains usable,
1464
+ just unratified where \`## Confirmed\` does not cover it. Then read only the
1465
+ file relevant to the task \u2014 \`architecture.md\`, \`contexts.md\`,
1466
+ \`conventions.md\`, \`workflows.md\`, \`decisions.md\` \u2014 and start from the
1467
+ canonical entry points it lists.
1468
+
1469
+ Source code is authoritative: if the map disagrees with the code,
1470
+ trust the code and say so; never edit source to match it.
1471
+ Never edit the five context files yourself \u2014 the context agent owns them.
1472
+ \`manager.md\` is the client's own file and the context agent is explicitly
1473
+ permission-denied from writing it. You may write it only when the client
1474
+ explicitly asks you to in this conversation, and then only what they asked
1475
+ for; never on your own initiative, and never to "keep it up to date". Say in
1476
+ your final report if your work revealed something that belongs to the map, or
1477
+ if \`manager.md\` and the code disagree.`;
1401
1478
  function applyInstructions(cfg) {
1402
- const existing = cfg.instructions;
1403
- if (Array.isArray(existing) && existing.length > 0) return { added: false };
1404
- cfg.instructions = [STANDING_INSTRUCTION];
1479
+ const existing = Array.isArray(cfg.instructions) ? cfg.instructions : [];
1480
+ const texts = existing.filter((e) => typeof e === "string");
1481
+ if (texts.some((e) => e.includes(INSTRUCTION_OPT_OUT))) return { added: false };
1482
+ if (texts.some((e) => e.includes(INSTRUCTION_MARKER))) return { added: false };
1483
+ cfg.instructions = [...existing, `${INSTRUCTION_MARKER}
1484
+
1485
+ ${STANDING_INSTRUCTION}`];
1405
1486
  return { added: true };
1406
1487
  }
1407
1488
 
@@ -1454,7 +1535,7 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1454
1535
  const stuck = findStuck(s);
1455
1536
  if (stuck.length === 0) return;
1456
1537
  const released = [];
1457
- for (const { key, claim } of stuck) {
1538
+ for (const { key, claim, reason } of stuck) {
1458
1539
  if (!claim.g) backfillChild(s, claim);
1459
1540
  const idle = idleFor(s, claim, Date.now());
1460
1541
  const since = Number.isFinite(idle) ? `${Math.round(idle / 1e3)}s of silence` : "no child session ever bound";
@@ -1466,7 +1547,7 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1466
1547
  s.reaped++;
1467
1548
  await log(
1468
1549
  "warn",
1469
- `hung ${claim.v} on ${claim.m} after ${age}s with ${since}; aborted session ${claim.g}`,
1550
+ 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}`,
1470
1551
  { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: true }
1471
1552
  );
1472
1553
  await coolForHang(claim, log);
@@ -1513,7 +1594,7 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1513
1594
  timer.unref?.();
1514
1595
  void log(
1515
1596
  "info",
1516
- `reaper armed: every ${Math.round(reapMs / 1e3)}s, aborts a claim older than ${Math.round(STUCK_MIN_AGE_MS / 6e4)}m whose target has been silent ${Math.round(STUCK_IDLE_MS / 6e4)}m`
1597
+ `reaper armed: every ${Math.round(reapMs / 1e3)}s, aborts a claim older than ${Math.round(STUCK_MIN_AGE_MS / 6e4)}m whose target has been silent ${Math.round(STUCK_IDLE_MS / 6e4)}m, or any claim past the ${Math.round(MAX_CLAIM_AGE_MS / 6e4)}m ceiling however chatty`
1517
1598
  );
1518
1599
  return {
1519
1600
  config: async (cfg) => {
@@ -1592,9 +1673,12 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1592
1673
  if (!info || info.role !== "assistant" || !info.error) return;
1593
1674
  const haystack = JSON.stringify(info.error) ?? "";
1594
1675
  const status = typeof info.error.data?.statusCode === "number" ? info.error.data.statusCode : void 0;
1595
- if (!isLimitText(haystack) && status !== 429) return;
1676
+ const limited = isLimitText(haystack) || status === 429;
1677
+ const gateway5xx = status !== void 0 && status >= 500 && status <= 599;
1678
+ const transientGateway = gateway5xx || isTransientGatewayText(haystack);
1679
+ if (!limited && !transientGateway) return;
1596
1680
  const model = `${info.providerID ?? "?"}/${info.modelID ?? "?"}`;
1597
- if (isUnavailableText(haystack)) {
1681
+ if (isUnavailableText(haystack) || !limited && transientGateway) {
1598
1682
  const transient = coolUnavailable(s, model);
1599
1683
  if (transient) await log("warn", `cooling ${model} for ${Math.round((transient.until - Date.now()) / 1e3)}s (${transient.reason})`);
1600
1684
  return;
@@ -1609,9 +1693,19 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1609
1693
  },
1610
1694
  "tool.execute.before": async (input, output) => {
1611
1695
  if (input.tool !== "task") return;
1696
+ const requested = output.args?.subagent_type;
1697
+ if (typeof requested !== "string") return;
1698
+ const manual = manualVariant(requested);
1699
+ if (manual) {
1700
+ await log("warn", `refused manual spawn of slot variant ${requested}; spawn "${manual}" instead`, {
1701
+ variant: requested,
1702
+ base: manual
1703
+ });
1704
+ throw new Error(
1705
+ `${requested} is a pool slot variant and cannot be spawned by name. Naming it lets the caller pick the provider: it skips the round-robin rotation and the slot's ceiling, so one provider takes every concurrent claim. Spawn "${manual}" and let the pool choose the slot.`
1706
+ );
1707
+ }
1612
1708
  try {
1613
- const requested = output.args?.subagent_type;
1614
- if (typeof requested !== "string") return;
1615
1709
  await reapStuck();
1616
1710
  refreshCatalog(s, serverUrl);
1617
1711
  const snap = snapshot(s);
@@ -1633,11 +1727,6 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1633
1727
  });
1634
1728
  return;
1635
1729
  }
1636
- const direct = /^(.*)-([1-9][0-9]*)$/.exec(requested);
1637
- if (direct && BASES.includes(direct[1])) {
1638
- const slot = s.slots.find((sl) => sl.index === Number(direct[2]));
1639
- if (slot) acquire(s, direct[1], slot, input.sessionID, claimKey(input.sessionID, input.callID));
1640
- }
1641
1730
  } catch (error) {
1642
1731
  await log("error", `routing failed, spawn unchanged: ${String(error)}`);
1643
1732
  }
@@ -1681,7 +1770,9 @@ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1681
1770
  };
1682
1771
  const total = [...snap.counts.values()].reduce((sum, n) => sum + n, 0);
1683
1772
  const coolingNow = [...snap.cooling.entries()].filter(([, st]) => st.until > now);
1684
- const hung = [...snap.own.values()].filter((c) => now - c.t > STUCK_MIN_AGE_MS && idleFor(s, c, now) >= STUCK_IDLE_MS).length;
1773
+ const verdicts = [...snap.own.values()].map((claim) => condemnReason(s, claim, now));
1774
+ const hung = verdicts.filter((verdict) => verdict === "hung").length;
1775
+ const overran = verdicts.filter((verdict) => verdict === "overran").length;
1685
1776
  return {
1686
1777
  title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
1687
1778
  output: [
@@ -1695,6 +1786,7 @@ round robin in slot order, each slot taking as many turns as its weight says, so
1695
1786
  "",
1696
1787
  `${total} claim(s) in flight across ${snap.siblings.length + 1} process(es).`,
1697
1788
  `hung past ${Math.round(STUCK_MIN_AGE_MS / 6e4)}min with ${Math.round(STUCK_IDLE_MS / 6e4)}min silence: ${hung} (auto-aborted); reaped total: ${s.reaped}.`,
1789
+ `overran past ${Math.round(MAX_CLAIM_AGE_MS / 6e4)}min: ${overran} (auto-aborted).`,
1698
1790
  `watchdog alive: last sweep ${s.lastSweep ? `${Math.max(0, Math.round((now - s.lastSweep) / 1e3))}s ago` : "never"} (every ${Math.round(reapMs / 1e3)}s).`,
1699
1791
  coolingNow.length === 0 ? "no models cooling down." : `cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
1700
1792
  ].join("\n")
@@ -1711,28 +1803,10 @@ round robin in slot order, each slot taking as many turns as its weight says, so
1711
1803
  });
1712
1804
 
1713
1805
  // src/plugins/context-autoupdate.ts
1714
- import { existsSync as existsSync2, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
1806
+ import { existsSync as existsSync2, readdirSync as readdirSync2, statSync as statSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
1715
1807
  import * as path2 from "node:path";
1716
1808
 
1717
- // src/lib/writer-rule.ts
1718
- import { readFileSync as readFileSync3 } from "node:fs";
1719
- import * as path from "node:path";
1720
- var CONTEXT_DIR = path.join(".opencode", "context");
1721
- var WRITER = "context";
1722
- var toPosix = (value) => value.split(path.sep).join("/");
1723
- var compilePattern = (pattern) => {
1724
- const escaped = toPosix(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
1725
- return new RegExp(`^${escaped}$`, "s");
1726
- };
1727
- var winningRule = (rule, file) => {
1728
- if (typeof rule === "string") return { pattern: "<whole tool>", action: rule };
1729
- if (!rule || typeof rule !== "object") return void 0;
1730
- let winner;
1731
- for (const [pattern, action] of Object.entries(rule)) {
1732
- if (compilePattern(pattern).test(file)) winner = { pattern, action: String(action) };
1733
- }
1734
- return winner;
1735
- };
1809
+ // src/lib/jsonc.ts
1736
1810
  var stripJsonc = (raw) => {
1737
1811
  let out = "";
1738
1812
  let i = 0;
@@ -1771,9 +1845,36 @@ var stripJsonc = (raw) => {
1771
1845
  }
1772
1846
  return out.replace(/,(\s*[}\]])/g, "$1");
1773
1847
  };
1848
+ var parseJsonc = (raw) => {
1849
+ try {
1850
+ return JSON.parse(stripJsonc(raw));
1851
+ } catch {
1852
+ return void 0;
1853
+ }
1854
+ };
1855
+
1856
+ // src/lib/writer-rule.ts
1857
+ import { readFileSync as readFileSync3 } from "node:fs";
1858
+ import * as path from "node:path";
1859
+ var CONTEXT_DIR = path.join(".opencode", "context");
1860
+ var WRITER = "context";
1861
+ var toPosix = (value) => value.split(path.sep).join("/");
1862
+ var compilePattern = (pattern) => {
1863
+ const escaped = toPosix(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
1864
+ return new RegExp(`^${escaped}$`, "s");
1865
+ };
1866
+ var winningRule = (rule, file) => {
1867
+ if (typeof rule === "string") return { pattern: "<whole tool>", action: rule };
1868
+ if (!rule || typeof rule !== "object") return void 0;
1869
+ let winner;
1870
+ for (const [pattern, action] of Object.entries(rule)) {
1871
+ if (compilePattern(pattern).test(file)) winner = { pattern, action: String(action) };
1872
+ }
1873
+ return winner;
1874
+ };
1774
1875
  var readAgents = (raw) => {
1775
1876
  try {
1776
- const agents = JSON.parse(stripJsonc(raw)).agent;
1877
+ const agents = parseJsonc(raw).agent;
1777
1878
  if (agents && typeof agents === "object" && !Array.isArray(agents)) return agents;
1778
1879
  return {};
1779
1880
  } catch {
@@ -1785,6 +1886,10 @@ var evaluatedForm = (mapDir, projectRoot) => {
1785
1886
  const absolute = toPosix(path.join(mapDir, "architecture.md"));
1786
1887
  return projectRoot ? toPosix(path.relative(projectRoot, absolute)) : absolute;
1787
1888
  };
1889
+ var managerForm = (mapDir, projectRoot) => {
1890
+ const absolute = toPosix(path.join(mapDir, "manager.md"));
1891
+ return projectRoot ? toPosix(path.relative(projectRoot, absolute)) : absolute;
1892
+ };
1788
1893
  var verifyWriterRule = (configPath, mapDir, projectRoot) => {
1789
1894
  let raw;
1790
1895
  try {
@@ -1816,6 +1921,18 @@ var verifyWriterRule = (configPath, mapDir, projectRoot) => {
1816
1921
  return `context map has more than one writer: ${offenders.join(", ")} would be granted the map by permission, evaluating "${form}". Only ${WRITER} may write the map. Narrow the grant and put the deny LAST -- opencode takes the LAST matching rule, so "${CONTEXT_DIR}/**": "deny" written after "*": "allow" is what denies it: "*": "allow", "*/${CONTEXT_DIR}/**": "deny", "${CONTEXT_DIR}/**": "deny". Both spellings are required because the evaluated path depends on the project root.`;
1817
1922
  }
1818
1923
  }
1924
+ const managerPath = managerForm(mapDir, projectRoot);
1925
+ const agentDefs = readAgents(raw);
1926
+ if (agentDefs) {
1927
+ const writers = [];
1928
+ for (const [name, def] of Object.entries(agentDefs)) {
1929
+ const win = winningRule(permissionOf(def), managerPath);
1930
+ if (win?.action === "allow") writers.push(`${name} (edit rule "${win.pattern}": "${win.action}")`);
1931
+ }
1932
+ if (writers.length > 0) {
1933
+ return `${CONTEXT_DIR}/manager.md is writable by permission: ${writers.join(", ")}. It is the client's own file \u2014 what they are working on, what is ratified, what every agent must respect \u2014 and no agent writes it. Deny it LAST, after any broad allow, because opencode takes the LAST matching rule: "*": "allow", "*/${CONTEXT_DIR}/manager.md": "deny", "${CONTEXT_DIR}/manager.md": "deny". Declare this on every agent above, including \`${WRITER}\`: an agent you define yourself replaces the shipped one wholesale, permission and all.`;
1934
+ }
1935
+ }
1819
1936
  return void 0;
1820
1937
  };
1821
1938
 
@@ -1842,6 +1959,51 @@ var IGNORED_SEGMENTS = /* @__PURE__ */ new Set([
1842
1959
  ".cache",
1843
1960
  ".git"
1844
1961
  ]);
1962
+ var MANAGER_TEMPLATE = `# Managerial directions
1963
+
1964
+ This file is yours. No agent writes it: the context agent is explicitly denied
1965
+ permission to touch this path, and nothing changes it except you, or an agent
1966
+ acting on your explicit instruction in that conversation.
1967
+
1968
+ It records what you want done and how you want it approached \u2014 what is being
1969
+ worked on, what comes next, what you have settled, and what every agent should
1970
+ keep in mind. It deliberately says nothing about where code lives: the source
1971
+ and the five generated context files are the only authority on that, so this
1972
+ file can never contradict the code.
1973
+
1974
+ Add any headings you like. The four below are a convenience, not a schema \u2014
1975
+ every heading in this file is direction.
1976
+
1977
+ ## Current
1978
+
1979
+ <!-- What is being worked on right now. Rewrite it; do not append a history. -->
1980
+
1981
+ ## Next
1982
+
1983
+ <!-- What comes next, in order. -->
1984
+
1985
+ ## Confirmed
1986
+
1987
+ <!-- What you have personally ratified. Nothing enters or leaves this list
1988
+ except by your hand \u2014 no agent promotes, prunes or rewrites it. -->
1989
+
1990
+ ## Instructions
1991
+
1992
+ <!-- Standing direction that applies beyond the current task: priorities, things
1993
+ to care about, constraints that are not obvious from the code. -->`;
1994
+ var ensureManagerFile = (root) => {
1995
+ try {
1996
+ const target = path2.join(root, ".opencode", "context", "manager.md");
1997
+ if (existsSync2(target)) return { created: false };
1998
+ mkdirSync2(path2.dirname(target), { recursive: true });
1999
+ writeFileSync2(target, MANAGER_TEMPLATE, { flag: "wx" });
2000
+ return { created: true };
2001
+ } catch (error) {
2002
+ if (error?.code === "EEXIST") return { created: false };
2003
+ console.warn(`[context-autoupdate] ensureManagerFile failed: ${error}`);
2004
+ return { created: false };
2005
+ }
2006
+ };
1845
2007
  var toPosix2 = (value) => value.split(path2.sep).join("/");
1846
2008
  var findProjectConfig = () => {
1847
2009
  let dir = import.meta.dir ?? path2.dirname(new URL(import.meta.url).pathname);
@@ -1885,6 +2047,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
1885
2047
  });
1886
2048
  const root = path2.resolve(directory);
1887
2049
  const contextAbs = path2.join(root, CONTEXT_DIR2);
2050
+ if (ensureManagerFile(root).created) void log("info", `created ${CONTEXT_DIR2}/manager.md`);
1888
2051
  const config = findProjectConfig();
1889
2052
  const writerProblem = config ? verifyWriterRule(config, contextAbs, root) : void 0;
1890
2053
  if (writerProblem) void log("error", writerProblem);
@@ -1944,7 +2107,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
1944
2107
  const requeue = () => {
1945
2108
  for (const file of files) pending.add(file);
1946
2109
  };
1947
- const mode = existsSync2(contextAbs) ? "update" : "bootstrap";
2110
+ const mode = existsSync2(path2.join(contextAbs, "contexts.md")) ? "update" : "bootstrap";
1948
2111
  init(state2, (m) => void log("warn", m));
1949
2112
  const snap = snapshot(state2);
1950
2113
  const slot = pickSlot(state2, snap);
@@ -2844,7 +3007,7 @@ var omniRouteHooks = async (input, options, deps = {}) => {
2844
3007
 
2845
3008
  // src/lib/selfupdate.ts
2846
3009
  import { spawn as spawn2 } from "node:child_process";
2847
- import { closeSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2, writeSync } from "node:fs";
3010
+ import { closeSync, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3, writeSync } from "node:fs";
2848
3011
  import { homedir as homedir2 } from "node:os";
2849
3012
  import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
2850
3013
  var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
@@ -2869,9 +3032,17 @@ function compareSemver(a, b) {
2869
3032
  }
2870
3033
  return 0;
2871
3034
  }
2872
- function readInstalledVersion(packageRoot) {
3035
+ function readInstalledVersion(packageRoot, read = (path4) => {
3036
+ try {
3037
+ return readFileSync4(path4, "utf8");
3038
+ } catch {
3039
+ return void 0;
3040
+ }
3041
+ }) {
2873
3042
  try {
2874
- const manifest = JSON.parse(readFileSync4(join6(packageRoot, "package.json"), "utf8"));
3043
+ const raw = read(join6(packageRoot, "package.json"));
3044
+ if (raw === void 0) return void 0;
3045
+ const manifest = JSON.parse(raw);
2875
3046
  if (manifest.name !== PACKAGE_NAME) return void 0;
2876
3047
  return typeof manifest.version === "string" ? manifest.version : void 0;
2877
3048
  } catch {
@@ -2930,8 +3101,8 @@ function readState(path4 = STATE_PATH) {
2930
3101
  }
2931
3102
  function writeState(state2, path4 = STATE_PATH) {
2932
3103
  try {
2933
- mkdirSync2(dirname3(path4), { recursive: true });
2934
- writeFileSync2(path4, JSON.stringify(state2));
3104
+ mkdirSync3(dirname3(path4), { recursive: true });
3105
+ writeFileSync3(path4, JSON.stringify(state2));
2935
3106
  } catch {
2936
3107
  }
2937
3108
  }
@@ -2957,7 +3128,7 @@ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
2957
3128
  };
2958
3129
  };
2959
3130
  try {
2960
- mkdirSync2(dirname3(path4), { recursive: true });
3131
+ mkdirSync3(dirname3(path4), { recursive: true });
2961
3132
  return claim();
2962
3133
  } catch {
2963
3134
  let orphaned = false;
@@ -3146,7 +3317,7 @@ var LaCode = (async (input, options) => {
3146
3317
  await log("info", `commands: added ${cmds.added.join(", ")}; kept ${cmds.kept.join(", ") || "none"} already defined`);
3147
3318
  }
3148
3319
  if (applyInstructions(cfg).added) {
3149
- await log("info", "instructions: added the standing context-map rule (no user instructions were present)");
3320
+ await log("info", "instructions: ensured the standing context-map rule is present");
3150
3321
  }
3151
3322
  await omniroute.config?.(cfg);
3152
3323
  await pool.config?.(cfg);