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,128 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { readFile } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { promisify } from 'node:util'
5
+
6
+ import { formatTimestamp, readTextIfExists, writeTextFile } from './ai-memory-utils.mjs'
7
+
8
+ const execFileAsync = promisify(execFile)
9
+
10
+ const rootDir = process.argv[2] ? path.resolve(process.cwd(), process.argv[2]) : process.cwd()
11
+ const aiDir = path.join(rootDir, '.ai')
12
+ const outputMdPath = path.join(aiDir, 'report-current-task-code.md')
13
+ const outputXmlPath = path.join(aiDir, 'report-current-task-code.xml')
14
+
15
+ const packageJsonContent = await readTextIfExists(path.join(rootDir, 'package.json'))
16
+ if (packageJsonContent === null) {
17
+ throw new Error('Missing package.json for ai:report:current-task-code.')
18
+ }
19
+
20
+ const packageJson = JSON.parse(packageJsonContent)
21
+ const generatedAt = new Date()
22
+
23
+ const isCodeFile = (filePath) =>
24
+ !filePath.startsWith('.ai/') &&
25
+ !filePath.startsWith('node_modules/') &&
26
+ (/\.(ts|tsx|js|mjs|cjs|json)$/u.test(filePath) || filePath === 'package.json')
27
+
28
+ const getTrackedAndUntrackedFiles = async () => {
29
+ const [statusResult, untrackedResult] = await Promise.all([
30
+ execFileAsync('git', ['status', '--porcelain'], { cwd: rootDir, encoding: 'utf8' }),
31
+ execFileAsync('git', ['ls-files', '--others', '--exclude-standard'], {
32
+ cwd: rootDir,
33
+ encoding: 'utf8',
34
+ }),
35
+ ])
36
+
37
+ const trackedModifiedFiles = statusResult.stdout
38
+ .split('\n')
39
+ .map((line) => line.trimEnd())
40
+ .filter(Boolean)
41
+ .map((line) => line.slice(3))
42
+ .filter((filePath) => filePath.length > 0)
43
+
44
+ const untrackedFiles = untrackedResult.stdout
45
+ .split('\n')
46
+ .map((line) => line.trimEnd())
47
+ .filter(Boolean)
48
+
49
+ return [...new Set([...trackedModifiedFiles, ...untrackedFiles])].filter(isCodeFile)
50
+ }
51
+
52
+ const safeReadFile = async (filePath) => {
53
+ try {
54
+ return await readFile(path.join(rootDir, filePath), 'utf8')
55
+ } catch (error) {
56
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
57
+ return null
58
+ }
59
+
60
+ throw error
61
+ }
62
+ }
63
+
64
+ const escapeXml = (value) =>
65
+ value
66
+ .replace(/&/g, '&')
67
+ .replace(/</g, '&lt;')
68
+ .replace(/>/g, '&gt;')
69
+ .replace(/"/g, '&quot;')
70
+ .replace(/'/g, '&apos;')
71
+
72
+ const codeFiles = await getTrackedAndUntrackedFiles()
73
+ const fileEntries = await Promise.all(
74
+ codeFiles.map(async (filePath) => ({
75
+ path: filePath,
76
+ content: await safeReadFile(filePath),
77
+ })),
78
+ )
79
+
80
+ const entries = fileEntries.filter((entry) => entry.content !== null)
81
+ const missingFiles = fileEntries.filter((entry) => entry.content === null)
82
+
83
+ const md = [
84
+ '# AI Current Task Code Report',
85
+ '',
86
+ `Project: \`${packageJson.name}\``,
87
+ `Generated: ${formatTimestamp(generatedAt)}`,
88
+ '',
89
+ ...(missingFiles.length > 0
90
+ ? [
91
+ '## Missing Files',
92
+ ...missingFiles.map((entry) => `- \`${entry.path}\` (file missing at report time)`),
93
+ '',
94
+ ]
95
+ : []),
96
+ '## Files',
97
+ ...entries.flatMap((entry) => [
98
+ `### \`${entry.path}\``,
99
+ '```ts',
100
+ entry.content.trimEnd(),
101
+ '```',
102
+ '',
103
+ ]),
104
+ ].join('\n')
105
+
106
+ const xml = [
107
+ '<?xml version="1.0" encoding="UTF-8"?>',
108
+ `<current-task-code-report project="${escapeXml(packageJson.name)}" generated="${escapeXml(formatTimestamp(generatedAt))}">`,
109
+ ...(missingFiles.length > 0
110
+ ? [
111
+ ' <missing-files>',
112
+ ...missingFiles.map(
113
+ (entry) => ` <file path="${escapeXml(entry.path)}" reason="missing-at-report-time" />`,
114
+ ),
115
+ ' </missing-files>',
116
+ ]
117
+ : []),
118
+ ...entries.map(
119
+ (entry) =>
120
+ ` <file path="${escapeXml(entry.path)}"><![CDATA[${entry.content.trimEnd()}]]></file>`,
121
+ ),
122
+ '</current-task-code-report>',
123
+ ].join('\n')
124
+
125
+ await writeTextFile(outputMdPath, md)
126
+ await writeTextFile(outputXmlPath, xml)
127
+
128
+ process.stderr.write(`Generated ${outputMdPath}\nGenerated ${outputXmlPath}\n`)
@@ -0,0 +1,185 @@
1
+ import { spawn } from 'node:child_process'
2
+ import path from 'node:path'
3
+
4
+ import {
5
+ countCheckboxes,
6
+ extractChangedFileTitles,
7
+ extractLabeledValue,
8
+ extractMarkdownSection,
9
+ extractTopDecision,
10
+ extractUnresolvedReflections,
11
+ formatTimestamp,
12
+ getFreshnessStatus,
13
+ normalizeStackText,
14
+ readFileTimestamp,
15
+ readTextIfExists,
16
+ summarizeVerification,
17
+ writeTextFile,
18
+ } from './ai-memory-utils.mjs'
19
+
20
+ const rootDir = process.argv[2] ? path.resolve(process.cwd(), process.argv[2]) : process.cwd()
21
+ const aiDir = path.join(rootDir, '.ai')
22
+ const fullReportPath = path.join(aiDir, 'report-full.md')
23
+ const xmlReportPath = path.join(aiDir, 'report-full.xml')
24
+ const currentTaskPath = path.join(aiDir, 'current-task.md')
25
+ const handoffPath = path.join(aiDir, 'session-handoff.md')
26
+ const shouldSkipXml = process.env.AI_REPORT_SKIP_XML === '1'
27
+
28
+ const [
29
+ packageJsonContent,
30
+ agentsContent,
31
+ currentTaskContent,
32
+ handoffContent,
33
+ decisionsContent,
34
+ changedFilesContent,
35
+ workLogContent,
36
+ reflectionLogContent,
37
+ currentTaskTimestamp,
38
+ handoffTimestamp,
39
+ ] = await Promise.all([
40
+ readTextIfExists(path.join(rootDir, 'package.json')),
41
+ readTextIfExists(path.join(rootDir, 'AGENTS.md')),
42
+ readTextIfExists(currentTaskPath),
43
+ readTextIfExists(handoffPath),
44
+ readTextIfExists(path.join(aiDir, 'decisions.md')),
45
+ readTextIfExists(path.join(aiDir, 'changed-files.md')),
46
+ readTextIfExists(path.join(aiDir, 'work-log.md')),
47
+ readTextIfExists(path.join(aiDir, 'reflection-log.md')),
48
+ readFileTimestamp(currentTaskPath),
49
+ readFileTimestamp(handoffPath),
50
+ ])
51
+
52
+ if (
53
+ packageJsonContent === null ||
54
+ agentsContent === null ||
55
+ currentTaskContent === null ||
56
+ handoffContent === null ||
57
+ decisionsContent === null ||
58
+ changedFilesContent === null ||
59
+ workLogContent === null
60
+ ) {
61
+ throw new Error('Missing package, AGENTS.md, or required .ai/* files for ai:report.')
62
+ }
63
+
64
+ const packageJson = JSON.parse(packageJsonContent)
65
+ const lifecycle = extractMarkdownSection(currentTaskContent, 'Lifecycle')
66
+ const generatedAt = new Date()
67
+ const checklist = countCheckboxes(
68
+ extractMarkdownSection(currentTaskContent, 'Completion Checklist'),
69
+ )
70
+ const changedAreas = extractChangedFileTitles(changedFilesContent, 8)
71
+ const verification = summarizeVerification(
72
+ extractMarkdownSection(handoffContent, 'Verification'),
73
+ 6,
74
+ )
75
+ const freshness = getFreshnessStatus(generatedAt, currentTaskTimestamp, handoffTimestamp)
76
+ const currentTaskVersion = extractLabeledValue(lifecycle, 'Version') || 'unknown'
77
+ const currentTaskTier = extractLabeledValue(lifecycle, 'Task Tier') || 'unknown'
78
+ const unresolvedReflections = extractUnresolvedReflections(reflectionLogContent ?? '', 5)
79
+ const fullReport = `# AI Full Report
80
+
81
+ Project: \`${packageJson.name}\`
82
+
83
+ ## Report Metadata
84
+ - Generated: ${formatTimestamp(generatedAt)}
85
+ - Freshness: ${freshness}
86
+ - Current task version: ${currentTaskVersion}
87
+ - Current task tier: ${currentTaskTier}
88
+ - Source current-task: ${currentTaskTimestamp ? formatTimestamp(currentTaskTimestamp) : 'Unknown'}
89
+ - Source session-handoff: ${handoffTimestamp ? formatTimestamp(handoffTimestamp) : 'Unknown'}
90
+ - Verification level: ${verification.level}
91
+
92
+ ## Stack
93
+ ${normalizeStackText(extractMarkdownSection(agentsContent, 'Stack (non-negotiable)'))}
94
+
95
+ ## Architecture Rules
96
+ ${extractMarkdownSection(agentsContent, 'Architecture rules').trim()}
97
+
98
+ ## Current Task
99
+ ${extractMarkdownSection(currentTaskContent, 'Feature Name')}
100
+
101
+ ## Lifecycle
102
+ ${lifecycle}
103
+
104
+ ## Goal
105
+ ${extractMarkdownSection(currentTaskContent, 'Goal')}
106
+
107
+ ## Business Value
108
+ ${extractMarkdownSection(currentTaskContent, 'Business Value / Product Alignment') || '- Not recorded.'}
109
+
110
+ ## Technical Approach
111
+ ${extractMarkdownSection(currentTaskContent, 'Technical Approach') || '- Not recorded.'}
112
+
113
+ ## Current Status
114
+ ${extractMarkdownSection(currentTaskContent, 'Current Status')}
115
+
116
+ ## What Was Done
117
+ ${extractMarkdownSection(handoffContent, 'What Was Done')}
118
+
119
+ ## Current State
120
+ ${extractMarkdownSection(handoffContent, 'Current State')}
121
+
122
+ ## Next Steps
123
+ ${extractMarkdownSection(handoffContent, 'Next Steps')}
124
+
125
+ ## Known Issues
126
+ ${extractMarkdownSection(handoffContent, 'Known Issues')}
127
+
128
+ ## Quality Review
129
+ ${extractMarkdownSection(handoffContent, 'Quality Review') || '- Not recorded.'}
130
+
131
+ ## Verification
132
+ ${verification.checks.length > 0 ? verification.checks.map((item) => `- ${item}`).join('\n') : '- No verification recorded.'}
133
+
134
+ ## Recent Decisions
135
+ ${extractTopDecision(decisionsContent) || 'No durable decisions recorded yet.'}
136
+
137
+ ## Changed Areas
138
+ ${changedAreas.length > 0 ? changedAreas.map((item) => `- \`${item}\``).join('\n') : '- No changed files recorded.'}
139
+
140
+ ## Latest Work Log
141
+ ${workLogContent.trim()}
142
+
143
+ ## Unresolved Reflections
144
+ ${unresolvedReflections.length > 0 ? unresolvedReflections.map((item) => `- ${item}`).join('\n') : '- No unresolved reflections recorded.'}
145
+
146
+ ## Overall Progress
147
+ - Completion checklist: ${checklist.complete}/${checklist.total}
148
+ - Canonical context lives in \`.ai/*\`.
149
+ - XML bundle generated at \`.ai/report-full.xml\` for parsable handoff.
150
+ `
151
+
152
+ await writeTextFile(fullReportPath, fullReport)
153
+ if (!shouldSkipXml) {
154
+ await runRepomix(rootDir)
155
+ }
156
+ process.stderr.write(`Generated ${fullReportPath}\n`)
157
+
158
+ if (!shouldSkipXml) {
159
+ process.stderr.write(`Generated ${xmlReportPath}\n`)
160
+ }
161
+
162
+ async function runRepomix(cwd) {
163
+ const command =
164
+ process.platform === 'win32'
165
+ ? 'pnpm.cmd dlx repomix --include ".ai/current-task.md,.ai/session-handoff.md,.ai/work-log.md,.ai/changed-files.md,.ai/decisions.md,.ai/reflection-log.md,.ai/memory-config.json,AGENTS.md" --output .ai/report-full.xml --style xml --parsable-style --no-default-patterns'
166
+ : 'pnpm dlx repomix --include ".ai/current-task.md,.ai/session-handoff.md,.ai/work-log.md,.ai/changed-files.md,.ai/decisions.md,.ai/reflection-log.md,.ai/memory-config.json,AGENTS.md" --output .ai/report-full.xml --style xml --parsable-style --no-default-patterns'
167
+
168
+ await new Promise((resolve, reject) => {
169
+ const child = spawn(command, [], {
170
+ cwd,
171
+ shell: true,
172
+ stdio: 'inherit',
173
+ })
174
+
175
+ child.on('error', reject)
176
+ child.on('exit', (code) => {
177
+ if (code === 0) {
178
+ resolve()
179
+ return
180
+ }
181
+
182
+ reject(new Error(`repomix exited with code ${code ?? 'unknown'}`))
183
+ })
184
+ })
185
+ }
@@ -0,0 +1,236 @@
1
+ import { execFile } from 'node:child_process'
2
+ import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { promisify } from 'node:util'
5
+
6
+ import { getRiskMatches, normalizeRepoPath, readMemoryConfig } from './ai-memory-config.mjs'
7
+ import { getArgValue, parseCliArgs, writeAceBanner } from './ai-memory-utils.mjs'
8
+
9
+ const execFileAsync = promisify(execFile)
10
+ const TIER_RANK = {
11
+ small: 0,
12
+ standard: 1,
13
+ large: 2,
14
+ }
15
+
16
+ export async function classifyRepositoryTask(rootDir, options = {}) {
17
+ const [changedFiles, diffStats, diffText, config] = await Promise.all([
18
+ getChangedFiles(rootDir),
19
+ getDiffStats(rootDir),
20
+ getDiffText(rootDir),
21
+ readMemoryConfig(rootDir),
22
+ ])
23
+
24
+ return classifyTask({
25
+ changedFiles,
26
+ config,
27
+ diffLineCount: diffStats.diffLineCount,
28
+ diffText,
29
+ overrideReason: options.overrideReason,
30
+ overrideTier: options.overrideTier,
31
+ })
32
+ }
33
+
34
+ export function classifyTask({
35
+ changedFiles,
36
+ config,
37
+ diffLineCount,
38
+ diffText = '',
39
+ overrideReason,
40
+ overrideTier,
41
+ }) {
42
+ const normalizedFiles = [...new Set(changedFiles.map(normalizeRepoPath))].sort()
43
+ const riskMatches = getRiskMatches({
44
+ changedFiles: normalizedFiles,
45
+ config,
46
+ diffText,
47
+ })
48
+ const reasons = []
49
+ let detectedTier = getBaselineTier(normalizedFiles.length, diffLineCount, config)
50
+
51
+ if (normalizedFiles.length === 0 && diffLineCount === 0) {
52
+ reasons.push('No working-tree changes detected.')
53
+ } else {
54
+ reasons.push(`${normalizedFiles.length} changed file(s), ${diffLineCount} diff line(s).`)
55
+ }
56
+
57
+ for (const match of riskMatches) {
58
+ detectedTier = maxTier(detectedTier, match.tier)
59
+ reasons.push(`${match.label} risk matched ${match.kind} rule \`${match.pattern}\`.`)
60
+ }
61
+
62
+ const finalTier = applyOverride(detectedTier, overrideTier, overrideReason)
63
+ const designReviewRequired =
64
+ finalTier === 'large' || riskMatches.some((match) => match.requiresDesignReview)
65
+
66
+ if (overrideTier && overrideTier !== detectedTier) {
67
+ reasons.push(`Override selected \`${overrideTier}\`: ${overrideReason}`)
68
+ }
69
+
70
+ return {
71
+ changedFiles: normalizedFiles,
72
+ detectedTier,
73
+ designReviewRequired,
74
+ diffLineCount,
75
+ reasons,
76
+ requiredWorkflow: getRequiredWorkflow(finalTier, designReviewRequired),
77
+ riskMatches,
78
+ tier: finalTier,
79
+ }
80
+ }
81
+
82
+ export function getRequiredWorkflow(tier, designReviewRequired) {
83
+ const workflow = [
84
+ 'Update .ai/session-handoff.md with latest state.',
85
+ 'Update .ai/changed-files.md for touched areas.',
86
+ 'Append a compact .ai/work-log.md entry.',
87
+ 'Generate pnpm ace:report:brief.',
88
+ ]
89
+
90
+ if (tier === 'standard' || tier === 'large') {
91
+ workflow.push('Fill product, architecture, security, and code-quality review notes.')
92
+ }
93
+
94
+ if (designReviewRequired) {
95
+ workflow.unshift('Complete .ai/current-task.md Technical Approach before coding.')
96
+ }
97
+
98
+ if (tier === 'large') {
99
+ workflow.push('Maintain lifecycle goal fields and archive a final task snapshot.')
100
+ workflow.push(
101
+ 'Review .ai/tech-docs.md or .ai/product-roadmap.md when technical or business state changed.',
102
+ )
103
+ workflow.push('Add a reflection entry when the task exposes friction or repeated mistakes.')
104
+ workflow.push('Generate pnpm ace:report.')
105
+ }
106
+
107
+ return workflow
108
+ }
109
+
110
+ export function formatClassification(classification) {
111
+ return [
112
+ 'AI task classification',
113
+ `Tier: ${classification.tier}`,
114
+ `Detected tier: ${classification.detectedTier}`,
115
+ `Design review required: ${classification.designReviewRequired ? 'yes' : 'no'}`,
116
+ '',
117
+ 'Reasons:',
118
+ ...classification.reasons.map((reason) => `- ${reason}`),
119
+ '',
120
+ 'Required workflow:',
121
+ ...classification.requiredWorkflow.map((item) => `- ${item}`),
122
+ ].join('\n')
123
+ }
124
+
125
+ async function getChangedFiles(rootDir) {
126
+ const output = await gitOutput(rootDir, ['status', '--porcelain'])
127
+
128
+ return output
129
+ .split('\n')
130
+ .map((line) => line.trimEnd())
131
+ .filter(Boolean)
132
+ .map((line) => line.slice(3).trim())
133
+ .map((filePath) => filePath.split(' -> ').at(-1) ?? filePath)
134
+ .map(normalizeRepoPath)
135
+ .filter(Boolean)
136
+ }
137
+
138
+ async function getDiffStats(rootDir) {
139
+ const output = await gitOutput(rootDir, ['diff', '--numstat', 'HEAD', '--'])
140
+ let diffLineCount = 0
141
+
142
+ for (const line of output.split('\n')) {
143
+ const [added, removed] = line.split('\t')
144
+ const addedLines = Number.parseInt(added, 10)
145
+ const removedLines = Number.parseInt(removed, 10)
146
+
147
+ if (Number.isFinite(addedLines)) {
148
+ diffLineCount += addedLines
149
+ }
150
+
151
+ if (Number.isFinite(removedLines)) {
152
+ diffLineCount += removedLines
153
+ }
154
+ }
155
+
156
+ return { diffLineCount }
157
+ }
158
+
159
+ async function getDiffText(rootDir) {
160
+ return gitOutput(rootDir, ['diff', '--unified=0', 'HEAD', '--'])
161
+ }
162
+
163
+ async function gitOutput(rootDir, args) {
164
+ try {
165
+ const result = await execFileAsync('git', args, {
166
+ cwd: rootDir,
167
+ encoding: 'utf8',
168
+ maxBuffer: 20 * 1024 * 1024,
169
+ })
170
+
171
+ return result.stdout
172
+ } catch {
173
+ return ''
174
+ }
175
+ }
176
+
177
+ function getBaselineTier(fileCount, diffLineCount, config) {
178
+ if (
179
+ fileCount <= config.thresholds.small.maxFiles &&
180
+ diffLineCount <= config.thresholds.small.maxDiffLines
181
+ ) {
182
+ return 'small'
183
+ }
184
+
185
+ if (
186
+ fileCount >= config.thresholds.large.minFiles ||
187
+ diffLineCount >= config.thresholds.large.minDiffLines
188
+ ) {
189
+ return 'large'
190
+ }
191
+
192
+ return 'standard'
193
+ }
194
+
195
+ function maxTier(left, right) {
196
+ return TIER_RANK[right] > TIER_RANK[left] ? right : left
197
+ }
198
+
199
+ function applyOverride(detectedTier, overrideTier, overrideReason) {
200
+ if (!overrideTier) {
201
+ return detectedTier
202
+ }
203
+
204
+ if (!(overrideTier in TIER_RANK)) {
205
+ throw new Error(`Invalid override tier: ${overrideTier}`)
206
+ }
207
+
208
+ if (overrideTier !== detectedTier && !overrideReason) {
209
+ throw new Error('Task tier override requires --reason.')
210
+ }
211
+
212
+ return overrideTier
213
+ }
214
+
215
+ async function main() {
216
+ writeAceBanner()
217
+
218
+ const rawArgs = process.argv.slice(2)
219
+ const args = parseCliArgs(rawArgs)
220
+ const rootDir = path.resolve(process.cwd(), getArgValue(args, 'root') ?? '.')
221
+ const classification = await classifyRepositoryTask(rootDir, {
222
+ overrideReason: getArgValue(args, 'reason'),
223
+ overrideTier: getArgValue(args, 'tier'),
224
+ })
225
+
226
+ if (getArgValue(args, 'json') === 'true') {
227
+ process.stdout.write(`${JSON.stringify(classification, null, 2)}\n`)
228
+ return
229
+ }
230
+
231
+ process.stdout.write(`${formatClassification(classification)}\n`)
232
+ }
233
+
234
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
235
+ await main()
236
+ }