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.
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/ace-pack.cmd +2 -0
- package/agent-memory-pack.cmd +2 -0
- package/install-ace-pack.cmd +2 -0
- package/install-ace-pack.mjs +261 -0
- package/install-agent-memory-pack.cmd +2 -0
- package/install-agent-memory-pack.mjs +17 -0
- package/logo.svg +25 -0
- package/package.json +49 -0
- package/scripts/ace-hub.mjs +137 -0
- package/scripts/ace-onboard.mjs +503 -0
- package/scripts/ace-project-presets.mjs +158 -0
- package/scripts/ace-universal-doc-templates.mjs +40 -0
- package/scripts/agent-memory-lib.mjs +141 -0
- package/scripts/agent-memory-templates.mjs +350 -0
- package/scripts/ai-memory-config.mjs +171 -0
- package/scripts/ai-memory-utils.mjs +372 -0
- package/scripts/ai-report-brief.mjs +131 -0
- package/scripts/ai-report-current-task-code.mjs +128 -0
- package/scripts/ai-report.mjs +185 -0
- package/scripts/ai-task-classify.mjs +236 -0
- package/scripts/ai-task-finish.mjs +206 -0
- package/scripts/ai-update.mjs +236 -0
- package/scripts/bootstrap-agent-memory.mjs +23 -0
- package/scripts/check-agent-memory.mjs +22 -0
|
@@ -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`)
|