@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 +19 -1
- package/README.md +3 -6
- package/dist/agents.js +3 -41
- package/dist/commands.js +2 -5
- package/dist/index.js +196 -235
- package/docs/CONTEXT.md +17 -33
- package/docs/USER-STORIES.md +27 -2
- package/package.json +1 -1
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/
|
|
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`)
|
|
275
|
-
|
|
276
|
-
|
|
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.
|
|
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
|
|
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
|
|
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)\`).
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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)\`).
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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 (!
|
|
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()) +
|
|
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 *
|
|
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
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
7159
|
-
if (!state2)
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -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
|
|
565
|
-
|
|
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