ace-pack 0.1.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.
@@ -0,0 +1,171 @@
1
+ import path from 'node:path'
2
+
3
+ import { readTextIfExists } from './ai-memory-utils.mjs'
4
+
5
+ const DEFAULT_MEMORY_CONFIG = {
6
+ version: 1,
7
+ thresholds: {
8
+ small: {
9
+ maxFiles: 2,
10
+ maxDiffLines: 80,
11
+ },
12
+ large: {
13
+ minFiles: 8,
14
+ minDiffLines: 300,
15
+ },
16
+ },
17
+ highRiskPaths: [],
18
+ highRiskKeywords: [],
19
+ }
20
+
21
+ const VALID_TIERS = new Set(['small', 'standard', 'large'])
22
+
23
+ export async function readMemoryConfig(rootDir) {
24
+ const configPath = path.join(rootDir, '.ai', 'memory-config.json')
25
+ const content = await readTextIfExists(configPath)
26
+
27
+ if (content === null) {
28
+ return DEFAULT_MEMORY_CONFIG
29
+ }
30
+
31
+ return normalizeMemoryConfig(JSON.parse(content))
32
+ }
33
+
34
+ export function normalizeMemoryConfig(config) {
35
+ const thresholds = {
36
+ small: {
37
+ maxFiles: numberOrDefault(config?.thresholds?.small?.maxFiles, 2),
38
+ maxDiffLines: numberOrDefault(config?.thresholds?.small?.maxDiffLines, 80),
39
+ },
40
+ large: {
41
+ minFiles: numberOrDefault(config?.thresholds?.large?.minFiles, 8),
42
+ minDiffLines: numberOrDefault(config?.thresholds?.large?.minDiffLines, 300),
43
+ },
44
+ }
45
+
46
+ return {
47
+ version: numberOrDefault(config?.version, 1),
48
+ thresholds,
49
+ highRiskPaths: normalizeRules(config?.highRiskPaths),
50
+ highRiskKeywords: normalizeKeywordRules(config?.highRiskKeywords),
51
+ }
52
+ }
53
+
54
+ export function getRiskMatches({ changedFiles, config, diffText = '' }) {
55
+ const matches = []
56
+ const normalizedFiles = changedFiles.map(normalizeRepoPath)
57
+ const normalizedDiffText = diffText.toLowerCase()
58
+
59
+ for (const rule of config.highRiskPaths) {
60
+ const matchedFiles = normalizedFiles.filter((filePath) =>
61
+ matchConfigPattern(filePath, rule.pattern),
62
+ )
63
+
64
+ if (matchedFiles.length > 0) {
65
+ matches.push({
66
+ kind: 'path',
67
+ label: rule.label,
68
+ matched: matchedFiles,
69
+ pattern: rule.pattern,
70
+ requiresDesignReview: rule.requiresDesignReview,
71
+ tier: rule.tier,
72
+ })
73
+ }
74
+ }
75
+
76
+ for (const rule of config.highRiskKeywords) {
77
+ const keyword = rule.keyword.toLowerCase()
78
+ const pathMatches = normalizedFiles.filter((filePath) => keywordMatches(filePath, keyword))
79
+ const diffMatches = keywordMatches(normalizedDiffText, keyword)
80
+
81
+ if (pathMatches.length > 0 || diffMatches) {
82
+ matches.push({
83
+ kind: 'keyword',
84
+ label: rule.label,
85
+ matched: pathMatches,
86
+ pattern: rule.keyword,
87
+ requiresDesignReview: rule.requiresDesignReview,
88
+ tier: rule.tier,
89
+ })
90
+ }
91
+ }
92
+
93
+ return matches
94
+ }
95
+
96
+ export function matchConfigPattern(filePath, pattern) {
97
+ return globToRegExp(normalizeRepoPath(pattern)).test(normalizeRepoPath(filePath))
98
+ }
99
+
100
+ export function normalizeRepoPath(filePath) {
101
+ return filePath.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
102
+ }
103
+
104
+ function normalizeRules(value) {
105
+ if (!Array.isArray(value)) {
106
+ return []
107
+ }
108
+
109
+ return value
110
+ .map((rule) => {
111
+ if (typeof rule === 'string') {
112
+ return normalizeRule({ pattern: rule })
113
+ }
114
+
115
+ return normalizeRule(rule)
116
+ })
117
+ .filter((rule) => rule.pattern.length > 0)
118
+ }
119
+
120
+ function normalizeKeywordRules(value) {
121
+ if (!Array.isArray(value)) {
122
+ return []
123
+ }
124
+
125
+ return value
126
+ .map((rule) => {
127
+ if (typeof rule === 'string') {
128
+ return normalizeRule({ keyword: rule, label: rule })
129
+ }
130
+
131
+ return normalizeRule(rule)
132
+ })
133
+ .filter((rule) => rule.keyword.length > 0)
134
+ }
135
+
136
+ function normalizeRule(rule) {
137
+ const tier = VALID_TIERS.has(rule?.tier) ? rule.tier : 'large'
138
+ const pattern = typeof rule?.pattern === 'string' ? rule.pattern : ''
139
+ const keyword = typeof rule?.keyword === 'string' ? rule.keyword : ''
140
+ const label = typeof rule?.label === 'string' ? rule.label : pattern || keyword
141
+
142
+ return {
143
+ keyword,
144
+ label,
145
+ pattern,
146
+ requiresDesignReview: rule?.requiresDesignReview !== false,
147
+ tier,
148
+ }
149
+ }
150
+
151
+ function numberOrDefault(value, fallback) {
152
+ return Number.isFinite(value) ? value : fallback
153
+ }
154
+
155
+ function globToRegExp(pattern) {
156
+ const escapedPattern = pattern
157
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
158
+ .replace(/\*\*/g, '__DOUBLE_STAR__')
159
+ .replace(/\*/g, '[^/]*')
160
+ .replace(/__DOUBLE_STAR__/g, '.*')
161
+
162
+ return new RegExp(`^${escapedPattern}$`, 'u')
163
+ }
164
+
165
+ function keywordMatches(value, keyword) {
166
+ return new RegExp(`(^|[^a-z0-9])${escapeRegExp(keyword)}($|[^a-z0-9])`, 'iu').test(value)
167
+ }
168
+
169
+ function escapeRegExp(value) {
170
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
171
+ }
@@ -0,0 +1,372 @@
1
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ export const ACE_BANNER = '[ACE] Agentic Context Engine initialized...'
5
+
6
+ const timestampFormatter = new Intl.DateTimeFormat('sv-SE', {
7
+ day: '2-digit',
8
+ hour: '2-digit',
9
+ hour12: false,
10
+ minute: '2-digit',
11
+ month: '2-digit',
12
+ year: 'numeric',
13
+ })
14
+
15
+ export function normalizeTrailingNewline(content) {
16
+ return content.endsWith('\n') ? content : `${content}\n`
17
+ }
18
+
19
+ export async function readTextIfExists(filePath) {
20
+ try {
21
+ return await readFile(filePath, 'utf8')
22
+ } catch (error) {
23
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
24
+ return null
25
+ }
26
+
27
+ throw error
28
+ }
29
+ }
30
+
31
+ export async function writeTextFile(filePath, content) {
32
+ await mkdir(path.dirname(filePath), { recursive: true })
33
+ await writeFile(filePath, normalizeTrailingNewline(content), 'utf8')
34
+ }
35
+
36
+ export async function readFileTimestamp(filePath) {
37
+ try {
38
+ return (await stat(filePath)).mtime
39
+ } catch (error) {
40
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
41
+ return null
42
+ }
43
+
44
+ throw error
45
+ }
46
+ }
47
+
48
+ export function formatTimestamp(value) {
49
+ return timestampFormatter.format(value)
50
+ }
51
+
52
+ export function nowTimestamp() {
53
+ return formatTimestamp(new Date())
54
+ }
55
+
56
+ export function writeAceBanner(output = process.stderr) {
57
+ output.write(`${ACE_BANNER}\n`)
58
+ }
59
+
60
+ export function parseCliArgs(args) {
61
+ const parsed = {}
62
+
63
+ for (let index = 0; index < args.length; index += 1) {
64
+ const token = args[index]
65
+
66
+ if (!token.startsWith('--')) {
67
+ continue
68
+ }
69
+
70
+ const key = token.slice(2)
71
+ const nextToken = args[index + 1]
72
+ const value = nextToken && !nextToken.startsWith('--') ? nextToken : 'true'
73
+
74
+ if (value !== 'true') {
75
+ index += 1
76
+ }
77
+
78
+ const currentValue = parsed[key]
79
+
80
+ if (currentValue === undefined) {
81
+ parsed[key] = value
82
+ continue
83
+ }
84
+
85
+ if (Array.isArray(currentValue)) {
86
+ currentValue.push(value)
87
+ continue
88
+ }
89
+
90
+ parsed[key] = [currentValue, value]
91
+ }
92
+
93
+ return parsed
94
+ }
95
+
96
+ export function getArgList(args, key) {
97
+ const value = args[key]
98
+
99
+ if (value === undefined) {
100
+ return []
101
+ }
102
+
103
+ return Array.isArray(value) ? value : [value]
104
+ }
105
+
106
+ export function getArgValue(args, key) {
107
+ const value = args[key]
108
+
109
+ if (Array.isArray(value)) {
110
+ return value.at(-1)
111
+ }
112
+
113
+ return value
114
+ }
115
+
116
+ export function formatBullets(items, fallback = '- [Add content here]') {
117
+ if (items.length === 0) {
118
+ return fallback
119
+ }
120
+
121
+ return items.map((item) => `- ${item}`).join('\n')
122
+ }
123
+
124
+ export function formatChecklist(items, fallback = '- [ ] Add checklist item') {
125
+ if (items.length === 0) {
126
+ return fallback
127
+ }
128
+
129
+ return items.map((item) => (item.startsWith('- [') ? item : `- [ ] ${item}`)).join('\n')
130
+ }
131
+
132
+ export function replaceMarkdownSection(content, heading, body) {
133
+ const lines = content.split(/\r?\n/)
134
+ const startIndex = lines.findIndex((line) => line === `## ${heading}`)
135
+
136
+ if (startIndex === -1) {
137
+ return content
138
+ }
139
+
140
+ let endIndex = lines.length
141
+
142
+ for (let index = startIndex + 1; index < lines.length; index += 1) {
143
+ if (lines[index].startsWith('## ')) {
144
+ endIndex = index
145
+ break
146
+ }
147
+ }
148
+
149
+ const replacementLines = [`## ${heading}`, ...body.trimEnd().split('\n')]
150
+ const nextLines = [
151
+ ...lines.slice(0, startIndex),
152
+ ...replacementLines,
153
+ '',
154
+ ...lines.slice(endIndex),
155
+ ]
156
+
157
+ return normalizeTrailingNewline(nextLines.join('\n')).replace(/\n{3,}/g, '\n\n')
158
+ }
159
+
160
+ export function extractMarkdownSection(content, heading) {
161
+ const lines = content.split(/\r?\n/)
162
+ const startIndex = lines.findIndex((line) => line === `## ${heading}`)
163
+
164
+ if (startIndex === -1) {
165
+ return ''
166
+ }
167
+
168
+ let endIndex = lines.length
169
+
170
+ for (let index = startIndex + 1; index < lines.length; index += 1) {
171
+ if (lines[index].startsWith('## ')) {
172
+ endIndex = index
173
+ break
174
+ }
175
+ }
176
+
177
+ return lines
178
+ .slice(startIndex + 1, endIndex)
179
+ .join('\n')
180
+ .trim()
181
+ }
182
+
183
+ export function replaceLabeledValue(content, label, value) {
184
+ const pattern = new RegExp(`^${escapeRegExp(label)}:.*$`, 'm')
185
+ return content.replace(pattern, `${label}: ${value}`)
186
+ }
187
+
188
+ export function countCheckboxes(content) {
189
+ const total = (content.match(/^- \[(?: |x)\]/gm) ?? []).length
190
+ const complete = (content.match(/^- \[x\]/gm) ?? []).length
191
+
192
+ return { complete, total }
193
+ }
194
+
195
+ export function extractTopDecision(content) {
196
+ const match = content.match(/^## .+?(?:\r?\n[\s\S]*?)(?=^## |\Z)/m)
197
+ return match ? match[0].trim() : ''
198
+ }
199
+
200
+ export function extractChangedFileTitles(content, limit = 8) {
201
+ const headings = [...content.matchAll(/^\[(.+?)\]$/gm)].map((match) => match[1])
202
+ const recentPaths = []
203
+
204
+ for (const heading of headings) {
205
+ if (!isChangedPathHeading(heading) || recentPaths.includes(heading)) {
206
+ continue
207
+ }
208
+
209
+ recentPaths.push(heading)
210
+
211
+ if (recentPaths.length >= limit) {
212
+ break
213
+ }
214
+ }
215
+
216
+ return recentPaths
217
+ }
218
+
219
+ export function extractUnresolvedReflections(content, limit = 5) {
220
+ const unresolvedSection = extractMarkdownSection(content, 'Unresolved')
221
+
222
+ if (!unresolvedSection) {
223
+ return []
224
+ }
225
+
226
+ const entries = extractHeadingBlocks(unresolvedSection, '###')
227
+ .filter((entry) => !/Status:\s*resolved/i.test(entry.body))
228
+ .filter((entry) => !/\[[^\]]+\]/.test(`${entry.title}\n${entry.body}`))
229
+
230
+ return entries.slice(0, limit).map((entry) => {
231
+ const summary =
232
+ entry.body
233
+ .split(/\r?\n/)
234
+ .map((line) => line.replace(/^- /, '').trim())
235
+ .find((line) => line.length > 0 && !line.startsWith('Status:')) ?? 'No summary recorded.'
236
+
237
+ return `${entry.title} - ${summary}`
238
+ })
239
+ }
240
+
241
+ function extractHeadingBlocks(content, headingMarker) {
242
+ const lines = content.split(/\r?\n/)
243
+ const entries = []
244
+ let currentEntry = null
245
+
246
+ for (const line of lines) {
247
+ if (line.startsWith(`${headingMarker} `)) {
248
+ if (currentEntry) {
249
+ entries.push(currentEntry)
250
+ }
251
+
252
+ currentEntry = {
253
+ bodyLines: [],
254
+ title: line.slice(headingMarker.length + 1).trim(),
255
+ }
256
+ continue
257
+ }
258
+
259
+ if (currentEntry) {
260
+ currentEntry.bodyLines.push(line)
261
+ }
262
+ }
263
+
264
+ if (currentEntry) {
265
+ entries.push(currentEntry)
266
+ }
267
+
268
+ return entries.map((entry) => ({
269
+ body: entry.bodyLines.join('\n').trim(),
270
+ title: entry.title,
271
+ }))
272
+ }
273
+
274
+ export function normalizeStackText(content) {
275
+ return content
276
+ .replace(/•/g, ' | ')
277
+ .replace(/[•·]/g, ' | ')
278
+ .replace(/\s*\|\s*/g, ' | ')
279
+ .replace(/\s+/g, ' ')
280
+ .trim()
281
+ }
282
+
283
+ export function summarizeVerification(content, limit = 4) {
284
+ const checks = extractBulletBlocks(content)
285
+ const signals = checks.join('\n').toLowerCase()
286
+
287
+ let level = 'not recorded'
288
+
289
+ if (/smoke|browser|playwright|manual|loaded /.test(signals)) {
290
+ level = 'smoke-tested'
291
+ } else if (/\btest\b|vitest|passed/.test(signals)) {
292
+ level = 'test-backed'
293
+ } else if (/typecheck|tsc/.test(signals)) {
294
+ level = 'typecheck-only'
295
+ } else if (/\blint\b/.test(signals)) {
296
+ level = 'static checks only'
297
+ }
298
+
299
+ return {
300
+ checks: checks.slice(0, limit),
301
+ level,
302
+ }
303
+ }
304
+
305
+ export function getFreshnessStatus(generatedAt, ...sourceDates) {
306
+ const latestSource = sourceDates
307
+ .filter((value) => value instanceof Date)
308
+ .sort((left, right) => right.getTime() - left.getTime())
309
+ .at(0)
310
+
311
+ if (!latestSource) {
312
+ return 'Possibly stale'
313
+ }
314
+
315
+ return generatedAt.getTime() >= latestSource.getTime() ? 'Fresh' : 'Possibly stale'
316
+ }
317
+
318
+ export function extractLabeledValue(content, label) {
319
+ const pattern = new RegExp(`^${escapeRegExp(label)}:\\s*(.+)$`, 'm')
320
+ return content.match(pattern)?.[1]?.trim() ?? ''
321
+ }
322
+
323
+ function isChangedPathHeading(heading) {
324
+ if (/\s-\s\d{4}-\d{2}-\d{2}(?:[ -]\d{2}:\d{2})?$/.test(heading)) {
325
+ return false
326
+ }
327
+
328
+ return (
329
+ heading.startsWith('.') ||
330
+ heading.includes('*') ||
331
+ heading.includes('/') ||
332
+ heading.includes('\\') ||
333
+ /\.[a-z0-9]+(?:$|\s)/i.test(heading)
334
+ )
335
+ }
336
+
337
+ function extractBulletBlocks(content) {
338
+ const lines = content.split(/\r?\n/)
339
+ const blocks = []
340
+ let currentBlock = []
341
+
342
+ for (const line of lines) {
343
+ if (line.startsWith('- ')) {
344
+ if (currentBlock.length > 0) {
345
+ blocks.push(currentBlock.join('\n').trim())
346
+ }
347
+
348
+ currentBlock = [line.slice(2)]
349
+ continue
350
+ }
351
+
352
+ if (currentBlock.length > 0 && (/^\s{2,}\S/.test(line) || line.trim() === '')) {
353
+ currentBlock.push(line.trim())
354
+ continue
355
+ }
356
+
357
+ if (currentBlock.length > 0) {
358
+ blocks.push(currentBlock.join('\n').trim())
359
+ currentBlock = []
360
+ }
361
+ }
362
+
363
+ if (currentBlock.length > 0) {
364
+ blocks.push(currentBlock.join('\n').trim())
365
+ }
366
+
367
+ return blocks.filter(Boolean)
368
+ }
369
+
370
+ function escapeRegExp(value) {
371
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
372
+ }
@@ -0,0 +1,131 @@
1
+ import path from 'node:path'
2
+
3
+ import {
4
+ countCheckboxes,
5
+ extractChangedFileTitles,
6
+ extractLabeledValue,
7
+ extractMarkdownSection,
8
+ extractTopDecision,
9
+ extractUnresolvedReflections,
10
+ formatTimestamp,
11
+ getFreshnessStatus,
12
+ normalizeStackText,
13
+ readFileTimestamp,
14
+ readTextIfExists,
15
+ summarizeVerification,
16
+ writeTextFile,
17
+ } from './ai-memory-utils.mjs'
18
+
19
+ const rootDir = process.argv[2] ? path.resolve(process.cwd(), process.argv[2]) : process.cwd()
20
+ const aiDir = path.join(rootDir, '.ai')
21
+ const currentTaskPath = path.join(aiDir, 'current-task.md')
22
+ const handoffPath = path.join(aiDir, 'session-handoff.md')
23
+
24
+ const [
25
+ packageJsonContent,
26
+ agentsContent,
27
+ currentTaskContent,
28
+ handoffContent,
29
+ decisionsContent,
30
+ changedFilesContent,
31
+ reflectionLogContent,
32
+ currentTaskTimestamp,
33
+ handoffTimestamp,
34
+ ] = await Promise.all([
35
+ readTextIfExists(path.join(rootDir, 'package.json')),
36
+ readTextIfExists(path.join(rootDir, 'AGENTS.md')),
37
+ readTextIfExists(currentTaskPath),
38
+ readTextIfExists(handoffPath),
39
+ readTextIfExists(path.join(aiDir, 'decisions.md')),
40
+ readTextIfExists(path.join(aiDir, 'changed-files.md')),
41
+ readTextIfExists(path.join(aiDir, 'reflection-log.md')),
42
+ readFileTimestamp(currentTaskPath),
43
+ readFileTimestamp(handoffPath),
44
+ ])
45
+
46
+ if (
47
+ packageJsonContent === null ||
48
+ agentsContent === null ||
49
+ currentTaskContent === null ||
50
+ handoffContent === null ||
51
+ decisionsContent === null ||
52
+ changedFilesContent === null
53
+ ) {
54
+ throw new Error('Missing package, AGENTS.md, or required .ai/* files for ai:report:brief.')
55
+ }
56
+
57
+ const packageJson = JSON.parse(packageJsonContent)
58
+ const lifecycle = extractMarkdownSection(currentTaskContent, 'Lifecycle')
59
+ const currentStatus = extractMarkdownSection(currentTaskContent, 'Current Status')
60
+ const nextSteps = extractMarkdownSection(handoffContent, 'Next Steps')
61
+ const knownIssues = extractMarkdownSection(handoffContent, 'Known Issues')
62
+ const verification = summarizeVerification(extractMarkdownSection(handoffContent, 'Verification'))
63
+ const generatedAt = new Date()
64
+ const stack = normalizeStackText(extractMarkdownSection(agentsContent, 'Stack (non-negotiable)'))
65
+ const checklist = countCheckboxes(
66
+ extractMarkdownSection(currentTaskContent, 'Completion Checklist'),
67
+ )
68
+ const changedAreas = extractChangedFileTitles(changedFilesContent, 6)
69
+ const topDecision = extractTopDecision(decisionsContent)
70
+ const unresolvedReflections = extractUnresolvedReflections(reflectionLogContent ?? '', 5)
71
+ const freshness = getFreshnessStatus(generatedAt, currentTaskTimestamp, handoffTimestamp)
72
+ const currentTaskVersion = extractLabeledValue(lifecycle, 'Version') || 'unknown'
73
+ const currentTaskTier = extractLabeledValue(lifecycle, 'Task Tier') || 'unknown'
74
+
75
+ const briefReport = `# AI Brief Report
76
+
77
+ Project: \`${packageJson.name}\`
78
+
79
+ ## Report Metadata
80
+ - Generated: ${formatTimestamp(generatedAt)}
81
+ - Freshness: ${freshness}
82
+ - Current task version: ${currentTaskVersion}
83
+ - Current task tier: ${currentTaskTier}
84
+ - Source current-task: ${currentTaskTimestamp ? formatTimestamp(currentTaskTimestamp) : 'Unknown'}
85
+ - Source session-handoff: ${handoffTimestamp ? formatTimestamp(handoffTimestamp) : 'Unknown'}
86
+ - Verification level: ${verification.level}
87
+
88
+ ## Stack
89
+ ${stack}
90
+
91
+ ## Current Task
92
+ ${extractMarkdownSection(currentTaskContent, 'Feature Name')}
93
+
94
+ ## Lifecycle
95
+ ${lifecycle}
96
+
97
+ ## Goal
98
+ ${extractMarkdownSection(currentTaskContent, 'Goal')}
99
+
100
+ ## Business Value
101
+ ${extractMarkdownSection(currentTaskContent, 'Business Value / Product Alignment') || '- Not recorded.'}
102
+
103
+ ## Current Status
104
+ ${currentStatus}
105
+
106
+ ## Next Steps
107
+ ${nextSteps || '- No next steps recorded.'}
108
+
109
+ ## Risks / Blockers
110
+ ${knownIssues || '- No known issues recorded.'}
111
+
112
+ ## Verification
113
+ ${verification.checks.length > 0 ? verification.checks.map((item) => `- ${item}`).join('\n') : '- No verification recorded.'}
114
+
115
+ ## Recent Decision
116
+ ${topDecision || 'No durable decisions recorded yet.'}
117
+
118
+ ## Unresolved Reflections
119
+ ${unresolvedReflections.length > 0 ? unresolvedReflections.map((item) => `- ${item}`).join('\n') : '- No unresolved reflections recorded.'}
120
+
121
+ ## Changed Areas
122
+ ${changedAreas.length > 0 ? changedAreas.map((item) => `- \`${item}\``).join('\n') : '- No changed files recorded.'}
123
+
124
+ ## Overall Progress
125
+ - Completion checklist: ${checklist.complete}/${checklist.total}
126
+ - Source of truth: \`.ai/*\` files remain authoritative.
127
+ `
128
+
129
+ const outputPath = path.join(aiDir, 'report-brief.md')
130
+ await writeTextFile(outputPath, briefReport)
131
+ process.stderr.write(`Generated ${outputPath}\n`)