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,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
+ }