dsh-error-atlas 0.0.0-stage → 0.11.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-error-atlas contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,154 @@
1
- # Temporary Holding Version
1
+ # Error Atlas · 错误星图
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把 DSH 自己的运行错误和每个编程任务的复盘,沉淀成一张可以被**人看见**、也可以被**模型读到**的记忆图谱。
4
+
5
+ ![错误星图面板](https://raw.githubusercontent.com/chaos-miku/dsh-error-atlas/main/docs/screenshot.jpg)
6
+
7
+ *面板实拍:顶部是统计与操作,左侧是颜色图例,中间是 GPU 渲染的错误星系,底部是「记忆」开关、教训有效性榜单与最近事件表。标题旁的 `V33` 是 build 号,用来确认页面跑的是哪一代客户端代码。*
8
+
9
+ A local-first memory plugin for DeepSeek Harness: it records runtime failures and
10
+ coding-task retrospectives, injects the reusable lessons back into the prompt, and
11
+ renders the whole atlas as an interactive WebGL knowledge graph.
12
+
13
+ ---
14
+
15
+ ## 它解决什么
16
+
17
+ DSH 报过的错、踩过的坑,默认会随会话结束消失。这个插件做两件事:
18
+
19
+ **写侧**:把错误和复盘结构化地存下来。
20
+
21
+ - 运行期错误自动入库:`agent/error`(步/轮失败)、`api-session/error`、`agent-loop/config-start-failed`、`tools/result` 里的工具失败与非零退出码;
22
+ - 同一签名(类型 + 工具 + 消息)自动去重聚合,记次数、首末时间、涉及会话;
23
+ - 每次编程任务收尾由模型调用 `atlas_retro` 写一条复盘:**做了什么 / 根因 / 怎么修 / 教训**;模型没写的,宿主用工具与文件留一条自动骨架(不会抢模型手写的份额)。
24
+
25
+ **读侧**:让教训在下次真的被用上。
26
+
27
+ - 一段全局提示词要求「编程任务收尾前写复盘」;
28
+ - 每次请求动态注入简报:**失效教训优先**(写完又复发的),再加几条最近的规则;没有教训时整段不出现,不产生固定开销;
29
+ - 只读工具 `atlas_lessons`,可按 `query` / `program` / `limit` 查询全部教训。
30
+
31
+ **图谱只是这些数据的一个视图**——它不是装饰,但也不是产品本身:价值在于「教训能被下次会话读到」。
32
+
33
+ ## 使用方式
34
+
35
+ ### 1. 打开面板
36
+
37
+ 左侧栏点击 **错误图谱**(图标是一颗星)。第一次打开是空的——它只记录安装之后发生的事。
38
+
39
+ ### 2. 正常干活
40
+
41
+ 不需要做任何事。之后你在 DSH 里跑命令、改文件、插件加载失败、工具非零退出,都会自动入库并按签名聚合;重复犯同一个错,那颗点会变大变亮,次数 +1。
42
+
43
+ ### 3. 收尾写复盘
44
+
45
+ 一段全局提示词会让模型在每个编程任务收尾前调用 `atlas_retro`,写入四条信息:做了什么、根因、怎么修、教训(那条「下次别再犯」的规则)。如果模型忘了写,宿主会留一条自动骨架,保证「这一轮干过什么」不丢。
46
+
47
+ 面板上紫色的 `RETRO` 节点就是复盘,一条紫色的「教训」线把它连向它要治的那个错误;线上会有光点从错误流向复盘,表示这条教训正在被应用。**同一错误在复盘之后又出现,管线会反过来倒灌并变红**,面板顶部统计格里的「教训失效」计数 +1。
48
+
49
+ ### 4. 让教训回到下一次
50
+
51
+ - 新会话开始后,宿主会自动把「失效教训 + 最近几条规则」注入上下文,模型不用查就知道;
52
+ - 你也可以让它主动查:模型可以调用 `atlas_lessons`(按程序 / 关键词 / 条数),面板上的「导出」则给人看。
53
+
54
+ ### 5. 维护
55
+
56
+ - **噪声太多**:卡片上点「忽略」,把这条签名加入前缀规则,永久静音;顶部「过滤 ≥N」按命中次数裁剪视图;
57
+ - **找东西**:搜索框输入标签、程序名、错误文本,命中高亮、其余压暗;点教训有效性榜单里的任意一行,图谱会过滤到那一类;
58
+ - **太久之前**:点「清空」删除运行时错误记录(青色开发错误与紫色复盘是保留的);点「导出」把整份数据写成 Markdown。
59
+
60
+ ## 面板上都有什么
61
+
62
+ | 区域 | 说明 |
63
+ |---|---|
64
+ | 顶部统计 | 累计错误 / 去重条目 / 图谱节点 / 会话 / 工具 / 程序 / 开发错误 / 任务复盘 / 教训失效 |
65
+ | 顶部操作 | 刷新、搜索、暂停、自转速度、过滤 ≥N、重排、导出、清空、标题开关 |
66
+ | 左侧图例 | 见下表 |
67
+ | 中央星图 | 拖拽旋转、滚轮缩放、点节点看详情卡、双击带会话的节点跳回该会话 |
68
+ | 底部「记忆」 | 两枚开关:收尾提示 / 注入教训 |
69
+ | 教训有效性 | 按标签与程序统计复盘数、失效数、失效率;可收起;行可点击过滤 |
70
+ | 最近事件 | 最新 400 条事件的表格视图(次数、类型、工具、消息、首次/最近) |
71
+
72
+ 图例与配色:
73
+
74
+ | 节点 | 颜色 | 含义 |
75
+ |---|---|---|
76
+ | `AGENT` | 亮红 `#ff4453` | 模型自身在某一轮/步失败 |
77
+ | `TOOL` | 橙 `#ffb570` | 工具调用返回了错误 |
78
+ | `EXIT` | 杏黄 `#ffd68f` | 命令非零退出码 |
79
+ | `SESSION` | 粉 `#ff859d` | 会话枢纽(出过错的那个窗口) |
80
+ | `CONFIG` | 紫 `#d89eff` | 会话/插件配置启动失败 |
81
+ | `DEV` | 青 `#7ff3e0` | 插件自己的开发错误(内置 12 条,随版本更新) |
82
+ | `RETRO` | 紫 `#c7a4ff` | 一条任务复盘 |
83
+ | `PROJECT` | 浅蓝 `#7fd4ff` | 程序枢纽:同一项目里的错误额外连向它 |
84
+ | `STALE` | 红 `#ff5b4d` | 教训失效(复盘之后同一错误又出现) |
85
+
86
+ ## 安装
87
+
88
+ 市场安装:在 DSH 插件市场搜索 `error-atlas` / `错误星图`。
89
+
90
+ 本地目录安装(开发用):
91
+
92
+ ```
93
+ plugin_manager install_bundle target="<本包绝对路径>"
94
+ ```
95
+
96
+ 安装后**重启 DSH**(宿主半边要注册路由、工具和提示词段),再刷新页面。
97
+
98
+ ## 两个记忆开关
99
+
100
+ 面板底部有一条「记忆」栏,两枚开关都是即时生效、重启后保留:
101
+
102
+ | 开关 | 关闭后 |
103
+ |---|---|
104
+ | 收尾提示 | 不再注册「编程任务收尾前写复盘」的提示词段(省掉每轮固定字符) |
105
+ | 注入教训 | 不再每轮注入历史教训简报(图谱与复盘照常记录) |
106
+
107
+ 只想安静记录、不想被提示词影响的人,两个都关掉即可;复盘与图谱不受影响。
108
+
109
+ ## 命令行
110
+
111
+ 包内自带两个 CLI,也可被直接调用:
112
+
113
+ ```
114
+ node bin/atlas-digest.mjs # 终端摘要:概览、Top 失败、失效教训、最近复盘
115
+ node bin/atlas-digest.mjs --json # 机器可读
116
+ node bin/atlas-retro.mjs --list # 列出复盘
117
+ node bin/atlas-retro.mjs --upgrade <id> --lesson "…" # 升级/改写某条复盘
118
+ node bin/atlas-retro.mjs --new --title "…" --lesson "…" # 手写一条复盘
119
+ ```
120
+
121
+ `atlas-retro` 优先通过宿主路由写入(`POST /error-atlas/retro`,仅回环可访问);宿主不在时退化为直接改库文件并给出警告。
122
+
123
+ ## 数据与隐私
124
+
125
+ - 数据只落在本机:`$DSH_HOME/error-atlas/records.json`(日志、聚合错误、复盘、偏好设置);
126
+ - 导出的 Markdown 写到 `$DSH_HOME/error-atlas/exports/`;
127
+ - 所有 HTTP 路由(`/error-atlas/data|prefs|retro|clear|export`)都只接受回环地址;
128
+ - 不联网、不上报、不采集对话内容;错误消息里可能含命令与路径,介意的话用面板的「清空」一键删除运行时记录(开发错误、任务复盘是内置/用户自己的数据,不会被清空)。
129
+
130
+ ## 开发与测试
131
+
132
+ ```
133
+ npm test # 157 项断言:宿主事件/路由/存储 + 客户端渲染(借用本机 DSH 的 React)
134
+ npm run smoke # 干净 DSH_HOME 下的冷启动冒烟:空库 → 写复盘 → 读回 → CLI 可读
135
+ ```
136
+
137
+ 客户端为零依赖手写 bundle(`window.__ModuleLoader__`),宿主为纯 ESM。面板标题旁的 `V30`/`V31`… 是 BUILD 号,用来确认页面加载的是哪一代客户端代码。
138
+
139
+ ## 已知边界
140
+
141
+ - 只看得见**工具层失败与非零退出**,模型在「成功路径」上的逻辑错误不会自动入库——那部分要靠复盘自己写;
142
+ - 教训注入是全局的(每轮一次),不按工作区精确匹配;
143
+ - 程序枢纽只从复盘的 `files` + `cwd` 推导,错误记录本身不存路径,所以没写过复盘的目录不会出现在图里;
144
+ - 图谱上限 240 个节点,超出时 `stats.truncated` 会如实标记,且被复盘引用的错误不会被裁掉;
145
+ - 需要 Node ≥ 20;客户端需要 WebGL2(不可用时面板退化为提示)。
146
+
147
+ ## 相关链接
148
+
149
+ - 源码与问题反馈:<https://github.com/chaos-miku/dsh-error-atlas>
150
+ - 作者:<https://github.com/chaos-miku>
151
+
152
+ ## License
153
+
154
+ MIT
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ // atlas-digest: a small terminal reader for the Error Atlas store.
3
+ // Prints a compact digest of recorded runtime errors and coding retrospectives.
4
+ //
5
+ // node bin/atlas-digest.mjs [--json] [--store=<path>] [--top=<n>]
6
+
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ const argv = process.argv.slice(2)
12
+ const wantJson = argv.includes('--json')
13
+
14
+ const opt = (name, fallback) => {
15
+ const hit = argv.find((a) => a.startsWith('--' + name + '='))
16
+ return hit ? hit.slice(name.length + 3) : fallback
17
+ }
18
+
19
+ const stamp = (ms) => {
20
+ const d = new Date(ms)
21
+ const p = (n) => String(n).padStart(2, '0')
22
+ return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes())
23
+ }
24
+
25
+ // East Asian glyphs take two terminal cells; everything we print is padded by
26
+ // display width, never by String#length.
27
+ const wideCode = (code) =>
28
+ (code >= 0x1100 && code <= 0x115f) ||
29
+ (code >= 0x2e80 && code <= 0xa4cf) ||
30
+ (code >= 0xac00 && code <= 0xd7a3) ||
31
+ (code >= 0xf900 && code <= 0xfaff) ||
32
+ (code >= 0xfe30 && code <= 0xfe6f) ||
33
+ (code >= 0xff00 && code <= 0xff60) ||
34
+ (code >= 0xffe0 && code <= 0xffe6) ||
35
+ (code >= 0x1f300 && code <= 0x1f64f) ||
36
+ (code >= 0x20000 && code <= 0x3fffd)
37
+
38
+ function displayWidth(text) {
39
+ let width = 0
40
+ for (const ch of String(text)) width += wideCode(ch.codePointAt(0)) ? 2 : 1
41
+ return width
42
+ }
43
+
44
+ // One-line cell of exactly `width` terminal columns, ellipsised when too long.
45
+ function fit(text, width) {
46
+ const one = String(text == null ? '' : text).replace(/\s+/g, ' ').trim()
47
+ const total = displayWidth(one)
48
+ if (total <= width) return one + ' '.repeat(width - total)
49
+ let out = ''
50
+ let used = 0
51
+ for (const ch of one) {
52
+ const step = wideCode(ch.codePointAt(0)) ? 2 : 1
53
+ if (used + step > width - 1) break
54
+ out += ch
55
+ used += step
56
+ }
57
+ return out + '…' + ' '.repeat(Math.max(0, width - used - 1))
58
+ }
59
+
60
+ // A terminal cell is not the same thing as a JS character: CJK glyphs occupy two
61
+ // columns, so padding by String#length leaves every Chinese row ragged.
62
+ if (argv.includes('--selftest')) {
63
+ const cases = [
64
+ ['DSH', 6],
65
+ ['中文', 8],
66
+ ['学习管线:光点沿复盘连线运行', 20],
67
+ ['a中b', 7],
68
+ ['中文中文中文中文', 9],
69
+ ]
70
+ let bad = 0
71
+ for (const [text, width] of cases) {
72
+ const cell = fit(text, width)
73
+ const got = displayWidth(cell)
74
+ if (got !== width) {
75
+ bad += 1
76
+ console.error('FAIL fit(' + JSON.stringify(text) + ', ' + width + ') → ' + JSON.stringify(cell) + ' width ' + got)
77
+ } else {
78
+ console.log('ok fit(' + JSON.stringify(text) + ', ' + width + ')')
79
+ }
80
+ }
81
+ console.log(bad ? 'SELFTEST FAILED: ' + bad : 'SELFTEST PASSED')
82
+ process.exit(bad ? 1 : 0)
83
+ }
84
+
85
+ const storePath =
86
+ opt('store', null) ||
87
+ join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'error-atlas', 'records.json')
88
+ const topN = Number(opt('top', '6'))
89
+
90
+ if (!existsSync(storePath)) {
91
+ console.error('atlas-digest: no store at ' + storePath)
92
+ process.exit(2)
93
+ }
94
+
95
+ // Tolerate a BOM: hand-edited stores written by PowerShell often carry one.
96
+ const store = JSON.parse(readFileSync(storePath, 'utf8').replace(/^\uFEFF/, ''))
97
+ const records = Array.isArray(store.records) ? store.records : []
98
+ const retros = Array.isArray(store.retros) ? store.retros : []
99
+ const log = Array.isArray(store.log) ? store.log : []
100
+ const hits = records.reduce((sum, r) => sum + (Number(r.count) || 0), 0)
101
+
102
+ // A lesson fails when the error it explains comes back after it was written. The same
103
+ // 90s grace window the graph uses keeps the failure that produced the lesson from
104
+ // counting as its own relapse.
105
+ const GRACE_MS = 90 * 1000
106
+ const byId = new Map(records.map((r) => [r.id, r]))
107
+ const signature = (entry) => entry.kind + '\u0000' + entry.name + '\u0000' + entry.message
108
+
109
+ function recurrenceOf(retro) {
110
+ const ids = Array.isArray(retro.errorIds) ? retro.errorIds : []
111
+ if (ids.length === 0) return null
112
+ const cutoff = (Number(retro.at) || 0) + GRACE_MS
113
+ const keys = new Set()
114
+ let lastAt = 0
115
+ for (const id of ids) {
116
+ const entry = byId.get(id)
117
+ if (!entry) continue
118
+ keys.add(signature(entry))
119
+ if ((entry.last || 0) > cutoff) lastAt = Math.max(lastAt, entry.last)
120
+ }
121
+ if (keys.size === 0) return null
122
+ let times = 0
123
+ for (const event of log) {
124
+ if (!(event.at > cutoff)) continue
125
+ if (!keys.has(signature(event))) continue
126
+ times += 1
127
+ lastAt = Math.max(lastAt, event.at)
128
+ }
129
+ // The log is capped; an aggregate `last` beyond the cutoff still proves a relapse.
130
+ if (times === 0 && lastAt > 0) times = 1
131
+ if (times === 0) return null
132
+ const errors = ids
133
+ .map((id) => byId.get(id))
134
+ .filter(Boolean)
135
+ .map((entry) => ({ kind: entry.kind, name: entry.name, message: entry.message, count: entry.count }))
136
+ return { times, lastAt, errors }
137
+ }
138
+
139
+ const failed = []
140
+ for (const retro of retros) {
141
+ const relapse = recurrenceOf(retro)
142
+ if (relapse) failed.push({ retro, relapse })
143
+ }
144
+ failed.sort((a, b) => b.relapse.times - a.relapse.times || b.relapse.lastAt - a.relapse.lastAt)
145
+ const staleIds = new Set(failed.map((item) => item.retro.id))
146
+
147
+ if (wantJson) {
148
+ console.log(
149
+ JSON.stringify(
150
+ {
151
+ store: storePath,
152
+ version: store.version,
153
+ errors: records.length,
154
+ hits,
155
+ retros: retros.length,
156
+ modelRetros: retros.filter((r) => !r.auto).length,
157
+ autoRetros: retros.filter((r) => r.auto).length,
158
+ staleLessons: failed.length,
159
+ stale: failed.map(({ retro, relapse }) => ({
160
+ at: retro.at,
161
+ auto: Boolean(retro.auto),
162
+ title: retro.title,
163
+ times: relapse.times,
164
+ lastAt: relapse.lastAt,
165
+ errors: relapse.errors,
166
+ })),
167
+ top: [...records].sort((a, b) => b.count - a.count).slice(0, topN).map((r) => ({
168
+ count: r.count,
169
+ kind: r.kind,
170
+ name: r.name,
171
+ message: r.message,
172
+ })),
173
+ recent: [...retros].sort((a, b) => b.at - a.at).slice(0, topN).map((r) => ({
174
+ at: r.at,
175
+ auto: Boolean(r.auto),
176
+ title: r.title,
177
+ errors: Array.isArray(r.errorIds) ? r.errorIds.length : (Array.isArray(r.errors) ? r.errors.length : 0),
178
+ })),
179
+ },
180
+ null,
181
+ 2,
182
+ ),
183
+ )
184
+ process.exit(0)
185
+ }
186
+
187
+ const first = Math.min(...records.map((r) => r.first || r.last || 0))
188
+ const last = Math.max(...records.map((r) => r.last || r.first || 0))
189
+ const autoCount = retros.filter((r) => r.auto).length
190
+
191
+ console.log('')
192
+ console.log(' ERROR ATLAS · digest')
193
+ console.log(' ' + storePath)
194
+ console.log(
195
+ ' span ' + (records.length ? stamp(first) + ' → ' + stamp(last) : '—') +
196
+ ' · ' + records.length + ' distinct errors · ' + hits + ' hits',
197
+ )
198
+ console.log(
199
+ ' retros ' + retros.length + ' (' + (retros.length - autoCount) + ' model · ' + autoCount + ' auto)' +
200
+ ' · ' + (failed.length ? failed.length + ' failed lesson' + (failed.length > 1 ? 's' : '') : 'every lesson held'),
201
+ )
202
+
203
+ // Read this first: a lesson that did not hold is the one thing worth looking at.
204
+ if (failed.length) {
205
+ console.log('')
206
+ console.log(' STALE LESSONS (' + failed.length + ')')
207
+ for (const { retro, relapse } of failed) {
208
+ const target = relapse.errors[0] || { kind: '?', name: '?', message: '' }
209
+ console.log(
210
+ ' ' + stamp(retro.at) + ' ' + ('×' + relapse.times).padStart(3) + ' ' + fit(retro.title, 46) +
211
+ ' ' + (retro.auto ? 'auto' : 'model'),
212
+ )
213
+ console.log(
214
+ ' ' + '最近 ' + stamp(relapse.lastAt) + ' · ' +
215
+ fit(target.kind + '/' + (target.name || '?'), 18) + ' · ' + fit(target.message, 46),
216
+ )
217
+ if (relapse.errors.length > 1) {
218
+ console.log(' ' + '关联错误 ' + relapse.errors.length + ' 条(其余见 JSON)')
219
+ }
220
+ }
221
+ }
222
+
223
+ const ranked = [...records].sort((a, b) => b.count - a.count).slice(0, topN)
224
+ const widest = ranked.reduce((max, r) => Math.max(max, String(r.count).length), 1)
225
+ console.log('')
226
+ console.log(' TOP FAILURES')
227
+ for (const r of ranked) {
228
+ const bar = '█'.repeat(Math.max(1, Math.round((r.count / ranked[0].count) * 12)))
229
+ console.log(
230
+ ' ' + String(r.count).padStart(widest) + ' ' + bar.padEnd(12) +
231
+ ' ' + fit(r.kind + '/' + (r.name || '?'), 16) + ' ' + fit(r.message, 58),
232
+ )
233
+ }
234
+
235
+ const recent = [...retros].sort((a, b) => b.at - a.at).slice(0, topN)
236
+ if (recent.length) {
237
+ console.log('')
238
+ console.log(' RECENT RETROSPECTIVES')
239
+ for (const r of recent) {
240
+ const linked = Array.isArray(r.errorIds) ? r.errorIds.length : Array.isArray(r.errors) ? r.errors.length : 0
241
+ console.log(
242
+ ' ' + stamp(r.at) + ' ' + (r.auto ? 'auto ' : 'model') + ' ' +
243
+ fit(r.title, 44) + ' ' + (linked ? linked + ' linked' : '—') +
244
+ (staleIds.has(r.id) ? ' ⚠ 失效' : ''),
245
+ )
246
+ }
247
+ }
248
+ console.log('')
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ // atlas-retro — write a retrospective into the error atlas by hand.
3
+ //
4
+ // The atlas_retro tool only exists inside a session that was started after the plugin
5
+ // loaded; this CLI is the same thing from the outside, so a retrospective can be written
6
+ // (or an automatic skeleton upgraded) at any time.
7
+ //
8
+ // node bin/atlas-retro.mjs --list
9
+ // node bin/atlas-retro.mjs --upgrade r7 --cause "..." --lesson "..."
10
+ // node bin/atlas-retro.mjs --new --title "..." --lesson "..." --done "..."
11
+ // node bin/atlas-retro.mjs --fields retros.json # one object or an array, UTF-8 safe
12
+ //
13
+ // --fields exists because a Chinese --cause on a Windows shell is mangled long before it
14
+ // reaches node: put the text in a JSON file and the bytes survive.
15
+ //
16
+ // It posts to the running host when possible (the host owns the store, so writing the
17
+ // JSON file underneath it would be lost on the next save) and falls back to the file
18
+ // with a warning when the host is not answering.
19
+
20
+ import { readFileSync, writeFileSync, renameSync, existsSync } from 'node:fs'
21
+ import { join, dirname } from 'node:path'
22
+ import { homedir } from 'node:os'
23
+
24
+ const HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
25
+ const STORE_DIR = join(HOME, 'error-atlas')
26
+ const STORE = join(STORE_DIR, 'records.json')
27
+ const ENDPOINT = join(STORE_DIR, 'endpoint.json')
28
+
29
+ const args = process.argv.slice(2)
30
+ const flag = (name) => {
31
+ const index = args.indexOf('--' + name)
32
+ if (index < 0) return undefined
33
+ const next = args[index + 1]
34
+ return next && !next.startsWith('--') ? next : ''
35
+ }
36
+ const has = (name) => args.includes('--' + name)
37
+
38
+ function readJson(path) {
39
+ try {
40
+ // PowerShell's Set-Content can leave a BOM, and JSON.parse dies on it.
41
+ return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ function storeOf() {
48
+ const data = readJson(STORE)
49
+ if (!data || !Array.isArray(data.retros)) return null
50
+ return data
51
+ }
52
+
53
+ function stamp(ms) {
54
+ const d = new Date(ms)
55
+ const p = (n) => String(n).padStart(2, '0')
56
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
57
+ }
58
+
59
+ function list() {
60
+ const data = storeOf()
61
+ if (!data) {
62
+ console.log('no store at ' + STORE)
63
+ return 1
64
+ }
65
+ const retros = data.retros
66
+ console.log(`${retros.length} retrospective(s) · ${STORE}`)
67
+ for (const retro of retros) {
68
+ const mode = retro.auto ? 'auto ' : 'model'
69
+ const linked = Array.isArray(retro.errorIds) && retro.errorIds.length ? ` · ${retro.errorIds.length} linked` : ''
70
+ const title = (retro.title || '(untitled)').slice(0, 52)
71
+ console.log(` ${retro.id.padEnd(4)} ${mode} ${stamp(retro.at)} ${title}${linked}`)
72
+ }
73
+ return 0
74
+ }
75
+
76
+ function bodyFrom(args2) {
77
+ const body = {}
78
+ for (const key of ['title', 'done', 'cause', 'fix', 'lesson']) {
79
+ const value = flag(key)
80
+ if (typeof value === 'string' && value.trim()) body[key] = value
81
+ }
82
+ const tags = flag('tags')
83
+ if (tags) body.tags = tags.split(',').map((s) => s.trim()).filter(Boolean)
84
+ const ids = flag('ids') ?? flag('errorIds')
85
+ if (ids) body.errorIds = ids.split(',').map((s) => s.trim()).filter(Boolean)
86
+ const tools = flag('tools')
87
+ if (tools) body.tools = tools.split(',').map((s) => s.trim()).filter(Boolean)
88
+ const files = flag('files')
89
+ if (files) body.files = files.split(',').map((s) => s.trim()).filter(Boolean)
90
+ return body
91
+ }
92
+
93
+ // Merge into one stored record without the host: used only when the host is unavailable.
94
+ function patchFile(id, body) {
95
+ const data = storeOf()
96
+ if (!data) return { ok: false, error: 'no store at ' + STORE }
97
+ const retro = data.retros.find((item) => item.id === id)
98
+ if (!retro) return { ok: false, error: 'no retrospective ' + id }
99
+ retro.auto = false
100
+ for (const key of ['title', 'done', 'cause', 'fix', 'lesson']) {
101
+ if (typeof body[key] === 'string') retro[key] = body[key].slice(0, 420)
102
+ }
103
+ if (Array.isArray(body.tags)) retro.tags = body.tags.slice(0, 8)
104
+ if (Array.isArray(body.errorIds)) retro.errorIds = body.errorIds.slice(0, 12)
105
+ if (Array.isArray(body.tools)) retro.tools = body.tools.slice(0, 12)
106
+ if (Array.isArray(body.files)) retro.files = body.files.slice(0, 12)
107
+ data.updatedAt = Date.now()
108
+ const tmp = join(dirname(STORE), 'records.json.tmp')
109
+ writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8')
110
+ renameSync(tmp, STORE)
111
+ return { ok: true, id: retro.id, at: retro.at, via: 'file' }
112
+ }
113
+
114
+ async function post(body) {
115
+ const endpoint = readJson(ENDPOINT)
116
+ const port = Number(flag('port')) || (endpoint && Number(endpoint.port)) || 0
117
+ if (!port) return { ok: false, error: 'no endpoint.json yet — restart DSH once so the host publishes its port' }
118
+ try {
119
+ const response = await fetch(`http://127.0.0.1:${port}/error-atlas/retro`, {
120
+ method: 'POST',
121
+ headers: { 'content-type': 'application/json' },
122
+ body: JSON.stringify(body),
123
+ signal: AbortSignal.timeout(4000),
124
+ })
125
+ const text = await response.text()
126
+ let parsed = null
127
+ try {
128
+ parsed = JSON.parse(text)
129
+ } catch {
130
+ parsed = null
131
+ }
132
+ if (response.status === 404) {
133
+ return { ok: false, error: 'the running host has no /error-atlas/retro route yet — restart DSH' }
134
+ }
135
+ if (!response.ok || !parsed || parsed.ok !== true) {
136
+ return { ok: false, error: (parsed && parsed.error) || text.slice(0, 200) || 'HTTP ' + response.status }
137
+ }
138
+ return { ok: true, id: parsed.id, at: parsed.at, via: 'host' }
139
+ } catch (error) {
140
+ return { ok: false, error: String((error && error.message) || error) }
141
+ }
142
+ }
143
+
144
+ const USAGE = `atlas-retro — write a retrospective into the error atlas by hand.
145
+
146
+ node bin/atlas-retro.mjs --list
147
+ node bin/atlas-retro.mjs --upgrade r7 --cause "..." --lesson "..."
148
+ node bin/atlas-retro.mjs --new --title "..." --lesson "..." [--done ...] [--fix ...]
149
+ [--tags a,b] [--ids e1,e2] [--tools edit,pwsh] [--files x.js]
150
+ node bin/atlas-retro.mjs --port 65203 ... # override the published host port
151
+ node bin/atlas-retro.mjs --fields retros.json # one record or an array of records
152
+
153
+ Fields: title, done, cause, fix, lesson (text); tags (a,b); ids / errorIds (e1,e2).
154
+ A --fields record needs "id" to upgrade an existing one, or title+lesson to create one.`
155
+
156
+ async function main() {
157
+ if (has('help') || args.length === 0) {
158
+ console.log(USAGE)
159
+ return 0
160
+ }
161
+ if (has('list')) return list()
162
+
163
+ const fieldsPath = flag('fields')
164
+ let batch
165
+ if (fieldsPath) {
166
+ const parsed = readJson(fieldsPath)
167
+ if (parsed === null || typeof parsed !== 'object') {
168
+ console.error('cannot read a JSON object/array from ' + fieldsPath)
169
+ return 2
170
+ }
171
+ batch = Array.isArray(parsed) ? parsed : [parsed]
172
+ if (batch.length === 0) {
173
+ console.error('fields file holds no records: ' + fieldsPath)
174
+ return 2
175
+ }
176
+ } else {
177
+ const upgrade = flag('upgrade')
178
+ const body = bodyFrom(args)
179
+ if (upgrade) body.id = upgrade
180
+ if (!upgrade) {
181
+ if (!body.title || !body.lesson) {
182
+ console.error('need --title and --lesson (or --upgrade <id>)')
183
+ return 2
184
+ }
185
+ } else if (Object.keys(body).length === 1) {
186
+ console.error('--upgrade needs at least one field to write (--cause/--lesson/--done/--fix/--tags)')
187
+ return 2
188
+ }
189
+ batch = [body]
190
+ }
191
+
192
+ let failures = 0
193
+ for (const body of batch) {
194
+ const upgrade = typeof body.id === 'string' && body.id ? body.id : ''
195
+ if (!upgrade && (!body.title || !body.lesson)) {
196
+ console.error('skipped a record without id or title+lesson: ' + JSON.stringify(body).slice(0, 90))
197
+ failures++
198
+ continue
199
+ }
200
+ const result = await post(body)
201
+ if (result.ok) {
202
+ console.log(`ok ${result.id} ${stamp(result.at)} via ${result.via}`)
203
+ continue
204
+ }
205
+ const reason = result.error || 'host unavailable'
206
+ if (upgrade) {
207
+ const patched = patchFile(upgrade, body)
208
+ if (patched.ok) {
209
+ console.error(`! host not reachable (${reason}) — wrote the store file directly.`)
210
+ console.error('! the running host may overwrite that file on its next save: restart DSH to be safe.')
211
+ console.log(`ok ${patched.id} ${stamp(patched.at)} via ${patched.via}`)
212
+ continue
213
+ }
214
+ console.error(`failed ${upgrade}: ${reason} / ${patched.error}`)
215
+ failures++
216
+ continue
217
+ }
218
+ console.error('failed: ' + reason)
219
+ console.error('a brand-new retrospective needs the host: restart DSH so it publishes endpoint.json.')
220
+ failures++
221
+ }
222
+ return failures ? 1 : 0
223
+ }
224
+
225
+ process.exitCode = await main()