@betterdanlins/ai-memory 0.1.0 → 0.3.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 +108 -14
- package/README.zh-CN.md +120 -0
- package/bin/cli.js +93 -4
- package/package.json +1 -1
- package/src/framework.js +259 -0
- package/src/legacy-v0.1.js +43 -0
- package/src/manifest.js +20 -1
- package/src/migrations.js +28 -0
- package/src/path-safety.js +51 -0
- package/src/scaffold.js +114 -13
- 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/project-inception/SKILL.md +6 -0
- package/templates/claude/CLAUDE.md +10 -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/project-inception/SKILL.md +6 -0
- package/templates/codex/AGENTS.md +10 -5
- package/templates/common/.ai/README.md +2 -1
- package/templates/common/.ai/memory/project-state.md +1 -1
- package/templates/common/.ai/memory/session-log.md +1 -1
- 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 +3 -2
- package/templates/common/.ai/skills/delivery-readiness.md +32 -0
- package/templates/common/.ai/skills/feature-design.md +33 -0
- package/templates/common/.ai/skills/memory-update.md +19 -14
- package/templates/common/.ai/skills/project-inception.md +33 -0
- package/templates/common/.ai/skills/requirements-flow.md +31 -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,259 @@
|
|
|
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/memory/',
|
|
16
|
+
'docs/architecture/',
|
|
17
|
+
'docs/requirements/v',
|
|
18
|
+
'docs/design/v',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const MIXED_FILES = new Set(['AGENTS.md', 'CLAUDE.md', '.claude/settings.json']);
|
|
22
|
+
const FRAMEWORK_EXCEPTIONS = new Set(['docs/requirements/README.md', 'docs/design/README.md']);
|
|
23
|
+
|
|
24
|
+
export function ownershipFor(dest) {
|
|
25
|
+
if (MIXED_FILES.has(dest)) return 'mixed';
|
|
26
|
+
if (FRAMEWORK_EXCEPTIONS.has(dest)) return 'framework';
|
|
27
|
+
if (USER_PREFIXES.some(prefix => dest.startsWith(prefix))) return 'user';
|
|
28
|
+
return 'framework';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function hashContent(content) {
|
|
32
|
+
return createHash('sha256').update(content).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function detectInstallation(targetDir) {
|
|
36
|
+
const metadataPath = path.join(targetDir, '.ai', 'ai-memory.json');
|
|
37
|
+
try {
|
|
38
|
+
const metadata = JSON.parse(await readFile(metadataPath, 'utf8'));
|
|
39
|
+
validateMetadata(metadata, metadataPath);
|
|
40
|
+
return { kind: 'metadata', metadata };
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (err.code !== 'ENOENT') {
|
|
43
|
+
if (err instanceof SyntaxError) throw new Error(`框架元数据不是有效 JSON: ${metadataPath}`, { cause: err });
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const readmePath = path.join(targetDir, '.ai', 'README.md');
|
|
49
|
+
try {
|
|
50
|
+
const readme = await readFile(readmePath, 'utf8');
|
|
51
|
+
if (readme.includes('@betterdanlins/ai-memory')) {
|
|
52
|
+
const facts = await readLegacyFacts(targetDir, readme);
|
|
53
|
+
return { kind: 'legacy', version: '0.1.0', schemaVersion: 0, ...facts };
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (err.code !== 'ENOENT') throw err;
|
|
57
|
+
}
|
|
58
|
+
return { kind: 'none' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function writeFrameworkMetadata({
|
|
62
|
+
targetDir, templatesRoot, frameworkVersion, tools, projectName, techStack, date, previousMetadata,
|
|
63
|
+
}) {
|
|
64
|
+
const manifest = await buildManifest(templatesRoot, tools);
|
|
65
|
+
const files = {};
|
|
66
|
+
for (const { dest } of manifest) {
|
|
67
|
+
const content = await readFile(path.join(targetDir, ...dest.split('/')), 'utf8');
|
|
68
|
+
files[dest] = { ownership: ownershipFor(dest), sha256: hashContent(content) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const metadata = {
|
|
72
|
+
frameworkVersion,
|
|
73
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
74
|
+
generatedAt: previousMetadata?.generatedAt ?? new Date().toISOString(),
|
|
75
|
+
...(previousMetadata ? { updatedAt: new Date().toISOString() } : {}),
|
|
76
|
+
tools: [...tools],
|
|
77
|
+
templateVars: { projectName, techStack, date },
|
|
78
|
+
files,
|
|
79
|
+
};
|
|
80
|
+
const metadataPath = path.join(targetDir, '.ai', 'ai-memory.json');
|
|
81
|
+
await mkdir(path.dirname(metadataPath), { recursive: true });
|
|
82
|
+
await writeFile(metadataPath, JSON.stringify(metadata, null, 2) + '\n', { flag: previousMetadata ? 'w' : 'wx' });
|
|
83
|
+
return metadata;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function planFrameworkUpdate({ targetDir, templatesRoot, frameworkVersion }) {
|
|
87
|
+
const installation = await detectInstallation(targetDir);
|
|
88
|
+
if (installation.kind === 'none') throw new Error('当前目录不是 ai-memory 项目,请先运行 init');
|
|
89
|
+
|
|
90
|
+
const tools = installation.kind === 'metadata' ? installation.metadata.tools : installation.tools;
|
|
91
|
+
const vars = installation.kind === 'metadata' ? installation.metadata.templateVars : installation.templateVars;
|
|
92
|
+
if (installation.kind === 'metadata' && compareVersions(installation.metadata.frameworkVersion, frameworkVersion) > 0) {
|
|
93
|
+
throw new Error(`项目框架版本 ${installation.metadata.frameworkVersion} 高于当前 CLI ${frameworkVersion},请使用更新版本的 CLI`);
|
|
94
|
+
}
|
|
95
|
+
const fromSchema = installation.kind === 'metadata' ? installation.metadata.schemaVersion : 0;
|
|
96
|
+
const migrations = migrationsBetween(fromSchema, CURRENT_SCHEMA_VERSION);
|
|
97
|
+
const manifest = await buildManifest(templatesRoot, tools);
|
|
98
|
+
const desiredDests = new Set(manifest.map(({ dest }) => dest));
|
|
99
|
+
const actions = [];
|
|
100
|
+
|
|
101
|
+
for (const { src, dest } of manifest) {
|
|
102
|
+
const desired = render(await readFile(src, 'utf8'), vars);
|
|
103
|
+
const current = await readOptional(path.join(targetDir, ...dest.split('/')));
|
|
104
|
+
const ownership = ownershipFor(dest);
|
|
105
|
+
if (current === undefined) {
|
|
106
|
+
actions.push({ action: 'add', dest, ownership });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const currentHash = hashContent(current);
|
|
111
|
+
const desiredHash = hashContent(desired);
|
|
112
|
+
if (currentHash === desiredHash) {
|
|
113
|
+
actions.push({ action: 'unchanged', dest, ownership });
|
|
114
|
+
} else if (ownership === 'user') {
|
|
115
|
+
actions.push({ action: 'preserve', dest, ownership });
|
|
116
|
+
} else {
|
|
117
|
+
const matchesBaseline = installation.kind === 'metadata'
|
|
118
|
+
? installation.metadata.files[dest]?.sha256 === currentHash
|
|
119
|
+
: matchesLegacyV01Baseline(dest, current, vars);
|
|
120
|
+
const action = matchesBaseline ? 'update' : ownership === 'mixed' ? 'merge' : 'review';
|
|
121
|
+
actions.push({ action, dest, ownership, currentHash, desiredHash });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (installation.kind === 'metadata') {
|
|
126
|
+
for (const [dest, record] of Object.entries(installation.metadata.files)) {
|
|
127
|
+
if (desiredDests.has(dest) || !(await exists(path.join(targetDir, ...dest.split('/'))))) continue;
|
|
128
|
+
actions.push({
|
|
129
|
+
action: record.ownership === 'user' ? 'preserve' : 'review-remove',
|
|
130
|
+
dest,
|
|
131
|
+
ownership: record.ownership,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
installation,
|
|
138
|
+
frameworkVersion,
|
|
139
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
140
|
+
tools,
|
|
141
|
+
migrations,
|
|
142
|
+
actions,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function applyFrameworkUpdate({ targetDir, templatesRoot, plan }) {
|
|
147
|
+
const blockers = plan.actions.filter(item => ['merge', 'review', 'review-remove'].includes(item.action));
|
|
148
|
+
if (blockers.length) {
|
|
149
|
+
throw new Error(`存在 ${blockers.length} 个需合并/审查项,未写入任何文件:\n${blockers.map(item => `- ${item.dest}`).join('\n')}`);
|
|
150
|
+
}
|
|
151
|
+
const manualMigrations = plan.migrations.filter(item => !item.automatic);
|
|
152
|
+
if (manualMigrations.length) {
|
|
153
|
+
throw new Error(`存在不可自动执行的 Schema 迁移: ${manualMigrations.map(item => item.id).join(', ')}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const vars = plan.installation.kind === 'metadata'
|
|
157
|
+
? plan.installation.metadata.templateVars
|
|
158
|
+
: plan.installation.templateVars;
|
|
159
|
+
const manifest = await buildManifest(templatesRoot, plan.tools);
|
|
160
|
+
const sources = new Map(manifest.map(entry => [entry.dest, entry.src]));
|
|
161
|
+
const candidates = [];
|
|
162
|
+
|
|
163
|
+
for (const action of plan.actions.filter(item => item.action === 'add' || item.action === 'update')) {
|
|
164
|
+
const src = sources.get(action.dest);
|
|
165
|
+
if (!src) throw new Error(`升级计划缺少当前模板: ${action.dest}`);
|
|
166
|
+
const destPath = resolveSafeDestination(targetDir, action.dest);
|
|
167
|
+
await assertNoSymlinkPath(targetDir, destPath);
|
|
168
|
+
if (action.action === 'update') {
|
|
169
|
+
const current = await readFile(destPath, 'utf8');
|
|
170
|
+
if (action.currentHash && hashContent(current) !== action.currentHash) {
|
|
171
|
+
throw new Error(`文件在预检后发生变化,请重新 dry-run: ${action.dest}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
candidates.push({ ...action, destPath, content: render(await readFile(src, 'utf8'), vars) });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const summary = { written: [], skipped: [], imported: [], fallback: [] };
|
|
178
|
+
for (const [index, item] of candidates.entries()) {
|
|
179
|
+
try {
|
|
180
|
+
await mkdir(path.dirname(item.destPath), { recursive: true });
|
|
181
|
+
await writeFile(item.destPath, item.content, { flag: item.action === 'add' ? 'wx' : 'w' });
|
|
182
|
+
summary.written.push(item.dest);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
throw new ScaffoldError({
|
|
185
|
+
phase: 'update', dest: item.dest, destPath: item.destPath, cause: err, summary,
|
|
186
|
+
notWritten: candidates.slice(index).map(({ dest }) => dest),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const previousMetadata = plan.installation.kind === 'metadata' ? plan.installation.metadata : undefined;
|
|
192
|
+
try {
|
|
193
|
+
await writeFrameworkMetadata({
|
|
194
|
+
targetDir,
|
|
195
|
+
templatesRoot,
|
|
196
|
+
frameworkVersion: plan.frameworkVersion,
|
|
197
|
+
tools: plan.tools,
|
|
198
|
+
projectName: vars.projectName,
|
|
199
|
+
techStack: vars.techStack,
|
|
200
|
+
date: vars.date,
|
|
201
|
+
previousMetadata,
|
|
202
|
+
});
|
|
203
|
+
summary.written.push(METADATA_DEST);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw new ScaffoldError({
|
|
206
|
+
phase: 'update', dest: METADATA_DEST, destPath: path.join(targetDir, '.ai', 'ai-memory.json'),
|
|
207
|
+
cause: err, summary, notWritten: [METADATA_DEST],
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return summary;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function readLegacyFacts(targetDir, readme) {
|
|
214
|
+
const state = await readOptional(path.join(targetDir, '.ai', 'memory', 'project-state.md')) ?? '';
|
|
215
|
+
const projectName = state.match(/^- 项目:(.+)$/m)?.[1]?.trim() || path.basename(path.resolve(targetDir));
|
|
216
|
+
const techStack = state.match(/^- 技术栈:(.+)$/m)?.[1]?.trim() || '(待补充)';
|
|
217
|
+
const date = readme.match(/生成于 (\d{4}-\d{2}-\d{2})/)?.[1]
|
|
218
|
+
|| state.match(/最后更新:(\d{4}-\d{2}-\d{2})/)?.[1]
|
|
219
|
+
|| new Date().toISOString().slice(0, 10);
|
|
220
|
+
const tools = [];
|
|
221
|
+
if (await exists(path.join(targetDir, 'CLAUDE.md'))) tools.push('claude');
|
|
222
|
+
if (await exists(path.join(targetDir, 'AGENTS.md'))) tools.push('codex');
|
|
223
|
+
return { tools, templateVars: { projectName, techStack, date } };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function validateMetadata(metadata, metadataPath) {
|
|
227
|
+
if (!metadata || typeof metadata !== 'object') throw new Error(`框架元数据格式无效: ${metadataPath}`);
|
|
228
|
+
if (typeof metadata.frameworkVersion !== 'string' || !Number.isInteger(metadata.schemaVersion)) {
|
|
229
|
+
throw new Error(`框架元数据缺少版本字段: ${metadataPath}`);
|
|
230
|
+
}
|
|
231
|
+
if (!Array.isArray(metadata.tools) || !metadata.templateVars || !metadata.files) {
|
|
232
|
+
throw new Error(`框架元数据缺少安装基线: ${metadataPath}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function readOptional(filePath) {
|
|
237
|
+
try {
|
|
238
|
+
return await readFile(filePath, 'utf8');
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return undefined;
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const exists = filePath => access(filePath).then(() => true, err => {
|
|
246
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
|
|
247
|
+
throw err;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
function compareVersions(left, right) {
|
|
251
|
+
const parse = value => value.split('-', 1)[0].split('.').map(part => Number.parseInt(part, 10) || 0);
|
|
252
|
+
const a = parse(left);
|
|
253
|
+
const b = parse(right);
|
|
254
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
255
|
+
const difference = (a[i] ?? 0) - (b[i] ?? 0);
|
|
256
|
+
if (difference !== 0) return Math.sign(difference);
|
|
257
|
+
}
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
@@ -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,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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: critic
|
|
3
|
-
description: Use when
|
|
3
|
+
description: Use when an M/L-risk requirement, architecture baseline, or implementation design needs independent adversarial review, or when the user explicitly requests critique, rebuttal, or challenge
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
派发 critic subagent(独立上下文,只给文档路径与 `.ai/skills/critic.md`,不给对话历史),随后按其纪律逐条回应质疑。
|
|
6
|
+
派发 critic subagent(独立上下文,只给文档路径与 `.ai/skills/critic.md`,不给对话历史),随后按其纪律逐条回应质疑。S 级需求由 requirements-flow 直接做精简自检,不要为其触发本 skill。
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: delivery-readiness
|
|
3
|
+
description: Use when a feature is preparing to merge, release, deploy, or hand over and needs evidence-based verification of contracts, tests, migration, rollback, performance, observability, reliability, security, or operational readiness
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
读取并严格遵循 `.ai/skills/delivery-readiness.md`,按 S/M/L 风险检查适用项;不要重新执行需求澄清或技术设计。
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: feature-design
|
|
3
|
+
description: Use when an M/L-risk finalized requirement needs implementation design, module or service boundaries, technical interface contracts, data-model changes, quality-attribute impact, migration, rollback, or an implementation-ready how document
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
读取并严格遵循 `.ai/skills/feature-design.md`,以 final 需求和项目架构基线为输入;不要重新讨论已确认的 what/why,也不要默认启动完整 Superpowers 流程。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: memory-update
|
|
3
|
-
description: Use when
|
|
3
|
+
description: Use when completing an independently verifiable feature, phase, or release; making a durable decision; changing requirement/design/delivery status; preserving a cross-session risk; switching tools; or when the user explicitly asks to update memory
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
读取并严格遵循 `.ai/skills/memory-update.md` 的写入路由与硬性约束执行。
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: project-inception
|
|
3
|
+
description: Use when starting a 0-to-1/greenfield project, establishing its first engineering and architecture baseline, or planning a major redesign that changes system boundaries, data ownership, deployment topology, or quality-attribute targets
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
读取并严格遵循 `.ai/skills/project-inception.md`,建立或增量更新 `docs/architecture/` 四份工程基线;普通功能迭代不要触发。
|