@betterdanlins/ai-memory 0.1.0 → 0.4.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 +158 -14
- package/README.zh-CN.md +170 -0
- package/bin/cli.js +189 -5
- package/package.json +1 -1
- package/src/framework.js +260 -0
- package/src/legacy-v0.1.js +43 -0
- package/src/manifest.js +20 -1
- package/src/migrations.js +28 -0
- package/src/model-routing.js +108 -0
- package/src/path-safety.js +51 -0
- package/src/scaffold.js +114 -13
- package/src/workflow.js +185 -0
- package/templates/claude/.claude/agents/economy-test-worker.md +8 -0
- package/templates/claude/.claude/agents/premium-implementer.md +8 -0
- package/templates/claude/.claude/agents/premium-planner.md +8 -0
- package/templates/claude/.claude/agents/premium-reviewer.md +8 -0
- package/templates/claude/.claude/agents/standard-implementer.md +8 -0
- package/templates/claude/.claude/agents/standard-test-worker.md +8 -0
- package/templates/claude/.claude/commands/delivery-readiness.md +6 -0
- package/templates/claude/.claude/commands/design-feature.md +6 -0
- package/templates/claude/.claude/commands/finalize-requirement.md +1 -1
- package/templates/claude/.claude/commands/project-inception.md +5 -0
- package/templates/claude/.claude/settings.json +1 -1
- package/templates/claude/.claude/skills/critic/SKILL.md +2 -2
- package/templates/claude/.claude/skills/delivery-readiness/SKILL.md +6 -0
- package/templates/claude/.claude/skills/feature-design/SKILL.md +6 -0
- package/templates/claude/.claude/skills/memory-update/SKILL.md +1 -1
- package/templates/claude/.claude/skills/model-routing/SKILL.md +6 -0
- package/templates/claude/.claude/skills/project-inception/SKILL.md +6 -0
- package/templates/claude/CLAUDE.md +11 -5
- package/templates/codex/.agents/skills/critic/SKILL.md +2 -2
- package/templates/codex/.agents/skills/delivery-readiness/SKILL.md +6 -0
- package/templates/codex/.agents/skills/feature-design/SKILL.md +6 -0
- package/templates/codex/.agents/skills/memory-update/SKILL.md +1 -1
- package/templates/codex/.agents/skills/model-routing/SKILL.md +6 -0
- package/templates/codex/.agents/skills/project-inception/SKILL.md +6 -0
- package/templates/codex/.codex/agents/economy_test_worker.toml +6 -0
- package/templates/codex/.codex/agents/premium_implementer.toml +6 -0
- package/templates/codex/.codex/agents/premium_planner.toml +6 -0
- package/templates/codex/.codex/agents/premium_reviewer.toml +6 -0
- package/templates/codex/.codex/agents/standard_implementer.toml +6 -0
- package/templates/codex/.codex/agents/standard_test_worker.toml +6 -0
- package/templates/codex/AGENTS.md +11 -5
- package/templates/common/.ai/README.md +5 -1
- package/templates/common/.ai/config/model-routing.json +5 -0
- package/templates/common/.ai/memory/project-state.md +1 -1
- package/templates/common/.ai/memory/session-log.md +1 -1
- package/templates/common/.ai/runs/.gitignore +2 -0
- package/templates/common/.ai/skills/architecture.md +24 -14
- package/templates/common/.ai/skills/code-review.md +1 -1
- package/templates/common/.ai/skills/critic.md +4 -2
- package/templates/common/.ai/skills/delivery-readiness.md +34 -0
- package/templates/common/.ai/skills/feature-design.md +35 -0
- package/templates/common/.ai/skills/memory-update.md +19 -14
- package/templates/common/.ai/skills/model-routing.md +39 -0
- package/templates/common/.ai/skills/project-inception.md +33 -0
- package/templates/common/.ai/skills/requirements-flow.md +33 -16
- package/templates/common/docs/architecture/data-model.md +43 -0
- package/templates/common/docs/architecture/deployment.md +40 -0
- package/templates/common/docs/architecture/quality-attributes.md +50 -0
- package/templates/common/docs/architecture/system-context.md +35 -0
- package/templates/common/docs/design/README.md +22 -0
- package/templates/common/docs/design/v1.0.0/.gitkeep +0 -0
- package/templates/common/docs/requirements/README.md +2 -1
package/src/framework.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { buildManifest } from './manifest.js';
|
|
5
|
+
import { matchesLegacyV01Baseline } from './legacy-v0.1.js';
|
|
6
|
+
import { migrationsBetween } from './migrations.js';
|
|
7
|
+
import { assertNoSymlinkPath, resolveSafeDestination } from './path-safety.js';
|
|
8
|
+
import { render } from './render.js';
|
|
9
|
+
import { ScaffoldError } from './scaffold.js';
|
|
10
|
+
|
|
11
|
+
export const CURRENT_SCHEMA_VERSION = 1;
|
|
12
|
+
export const METADATA_DEST = '.ai/ai-memory.json';
|
|
13
|
+
|
|
14
|
+
const USER_PREFIXES = [
|
|
15
|
+
'.ai/config/',
|
|
16
|
+
'.ai/memory/',
|
|
17
|
+
'docs/architecture/',
|
|
18
|
+
'docs/requirements/v',
|
|
19
|
+
'docs/design/v',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const MIXED_FILES = new Set(['AGENTS.md', 'CLAUDE.md', '.claude/settings.json']);
|
|
23
|
+
const FRAMEWORK_EXCEPTIONS = new Set(['docs/requirements/README.md', 'docs/design/README.md']);
|
|
24
|
+
|
|
25
|
+
export function ownershipFor(dest) {
|
|
26
|
+
if (MIXED_FILES.has(dest)) return 'mixed';
|
|
27
|
+
if (FRAMEWORK_EXCEPTIONS.has(dest)) return 'framework';
|
|
28
|
+
if (USER_PREFIXES.some(prefix => dest.startsWith(prefix))) return 'user';
|
|
29
|
+
return 'framework';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function hashContent(content) {
|
|
33
|
+
return createHash('sha256').update(content).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function detectInstallation(targetDir) {
|
|
37
|
+
const metadataPath = path.join(targetDir, '.ai', 'ai-memory.json');
|
|
38
|
+
try {
|
|
39
|
+
const metadata = JSON.parse(await readFile(metadataPath, 'utf8'));
|
|
40
|
+
validateMetadata(metadata, metadataPath);
|
|
41
|
+
return { kind: 'metadata', metadata };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.code !== 'ENOENT') {
|
|
44
|
+
if (err instanceof SyntaxError) throw new Error(`框架元数据不是有效 JSON: ${metadataPath}`, { cause: err });
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const readmePath = path.join(targetDir, '.ai', 'README.md');
|
|
50
|
+
try {
|
|
51
|
+
const readme = await readFile(readmePath, 'utf8');
|
|
52
|
+
if (readme.includes('@betterdanlins/ai-memory')) {
|
|
53
|
+
const facts = await readLegacyFacts(targetDir, readme);
|
|
54
|
+
return { kind: 'legacy', version: '0.1.0', schemaVersion: 0, ...facts };
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err.code !== 'ENOENT') throw err;
|
|
58
|
+
}
|
|
59
|
+
return { kind: 'none' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function writeFrameworkMetadata({
|
|
63
|
+
targetDir, templatesRoot, frameworkVersion, tools, projectName, techStack, date, previousMetadata,
|
|
64
|
+
}) {
|
|
65
|
+
const manifest = await buildManifest(templatesRoot, tools);
|
|
66
|
+
const files = {};
|
|
67
|
+
for (const { dest } of manifest) {
|
|
68
|
+
const content = await readFile(path.join(targetDir, ...dest.split('/')), 'utf8');
|
|
69
|
+
files[dest] = { ownership: ownershipFor(dest), sha256: hashContent(content) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const metadata = {
|
|
73
|
+
frameworkVersion,
|
|
74
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
75
|
+
generatedAt: previousMetadata?.generatedAt ?? new Date().toISOString(),
|
|
76
|
+
...(previousMetadata ? { updatedAt: new Date().toISOString() } : {}),
|
|
77
|
+
tools: [...tools],
|
|
78
|
+
templateVars: { projectName, techStack, date },
|
|
79
|
+
files,
|
|
80
|
+
};
|
|
81
|
+
const metadataPath = path.join(targetDir, '.ai', 'ai-memory.json');
|
|
82
|
+
await mkdir(path.dirname(metadataPath), { recursive: true });
|
|
83
|
+
await writeFile(metadataPath, JSON.stringify(metadata, null, 2) + '\n', { flag: previousMetadata ? 'w' : 'wx' });
|
|
84
|
+
return metadata;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function planFrameworkUpdate({ targetDir, templatesRoot, frameworkVersion }) {
|
|
88
|
+
const installation = await detectInstallation(targetDir);
|
|
89
|
+
if (installation.kind === 'none') throw new Error('当前目录不是 ai-memory 项目,请先运行 init');
|
|
90
|
+
|
|
91
|
+
const tools = installation.kind === 'metadata' ? installation.metadata.tools : installation.tools;
|
|
92
|
+
const rawVars = installation.kind === 'metadata' ? installation.metadata.templateVars : installation.templateVars;
|
|
93
|
+
const vars = { modelProfile: 'inherit', ...rawVars, frameworkVersion };
|
|
94
|
+
if (installation.kind === 'metadata' && compareVersions(installation.metadata.frameworkVersion, frameworkVersion) > 0) {
|
|
95
|
+
throw new Error(`项目框架版本 ${installation.metadata.frameworkVersion} 高于当前 CLI ${frameworkVersion},请使用更新版本的 CLI`);
|
|
96
|
+
}
|
|
97
|
+
const fromSchema = installation.kind === 'metadata' ? installation.metadata.schemaVersion : 0;
|
|
98
|
+
const migrations = migrationsBetween(fromSchema, CURRENT_SCHEMA_VERSION);
|
|
99
|
+
const manifest = await buildManifest(templatesRoot, tools);
|
|
100
|
+
const desiredDests = new Set(manifest.map(({ dest }) => dest));
|
|
101
|
+
const actions = [];
|
|
102
|
+
|
|
103
|
+
for (const { src, dest } of manifest) {
|
|
104
|
+
const desired = render(await readFile(src, 'utf8'), vars);
|
|
105
|
+
const current = await readOptional(path.join(targetDir, ...dest.split('/')));
|
|
106
|
+
const ownership = ownershipFor(dest);
|
|
107
|
+
if (current === undefined) {
|
|
108
|
+
actions.push({ action: 'add', dest, ownership });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const currentHash = hashContent(current);
|
|
113
|
+
const desiredHash = hashContent(desired);
|
|
114
|
+
if (currentHash === desiredHash) {
|
|
115
|
+
actions.push({ action: 'unchanged', dest, ownership });
|
|
116
|
+
} else if (ownership === 'user') {
|
|
117
|
+
actions.push({ action: 'preserve', dest, ownership });
|
|
118
|
+
} else {
|
|
119
|
+
const matchesBaseline = installation.kind === 'metadata'
|
|
120
|
+
? installation.metadata.files[dest]?.sha256 === currentHash
|
|
121
|
+
: matchesLegacyV01Baseline(dest, current, vars);
|
|
122
|
+
const action = matchesBaseline ? 'update' : ownership === 'mixed' ? 'merge' : 'review';
|
|
123
|
+
actions.push({ action, dest, ownership, currentHash, desiredHash });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (installation.kind === 'metadata') {
|
|
128
|
+
for (const [dest, record] of Object.entries(installation.metadata.files)) {
|
|
129
|
+
if (desiredDests.has(dest) || !(await exists(path.join(targetDir, ...dest.split('/'))))) continue;
|
|
130
|
+
actions.push({
|
|
131
|
+
action: record.ownership === 'user' ? 'preserve' : 'review-remove',
|
|
132
|
+
dest,
|
|
133
|
+
ownership: record.ownership,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
installation,
|
|
140
|
+
frameworkVersion,
|
|
141
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
142
|
+
tools,
|
|
143
|
+
templateVars: vars,
|
|
144
|
+
migrations,
|
|
145
|
+
actions,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function applyFrameworkUpdate({ targetDir, templatesRoot, plan }) {
|
|
150
|
+
const blockers = plan.actions.filter(item => ['merge', 'review', 'review-remove'].includes(item.action));
|
|
151
|
+
if (blockers.length) {
|
|
152
|
+
throw new Error(`存在 ${blockers.length} 个需合并/审查项,未写入任何文件:\n${blockers.map(item => `- ${item.dest}`).join('\n')}`);
|
|
153
|
+
}
|
|
154
|
+
const manualMigrations = plan.migrations.filter(item => !item.automatic);
|
|
155
|
+
if (manualMigrations.length) {
|
|
156
|
+
throw new Error(`存在不可自动执行的 Schema 迁移: ${manualMigrations.map(item => item.id).join(', ')}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const vars = plan.templateVars;
|
|
160
|
+
const manifest = await buildManifest(templatesRoot, plan.tools);
|
|
161
|
+
const sources = new Map(manifest.map(entry => [entry.dest, entry.src]));
|
|
162
|
+
const candidates = [];
|
|
163
|
+
|
|
164
|
+
for (const action of plan.actions.filter(item => item.action === 'add' || item.action === 'update')) {
|
|
165
|
+
const src = sources.get(action.dest);
|
|
166
|
+
if (!src) throw new Error(`升级计划缺少当前模板: ${action.dest}`);
|
|
167
|
+
const destPath = resolveSafeDestination(targetDir, action.dest);
|
|
168
|
+
await assertNoSymlinkPath(targetDir, destPath);
|
|
169
|
+
if (action.action === 'update') {
|
|
170
|
+
const current = await readFile(destPath, 'utf8');
|
|
171
|
+
if (action.currentHash && hashContent(current) !== action.currentHash) {
|
|
172
|
+
throw new Error(`文件在预检后发生变化,请重新 dry-run: ${action.dest}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
candidates.push({ ...action, destPath, content: render(await readFile(src, 'utf8'), vars) });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const summary = { written: [], skipped: [], imported: [], fallback: [] };
|
|
179
|
+
for (const [index, item] of candidates.entries()) {
|
|
180
|
+
try {
|
|
181
|
+
await mkdir(path.dirname(item.destPath), { recursive: true });
|
|
182
|
+
await writeFile(item.destPath, item.content, { flag: item.action === 'add' ? 'wx' : 'w' });
|
|
183
|
+
summary.written.push(item.dest);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
throw new ScaffoldError({
|
|
186
|
+
phase: 'update', dest: item.dest, destPath: item.destPath, cause: err, summary,
|
|
187
|
+
notWritten: candidates.slice(index).map(({ dest }) => dest),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const previousMetadata = plan.installation.kind === 'metadata' ? plan.installation.metadata : undefined;
|
|
193
|
+
try {
|
|
194
|
+
await writeFrameworkMetadata({
|
|
195
|
+
targetDir,
|
|
196
|
+
templatesRoot,
|
|
197
|
+
frameworkVersion: plan.frameworkVersion,
|
|
198
|
+
tools: plan.tools,
|
|
199
|
+
projectName: vars.projectName,
|
|
200
|
+
techStack: vars.techStack,
|
|
201
|
+
date: vars.date,
|
|
202
|
+
previousMetadata,
|
|
203
|
+
});
|
|
204
|
+
summary.written.push(METADATA_DEST);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
throw new ScaffoldError({
|
|
207
|
+
phase: 'update', dest: METADATA_DEST, destPath: path.join(targetDir, '.ai', 'ai-memory.json'),
|
|
208
|
+
cause: err, summary, notWritten: [METADATA_DEST],
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return summary;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function readLegacyFacts(targetDir, readme) {
|
|
215
|
+
const state = await readOptional(path.join(targetDir, '.ai', 'memory', 'project-state.md')) ?? '';
|
|
216
|
+
const projectName = state.match(/^- 项目:(.+)$/m)?.[1]?.trim() || path.basename(path.resolve(targetDir));
|
|
217
|
+
const techStack = state.match(/^- 技术栈:(.+)$/m)?.[1]?.trim() || '(待补充)';
|
|
218
|
+
const date = readme.match(/生成于 (\d{4}-\d{2}-\d{2})/)?.[1]
|
|
219
|
+
|| state.match(/最后更新:(\d{4}-\d{2}-\d{2})/)?.[1]
|
|
220
|
+
|| new Date().toISOString().slice(0, 10);
|
|
221
|
+
const tools = [];
|
|
222
|
+
if (await exists(path.join(targetDir, 'CLAUDE.md'))) tools.push('claude');
|
|
223
|
+
if (await exists(path.join(targetDir, 'AGENTS.md'))) tools.push('codex');
|
|
224
|
+
return { tools, templateVars: { projectName, techStack, date } };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function validateMetadata(metadata, metadataPath) {
|
|
228
|
+
if (!metadata || typeof metadata !== 'object') throw new Error(`框架元数据格式无效: ${metadataPath}`);
|
|
229
|
+
if (typeof metadata.frameworkVersion !== 'string' || !Number.isInteger(metadata.schemaVersion)) {
|
|
230
|
+
throw new Error(`框架元数据缺少版本字段: ${metadataPath}`);
|
|
231
|
+
}
|
|
232
|
+
if (!Array.isArray(metadata.tools) || !metadata.templateVars || !metadata.files) {
|
|
233
|
+
throw new Error(`框架元数据缺少安装基线: ${metadataPath}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function readOptional(filePath) {
|
|
238
|
+
try {
|
|
239
|
+
return await readFile(filePath, 'utf8');
|
|
240
|
+
} catch (err) {
|
|
241
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return undefined;
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const exists = filePath => access(filePath).then(() => true, err => {
|
|
247
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
|
|
248
|
+
throw err;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
function compareVersions(left, right) {
|
|
252
|
+
const parse = value => value.split('-', 1)[0].split('.').map(part => Number.parseInt(part, 10) || 0);
|
|
253
|
+
const a = parse(left);
|
|
254
|
+
const b = parse(right);
|
|
255
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
256
|
+
const difference = (a[i] ?? 0) - (b[i] ?? 0);
|
|
257
|
+
if (difference !== 0) return Math.sign(difference);
|
|
258
|
+
}
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const BASELINE_HASHES = {
|
|
4
|
+
'.ai/README.md': '5398a845005031d7905faeae29b2891af94eaaf7104b415e5fed3657fc42ee2f',
|
|
5
|
+
'CLAUDE.md': '1ab2be083986c96f13ff126daf8f0d77ceff9918e695996e10706c1f1770d866',
|
|
6
|
+
'AGENTS.md': 'b8f49a179b35f2c111450d24a840fc964cc3049dc3cc0b87c8193e1940fb7a42',
|
|
7
|
+
'.claude/agents/critic.md': 'e3686c1735337b06fc3679de7302b477b1cff851f162d72a5ee855d86e7e8501',
|
|
8
|
+
'.claude/commands/critic.md': 'cca4f2f98542c340fd169c55ac60c77a8cade348059d5f21374dfa0589225ff4',
|
|
9
|
+
'.claude/commands/finalize-requirement.md': '3e0613d64d9f9e91d6f801fab77eec016cd874f3ca3ccd63118e24dd281a4158',
|
|
10
|
+
'.claude/commands/new-requirement.md': 'f6409b11e74f0d134c8856e87e39d0bc5dc3760269e9be0bf954668ce7e1f0c3',
|
|
11
|
+
'.claude/commands/update-memory.md': '9fa499b665763a9d25e09a7aa20b4dca22216f5ef32f411c643ccc5c096b7617',
|
|
12
|
+
'.claude/settings.json': '7a800ca223aef127d4872baa481e7d75a8adc3c278e59d1e8383b58bfe9f8a29',
|
|
13
|
+
'.claude/skills/critic/SKILL.md': '2d1537599c3f01dd03103fb1ad6777a439179561c0d054d17d120542b458faeb',
|
|
14
|
+
'.claude/skills/memory-update/SKILL.md': 'c63cb7c3ff2b6e20f61619703112e3f0a75c22e9d37deabb38e40655c15f66d7',
|
|
15
|
+
'.claude/skills/requirements-flow/SKILL.md': '41bcad8765c3f1d44821a4fecc6322b0534a166ef1362161300e434bbe9593c1',
|
|
16
|
+
'.agents/skills/critic/SKILL.md': 'c9cb6471ab6c45360177a18b1ed7a900d943b16ae0e19045374e2767655c5da8',
|
|
17
|
+
'.agents/skills/memory-update/SKILL.md': 'c63cb7c3ff2b6e20f61619703112e3f0a75c22e9d37deabb38e40655c15f66d7',
|
|
18
|
+
'.agents/skills/requirements-flow/SKILL.md': '3417925a8fff47e7990739c04eab7a9a4a598372c73ff3be6b8b9683871a6dd7',
|
|
19
|
+
'.ai/skills/architecture.md': 'd67e2275b8bc2797a5644d2eacbc0e4d23e04cf3e980e41f2081f556f0e243a8',
|
|
20
|
+
'.ai/skills/code-review.md': 'c241b82763e8ee5948bac6ce2a00437347b95b18e5cad09d07669c75881cc1d9',
|
|
21
|
+
'.ai/skills/critic.md': '24efea9e792435fbc1f3ff7204f153f33dd8c27bded656b2877ca6c84ef53d05',
|
|
22
|
+
'.ai/skills/memory-update.md': '5d5e61cae831a5d74744f579fc6f550abfd42a3ef287bf791b4c9e2a235638e8',
|
|
23
|
+
'.ai/skills/requirements-flow.md': 'b95f2649002cd028fc72122d243e39c16365aa1ed9b38d390522db7542b75f21',
|
|
24
|
+
'docs/requirements/README.md': '968085f9824686deb3282ab7e82970899a73a06e8ccb31c55e6aadd8e87d91ec',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const VARIABLE_KEYS = {
|
|
28
|
+
'.ai/README.md': ['projectName', 'date'],
|
|
29
|
+
'CLAUDE.md': ['projectName', 'techStack'],
|
|
30
|
+
'AGENTS.md': ['projectName', 'techStack'],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function matchesLegacyV01Baseline(dest, content, vars) {
|
|
34
|
+
const baseline = BASELINE_HASHES[dest];
|
|
35
|
+
if (!baseline) return false;
|
|
36
|
+
let normalized = content;
|
|
37
|
+
for (const key of VARIABLE_KEYS[dest] ?? []) {
|
|
38
|
+
const value = String(vars[key] ?? '');
|
|
39
|
+
if (!value) return false;
|
|
40
|
+
normalized = normalized.replaceAll(value, `{{${key}}}`);
|
|
41
|
+
}
|
|
42
|
+
return createHash('sha256').update(normalized).digest('hex') === baseline;
|
|
43
|
+
}
|
package/src/manifest.js
CHANGED
|
@@ -16,7 +16,26 @@ export async function buildManifest(templatesRoot, tools) {
|
|
|
16
16
|
entries.push({ src: path.join(groupDir, ...rel.split('/')), dest: rel });
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
const sorted = entries.sort((a, b) => a.dest < b.dest ? -1 : a.dest > b.dest ? 1 : 0);
|
|
20
|
+
assertUniqueDestinations(sorted);
|
|
21
|
+
return sorted;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertUniqueDestinations(entries) {
|
|
25
|
+
const sourcesByDest = new Map();
|
|
26
|
+
for (const { src, dest } of entries) {
|
|
27
|
+
const sources = sourcesByDest.get(dest) ?? [];
|
|
28
|
+
sources.push(src);
|
|
29
|
+
sourcesByDest.set(dest, sources);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const conflicts = [...sourcesByDest]
|
|
33
|
+
.filter(([, sources]) => sources.length > 1)
|
|
34
|
+
.map(([dest, sources]) => `- ${dest}\n${sources.map(src => ` - ${src}`).join('\n')}`);
|
|
35
|
+
|
|
36
|
+
if (conflicts.length > 0) {
|
|
37
|
+
throw new Error(`Duplicate template destinations:\n${conflicts.join('\n')}`);
|
|
38
|
+
}
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
async function walk(dir, prefix) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const MIGRATIONS = [
|
|
2
|
+
{
|
|
3
|
+
id: 'bootstrap-schema-v1',
|
|
4
|
+
from: 0,
|
|
5
|
+
to: 1,
|
|
6
|
+
automatic: true,
|
|
7
|
+
description: '为无元数据的 legacy 项目建立所有权与生成基线;既有文件保持保守审查',
|
|
8
|
+
},
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export function migrationsBetween(from, to) {
|
|
12
|
+
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to < 0) {
|
|
13
|
+
throw new Error(`无效 Schema 版本: ${from} → ${to}`);
|
|
14
|
+
}
|
|
15
|
+
if (from > to) throw new Error(`项目 Schema ${from} 高于当前 CLI Schema ${to},请使用更新版本的 CLI`);
|
|
16
|
+
|
|
17
|
+
const result = [];
|
|
18
|
+
let current = from;
|
|
19
|
+
while (current < to) {
|
|
20
|
+
const migration = MIGRATIONS.find(item => item.from === current);
|
|
21
|
+
if (!migration || migration.to <= current || migration.to > to) {
|
|
22
|
+
throw new Error(`缺少 Schema 迁移路径: ${current} → ${to}`);
|
|
23
|
+
}
|
|
24
|
+
result.push(migration);
|
|
25
|
+
current = migration.to;
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { assertNoSymlinkPath } from './path-safety.js';
|
|
4
|
+
|
|
5
|
+
export const MODEL_ROUTING_DEST = '.ai/config/model-routing.json';
|
|
6
|
+
export const MODEL_ROUTING_SCHEMA_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
export const STAGES = [
|
|
9
|
+
'brainstorm',
|
|
10
|
+
'requirement-finalize',
|
|
11
|
+
'feature-design',
|
|
12
|
+
'write-plan',
|
|
13
|
+
'implementation',
|
|
14
|
+
'routine-tests',
|
|
15
|
+
'test-execution',
|
|
16
|
+
'failure-diagnosis',
|
|
17
|
+
'adversarial-testing',
|
|
18
|
+
'final-review',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const PROFILES = {
|
|
22
|
+
inherit: Object.fromEntries(STAGES.map(stage => [stage, stage === 'test-execution' ? 'none' : 'inherit'])),
|
|
23
|
+
balanced: {
|
|
24
|
+
brainstorm: 'premium',
|
|
25
|
+
'requirement-finalize': 'premium',
|
|
26
|
+
'feature-design': 'premium',
|
|
27
|
+
'write-plan': 'premium',
|
|
28
|
+
implementation: 'standard',
|
|
29
|
+
'routine-tests': 'economy',
|
|
30
|
+
'test-execution': 'none',
|
|
31
|
+
'failure-diagnosis': 'standard',
|
|
32
|
+
'adversarial-testing': 'premium',
|
|
33
|
+
'final-review': 'premium',
|
|
34
|
+
},
|
|
35
|
+
quality: {
|
|
36
|
+
brainstorm: 'premium',
|
|
37
|
+
'requirement-finalize': 'premium',
|
|
38
|
+
'feature-design': 'premium',
|
|
39
|
+
'write-plan': 'premium',
|
|
40
|
+
implementation: 'premium',
|
|
41
|
+
'routine-tests': 'standard',
|
|
42
|
+
'test-execution': 'none',
|
|
43
|
+
'failure-diagnosis': 'premium',
|
|
44
|
+
'adversarial-testing': 'premium',
|
|
45
|
+
'final-review': 'premium',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const MODEL_PROFILES = Object.freeze(Object.keys(PROFILES));
|
|
50
|
+
|
|
51
|
+
export function createModelRoutingConfig(profile = 'inherit') {
|
|
52
|
+
assertProfile(profile);
|
|
53
|
+
return { schemaVersion: MODEL_ROUTING_SCHEMA_VERSION, profile, overrides: {} };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveModelRouting(config) {
|
|
57
|
+
validateModelRoutingConfig(config);
|
|
58
|
+
return { ...PROFILES[config.profile], ...config.overrides };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function readModelRoutingConfig(targetDir) {
|
|
62
|
+
const configPath = path.join(targetDir, ...MODEL_ROUTING_DEST.split('/'));
|
|
63
|
+
await assertNoSymlinkPath(targetDir, configPath);
|
|
64
|
+
let config;
|
|
65
|
+
try {
|
|
66
|
+
config = JSON.parse(await readFile(configPath, 'utf8'));
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err.code === 'ENOENT') throw new Error(`缺少模型路由配置: ${MODEL_ROUTING_DEST};请先更新 ai-memory 框架`);
|
|
69
|
+
if (err instanceof SyntaxError) throw new Error(`模型路由配置不是有效 JSON: ${MODEL_ROUTING_DEST}`, { cause: err });
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
validateModelRoutingConfig(config);
|
|
73
|
+
return config;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function writeModelRoutingConfig(targetDir, config) {
|
|
77
|
+
validateModelRoutingConfig(config);
|
|
78
|
+
const configPath = path.join(targetDir, ...MODEL_ROUTING_DEST.split('/'));
|
|
79
|
+
await assertNoSymlinkPath(targetDir, configPath);
|
|
80
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
81
|
+
await writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
82
|
+
return config;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function validateModelRoutingConfig(config) {
|
|
86
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
87
|
+
throw new Error('模型路由配置必须是对象');
|
|
88
|
+
}
|
|
89
|
+
if (config.schemaVersion !== MODEL_ROUTING_SCHEMA_VERSION) {
|
|
90
|
+
throw new Error(`不支持的模型路由 Schema: ${config.schemaVersion}`);
|
|
91
|
+
}
|
|
92
|
+
assertProfile(config.profile);
|
|
93
|
+
if (!config.overrides || typeof config.overrides !== 'object' || Array.isArray(config.overrides)) {
|
|
94
|
+
throw new Error('模型路由 overrides 必须是对象');
|
|
95
|
+
}
|
|
96
|
+
for (const [stage, tier] of Object.entries(config.overrides)) {
|
|
97
|
+
if (!STAGES.includes(stage)) throw new Error(`未知模型路由阶段: ${stage}`);
|
|
98
|
+
if (!['inherit', 'premium', 'standard', 'economy', 'none'].includes(tier)) {
|
|
99
|
+
throw new Error(`阶段 ${stage} 的模型等级无效: ${tier}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assertProfile(profile) {
|
|
105
|
+
if (!MODEL_PROFILES.includes(profile)) {
|
|
106
|
+
throw new Error(`model profile 仅支持 ${MODEL_PROFILES.join('、')},收到: ${profile}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function resolveSafeDestination(targetDir, dest) {
|
|
5
|
+
if (typeof dest !== 'string' || dest.length === 0) {
|
|
6
|
+
throw new Error('模板目标路径必须是非空相对路径');
|
|
7
|
+
}
|
|
8
|
+
if (dest.includes('\\')) {
|
|
9
|
+
throw new Error(`模板目标路径必须使用 "/" 分隔: ${dest}`);
|
|
10
|
+
}
|
|
11
|
+
if (path.posix.isAbsolute(dest) || path.win32.isAbsolute(dest) || /^[A-Za-z]:/.test(dest)) {
|
|
12
|
+
throw new Error(`模板目标路径不能是绝对路径: ${dest}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const segments = dest.split('/');
|
|
16
|
+
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
17
|
+
throw new Error(`模板目标路径包含无效路径段: ${dest}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const root = path.resolve(targetDir);
|
|
21
|
+
const destination = path.resolve(root, ...segments);
|
|
22
|
+
const relative = path.relative(root, destination);
|
|
23
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
24
|
+
throw new Error(`模板目标路径超出目标目录: ${dest}`);
|
|
25
|
+
}
|
|
26
|
+
return destination;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function assertNoSymlinkPath(targetDir, destPath) {
|
|
30
|
+
const root = path.resolve(targetDir);
|
|
31
|
+
const destination = path.resolve(destPath);
|
|
32
|
+
const relative = path.relative(root, destination);
|
|
33
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
34
|
+
throw new Error(`目标路径不在目标目录内: ${destPath}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let current = root;
|
|
38
|
+
for (const segment of relative.split(path.sep)) {
|
|
39
|
+
current = path.join(current, segment);
|
|
40
|
+
let stats;
|
|
41
|
+
try {
|
|
42
|
+
stats = await lstat(current);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err.code === 'ENOENT') return;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
if (stats.isSymbolicLink()) {
|
|
48
|
+
throw new Error(`目标路径包含符号链接或 junction: ${current}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/scaffold.js
CHANGED
|
@@ -1,33 +1,134 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile, access, lstat, stat } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { buildManifest } from './manifest.js';
|
|
5
|
+
import { assertNoSymlinkPath, resolveSafeDestination } from './path-safety.js';
|
|
4
6
|
import { render } from './render.js';
|
|
5
7
|
|
|
6
8
|
const IMPORT_DESTS = new Set(['.ai/memory/user-profile.md', '.ai/memory/feedback.md']);
|
|
7
9
|
|
|
10
|
+
export class ScaffoldError extends Error {
|
|
11
|
+
constructor({ phase, dest, destPath, cause, summary, notWritten }) {
|
|
12
|
+
super(`生成 ${dest} 失败(${phase}): ${cause.message}`, { cause });
|
|
13
|
+
this.name = 'ScaffoldError';
|
|
14
|
+
this.code = phase === 'prepare' ? 'SCAFFOLD_PREPARE_FAILED' : 'SCAFFOLD_WRITE_FAILED';
|
|
15
|
+
this.phase = phase;
|
|
16
|
+
this.dest = dest;
|
|
17
|
+
this.destPath = destPath;
|
|
18
|
+
this.summary = copySummary(summary);
|
|
19
|
+
this.notWritten = [...notWritten];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
export async function scaffold({ templatesRoot, targetDir, vars, tools, onConflict, importFrom }) {
|
|
24
|
+
const importRoot = importFrom ? await validateImportRoot(importFrom) : undefined;
|
|
9
25
|
const manifest = await buildManifest(templatesRoot, tools);
|
|
10
|
-
const summary = { written: [], skipped: [] };
|
|
11
|
-
|
|
12
|
-
|
|
26
|
+
const summary = { written: [], skipped: [], imported: [], fallback: [] };
|
|
27
|
+
const candidates = [];
|
|
28
|
+
|
|
29
|
+
for (const entry of manifest) {
|
|
30
|
+
const destPath = resolveSafeDestination(targetDir, entry.dest);
|
|
13
31
|
if (await exists(destPath)) {
|
|
32
|
+
const { dest } = entry;
|
|
14
33
|
if ((await onConflict(dest)) !== 'overwrite') { summary.skipped.push(dest); continue; }
|
|
15
34
|
}
|
|
35
|
+
candidates.push({ ...entry, destPath });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const { destPath } of candidates) {
|
|
39
|
+
await assertNoSymlinkPath(targetDir, destPath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const planned = [];
|
|
43
|
+
for (const candidate of candidates) {
|
|
44
|
+
const { src, dest, destPath } = candidate;
|
|
16
45
|
let content;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
46
|
+
let source = 'template';
|
|
47
|
+
try {
|
|
48
|
+
if (importRoot && IMPORT_DESTS.has(dest)) {
|
|
49
|
+
const importSrc = path.join(importRoot, '.ai', 'memory', path.basename(dest));
|
|
50
|
+
content = await readOptionalImport(importSrc);
|
|
51
|
+
source = content === undefined ? 'fallback' : 'import';
|
|
52
|
+
}
|
|
53
|
+
if (content === undefined) {
|
|
54
|
+
content = render(await readFile(src, 'utf8'), vars);
|
|
21
55
|
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw new ScaffoldError({
|
|
58
|
+
phase: 'prepare', dest, destPath, cause: err, summary,
|
|
59
|
+
notWritten: candidates.map(({ dest: pending }) => pending),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
planned.push({ dest, destPath, content, source });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const [index, item] of planned.entries()) {
|
|
66
|
+
const { dest, destPath, content, source } = item;
|
|
67
|
+
try {
|
|
68
|
+
await mkdir(path.dirname(destPath), { recursive: true });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
throw writeError('mkdir', item, err, summary, planned, index);
|
|
22
71
|
}
|
|
23
|
-
|
|
24
|
-
|
|
72
|
+
try {
|
|
73
|
+
await writeFile(destPath, content);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
throw writeError('write', item, err, summary, planned, index);
|
|
25
76
|
}
|
|
26
|
-
await mkdir(path.dirname(destPath), { recursive: true });
|
|
27
|
-
await writeFile(destPath, content);
|
|
28
77
|
summary.written.push(dest);
|
|
78
|
+
if (source === 'import') summary.imported.push(dest);
|
|
79
|
+
if (source === 'fallback') summary.fallback.push(dest);
|
|
29
80
|
}
|
|
30
81
|
return summary;
|
|
31
82
|
}
|
|
32
83
|
|
|
33
|
-
|
|
84
|
+
async function exists(filePath) {
|
|
85
|
+
try {
|
|
86
|
+
await lstat(filePath);
|
|
87
|
+
return true;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeError(phase, item, cause, summary, planned, index) {
|
|
95
|
+
return new ScaffoldError({
|
|
96
|
+
phase,
|
|
97
|
+
dest: item.dest,
|
|
98
|
+
destPath: item.destPath,
|
|
99
|
+
cause,
|
|
100
|
+
summary,
|
|
101
|
+
notWritten: planned.slice(index).map(({ dest }) => dest),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function copySummary(summary) {
|
|
106
|
+
return Object.fromEntries(Object.entries(summary).map(([key, value]) => [key, [...value]]));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function validateImportRoot(importFrom) {
|
|
110
|
+
const root = path.resolve(importFrom);
|
|
111
|
+
let info;
|
|
112
|
+
try {
|
|
113
|
+
info = await stat(root);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err.code === 'ENOENT') throw new Error(`导入目录不存在: ${root}`, { cause: err });
|
|
116
|
+
throw new Error(`无法访问导入目录 ${root}: ${err.message}`, { cause: err });
|
|
117
|
+
}
|
|
118
|
+
if (!info.isDirectory()) throw new Error(`导入路径不是目录: ${root}`);
|
|
119
|
+
try {
|
|
120
|
+
await access(root, constants.R_OK);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
throw new Error(`导入目录不可读 ${root}: ${err.message}`, { cause: err });
|
|
123
|
+
}
|
|
124
|
+
return root;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readOptionalImport(importSrc) {
|
|
128
|
+
try {
|
|
129
|
+
return await readFile(importSrc, 'utf8');
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err.code === 'ENOENT') return undefined;
|
|
132
|
+
throw new Error(`无法读取导入文件 ${importSrc}: ${err.message}`, { cause: err });
|
|
133
|
+
}
|
|
134
|
+
}
|