@ddtcorex/dsh-maestro-memory 1.0.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +21 -0
  3. package/README.md +448 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +756 -0
  6. package/lib/index.d.ts +10 -0
  7. package/lib/index.d.ts.map +1 -0
  8. package/lib/index.js +627 -0
  9. package/lib/index.js.map +1 -0
  10. package/lib/memory/batch.d.ts +44 -0
  11. package/lib/memory/batch.d.ts.map +1 -0
  12. package/lib/memory/batch.js +75 -0
  13. package/lib/memory/batch.js.map +1 -0
  14. package/lib/memory/feedback.d.ts +18 -0
  15. package/lib/memory/feedback.d.ts.map +1 -0
  16. package/lib/memory/feedback.js +29 -0
  17. package/lib/memory/feedback.js.map +1 -0
  18. package/lib/memory/store.d.ts +154 -0
  19. package/lib/memory/store.d.ts.map +1 -0
  20. package/lib/memory/store.js +606 -0
  21. package/lib/memory/store.js.map +1 -0
  22. package/lib/migration/cli.d.ts +18 -0
  23. package/lib/migration/cli.d.ts.map +1 -0
  24. package/lib/migration/cli.js +143 -0
  25. package/lib/migration/cli.js.map +1 -0
  26. package/lib/migration/fixture.d.ts +45 -0
  27. package/lib/migration/fixture.d.ts.map +1 -0
  28. package/lib/migration/fixture.js +177 -0
  29. package/lib/migration/fixture.js.map +1 -0
  30. package/lib/migration/service.d.ts +75 -0
  31. package/lib/migration/service.d.ts.map +1 -0
  32. package/lib/migration/service.js +713 -0
  33. package/lib/migration/service.js.map +1 -0
  34. package/lib/prompt/snapshot.d.ts +26 -0
  35. package/lib/prompt/snapshot.d.ts.map +1 -0
  36. package/lib/prompt/snapshot.js +74 -0
  37. package/lib/prompt/snapshot.js.map +1 -0
  38. package/lib/review/queue.d.ts +72 -0
  39. package/lib/review/queue.d.ts.map +1 -0
  40. package/lib/review/queue.js +306 -0
  41. package/lib/review/queue.js.map +1 -0
  42. package/lib/skills-browser.d.ts +67 -0
  43. package/lib/skills-browser.d.ts.map +1 -0
  44. package/lib/skills-browser.js +185 -0
  45. package/lib/skills-browser.js.map +1 -0
  46. package/lib/storage/atomic-store.d.ts +211 -0
  47. package/lib/storage/atomic-store.d.ts.map +1 -0
  48. package/lib/storage/atomic-store.js +582 -0
  49. package/lib/storage/atomic-store.js.map +1 -0
  50. package/lib/storage/layout.d.ts +81 -0
  51. package/lib/storage/layout.d.ts.map +1 -0
  52. package/lib/storage/layout.js +162 -0
  53. package/lib/storage/layout.js.map +1 -0
  54. package/lib/storage/legacy-format.d.ts +104 -0
  55. package/lib/storage/legacy-format.d.ts.map +1 -0
  56. package/lib/storage/legacy-format.js +262 -0
  57. package/lib/storage/legacy-format.js.map +1 -0
  58. package/lib/sync/config.d.ts +22 -0
  59. package/lib/sync/config.d.ts.map +1 -0
  60. package/lib/sync/config.js +57 -0
  61. package/lib/sync/config.js.map +1 -0
  62. package/lib/sync/git.d.ts +97 -0
  63. package/lib/sync/git.d.ts.map +1 -0
  64. package/lib/sync/git.js +282 -0
  65. package/lib/sync/git.js.map +1 -0
  66. package/lib/sync/index.d.ts +6 -0
  67. package/lib/sync/index.d.ts.map +1 -0
  68. package/lib/sync/index.js +6 -0
  69. package/lib/sync/index.js.map +1 -0
  70. package/lib/sync/layout.d.ts +15 -0
  71. package/lib/sync/layout.d.ts.map +1 -0
  72. package/lib/sync/layout.js +33 -0
  73. package/lib/sync/layout.js.map +1 -0
  74. package/lib/sync/merge.d.ts +42 -0
  75. package/lib/sync/merge.d.ts.map +1 -0
  76. package/lib/sync/merge.js +159 -0
  77. package/lib/sync/merge.js.map +1 -0
  78. package/lib/sync/service.d.ts +70 -0
  79. package/lib/sync/service.d.ts.map +1 -0
  80. package/lib/sync/service.js +508 -0
  81. package/lib/sync/service.js.map +1 -0
  82. package/lib/todo/store.d.ts +142 -0
  83. package/lib/todo/store.d.ts.map +1 -0
  84. package/lib/todo/store.js +452 -0
  85. package/lib/todo/store.js.map +1 -0
  86. package/lib/types/client/index.d.ts +7 -0
  87. package/lib/types/client/index.d.ts.map +1 -0
  88. package/package.json +63 -0
  89. package/src/client/index.tsx +994 -0
  90. package/src/host/index.ts +591 -0
  91. package/src/host/memory/batch.ts +107 -0
  92. package/src/host/memory/feedback.ts +36 -0
  93. package/src/host/memory/store.ts +650 -0
  94. package/src/host/migration/cli.ts +139 -0
  95. package/src/host/migration/fixture.ts +218 -0
  96. package/src/host/migration/service.ts +786 -0
  97. package/src/host/prompt/snapshot.ts +95 -0
  98. package/src/host/review/queue.ts +325 -0
  99. package/src/host/skills-browser.ts +191 -0
  100. package/src/host/storage/atomic-store.ts +605 -0
  101. package/src/host/storage/layout.ts +191 -0
  102. package/src/host/storage/legacy-format.ts +308 -0
  103. package/src/host/sync/config.ts +67 -0
  104. package/src/host/sync/git.ts +280 -0
  105. package/src/host/sync/index.ts +5 -0
  106. package/src/host/sync/layout.ts +39 -0
  107. package/src/host/sync/merge.ts +188 -0
  108. package/src/host/sync/service.ts +494 -0
  109. package/src/host/todo/store.ts +480 -0
@@ -0,0 +1,95 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import type { MaestroMemoryStore } from '../memory/store.ts'
3
+ import { entryHeadPrefix, parseEntrySummary } from '../storage/legacy-format.ts'
4
+
5
+ export interface SnapshotContext {
6
+ cwd: string | null
7
+ branch?: string
8
+ sessionId?: string
9
+ sessionName?: string
10
+ }
11
+
12
+ /** Default per-section byte budgets for the snapshot prompt. */
13
+ export const SNAPSHOT_SECTION_CAPS = { memory: 2048, user: 4096, key: 6144 } as const
14
+
15
+ export type SnapshotSectionKey = keyof typeof SNAPSHOT_SECTION_CAPS
16
+
17
+ export interface SnapshotRenderOpts {
18
+ /** Partial override of {@link SNAPSHOT_SECTION_CAPS}; unspecified sections keep defaults. */
19
+ caps?: Partial<Record<SnapshotSectionKey, number>>
20
+ }
21
+
22
+ const SECTION_SEP = '\n---\n'
23
+
24
+ /** Compact an oversize entry to `head + [summary:…]` when it carries a summary tag; otherwise keep whole. */
25
+ function compactToHead(entry: string): string {
26
+ const summary = parseEntrySummary(entry)
27
+ if (summary === null) return entry
28
+ return `${entryHeadPrefix(entry)}[summary:${summary}]`
29
+ }
30
+
31
+ /**
32
+ * Keep the newest entries whose combined UTF-8 size (with separators) fits `cap`.
33
+ * The newest entry is always kept — compacted to its summary head when oversized
34
+ * and tagged; untagged oversize entries stay whole rather than vanishing.
35
+ */
36
+ function fitSection(entries: string[], cap: number): string[] {
37
+ if (entries.length === 0) return []
38
+ const keptDesc: string[] = []
39
+ let used = 0
40
+ for (let i = entries.length - 1; i >= 0; i--) {
41
+ const isNewest = keptDesc.length === 0
42
+ let candidate = entries[i]
43
+ if (isNewest && Buffer.byteLength(candidate, 'utf8') > cap) candidate = compactToHead(candidate)
44
+ const cost = Buffer.byteLength(candidate, 'utf8') + (keptDesc.length ? SECTION_SEP.length : 0)
45
+ if (!isNewest && used + cost > cap) break
46
+ keptDesc.push(candidate)
47
+ used += cost
48
+ }
49
+ return keptDesc.reverse()
50
+ }
51
+
52
+ /**
53
+ * Bounded snapshot renderer — contract từ README § System Prompt Snapshot:
54
+ * Header (sessionId/sessionName) + USER + global MEMORY + current-project KEY
55
+ * (branch-filtered) + end-of-turn discipline note.
56
+ * daily và project KHÔNG inject.
57
+ */
58
+ export function renderSnapshot(
59
+ store: MaestroMemoryStore,
60
+ ctx: SnapshotContext,
61
+ opts: SnapshotRenderOpts = {},
62
+ ): string {
63
+ const caps = { ...SNAPSHOT_SECTION_CAPS, ...opts.caps }
64
+ const parts: string[] = []
65
+
66
+ // Header
67
+ if (ctx.sessionId || ctx.sessionName) {
68
+ const header = [
69
+ ctx.sessionId ? `sessionId: ${ctx.sessionId}` : null,
70
+ ctx.sessionName ? `sessionName: ${ctx.sessionName}` : null,
71
+ ].filter(Boolean).join(' | ')
72
+ if (header) parts.push(`# Session\n${header}`)
73
+ }
74
+
75
+ // Bounded memory sections — delegate branch filtering to store.list, then enforce byte caps
76
+ const mem = fitSection(store.list('memory'), caps.memory)
77
+ const user = fitSection(store.list('user'), caps.user)
78
+ const key = ctx.cwd
79
+ ? fitSection(store.list('key', ctx.cwd, ctx.branch ? { branch: ctx.branch } : {}), caps.key)
80
+ : []
81
+
82
+ if (mem.length) parts.push(`# Global Memory\n${mem.join('\n---\n')}`)
83
+ if (user.length) parts.push(`# User Memory\n${user.join('\n---\n')}`)
84
+ if (key.length) parts.push(`# Project Key Memory\n${key.join('\n---\n')}`)
85
+
86
+ // End-of-turn discipline note — verbatim contract (hardened: exactly once, always last)
87
+ const discipline =
88
+ `---\nEnd of every turn you must: 1. Write daily+project via memory entries (daily+project in one call) 2. Check dtodo list (bounded, max 8)`
89
+ // Defensive: strip any pre-existing discipline entry (should never occur — parts is fresh per call)
90
+ // then append exactly once so the note is guaranteed last even for empty stores or repeated calls.
91
+ const deduped = parts.filter((p) => p !== discipline)
92
+ deduped.push(discipline)
93
+
94
+ return deduped.join('\n\n')
95
+ }
@@ -0,0 +1,325 @@
1
+ /**
2
+ * review/queue.ts — SuggestionQueue for M2-PR-B confirmation queue
3
+ * Durable JSONL queue SUGGESTIONS.jsonl, gated memory_suggest and explicit RPC decisions.
4
+ * Handles append/dedupe/edited approval/reject/archive/malformed JSONL/recovery.
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
8
+ import { dirname, join } from 'node:path'
9
+ import { withLockSync } from '../storage/atomic-store.ts'
10
+ import { resolveMemoryRoot, suggestionsPath } from '../storage/layout.ts'
11
+
12
+ export interface SuggestionEntry {
13
+ time: string
14
+ target: string
15
+ content: string
16
+ reason: string
17
+ cwd?: string | null
18
+ sessionId?: string | null
19
+ hits?: number
20
+ firstSeen?: string
21
+ lastSeen?: string
22
+ }
23
+
24
+ function nowIso(): string {
25
+ return new Date().toISOString()
26
+ }
27
+
28
+ export function normalizeWhitespace(text: string): string {
29
+ return String(text ?? '').replace(/\s+/g, ' ').trim()
30
+ }
31
+
32
+ /**
33
+ * SuggestionQueue — durable JSONL queue with malformed recovery and dedupe.
34
+ */
35
+ export class SuggestionQueue {
36
+ constructor(private readonly file: string) {}
37
+
38
+ static fromRoot(memoryDir: string | null, file?: string): SuggestionQueue {
39
+ const root = resolveMemoryRoot(memoryDir)
40
+ return new SuggestionQueue(file ?? suggestionsPath(root))
41
+ }
42
+
43
+ /** Read all suggestions; missing file -> [], malformed lines skipped. */
44
+ read(): SuggestionEntry[] {
45
+ try {
46
+ const text = readFileSync(this.file, 'utf8')
47
+ const lines = text.split('\n').filter((l) => l.trim().length > 0)
48
+ const out: SuggestionEntry[] = []
49
+ for (const line of lines) {
50
+ try {
51
+ const obj = JSON.parse(line)
52
+ if (obj && typeof obj.target === 'string' && typeof obj.content === 'string') {
53
+ out.push(obj as SuggestionEntry)
54
+ }
55
+ } catch {
56
+ // skip malformed line (recovery: not crashing, next write cleans)
57
+ continue
58
+ }
59
+ }
60
+ return out
61
+ } catch (e: any) {
62
+ if (e?.code === 'ENOENT') return []
63
+ throw e
64
+ }
65
+ }
66
+
67
+ /** Atomically write full list (same-directory lock, temp+rename). */
68
+ write(entries: SuggestionEntry[]): void {
69
+ const dir = dirname(this.file)
70
+ mkdirSync(dir, { recursive: true })
71
+ withLockSync(dir, () => {
72
+ const tmp = join(dir, `.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`)
73
+ const content = entries.map((e) => JSON.stringify(e)).join('\n') + (entries.length > 0 ? '\n' : '')
74
+ writeFileSync(tmp, content, 'utf8')
75
+ renameSync(tmp, this.file)
76
+ })
77
+ }
78
+
79
+ /** Append one suggestion with dedupe (same target+normalized content bumps hits). */
80
+ append(entry: SuggestionEntry): { ok: true; queued: number; deduped?: boolean; hits?: number } {
81
+ const dir = dirname(this.file)
82
+ mkdirSync(dir, { recursive: true })
83
+ return withLockSync(dir, () => {
84
+ const entries = this.readUnsafe()
85
+ const normalized = normalizeWhitespace(entry.content)
86
+ const existing = entries.find(
87
+ (e) => e.target === entry.target && normalizeWhitespace(e.content) === normalized,
88
+ )
89
+ if (existing) {
90
+ existing.hits = (existing.hits ?? 1) + 1
91
+ existing.lastSeen = nowIso()
92
+ if (entry.reason) existing.reason = entry.reason
93
+ this.writeUnsafe(entries)
94
+ return { ok: true as const, queued: entries.length, deduped: true, hits: existing.hits }
95
+ }
96
+ const toPush: SuggestionEntry = {
97
+ time: entry.time ?? nowIso(),
98
+ target: entry.target,
99
+ content: entry.content,
100
+ reason: entry.reason,
101
+ cwd: entry.cwd ?? null,
102
+ sessionId: entry.sessionId ?? null,
103
+ hits: 1,
104
+ firstSeen: nowIso(),
105
+ lastSeen: nowIso(),
106
+ }
107
+ entries.push(toPush)
108
+ this.writeUnsafe(entries)
109
+ return { ok: true as const, queued: entries.length }
110
+ })
111
+ }
112
+
113
+ /** Mutate under lock; fn may edit entries in place. Writes back after fn. */
114
+ mutate<T>(fn: (entries: SuggestionEntry[]) => T): T {
115
+ const dir = dirname(this.file)
116
+ mkdirSync(dir, { recursive: true })
117
+ return withLockSync(dir, () => {
118
+ const entries = this.readUnsafe()
119
+ const result = fn(entries)
120
+ this.writeUnsafe(entries)
121
+ return result
122
+ })
123
+ }
124
+
125
+ /** Alias for read */
126
+ list(): SuggestionEntry[] {
127
+ return this.read()
128
+ }
129
+
130
+ // internal read without lock (caller holds lock)
131
+ private readUnsafe(): SuggestionEntry[] {
132
+ try {
133
+ const text = readFileSync(this.file, 'utf8')
134
+ const lines = text.split('\n').filter((l) => l.trim().length > 0)
135
+ const out: SuggestionEntry[] = []
136
+ for (const line of lines) {
137
+ try {
138
+ const obj = JSON.parse(line)
139
+ if (obj && typeof obj.target === 'string' && typeof obj.content === 'string') out.push(obj as SuggestionEntry)
140
+ } catch {
141
+ continue
142
+ }
143
+ }
144
+ return out
145
+ } catch (e: any) {
146
+ if (e?.code === 'ENOENT') return []
147
+ throw e
148
+ }
149
+ }
150
+
151
+ private writeUnsafe(entries: SuggestionEntry[]): void {
152
+ const dir = dirname(this.file)
153
+ mkdirSync(dir, { recursive: true })
154
+ const tmp = join(dir, `.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`)
155
+ const content = entries.map((e) => JSON.stringify(e)).join('\n') + (entries.length > 0 ? '\n' : '')
156
+ writeFileSync(tmp, content, 'utf8')
157
+ renameSync(tmp, this.file)
158
+ }
159
+ }
160
+
161
+ /** Enqueue helper with dedupe, used by memory_suggest tool */
162
+ export function enqueueSuggestion(
163
+ queue: SuggestionQueue,
164
+ target: string,
165
+ content: string,
166
+ reason: string,
167
+ agent?: { id?: string; session?: { header?: { cwd?: string | null } } },
168
+ ): { ok: boolean; message?: string; queued: number; hits?: number } {
169
+ const normalized = normalizeWhitespace(content)
170
+ if (!normalized) return { ok: false, message: 'empty content', queued: queue.read().length }
171
+ if (!reason?.trim()) return { ok: false, message: 'empty reason', queued: queue.read().length }
172
+ const res = queue.mutate((entries) => {
173
+ const existing = entries.find(
174
+ (e) => e.target === target && normalizeWhitespace(e.content) === normalized,
175
+ )
176
+ if (existing) {
177
+ existing.hits = (existing.hits ?? 1) + 1
178
+ existing.lastSeen = nowIso()
179
+ if (reason) existing.reason = reason
180
+ return { deduped: true, hits: existing.hits, queued: entries.length }
181
+ }
182
+ entries.push({
183
+ time: nowIso(),
184
+ sessionId: agent?.id ?? null,
185
+ cwd: (agent?.session?.header?.cwd as string) ?? null,
186
+ target,
187
+ content,
188
+ reason,
189
+ hits: 1,
190
+ firstSeen: nowIso(),
191
+ lastSeen: nowIso(),
192
+ })
193
+ return { deduped: false, queued: entries.length }
194
+ })
195
+ if ((res as any).deduped) {
196
+ return { ok: true, message: `deduped (hits=${(res as any).hits})`, queued: (res as any).queued, hits: (res as any).hits }
197
+ }
198
+ return { ok: true, queued: (res as any).queued }
199
+ }
200
+
201
+ /** Approve suggestions by 1-based indices, supports edited content and target override */
202
+ export function approveSuggestions(
203
+ store: any,
204
+ todoStore: any,
205
+ queue: SuggestionQueue,
206
+ indices: number[],
207
+ agent?: any,
208
+ edits?: Map<number, string>,
209
+ targets?: Map<number, string>,
210
+ options: { isTodoEnabled?: () => boolean } = {},
211
+ ): { lines: string[]; remaining: number } {
212
+ const isTodoEnabled = options.isTodoEnabled ?? (() => true)
213
+ return queue.mutate((entries) => {
214
+ const kept: SuggestionEntry[] = []
215
+ const lines: string[] = []
216
+ entries.forEach((entry, idx) => {
217
+ const number = idx + 1
218
+ if (!indices.includes(number)) {
219
+ kept.push(entry)
220
+ return
221
+ }
222
+ const isTodo = entry.target.startsWith('todo-')
223
+ const target = isTodo ? entry.target : (targets?.get(number) ?? entry.target)
224
+ const edited = edits?.get(number)?.trim()
225
+ const content = edited ? edited : entry.content
226
+ if (!content?.trim()) {
227
+ lines.push(`✗ #${number} [${target}] empty content, kept`)
228
+ kept.push(entry)
229
+ return
230
+ }
231
+ if (isTodo && !isTodoEnabled()) {
232
+ lines.push(`✗ #${number} [${target}] TODO_DISABLED: Todo feature is not enabled`)
233
+ kept.push(entry)
234
+ return
235
+ }
236
+ let outcome: any
237
+ if (isTodo) {
238
+ // todoStore may be minimal; try addTodo or add
239
+ if (todoStore?.addTodo) outcome = todoStore.addTodo(target.slice(5), content, {}, entry.cwd ?? agent?.session?.header?.cwd)
240
+ else if (todoStore?.add) outcome = todoStore.add(target.slice(5), { content }, entry.cwd ?? agent?.session?.header?.cwd)
241
+ else outcome = { ok: false, message: 'todo store not available' }
242
+ } else {
243
+ const cwdForStore = (entry.cwd as string) ?? agent?.session?.header?.cwd ?? null
244
+ try {
245
+ if (store?.add) outcome = store.add(target, content, cwdForStore)
246
+ else outcome = { ok: false, message: 'store not available' }
247
+ } catch (e: any) {
248
+ outcome = { ok: false, message: e?.message ?? String(e) }
249
+ }
250
+ }
251
+ if (outcome?.duplicate === true) {
252
+ lines.push(`#${number} [${target}] duplicate skipped`)
253
+ } else if (outcome?.ok) {
254
+ lines.push(`#${number} [${target}] approved`)
255
+ } else {
256
+ lines.push(`✗ #${number} [${target}] ${outcome?.message ?? outcome?.error ?? 'failed'}`)
257
+ kept.push(entry)
258
+ }
259
+ })
260
+ entries.length = 0
261
+ entries.push(...kept)
262
+ return { lines, remaining: kept.length }
263
+ })
264
+ }
265
+
266
+ export function rejectSuggestions(queue: SuggestionQueue, indices: number[]): { removed: number; remaining: number } {
267
+ return queue.mutate((entries) => {
268
+ const kept: SuggestionEntry[] = []
269
+ let removed = 0
270
+ entries.forEach((entry, idx) => {
271
+ if (indices.includes(idx + 1)) removed += 1
272
+ else kept.push(entry)
273
+ })
274
+ entries.length = 0
275
+ entries.push(...kept)
276
+ return { removed, remaining: kept.length }
277
+ })
278
+ }
279
+
280
+ export function archiveSuggestions(
281
+ archive: any,
282
+ queue: SuggestionQueue,
283
+ indices: number[],
284
+ ): { lines: string[]; remaining: number } {
285
+ return queue.mutate((entries) => {
286
+ const kept: SuggestionEntry[] = []
287
+ const lines: string[] = []
288
+ entries.forEach((entry, idx) => {
289
+ const number = idx + 1
290
+ if (!indices.includes(number)) {
291
+ kept.push(entry)
292
+ return
293
+ }
294
+ const originTag = entry.target.startsWith('todo-') ? `\n(original track: ${entry.target})` : ''
295
+ const stamp = new Date().toISOString().slice(0, 10)
296
+ const stamped = `[${stamp}] ${entry.content}${originTag}${entry.reason ? `\n(archive reason: ${entry.reason})` : ''}`
297
+ // archive may be MaestroMemoryStore or ArchiveStore
298
+ let outcome: any
299
+ try {
300
+ if (archive?.append) {
301
+ // ArchiveStore path: needs cwd for key
302
+ if (entry.target === 'key' || entry.target.startsWith('todo-')) outcome = archive.append(entry.target, stamped, entry.cwd ?? undefined)
303
+ else outcome = archive.append(entry.target, stamped)
304
+ } else if (archive?.archive) {
305
+ outcome = archive.archive(entry.target, entry.content, entry.cwd)
306
+ } else {
307
+ outcome = { ok: false, message: 'archive not available' }
308
+ }
309
+ } catch (e: any) {
310
+ outcome = { ok: false, message: e?.message ?? String(e) }
311
+ }
312
+ // MaestroMemoryStore archive via atomic store uses raw string; treat any truthy ok
313
+ const ok = outcome?.ok === true || outcome === undefined
314
+ if (ok) {
315
+ lines.push(`#${number} [${entry.target}] archived`)
316
+ } else {
317
+ lines.push(`✗ #${number} [${entry.target}] ${outcome?.message ?? outcome?.error ?? 'archive failed'}`)
318
+ kept.push(entry)
319
+ }
320
+ })
321
+ entries.length = 0
322
+ entries.push(...kept)
323
+ return { lines, remaining: kept.length }
324
+ })
325
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * host/skills-browser.ts — M6 read-first skills browser
3
+ *
4
+ * Goal: expose existing Maestro skills without taking ownership.
5
+ * - Confirm boundary with maestro-skills and list metadata/origin only.
6
+ * - Read-only: no mutation, no write API.
7
+ * - Model suggestions cannot change skills.
8
+ * - Browser cannot alter maestro-skills discovery behavior (no provider registration, no writes).
9
+ *
10
+ * If later approved to edit, mutation must require explicit user action and
11
+ * symlink/path containment tests before any write support. This file prepares
12
+ * the containment helper (isPathContained) and documents the guard, but does
13
+ * not implement mutation in M6.
14
+ *
15
+ * NOTE for future mutation (NOT implemented in M6):
16
+ * Any future mutation endpoint must require an explicit user action
17
+ * (UI click with confirmation) and must verify path containment via
18
+ * isPathContained(targetPath, allowedRoot) including realpath resolution
19
+ * for symlinks, rejecting any traversal or symlink escape. Tests for
20
+ * symlink/path containment must pass before mutation is enabled.
21
+ */
22
+
23
+ import { existsSync, readdirSync, statSync, lstatSync, readFileSync } from 'node:fs'
24
+ import { join, resolve, relative } from 'node:path'
25
+
26
+ export interface SkillBrowseEntry {
27
+ name: string
28
+ description: string
29
+ origin: string
30
+ path: string
31
+ locator: string
32
+ metadata: Record<string, string>
33
+ }
34
+
35
+ export interface ListSkillsOpts {
36
+ roots?: Array<{ dir: string; origin: string }>
37
+ skillsDir?: string
38
+ origin?: string
39
+ }
40
+
41
+ /**
42
+ * Whether child is contained inside parent (no traversal escape).
43
+ * Uses resolved absolute paths and checks relative path does not start with "..".
44
+ * This helper prepares symlink/path containment tests required before any
45
+ * future mutation support — explicit user action + containment check.
46
+ */
47
+ export function isPathContained(child: string, parent: string): boolean {
48
+ const resolvedChild = resolve(child)
49
+ const resolvedParent = resolve(parent)
50
+ if (resolvedChild === resolvedParent) return true
51
+ const rel = relative(resolvedParent, resolvedChild)
52
+ // contained if rel does not start with ".." and is not absolute
53
+ return rel !== '' && !rel.startsWith('..') && !rel.startsWith('/')
54
+ }
55
+
56
+ function parseFrontmatter(rawContent: string): { metadata: Record<string, string>; body: string } {
57
+ if (!rawContent.startsWith('---')) return { metadata: {}, body: rawContent }
58
+ const endIdx = rawContent.indexOf('\n---', 3)
59
+ if (endIdx === -1) return { metadata: {}, body: rawContent }
60
+ const yamlLines = rawContent.slice(3, endIdx).split('\n')
61
+ const body = rawContent.slice(endIdx + 4).trim()
62
+ const metadata: Record<string, string> = {}
63
+ let blockKey: string | undefined
64
+ for (const line of yamlLines) {
65
+ const trimmed = line.trim()
66
+ const indent = line.length - trimmed.length
67
+ if (blockKey !== undefined) {
68
+ if (trimmed === '') {
69
+ blockKey = undefined
70
+ continue
71
+ }
72
+ if (indent > 0) {
73
+ metadata[blockKey] = (metadata[blockKey] + ' ' + trimmed).replace(/\s+/g, ' ').trim()
74
+ continue
75
+ }
76
+ blockKey = undefined
77
+ }
78
+ const colonIdx = trimmed.indexOf(':')
79
+ if (colonIdx <= 0) continue
80
+ const key = trimmed.slice(0, colonIdx).trim()
81
+ const val = trimmed.slice(colonIdx + 1).trim()
82
+ if (val.startsWith('|') || val.startsWith('>')) {
83
+ metadata[key] = ''
84
+ blockKey = key
85
+ continue
86
+ }
87
+ metadata[key] = val.startsWith('"') && val.endsWith('"') ? val.slice(1, -1) : val
88
+ }
89
+ return { metadata, body }
90
+ }
91
+
92
+ /**
93
+ * List skills from a single directory, read-only.
94
+ * - Scans <skillsDir>/* /SKILL.md
95
+ * - Parses frontmatter for name/description
96
+ * - Returns metadata/origin only, does not return body/content
97
+ * - Does not follow symlink escapes for future mutation; for listing we
98
+ * report the logical path, but future mutation will verify containment via
99
+ * isPathContained + realpath.
100
+ */
101
+ export function listSkillsSync(skillsDir: string, origin = 'custom'): SkillBrowseEntry[] {
102
+ if (!skillsDir) return []
103
+ let entries: string[] = []
104
+ try {
105
+ entries = readdirSync(skillsDir)
106
+ } catch {
107
+ return []
108
+ }
109
+ const out: SkillBrowseEntry[] = []
110
+ for (const entry of entries) {
111
+ const skillFolder = join(skillsDir, entry)
112
+ let st: any = null
113
+ try {
114
+ st = lstatSync(skillFolder)
115
+ } catch {
116
+ continue
117
+ }
118
+ // allow directory or symlink to directory
119
+ const isDir = st.isDirectory() || st.isSymbolicLink()
120
+ if (!isDir) continue
121
+ // verify it is a directory (follow symlink)
122
+ try {
123
+ const follow = statSync(skillFolder)
124
+ if (!follow.isDirectory()) continue
125
+ } catch {
126
+ continue
127
+ }
128
+ const skillFilePath = join(skillFolder, 'SKILL.md')
129
+ try {
130
+ const fileSt = statSync(skillFilePath)
131
+ if (!fileSt.isFile()) continue
132
+ } catch {
133
+ continue
134
+ }
135
+ let rawContent = ''
136
+ try {
137
+ rawContent = readFileSync(skillFilePath, 'utf8')
138
+ } catch {
139
+ continue
140
+ }
141
+ const { metadata } = parseFrontmatter(rawContent)
142
+ const skillName = metadata.name || entry
143
+ const description = metadata.description || `Skill for ${skillName}`
144
+ out.push({
145
+ name: skillName,
146
+ description,
147
+ origin,
148
+ path: skillFilePath,
149
+ locator: skillFilePath,
150
+ metadata,
151
+ })
152
+ }
153
+ return out
154
+ }
155
+
156
+ /**
157
+ * Async variant aggregating multiple roots.
158
+ * Each root is { dir, origin }. If only skillsDir is supplied, use that.
159
+ */
160
+ export async function listSkills(opts: ListSkillsOpts = {}): Promise<SkillBrowseEntry[]> {
161
+ if (opts.roots && opts.roots.length > 0) {
162
+ const all: SkillBrowseEntry[] = []
163
+ for (const r of opts.roots) {
164
+ const entries = listSkillsSync(r.dir, r.origin)
165
+ all.push(...entries)
166
+ }
167
+ return all
168
+ }
169
+ if (opts.skillsDir) {
170
+ return listSkillsSync(opts.skillsDir, opts.origin ?? 'custom')
171
+ }
172
+ // default: no dir => empty (caller should supply maestro-skills dir explicitly)
173
+ return []
174
+ }
175
+
176
+ /**
177
+ * Resolve default maestro-skills directory for browser (read-only).
178
+ * Tries well-known checkout location; returns null if not present.
179
+ * This does NOT alter maestro-skills discovery — it only reads the directory
180
+ * if it exists, for metadata/origin listing.
181
+ */
182
+ export function resolveDefaultMaestroSkillsDir(): string | null {
183
+ const candidates = [
184
+ '/home/kai/Work/htdocs/maestro-harness/maestro-skills/skills',
185
+ join(process.cwd(), '../maestro-skills/skills'),
186
+ ]
187
+ for (const c of candidates) {
188
+ if (existsSync(c)) return c
189
+ }
190
+ return null
191
+ }