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,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, '<')
|
|
68
|
+
.replace(/>/g, '>')
|
|
69
|
+
.replace(/"/g, '"')
|
|
70
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|