@lawaty/lacode 1.0.2 → 1.1.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
@@ -106,25 +106,25 @@ function parseSlotText(text, origin) {
106
106
  }
107
107
  return parseTextSlots(text, origin);
108
108
  }
109
- var readJSON = (path4) => {
109
+ var readJSON = (path5) => {
110
110
  try {
111
- return { ok: true, value: JSON.parse(readFileSync2(path4, "utf8")) };
111
+ return { ok: true, value: JSON.parse(readFileSync2(path5, "utf8")) };
112
112
  } catch (error) {
113
113
  return { ok: false, error: String(error) };
114
114
  }
115
115
  };
116
- function readOptional(path4, origin, problems) {
117
- const result = readJSON(path4);
116
+ function readOptional(path5, origin, problems) {
117
+ const result = readJSON(path5);
118
118
  if (!result.ok) {
119
- if (existsSync(path4)) problems.push(`${origin} unreadable (${result.error}); trying the next source`);
119
+ if (existsSync(path5)) problems.push(`${origin} unreadable (${result.error}); trying the next source`);
120
120
  return void 0;
121
121
  }
122
122
  return result.value;
123
123
  }
124
- function readTextOptional(path4, origin, problems) {
125
- if (!existsSync(path4)) return void 0;
124
+ function readTextOptional(path5, origin, problems) {
125
+ if (!existsSync(path5)) return void 0;
126
126
  try {
127
- return readFileSync2(path4, "utf8");
127
+ return readFileSync2(path5, "utf8");
128
128
  } catch (error) {
129
129
  problems.push(`${origin} unreadable (${String(error)}); trying the next source`);
130
130
  return void 0;
@@ -163,14 +163,14 @@ function resolveModels(input = {}) {
163
163
  }
164
164
  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
165
  }
166
- function readSlotFile(path4) {
166
+ function readSlotFile(path5) {
167
167
  let text;
168
168
  try {
169
- text = readFileSync2(path4, "utf8");
169
+ text = readFileSync2(path5, "utf8");
170
170
  } catch (error) {
171
- return { slots: FALLBACK_SLOTS, problems: [`${path4} unreadable (${String(error)}); using built-in defaults`] };
171
+ return { slots: FALLBACK_SLOTS, problems: [`${path5} unreadable (${String(error)}); using built-in defaults`] };
172
172
  }
173
- return parseSlotText(text, path4);
173
+ return parseSlotText(text, path5);
174
174
  }
175
175
  function ensureUserConfigDir() {
176
176
  try {
@@ -433,16 +433,16 @@ function readSiblings(s) {
433
433
  }
434
434
  const now = Date.now();
435
435
  for (const entry of entries) {
436
- const path4 = join2(s.dir, entry);
436
+ const path5 = join2(s.dir, entry);
437
437
  let age;
438
438
  try {
439
- age = now - statSync(path4).mtimeMs;
439
+ age = now - statSync(path5).mtimeMs;
440
440
  } catch {
441
441
  continue;
442
442
  }
443
443
  if (entry.endsWith(".tmp")) {
444
444
  if (age > ORPHAN_TMP_MS) try {
445
- unlinkSync(path4);
445
+ unlinkSync(path5);
446
446
  } catch {
447
447
  }
448
448
  continue;
@@ -451,20 +451,20 @@ function readSiblings(s) {
451
451
  if (!match || match[1] === s.id) continue;
452
452
  if (age > DEAD_FILE_MS) {
453
453
  try {
454
- unlinkSync(path4);
454
+ unlinkSync(path5);
455
455
  } catch {
456
456
  }
457
457
  continue;
458
458
  }
459
459
  if (/^\d+$/.test(match[1]) && !isPidAlive(Number(match[1]))) {
460
460
  try {
461
- unlinkSync(path4);
461
+ unlinkSync(path5);
462
462
  } catch {
463
463
  }
464
464
  continue;
465
465
  }
466
466
  try {
467
- const parsed = JSON.parse(readFileSync2(path4, "utf8"));
467
+ const parsed = JSON.parse(readFileSync2(path5, "utf8"));
468
468
  const claims = Object.values(parsed).filter(
469
469
  (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - (c.hb ?? c.t) <= CLAIM_TTL_MS
470
470
  );
@@ -488,9 +488,9 @@ function readLimits(s) {
488
488
  const now = Date.now();
489
489
  for (const entry of entries) {
490
490
  if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
491
- const path4 = join2(s.dir, entry);
491
+ const path5 = join2(s.dir, entry);
492
492
  try {
493
- const strike = JSON.parse(readFileSync2(path4, "utf8"));
493
+ const strike = JSON.parse(readFileSync2(path5, "utf8"));
494
494
  if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
495
495
  const countsAsStrike = strike.reason !== UNAVAILABLE_REASON;
496
496
  if (strike.until > now) {
@@ -499,11 +499,11 @@ function readLimits(s) {
499
499
  } else if (typeof strike.last === "number" && now - strike.last < STRIKE_DECAY_MS) {
500
500
  if (countsAsStrike) memory.set(strike.model, strike);
501
501
  } else {
502
- unlinkSync(path4);
502
+ unlinkSync(path5);
503
503
  }
504
504
  } catch {
505
505
  try {
506
- unlinkSync(path4);
506
+ unlinkSync(path5);
507
507
  } catch {
508
508
  }
509
509
  }
@@ -525,10 +525,10 @@ function recordStrike(s, model, reason, stated) {
525
525
  const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
526
526
  s.cooling.set(model, strike);
527
527
  s.strikes.set(model, strike);
528
- const path4 = join2(s.dir, limitFile(model));
528
+ const path5 = join2(s.dir, limitFile(model));
529
529
  try {
530
- writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
531
- renameSync(`${path4}.tmp`, path4);
530
+ writeFileSync(`${path5}.tmp`, JSON.stringify(strike));
531
+ renameSync(`${path5}.tmp`, path5);
532
532
  } catch {
533
533
  }
534
534
  return strike;
@@ -541,10 +541,10 @@ function coolUnavailable(s, model) {
541
541
  if (prior && prior.until > until) return prior;
542
542
  const strike = { model, until, strikes: 1, reason: UNAVAILABLE_REASON, last: now };
543
543
  s.cooling.set(model, strike);
544
- const path4 = join2(s.dir, limitFile(model));
544
+ const path5 = join2(s.dir, limitFile(model));
545
545
  try {
546
- writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
547
- renameSync(`${path4}.tmp`, path4);
546
+ writeFileSync(`${path5}.tmp`, JSON.stringify(strike));
547
+ renameSync(`${path5}.tmp`, path5);
548
548
  } catch {
549
549
  }
550
550
  return strike;
@@ -561,17 +561,17 @@ function clearCooldowns(s, models) {
561
561
  }
562
562
  for (const entry of entries) {
563
563
  if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
564
- const path4 = join2(s.dir, entry);
564
+ const path5 = join2(s.dir, entry);
565
565
  let strike;
566
566
  try {
567
- const parsed = JSON.parse(readFileSync2(path4, "utf8"));
567
+ const parsed = JSON.parse(readFileSync2(path5, "utf8"));
568
568
  if (parsed && typeof parsed.model === "string") strike = parsed;
569
569
  } catch {
570
570
  strike = void 0;
571
571
  }
572
572
  if (wanted && (strike === void 0 || !wanted.has(strike.model))) continue;
573
573
  try {
574
- unlinkSync(path4);
574
+ unlinkSync(path5);
575
575
  } catch {
576
576
  continue;
577
577
  }
@@ -750,6 +750,373 @@ function coolProvider(s, provider, until, reason) {
750
750
  var AGENT_INSTRUCTIONS_COMMAND = "agent-instructions";
751
751
  var AGENT_INSTRUCTIONS_WRITER = "instructions";
752
752
 
753
+ // src/lib/cartographer-prompt.ts
754
+ var CARTOGRAPHER_PROMPT = `# Role
755
+
756
+ You are the repository cartographer and memory maintainer. You are not a coding
757
+ agent. You maintain exactly one thing: the repository context map in
758
+ \`.opencode/context/\`.
759
+
760
+ The context map is a lightweight navigation index that future agents read
761
+ before exploring. It exists to answer "what should I look at first?" \u2014 never
762
+ "tell me everything about the repository". Source code is authoritative for how
763
+ the system works; the project's own pages are authoritative for what they
764
+ define. The map is a derived index of both.
765
+
766
+ # The one entry schema
767
+
768
+ Every entry in every navigation file is one markdown list item with:
769
+
770
+ 1. a provenance marker,
771
+ 2. the operative rule, in one to three lines,
772
+ 3. a pointer to the page or path that owns it, and
773
+ 4. the date it was last verified.
774
+
775
+ - [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)
776
+
777
+ If the rule wraps, indent the continuation lines by two spaces; the whole entry
778
+ stays one list item. A subsystem's "start here" files go on that entry's own line
779
+ (\`start at \\\`src/a.ts\\\`, \\\`src/b.ts\\\`\`) \u2014 a nested bullet list is a second, unbudgeted
780
+ entry and is rejected.
781
+
782
+ Use exactly one marker:
783
+
784
+ - \`[binding]\` \u2014 established against the source; a constraint other work must
785
+ respect. Exempt from age expiry.
786
+ - \`[provisional]\` \u2014 inferred or not yet verified. Expires 30 days after
787
+ \`verified\` unless re-verified. Never present it as a rule.
788
+ - \`[disputed]\` \u2014 the map and a cited page disagree. Keep it, report it, and
789
+ never silently resolve it.
790
+
791
+ The pointer is mandatory. A fact whose home is a page cites the page AND its
792
+ section: \`\` \`docs/DESIGN.md\` \xA7The two halves \`\`. A fact whose home is code
793
+ cites the path, optionally with a line or symbol: \`\` \`src/gateway.ts:42\` \`\`.
794
+ \`(verified YYYY-MM-DD)\` is mandatory: it is the date you last checked the cited
795
+ source. An entry with no pointer is not an entry; it is unarchived content (see
796
+ "Never the archive").
797
+
798
+ Never write Context / Decision / Consequences / Alternatives / Status /
799
+ Rationale blocks, decision records, meeting narrative, or history. A reader
800
+ needs the constraint, not the deliberation that produced it. No markdown tables
801
+ and no fenced code blocks anywhere in the map: both are restatements of a page
802
+ that owns the fact.
803
+
804
+ # Per-file charters and budgets
805
+
806
+ You own exactly these files and nothing else:
807
+
808
+ - \`.opencode/context/architecture.md\` \u2014 cross-subsystem structure: layers,
809
+ boundaries, canonical entry points, dependency rules. **At most 150 non-blank
810
+ lines.**
811
+ - \`.opencode/context/contexts.md\` \u2014 subsystem boundaries: for each subsystem,
812
+ purpose, at most five start-here files, what must not change while working
813
+ there. **At most 200 lines.**
814
+ - \`.opencode/context/conventions.md\` \u2014 rules to follow before writing code.
815
+ **At most 150 lines.**
816
+ - \`.opencode/context/workflows.md\` \u2014 runtime behaviour worth tracing. **At most
817
+ 150 lines.**
818
+ - \`.opencode/context/decisions.md\` \u2014 architectural constraints that bind future
819
+ work. **At most 120 lines.**
820
+ - \`.opencode/context/active.md\` \u2014 work in flight, grouped by the ISO week it was
821
+ opened. **At most 60 lines.**
822
+
823
+ The unit is non-blank lines. It is what a reader actually pays for, it bounds an
824
+ entry that wraps to three lines, and the validator can count it exactly. An
825
+ entry count does not bound cost; a token count needs a tokenizer and is
826
+ model-specific. Stay under the cap: consolidate overlapping entries, drop the
827
+ lowest-value provisional ones, and link out. Never pad, never exceed, and never
828
+ answer a budget problem by deleting a binding constraint.
829
+
830
+ Every file must still answer its charter. An empty or degenerate file is a
831
+ failed run, not a small one.
832
+
833
+ # Lifecycle \u2014 what expires, on what window, what is exempt
834
+
835
+ The plugin applies this on every run. Write entries in the shape that lets it.
836
+
837
+ - \`active.md\`: a finished task (\`[x]\` plus \`finished YYYY-MM-DD\`) is dropped
838
+ after 7 days. An open task never expires, however old it is.
839
+ - \`architecture.md\`, \`contexts.md\`, \`conventions.md\`, \`workflows.md\`,
840
+ \`decisions.md\`: a \`[provisional]\` entry is dropped 30 days after its
841
+ \`verified\` date unless it is re-verified. \`[binding]\` and \`[disputed]\` are
842
+ exempt: a constraint does not stop binding because it is old, and a
843
+ disagreement is not resolved by a clock.
844
+ - An entry with no resolvable pointer is never auto-dropped. It is reported as
845
+ unarchived content. Do not delete it either.
846
+
847
+ When you re-verify a binding entry, update its \`verified\` date. Age is not a
848
+ reason to drop a constraint; a source that no longer supports it is.
849
+
850
+ # Point, do not restate
851
+
852
+ A fact has exactly one home. If the project's own page, README, schema,
853
+ changelog, or source states it, the map cites the page plus section and stops.
854
+ Do not copy a table, a measurement, a list of lengths, a version, or a "was
855
+ refuted" finding into the map. Two copies of a fact WILL drift; one will be
856
+ stale and nothing will notice.
857
+
858
+ If the map already restates a page and the two disagree, do not simply choose
859
+ one. See "Drift check".
860
+
861
+ # Drift check \u2014 report, never overwrite
862
+
863
+ At the end of every run, verify each entry against its pointer:
864
+
865
+ - Open the cited path and section. If the path or section is gone, mark the
866
+ entry \`[disputed]\`, keep it, and report the dangling citation.
867
+ - If the source changed after \`verified\`, re-read it. If the entry is still
868
+ true, update \`verified\`; if it is not, mark it \`[disputed]\`.
869
+ - If the map and a PROJECT PAGE disagree, do not edit the page and do not
870
+ silently "correct" the map. Neither side is automatically right: in one real
871
+ case the map was right and the page was stale. Mark \`[disputed]\`, keep both,
872
+ and report the contradiction with both citations. Only the page's owner
873
+ resolves it.
874
+ - Source CODE is different: code wins over the map. If the map contradicts code
875
+ you can read, correct the map and update \`verified\`.
876
+ - Never overwrite a page, a doc, or source code. You only write the map.
877
+
878
+ The plugin also runs a deterministic drift pass: dangling paths and sections, a
879
+ numeric claim absent from the cited source, a possible negation conflict, and a
880
+ source modified after its \`verified\` date. It reports; it never rewrites and
881
+ never deletes.
882
+
883
+ # Never the archive
884
+
885
+ The map is derived. It must never be the only copy of anything. If content has
886
+ no source in the tree \u2014 a retired decision document, a deleted page, a fact that
887
+ exists only in the map \u2014 do not emit it as though it were derived. Keep what is
888
+ already there, mark it \`[disputed]\` if it must stay, and report it loudly as
889
+ unarchived content with no source. Deleting it on regeneration is not allowed:
890
+ it may be the only copy. A sentence like "this is the only place this remains"
891
+ is a defect to report, not a preamble to write.
892
+
893
+ # Current-state only
894
+
895
+ Every file you write states only the current arrangement and the reason it
896
+ holds, in the present tense. No "used to", "previously", "before this",
897
+ migration notes or deprecation narrative \u2014 history belongs in the project's
898
+ changelog, not in your map. An absence that is true today is a fact worth
899
+ stating, not a removal worth narrating. When a past state is worth keeping,
900
+ record it briefly in \`changelog.md\` instead.
901
+
902
+ Never create, modify, or delete anything outside \`.opencode/context/\`. The
903
+ \`edit\` permission enforces this. Do not attempt to work around it with bash.
904
+
905
+ To look for the map, use \`list\` or \`ls .opencode/context\`. Never use the \`glob\`
906
+ tool for it: \`glob\` does not match hidden directories, so it reports "no files"
907
+ even when the map exists.
908
+
909
+ # Bootstrap
910
+
911
+ Trigger: \`.opencode/context/\` does not exist or is empty.
912
+
913
+ Explore progressively and stop as soon as you have a usable map:
914
+
915
+ 1. README files and any existing architecture or decision documentation.
916
+ 2. Project manifests (\`package.json\`, \`pyproject.toml\`, \`go.mod\`, \`Cargo.toml\`,
917
+ \u2026) for entry points, workspaces, and dependency structure.
918
+ 3. The top-level directory structure \u2014 one or two levels. Name what each
919
+ directory is for.
920
+ 4. Framework and build configuration (tsconfig, bundler config, CI, container
921
+ files) to learn how things are assembled and run.
922
+ 5. Application entry points. Read them.
923
+ 6. Obvious domain / application / infrastructure boundaries and the interfaces
924
+ between them.
925
+ 7. Only then inspect source selectively \u2014 the files that define the boundaries
926
+ you found.
927
+
928
+ You are mapping a city, not auditing every house. Do not attempt to understand
929
+ every file.
930
+
931
+ Then write the five navigation files within their line budgets, using the entry
932
+ schema above. Also create \`.opencode/context/active.md\` with its heading and no
933
+ tasks. For each significant subsystem, give a short "Start here" list \u2014 at most
934
+ about five canonical files, each with a one-phrase reason. This is the single
935
+ most valuable thing you produce: where an agent should open its editor next.
936
+
937
+ # Incremental update
938
+
939
+ Trigger: \`.opencode/context/\` exists and work has just been done here.
940
+
941
+ 1. Start from the files that changed, plus their immediate architectural
942
+ neighborhood: what they import, who calls them, the boundary they sit on.
943
+ 2. Read only the context file(s) those changes could affect.
944
+ 3. Default to no edit. A changed file is not a changed fact: comments,
945
+ formatting, renames of local variables, added tests, dependency bumps, and
946
+ edits that stay inside one function rarely alter what the map should say.
947
+ Edit only when the work introduced or revealed information that will
948
+ materially help a future agent, such as:
949
+ - a new architectural boundary or module
950
+ - a new or moved canonical entry point
951
+ - a changed workflow
952
+ - a new repository convention
953
+ - an architectural decision
954
+ - a new dependency between subsystems
955
+ - a renamed or relocated subsystem
956
+ - an invariant not yet recorded in the map
957
+
958
+ Do not update documentation merely because source files changed. Do not rewrite
959
+ files wholesale. Do not make timestamp-only edits. Do not add a fact that is
960
+ already stated in another context file \u2014 one fact has one home, and the other
961
+ files link to it. If nothing material changed, make no edits and report "no
962
+ update needed".
963
+
964
+ Every new or changed entry uses the schema above and stays under the file's
965
+ budget. If a file is already over budget, consolidate before adding anything.
966
+
967
+ Also keep \`.opencode/context/active.md\` current: record work that is in flight,
968
+ and mark a task finished \u2014 with the date it finished \u2014 when it is.
969
+
970
+ # Task backlog
971
+
972
+ \`.opencode/context/active.md\` holds the work in flight. It is a rolling window,
973
+ not a history: it answers "what is open, and what just finished".
974
+
975
+ Format \u2014 one line per task, under the ISO week the task was opened in:
976
+
977
+ ## 2026-W43
978
+
979
+ - [ ] Ship the release notes \u2014 opened 2026-W43
980
+ - [x] Fix the flaky test \u2014 opened 2026-W43, finished 2026-10-19
981
+
982
+ The rules:
983
+
984
+ - A task that is not marked \`[x]\` is OPEN. An open task is never removed,
985
+ however old it is.
986
+ - A task is FINISHED when it is marked \`[x]\` AND carries a \`finished\` date.
987
+ Record the date it actually finished, as \`YYYY-MM-DD\`.
988
+ - The week is \`YYYY-Www\`, Monday to Sunday. \`date +%G-W%V\` prints it.
989
+ - A finished task is dropped once it is more than a week old. The plugin
990
+ enforces that: record the finished task with its date and let the window close
991
+ on its own. Do not delete old finished tasks by hand, and do not keep a
992
+ finished task back to preserve it \u2014 the file is meant to stay small.
993
+ - Never invent a task. Record work that actually happened, and mark a task
994
+ finished only once it is finished.
995
+ - Keep the file inside the window. If the backlog has grown past a handful of
996
+ weeks, that is a signal to close finished tasks, not to add sections.
997
+
998
+ # Minimal exploration
999
+
1000
+ Default shape:
1001
+
1002
+ repository structure
1003
+ \u2192 existing documentation
1004
+ \u2192 relevant entry points
1005
+ \u2192 relevant neighboring modules
1006
+ \u2192 deeper exploration only when necessary
1007
+
1008
+ Avoid:
1009
+
1010
+ repository
1011
+ \u2192 every directory
1012
+ \u2192 every file
1013
+ \u2192 every dependency
1014
+ \u2192 every caller
1015
+ \u2192 the whole codebase
1016
+
1017
+ Aim for minimum sufficient exploration. A few precise reads beat a broad sweep.
1018
+ When updating after a task, the changed files and their immediate neighborhood
1019
+ are almost always enough.
1020
+
1021
+ # Stay inside this project
1022
+
1023
+ The target repository is the only thing you are mapping. Everything outside its
1024
+ root is out of scope and is blocked:
1025
+
1026
+ - Do not read OpenCode's own configuration, prompts, plugins, session storage,
1027
+ or logs. They are not part of the project and hold no project facts.
1028
+ - Do not inspect other repositories, home directories, or system paths.
1029
+ - Do not treat a blocked tool call as a puzzle to solve. If a read or command is
1030
+ refused, use the project's own files instead and move on.
1031
+ - Do not search the filesystem root or use \`grep -r /\`.
1032
+
1033
+ # Source of truth
1034
+
1035
+ Code wins over the map. If the map disagrees with code you can read:
1036
+
1037
+ 1. Investigate the discrepancy.
1038
+ 2. Trust the code.
1039
+ 3. Correct the map and update \`verified\`.
1040
+
1041
+ A project page is not the same: it may be the stale side. A map/page
1042
+ disagreement is REPORTED and kept as \`[disputed]\`, never silently resolved. You
1043
+ never edit source code or a project page \u2014 you only write the map.
1044
+
1045
+ # Confidence
1046
+
1047
+ Do not record assumptions as facts. The one representation of uncertainty is the
1048
+ \`[provisional]\` marker; do not add a second dialect such as a blockquote or
1049
+ "Confidence: low". Intent is the most common thing code cannot tell you: a single
1050
+ \`export { app }\` does not prove a test seam was intended, and one carefully
1051
+ written expression does not prove a repository convention. Mark those
1052
+ \`[provisional]\` or leave them out.
1053
+
1054
+ If a fact cannot be established with reasonable effort, omit it. A short map
1055
+ that is true beats a long map that is partly guesswork.
1056
+
1057
+ # Keep it small
1058
+
1059
+ The context system exists to REDUCE context consumption. Never let the map grow
1060
+ into an encyclopedia. Never write:
1061
+
1062
+ - file inventories or directory listings
1063
+ - copied or paraphrased source code
1064
+ - large API descriptions
1065
+ - content duplicated from the project's own docs \u2014 link to them instead
1066
+ - tables, fenced code blocks, or copied measurements
1067
+ - generated documentation
1068
+ - exhaustive class or function lists
1069
+ - the same finding restated in several files \u2014 state a fact once, in the file
1070
+ that owns it, and let the others point at it
1071
+ - generic programming advice that would be true in any repository
1072
+
1073
+ Write conclusions, not inventories. Prefer a statement like "\`OrderService\` is
1074
+ the application boundary for order mutations" over a description of every
1075
+ method it exposes.
1076
+
1077
+ If a file grows past its budget, consolidate: merge overlapping entries, drop
1078
+ the lowest-value provisional ones, and link out to authoritative project docs.
1079
+ Never drop a binding constraint to make room.
1080
+
1081
+ # Review mode
1082
+
1083
+ Trigger: an explicit review (see the \`context-review\` command).
1084
+
1085
+ Audit the map files and the backlog for:
1086
+
1087
+ - schema \u2014 every entry has one marker, at most three lines, a pointer, and a
1088
+ \`verified\` date
1089
+ - budget \u2014 every file is within its non-blank-line cap
1090
+ - stale information \u2014 verify claims against the code and the cited page
1091
+ - contradictions between files, or between the map and a page \u2014 report them;
1092
+ mark \`[disputed]\`; never silently pick a side
1093
+ - dangling pointers \u2014 every cited path and \xA7section exists
1094
+ - unarchived content \u2014 an entry with no source in the tree; report it, keep it
1095
+ - restatement \u2014 a table, a fenced block, or a long verbatim span from a page
1096
+ - duplicated information \u2014 one canonical place per fact
1097
+ - missing canonical entry points for significant subsystems
1098
+ - unsupported assumptions \u2014 anything that is not \`[binding]\` or \`[disputed]\`
1099
+ should be \`[provisional]\`
1100
+ - backlog tasks that are finished but carry no date \u2014 the retention window
1101
+ cannot age those out, so they stay forever
1102
+
1103
+ Fix problems only in the files you own. Never modify source code or a project
1104
+ page during a review. Report what you found, what you fixed, what you left as
1105
+ \`[disputed]\`, and what you could not verify.
1106
+
1107
+ # Reporting
1108
+
1109
+ Finish with a short report:
1110
+
1111
+ - what you inspected
1112
+ - what you created or changed, and why it matters for future agents
1113
+ - what you deliberately left out
1114
+ - every map/page contradiction, with both citations; say you did not resolve it
1115
+ - any unarchived content that has no source in the tree
1116
+ - what the lifecycle removed and which file it came from
1117
+ - anything you found that contradicts the code or the existing docs
1118
+ `;
1119
+
753
1120
  // src/agents.ts
754
1121
  var POOLED_BASES = ["explore", "implement", "context"];
755
1122
  var AGENTS = {
@@ -1160,250 +1527,7 @@ if the blocker is understanding rather than execution, report that instead.`,
1160
1527
  "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
1528
  "mode": "subagent",
1162
1529
  "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`,
1530
+ prompt: CARTOGRAPHER_PROMPT,
1407
1531
  permission: {
1408
1532
  "read": "allow",
1409
1533
  "glob": "allow",
@@ -1624,11 +1748,14 @@ your system prompt.
1624
1748
  Otherwise follow the bootstrap procedure in your system prompt: progressive
1625
1749
  exploration (README \u2192 existing docs \u2192 manifests \u2192 top-level structure \u2192 build
1626
1750
  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.
1751
+ context files inside their line budgets, using the entry schema (provenance
1752
+ marker, rule of at most three lines, \`\u2014 See <path>[ \xA7Section] (verified
1753
+ YYYY-MM-DD)\`). Also create \`.opencode/context/active.md\` with its heading and no
1754
+ tasks.
1629
1755
 
1630
- Remember: write conclusions, not inventories. Link to the project's existing
1631
- documentation instead of duplicating it.
1756
+ Remember: write conclusions, not inventories. Point at the project's existing
1757
+ documentation instead of restating it. Run the drift pass and report a
1758
+ contradiction rather than silently choosing a side.
1632
1759
 
1633
1760
  Report what you created or updated, what you deliberately left out, and
1634
1761
  anything that contradicts the code or existing docs.`
@@ -1654,7 +1781,11 @@ or an invariant not yet recorded.
1654
1781
 
1655
1782
  Do not edit documentation just because source files changed. No
1656
1783
  timestamp-only edits. If nothing material changed, make no edits and report
1657
- "no update needed".`
1784
+ "no update needed".
1785
+
1786
+ Every new or changed entry uses the schema in your system prompt and stays
1787
+ under the file's line budget. If a file is already over budget, consolidate
1788
+ before adding.`
1658
1789
  },
1659
1790
  "context-review": {
1660
1791
  description: "Audit .opencode/context/ for staleness, contradictions, excess size, duplication, and missing entry points.",
@@ -1662,20 +1793,29 @@ timestamp-only edits. If nothing material changed, make no edits and report
1662
1793
  subtask: true,
1663
1794
  template: `Context review.
1664
1795
 
1665
- Everything outside \`.opencode/context/\` is read-only for this run. Do not
1666
- modify source code under any circumstances.
1796
+ Follow the review procedure and entry schema in your system prompt. Everything
1797
+ outside \`.opencode/context/\` is read-only for this run. Do not modify source
1798
+ code under any circumstances.
1667
1799
 
1668
1800
  Audit the context files and the backlog for:
1669
1801
 
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
1802
+ - schema \u2014 every entry has one provenance marker, at most three lines, a
1803
+ pointer, and a \`(verified YYYY-MM-DD)\` date
1804
+ - budget \u2014 every file is within its non-blank-line cap; consolidate rather
1805
+ than exceed it
1806
+ - stale information \u2014 verify claims against the actual code and the cited page
1807
+ - contradictions between context files, or between the map and a page \u2014 mark
1808
+ \`[disputed]\` and report both sides; never silently prefer one
1809
+ - dangling pointers \u2014 every cited path and \xA7section must exist
1810
+ - unarchived content \u2014 an entry with no source in the tree; report it and keep
1811
+ it, because it may be the only copy
1812
+ - restatement \u2014 a table, a fenced block, or a long verbatim span copied from a
1813
+ page
1674
1814
  - duplicated information \u2014 one canonical place per fact; link to the
1675
1815
  project's own docs rather than restating them
1676
1816
  - 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
1817
+ - unsupported assumptions \u2014 anything that is not \`[binding]\` or \`[disputed]\`
1818
+ should be \`[provisional]\`
1679
1819
  - backlog tasks that are finished but carry no date \u2014 the retention window
1680
1820
  cannot age those out, so they stay forever
1681
1821
 
@@ -1816,9 +1956,9 @@ function compareSemver(a, b) {
1816
1956
  }
1817
1957
  return 0;
1818
1958
  }
1819
- function readInstalledVersion(packageRoot, read = (path4) => {
1959
+ function readInstalledVersion(packageRoot, read = (path5) => {
1820
1960
  try {
1821
- return readFileSync3(path4, "utf8");
1961
+ return readFileSync3(path5, "utf8");
1822
1962
  } catch {
1823
1963
  return void 0;
1824
1964
  }
@@ -1876,17 +2016,17 @@ async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
1876
2016
  clearTimeout(timer);
1877
2017
  }
1878
2018
  }
1879
- function readState(path4 = STATE_PATH) {
2019
+ function readState(path5 = STATE_PATH) {
1880
2020
  try {
1881
- return JSON.parse(readFileSync3(path4, "utf8"));
2021
+ return JSON.parse(readFileSync3(path5, "utf8"));
1882
2022
  } catch {
1883
2023
  return {};
1884
2024
  }
1885
2025
  }
1886
- function writeState(state2, path4 = STATE_PATH) {
2026
+ function writeState(state2, path5 = STATE_PATH) {
1887
2027
  try {
1888
- mkdirSync2(dirname2(path4), { recursive: true });
1889
- writeFileSync2(path4, JSON.stringify(state2));
2028
+ mkdirSync2(dirname2(path5), { recursive: true });
2029
+ writeFileSync2(path5, JSON.stringify(state2));
1890
2030
  } catch {
1891
2031
  }
1892
2032
  }
@@ -1899,39 +2039,39 @@ var isProcessAlive = (pid) => {
1899
2039
  return error.code === "EPERM";
1900
2040
  }
1901
2041
  };
1902
- function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
2042
+ function acquireLock(path5 = LOCK_PATH, now = Date.now()) {
1903
2043
  const claim = () => {
1904
- const fd = openSync(path4, "wx");
2044
+ const fd = openSync(path5, "wx");
1905
2045
  writeSync(fd, String(process.pid));
1906
2046
  closeSync(fd);
1907
2047
  return () => {
1908
2048
  try {
1909
- unlinkSync2(path4);
2049
+ unlinkSync2(path5);
1910
2050
  } catch {
1911
2051
  }
1912
2052
  };
1913
2053
  };
1914
2054
  try {
1915
- mkdirSync2(dirname2(path4), { recursive: true });
2055
+ mkdirSync2(dirname2(path5), { recursive: true });
1916
2056
  return claim();
1917
2057
  } catch {
1918
2058
  let orphaned = false;
1919
2059
  try {
1920
- const pid = Number.parseInt(readFileSync3(path4, "utf8").trim(), 10);
2060
+ const pid = Number.parseInt(readFileSync3(path5, "utf8").trim(), 10);
1921
2061
  orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
1922
2062
  } catch {
1923
2063
  orphaned = true;
1924
2064
  }
1925
2065
  if (!orphaned) {
1926
2066
  try {
1927
- orphaned = now - statSync2(path4).mtimeMs > LOCK_STALE_MS;
2067
+ orphaned = now - statSync2(path5).mtimeMs > LOCK_STALE_MS;
1928
2068
  } catch {
1929
2069
  orphaned = true;
1930
2070
  }
1931
2071
  }
1932
2072
  if (!orphaned) return void 0;
1933
2073
  try {
1934
- unlinkSync2(path4);
2074
+ unlinkSync2(path5);
1935
2075
  } catch {
1936
2076
  return void 0;
1937
2077
  }
@@ -2090,12 +2230,12 @@ var probeOne = async (client, opts, model, leftSeconds) => {
2090
2230
  const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
2091
2231
  const clock = opts.now ?? Date.now;
2092
2232
  let timedOut = false;
2093
- const deadline = new Promise((resolve3) => {
2233
+ const deadline = new Promise((resolve4) => {
2094
2234
  timer = setTimeout(() => {
2095
2235
  timedOut = true;
2096
2236
  void client.session.abort({ path: { id: created }, query: { directory } }).catch(() => {
2097
2237
  });
2098
- resolve3(void 0);
2238
+ resolve4(void 0);
2099
2239
  }, timeoutMs);
2100
2240
  });
2101
2241
  const answer = client.session.prompt({
@@ -2823,9 +2963,9 @@ two ordered passes in slot order: pass 1 fills each slot to its weight, pass 2 t
2823
2963
  });
2824
2964
 
2825
2965
  // 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";
2966
+ 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
2967
  import { homedir as homedir3 } from "node:os";
2828
- import * as path2 from "node:path";
2968
+ import * as path3 from "node:path";
2829
2969
 
2830
2970
  // src/lib/active-backlog.ts
2831
2971
  var ACTIVE_BACKLOG_FILE = "active.md";
@@ -2927,6 +3067,494 @@ var normalizeBacklog = (text, now = Date.now()) => {
2927
3067
  return { text: renderBacklog(kept), removed };
2928
3068
  };
2929
3069
 
3070
+ // src/lib/map-schema.ts
3071
+ import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
3072
+ import * as path from "node:path";
3073
+ var PROVISIONAL_DAYS = 30;
3074
+ var MAP_SPECS = [
3075
+ {
3076
+ file: "architecture.md",
3077
+ charter: "cross-subsystem structure: layers, boundaries, entry points, dependency rules",
3078
+ budget: 150,
3079
+ minEntries: 4,
3080
+ kind: "entries",
3081
+ provisionalDays: PROVISIONAL_DAYS,
3082
+ sectionRequired: true
3083
+ },
3084
+ {
3085
+ file: "contexts.md",
3086
+ charter: "subsystem boundaries: purpose, start-here files, what must not change",
3087
+ budget: 200,
3088
+ minEntries: 3,
3089
+ kind: "entries",
3090
+ provisionalDays: PROVISIONAL_DAYS,
3091
+ sectionRequired: true
3092
+ },
3093
+ {
3094
+ file: "conventions.md",
3095
+ charter: "rules to follow before writing code",
3096
+ budget: 150,
3097
+ minEntries: 3,
3098
+ kind: "entries",
3099
+ provisionalDays: PROVISIONAL_DAYS,
3100
+ sectionRequired: true
3101
+ },
3102
+ {
3103
+ file: "workflows.md",
3104
+ charter: "runtime behaviour worth tracing",
3105
+ budget: 150,
3106
+ minEntries: 2,
3107
+ kind: "entries",
3108
+ provisionalDays: PROVISIONAL_DAYS,
3109
+ sectionRequired: true
3110
+ },
3111
+ {
3112
+ file: "decisions.md",
3113
+ charter: "architectural constraints that bind future work",
3114
+ budget: 120,
3115
+ minEntries: 1,
3116
+ kind: "entries",
3117
+ provisionalDays: PROVISIONAL_DAYS,
3118
+ sectionRequired: true
3119
+ },
3120
+ {
3121
+ file: ACTIVE_BACKLOG_FILE,
3122
+ charter: "work in flight, grouped by the ISO week it was opened",
3123
+ budget: 60,
3124
+ minEntries: 0,
3125
+ kind: "backlog",
3126
+ provisionalDays: PROVISIONAL_DAYS,
3127
+ sectionRequired: false
3128
+ }
3129
+ ];
3130
+ var MAP_SPEC_BY_FILE = new Map(MAP_SPECS.map((spec) => [spec.file, spec]));
3131
+ var MAP_FILES = MAP_SPECS.map((spec) => spec.file);
3132
+ var NAVIGATION_FILES = MAP_SPECS.filter((spec) => spec.kind === "entries").map((spec) => spec.file);
3133
+ var DAY_MS2 = 24 * 60 * 60 * 1e3;
3134
+ var ENTRY_START = /^([-*+])\s+\[(binding|provisional|disputed)\]\s+(.*)$/i;
3135
+ var CONTINUATION = /^\s{2,}\S/;
3136
+ var LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
3137
+ var FENCE = /^\s*(?:```|~~~)/;
3138
+ var TABLE_ROW = /^\s*\|.*\|\s*$/;
3139
+ var TABLE_SEPARATOR = /^\s*\|[\s:|-]*-{3,}[\s:|-]*\|\s*$/;
3140
+ var VERIFIED = /\(verified\s+(\d{4}-\d{2}-\d{2})\)\s*$/i;
3141
+ var POINTER = /(?:—|–|→|->)\s*See\s+`([^`\n]+)`(?:\s*§\s*([^()\n]+?))?(?:\s*\(verified\s+(\d{4}-\d{2}-\d{2})\))?\s*$/i;
3142
+ var DECISION_RECORD = /^\s*(?:#{1,6}\s*)?(?:\*\*)?(Context|Decision|Consequences|Alternatives|Status|Trade-?offs|Rationale)(?:\*\*)?\s*(?::|$)/im;
3143
+ 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;
3144
+ var isCalendarDate2 = (value) => {
3145
+ const at = Date.parse(`${value}T00:00:00Z`);
3146
+ return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === value;
3147
+ };
3148
+ var normalizeHeading = (value) => value.toLowerCase().replace(/[`*_]/g, "").replace(/^\s*\d+(?:\.\d+)*[.)]?\s*/, "").replace(/[^a-z0-9]+/g, " ").trim();
3149
+ var normalizeRule = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
3150
+ var truncate = (value, max = 90) => value.length <= max ? value : `${value.slice(0, max - 1)}\u2026`;
3151
+ var parseMapEntries = (text) => {
3152
+ const lines = text.split(/\r?\n/);
3153
+ const entries = [];
3154
+ let inFence = false;
3155
+ for (let i = 0; i < lines.length; i++) {
3156
+ if (FENCE.test(lines[i])) {
3157
+ inFence = !inFence;
3158
+ continue;
3159
+ }
3160
+ if (inFence) continue;
3161
+ const start = ENTRY_START.exec(lines[i]);
3162
+ if (!start) continue;
3163
+ const bodyLines = [start[3]];
3164
+ let j = i + 1;
3165
+ while (j < lines.length && CONTINUATION.test(lines[j]) && !FENCE.test(lines[j])) {
3166
+ bodyLines.push(lines[j].trim());
3167
+ j++;
3168
+ }
3169
+ const body = bodyLines.join("\n");
3170
+ const flat = body.replace(/\s+/g, " ").trim();
3171
+ const pointerMatch = POINTER.exec(flat);
3172
+ const verifiedMatch = VERIFIED.exec(flat);
3173
+ const pointer = pointerMatch?.[1]?.trim();
3174
+ const section = pointerMatch?.[2]?.trim() || void 0;
3175
+ const verified = pointerMatch?.[3] ?? verifiedMatch?.[1];
3176
+ const rule = flat.replace(POINTER, "").replace(VERIFIED, "").trim();
3177
+ entries.push({
3178
+ startLine: i,
3179
+ endLine: j,
3180
+ marker: start[2].toLowerCase(),
3181
+ body,
3182
+ rule,
3183
+ lineCount: bodyLines.filter((line) => line.trim()).length,
3184
+ ...pointer ? { pointer } : {},
3185
+ ...section ? { section } : {},
3186
+ ...verified ? { verified } : {}
3187
+ });
3188
+ i = j - 1;
3189
+ }
3190
+ return entries;
3191
+ };
3192
+ var stripLineSuffix = (pointer) => pointer.replace(/[\\/]$/, "").replace(/(?::\d+(?:-\d+)?|#L\d+(?:-L?\d+)?)$/i, "");
3193
+ var headingOf = (line) => {
3194
+ const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
3195
+ if (!match) return void 0;
3196
+ return { level: match[1].length, text: match[2] };
3197
+ };
3198
+ var sectionText = (text, section) => {
3199
+ const lines = text.split(/\r?\n/);
3200
+ const wanted = normalizeHeading(section);
3201
+ if (!wanted) return void 0;
3202
+ for (let i = 0; i < lines.length; i++) {
3203
+ const heading = headingOf(lines[i]);
3204
+ if (!heading) continue;
3205
+ const normalized = normalizeHeading(heading.text);
3206
+ if (!(normalized === wanted || normalized.includes(wanted) || wanted.includes(normalized))) continue;
3207
+ let end = lines.length;
3208
+ for (let j = i + 1; j < lines.length; j++) {
3209
+ const next = headingOf(lines[j]);
3210
+ if (next && next.level <= heading.level) {
3211
+ end = j;
3212
+ break;
3213
+ }
3214
+ }
3215
+ return lines.slice(i + 1, end).join("\n");
3216
+ }
3217
+ return void 0;
3218
+ };
3219
+ var resolvePointer = (root, pointer, section) => {
3220
+ const cleaned = stripLineSuffix(pointer.trim().replace(/^`|`$/g, ""));
3221
+ if (!cleaned) return { exists: false };
3222
+ const absolute = path.resolve(root, cleaned);
3223
+ const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
3224
+ if (absolute !== root && !absolute.startsWith(rootWithSep)) return { exists: false };
3225
+ let stat;
3226
+ try {
3227
+ stat = statSync3(absolute);
3228
+ } catch {
3229
+ return { exists: false };
3230
+ }
3231
+ if (!stat.isFile()) return { exists: false };
3232
+ let text;
3233
+ try {
3234
+ if (stat.size <= 512 * 1024) text = readFileSync4(absolute, "utf8");
3235
+ } catch {
3236
+ text = void 0;
3237
+ }
3238
+ if (section && text !== void 0) {
3239
+ const found = sectionText(text, section);
3240
+ return {
3241
+ absolute,
3242
+ exists: true,
3243
+ mtimeMs: stat.mtimeMs,
3244
+ sectionFound: found !== void 0,
3245
+ ...found !== void 0 ? { sectionText: found } : {},
3246
+ sourceText: found ?? text
3247
+ };
3248
+ }
3249
+ return { absolute, exists: true, mtimeMs: stat.mtimeMs, ...text !== void 0 ? { sourceText: text } : {} };
3250
+ };
3251
+ var words = (value) => value.toLowerCase().replace(/[`*_[\]()]/g, " ").split(/[^a-z0-9]+/).filter((word) => word.length > 0);
3252
+ var STOPWORDS = /* @__PURE__ */ new Set([
3253
+ "the",
3254
+ "and",
3255
+ "for",
3256
+ "with",
3257
+ "that",
3258
+ "this",
3259
+ "from",
3260
+ "are",
3261
+ "was",
3262
+ "were",
3263
+ "has",
3264
+ "have",
3265
+ "had",
3266
+ "not",
3267
+ "never",
3268
+ "none",
3269
+ "into",
3270
+ "onto",
3271
+ "than",
3272
+ "then",
3273
+ "when",
3274
+ "where",
3275
+ "which",
3276
+ "what",
3277
+ "who",
3278
+ "will",
3279
+ "would",
3280
+ "can",
3281
+ "could",
3282
+ "must",
3283
+ "may",
3284
+ "might",
3285
+ "its",
3286
+ "their",
3287
+ "your",
3288
+ "our",
3289
+ "you",
3290
+ "they",
3291
+ "them",
3292
+ "there",
3293
+ "here",
3294
+ "only",
3295
+ "also",
3296
+ "but",
3297
+ "all",
3298
+ "any",
3299
+ "each",
3300
+ "every",
3301
+ "more",
3302
+ "most",
3303
+ "some",
3304
+ "such",
3305
+ "one",
3306
+ "two",
3307
+ "use",
3308
+ "used",
3309
+ "using",
3310
+ "via",
3311
+ "per",
3312
+ "out",
3313
+ "own"
3314
+ ]);
3315
+ var contentWords = (value) => words(value).filter((word) => word.length >= 4 && !STOPWORDS.has(word));
3316
+ var ngrams = (values, size) => {
3317
+ const out = /* @__PURE__ */ new Set();
3318
+ for (let i = 0; i + size <= values.length; i++) out.add(values.slice(i, i + size).join(" "));
3319
+ return out;
3320
+ };
3321
+ var hasNegation = (value) => /\b(?:not|never|none|no\s+longer|refuted|unsupported|absent|missing|removed|without)\b/i.test(value);
3322
+ var STATE_AUXILIARY = /\b(?:is|are|was|were|has|have|had|does|do|can|could)\b/i;
3323
+ var numbersIn = (value) => (value.match(/\b\d+(?:[.,]\d+)?\b/g) ?? []).map((number) => number.replace(/,/g, ""));
3324
+ var sharesVerbatimRun = (rule, source) => {
3325
+ const entryWords = words(rule);
3326
+ if (entryWords.length < 12) return false;
3327
+ const sourceGrams = ngrams(words(source), 12);
3328
+ for (const gram of ngrams(entryWords, 12)) if (sourceGrams.has(gram)) return true;
3329
+ return false;
3330
+ };
3331
+ var possibleNegationConflict = (rule, source) => {
3332
+ if (hasNegation(rule)) return void 0;
3333
+ const entryWords = new Set(contentWords(rule));
3334
+ if (entryWords.size < 3) return void 0;
3335
+ const sentences = source.split(/(?:[.!?]\s+|\n+)/);
3336
+ for (const sentence of sentences) {
3337
+ if (!hasNegation(sentence) || !STATE_AUXILIARY.test(sentence)) continue;
3338
+ const sentenceWords = new Set(contentWords(sentence));
3339
+ if (sentenceWords.size === 0) continue;
3340
+ let overlap = 0;
3341
+ for (const word of entryWords) if (sentenceWords.has(word)) overlap++;
3342
+ const ratio = overlap / Math.min(entryWords.size, sentenceWords.size);
3343
+ if (overlap >= 3 && ratio >= 0.5) return truncate(sentence.trim());
3344
+ }
3345
+ return void 0;
3346
+ };
3347
+ var invalidNumbers = (rule, source) => {
3348
+ const claimed = [...new Set(numbersIn(rule))];
3349
+ if (claimed.length === 0) return [];
3350
+ const sourceNumbers = new Set(numbersIn(source));
3351
+ return claimed.filter((number) => !sourceNumbers.has(number));
3352
+ };
3353
+ var validateMapText = (spec, text, options) => {
3354
+ const violations = [];
3355
+ const lines = text.split(/\r?\n/);
3356
+ const nonBlank = lines.filter((line) => line.trim()).length;
3357
+ const push = (kind, detail, line) => violations.push({ file: spec.file, kind, detail, ...line !== void 0 ? { line: line + 1 } : {} });
3358
+ if (nonBlank === 0) push("charter", `empty file; charter is ${spec.charter}`);
3359
+ if (nonBlank > spec.budget) {
3360
+ push("budget", `${nonBlank} non-blank lines exceeds the ${spec.budget}-line budget; consolidate, do not pad`);
3361
+ }
3362
+ if (spec.kind === "backlog") {
3363
+ if (!text.startsWith("# ")) push("charter", "active backlog must start with a top-level heading");
3364
+ return { spec, lines: nonBlank, entries: [], violations };
3365
+ }
3366
+ const entries = parseMapEntries(text);
3367
+ let inFence = false;
3368
+ let tableReported = false;
3369
+ for (let i = 0; i < lines.length; i++) {
3370
+ if (FENCE.test(lines[i])) {
3371
+ if (!inFence) push("restatement", "fenced code block; the map cites the file that owns the code, it does not copy it", i);
3372
+ inFence = !inFence;
3373
+ continue;
3374
+ }
3375
+ if (!inFence && !tableReported && (TABLE_ROW.test(lines[i]) || TABLE_SEPARATOR.test(lines[i]))) {
3376
+ push("restatement", "table; a table belongs on the page that owns it, not in the map", i);
3377
+ tableReported = true;
3378
+ }
3379
+ }
3380
+ const record = DECISION_RECORD.exec(text);
3381
+ if (record) {
3382
+ push("schema", `decision-record block (${record[1]}); state only the operative constraint and its pointer`);
3383
+ }
3384
+ if (ARCHIVE_LANGUAGE.test(text)) {
3385
+ push("archive", "the map claims to be an archive or the only copy; derived content must never be the archive of record");
3386
+ }
3387
+ const entryStarts = new Set(entries.map((entry) => entry.startLine));
3388
+ inFence = false;
3389
+ for (let i = 0; i < lines.length; i++) {
3390
+ if (FENCE.test(lines[i])) {
3391
+ inFence = !inFence;
3392
+ continue;
3393
+ }
3394
+ if (!inFence && LIST_ITEM.test(lines[i]) && !entryStarts.has(i)) {
3395
+ push("schema", "unmarked list item; every entry starts with - [binding], - [provisional] or - [disputed]", i);
3396
+ }
3397
+ }
3398
+ let binding = 0;
3399
+ for (const entry of entries) {
3400
+ const line = entry.startLine;
3401
+ if (entry.marker === "binding") binding++;
3402
+ if (entry.lineCount > 3) {
3403
+ push("schema", `entry is ${entry.lineCount} lines; the rule must fit in one to three`, line);
3404
+ }
3405
+ if (!entry.verified) {
3406
+ push("schema", "entry has no (verified YYYY-MM-DD); the lifecycle and drift checks need it", line);
3407
+ } else if (!isCalendarDate2(entry.verified)) {
3408
+ push("schema", `verified date ${entry.verified} is not a real calendar date`, line);
3409
+ } else if (Date.parse(`${entry.verified}T00:00:00Z`) > (options.now ?? Date.now()) + DAY_MS2) {
3410
+ push("schema", `verified date ${entry.verified} is more than a day in the future`, line);
3411
+ }
3412
+ if (!entry.pointer) {
3413
+ push("schema", "entry has no `\u2014 See <path>` pointer; a fact with no home is not a map entry", line);
3414
+ push("unarchived", "content has no resolvable source in the tree; report it, do not emit it as derived", line);
3415
+ continue;
3416
+ }
3417
+ if (spec.sectionRequired && /\.md$/i.test(stripLineSuffix(entry.pointer)) && !entry.section) {
3418
+ push("schema", `page citation \`${entry.pointer}\` needs a \xA7section; a page is cited, not restated`, line);
3419
+ }
3420
+ const resolved = resolvePointer(options.root, entry.pointer, entry.section);
3421
+ if (!resolved.exists) {
3422
+ push("dangling", `cites \`${entry.pointer}\`, which does not exist in the tree`, line);
3423
+ push("unarchived", `no source for \`${entry.pointer}\`; keep the text and report it, never delete it`, line);
3424
+ continue;
3425
+ }
3426
+ if (entry.section && resolved.sectionFound === false) {
3427
+ push("drift", `cites \xA7${entry.section} in \`${entry.pointer}\`, but that section no longer exists`, line);
3428
+ continue;
3429
+ }
3430
+ if (resolved.mtimeMs !== void 0 && entry.verified) {
3431
+ const modified = new Date(resolved.mtimeMs).toISOString().slice(0, 10);
3432
+ if (modified > entry.verified) {
3433
+ push("drift", `\`${entry.pointer}\` changed on ${modified}, after this entry was verified on ${entry.verified}; re-verify it`, line);
3434
+ }
3435
+ }
3436
+ if (resolved.sourceText !== void 0) {
3437
+ const missing = invalidNumbers(entry.rule, resolved.sourceText);
3438
+ if (missing.length > 0) {
3439
+ push("drift", `numeric claim(s) ${missing.join(", ")} not found in \`${entry.pointer}\`; the map and the source may disagree`, line);
3440
+ }
3441
+ if (sharesVerbatimRun(entry.rule, resolved.sourceText)) {
3442
+ push("restatement", `contains a long verbatim span from \`${entry.pointer}\`; cite the section instead`, line);
3443
+ }
3444
+ const conflict = possibleNegationConflict(entry.rule, resolved.sourceText);
3445
+ if (conflict) {
3446
+ push("drift", `possible contradiction with \`${entry.pointer}\`: "${conflict}"; verify, do not silently overwrite either side`, line);
3447
+ }
3448
+ }
3449
+ }
3450
+ if (entries.length < spec.minEntries) {
3451
+ push("charter", `${entries.length} entr${entries.length === 1 ? "y" : "ies"} is degenerate for ${spec.charter}`);
3452
+ } else if (entries.length > 0 && binding === 0) {
3453
+ push("charter", "every entry is provisional or disputed; a navigation file must carry at least one binding constraint");
3454
+ }
3455
+ return { spec, lines: nonBlank, entries, violations };
3456
+ };
3457
+ var normalizeMapText = (spec, text, options = {}) => {
3458
+ const now = options.now ?? Date.now();
3459
+ if (spec.kind === "backlog") {
3460
+ const result = normalizeBacklog(text, now);
3461
+ return {
3462
+ text: result.text,
3463
+ removed: result.removed.map((title) => ({ line: 0, text: title })),
3464
+ kept: 0
3465
+ };
3466
+ }
3467
+ const entries = parseMapEntries(text);
3468
+ const drop = /* @__PURE__ */ new Set();
3469
+ const removed = [];
3470
+ for (const entry of entries) {
3471
+ if (entry.marker !== "provisional") continue;
3472
+ if (!entry.verified || !entry.pointer) continue;
3473
+ if (options.canResolve && !options.canResolve(entry.pointer, entry.section)) continue;
3474
+ const at = Date.parse(`${entry.verified}T00:00:00Z`);
3475
+ if (!Number.isFinite(at)) continue;
3476
+ if (now - at <= spec.provisionalDays * DAY_MS2) continue;
3477
+ for (let i = entry.startLine; i < entry.endLine; i++) drop.add(i);
3478
+ removed.push({ line: entry.startLine + 1, text: truncate(entry.rule) });
3479
+ }
3480
+ if (removed.length === 0) return { text, removed, kept: entries.length };
3481
+ const keptLines = text.split(/\r?\n/).filter((_, index) => !drop.has(index));
3482
+ const compact = keptLines.join("\n").replace(/\n{3,}/g, "\n\n");
3483
+ const out = compact.endsWith("\n") ? compact : `${compact}
3484
+ `;
3485
+ return { text: out, removed, kept: entries.length - removed.length };
3486
+ };
3487
+ var maintainMap = (root, mapDir, now = Date.now()) => {
3488
+ const files = [];
3489
+ let removedTotal = 0;
3490
+ for (const spec of MAP_SPECS) {
3491
+ const absolute = path.join(mapDir, spec.file);
3492
+ if (!existsSync2(absolute)) continue;
3493
+ let before;
3494
+ try {
3495
+ before = readFileSync4(absolute, "utf8");
3496
+ } catch {
3497
+ continue;
3498
+ }
3499
+ const canResolve = (pointer, section) => resolvePointer(root, pointer, section).exists;
3500
+ const result = normalizeMapText(spec, before, { now, canResolve });
3501
+ const changed = result.text !== before;
3502
+ if (changed) writeFileSync3(absolute, result.text);
3503
+ if (result.removed.length > 0) {
3504
+ removedTotal += result.removed.length;
3505
+ files.push({ file: spec.file, changed, removed: result.removed });
3506
+ } else if (changed) {
3507
+ files.push({ file: spec.file, changed, removed: [] });
3508
+ }
3509
+ }
3510
+ return { files, removedTotal };
3511
+ };
3512
+ var auditMap = (root, mapDir, now = Date.now()) => {
3513
+ const files = [];
3514
+ const violations = [];
3515
+ const rulesSeen = /* @__PURE__ */ new Map();
3516
+ for (const spec of MAP_SPECS) {
3517
+ const absolute = path.join(mapDir, spec.file);
3518
+ if (!existsSync2(absolute)) {
3519
+ const violation = { file: spec.file, kind: "missing-file", detail: `missing; charter is ${spec.charter}` };
3520
+ violations.push(violation);
3521
+ continue;
3522
+ }
3523
+ let text;
3524
+ try {
3525
+ text = readFileSync4(absolute, "utf8");
3526
+ } catch {
3527
+ violations.push({ file: spec.file, kind: "missing-file", detail: "unreadable" });
3528
+ continue;
3529
+ }
3530
+ const result = validateMapText(spec, text, { root, now });
3531
+ files.push(result);
3532
+ violations.push(...result.violations);
3533
+ if (spec.kind === "entries") {
3534
+ for (const entry of result.entries) {
3535
+ const key = normalizeRule(entry.rule);
3536
+ if (key.length < 12) continue;
3537
+ const home = rulesSeen.get(key);
3538
+ if (home !== void 0 && home !== spec.file) {
3539
+ violations.push({
3540
+ file: spec.file,
3541
+ line: entry.startLine + 1,
3542
+ kind: "duplicate",
3543
+ detail: `same rule is already stated in ${home}; one fact has one home`
3544
+ });
3545
+ } else if (home === void 0) {
3546
+ rulesSeen.set(key, spec.file);
3547
+ }
3548
+ }
3549
+ }
3550
+ }
3551
+ return { ok: violations.length === 0, files, violations };
3552
+ };
3553
+ var formatViolations = (violations, limit = 20) => violations.slice(0, limit).map((violation) => {
3554
+ const where = violation.line !== void 0 ? `${violation.file}:${violation.line}` : violation.file;
3555
+ return `${where} [${violation.kind}] ${violation.detail}`;
3556
+ });
3557
+
2930
3558
  // src/lib/jsonc.ts
2931
3559
  var stripJsonc = (raw) => {
2932
3560
  let out = "";
@@ -2975,12 +3603,12 @@ var parseJsonc = (raw) => {
2975
3603
  };
2976
3604
 
2977
3605
  // 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");
3606
+ import { readFileSync as readFileSync5 } from "node:fs";
3607
+ import * as path2 from "node:path";
3608
+ var CONTEXT_DIR = path2.join(".opencode", "context");
2981
3609
  var WRITER = "context";
2982
3610
  var INSTRUCTIONS_WRITER = AGENT_INSTRUCTIONS_WRITER;
2983
- var toPosix = (value) => value.split(path.sep).join("/");
3611
+ var toPosix = (value) => value.split(path2.sep).join("/");
2984
3612
  var compilePattern = (pattern) => {
2985
3613
  const escaped = toPosix(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
2986
3614
  return new RegExp(`^${escaped}$`, "s");
@@ -3005,17 +3633,17 @@ var readAgents = (raw) => {
3005
3633
  };
3006
3634
  var permissionOf = (agent) => agent?.permission?.edit;
3007
3635
  var evaluatedForm = (mapDir, projectRoot) => {
3008
- const absolute = toPosix(path.join(mapDir, "architecture.md"));
3009
- return projectRoot ? toPosix(path.relative(projectRoot, absolute)) : absolute;
3636
+ const absolute = toPosix(path2.join(mapDir, "architecture.md"));
3637
+ return projectRoot ? toPosix(path2.relative(projectRoot, absolute)) : absolute;
3010
3638
  };
3011
3639
  var agentsForm = (mapDir, projectRoot) => {
3012
- const absolute = toPosix(path.join(mapDir, "..", "..", "AGENTS.md"));
3013
- return projectRoot ? toPosix(path.relative(projectRoot, absolute)) : absolute;
3640
+ const absolute = toPosix(path2.join(mapDir, "..", "..", "AGENTS.md"));
3641
+ return projectRoot ? toPosix(path2.relative(projectRoot, absolute)) : absolute;
3014
3642
  };
3015
3643
  var verifyWriterRule = (configPath, mapDir, projectRoot) => {
3016
3644
  let raw;
3017
3645
  try {
3018
- raw = readFileSync4(configPath, "utf8");
3646
+ raw = readFileSync5(configPath, "utf8");
3019
3647
  } catch {
3020
3648
  return void 0;
3021
3649
  }
@@ -3028,7 +3656,7 @@ var verifyWriterRule = (configPath, mapDir, projectRoot) => {
3028
3656
  const form = evaluatedForm(mapDir, projectRoot);
3029
3657
  if (rules.length > 0) {
3030
3658
  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.`;
3659
+ 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
3660
  }
3033
3661
  }
3034
3662
  const agents = readAgents(raw);
@@ -3061,7 +3689,7 @@ var verifyWriterRule = (configPath, mapDir, projectRoot) => {
3061
3689
 
3062
3690
  // src/plugins/context-autoupdate.ts
3063
3691
  var SERVICE2 = "context-autoupdate";
3064
- var CONTEXT_DIR2 = path2.join(".opencode", "context");
3692
+ var CONTEXT_DIR2 = path3.join(".opencode", "context");
3065
3693
  var COOLDOWN_MS = 10 * 60 * 1e3;
3066
3694
  var OWNED_TTL_MS = 20 * 60 * 1e3;
3067
3695
  var MAX_FILES_LISTED = 25;
@@ -3098,13 +3726,19 @@ var NO_BULK_READS = [
3098
3726
  "that PRODUCES or CONSUMES them instead. If a payload must be sampled, use",
3099
3727
  "`head -c 2000` or `wc -c` \u2014 never `cat` or a bare `grep -r` across one."
3100
3728
  ].join(" ");
3101
- var toPosix2 = (value) => value.split(path2.sep).join("/");
3729
+ var ENTRY_CONTRACT = [
3730
+ "Every navigation entry uses the schema in your system prompt:",
3731
+ "`- [binding|provisional|disputed] <rule, at most three lines> \u2014 See `<path>`[ \xA7Section] (verified YYYY-MM-DD)`.",
3732
+ "Stay inside each file's line budget. Point at the page or path that owns a fact; do not restate it.",
3733
+ "After writing, run the drift pass: report a contradiction, never overwrite the page or silently pick a side."
3734
+ ].join(" ");
3735
+ var toPosix2 = (value) => value.split(path3.sep).join("/");
3102
3736
  var findProjectConfig = () => {
3103
- let dir = import.meta.dir ?? path2.dirname(new URL(import.meta.url).pathname);
3737
+ let dir = import.meta.dir ?? path3.dirname(new URL(import.meta.url).pathname);
3104
3738
  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);
3739
+ const candidate = path3.join(dir, "opencode.jsonc");
3740
+ if (existsSync3(candidate)) return candidate;
3741
+ const parent = path3.dirname(dir);
3108
3742
  if (parent === dir) break;
3109
3743
  dir = parent;
3110
3744
  }
@@ -3114,9 +3748,9 @@ var snapshotMap = (dir) => {
3114
3748
  const out = /* @__PURE__ */ new Map();
3115
3749
  try {
3116
3750
  for (const name of readdirSync2(dir)) {
3117
- const file = path2.join(dir, name);
3751
+ const file = path3.join(dir, name);
3118
3752
  try {
3119
- const st = statSync3(file);
3753
+ const st = statSync4(file);
3120
3754
  if (st.isFile()) out.set(name, `${st.size}:${st.mtimeMs}`);
3121
3755
  } catch {
3122
3756
  }
@@ -3133,20 +3767,20 @@ var changedSince = (before, dir) => {
3133
3767
  return changed;
3134
3768
  };
3135
3769
  var RETAIN_TRANSCRIPTS = 20;
3136
- var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(homedir3(), ".local", "share", "opencode", "lacode", "cartographer");
3770
+ var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path3.join(homedir3(), ".local", "share", "opencode", "lacode", "cartographer");
3137
3771
  var retireSession = async (client, directory, sessionID, mode, log) => {
3138
3772
  try {
3139
3773
  const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
3140
3774
  const dir = transcriptDir();
3141
3775
  mkdirSync3(dir, { recursive: true });
3142
3776
  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));
3777
+ const file = path3.join(dir, `${stamp}-${mode}.json`);
3778
+ writeFileSync4(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
3145
3779
  pruneTranscripts(dir);
3146
3780
  const gone = async () => await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0) === void 0;
3147
3781
  await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
3148
3782
  if (await gone()) return;
3149
- await new Promise((resolve3) => setTimeout(resolve3, 500));
3783
+ await new Promise((resolve4) => setTimeout(resolve4, 500));
3150
3784
  await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
3151
3785
  if (await gone()) return;
3152
3786
  await log?.("warn", "the cartographer's session could not be removed from the session list; it stays as an empty entry", {
@@ -3162,7 +3796,7 @@ var pruneTranscripts = (dir) => {
3162
3796
  const entries = readdirSync2(dir).filter((name) => name.endsWith(".json")).sort();
3163
3797
  for (const name of entries.slice(0, Math.max(0, entries.length - RETAIN_TRANSCRIPTS))) {
3164
3798
  try {
3165
- unlinkSync3(path2.join(dir, name));
3799
+ unlinkSync3(path3.join(dir, name));
3166
3800
  } catch {
3167
3801
  }
3168
3802
  }
@@ -3176,24 +3810,24 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3176
3810
  const state2 = state(options);
3177
3811
  const log = (level, message, extra) => client.app.log({ body: { service: SERVICE2, level, message, extra } }).catch(() => {
3178
3812
  });
3179
- const root = path2.resolve(directory);
3180
- const contextAbs = path2.join(root, CONTEXT_DIR2);
3181
- const activeBacklogPath = path2.join(contextAbs, ACTIVE_BACKLOG_FILE);
3813
+ const root = path3.resolve(directory);
3814
+ const contextAbs = path3.join(root, CONTEXT_DIR2);
3815
+ const activeBacklogPath = path3.join(contextAbs, ACTIVE_BACKLOG_FILE);
3182
3816
  const config = findProjectConfig();
3183
3817
  const writerProblem = config ? verifyWriterRule(config, contextAbs, root) : void 0;
3184
3818
  if (writerProblem) void log("error", writerProblem);
3185
3819
  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;
3820
+ const absolute = path3.resolve(root, file);
3821
+ if (absolute !== root && !absolute.startsWith(root + path3.sep)) return false;
3822
+ if (absolute === contextAbs || absolute.startsWith(contextAbs + path3.sep)) return false;
3189
3823
  return !toPosix2(absolute).split("/").some((segment) => IGNORED_SEGMENTS.has(segment));
3190
3824
  };
3191
3825
  const normalizeActiveBacklog = async () => {
3192
3826
  try {
3193
- if (!existsSync2(activeBacklogPath)) return { removed: 0 };
3194
- const before = readFileSync5(activeBacklogPath, "utf8");
3827
+ if (!existsSync3(activeBacklogPath)) return { removed: 0 };
3828
+ const before = readFileSync6(activeBacklogPath, "utf8");
3195
3829
  const { text, removed } = normalizeBacklog(before);
3196
- if (text !== before) writeFileSync3(activeBacklogPath, text);
3830
+ if (text !== before) writeFileSync4(activeBacklogPath, text);
3197
3831
  if (removed.length > 0) {
3198
3832
  await log("info", `active backlog: dropped ${removed.length} task(s) finished more than a week ago`, {
3199
3833
  removed: removed.length
@@ -3204,7 +3838,46 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3204
3838
  return { removed: 0 };
3205
3839
  }
3206
3840
  };
3207
- void normalizeActiveBacklog();
3841
+ const applyLifecycle = async () => {
3842
+ try {
3843
+ const result = maintainMap(root, contextAbs);
3844
+ for (const file of result.files) {
3845
+ if (file.removed.length === 0) continue;
3846
+ await log(
3847
+ "info",
3848
+ `map lifecycle: dropped ${file.removed.length} expired provisional entr${file.removed.length === 1 ? "y" : "ies"} from ${file.file}`,
3849
+ { file: file.file, removed: file.removed.map((entry) => entry.text) }
3850
+ );
3851
+ }
3852
+ return result;
3853
+ } catch {
3854
+ return void 0;
3855
+ }
3856
+ };
3857
+ const reportMapAudit = async (mode) => {
3858
+ try {
3859
+ const audit = auditMap(root, contextAbs);
3860
+ if (audit.ok) {
3861
+ await log("info", "context map validation passed", { mode, files: audit.files.length });
3862
+ return audit;
3863
+ }
3864
+ const byKind = {};
3865
+ for (const violation of audit.violations) {
3866
+ byKind[violation.kind] = (byKind[violation.kind] ?? 0) + 1;
3867
+ }
3868
+ await log("error", `context map validation failed: ${audit.violations.length} issue(s)`, {
3869
+ mode,
3870
+ byKind,
3871
+ issues: formatViolations(audit.violations, 20),
3872
+ note: "reported, not rewritten; fix the entry or its source and run /context-update"
3873
+ });
3874
+ return audit;
3875
+ } catch (error) {
3876
+ await log("warn", `context map validation could not run: ${String(error)}`, { mode });
3877
+ return void 0;
3878
+ }
3879
+ };
3880
+ void applyLifecycle();
3208
3881
  const listed = (files) => {
3209
3882
  const shown = files.slice(0, MAX_FILES_LISTED);
3210
3883
  const rest = files.length - shown.length;
@@ -3222,7 +3895,9 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3222
3895
  "",
3223
3896
  NO_BULK_READS,
3224
3897
  "",
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."
3898
+ ENTRY_CONTRACT,
3899
+ "",
3900
+ "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
3901
  ].join("\n") : [
3227
3902
  "Incremental context update. Project root: " + directory,
3228
3903
  "",
@@ -3231,6 +3906,8 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3231
3906
  "",
3232
3907
  NO_BULK_READS,
3233
3908
  "",
3909
+ ENTRY_CONTRACT,
3910
+ "",
3234
3911
  '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
3912
  ].join("\n");
3236
3913
  return {
@@ -3238,7 +3915,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3238
3915
  try {
3239
3916
  if (event.type === "file.edited") {
3240
3917
  const file = event.properties.file;
3241
- if (path2.resolve(root, file) === activeBacklogPath) {
3918
+ if (path3.resolve(root, file) === activeBacklogPath) {
3242
3919
  await normalizeActiveBacklog();
3243
3920
  return;
3244
3921
  }
@@ -3265,7 +3942,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3265
3942
  const requeue = () => {
3266
3943
  for (const file of files) pending.add(file);
3267
3944
  };
3268
- const mode = existsSync2(path2.join(contextAbs, "contexts.md")) ? "update" : "bootstrap";
3945
+ const mode = existsSync3(path3.join(contextAbs, "contexts.md")) ? "update" : "bootstrap";
3269
3946
  init(state2, (m) => void log("warn", m));
3270
3947
  const snap = snapshot(state2);
3271
3948
  const slot = pickSlot(state2, snap);
@@ -3332,6 +4009,13 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3332
4009
  hint: "either nothing needed updating, or the cartographer could not write -- check the one-writer permission rule"
3333
4010
  });
3334
4011
  }
4012
+ await applyLifecycle();
4013
+ const audit = await reportMapAudit(mode);
4014
+ if (audit && !audit.ok) {
4015
+ await log("error", `auto ${mode} finished with ${audit.violations.length} map validation issue(s)`, {
4016
+ sessionID: target
4017
+ });
4018
+ }
3335
4019
  }).catch(async (error) => {
3336
4020
  release(state2, claimKey2);
3337
4021
  await retireSession(client, directory, target, mode, log);
@@ -3345,11 +4029,92 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
3345
4029
  };
3346
4030
  });
3347
4031
 
4032
+ // src/lib/feedback.ts
4033
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync7, renameSync as renameSync3, unlinkSync as unlinkSync4, writeFileSync as writeFileSync5 } from "node:fs";
4034
+ import { homedir as homedir4 } from "node:os";
4035
+ import { dirname as dirname4, join as join7 } from "node:path";
4036
+ var FEEDBACK_HINT_ENV = "LACODE_NO_FEEDBACK_HINT";
4037
+ 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)";
4038
+ var feedbackStatePath = () => join7(homedir4(), ".local", "share", "opencode", "lacode", "feedback.json");
4039
+ function readFeedbackState(path5 = feedbackStatePath()) {
4040
+ try {
4041
+ const parsed = JSON.parse(readFileSync7(path5, "utf8"));
4042
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
4043
+ return parsed;
4044
+ } catch (error) {
4045
+ if (error?.code === "ENOENT") return {};
4046
+ return void 0;
4047
+ }
4048
+ }
4049
+ function markFeedbackHintShown(path5 = feedbackStatePath()) {
4050
+ try {
4051
+ mkdirSync4(dirname4(path5), { recursive: true });
4052
+ const tmp = `${path5}.${process.pid}.tmp`;
4053
+ writeFileSync5(tmp, JSON.stringify({ feedbackHintShown: true }));
4054
+ renameSync3(tmp, path5);
4055
+ return true;
4056
+ } catch {
4057
+ return false;
4058
+ }
4059
+ }
4060
+ function restoreFeedbackState(path5 = feedbackStatePath(), previous = {}) {
4061
+ try {
4062
+ if (Object.keys(previous).length === 0) {
4063
+ unlinkSync4(path5);
4064
+ return;
4065
+ }
4066
+ const tmp = `${path5}.${process.pid}.tmp`;
4067
+ writeFileSync5(tmp, JSON.stringify(previous));
4068
+ renameSync3(tmp, path5);
4069
+ } catch {
4070
+ }
4071
+ }
4072
+ async function maybeShowFeedbackHint(deps = {}) {
4073
+ const env = deps.env ?? process.env;
4074
+ if (deps.enabled === false || deps.feedbackHint === false) return "disabled";
4075
+ if (env[FEEDBACK_HINT_ENV] === "1") return "disabled";
4076
+ const interactive = deps.interactive ?? process.stdout.isTTY === true;
4077
+ if (!interactive || env.CI || env.NODE_TEST_CONTEXT) return "unavailable";
4078
+ if (!deps.show) return "unavailable";
4079
+ const state2 = readFeedbackState(deps.statePath);
4080
+ if (state2 === void 0 || state2.feedbackHintShown === true) return "skipped";
4081
+ if (!markFeedbackHintShown(deps.statePath)) return "skipped";
4082
+ try {
4083
+ await deps.show(FEEDBACK_HINT);
4084
+ } catch {
4085
+ restoreFeedbackState(deps.statePath, state2);
4086
+ return "skipped";
4087
+ }
4088
+ return "shown";
4089
+ }
4090
+
4091
+ // src/plugins/feedback.ts
4092
+ async function feedbackHooks(input, options, deps = {}) {
4093
+ const opts = options ?? {};
4094
+ if (opts.enabled === false || opts.feedbackHint === false) return {};
4095
+ const tui = input.client.tui;
4096
+ const show = deps.show ?? (tui?.showToast ? async (message) => {
4097
+ await tui.showToast({
4098
+ body: { title: "lacode", message, variant: "info", duration: 8e3 }
4099
+ });
4100
+ } : void 0);
4101
+ void maybeShowFeedbackHint({
4102
+ enabled: opts.enabled,
4103
+ feedbackHint: opts.feedbackHint,
4104
+ env: deps.env,
4105
+ statePath: deps.statePath,
4106
+ interactive: deps.interactive,
4107
+ show
4108
+ }).catch(() => {
4109
+ });
4110
+ return {};
4111
+ }
4112
+
3348
4113
  // src/plugins/image-paste.ts
3349
4114
  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";
4115
+ import { globSync, mkdirSync as mkdirSync5, readFileSync as readFileSync8, renameSync as renameSync4, statSync as statSync5, writeFileSync as writeFileSync6 } from "node:fs";
4116
+ import { homedir as homedir5 } from "node:os";
4117
+ import { basename as basename2, join as join8 } from "node:path";
3353
4118
 
3354
4119
  // src/lib/clipboard.ts
3355
4120
  import { spawnSync } from "node:child_process";
@@ -3410,12 +4175,12 @@ var WINDOWS_SCRIPT = [
3410
4175
  var readWindows = async (run) => {
3411
4176
  const result = await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT]);
3412
4177
  if (!result.ok) return null;
3413
- const path4 = result.stdout.toString("utf8").replace(/^\uFEFF/, "").trim();
3414
- if (!path4) return null;
4178
+ const path5 = result.stdout.toString("utf8").replace(/^\uFEFF/, "").trim();
4179
+ if (!path5) return null;
3415
4180
  try {
3416
- return validImage({ ok: true, stdout: await readFile(path4) });
4181
+ return validImage({ ok: true, stdout: await readFile(path5) });
3417
4182
  } finally {
3418
- await rm(path4, { force: true }).catch(() => {
4183
+ await rm(path5, { force: true }).catch(() => {
3419
4184
  });
3420
4185
  }
3421
4186
  };
@@ -3499,8 +4264,8 @@ function splitArguments(input) {
3499
4264
  return out;
3500
4265
  }
3501
4266
  function expandTilde(p) {
3502
- if (p === "~") return homedir4();
3503
- if (p.startsWith("~/")) return join6(homedir4(), p.slice(2));
4267
+ if (p === "~") return homedir5();
4268
+ if (p.startsWith("~/")) return join8(homedir5(), p.slice(2));
3504
4269
  return p;
3505
4270
  }
3506
4271
  function expandGlob(p) {
@@ -3515,11 +4280,11 @@ function storeImage(bytes, dir, sessionID, label) {
3515
4280
  const kind = detectImageKind(bytes);
3516
4281
  if (kind === null) return null;
3517
4282
  const hash = createHash("sha256").update(bytes).digest("hex");
3518
- mkdirSync4(dir, { recursive: true });
3519
- const target = join6(dir, `${hash}.${kind.ext}`);
4283
+ mkdirSync5(dir, { recursive: true });
4284
+ const target = join8(dir, `${hash}.${kind.ext}`);
3520
4285
  const tmp = `${target}.tmp`;
3521
- writeFileSync4(tmp, bytes, { mode: 384 });
3522
- renameSync3(tmp, target);
4286
+ writeFileSync6(tmp, bytes, { mode: 384 });
4287
+ renameSync4(tmp, target);
3523
4288
  const part = {
3524
4289
  id: randomUUID(),
3525
4290
  sessionID,
@@ -3547,7 +4312,7 @@ async function imagePasteHooks(input, options, deps) {
3547
4312
  if (opts.enabled === false) return {};
3548
4313
  const maxBytes = resolveMaxBytes(opts);
3549
4314
  const maxImages = resolveMaxImages(opts);
3550
- const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join6(homedir4(), ".local/share/opencode/lacode/images");
4315
+ const dir = opts.dir ?? process.env.LACODE_IMAGE_DIR ?? join8(homedir5(), ".local/share/opencode/lacode/images");
3551
4316
  const readClipboard = (deps ?? {}).readClipboard ?? readClipboardImage;
3552
4317
  return {
3553
4318
  "command.execute.before": async (cmd, output) => {
@@ -3588,7 +4353,7 @@ async function imagePasteHooks(input, options, deps) {
3588
4353
  const name = basename2(filePath);
3589
4354
  let size;
3590
4355
  try {
3591
- const stat = statSync4(filePath);
4356
+ const stat = statSync5(filePath);
3592
4357
  if (!stat.isFile()) {
3593
4358
  skipped.push(name);
3594
4359
  continue;
@@ -3604,7 +4369,7 @@ async function imagePasteHooks(input, options, deps) {
3604
4369
  }
3605
4370
  let bytes;
3606
4371
  try {
3607
- bytes = readFileSync6(filePath);
4372
+ bytes = readFileSync8(filePath);
3608
4373
  } catch {
3609
4374
  skipped.push(name);
3610
4375
  continue;
@@ -3662,13 +4427,14 @@ function scrubSlotNames(text) {
3662
4427
 
3663
4428
  // src/plugins/notify.ts
3664
4429
  import { spawn as spawn2 } from "node:child_process";
3665
- import { existsSync as existsSync3 } from "node:fs";
3666
- import * as path3 from "node:path";
4430
+ import { randomUUID as randomUUID2 } from "node:crypto";
4431
+ import { existsSync as existsSync4 } from "node:fs";
4432
+ import * as path4 from "node:path";
3667
4433
 
3668
4434
  // src/lib/notify.ts
3669
- 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";
3670
- import { homedir as homedir5 } from "node:os";
3671
- import { basename as basename3, join as join7 } from "node:path";
4435
+ 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";
4436
+ import { homedir as homedir6 } from "node:os";
4437
+ import { basename as basename3, join as join9 } from "node:path";
3672
4438
  var INTERNAL_TITLE_PREFIXES = [
3673
4439
  // context-autoupdate spawns the cartographer through the session API, with no
3674
4440
  // parentID, so it looks exactly like a root session going idle. Notifying for
@@ -3845,7 +4611,7 @@ var Notifier = class {
3845
4611
  return this.lastSent.size;
3846
4612
  }
3847
4613
  };
3848
- var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join7(homedir5(), ".local", "share", "opencode", "lacode", "notify");
4614
+ var notificationStateDir = () => process.env.LACODE_NOTIFY_DIR ?? join9(homedir6(), ".local", "share", "opencode", "lacode", "notify");
3849
4615
  var safeCorrelationKey = (correlationKey) => correlationKey.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64);
3850
4616
  var CLAIM_WINDOW_MS = 5e3;
3851
4617
  var isPidAlive2 = (pid) => {
@@ -3856,19 +4622,19 @@ var isPidAlive2 = (pid) => {
3856
4622
  return error.code === "EPERM";
3857
4623
  }
3858
4624
  };
3859
- var claimFile = (sessionID, kind) => join7(notificationStateDir(), `claim.${safeCorrelationKey(`${sessionID}.${kind}`)}`);
3860
- var claimNotification = (sessionID, kind) => {
3861
- const path4 = claimFile(sessionID, kind);
4625
+ var claimFile = (sessionID, kind) => join9(notificationStateDir(), `claim.${safeCorrelationKey(`${sessionID}.${kind}`)}`);
4626
+ var claimNotification = (sessionID, kind, instanceId = "") => {
4627
+ const path5 = claimFile(sessionID, kind);
3862
4628
  try {
3863
- mkdirSync5(notificationStateDir(), { recursive: true });
4629
+ mkdirSync6(notificationStateDir(), { recursive: true });
3864
4630
  } catch {
3865
4631
  return true;
3866
4632
  }
3867
4633
  const take = () => {
3868
4634
  try {
3869
- const fd = openSync2(path4, "wx");
4635
+ const fd = openSync2(path5, "wx");
3870
4636
  try {
3871
- writeSync2(fd, String(process.pid));
4637
+ writeSync2(fd, `${process.pid} ${instanceId} ${kind}`);
3872
4638
  } finally {
3873
4639
  closeSync2(fd);
3874
4640
  }
@@ -3884,20 +4650,28 @@ var claimNotification = (sessionID, kind) => {
3884
4650
  const judge = () => {
3885
4651
  let raw;
3886
4652
  try {
3887
- raw = readFileSync7(path4, "utf8").trim();
4653
+ raw = readFileSync9(path5, "utf8").trim();
3888
4654
  } catch (error) {
3889
4655
  return error.code === "ENOENT" ? "gone" : "unknown";
3890
4656
  }
4657
+ const fields = raw.split(/\s+/).filter((entry) => entry.length > 0);
3891
4658
  let holder;
3892
4659
  const empty = raw === "";
3893
4660
  if (!empty) {
3894
- const parsed = Number.parseInt(raw, 10);
4661
+ const parsed = Number.parseInt(fields[0] ?? "", 10);
3895
4662
  holder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
3896
4663
  }
3897
- if (holder === process.pid) return "ours";
4664
+ if (holder === process.pid) {
4665
+ const hasToken = fields.length >= 3;
4666
+ const fileToken = hasToken ? fields[1] ?? "" : "";
4667
+ if (hasToken && instanceId !== "" && fileToken !== instanceId) {
4668
+ return "held";
4669
+ }
4670
+ return "ours";
4671
+ }
3898
4672
  let age = Number.POSITIVE_INFINITY;
3899
4673
  try {
3900
- age = Date.now() - statSync5(path4).mtimeMs;
4674
+ age = Date.now() - statSync6(path5).mtimeMs;
3901
4675
  } catch {
3902
4676
  return "gone";
3903
4677
  }
@@ -3914,7 +4688,7 @@ var claimNotification = (sessionID, kind) => {
3914
4688
  if (judged === "held") return false;
3915
4689
  if (judged === "unknown") return true;
3916
4690
  if (judged === "gone") return take() !== "held";
3917
- const lockPath = `${path4}.takeover`;
4691
+ const lockPath = `${path5}.takeover`;
3918
4692
  const takeOver = () => {
3919
4693
  const create = () => {
3920
4694
  try {
@@ -3926,7 +4700,7 @@ var claimNotification = (sessionID, kind) => {
3926
4700
  }
3927
4701
  return () => {
3928
4702
  try {
3929
- unlinkSync4(lockPath);
4703
+ unlinkSync5(lockPath);
3930
4704
  } catch {
3931
4705
  }
3932
4706
  };
@@ -3940,9 +4714,9 @@ var claimNotification = (sessionID, kind) => {
3940
4714
  let lockHolder;
3941
4715
  let lockAge = Number.POSITIVE_INFINITY;
3942
4716
  try {
3943
- const parsed = Number.parseInt(readFileSync7(lockPath, "utf8").trim(), 10);
4717
+ const parsed = Number.parseInt(readFileSync9(lockPath, "utf8").trim(), 10);
3944
4718
  lockHolder = Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
3945
- lockAge = Date.now() - statSync5(lockPath).mtimeMs;
4719
+ lockAge = Date.now() - statSync6(lockPath).mtimeMs;
3946
4720
  } catch {
3947
4721
  return create();
3948
4722
  }
@@ -3950,7 +4724,7 @@ var claimNotification = (sessionID, kind) => {
3950
4724
  return void 0;
3951
4725
  }
3952
4726
  try {
3953
- unlinkSync4(lockPath);
4727
+ unlinkSync5(lockPath);
3954
4728
  } catch {
3955
4729
  return void 0;
3956
4730
  }
@@ -3964,7 +4738,7 @@ var claimNotification = (sessionID, kind) => {
3964
4738
  if (current === "held") return false;
3965
4739
  if (current === "unknown") return true;
3966
4740
  try {
3967
- unlinkSync4(path4);
4741
+ unlinkSync5(path5);
3968
4742
  } catch {
3969
4743
  }
3970
4744
  return take() !== "held";
@@ -3973,23 +4747,23 @@ var claimNotification = (sessionID, kind) => {
3973
4747
  }
3974
4748
  };
3975
4749
  var releaseNotificationClaim = (sessionID, kind) => {
3976
- const path4 = claimFile(sessionID, kind);
4750
+ const path5 = claimFile(sessionID, kind);
3977
4751
  try {
3978
- const parsed = Number.parseInt(readFileSync7(path4, "utf8").trim(), 10);
4752
+ const parsed = Number.parseInt(readFileSync9(path5, "utf8").trim(), 10);
3979
4753
  if (!Number.isFinite(parsed) || parsed <= 0) return;
3980
4754
  if (parsed !== process.pid) return;
3981
- unlinkSync4(path4);
4755
+ unlinkSync5(path5);
3982
4756
  } catch {
3983
4757
  }
3984
4758
  };
3985
- var completionFile = (sessionID) => join7(notificationStateDir(), `completion.${safeCorrelationKey(sessionID)}`);
4759
+ var completionFile = (sessionID) => join9(notificationStateDir(), `completion.${safeCorrelationKey(sessionID)}`);
3986
4760
  var claimCompletion = (sessionID, kind) => {
3987
- const path4 = completionFile(sessionID);
4761
+ const path5 = completionFile(sessionID);
3988
4762
  try {
3989
- mkdirSync5(notificationStateDir(), { recursive: true });
3990
- const fd = openSync2(path4, "wx");
4763
+ mkdirSync6(notificationStateDir(), { recursive: true });
4764
+ const fd = openSync2(path5, "wx");
3991
4765
  try {
3992
- writeFileSync5(fd, `${process.pid} ${kind}
4766
+ writeFileSync7(fd, `${process.pid} ${kind}
3993
4767
  `);
3994
4768
  } finally {
3995
4769
  closeSync2(fd);
@@ -4002,18 +4776,18 @@ var claimCompletion = (sessionID, kind) => {
4002
4776
  };
4003
4777
  var releaseCompletion = (sessionID) => {
4004
4778
  try {
4005
- unlinkSync4(completionFile(sessionID));
4779
+ unlinkSync5(completionFile(sessionID));
4006
4780
  } catch {
4007
4781
  }
4008
4782
  };
4009
4783
  var markCompletion = (sessionID, kind) => {
4010
4784
  try {
4011
- mkdirSync5(notificationStateDir(), { recursive: true });
4012
- const path4 = completionFile(sessionID);
4013
- const tmp = `${path4}.${process.pid}.tmp`;
4014
- writeFileSync5(tmp, `${process.pid} ${kind}
4785
+ mkdirSync6(notificationStateDir(), { recursive: true });
4786
+ const path5 = completionFile(sessionID);
4787
+ const tmp = `${path5}.${process.pid}.tmp`;
4788
+ writeFileSync7(tmp, `${process.pid} ${kind}
4015
4789
  `);
4016
- renameSync4(tmp, path4);
4790
+ renameSync5(tmp, path5);
4017
4791
  } catch {
4018
4792
  }
4019
4793
  };
@@ -4034,13 +4808,13 @@ var findHelper = () => {
4034
4808
  const home = process.env.HOME ?? "";
4035
4809
  const candidates = [
4036
4810
  process.env.OC_NOTIFY_HELPER,
4037
- path3.join(home, "bin", "lacode"),
4038
- path3.join(PACKAGE_ROOT, "dist", "lacode.js"),
4039
- path3.join(PACKAGE_ROOT, "bin", "lacode"),
4040
- path3.join(home, "bin", "oc-notify"),
4041
- path3.join(PACKAGE_ROOT, "bin", "oc-notify")
4811
+ path4.join(home, "bin", "lacode"),
4812
+ path4.join(PACKAGE_ROOT, "dist", "lacode.js"),
4813
+ path4.join(PACKAGE_ROOT, "bin", "lacode"),
4814
+ path4.join(home, "bin", "oc-notify"),
4815
+ path4.join(PACKAGE_ROOT, "bin", "oc-notify")
4042
4816
  ].filter((candidate) => typeof candidate === "string" && candidate.length > 0);
4043
- for (const candidate of candidates) if (existsSync3(candidate)) return candidate;
4817
+ for (const candidate of candidates) if (existsSync4(candidate)) return candidate;
4044
4818
  return void 0;
4045
4819
  };
4046
4820
  var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
@@ -4050,6 +4824,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
4050
4824
  const soundEnv = (process.env[SOUND_ENV] ?? "").trim().toLowerCase();
4051
4825
  const soundOn = !SOUND_OFF_VALUES.includes(soundEnv) && opts.sound !== false;
4052
4826
  const notifier = new Notifier(COOLDOWN_MS2);
4827
+ const instanceId = randomUUID2();
4053
4828
  const active = /* @__PURE__ */ new Set();
4054
4829
  const titles = /* @__PURE__ */ new Map();
4055
4830
  const pending = /* @__PURE__ */ new Set();
@@ -4152,10 +4927,10 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
4152
4927
  const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
4153
4928
  return info;
4154
4929
  };
4155
- const deliverViaHelper = (title, message, urgency, sound, requestID, sessionID) => new Promise((resolve3) => {
4930
+ const deliverViaHelper = (title, message, urgency, sound, requestID, sessionID) => new Promise((resolve4) => {
4156
4931
  if (inflight >= maxInflight) {
4157
4932
  void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
4158
- resolve3({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
4933
+ resolve4({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
4159
4934
  return;
4160
4935
  }
4161
4936
  const helper = findHelper();
@@ -4164,7 +4939,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
4164
4939
  warnedHelper = true;
4165
4940
  void log("warn", "no oc-notify helper found; run 'bin/oc-sync' to install one");
4166
4941
  }
4167
- resolve3({ ok: false, detail: "no oc-notify helper found" });
4942
+ resolve4({ ok: false, detail: "no oc-notify helper found" });
4168
4943
  return;
4169
4944
  }
4170
4945
  const childEnv = { ...process.env, ...sessionID ? { [NOTIFY_SESSION_ENV]: sessionID } : {} };
@@ -4187,7 +4962,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
4187
4962
  settled = true;
4188
4963
  inflight--;
4189
4964
  if (timer) clearTimeout(timer);
4190
- resolve3(result);
4965
+ resolve4(result);
4191
4966
  };
4192
4967
  timer = setTimeout(() => {
4193
4968
  try {
@@ -4345,7 +5120,7 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
4345
5120
  void log("debug", "suppressed: completion already announced", { sessionID });
4346
5121
  return;
4347
5122
  }
4348
- } else if (!claimNotification(sessionID, kind)) {
5123
+ } else if (!claimNotification(sessionID, kind, instanceId)) {
4349
5124
  void log("debug", "suppressed: another process is raising this", { sessionID, kind });
4350
5125
  return;
4351
5126
  } else if (outcome === "error") {
@@ -4508,9 +5283,9 @@ function normalizeBaseURL(raw) {
4508
5283
  return null;
4509
5284
  }
4510
5285
  if (url.protocol !== "http:" && url.protocol !== "https:") return null;
4511
- let path4 = url.pathname.replace(/\/+$/, "");
4512
- while (path4.endsWith("/v1")) path4 = path4.slice(0, -"/v1".length);
4513
- return `${url.origin}${path4}/v1`;
5286
+ let path5 = url.pathname.replace(/\/+$/, "");
5287
+ while (path5.endsWith("/v1")) path5 = path5.slice(0, -"/v1".length);
5288
+ return `${url.origin}${path5}/v1`;
4514
5289
  }
4515
5290
  function parseConfig(raw, env = process.env) {
4516
5291
  if (typeof raw !== "object" || raw === null) return { enabled: false, config: null, problems: [] };
@@ -5016,6 +5791,7 @@ var LaCode = (async (input, options) => {
5016
5791
  const autoupdate = await settled("context-autoupdate", () => contextAutoUpdateHooks(input, options)) ?? inertHooks;
5017
5792
  const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
5018
5793
  const selfupdate = await settled("self-update", () => selfUpdateHooks(input, options)) ?? inertHooks;
5794
+ const feedback = await settled("feedback", () => feedbackHooks(input, options)) ?? inertHooks;
5019
5795
  const omniroute = await settled("omniroute", () => omniRouteHooks(input, options)) ?? inertHooks;
5020
5796
  const imagepaste = await settled("image-paste", () => imagePasteHooks(input, opts.imagePaste)) ?? inertHooks;
5021
5797
  const config = async (cfg) => {
@@ -5058,6 +5834,7 @@ var LaCode = (async (input, options) => {
5058
5834
  await autoupdate.dispose?.();
5059
5835
  await notifier.dispose?.();
5060
5836
  await selfupdate.dispose?.();
5837
+ await feedback.dispose?.();
5061
5838
  await omniroute.dispose?.();
5062
5839
  await imagepaste.dispose?.();
5063
5840
  },