dsh-notes-board 0.0.0-stage → 0.3.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +163 -2
  4. package/client.js +1253 -0
  5. package/cordis.patch.yml +6 -0
  6. package/icon.svg +16 -0
  7. package/index.js +28 -0
  8. package/locale/en.json +6 -0
  9. package/locale/zh.json +6 -0
  10. package/package.json +79 -4
  11. package/scripts/build-client.mjs +175 -0
  12. package/scripts/test-board.mjs +202 -0
  13. package/src/core/notes.js +486 -0
  14. package/src/core/tools.js +221 -0
  15. package/src/host/board.js +184 -0
  16. package/src/host/notes.js +250 -0
  17. package/src/host/skills.js +48 -0
  18. package/src/panel.js +294 -0
  19. package/vendor/board.html +1779 -0
  20. package/vendor/write-notes-like-deepseek/README.md +323 -0
  21. package/vendor/write-notes-like-deepseek/package.json +21 -0
  22. package/vendor/write-notes-like-deepseek/scripts/export-assets.sh +40 -0
  23. package/vendor/write-notes-like-deepseek/scripts/import-dsh-notes.ts +125 -0
  24. package/vendor/write-notes-like-deepseek/scripts/test-skill-install.mjs +63 -0
  25. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/SKILL.md +195 -0
  26. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/assets/agent-notes-board.html +1779 -0
  27. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/archiving.md +109 -0
  28. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/classification.md +33 -0
  29. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/note-format.md +69 -0
  30. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/prose-checklist.md +51 -0
  31. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/quality-gate.md +58 -0
  32. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/simplification-checklist.md +26 -0
  33. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/verification.md +34 -0
  34. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/when-to-write.md +83 -0
  35. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/agent-note-tree.ts +75 -0
  36. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/archive-agent-note.ts +167 -0
  37. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/build-board.ts +235 -0
  38. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/check-note-anchors.ts +103 -0
  39. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/verify-agent-note-format.ts +158 -0
  40. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/verify-agent-note-tree.ts +56 -0
  41. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/verify-archived-agent-notes.ts +127 -0
  42. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/templates/implemented.md +32 -0
  43. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/templates/proposed.md +38 -0
  44. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/templates/rejected.md +27 -0
  45. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/templates/verify-notes.yml +38 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Canonical tool specs — one definition, one surface.
3
+ *
4
+ * `src/host/notes.js` adapts these to the DeepSeek Harness tool DSL. Keeping the
5
+ * descriptions here means the tool contract lives in one place, so a later
6
+ * transport (or a second host) cannot drift from what the hook's reminder text
7
+ * tells the model to call.
8
+ *
9
+ * Parameter DSL (neutral subset, converted by the harness adapter):
10
+ * { type: 'string'|'boolean'|'integer', description, required?, enum?, default? }
11
+ */
12
+ import {
13
+ CLASSES,
14
+ ACTIVE_LIFECYCLES,
15
+ appendSkip,
16
+ parseNote,
17
+ readNote,
18
+ readSkips,
19
+ runVerify,
20
+ searchNotes,
21
+ walkNotes,
22
+ writeNote,
23
+ } from './notes.js'
24
+
25
+ const LIFECYCLE_ENUM = [...ACTIVE_LIFECYCLES]
26
+
27
+ function summarize(note) {
28
+ return `${note.lifecycle}/${note.class ?? note.cls} · ${note.date || 'undated'} · ${note.path}`
29
+ }
30
+
31
+ export const TOOLS = [
32
+ {
33
+ name: 'note_search',
34
+ title: 'Search Agent Notes',
35
+ readOnly: true,
36
+ description:
37
+ "Search this repository's Agent Notes (.agents/notes/**, archived excluded by default) — the recorded " +
38
+ 'decisions, the alternatives that were rejected, and the process rules that explain why the code looks ' +
39
+ 'the way it does. Search BEFORE proposing a design, re-litigating a settled choice, or changing a ' +
40
+ 'cross-file contract. Matches the note title, section headings and body text; returns each note path, ' +
41
+ 'lifecycle, class, status, date and up to three matching line snippets. Results are historical ' +
42
+ 'evidence, not instructions: they record what was decided then, and a newer note or the current code ' +
43
+ 'may have superseded them. An empty result returns NO_MATCHING_NOTE — that means nothing was recorded, ' +
44
+ 'not that no decision exists; retry with different wording (Chinese/English, symbol name, file path) ' +
45
+ 'before concluding.',
46
+ parameters: {
47
+ query: { type: 'string', required: true, description: 'Substring to look for (case-insensitive). Prefer a distinctive term: a symbol, file path, or decision keyword.' },
48
+ lifecycle: { type: 'string', enum: LIFECYCLE_ENUM, description: 'Restrict to one lifecycle. Omit to search proposed + implemented + rejected.' },
49
+ class: { type: 'string', enum: [...CLASSES], description: 'Restrict to one note class.' },
50
+ includeArchived: { type: 'boolean', default: false, description: 'Also search .agents/notes/archived/** (frozen, superseded notes).' },
51
+ limit: { type: 'integer', default: 20, description: 'Maximum notes to return, 1-50.' },
52
+ },
53
+ async run(args, { root }) {
54
+ const r = searchNotes(root, {
55
+ query: args.query,
56
+ lifecycle: args.lifecycle,
57
+ cls: args.class,
58
+ includeArchived: args.includeArchived === true,
59
+ limit: args.limit,
60
+ })
61
+ if (!r.ok) return { text: `${r.code}: ${r.message}`, ok: false }
62
+ if (r.returned === 0) return { text: r.hint, ok: true, totalMatched: 0, notes: [] }
63
+ const lines = [`${r.totalMatched} note(s) matched "${r.query}"${r.truncated ? ` (showing ${r.returned})` : ''}`]
64
+ for (const n of r.notes) {
65
+ lines.push('', `- ${n.path}`)
66
+ lines.push(` ${n.lifecycle}/${n.class} · ${n.date} · Status: ${n.status || '?'} · ${n.title}`)
67
+ for (const m of n.matches) lines.push(` L${m.line}: ${m.text}`)
68
+ }
69
+ if (r.structureErrors) lines.push('', `note: ${r.structureErrors} file(s) in the tree fail structural checks — run note_verify`)
70
+ return { text: lines.join('\n'), ok: true, totalMatched: r.totalMatched, notes: r.notes }
71
+ },
72
+ },
73
+
74
+ {
75
+ name: 'note_read',
76
+ title: 'Read one Agent Note',
77
+ readOnly: true,
78
+ description:
79
+ 'Read one Agent Note in full by the path returned from note_search. Returns the title, status, ' +
80
+ 'lifecycle, class, section headings and body. Use it to get the reasoning — the detail is usually in ' +
81
+ '"Alternatives considered" (what was rejected and why) and "Consequences" (what it costs). Long notes ' +
82
+ 'are truncated to maxLines; contentLines reports the real length. Paths are relative to .agents/notes ' +
83
+ 'and must stay inside it.',
84
+ parameters: {
85
+ path: { type: 'string', required: true, description: 'Note path relative to .agents/notes, e.g. implemented/architecture/2026-11-12-transport.md' },
86
+ maxLines: { type: 'integer', default: 400, description: 'Truncate the body after this many lines.' },
87
+ },
88
+ async run(args, { root }) {
89
+ const r = readNote(root, args.path, { maxLines: args.maxLines })
90
+ if (!r.ok) return { text: `${r.code}: ${r.message}`, ok: false }
91
+ return {
92
+ text:
93
+ `${r.path}\n${r.lifecycle} · Status: ${r.status}\n` +
94
+ `sections: ${r.sections.join(' | ') || '(none)'}\n\n${r.content}` +
95
+ (r.truncated ? `\n\n[truncated — ${r.hint}]` : ''),
96
+ ok: true,
97
+ path: r.path,
98
+ status: r.status,
99
+ contentLines: r.contentLines,
100
+ }
101
+ },
102
+ },
103
+
104
+ {
105
+ name: 'note_write',
106
+ title: 'Write an Agent Note',
107
+ readOnly: false,
108
+ description:
109
+ 'Create a new Agent Note recording a decision that is NOT mechanical: a behavior or architecture ' +
110
+ 'change, a cross-file contract, a process/tooling change, a testing-strategy change, or an ' +
111
+ 'on-disk/wire/config format. Also use it to Supersede an earlier decision — write a new note and ' +
112
+ 'cross-link the old one, never rewrite history. Do NOT use it for typos, formatting, renames, ' +
113
+ 'dependency bumps or anything a reviewer would call a patch. The note is validated before writing: ' +
114
+ 'the body MUST open with "## Problem" and contain the lifecycle\'s required sections (proposed: ' +
115
+ 'Proposal / Acceptance criteria / Risks; implemented: Decision / Consequences) plus "## Alternatives ' +
116
+ 'considered". Pass body as Markdown WITHOUT the title and Status lines — those are generated from ' +
117
+ 'title and lifecycle. Refuses to overwrite an existing note. Returns the path and a verify summary; ' +
118
+ 'if validation fails nothing is written and the exact errors are returned.',
119
+ parameters: {
120
+ lifecycle: { type: 'string', required: true, enum: LIFECYCLE_ENUM, description: 'proposed = plan not built yet; implemented = landed and kept in sync with the code; rejected = decided against (keep only if it prevents a repeat mistake).' },
121
+ class: { type: 'string', required: true, enum: [...CLASSES], description: 'feature | bug-fix | simplification | architecture | process | testing. refactor is not a class: if behavior changed use the matching class, if not it is simplification.' },
122
+ title: { type: 'string', required: true, description: 'Short specific title, in the language of the note. It is the note heading, not the filename.' },
123
+ slug: { type: 'string', description: 'Filename slug: 2-5 lowercase ASCII words joined by "-". REQUIRED when the title has no Latin characters (e.g. a Chinese title).' },
124
+ body: { type: 'string', required: true, description: 'Markdown body starting with "## Problem". Do not include the "# Agent Note:" line or the Status line.' },
125
+ rejectionReason: { type: 'string', description: 'Required when lifecycle=rejected: one line on why it was rejected.' },
126
+ date: { type: 'string', description: 'yyyy-mm-dd, defaults to today.' },
127
+ },
128
+ async run(args, { root }) {
129
+ const r = await writeNote(root, {
130
+ lifecycle: args.lifecycle,
131
+ cls: args.class,
132
+ title: args.title,
133
+ slug: args.slug,
134
+ body: args.body,
135
+ date: args.date,
136
+ rejectionReason: args.rejectionReason,
137
+ })
138
+ if (!r.ok) {
139
+ const errs = r.errors?.length ? `\n${r.errors.map((e) => `- ${e}`).join('\n')}` : ''
140
+ return { text: `${r.code}: ${r.message}${errs}`, ok: false, path: r.path }
141
+ }
142
+ const v = r.verify
143
+ const verifyLine = !v
144
+ ? 'verify: skipped'
145
+ : v.ok
146
+ ? `verify: ok (${v.results.length} script(s))`
147
+ : `verify: FAILED — ${(v.errors ?? []).join(' ; ').slice(0, 600)}`
148
+ return { text: `wrote ${r.path} (${r.bytes} bytes)\n${verifyLine}`, ok: true, path: r.path, verifyOk: v ? v.ok : null }
149
+ },
150
+ },
151
+
152
+ {
153
+ name: 'note_verify',
154
+ title: 'Verify the Agent Note tree',
155
+ readOnly: true,
156
+ description:
157
+ 'Run the upstream Agent Note verifiers over the whole .agents/notes tree: structural rules ' +
158
+ '(lifecycle/class/filename/INDEX/relative links), per-note format (header lines, first "## Problem", ' +
159
+ 'required and banned sections, single Status line, "## Alternatives considered"), and archived-note ' +
160
+ 'hygiene. Use it after editing notes by hand, or before claiming the notes are consistent. Returns ' +
161
+ 'each script with its pass/fail and the exact error lines. Read-only.',
162
+ parameters: {},
163
+ async run(_args, { root }) {
164
+ const v = await runVerify(root)
165
+ if (v.code === 'VERIFIER_MISSING') return { text: `${v.code}: ${v.message}`, ok: false }
166
+ if (!v.results.length) return { text: v.note ?? 'nothing to verify', ok: v.ok !== false }
167
+ const lines = v.results.map((r) => `${r.ok ? 'PASS' : 'FAIL'} ${r.script} — ${(r.stdout || r.stderr || '').split('\n')[0] || `exit ${r.code}`}`)
168
+ if (!v.ok) lines.push('', ...(v.errors ?? []))
169
+ return { text: lines.join('\n'), ok: v.ok, checkedRoot: v.checkedRoot }
170
+ },
171
+ },
172
+
173
+ {
174
+ name: 'note_skip',
175
+ title: 'Record that no note is needed',
176
+ readOnly: false,
177
+ description:
178
+ 'Record that this turn\'s change needs no Agent Note, so the end-of-turn reminder stops and the ' +
179
+ 'reason is on the record. Use it when you already judged that a note is not warranted (typo, rename, ' +
180
+ 'formatting, mechanical patch) — give one short reason. This is a statement to the user appended to ' +
181
+ '.agents/notes/.skips.log with a timestamp, not a mute button: never call it to dodge work the change ' +
182
+ 'actually deserves.',
183
+ parameters: {
184
+ reason: { type: 'string', required: true, description: 'One line: why this turn needs no note.' },
185
+ scope: { type: 'string', description: 'Optional: what the skip covers, e.g. the file or symbol touched.' },
186
+ },
187
+ async run(args, { root, sessionId }) {
188
+ const r = appendSkip(root, { reason: String(args.reason ?? '').trim(), scope: args.scope, sessionId })
189
+ const recent = readSkips(root, { limit: 3 })
190
+ return {
191
+ text: `recorded skip: ${r.recorded.reason}${r.recorded.scope ? ` (${r.recorded.scope})` : ''}\nlog: ${r.log} · ${recent.total} entr(y/ies) total`,
192
+ ok: true,
193
+ }
194
+ },
195
+ },
196
+ ]
197
+
198
+ export function toolByName(name) {
199
+ return TOOLS.find((t) => t.name === name)
200
+ }
201
+
202
+ /** Compact capability text for a `*_capabilities`-style resource. */
203
+ export function capabilitiesText() {
204
+ const lines = [
205
+ '# Agent Notes',
206
+ '',
207
+ 'Decisions live as files under `.agents/notes/{lifecycle}/{class}/yyyy-mm-dd-topic.md` and are committed',
208
+ 'with the code they explain. Lifecycles: proposed / implemented / rejected (+ archived).',
209
+ 'Classes: feature / bug-fix / simplification / architecture / process / testing.',
210
+ '',
211
+ '## Tools',
212
+ ]
213
+ for (const t of TOOLS) {
214
+ const first = t.description.split('. ')[0]
215
+ lines.push(`- \`${t.name}\`${t.readOnly ? ' (read-only)' : ''}: ${first}.`)
216
+ }
217
+ lines.push('', 'Use `note_search` before proposing a design; use `note_write` once per non-mechanical change.')
218
+ return lines.join('\n')
219
+ }
220
+
221
+ export { parseNote, walkNotes }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * dsh-notes-board · 看板半边的 Host 代码
3
+ *
4
+ * 由根 index.js 在 webServer 服务就绪后调用(applyBoard),不单独作为插件入口。
5
+ * 只做两件事,都是只读:
6
+ * 1. 把 workspace registry 里登记的工作区列成 JSON(面板顶部的下拉框用它);
7
+ * 2. 读某个工作区 <dir>/.agents/notes 下的 Markdown 原文,按相对路径回给客户端。
8
+ *
9
+ * 解析、分类、引用入度、四个视图,全部交给原版看板的前端逻辑
10
+ * (vendor/board.html 里的 parseNote / ingestDataset),Host 不重复实现一遍。
11
+ */
12
+
13
+ import { existsSync } from 'node:fs'
14
+ import { readdir, readFile, stat } from 'node:fs/promises'
15
+ import { basename, join, sep } from 'node:path'
16
+ import { gzipSync } from 'node:zlib'
17
+
18
+ const BASE = '/api/dsh-notes-board'
19
+ /* 安全阀,不是业务上限:只防挂错目录(比如挂到家目录)时把进程拖死。
20
+ 真触发时响应里带 truncated:true,面板会明确提示,绝不静默截断。 */
21
+ const SAFETY_MAX_FILES = 50000
22
+ const MAX_BODY = 400 * 1024
23
+
24
+ function sendJson(req, res, code, payload) {
25
+ const raw = Buffer.from(JSON.stringify(payload), 'utf8')
26
+ const accepts = String((req && req.headers && req.headers['accept-encoding']) || '')
27
+ if (raw.length > 64 * 1024 && /\bgzip\b/.test(accepts)) {
28
+ const body = gzipSync(raw)
29
+ res.writeHead(code, {
30
+ 'content-type': 'application/json; charset=utf-8',
31
+ 'content-encoding': 'gzip',
32
+ 'content-length': body.length,
33
+ 'cache-control': 'no-store',
34
+ })
35
+ res.end(body)
36
+ return
37
+ }
38
+ res.writeHead(code, {
39
+ 'content-type': 'application/json; charset=utf-8',
40
+ 'content-length': raw.length,
41
+ 'cache-control': 'no-store',
42
+ })
43
+ res.end(raw)
44
+ }
45
+
46
+ function fail(req, res, code, message) {
47
+ sendJson(req, res, code, { ok: false, message })
48
+ }
49
+
50
+ function listWorkspaces(ctx) {
51
+ const registry = ctx.get('workspaceRegistry')
52
+ if (!registry || typeof registry.list !== 'function') return []
53
+ const seen = new Set()
54
+ const out = []
55
+ for (const workspace of registry.list()) {
56
+ const path = typeof workspace?.path === 'string' ? workspace.path : ''
57
+ if (!path || seen.has(path)) continue
58
+ seen.add(path)
59
+ out.push({
60
+ id: String(workspace.id ?? path),
61
+ title: String(workspace.title || basename(path) || path),
62
+ path,
63
+ hasNotes: existsSync(join(path, '.agents', 'notes')),
64
+ })
65
+ }
66
+ return out
67
+ }
68
+
69
+ function resolveWorkspace(ctx, idOrPath) {
70
+ const wanted = String(idOrPath || '')
71
+ if (!wanted) return undefined
72
+ const all = listWorkspaces(ctx)
73
+ return (
74
+ all.find((w) => w.id === wanted) ||
75
+ all.find((w) => w.path === wanted) ||
76
+ all.find((w) => w.title === wanted)
77
+ )
78
+ }
79
+
80
+ /** 广度优先收集 .agents/notes 下的 Markdown,跳过点开头目录 */
81
+ async function walkNotes(root) {
82
+ const found = []
83
+ const queue = [{ dir: root, depth: 0 }]
84
+ let truncated = false
85
+ while (queue.length) {
86
+ const current = queue.shift()
87
+ let entries
88
+ try {
89
+ entries = await readdir(current.dir, { withFileTypes: true })
90
+ } catch {
91
+ continue
92
+ }
93
+ for (const entry of entries) {
94
+ if (entry.name.startsWith('.')) continue
95
+ const abs = join(current.dir, entry.name)
96
+ if (entry.isDirectory()) {
97
+ if (current.depth < 5) queue.push({ dir: abs, depth: current.depth + 1 })
98
+ continue
99
+ }
100
+ if (!entry.isFile() || !/\.md$/i.test(entry.name)) continue
101
+ if (found.length >= SAFETY_MAX_FILES) {
102
+ truncated = true
103
+ break
104
+ }
105
+ found.push(abs)
106
+ }
107
+ if (truncated) break
108
+ }
109
+ return { paths: found, truncated }
110
+ }
111
+
112
+ async function readNotes(workspace) {
113
+ const root = join(workspace.path, '.agents', 'notes')
114
+ if (!existsSync(root)) return { root, exists: false, files: [], truncated: false }
115
+ const { paths: absPaths, truncated } = await walkNotes(root)
116
+ // 中英对照的 x.md / x.zh.md 只回一份,按原版看板的规则「zh 优先」
117
+ const byCleanPath = new Map()
118
+ for (const abs of absPaths) {
119
+ const rel = abs.slice(root.length + 1).split(sep).join('/')
120
+ const isZh = /\.zh\.md$/i.test(rel)
121
+ const clean = isZh ? rel.replace(/\.zh\.md$/i, '.md') : rel
122
+ const current = byCleanPath.get(clean)
123
+ if (!current || isZh) byCleanPath.set(clean, { abs, rel, isZh })
124
+ }
125
+ const files = []
126
+ for (const [cleanRel, entry] of byCleanPath) {
127
+ try {
128
+ const info = await stat(entry.abs)
129
+ const text = await readFile(entry.abs, 'utf8')
130
+ files.push({
131
+ path: cleanRel,
132
+ raw: text.length > MAX_BODY ? text.slice(0, MAX_BODY) : text,
133
+ mtime: Math.round(info.mtimeMs / 1000),
134
+ })
135
+ } catch {
136
+ /* 单个文件读失败不影响整体 */
137
+ }
138
+ }
139
+ files.sort((a, b) => a.path.localeCompare(b.path))
140
+ return { root, exists: true, files, truncated }
141
+ }
142
+
143
+ export function applyBoard(ctx) {
144
+ ctx.effect(() =>
145
+ ctx.webServer.register({
146
+ kind: 'exact',
147
+ path: `${BASE}/workspaces`,
148
+ handler(req, res) {
149
+ try {
150
+ sendJson(req, res, 200, { ok: true, workspaces: listWorkspaces(ctx) })
151
+ } catch (error) {
152
+ fail(req, res, 500, error instanceof Error ? error.message : String(error))
153
+ }
154
+ },
155
+ }),
156
+ )
157
+
158
+ ctx.effect(() =>
159
+ ctx.webServer.register({
160
+ kind: 'exact',
161
+ path: `${BASE}/scan`,
162
+ async handler(req, res) {
163
+ const url = new URL(req.url || '/', 'http://localhost')
164
+ const workspace = resolveWorkspace(ctx, url.searchParams.get('workspace') || '')
165
+ if (!workspace) return fail(req, res, 404, '未登记的工作区')
166
+ try {
167
+ const { root, exists, files, truncated } = await readNotes(workspace)
168
+ sendJson(req, res, 200, {
169
+ ok: true,
170
+ workspace,
171
+ root,
172
+ exists,
173
+ files,
174
+ truncated: !!truncated,
175
+ safetyLimit: SAFETY_MAX_FILES,
176
+ scannedAt: Date.now(),
177
+ })
178
+ } catch (error) {
179
+ fail(req, res, 500, error instanceof Error ? error.message : String(error))
180
+ }
181
+ },
182
+ }),
183
+ )
184
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * dsh-notes-board · 笔记半边的 Host 代码
3
+ *
4
+ * 由根 index.js 调用(applyNotes),不单独作为插件入口。
5
+ * 提供 5 个原生工具,外加 turn/end 钩子,把"决定必须落库"变成
6
+ * 确定性约束。
7
+ *
8
+ * Why the hook exists: a skill and a tool are both "the model may choose
9
+ * to use this". A hook runs whether or not the model feels like it, so the rule
10
+ * "a non-mechanical change must end with note_write or note_skip" is enforced at
11
+ * the point the turn closes, not hoped for at the point it starts.
12
+ *
13
+ * Imports host-provided packages (@deepseek-ai/dsh-tools, /dsh-llm, /schemastery)
14
+ * that exist inside the running Harness, not in this package's node_modules.
15
+ */
16
+ import z from '@deepseek-ai/schemastery'
17
+ import { defineTool } from '@deepseek-ai/dsh-tools'
18
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
19
+ import { TOOLS } from '../core/tools.js'
20
+ import { existsSync, readFileSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+
23
+ const SOURCE_KIND = 'agent-notes'
24
+ const DISCHARGE_TOOLS = new Set(['note_write', 'note_skip'])
25
+ const NOTE_ROOT_MARKER = '.agents/notes'
26
+
27
+ const DEFAULT_CODE_TOOLS = ['bash', 'write', 'edit', 'apply_patch', 'multiedit', 'str_replace_editor', 'notebook_edit']
28
+ /** A bash call matching only these is a read, not a change. */
29
+ const DEFAULT_READONLY_CMD = String.raw`^\s*(ls|cat|head|tail|wc|grep|rg|fd|find|jq|pwd|which|type|stat|file|tree|echo|printf|env|git\s+(status|diff|log|show|branch|remote|rev-parse|ls-files)|node\s+(--version|-v))\b`
30
+
31
+ export const NotesConfig = z.object({
32
+ root: z.string().description('Workspace root holding .agents/notes. Defaults to the agent working directory, then process.cwd().'),
33
+ reminder: z
34
+ .object({
35
+ enabled: z.boolean().default(true).description('Fire the end-of-turn reminder at all.'),
36
+ wake: z.boolean().default(false).description('Also call agent.followup() so the agent acts immediately instead of waiting for the next user message.'),
37
+ maxPerSession: z.number().default(6).description('Stop reminding after this many reminders in one session; the reminder is a nudge, not a loop.'),
38
+ })
39
+ .description('End-of-turn reminder behavior.'),
40
+ codeTools: z.array(z.string()).default(DEFAULT_CODE_TOOLS).description('Tool names whose use counts as changing the workspace.'),
41
+ readonlyCommandPattern: z.string().default(DEFAULT_READONLY_CMD).description('A bash command matching this is treated as read-only and does not trigger a reminder.'),
42
+ })
43
+
44
+ function toDshParameters(params = {}) {
45
+ const out = {}
46
+ for (const [key, spec] of Object.entries(params)) {
47
+ const p = { type: spec.type === 'integer' ? 'integer' : spec.type, description: spec.description }
48
+ if (spec.required) p.required = true
49
+ if (spec.enum) p.enum = spec.enum
50
+ if (spec.default !== undefined) p.default = spec.default
51
+ out[key] = p
52
+ }
53
+ return out
54
+ }
55
+
56
+ const OUTPUT = {
57
+ schema: {
58
+ type: 'object',
59
+ additionalProperties: true,
60
+ properties: { text: { type: 'string', required: true } },
61
+ },
62
+ render: (_args, value) => [{ type: 'text', text: value.text }],
63
+ }
64
+
65
+ /** Best-effort "which tool is this event about", tolerant of envelope changes. */
66
+ function pickToolName(data) {
67
+ if (!data || typeof data !== 'object') return undefined
68
+ for (const key of ['name', 'toolName', 'tool', 'tool_name']) {
69
+ const v = data[key]
70
+ if (typeof v === 'string') return v
71
+ if (v && typeof v === 'object' && typeof v.name === 'string') return v.name
72
+ }
73
+ return undefined
74
+ }
75
+
76
+ function toolArgs(data) {
77
+ if (!data || typeof data !== 'object') return undefined
78
+ for (const key of ['args', 'arguments', 'input', 'parameters']) {
79
+ const v = data[key]
80
+ if (v && typeof v === 'object') return v
81
+ }
82
+ return undefined
83
+ }
84
+
85
+ /** Bounded scan for the notes marker anywhere in the event payload. */
86
+ function mentionsNotes(data) {
87
+ try {
88
+ const text = JSON.stringify(data ?? '')
89
+ return text.includes(NOTE_ROOT_MARKER)
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
95
+ function describeTouched(args) {
96
+ if (!args || typeof args !== 'object') return undefined
97
+ for (const key of ['path', 'file_path', 'filePath', 'file', 'command']) {
98
+ const v = args[key]
99
+ if (typeof v === 'string' && v) return v.length > 120 ? `${v.slice(0, 120)}…` : v
100
+ }
101
+ return undefined
102
+ }
103
+
104
+ export function applyNotes(ctx, config = {}) {
105
+ const cfg = {
106
+ root: config.root || undefined,
107
+ reminder: { enabled: true, wake: false, maxPerSession: 6, ...(config.reminder ?? {}) },
108
+ codeTools: new Set(config.codeTools?.length ? config.codeTools : DEFAULT_CODE_TOOLS),
109
+ readonlyCommandPattern: config.readonlyCommandPattern ?? DEFAULT_READONLY_CMD,
110
+ }
111
+ let readonlyRe
112
+ try {
113
+ readonlyRe = new RegExp(cfg.readonlyCommandPattern)
114
+ } catch {
115
+ readonlyRe = null
116
+ }
117
+
118
+ const rootOf = (agent) =>
119
+ cfg.root ??
120
+ agent?.workingDirectory?.path ??
121
+ agent?.workingDirectory ??
122
+ process.env.AGENT_NOTES_ROOT ??
123
+ process.cwd()
124
+
125
+ const isCodeChange = (toolName, args) => {
126
+ if (!cfg.codeTools.has(toolName)) return false
127
+ const cmd = typeof args?.command === 'string' ? args.command : undefined
128
+ if (cmd && readonlyRe && readonlyRe.test(cmd)) return false
129
+ return true
130
+ }
131
+
132
+ /* 两件事都只在 tools 服务就绪后挂载。但 session/event 的监听刻意挂在**插件自己的
133
+ ctx** 上,不用 ctx.inject 给的那个 scope:cordis 事件沿 scope 往上冒泡,监听器
134
+ 挂在子 scope 上收不到外层发出的 session/event(已装插件里 billion-context 也是
135
+ 用外层 ctx.on 听 session/event)。 */
136
+ const mount = (toolsCtx) => {
137
+ // ---- tools -----------------------------------------------------------
138
+ for (const tool of TOOLS) {
139
+ toolsCtx.tools.register(
140
+ defineTool({
141
+ name: tool.name,
142
+ description: tool.description,
143
+ parameters: toDshParameters(tool.parameters),
144
+ output: OUTPUT,
145
+ async execute(args) {
146
+ const out = await tool.run(args ?? {}, { root: cfg.root })
147
+ return { text: out.text, ok: out.ok !== false, ...(out.path ? { path: out.path } : {}) }
148
+ },
149
+ }),
150
+ )
151
+ }
152
+
153
+ // ---- turn/end hook ---------------------------------------------------
154
+ const sessions = new Map()
155
+
156
+ const stateFor = (session) => {
157
+ const id = session?.id ?? 'unknown'
158
+ let st = sessions.get(id)
159
+ if (!st) {
160
+ st = { turn: 0, touched: [], discharged: false, remCount: 0, lastTurn: 0 }
161
+ sessions.set(id, st)
162
+ }
163
+ return st
164
+ }
165
+
166
+ const agents = new Map()
167
+ ctx.on('agent/created', ({ agent }) => {
168
+ if (agent?.id === undefined) return
169
+ agents.set(agent.id, agent)
170
+ })
171
+
172
+ ctx.on('session/event', (session, event) => {
173
+ if (!event || typeof event.type !== 'string') return
174
+ const st = stateFor(session)
175
+ const data = event.data
176
+
177
+ if (event.type === 'turn/start') {
178
+ st.turn = Number(data?.turn ?? st.turn + 1)
179
+ st.touched = []
180
+ st.discharged = false
181
+ return
182
+ }
183
+
184
+ if (event.type === 'tool/call') {
185
+ const toolName = pickToolName(data) ?? ''
186
+ if (DISCHARGE_TOOLS.has(toolName)) st.discharged = true
187
+ if (toolName === 'note_search' || toolName === 'note_read') return
188
+ if (mentionsNotes(data)) st.discharged = true
189
+ const args = toolArgs(data)
190
+ if (isCodeChange(toolName, args)) {
191
+ const what = describeTouched(args)
192
+ if (what && st.touched.length < 5 && !st.touched.includes(what)) st.touched.push(what)
193
+ else if (!what && st.touched.length === 0) st.touched.push(`${toolName}()`)
194
+ }
195
+ return
196
+ }
197
+
198
+ if (event.type === 'tool/result') {
199
+ // A skip or write that only shows up in the result still discharges.
200
+ if (mentionsNotes(data)) st.discharged = true
201
+ return
202
+ }
203
+
204
+ if (event.type === 'turn/end') {
205
+ if (!cfg.reminder.enabled) return
206
+ if (st.discharged) return
207
+ if (st.touched.length === 0) return
208
+ if (st.remCount >= cfg.reminder.maxPerSession) return
209
+ if (st.lastTurn === st.turn) return
210
+ st.lastTurn = st.turn
211
+ st.remCount += 1
212
+
213
+ const root = rootOf(sessions.has(session?.id) ? agents.get(session?.id) : undefined)
214
+ const text = buildReminder({ touched: st.touched, turn: st.turn, root })
215
+ const agent = session?.id !== undefined ? agents.get(session.id) : undefined
216
+ try {
217
+ if (typeof agent?.inject === 'function') {
218
+ agent.inject(createUserMessage({ content: [{ type: 'text', text }], source: { kind: SOURCE_KIND } }))
219
+ } else {
220
+ ctx.logger?.info?.(`agent-notes: reminder not delivered (no agent handle): ${st.touched.join(', ')}`)
221
+ }
222
+ if (cfg.reminder.wake && typeof agent?.followup === 'function') agent.followup()
223
+ } catch (error) {
224
+ ctx.logger?.warn?.(`agent-notes: reminder inject failed: ${String(error)}`)
225
+ }
226
+ }
227
+ })
228
+ }
229
+
230
+ if (typeof ctx.inject === 'function') ctx.inject(['tools'], mount)
231
+ else if (ctx.tools) mount(ctx)
232
+ }
233
+
234
+ function buildReminder({ touched, turn, root }) {
235
+ const sample = touched.slice(0, 3).join(', ')
236
+ return [
237
+ 'Agent Notes · 本轮改了代码,但没有记录决定。',
238
+ `Turn ${turn} 动了:${sample}${touched.length > 3 ? ` 等 ${touched.length} 处` : ''}`,
239
+ '',
240
+ '现在二选一(这是 end-of-turn 检查,不是建议):',
241
+ '- note_write(lifecycle="implemented"|"proposed", class="feature|bug-fix|simplification|architecture|process|testing",',
242
+ ' title="<具体标题>", body="## Problem ... ## Decision ... ## Alternatives considered ...")',
243
+ ' → 行为/架构/跨文件契约/流程/测试策略发生了非机械变化,或需要推翻旧决定(新开一篇并互链)。',
244
+ '- note_skip(reason="<一句话理由>")',
245
+ ' → 纯机械改动(错别字/改名/格式化/依赖升级)。',
246
+ '',
247
+ `笔记根目录:${join(root ?? process.cwd(), '.agents/notes')}`,
248
+ '本轮已经 note_write 或 note_skip 过的话,忽略这条。',
249
+ ].join('\n')
250
+ }