@lawaty/lacode 1.3.1 → 2.0.0-beta.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/CHANGELOG.md CHANGED
@@ -9,6 +9,23 @@ commit as the changelog entry it describes.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [2.0.0-beta.0] — The timeline remembers, and the map has one job
13
+
14
+ ### Added
15
+
16
+ - Durable timeline state: each session's cursor, pending flag, failure count, and next retry time are written beside the timeline JSONL. Pending work resumes after a restart, a successful summary with no bullet text still advances the durable cursor, and deleting a session removes both files.
17
+ - Exponential backoff for failed timeline summarizations. A failure marks the slice pending and schedules a retry; repeated failures grow the wait up to a cap; an idle event during the wait re-arms the timer instead of bypassing it.
18
+ - `timeline.retryMs` and `timeline.maxBackoffMs` options.
19
+ - User stories US-35 (a timeline survives a restart and resumes unfinished work) and US-36 (a failed timeline summary retries with backoff), with story tests in `tests/stories/j-timeline-reliability.test.mjs`.
20
+
21
+ ### Changed
22
+
23
+ - The `.opencode/context/` map is now five navigation files only. The cartographer no longer creates or maintains `active.md`, the context plugin no longer normalizes a backlog, and `/context-init` and `/context-review` no longer mention one.
24
+
25
+ ### Removed
26
+
27
+ - `src/lib/active-backlog.ts`, the `.opencode/context/active.md` map file, and its finished-task retention window. The timeline is the single record of work in flight.
28
+
12
29
  ## [1.3.1] — reprocess-timeline rebuilds the caller's timeline, and the pool refreshes daily
13
30
 
14
31
  ### Added
@@ -1115,7 +1132,8 @@ experimental; `latest` remains 0.4.2.
1115
1132
 
1116
1133
  - First published release: pooled subagents over a shared set of models.
1117
1134
 
1118
- [Unreleased]: https://github.com/lawaty/lacode/compare/v1.3.1...HEAD
1135
+ [Unreleased]: https://github.com/lawaty/lacode/compare/v2.0.0-beta.0...HEAD
1136
+ [2.0.0-beta.0]: https://github.com/lawaty/lacode/compare/v1.3.1...v2.0.0-beta.0
1119
1137
  [1.3.1]: https://github.com/lawaty/lacode/releases/tag/v1.3.1
1120
1138
  [1.3.0-beta.9]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.9
1121
1139
  [1.3.0-beta.8]: https://github.com/lawaty/lacode/releases/tag/v1.3.0-beta.8
package/README.md CHANGED
@@ -31,7 +31,6 @@ is also on disk at `node_modules/@lawaty/lacode/CHANGELOG.md`.
31
31
  | Distinct notification sounds | A popup says something happened, but not at a glance whether it is a finish, a question or a block. |
32
32
  | SSH notification tunnel | Work on a remote host raises its notification on that host, which has no desktop. |
33
33
  | Repository context map | Every task starts by exploring your codebase cold, re-deriving the same layout, entry points and boundaries. |
34
- | Active backlog | What is half-done, and what can be dropped, lives in chat history the next session cannot see. |
35
34
  | Standing instructions | The same direction gets repeated every session, or buried in config that is not per-clone. |
36
35
  | Image attachments anywhere | A TUI with no image support locks vision models out entirely. |
37
36
 
@@ -271,11 +270,9 @@ Agents are stateless between tasks, and exploring your repository is the most
271
270
  expensive part of any task — so every agent pays for it again, re-deriving the
272
271
  same layout, entry points and boundaries from scratch. LaCode keeps a short,
273
272
  maintained map of the repository in `.opencode/context/` — five navigation files
274
- (`architecture`, `contexts`, `conventions`, `workflows`, `decisions`) plus
275
- `active.md`, the active backlog, where a finished task stays a week and an open
276
- one stays until done — and injects a standing rule that every agent reads the
277
- relevant file before exploring and starts from the canonical entry points it
278
- lists. The map holds conclusions, not
273
+ (`architecture`, `contexts`, `conventions`, `workflows`, `decisions`) — and injects
274
+ a standing rule that every agent reads the relevant file before exploring and
275
+ starts from the canonical entry points it lists. The map holds conclusions, not
279
276
  inventories: it answers "what should I look at first?" and nothing else, which
280
277
  is what makes reading it cheaper than the exploration it replaces.
281
278
 
package/dist/agents.js CHANGED
@@ -124,8 +124,6 @@ You own exactly these files and nothing else:
124
124
  150 lines.**
125
125
  - \`.opencode/context/decisions.md\` \u2014 architectural constraints that bind future
126
126
  work. **At most 120 lines.**
127
- - \`.opencode/context/active.md\` \u2014 work in flight, grouped by the ISO week it was
128
- opened. **At most 60 lines.**
129
127
 
130
128
  The unit is non-blank lines. It is what a reader actually pays for, it bounds an
131
129
  entry that wraps to three lines, and the validator can count it exactly. An
@@ -141,8 +139,6 @@ failed run, not a small one.
141
139
 
142
140
  The plugin applies this on every run. Write entries in the shape that lets it.
143
141
 
144
- - \`active.md\`: a finished task (\`[x]\` plus \`finished YYYY-MM-DD\`) is dropped
145
- after 7 days. An open task never expires, however old it is.
146
142
  - \`architecture.md\`, \`contexts.md\`, \`conventions.md\`, \`workflows.md\`,
147
143
  \`decisions.md\`: a \`[provisional]\` entry is dropped 30 days after its
148
144
  \`verified\` date unless it is re-verified. \`[binding]\` and \`[disputed]\` are
@@ -236,8 +232,7 @@ You are mapping a city, not auditing every house. Do not attempt to understand
236
232
  every file.
237
233
 
238
234
  Then write the five navigation files within their line budgets, using the entry
239
- schema above. Also create \`.opencode/context/active.md\` with its heading and no
240
- tasks. For each significant subsystem, give a short "Start here" list \u2014 at most
235
+ schema above. For each significant subsystem, give a short "Start here" list \u2014 at most
241
236
  about five canonical files, each with a one-phrase reason. This is the single
242
237
  most valuable thing you produce: where an agent should open its editor next.
243
238
 
@@ -271,37 +266,6 @@ update needed".
271
266
  Every new or changed entry uses the schema above and stays under the file's
272
267
  budget. If a file is already over budget, consolidate before adding anything.
273
268
 
274
- Also keep \`.opencode/context/active.md\` current: record work that is in flight,
275
- and mark a task finished \u2014 with the date it finished \u2014 when it is.
276
-
277
- # Task backlog
278
-
279
- \`.opencode/context/active.md\` holds the work in flight. It is a rolling window,
280
- not a history: it answers "what is open, and what just finished".
281
-
282
- Format \u2014 one line per task, under the ISO week the task was opened in:
283
-
284
- ## 2026-W43
285
-
286
- - [ ] Ship the release notes \u2014 opened 2026-W43
287
- - [x] Fix the flaky test \u2014 opened 2026-W43, finished 2026-10-19
288
-
289
- The rules:
290
-
291
- - A task that is not marked \`[x]\` is OPEN. An open task is never removed,
292
- however old it is.
293
- - A task is FINISHED when it is marked \`[x]\` AND carries a \`finished\` date.
294
- Record the date it actually finished, as \`YYYY-MM-DD\`.
295
- - The week is \`YYYY-Www\`, Monday to Sunday. \`date +%G-W%V\` prints it.
296
- - A finished task is dropped once it is more than a week old. The plugin
297
- enforces that: record the finished task with its date and let the window close
298
- on its own. Do not delete old finished tasks by hand, and do not keep a
299
- finished task back to preserve it \u2014 the file is meant to stay small.
300
- - Never invent a task. Record work that actually happened, and mark a task
301
- finished only once it is finished.
302
- - Keep the file inside the window. If the backlog has grown past a handful of
303
- weeks, that is a signal to close finished tasks, not to add sections.
304
-
305
269
  # Minimal exploration
306
270
 
307
271
  Default shape:
@@ -389,7 +353,7 @@ Never drop a binding constraint to make room.
389
353
 
390
354
  Trigger: an explicit review (see the \`context-review\` command).
391
355
 
392
- Audit the map files and the backlog for:
356
+ Audit the map files for:
393
357
 
394
358
  - schema \u2014 every entry has one marker, at most three lines, a pointer, and a
395
359
  \`verified\` date
@@ -404,8 +368,6 @@ Audit the map files and the backlog for:
404
368
  - missing canonical entry points for significant subsystems
405
369
  - unsupported assumptions \u2014 anything that is not \`[binding]\` or \`[disputed]\`
406
370
  should be \`[provisional]\`
407
- - backlog tasks that are finished but carry no date \u2014 the retention window
408
- cannot age those out, so they stay forever
409
371
 
410
372
  Fix problems only in the files you own. Never modify source code or a project
411
373
  page during a review. Report what you found, what you fixed, what you left as
@@ -877,7 +839,7 @@ if the blocker is understanding rather than execution, report that instead.`,
877
839
  }
878
840
  },
879
841
  "context": {
880
- "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.",
842
+ "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. 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.",
881
843
  "mode": "subagent",
882
844
  "temperature": 0.1,
883
845
  prompt: CARTOGRAPHER_PROMPT,
package/dist/commands.js CHANGED
@@ -70,8 +70,7 @@ exploration (README \u2192 existing docs \u2192 manifests \u2192 top-level struc
70
70
  and framework config \u2192 entry points \u2192 selective source), then write the five
71
71
  context files inside their line budgets, using the entry schema (provenance
72
72
  marker, rule of at most three lines, \`\u2014 See <path>[ \xA7Section] (verified
73
- YYYY-MM-DD)\`). Also create \`.opencode/context/active.md\` with its heading and no
74
- tasks.
73
+ YYYY-MM-DD)\`).
75
74
 
76
75
  Remember: write conclusions, not inventories. Point at the project's existing
77
76
  documentation instead of restating it. Run the drift pass and report a
@@ -117,7 +116,7 @@ Follow the review procedure and entry schema in your system prompt. Everything
117
116
  outside \`.opencode/context/\` is read-only for this run. Do not modify source
118
117
  code under any circumstances.
119
118
 
120
- Audit the context files and the backlog for:
119
+ Audit the context files for:
121
120
 
122
121
  - schema \u2014 every entry has one provenance marker, at most three lines, a
123
122
  pointer, and a \`(verified YYYY-MM-DD)\` date
@@ -136,8 +135,6 @@ Audit the context files and the backlog for:
136
135
  - missing canonical entry points for significant subsystems
137
136
  - unsupported assumptions \u2014 anything that is not \`[binding]\` or \`[disputed]\`
138
137
  should be \`[provisional]\`
139
- - backlog tasks that are finished but carry no date \u2014 the retention window
140
- cannot age those out, so they stay forever
141
138
 
142
139
  Fix what you find in \`.opencode/context/\` only, then report: findings, fixes,
143
140
  and anything you could not verify.`
package/dist/index.js CHANGED
@@ -951,8 +951,6 @@ You own exactly these files and nothing else:
951
951
  150 lines.**
952
952
  - \`.opencode/context/decisions.md\` \u2014 architectural constraints that bind future
953
953
  work. **At most 120 lines.**
954
- - \`.opencode/context/active.md\` \u2014 work in flight, grouped by the ISO week it was
955
- opened. **At most 60 lines.**
956
954
 
957
955
  The unit is non-blank lines. It is what a reader actually pays for, it bounds an
958
956
  entry that wraps to three lines, and the validator can count it exactly. An
@@ -968,8 +966,6 @@ failed run, not a small one.
968
966
 
969
967
  The plugin applies this on every run. Write entries in the shape that lets it.
970
968
 
971
- - \`active.md\`: a finished task (\`[x]\` plus \`finished YYYY-MM-DD\`) is dropped
972
- after 7 days. An open task never expires, however old it is.
973
969
  - \`architecture.md\`, \`contexts.md\`, \`conventions.md\`, \`workflows.md\`,
974
970
  \`decisions.md\`: a \`[provisional]\` entry is dropped 30 days after its
975
971
  \`verified\` date unless it is re-verified. \`[binding]\` and \`[disputed]\` are
@@ -1063,8 +1059,7 @@ You are mapping a city, not auditing every house. Do not attempt to understand
1063
1059
  every file.
1064
1060
 
1065
1061
  Then write the five navigation files within their line budgets, using the entry
1066
- schema above. Also create \`.opencode/context/active.md\` with its heading and no
1067
- tasks. For each significant subsystem, give a short "Start here" list \u2014 at most
1062
+ schema above. For each significant subsystem, give a short "Start here" list \u2014 at most
1068
1063
  about five canonical files, each with a one-phrase reason. This is the single
1069
1064
  most valuable thing you produce: where an agent should open its editor next.
1070
1065
 
@@ -1098,37 +1093,6 @@ update needed".
1098
1093
  Every new or changed entry uses the schema above and stays under the file's
1099
1094
  budget. If a file is already over budget, consolidate before adding anything.
1100
1095
 
1101
- Also keep \`.opencode/context/active.md\` current: record work that is in flight,
1102
- and mark a task finished \u2014 with the date it finished \u2014 when it is.
1103
-
1104
- # Task backlog
1105
-
1106
- \`.opencode/context/active.md\` holds the work in flight. It is a rolling window,
1107
- not a history: it answers "what is open, and what just finished".
1108
-
1109
- Format \u2014 one line per task, under the ISO week the task was opened in:
1110
-
1111
- ## 2026-W43
1112
-
1113
- - [ ] Ship the release notes \u2014 opened 2026-W43
1114
- - [x] Fix the flaky test \u2014 opened 2026-W43, finished 2026-10-19
1115
-
1116
- The rules:
1117
-
1118
- - A task that is not marked \`[x]\` is OPEN. An open task is never removed,
1119
- however old it is.
1120
- - A task is FINISHED when it is marked \`[x]\` AND carries a \`finished\` date.
1121
- Record the date it actually finished, as \`YYYY-MM-DD\`.
1122
- - The week is \`YYYY-Www\`, Monday to Sunday. \`date +%G-W%V\` prints it.
1123
- - A finished task is dropped once it is more than a week old. The plugin
1124
- enforces that: record the finished task with its date and let the window close
1125
- on its own. Do not delete old finished tasks by hand, and do not keep a
1126
- finished task back to preserve it \u2014 the file is meant to stay small.
1127
- - Never invent a task. Record work that actually happened, and mark a task
1128
- finished only once it is finished.
1129
- - Keep the file inside the window. If the backlog has grown past a handful of
1130
- weeks, that is a signal to close finished tasks, not to add sections.
1131
-
1132
1096
  # Minimal exploration
1133
1097
 
1134
1098
  Default shape:
@@ -1216,7 +1180,7 @@ Never drop a binding constraint to make room.
1216
1180
 
1217
1181
  Trigger: an explicit review (see the \`context-review\` command).
1218
1182
 
1219
- Audit the map files and the backlog for:
1183
+ Audit the map files for:
1220
1184
 
1221
1185
  - schema \u2014 every entry has one marker, at most three lines, a pointer, and a
1222
1186
  \`verified\` date
@@ -1231,8 +1195,6 @@ Audit the map files and the backlog for:
1231
1195
  - missing canonical entry points for significant subsystems
1232
1196
  - unsupported assumptions \u2014 anything that is not \`[binding]\` or \`[disputed]\`
1233
1197
  should be \`[provisional]\`
1234
- - backlog tasks that are finished but carry no date \u2014 the retention window
1235
- cannot age those out, so they stay forever
1236
1198
 
1237
1199
  Fix problems only in the files you own. Never modify source code or a project
1238
1200
  page during a review. Report what you found, what you fixed, what you left as
@@ -1704,7 +1666,7 @@ if the blocker is understanding rather than execution, report that instead.`,
1704
1666
  }
1705
1667
  },
1706
1668
  "context": {
1707
- "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.",
1669
+ "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. 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.",
1708
1670
  "mode": "subagent",
1709
1671
  "temperature": 0.1,
1710
1672
  prompt: CARTOGRAPHER_PROMPT,
@@ -1930,8 +1892,7 @@ exploration (README \u2192 existing docs \u2192 manifests \u2192 top-level struc
1930
1892
  and framework config \u2192 entry points \u2192 selective source), then write the five
1931
1893
  context files inside their line budgets, using the entry schema (provenance
1932
1894
  marker, rule of at most three lines, \`\u2014 See <path>[ \xA7Section] (verified
1933
- YYYY-MM-DD)\`). Also create \`.opencode/context/active.md\` with its heading and no
1934
- tasks.
1895
+ YYYY-MM-DD)\`).
1935
1896
 
1936
1897
  Remember: write conclusions, not inventories. Point at the project's existing
1937
1898
  documentation instead of restating it. Run the drift pass and report a
@@ -1977,7 +1938,7 @@ Follow the review procedure and entry schema in your system prompt. Everything
1977
1938
  outside \`.opencode/context/\` is read-only for this run. Do not modify source
1978
1939
  code under any circumstances.
1979
1940
 
1980
- Audit the context files and the backlog for:
1941
+ Audit the context files for:
1981
1942
 
1982
1943
  - schema \u2014 every entry has one provenance marker, at most three lines, a
1983
1944
  pointer, and a \`(verified YYYY-MM-DD)\` date
@@ -1996,8 +1957,6 @@ Audit the context files and the backlog for:
1996
1957
  - missing canonical entry points for significant subsystems
1997
1958
  - unsupported assumptions \u2014 anything that is not \`[binding]\` or \`[disputed]\`
1998
1959
  should be \`[provisional]\`
1999
- - backlog tasks that are finished but carry no date \u2014 the retention window
2000
- cannot age those out, so they stay forever
2001
1960
 
2002
1961
  Fix what you find in \`.opencode/context/\` only, then report: findings, fixes,
2003
1962
  and anything you could not verify.`
@@ -4125,110 +4084,10 @@ one ordered pass in slot order: each slot fills to its weight, then overflow goe
4125
4084
  });
4126
4085
 
4127
4086
  // src/plugins/context-autoupdate.ts
4128
- import { existsSync as existsSync4, mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync6, unlinkSync as unlinkSync5, writeFileSync as writeFileSync6 } from "node:fs";
4087
+ import { existsSync as existsSync4, mkdirSync as mkdirSync5, readdirSync as readdirSync2, statSync as statSync6, unlinkSync as unlinkSync5, writeFileSync as writeFileSync6 } from "node:fs";
4129
4088
  import { homedir as homedir4 } from "node:os";
4130
4089
  import * as path3 from "node:path";
4131
4090
 
4132
- // src/lib/active-backlog.ts
4133
- var ACTIVE_BACKLOG_FILE = "active.md";
4134
- var RETENTION_DAYS = 7;
4135
- var DAY_MS = 24 * 60 * 60 * 1e3;
4136
- var HEADER = `# Active backlog
4137
-
4138
- Tasks in flight, grouped by the week they were opened. A finished task stays
4139
- here for a week after it was finished and is then dropped; an open task stays
4140
- however long it takes. Add a line under the right week, and give a finished
4141
- task its date: \`- [x] Title \u2014 opened 2026-W43, finished 2026-10-19\`.`;
4142
- var TASK_LINE = /^\s*(?:[-*+]|\d+[.)])\s*\[([ xX])\]\s*(.*)$/;
4143
- var META = /(?:—|–|-)\s*opened\s+(\d{4}-W(?:0[1-9]|[1-4]\d|5[0-3]))|[,—–-]\s*finished\s+(\d{4}-\d{2}-\d{2})/gi;
4144
- var isCalendarDate = (s) => {
4145
- const at = Date.parse(`${s}T00:00:00Z`);
4146
- return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === s;
4147
- };
4148
- var parseBacklog = (text) => {
4149
- const entries = [];
4150
- for (const raw of text.split(/\r?\n/)) {
4151
- const line = TASK_LINE.exec(raw);
4152
- if (!line) continue;
4153
- const finished = line[1] !== " ";
4154
- let week;
4155
- let finishedAt;
4156
- const stripped = line[2].replace(META, (fragment, openedWeek, finishedDate) => {
4157
- if (openedWeek !== void 0) {
4158
- week ??= openedWeek.toUpperCase();
4159
- return "";
4160
- }
4161
- if (finishedDate !== void 0 && isCalendarDate(finishedDate)) {
4162
- finishedAt ??= finishedDate;
4163
- return "";
4164
- }
4165
- return fragment;
4166
- });
4167
- const title = stripped.replace(/^[\s—–-]+|[\s—–-]+$/g, "");
4168
- if (!title) continue;
4169
- entries.push({ title, ...week ? { week } : {}, finished, ...finishedAt ? { finishedAt } : {} });
4170
- }
4171
- return entries;
4172
- };
4173
- var ISO_WEEK = /^(\d{4})-W(\d{2})$/i;
4174
- var newestFirst = (a, b) => {
4175
- const ma = ISO_WEEK.exec(a);
4176
- const mb = ISO_WEEK.exec(b);
4177
- if (ma && mb) return Number(mb[1]) - Number(ma[1]) || Number(mb[2]) - Number(ma[2]);
4178
- if (ma) return -1;
4179
- if (mb) return 1;
4180
- return a < b ? 1 : a > b ? -1 : 0;
4181
- };
4182
- var renderLine = (entry) => {
4183
- const box = entry.finished ? "[x]" : "[ ]";
4184
- const meta = [];
4185
- if (entry.week) meta.push(`opened ${entry.week}`);
4186
- if (entry.finished && entry.finishedAt) meta.push(`finished ${entry.finishedAt}`);
4187
- const head = `- ${box} ${entry.title}`;
4188
- if (meta.length === 0) return head;
4189
- return entry.week ? `${head} \u2014 ${meta.join(", ")}` : `${head}, ${meta.join(", ")}`;
4190
- };
4191
- var renderBacklog = (entries) => {
4192
- const buckets = /* @__PURE__ */ new Map();
4193
- for (const entry of entries) {
4194
- const key = entry.week ? entry.week : null;
4195
- const bucket = buckets.get(key);
4196
- if (bucket) bucket.push(entry);
4197
- else buckets.set(key, [entry]);
4198
- }
4199
- const weeks = [...buckets.keys()].filter((week) => week !== null).sort(newestFirst);
4200
- let out = HEADER;
4201
- for (const week of weeks) out += `
4202
-
4203
- ## ${week}
4204
-
4205
- ${buckets.get(week).map(renderLine).join("\n")}`;
4206
- const unscheduled = buckets.get(null);
4207
- if (unscheduled) out += `
4208
-
4209
- ## Unscheduled
4210
-
4211
- ${unscheduled.map(renderLine).join("\n")}`;
4212
- return `${out}
4213
- `;
4214
- };
4215
- var normalizeBacklog = (text, now = Date.now()) => {
4216
- const entries = parseBacklog(text);
4217
- const kept = [];
4218
- const removed = [];
4219
- for (const entry of entries) {
4220
- if (entry.finished && entry.finishedAt) {
4221
- const at = Date.parse(`${entry.finishedAt}T00:00:00Z`);
4222
- if (Number.isFinite(at) && now - at > RETENTION_DAYS * DAY_MS) {
4223
- removed.push(entry.title);
4224
- continue;
4225
- }
4226
- }
4227
- kept.push(entry);
4228
- }
4229
- return { text: renderBacklog(kept), removed };
4230
- };
4231
-
4232
4091
  // src/lib/map-schema.ts
4233
4092
  import { existsSync as existsSync3, readFileSync as readFileSync5, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
4234
4093
  import * as path from "node:path";
@@ -4278,21 +4137,11 @@ var MAP_SPECS = [
4278
4137
  kind: "entries",
4279
4138
  provisionalDays: PROVISIONAL_DAYS,
4280
4139
  sectionRequired: true
4281
- },
4282
- {
4283
- file: ACTIVE_BACKLOG_FILE,
4284
- charter: "work in flight, grouped by the ISO week it was opened",
4285
- budget: 60,
4286
- minEntries: 0,
4287
- kind: "backlog",
4288
- provisionalDays: PROVISIONAL_DAYS,
4289
- sectionRequired: false
4290
4140
  }
4291
4141
  ];
4142
+ var NAVIGATION_FILES = MAP_SPECS.map((spec) => spec.file);
4292
4143
  var MAP_SPEC_BY_FILE = new Map(MAP_SPECS.map((spec) => [spec.file, spec]));
4293
- var MAP_FILES = MAP_SPECS.map((spec) => spec.file);
4294
- var NAVIGATION_FILES = MAP_SPECS.filter((spec) => spec.kind === "entries").map((spec) => spec.file);
4295
- var DAY_MS2 = 24 * 60 * 60 * 1e3;
4144
+ var DAY_MS = 24 * 60 * 60 * 1e3;
4296
4145
  var ENTRY_START = /^([-*+])\s+\[(binding|provisional|disputed)\]\s+(.*)$/i;
4297
4146
  var CONTINUATION = /^\s{2,}\S/;
4298
4147
  var LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
@@ -4303,7 +4152,7 @@ var VERIFIED = /\(verified\s+(\d{4}-\d{2}-\d{2})\)\s*$/i;
4303
4152
  var POINTER = /(?:—|–|→|->)\s*See\s+`([^`\n]+)`(?:\s*§\s*([^()\n]+?))?(?:\s*\(verified\s+(\d{4}-\d{2}-\d{2})\))?\s*$/i;
4304
4153
  var DECISION_RECORD = /^\s*(?:#{1,6}\s*)?(?:\*\*)?(Context|Decision|Consequences|Alternatives|Status|Trade-?offs|Rationale)(?:\*\*)?\s*(?::|$)/im;
4305
4154
  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;
4306
- var isCalendarDate2 = (value) => {
4155
+ var isCalendarDate = (value) => {
4307
4156
  const at = Date.parse(`${value}T00:00:00Z`);
4308
4157
  return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === value;
4309
4158
  };
@@ -4521,10 +4370,6 @@ var validateMapText = (spec, text, options) => {
4521
4370
  if (nonBlank > spec.budget) {
4522
4371
  push("budget", `${nonBlank} non-blank lines exceeds the ${spec.budget}-line budget; consolidate, do not pad`);
4523
4372
  }
4524
- if (spec.kind === "backlog") {
4525
- if (!text.startsWith("# ")) push("charter", "active backlog must start with a top-level heading");
4526
- return { spec, lines: nonBlank, entries: [], violations };
4527
- }
4528
4373
  const entries = parseMapEntries(text);
4529
4374
  let inFence = false;
4530
4375
  let tableReported = false;
@@ -4566,9 +4411,9 @@ var validateMapText = (spec, text, options) => {
4566
4411
  }
4567
4412
  if (!entry.verified) {
4568
4413
  push("schema", "entry has no (verified YYYY-MM-DD); the lifecycle and drift checks need it", line);
4569
- } else if (!isCalendarDate2(entry.verified)) {
4414
+ } else if (!isCalendarDate(entry.verified)) {
4570
4415
  push("schema", `verified date ${entry.verified} is not a real calendar date`, line);
4571
- } else if (Date.parse(`${entry.verified}T00:00:00Z`) > (options.now ?? Date.now()) + DAY_MS2) {
4416
+ } else if (Date.parse(`${entry.verified}T00:00:00Z`) > (options.now ?? Date.now()) + DAY_MS) {
4572
4417
  push("schema", `verified date ${entry.verified} is more than a day in the future`, line);
4573
4418
  }
4574
4419
  if (!entry.pointer) {
@@ -4618,14 +4463,6 @@ var validateMapText = (spec, text, options) => {
4618
4463
  };
4619
4464
  var normalizeMapText = (spec, text, options = {}) => {
4620
4465
  const now = options.now ?? Date.now();
4621
- if (spec.kind === "backlog") {
4622
- const result = normalizeBacklog(text, now);
4623
- return {
4624
- text: result.text,
4625
- removed: result.removed.map((title) => ({ line: 0, text: title })),
4626
- kept: 0
4627
- };
4628
- }
4629
4466
  const entries = parseMapEntries(text);
4630
4467
  const drop = /* @__PURE__ */ new Set();
4631
4468
  const removed = [];
@@ -4635,7 +4472,7 @@ var normalizeMapText = (spec, text, options = {}) => {
4635
4472
  if (options.canResolve && !options.canResolve(entry.pointer, entry.section)) continue;
4636
4473
  const at = Date.parse(`${entry.verified}T00:00:00Z`);
4637
4474
  if (!Number.isFinite(at)) continue;
4638
- if (now - at <= spec.provisionalDays * DAY_MS2) continue;
4475
+ if (now - at <= spec.provisionalDays * DAY_MS) continue;
4639
4476
  for (let i = entry.startLine; i < entry.endLine; i++) drop.add(i);
4640
4477
  removed.push({ line: entry.startLine + 1, text: truncate(entry.rule) });
4641
4478
  }
@@ -4665,8 +4502,6 @@ var maintainMap = (root, mapDir, now = Date.now()) => {
4665
4502
  if (result.removed.length > 0) {
4666
4503
  removedTotal += result.removed.length;
4667
4504
  files.push({ file: spec.file, changed, removed: result.removed });
4668
- } else if (changed) {
4669
- files.push({ file: spec.file, changed, removed: [] });
4670
4505
  }
4671
4506
  }
4672
4507
  return { files, removedTotal };
@@ -4692,21 +4527,19 @@ var auditMap = (root, mapDir, now = Date.now()) => {
4692
4527
  const result = validateMapText(spec, text, { root, now });
4693
4528
  files.push(result);
4694
4529
  violations.push(...result.violations);
4695
- if (spec.kind === "entries") {
4696
- for (const entry of result.entries) {
4697
- const key = normalizeRule(entry.rule);
4698
- if (key.length < 12) continue;
4699
- const home = rulesSeen.get(key);
4700
- if (home !== void 0 && home !== spec.file) {
4701
- violations.push({
4702
- file: spec.file,
4703
- line: entry.startLine + 1,
4704
- kind: "duplicate",
4705
- detail: `same rule is already stated in ${home}; one fact has one home`
4706
- });
4707
- } else if (home === void 0) {
4708
- rulesSeen.set(key, spec.file);
4709
- }
4530
+ for (const entry of result.entries) {
4531
+ const key = normalizeRule(entry.rule);
4532
+ if (key.length < 12) continue;
4533
+ const home = rulesSeen.get(key);
4534
+ if (home !== void 0 && home !== spec.file) {
4535
+ violations.push({
4536
+ file: spec.file,
4537
+ line: entry.startLine + 1,
4538
+ kind: "duplicate",
4539
+ detail: `same rule is already stated in ${home}; one fact has one home`
4540
+ });
4541
+ } else if (home === void 0) {
4542
+ rulesSeen.set(key, spec.file);
4710
4543
  }
4711
4544
  }
4712
4545
  }
@@ -4974,7 +4807,6 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
4974
4807
  });
4975
4808
  const root = path3.resolve(directory);
4976
4809
  const contextAbs = path3.join(root, CONTEXT_DIR2);
4977
- const activeBacklogPath = path3.join(contextAbs, ACTIVE_BACKLOG_FILE);
4978
4810
  const config = findProjectConfig();
4979
4811
  const writerProblem = config ? verifyWriterRule(config, contextAbs, root) : void 0;
4980
4812
  if (writerProblem) void log("error", writerProblem);
@@ -4984,22 +4816,6 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
4984
4816
  if (absolute === contextAbs || absolute.startsWith(contextAbs + path3.sep)) return false;
4985
4817
  return !toPosix2(absolute).split("/").some((segment) => IGNORED_SEGMENTS.has(segment));
4986
4818
  };
4987
- const normalizeActiveBacklog = async () => {
4988
- try {
4989
- if (!existsSync4(activeBacklogPath)) return { removed: 0 };
4990
- const before = readFileSync7(activeBacklogPath, "utf8");
4991
- const { text, removed } = normalizeBacklog(before);
4992
- if (text !== before) writeFileSync6(activeBacklogPath, text);
4993
- if (removed.length > 0) {
4994
- await log("info", `active backlog: dropped ${removed.length} task(s) finished more than a week ago`, {
4995
- removed: removed.length
4996
- });
4997
- }
4998
- return { removed: removed.length };
4999
- } catch {
5000
- return { removed: 0 };
5001
- }
5002
- };
5003
4819
  const applyLifecycle = async () => {
5004
4820
  try {
5005
4821
  const result = maintainMap(root, contextAbs);
@@ -5059,7 +4875,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
5059
4875
  "",
5060
4876
  ENTRY_CONTRACT,
5061
4877
  "",
5062
- "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."
4878
+ "Follow the bootstrap procedure in your system prompt: progressive exploration, then write the five context files within their line budgets. When the project already has architecture or decision documentation, link to it instead of duplicating it. Write conclusions, not inventories."
5063
4879
  ].join("\n") : [
5064
4880
  "Incremental context update. Project root: " + directory,
5065
4881
  "",
@@ -5070,17 +4886,13 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
5070
4886
  "",
5071
4887
  ENTRY_CONTRACT,
5072
4888
  "",
5073
- '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.'
4889
+ '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 update documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed".'
5074
4890
  ].join("\n");
5075
4891
  return {
5076
4892
  event: async ({ event }) => {
5077
4893
  try {
5078
4894
  if (event.type === "file.edited") {
5079
4895
  const file = event.properties.file;
5080
- if (path3.resolve(root, file) === activeBacklogPath) {
5081
- await normalizeActiveBacklog();
5082
- return;
5083
- }
5084
4896
  if (isTracked(file)) pending.add(file);
5085
4897
  return;
5086
4898
  }
@@ -5192,7 +5004,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
5192
5004
  });
5193
5005
 
5194
5006
  // src/lib/feedback.ts
5195
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync8, renameSync as renameSync4, unlinkSync as unlinkSync6, writeFileSync as writeFileSync7 } from "node:fs";
5007
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync7, renameSync as renameSync4, unlinkSync as unlinkSync6, writeFileSync as writeFileSync7 } from "node:fs";
5196
5008
  import { homedir as homedir5 } from "node:os";
5197
5009
  import { dirname as dirname4, join as join9 } from "node:path";
5198
5010
  var FEEDBACK_HINT_ENV = "LACODE_NO_FEEDBACK_HINT";
@@ -5200,7 +5012,7 @@ var FEEDBACK_HINT = "Enjoying lacode? Bugs, ideas, or a quick note on how you us
5200
5012
  var feedbackStatePath = () => join9(homedir5(), ".local", "share", "opencode", "lacode", "feedback.json");
5201
5013
  function readFeedbackState(path4 = feedbackStatePath()) {
5202
5014
  try {
5203
- const parsed = JSON.parse(readFileSync8(path4, "utf8"));
5015
+ const parsed = JSON.parse(readFileSync7(path4, "utf8"));
5204
5016
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
5205
5017
  return parsed;
5206
5018
  } catch (error) {
@@ -5274,7 +5086,7 @@ async function feedbackHooks(input, options, deps = {}) {
5274
5086
 
5275
5087
  // src/plugins/image-paste.ts
5276
5088
  import { createHash, randomUUID } from "node:crypto";
5277
- import { globSync, mkdirSync as mkdirSync7, readFileSync as readFileSync9, renameSync as renameSync5, statSync as statSync7, writeFileSync as writeFileSync8 } from "node:fs";
5089
+ import { globSync, mkdirSync as mkdirSync7, readFileSync as readFileSync8, renameSync as renameSync5, statSync as statSync7, writeFileSync as writeFileSync8 } from "node:fs";
5278
5090
  import { homedir as homedir6 } from "node:os";
5279
5091
  import { basename as basename3, join as join10 } from "node:path";
5280
5092
 
@@ -5531,7 +5343,7 @@ async function imagePasteHooks(input, options, deps) {
5531
5343
  }
5532
5344
  let bytes;
5533
5345
  try {
5534
- bytes = readFileSync9(filePath);
5346
+ bytes = readFileSync8(filePath);
5535
5347
  } catch {
5536
5348
  skipped.push(name);
5537
5349
  continue;
@@ -6677,27 +6489,35 @@ async function selfUpdateHooks(input, options) {
6677
6489
  }
6678
6490
 
6679
6491
  // src/plugins/timeline.ts
6680
- import { writeFileSync as writeFileSync9, renameSync as renameSync6, rmSync as rmSync3 } from "node:fs";
6492
+ import { writeFileSync as writeFileSync10, renameSync as renameSync7, rmSync as rmSync3 } from "node:fs";
6681
6493
  import { tool as tool2 } from "@opencode-ai/plugin";
6682
6494
 
6683
6495
  // src/lib/timeline.ts
6684
- import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync8, readFileSync as readFileSync10, rmSync as rmSync2 } from "node:fs";
6496
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync8, readdirSync as readdirSync3, readFileSync as readFileSync9, renameSync as renameSync6, rmSync as rmSync2, writeFileSync as writeFileSync9 } from "node:fs";
6685
6497
  import { homedir as homedir7 } from "node:os";
6686
6498
  import { dirname as dirname5, join as join11, resolve as resolve4 } from "node:path";
6499
+ var TIMELINE_STATE_VERSION = 1;
6687
6500
  function timelineDir() {
6688
6501
  const env = process.env.LACODE_TIMELINE_DIR;
6689
6502
  if (env) return resolve4(env);
6690
6503
  return join11(homedir7(), ".local/share/opencode/lacode/timeline");
6691
6504
  }
6692
- function timelineFile(sessionID) {
6505
+ function assertValidSessionID(sessionID) {
6693
6506
  if (!/^[A-Za-z0-9_.-]+$/.test(sessionID)) throw new Error("invalid sessionID");
6507
+ }
6508
+ function timelineFile(sessionID) {
6509
+ assertValidSessionID(sessionID);
6694
6510
  return join11(timelineDir(), sessionID + ".jsonl");
6695
6511
  }
6512
+ function timelineStateFile(sessionID) {
6513
+ assertValidSessionID(sessionID);
6514
+ return join11(timelineDir(), sessionID + ".state.json");
6515
+ }
6696
6516
  function normalizeThrough(through) {
6697
6517
  if (!through || typeof through !== "object") return void 0;
6698
6518
  const out = {};
6699
- if (typeof through.messageID === "string") out.messageID = through.messageID;
6700
- if (typeof through.time === "number") out.time = through.time;
6519
+ if (typeof through.messageID === "string" && through.messageID.length > 0) out.messageID = through.messageID;
6520
+ if (typeof through.time === "number" && Number.isFinite(through.time)) out.time = through.time;
6701
6521
  if (out.messageID === void 0 && out.time === void 0) return void 0;
6702
6522
  return out;
6703
6523
  }
@@ -6738,7 +6558,7 @@ function appendEntriesAt(file, texts, now, through) {
6738
6558
  function readEntriesAt(file, limit) {
6739
6559
  let raw = "";
6740
6560
  try {
6741
- raw = readFileSync10(file, "utf8");
6561
+ raw = readFileSync9(file, "utf8");
6742
6562
  } catch {
6743
6563
  return [];
6744
6564
  }
@@ -6772,6 +6592,70 @@ function removeEntries(sessionID) {
6772
6592
  } catch {
6773
6593
  }
6774
6594
  }
6595
+ function normalizePersistedState(raw) {
6596
+ const obj = raw && typeof raw === "object" ? raw : {};
6597
+ const cursor = normalizeThrough(obj.cursor);
6598
+ const failures = typeof obj.failures === "number" && Number.isFinite(obj.failures) ? Math.max(0, Math.trunc(obj.failures)) : 0;
6599
+ const pending = obj.pending === true;
6600
+ const nextAttemptAt = typeof obj.nextAttemptAt === "number" && Number.isFinite(obj.nextAttemptAt) && obj.nextAttemptAt > 0 ? obj.nextAttemptAt : void 0;
6601
+ const updatedAt = typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt) ? obj.updatedAt : Date.now();
6602
+ const state2 = {
6603
+ version: TIMELINE_STATE_VERSION,
6604
+ failures,
6605
+ pending,
6606
+ updatedAt
6607
+ };
6608
+ if (cursor) state2.cursor = cursor;
6609
+ if (nextAttemptAt !== void 0) state2.nextAttemptAt = nextAttemptAt;
6610
+ return state2;
6611
+ }
6612
+ function readTimelineState(sessionID) {
6613
+ try {
6614
+ const raw = readFileSync9(timelineStateFile(sessionID), "utf8");
6615
+ return normalizePersistedState(JSON.parse(raw));
6616
+ } catch {
6617
+ return void 0;
6618
+ }
6619
+ }
6620
+ function writeTimelineState(sessionID, state2) {
6621
+ const file = timelineStateFile(sessionID);
6622
+ mkdirSync8(dirname5(file), { recursive: true });
6623
+ const tmp = file + ".tmp." + process.pid + "." + Math.random().toString(36).slice(2);
6624
+ try {
6625
+ writeFileSync9(tmp, JSON.stringify(state2) + "\n");
6626
+ renameSync6(tmp, file);
6627
+ } catch (err) {
6628
+ try {
6629
+ rmSync2(tmp, { force: true });
6630
+ } catch {
6631
+ }
6632
+ throw err;
6633
+ }
6634
+ }
6635
+ function removeTimelineState(sessionID) {
6636
+ try {
6637
+ rmSync2(timelineStateFile(sessionID), { force: true });
6638
+ } catch {
6639
+ }
6640
+ }
6641
+ function listTimelineStates() {
6642
+ let names;
6643
+ try {
6644
+ names = readdirSync3(timelineDir());
6645
+ } catch {
6646
+ return [];
6647
+ }
6648
+ const out = [];
6649
+ for (const name of names) {
6650
+ if (!name.endsWith(".state.json")) continue;
6651
+ const sessionID = name.slice(0, -".state.json".length);
6652
+ if (!/^[A-Za-z0-9_.-]+$/.test(sessionID)) continue;
6653
+ const state2 = readTimelineState(sessionID);
6654
+ if (!state2) continue;
6655
+ out.push({ sessionID, state: state2 });
6656
+ }
6657
+ return out;
6658
+ }
6775
6659
  function collapse(s) {
6776
6660
  return s.replace(/\s+/g, " ").trim();
6777
6661
  }
@@ -7104,6 +6988,11 @@ function timelineHooks(input, options) {
7104
6988
  const intervalMs = Number.isFinite(rawInterval) ? Math.max(MIN_INTERVAL_MS, rawInterval) : DEFAULT_INTERVAL_MS;
7105
6989
  const rawMaxEntries = Number(config?.maxEntries ?? DEFAULT_MAX_ENTRIES);
7106
6990
  const maxEntries = Number.isFinite(rawMaxEntries) ? Math.min(MAX_ENTRIES, Math.max(1, Math.trunc(rawMaxEntries))) : DEFAULT_MAX_ENTRIES;
6991
+ const rawRetryMs = Number(config?.retryMs ?? 3e4);
6992
+ const retryMs = Number.isFinite(rawRetryMs) ? Math.max(1, rawRetryMs) : 3e4;
6993
+ const rawMaxBackoffMs = Number(config?.maxBackoffMs ?? 30 * 6e4);
6994
+ const maxBackoffMs = Math.max(retryMs, Number.isFinite(rawMaxBackoffMs) ? Math.max(1, rawMaxBackoffMs) : 30 * 6e4);
6995
+ const retryDelay = (failures) => Math.min(retryMs * 2 ** Math.min(Math.max(failures - 1, 0), 20), maxBackoffMs);
7107
6996
  const shared = globalThis[/* @__PURE__ */ Symbol.for("lacode:timeline:state")] ??= {
7108
6997
  sessions: /* @__PURE__ */ new Map(),
7109
6998
  owned: /* @__PURE__ */ new Set(),
@@ -7120,21 +7009,37 @@ function timelineHooks(input, options) {
7120
7009
  if (!id) return void 0;
7121
7010
  return { providerID: provider, modelID: id };
7122
7011
  };
7012
+ const persist = (id, state2) => {
7013
+ try {
7014
+ writeTimelineState(id, {
7015
+ version: TIMELINE_STATE_VERSION,
7016
+ cursor: state2.cursor,
7017
+ failures: state2.failures,
7018
+ pending: state2.pending,
7019
+ nextAttemptAt: state2.nextAttemptAt,
7020
+ updatedAt: Date.now()
7021
+ });
7022
+ } catch (error) {
7023
+ debug("persist failed", { sessionID: id, error });
7024
+ }
7025
+ };
7123
7026
  const stateFor = (id) => {
7124
7027
  let state2 = sessions.get(id);
7125
7028
  if (!state2) {
7029
+ const persisted = readTimelineState(id);
7126
7030
  state2 = {
7127
7031
  running: false,
7128
- pending: false,
7129
- failures: 0,
7130
- cursor: readEntries(id, 1)[0]?.through
7032
+ pending: persisted?.pending ?? false,
7033
+ failures: persisted?.failures ?? 0,
7034
+ cursor: persisted?.cursor ?? readEntries(id, 1)[0]?.through,
7035
+ nextAttemptAt: persisted?.nextAttemptAt
7131
7036
  };
7132
7037
  sessions.set(id, state2);
7133
7038
  while (sessions.size > 500) {
7134
7039
  let evicted = false;
7135
7040
  for (const [key, st] of sessions) {
7136
7041
  if (key === id) continue;
7137
- if (!st.timer && !st.running) {
7042
+ if (!st.timer && !st.running && !st.pending && st.nextAttemptAt === void 0) {
7138
7043
  sessions.delete(key);
7139
7044
  evicted = true;
7140
7045
  break;
@@ -7149,16 +7054,29 @@ function timelineHooks(input, options) {
7149
7054
  if (disabled || disposed) return;
7150
7055
  const state2 = stateFor(id);
7151
7056
  if (state2.root === false) return;
7057
+ const wasPending = state2.pending;
7152
7058
  state2.pending = true;
7059
+ if (!wasPending) persist(id, state2);
7153
7060
  if (state2.timer || state2.running) return;
7154
7061
  schedule(id, state2);
7155
7062
  };
7156
7063
  function flush(id) {
7157
7064
  if (disabled || disposed) return;
7158
- const state2 = sessions.get(id);
7159
- if (!state2) return;
7065
+ let state2 = sessions.get(id);
7066
+ if (!state2) {
7067
+ if (readTimelineState(id)?.pending === true) state2 = stateFor(id);
7068
+ else return;
7069
+ }
7160
7070
  if (state2.root === false) return;
7161
7071
  if (!state2.pending || state2.running) return;
7072
+ if (state2.nextAttemptAt && state2.nextAttemptAt > Date.now()) {
7073
+ if (state2.timer) {
7074
+ clearTimeout(state2.timer);
7075
+ state2.timer = void 0;
7076
+ }
7077
+ schedule(id, state2);
7078
+ return;
7079
+ }
7162
7080
  if (state2.timer) {
7163
7081
  clearTimeout(state2.timer);
7164
7082
  state2.timer = void 0;
@@ -7167,10 +7085,11 @@ function timelineHooks(input, options) {
7167
7085
  }
7168
7086
  function schedule(id, state2) {
7169
7087
  if (state2.timer) clearTimeout(state2.timer);
7088
+ const delay = typeof state2.nextAttemptAt === "number" ? Math.max(0, state2.nextAttemptAt - Date.now()) : state2.failures > 0 ? retryDelay(state2.failures) : intervalMs;
7170
7089
  state2.timer = setTimeout(() => {
7171
7090
  state2.timer = void 0;
7172
7091
  void tick(id, state2);
7173
- }, intervalMs);
7092
+ }, delay);
7174
7093
  state2.timer.unref?.();
7175
7094
  }
7176
7095
  async function summarize(id, title, excerptText, prior, abort) {
@@ -7289,13 +7208,13 @@ function timelineHooks(input, options) {
7289
7208
  if (reprocessing.has(id)) return;
7290
7209
  try {
7291
7210
  state2.running = true;
7292
- state2.pending = false;
7293
7211
  let title;
7294
7212
  let info = {};
7295
7213
  if (state2.root === void 0) {
7296
7214
  info = await client.session.get({ path: { id }, query: { directory } }).then((result2) => result2?.data).catch(() => void 0);
7297
7215
  if (!info) {
7298
7216
  sessions.delete(id);
7217
+ removeTimelineState(id);
7299
7218
  return;
7300
7219
  }
7301
7220
  title = info.title;
@@ -7303,6 +7222,7 @@ function timelineHooks(input, options) {
7303
7222
  state2.root = !info.parentID && !isInternalTitle(info.title);
7304
7223
  if (!state2.root) {
7305
7224
  sessions.delete(id);
7225
+ removeTimelineState(id);
7306
7226
  return;
7307
7227
  }
7308
7228
  }
@@ -7310,6 +7230,10 @@ function timelineHooks(input, options) {
7310
7230
  const messages = response?.data ?? [];
7311
7231
  const excerpt = buildExcerpt(messages, state2.cursor);
7312
7232
  if (!excerpt.text) {
7233
+ state2.pending = false;
7234
+ state2.failures = 0;
7235
+ state2.nextAttemptAt = void 0;
7236
+ persist(id, state2);
7313
7237
  return;
7314
7238
  }
7315
7239
  const result = await summarize(id, state2.title ?? info?.title ?? "untitled", excerpt.text, readEntries(id, 4));
@@ -7317,8 +7241,14 @@ function timelineHooks(input, options) {
7317
7241
  if (result.bullets.length) appendEntries(id, result.bullets, Date.now(), excerpt.next);
7318
7242
  state2.cursor = excerpt.next;
7319
7243
  state2.failures = 0;
7244
+ state2.pending = false;
7245
+ state2.nextAttemptAt = void 0;
7246
+ persist(id, state2);
7320
7247
  } else {
7321
7248
  state2.failures++;
7249
+ state2.pending = true;
7250
+ state2.nextAttemptAt = Date.now() + retryDelay(state2.failures);
7251
+ persist(id, state2);
7322
7252
  log("warn", "timeline: summarize failed", { sessionID: id, error: result.error });
7323
7253
  }
7324
7254
  } catch (error) {
@@ -7357,7 +7287,7 @@ function timelineHooks(input, options) {
7357
7287
  if (!info) return "Session not found.";
7358
7288
  const msgRes = await client.session.messages({ path: { id: rootId }, query: { directory } });
7359
7289
  const messages = msgRes?.data ?? [];
7360
- writeFileSync9(temp, "");
7290
+ writeFileSync10(temp, "");
7361
7291
  for (let i = 0; i < MAX_REPROCESS_SLICES; i++) {
7362
7292
  if (abort?.aborted) {
7363
7293
  try {
@@ -7397,13 +7327,21 @@ function timelineHooks(input, options) {
7397
7327
  }
7398
7328
  return "No entries generated; previous timeline kept.";
7399
7329
  }
7400
- renameSync6(temp, targetFile);
7330
+ renameSync7(temp, targetFile);
7401
7331
  const state2 = sessions.get(rootId);
7402
7332
  if (state2) {
7403
7333
  state2.cursor = cursor;
7404
7334
  state2.failures = 0;
7405
7335
  state2.pending = false;
7336
+ state2.nextAttemptAt = void 0;
7406
7337
  }
7338
+ persist(rootId, {
7339
+ running: state2?.running ?? false,
7340
+ pending: false,
7341
+ failures: 0,
7342
+ cursor,
7343
+ nextAttemptAt: void 0
7344
+ });
7407
7345
  return `Rebuilt ${messages.length} messages into ${entries} entries with ${calls} summarizer calls.`;
7408
7346
  } catch (error) {
7409
7347
  try {
@@ -7415,6 +7353,28 @@ function timelineHooks(input, options) {
7415
7353
  reprocessing.delete(rootId);
7416
7354
  }
7417
7355
  }
7356
+ function resume() {
7357
+ if (disabled || disposed) return;
7358
+ let listed;
7359
+ try {
7360
+ listed = listTimelineStates();
7361
+ } catch (error) {
7362
+ debug("resume failed", error);
7363
+ return;
7364
+ }
7365
+ listed.sort((a, b) => b.state.updatedAt - a.state.updatedAt);
7366
+ let scheduled = 0;
7367
+ for (const { sessionID, state: state2 } of listed) {
7368
+ if (state2.pending !== true) continue;
7369
+ if (sessions.has(sessionID)) continue;
7370
+ if (scheduled >= 50) continue;
7371
+ const loaded = stateFor(sessionID);
7372
+ if (loaded.root === false) continue;
7373
+ schedule(sessionID, loaded);
7374
+ scheduled++;
7375
+ }
7376
+ }
7377
+ resume();
7418
7378
  const timelineReprocess = tool2({
7419
7379
  description: "Rebuild the root session's entire timeline from its transcript, replacing the existing entries. Used by /reprocess-timeline.",
7420
7380
  args: {},
@@ -7464,6 +7424,7 @@ function timelineHooks(input, options) {
7464
7424
  }
7465
7425
  sessions.delete(id);
7466
7426
  removeEntries(id);
7427
+ removeTimelineState(id);
7467
7428
  owned.delete(id);
7468
7429
  return;
7469
7430
  }
package/docs/CONTEXT.md CHANGED
@@ -30,8 +30,7 @@ the exploration it replaces is worse than no map:
30
30
  `src/orders/`". The cartographer is forbidden by name from writing directory
31
31
  listings, copying source, or restating what the README already says.
32
32
  - **One file per question.** Five navigation files, each with a defined scope, so
33
- an agent reads one of them rather than all of them. A sixth file,
34
- `active.md`, holds the work in flight rather than a map of the repository.
33
+ an agent reads one of them rather than all of them.
35
34
  - **Canonical entry points.** The highest-value thing in the map is a short
36
35
  "Start here" list per subsystem: the canonical files an agent should open, each
37
36
  with a one-phrase reason.
@@ -48,8 +47,7 @@ harness that produces a number for your own workload.
48
47
 
49
48
  ## The files in the map
50
49
 
51
- `.opencode/context/` holds the five navigation files below, plus one file that is
52
- a work list rather than a map:
50
+ `.opencode/context/` holds the five navigation files below:
53
51
 
54
52
  | File | What belongs in it |
55
53
  | --- | --- |
@@ -58,7 +56,6 @@ a work list rather than a map:
58
56
  | `conventions.md` | Repository-specific rules: naming, module layout, error handling, testing, persistence. Read this before writing code, not after. |
59
57
  | `workflows.md` | The few runtime flows worth tracing end to end — how a request becomes a mutation, how a plugin hook reaches the thing it hooks. Read this when you need to know what actually happens at runtime. |
60
58
  | `decisions.md` | Architectural constraints that bind future work and that the repository's own decision log does not already record. Each entry states the constraint and points at the page or path that owns it. Capped at 120 non-blank lines; decision records and deliberation are not copied here. |
61
- | `active.md` | The active backlog: work in flight, grouped by the ISO week each task was opened in. A task that is not marked `[x]` is open and is never removed; a task that is finished and dated is kept for a week after it was finished, then dropped. The plugin enforces that window — see [How it stays current](#how-it-stays-current). Not a navigation file: there is no question it is the answer to. |
62
59
 
63
60
  The project's root `AGENTS.md` is the standing-instructions home: the client's
64
61
  own statement of intent, what they want worked on and how they want it
@@ -98,20 +95,19 @@ Every navigation entry has one shape, in every file:
98
95
  the drift check.
99
96
 
100
97
  Every file has a hard cap, counted in **non-blank lines**: architecture 150,
101
- contexts 200, conventions 150, workflows 150, decisions 120, active 60. Lines
102
- are the unit because they are what a reader pays for, they bound an entry that
103
- wraps to three lines, and the validator can count them exactly; an entry count
104
- does not bound cost, and a token count needs a tokenizer and is model-specific.
105
- The cap is enforced by a post-generation validator, not only by the prompt.
106
-
107
- The lifecycle generalises the window `active.md` already had. A finished backlog
108
- task is dropped after 7 days. A `[provisional]` entry in any navigation file is
109
- dropped 30 days after its `verified` date unless it is re-verified. `[binding]`
110
- and `[disputed]` never expire by age. An entry whose cited source no longer
111
- exists is **never** auto-dropped: it may be the only copy, so it is reported as
112
- unarchived and kept. Only provisional entries whose source still resolves are
113
- eligible for the sweep. The arithmetic lives in `src/lib/map-schema.ts`; the
114
- cartographer writes the shape, code decides what has aged out.
98
+ contexts 200, conventions 150, workflows 150, decisions 120. Lines are the unit
99
+ because they are what a reader pays for, they bound an entry that wraps to three
100
+ lines, and the validator can count them exactly; an entry count does not bound
101
+ cost, and a token count needs a tokenizer and is model-specific. The cap is
102
+ enforced by a post-generation validator, not only by the prompt.
103
+
104
+ A `[provisional]` entry in any navigation file is dropped 30 days after its
105
+ `verified` date unless it is re-verified. `[binding]` and `[disputed]` never
106
+ expire by age. An entry whose cited source no longer exists is **never**
107
+ auto-dropped: it may be the only copy, so it is reported as unarchived and kept.
108
+ Only provisional entries whose source still resolves are eligible for the sweep.
109
+ The arithmetic lives in `src/lib/map-schema.ts`; the cartographer writes the
110
+ shape, code decides what has aged out.
115
111
 
116
112
  The map is **local and untracked**. It describes *your* checkout on *this*
117
113
  machine, so committing it would churn on every session that edits a file, and
@@ -193,25 +189,13 @@ your source.
193
189
  If you have defined a command of the same name, yours wins and is left exactly
194
190
  as you wrote it — the same merge-without-clobber rule the agents follow.
195
191
 
196
- `active.md` is the file the plugin maintains as a backlog rather than as a
197
- navigation map. The cartographer records what happened — work in flight, and a
198
- task marked finished with the date it finished — and the plugin decides what is
199
- still worth keeping: the file is regrouped by week and a task that was finished
200
- more than a week ago is dropped. The arithmetic lives in
201
- `src/lib/active-backlog.ts`, not in the cartographer's prompt, because a rule a
202
- model applies is a rule the model applies when it feels like it, and the failure
203
- is silent — the backlog just grows. The plugin normalizes the file on every edit
204
- of it and once when the session starts, so a hand edit is grouped and pruned
205
- whether the cartographer ran or not, and the file is rewritten only when its
206
- canonical form differs from what is on disk, so the write cannot loop.
207
-
208
192
  The same code owns the navigation files' lifecycle, but it may only **drop** an
209
193
  entry that is `[provisional]`, past its 30-day window, and whose cited source
210
194
  still exists. It never drops a binding constraint or disputed entry, and it
211
195
  never drops an entry with no source — that is the one case where the map may be
212
196
  the only copy. The plugin is not a second writer of the map in the permission
213
197
  sense: `context` is still the only **agent** that writes it. The lifecycle is
214
- housekeeping by code, the same category as the backlog window.
198
+ housekeeping by code, applied after a run and then audited.
215
199
 
216
200
  ## One writer
217
201
 
@@ -301,7 +285,7 @@ to check is that permission rule, not the map's content.
301
285
  - The prompt the model actually runs, in one place: `src/lib/cartographer-prompt.ts`; the tracked copy is `.opencode/prompts/context.txt`, and a test pins equality.
302
286
  - The idle/edit trigger and the post-run audit wiring: `src/plugins/context-autoupdate.ts`.
303
287
  - The schema, budget, lifecycle and deterministic drift check: `src/lib/map-schema.ts`.
304
- - The active backlog's window: `src/lib/active-backlog.ts`.
288
+
305
289
  - The one-writer rule check: `src/lib/writer-rule.ts`.
306
290
  - Fixtures and their report: `tests/fixtures/context-map/`, `scripts/context-map-fixture.mjs`.
307
291
  - The behavioural contracts, with acceptance criteria a test asserts:
@@ -471,6 +471,29 @@ Enforcement is split deliberately, and the split matters: **what the permission
471
471
  - [ ] Given the user's config already defines one of the three commands, When the config hook runs, Then that command is left byte-identical while the other two are still added.
472
472
  - [ ] Given the commands are injected, When each one's target agent is resolved against the same config object, Then all three resolve to an agent that actually exists there — a command must never point at a missing agent.
473
473
 
474
+ ### US-35: A timeline survives a restart and resumes unfinished work
475
+ **As a** user whose opencode process restarts in the middle of a session, **I want** the timeline to remember what it has already summarized and to finish the slice it had not written yet, **so that** a crash or restart never silently loses part of the session's work record.
476
+
477
+ The cursor and retry state used to live only in memory; a process that stopped between messages and the next idle flush forgot that a slice was pending, and a successful summary that produced no bullet text advanced the cursor only in memory, so a restart could summarize the same slice again. The plugin now writes a small per-session state file next to the JSONL timeline (cursor, pending flag, failure count, next retry time); startup resumes pending work, and a deleted session removes both files.
478
+
479
+ **Story ID(s) for tests:** US-35
480
+ **Acceptance criteria:**
481
+ - [ ] Given a session has a pending timeline slice recorded on disk, When the process restarts, Then the timeline resumes and completes that slice without waiting for a new message event.
482
+ - [ ] Given a summarization succeeds but produces no bullet text, When the process restarts, Then the advanced cursor is still known and the same transcript slice is not summarized again.
483
+ - [ ] Given a session is deleted, When its timeline is removed, Then its durable state file is removed with it.
484
+
485
+ ### US-36: A failed timeline summary retries with backoff
486
+ **As a** user whose summarizer call fails transiently, **I want** the timeline to retry on its own with an increasing delay, **so that** a temporary model or network failure does not leave the timeline permanently behind the session.
487
+
488
+ A failed summarization used to clear the pending flag and never reschedule, so only new session activity could try again. A failure now marks the slice pending, records the next attempt time, and schedules a retry; repeated failures back off exponentially up to a cap, and an idle event during the wait re-arms the timer instead of bypassing it. A successful retry clears the failure count.
489
+
490
+ **Story ID(s) for tests:** US-36
491
+ **Acceptance criteria:**
492
+ - [ ] Given a summarization fails transiently, When the retry interval elapses, Then the timeline retries without waiting for new session activity.
493
+ - [ ] Given repeated failures, When retries are scheduled, Then the wait grows with each failure up to a configured cap.
494
+ - [ ] Given a retry is waiting, When the session goes idle before the backoff window elapses, Then the idle event does not bypass the wait.
495
+ - [ ] Given a retry succeeds, When entries are appended, Then the failure count resets to zero and the timeline is no longer pending.
496
+
474
497
  ---
475
498
 
476
499
  ## Epic H — Retention (sweeps)
@@ -554,6 +577,8 @@ table was filled in.
554
577
  | US-20 | G | `tests/stories/g-context-management.test.mjs` |
555
578
  | US-21 | G | `tests/stories/g-context-management.test.mjs`, `tests/permission-test.mjs` |
556
579
  | US-22 | G | `tests/stories/g-context-management.test.mjs` |
580
+ | US-35 | G | `tests/stories/j-timeline-reliability.test.mjs` |
581
+ | US-36 | G | `tests/stories/j-timeline-reliability.test.mjs` |
557
582
  | US-32 | H | `tests/retire-old-sessions-test.mjs` |
558
583
  | US-33 | H | `tests/prune-old-events-test.mjs` |
559
584
 
@@ -561,8 +586,8 @@ The nine suites under `tests/` are unit-style; the `tests/stories/` suites are
561
586
  the behavioural layer, written one epic per file with every test name carrying
562
587
  its story id. Run everything with `npm test`.
563
588
 
564
- **No story is uncovered.** The guard reports 33 stories, 325 test cases, and
565
- 33/33 stories with at least one behavioural test; US-1, US-2, US-3, US-6, and
589
+ **No story is uncovered.** The guard reports 36 stories, 348 test cases, and
590
+ 36/36 stories with at least one behavioural test; US-1, US-2, US-3, US-6, and
566
591
  all of epic F have tests, and the coverage guard fails the build if any of them
567
592
  loses one. The scan covers the `tests/stories/` suites and the root-level
568
593
  `tests/*-test.mjs` suites.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "1.3.1",
3
+ "version": "2.0.0-beta.0",
4
4
  "description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
5
5
  "license": "MIT",
6
6
  "type": "module",