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.
- package/LICENSE +21 -0
- package/NOTICE.md +21 -0
- package/README.md +163 -2
- package/client.js +1253 -0
- package/cordis.patch.yml +6 -0
- package/icon.svg +16 -0
- package/index.js +28 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +79 -4
- package/scripts/build-client.mjs +175 -0
- package/scripts/test-board.mjs +202 -0
- package/src/core/notes.js +486 -0
- package/src/core/tools.js +221 -0
- package/src/host/board.js +184 -0
- package/src/host/notes.js +250 -0
- package/src/host/skills.js +48 -0
- package/src/panel.js +294 -0
- package/vendor/board.html +1779 -0
- package/vendor/write-notes-like-deepseek/README.md +323 -0
- package/vendor/write-notes-like-deepseek/package.json +21 -0
- package/vendor/write-notes-like-deepseek/scripts/export-assets.sh +40 -0
- package/vendor/write-notes-like-deepseek/scripts/import-dsh-notes.ts +125 -0
- package/vendor/write-notes-like-deepseek/scripts/test-skill-install.mjs +63 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/SKILL.md +195 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/assets/agent-notes-board.html +1779 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/archiving.md +109 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/classification.md +33 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/note-format.md +69 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/prose-checklist.md +51 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/quality-gate.md +58 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/simplification-checklist.md +26 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/verification.md +34 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/when-to-write.md +83 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/agent-note-tree.ts +75 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/archive-agent-note.ts +167 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/build-board.ts +235 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/check-note-anchors.ts +103 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/verify-agent-note-format.ts +158 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/verify-agent-note-tree.ts +56 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/verify-archived-agent-notes.ts +127 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/templates/implemented.md +32 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/templates/proposed.md +38 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/templates/rejected.md +27 -0
- package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/templates/verify-notes.yml +38 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Note core — the single source of truth behind the plugin's tools, its
|
|
3
|
+
* turn/end hook and the bundled skill's verifiers. Pure Node, no dependencies.
|
|
4
|
+
*
|
|
5
|
+
* Structure contract (mirrors vendor/write-notes-like-deepseek scripts):
|
|
6
|
+
* .agents/notes/{lifecycle}/{class}/yyyy-mm-dd-topic.md
|
|
7
|
+
* lifecycle ∈ proposed | implemented | rejected (+ archived)
|
|
8
|
+
* class ∈ feature | bug-fix | simplification | architecture | process | testing
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
11
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
12
|
+
import { execFile } from 'node:child_process'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
|
|
16
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
export const PKG_ROOT = resolve(HERE, '..', '..')
|
|
18
|
+
|
|
19
|
+
export const ACTIVE_LIFECYCLES = Object.freeze(['proposed', 'implemented', 'rejected'])
|
|
20
|
+
export const LIFECYCLES = Object.freeze([...ACTIVE_LIFECYCLES, 'archived'])
|
|
21
|
+
export const CLASSES = Object.freeze([
|
|
22
|
+
'feature',
|
|
23
|
+
'bug-fix',
|
|
24
|
+
'simplification',
|
|
25
|
+
'architecture',
|
|
26
|
+
'process',
|
|
27
|
+
'testing',
|
|
28
|
+
])
|
|
29
|
+
export const SKIP_LOG = '.skips.log'
|
|
30
|
+
const FILENAME_RE = /^\d{4}-\d{2}-\d{2}-.+\.md$/
|
|
31
|
+
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
|
|
32
|
+
|
|
33
|
+
const STATUS_RE = {
|
|
34
|
+
proposed: /^Status: proposed$|^状态[::] ?(?:proposed|已提议)$/,
|
|
35
|
+
implemented: /^Status: implemented$|^状态[::] ?(?:implemented|已实现)$/,
|
|
36
|
+
rejected: /^Status: rejected — .+$|^状态[::] ?(?:rejected|已否决) — .+$/,
|
|
37
|
+
}
|
|
38
|
+
const PROBLEM_FIRST = ['## Problem', '## 问题']
|
|
39
|
+
const REQUIRED = {
|
|
40
|
+
proposed: [
|
|
41
|
+
['## Proposal', '## 提议', '## 方案', '## 提案'],
|
|
42
|
+
['## Acceptance criteria', '## 验收标准', '## 验收条件', '## 接受标准'],
|
|
43
|
+
['## Risks', '## 风险'],
|
|
44
|
+
],
|
|
45
|
+
implemented: [
|
|
46
|
+
['## Decision', '## 决定', '## 决策'],
|
|
47
|
+
['## Consequences', '## 后果', '## 影响', '## 结果', '## 结果与代价', '## 影响与验证', '## 后果与边界'],
|
|
48
|
+
],
|
|
49
|
+
rejected: [['## Proposal', '## 提议', '## 方案', '## 提案']],
|
|
50
|
+
}
|
|
51
|
+
const BANNED_IMPLEMENTED = new Set([
|
|
52
|
+
'## proposal', '## plan', '## migration plan', '## acceptance criteria',
|
|
53
|
+
'## 提议', '## 方案', '## 提案', '## 计划', '## 规划', '## 迁移计划',
|
|
54
|
+
'## 验收标准', '## 验收条件', '## 接受标准',
|
|
55
|
+
])
|
|
56
|
+
const ALTERNATIVES_RE = /^## (?:Alternatives considered|.{0,8}?(?:替代方案|备选(?:方案)?|其他做法))$/
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------- root / paths
|
|
59
|
+
|
|
60
|
+
/** Resolve the workspace root whose `.agents/notes` we operate on. */
|
|
61
|
+
export function resolveRoot(root) {
|
|
62
|
+
const r = root ?? process.env.AGENT_NOTES_ROOT ?? process.env.AGENT_NOTE_ROOT ?? process.cwd()
|
|
63
|
+
return resolve(r)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function notesDir(root) {
|
|
67
|
+
return join(resolveRoot(root), '.agents', 'notes')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Locate the upstream skill `scripts/` directory (vendored copy first). */
|
|
71
|
+
export function skillScriptsDir() {
|
|
72
|
+
const candidates = [
|
|
73
|
+
process.env.AGENT_NOTES_SKILL_DIR,
|
|
74
|
+
join(PKG_ROOT, 'vendor', 'write-notes-like-deepseek', 'skills', 'write-notes-like-deepseek'),
|
|
75
|
+
join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'skills', 'write-notes-like-deepseek'),
|
|
76
|
+
].filter(Boolean)
|
|
77
|
+
for (const dir of candidates) {
|
|
78
|
+
if (existsSync(join(dir, 'scripts', 'verify-agent-note-format.ts'))) return join(dir, 'scripts')
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ------------------------------------------------------------------- utilities
|
|
84
|
+
|
|
85
|
+
export function today() {
|
|
86
|
+
const d = new Date()
|
|
87
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
88
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function slugify(title) {
|
|
92
|
+
const s = String(title ?? '')
|
|
93
|
+
.trim()
|
|
94
|
+
.toLowerCase()
|
|
95
|
+
.replace(/[\\/:*?"<>|`]/g, '')
|
|
96
|
+
.replace(/[\s_]+/g, '-')
|
|
97
|
+
.replace(/-+/g, '-')
|
|
98
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
99
|
+
return s.slice(0, 60).replace(/[-.]+$/, '')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Latin-only slug used for filenames; empty when the title has no ASCII letters. */
|
|
103
|
+
export function fileSlug(title) {
|
|
104
|
+
return slugify(title).replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^[-.]+|[-.]+$/g, '')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function listMd(dir, prefix) {
|
|
108
|
+
if (!existsSync(dir)) return []
|
|
109
|
+
const out = []
|
|
110
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
111
|
+
if (entry.name.startsWith('.')) continue
|
|
112
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
113
|
+
if (entry.isDirectory()) out.push(...listMd(resolve(dir, entry.name), rel))
|
|
114
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) out.push(rel)
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalize(raw) {
|
|
120
|
+
return String(raw ?? '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// -------------------------------------------------------------- tree traversal
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Walk the note tree. Returns notes that satisfy the structural contract;
|
|
127
|
+
* structurally invalid files are reported in `errors` instead of silently
|
|
128
|
+
* disappearing.
|
|
129
|
+
*/
|
|
130
|
+
export function walkNotes(root, { includeArchived = false } = {}) {
|
|
131
|
+
const base = notesDir(root)
|
|
132
|
+
const notes = []
|
|
133
|
+
const errors = []
|
|
134
|
+
if (!existsSync(base)) return { notes, errors, root: base }
|
|
135
|
+
|
|
136
|
+
for (const lifecycle of ACTIVE_LIFECYCLES) {
|
|
137
|
+
for (const rel of listMd(resolve(base, lifecycle), lifecycle).sort()) {
|
|
138
|
+
const segs = rel.split('/')
|
|
139
|
+
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
|
|
140
|
+
if (rel.endsWith('.zh.md')) continue
|
|
141
|
+
const cls = segs[1]
|
|
142
|
+
const file = segs[2]
|
|
143
|
+
if (segs.length !== 3 || cls === undefined || file === undefined) {
|
|
144
|
+
errors.push(`structure: ${rel} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (!CLASSES.includes(cls)) {
|
|
148
|
+
errors.push(`structure: ${rel} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
if (!FILENAME_RE.test(file)) {
|
|
152
|
+
errors.push(`structure: ${rel} — filename must be yyyy-mm-dd-topic.md`)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
notes.push({ rel, lifecycle, cls, date: file.slice(0, 10), abs: resolve(base, rel) })
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (includeArchived) {
|
|
160
|
+
for (const rel of listMd(resolve(base, 'archived'), 'archived').sort()) {
|
|
161
|
+
if (rel.endsWith('.zh.md')) continue
|
|
162
|
+
const file = rel.split('/').pop() ?? rel
|
|
163
|
+
const date = FILENAME_RE.test(file) ? file.slice(0, 10) : ''
|
|
164
|
+
notes.push({ rel, lifecycle: 'archived', cls: rel.split('/')[1] ?? '', date, abs: resolve(base, rel) })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { notes, errors, root: base }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Parse header + section headings from note text. */
|
|
171
|
+
export function parseNote(text) {
|
|
172
|
+
const lines = normalize(text).split('\n')
|
|
173
|
+
const title = (lines[0] ?? '').replace(/^# Agent Note[::]\s*/, '').trim()
|
|
174
|
+
const statusLine = (lines[2] ?? '').trim()
|
|
175
|
+
const status = statusLine.replace(/^Status:\s*/, '').replace(/^状态[::]\s*/, '')
|
|
176
|
+
const sections = lines
|
|
177
|
+
.filter((l) => l.startsWith('## '))
|
|
178
|
+
.map((l) => l.replace(/[((].*$/, '').trimEnd())
|
|
179
|
+
return { title, status, statusLine, sections, lineCount: lines.length }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------- validation
|
|
183
|
+
|
|
184
|
+
/** Strip fenced code so headings inside samples are not counted. */
|
|
185
|
+
function proseLines(text) {
|
|
186
|
+
const lines = normalize(text).split('\n')
|
|
187
|
+
const out = []
|
|
188
|
+
let fenced = false
|
|
189
|
+
for (const line of lines) {
|
|
190
|
+
if (/^\s{0,3}```/.test(line)) {
|
|
191
|
+
fenced = !fenced
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
if (fenced) continue
|
|
195
|
+
out.push(line)
|
|
196
|
+
}
|
|
197
|
+
return out
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Pre-write check: the same gates the upstream verifiers enforce, so a bad
|
|
202
|
+
* note never reaches disk. Returns an array of human-readable errors.
|
|
203
|
+
*/
|
|
204
|
+
export function validateNoteText(lifecycle, text) {
|
|
205
|
+
const errors = []
|
|
206
|
+
const lines = normalize(text).split('\n')
|
|
207
|
+
if (!ACTIVE_LIFECYCLES.includes(lifecycle)) {
|
|
208
|
+
errors.push(`lifecycle must be one of ${ACTIVE_LIFECYCLES.join(', ')}`)
|
|
209
|
+
return errors
|
|
210
|
+
}
|
|
211
|
+
if (!/^# Agent Note[::] ?\S/.test(lines[0] ?? '')) errors.push('line 1 must be `# Agent Note: <title>`')
|
|
212
|
+
if (lines[1] !== '') errors.push('line 2 must be blank')
|
|
213
|
+
const re = STATUS_RE[lifecycle]
|
|
214
|
+
if (re && !re.test(lines[2] ?? '')) errors.push(`line 3 must match ${lifecycle} status grammar`)
|
|
215
|
+
if (lines[3] !== '') errors.push('line 4 must be blank')
|
|
216
|
+
|
|
217
|
+
const prose = proseLines(text)
|
|
218
|
+
const statusCount = prose.filter((l) => Object.values(STATUS_RE).some((r) => r.test(l.trim()))).length
|
|
219
|
+
if (statusCount !== 1) errors.push('Status: line must appear exactly once')
|
|
220
|
+
|
|
221
|
+
const headings = prose.filter((l) => l.startsWith('## ')).map((l) => l.replace(/[((].*$/, '').trimEnd())
|
|
222
|
+
if (!PROBLEM_FIRST.includes(headings[0] ?? '')) {
|
|
223
|
+
errors.push(`first section must be ## Problem or ## 问题 (got ${JSON.stringify(headings[0] ?? '<none>')})`)
|
|
224
|
+
}
|
|
225
|
+
for (const group of REQUIRED[lifecycle] ?? []) {
|
|
226
|
+
if (!group.some((h) => headings.includes(h))) errors.push(`missing one of ${JSON.stringify(group)}`)
|
|
227
|
+
}
|
|
228
|
+
if (lifecycle === 'implemented') {
|
|
229
|
+
for (const h of headings.filter((x) => BANNED_IMPLEMENTED.has(x.toLowerCase()))) {
|
|
230
|
+
errors.push(`banned in implemented: ${h}`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!headings.some((h) => ALTERNATIVES_RE.test(h))) {
|
|
234
|
+
errors.push('missing ## Alternatives considered / ## 备选方案')
|
|
235
|
+
}
|
|
236
|
+
return errors
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --------------------------------------------------------------------- writes
|
|
240
|
+
|
|
241
|
+
function composeNote({ lifecycle, title, body, rejectionReason }) {
|
|
242
|
+
const statusLine =
|
|
243
|
+
lifecycle === 'rejected' ? `Status: rejected — ${rejectionReason}` : `Status: ${lifecycle}`
|
|
244
|
+
let clean = normalize(body).trim()
|
|
245
|
+
clean = clean.replace(/^# Agent Note[::][^\n]*\n+/, '')
|
|
246
|
+
clean = clean.replace(/^Status:[^\n]*\n+/, '')
|
|
247
|
+
return `# Agent Note: ${title}\n\n${statusLine}\n\n${clean}\n`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Create a note. Refuses to overwrite, validates before writing, and then
|
|
252
|
+
* runs the upstream verifiers over the whole tree.
|
|
253
|
+
*/
|
|
254
|
+
export async function writeNote(root, opts = {}) {
|
|
255
|
+
const {
|
|
256
|
+
lifecycle,
|
|
257
|
+
cls,
|
|
258
|
+
title,
|
|
259
|
+
slug,
|
|
260
|
+
body = '',
|
|
261
|
+
date = today(),
|
|
262
|
+
rejectionReason,
|
|
263
|
+
runVerify: doVerify = true,
|
|
264
|
+
} = opts
|
|
265
|
+
|
|
266
|
+
const fail = (code, message, extra = {}) => ({ ok: false, code, message, ...extra })
|
|
267
|
+
|
|
268
|
+
if (!ACTIVE_LIFECYCLES.includes(lifecycle)) {
|
|
269
|
+
return fail('BAD_LIFECYCLE', `lifecycle must be one of ${ACTIVE_LIFECYCLES.join(', ')}; use the archive script to retire a note`)
|
|
270
|
+
}
|
|
271
|
+
if (!CLASSES.includes(cls)) return fail('BAD_CLASS', `class must be one of ${CLASSES.join(', ')}`)
|
|
272
|
+
const cleanTitle = String(title ?? '').replace(/[\r\n]+/g, ' ').trim()
|
|
273
|
+
if (!cleanTitle) return fail('BAD_TITLE', 'title is required')
|
|
274
|
+
// Filenames are ASCII: a Chinese-only title needs an explicit slug.
|
|
275
|
+
const filenameSlug = fileSlug(slug || '') || fileSlug(cleanTitle)
|
|
276
|
+
if (!filenameSlug) {
|
|
277
|
+
return fail('NEEDS_SLUG', 'title has no Latin characters to build a filename from; pass slug with 2-5 ASCII words', { title: cleanTitle })
|
|
278
|
+
}
|
|
279
|
+
if (lifecycle === 'rejected' && !String(rejectionReason ?? '').trim()) {
|
|
280
|
+
return fail('BAD_REASON', 'rejected notes require rejectionReason (one-line why)')
|
|
281
|
+
}
|
|
282
|
+
if (!FILENAME_RE.test(`${date}-x.md`)) return fail('BAD_DATE', 'date must be yyyy-mm-dd')
|
|
283
|
+
|
|
284
|
+
const text = composeNote({ lifecycle, title: cleanTitle, body, rejectionReason: String(rejectionReason ?? '').trim() })
|
|
285
|
+
const errors = validateNoteText(lifecycle, text)
|
|
286
|
+
if (errors.length) return fail('INVALID_NOTE', 'note failed format validation; nothing was written', { errors })
|
|
287
|
+
|
|
288
|
+
const rel = `${lifecycle}/${cls}/${date}-${filenameSlug}.md`
|
|
289
|
+
const abs = resolve(notesDir(root), rel)
|
|
290
|
+
if (existsSync(abs)) {
|
|
291
|
+
return fail('EXISTS', `note already exists: ${rel}`, { path: rel })
|
|
292
|
+
}
|
|
293
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
294
|
+
writeFileSync(abs, text, 'utf8')
|
|
295
|
+
|
|
296
|
+
const result = { ok: true, path: rel, absPath: abs, bytes: Buffer.byteLength(text), lifecycle, class: cls, date }
|
|
297
|
+
if (doVerify) result.verify = await runVerify(root)
|
|
298
|
+
return result
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// -------------------------------------------------------------------- reading
|
|
302
|
+
|
|
303
|
+
export function readNote(root, rel, { maxLines = 400 } = {}) {
|
|
304
|
+
const base = notesDir(root)
|
|
305
|
+
const abs = resolve(base, String(rel ?? ''))
|
|
306
|
+
if (!abs.startsWith(base + sep)) {
|
|
307
|
+
return { ok: false, code: 'OUTSIDE_ROOT', message: `path must stay inside ${base}` }
|
|
308
|
+
}
|
|
309
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) {
|
|
310
|
+
return { ok: false, code: 'NOT_FOUND', message: `no note at ${rel}`, path: rel }
|
|
311
|
+
}
|
|
312
|
+
const text = normalize(readFileSync(abs, 'utf8'))
|
|
313
|
+
const lines = text.split('\n')
|
|
314
|
+
const parsed = parseNote(text)
|
|
315
|
+
const truncated = lines.length > maxLines
|
|
316
|
+
const segs = String(rel).split('/')
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
path: rel,
|
|
320
|
+
lifecycle: segs[0],
|
|
321
|
+
class: segs[1],
|
|
322
|
+
...parsed,
|
|
323
|
+
content: truncated ? lines.slice(0, maxLines).join('\n') : text,
|
|
324
|
+
contentLines: lines.length,
|
|
325
|
+
truncated,
|
|
326
|
+
hint: truncated ? `showing first ${maxLines} of ${lines.length} lines` : undefined,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ------------------------------------------------------------------- searching
|
|
331
|
+
|
|
332
|
+
const MAX_SNIPPET = 160
|
|
333
|
+
|
|
334
|
+
export function searchNotes(root, opts = {}) {
|
|
335
|
+
const { query, lifecycle, cls, includeArchived = false, limit = 20 } = opts
|
|
336
|
+
const q = String(query ?? '').trim()
|
|
337
|
+
if (!q) return { ok: false, code: 'EMPTY_QUERY', message: 'query is required' }
|
|
338
|
+
const needle = q.toLowerCase()
|
|
339
|
+
const cap = Math.min(Math.max(Number(limit) || 20, 1), 50)
|
|
340
|
+
|
|
341
|
+
const { notes, errors } = walkNotes(root, { includeArchived })
|
|
342
|
+
const hits = []
|
|
343
|
+
for (const note of notes) {
|
|
344
|
+
if (lifecycle && note.lifecycle !== lifecycle) continue
|
|
345
|
+
if (cls && note.cls !== cls) continue
|
|
346
|
+
const text = normalize(readFileSync(note.abs, 'utf8'))
|
|
347
|
+
const parsed = parseNote(text)
|
|
348
|
+
const lines = text.split('\n')
|
|
349
|
+
const matches = []
|
|
350
|
+
let score = 0
|
|
351
|
+
if (parsed.title.toLowerCase().includes(needle)) score += 100
|
|
352
|
+
lines.forEach((line, i) => {
|
|
353
|
+
const low = line.toLowerCase()
|
|
354
|
+
if (!low.includes(needle)) return
|
|
355
|
+
if (line.startsWith('## ')) score += 10
|
|
356
|
+
else score += 1
|
|
357
|
+
if (matches.length < 3) {
|
|
358
|
+
const snippet = line.trim()
|
|
359
|
+
matches.push({
|
|
360
|
+
line: i + 1,
|
|
361
|
+
text: snippet.length > MAX_SNIPPET ? `${snippet.slice(0, MAX_SNIPPET)}…` : snippet,
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
if (score > 0) {
|
|
366
|
+
hits.push({
|
|
367
|
+
path: note.rel,
|
|
368
|
+
lifecycle: note.lifecycle,
|
|
369
|
+
class: note.cls,
|
|
370
|
+
date: note.date,
|
|
371
|
+
title: parsed.title,
|
|
372
|
+
status: parsed.status,
|
|
373
|
+
score,
|
|
374
|
+
matches,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
hits.sort((a, b) => b.score - a.score || String(b.date).localeCompare(String(a.date)))
|
|
379
|
+
const returned = hits.slice(0, cap)
|
|
380
|
+
return {
|
|
381
|
+
ok: true,
|
|
382
|
+
query: q,
|
|
383
|
+
totalMatched: hits.length,
|
|
384
|
+
returned: returned.length,
|
|
385
|
+
truncated: hits.length > returned.length,
|
|
386
|
+
structureErrors: errors.length,
|
|
387
|
+
notes: returned,
|
|
388
|
+
...(returned.length === 0
|
|
389
|
+
? {
|
|
390
|
+
hint:
|
|
391
|
+
'NO_MATCHING_NOTE — no Agent Note matches this query. Do not infer that no decision exists; ' +
|
|
392
|
+
'try different wording (Chinese/English, symbol name, file path), or search the repository directly.',
|
|
393
|
+
}
|
|
394
|
+
: {}),
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ----------------------------------------------------------------- skip ledger
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Record "this turn needs no note" so the hook stops nagging. The file is
|
|
402
|
+
* dot-prefixed, so the upstream tree walker ignores it.
|
|
403
|
+
*/
|
|
404
|
+
export function appendSkip(root, entry = {}) {
|
|
405
|
+
const dir = notesDir(root)
|
|
406
|
+
mkdirSync(dir, { recursive: true })
|
|
407
|
+
const line = JSON.stringify({ at: new Date().toISOString(), ...entry })
|
|
408
|
+
appendFileSync(join(dir, SKIP_LOG), `${line}\n`, 'utf8')
|
|
409
|
+
return { ok: true, recorded: JSON.parse(line), log: join('.agents', 'notes', SKIP_LOG) }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function readSkips(root, { limit = 10 } = {}) {
|
|
413
|
+
const file = join(notesDir(root), SKIP_LOG)
|
|
414
|
+
if (!existsSync(file)) return { ok: true, total: 0, entries: [] }
|
|
415
|
+
const lines = normalize(readFileSync(file, 'utf8')).split('\n').filter(Boolean)
|
|
416
|
+
const entries = []
|
|
417
|
+
for (const line of lines.slice(-limit)) {
|
|
418
|
+
try {
|
|
419
|
+
entries.push(JSON.parse(line))
|
|
420
|
+
} catch {
|
|
421
|
+
entries.push({ unparsable: line })
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return { ok: true, total: lines.length, entries }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// -------------------------------------------------------------------- verifying
|
|
428
|
+
|
|
429
|
+
const VERIFY_SCRIPTS = [
|
|
430
|
+
'verify-agent-note-tree.ts',
|
|
431
|
+
'verify-agent-note-format.ts',
|
|
432
|
+
'verify-archived-agent-notes.ts',
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
function runScript(scriptPath, base) {
|
|
436
|
+
return new Promise((done) => {
|
|
437
|
+
execFile(
|
|
438
|
+
process.execPath,
|
|
439
|
+
[scriptPath],
|
|
440
|
+
{ cwd: base, env: { ...process.env, AGENT_NOTE_ROOT: base }, timeout: 30_000 },
|
|
441
|
+
(err, stdout, stderr) => {
|
|
442
|
+
done({
|
|
443
|
+
script: scriptPath.split(sep).pop(),
|
|
444
|
+
code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
|
|
445
|
+
ok: !err,
|
|
446
|
+
stdout: String(stdout ?? '').trim(),
|
|
447
|
+
stderr: String(stderr ?? '').trim(),
|
|
448
|
+
})
|
|
449
|
+
},
|
|
450
|
+
)
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Run the upstream verifiers. This is authoritative — do not re-implement. */
|
|
455
|
+
export async function runVerify(root) {
|
|
456
|
+
const base = notesDir(root)
|
|
457
|
+
const scripts = skillScriptsDir()
|
|
458
|
+
if (!scripts) {
|
|
459
|
+
return {
|
|
460
|
+
ok: false,
|
|
461
|
+
code: 'VERIFIER_MISSING',
|
|
462
|
+
message:
|
|
463
|
+
'upstream verify scripts not found. Set AGENT_NOTES_SKILL_DIR to the write-notes-like-deepseek skill directory.',
|
|
464
|
+
results: [],
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (!existsSync(base)) {
|
|
468
|
+
return { ok: true, note: 'no .agents/notes yet — nothing to verify', results: [] }
|
|
469
|
+
}
|
|
470
|
+
const results = []
|
|
471
|
+
for (const name of VERIFY_SCRIPTS) {
|
|
472
|
+
const scriptPath = join(scripts, name)
|
|
473
|
+
if (!existsSync(scriptPath)) {
|
|
474
|
+
results.push({ script: name, ok: false, code: 'MISSING_SCRIPT' })
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
477
|
+
results.push(await runScript(scriptPath, base))
|
|
478
|
+
}
|
|
479
|
+
const failed = results.filter((r) => !r.ok)
|
|
480
|
+
return {
|
|
481
|
+
ok: failed.length === 0,
|
|
482
|
+
checkedRoot: base,
|
|
483
|
+
results,
|
|
484
|
+
errors: failed.flatMap((r) => (r.stderr ? r.stderr.split('\n') : [])).filter(Boolean),
|
|
485
|
+
}
|
|
486
|
+
}
|