svharness 0.11.0 → 0.13.2
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/README.md +37 -44
- package/dist/commands/apply.js +447 -107
- package/dist/commands/convert.js +17 -15
- package/dist/commands/init.js +33 -5
- package/dist/commands/wizard.js +5 -4
- package/dist/core/apply-project-entry.js +37 -63
- package/dist/core/project-ignore.js +53 -0
- package/dist/index.js +23 -27
- package/dist/utils/harness-name.js +41 -0
- package/dist/utils/validate-args.js +72 -31
- package/package.json +1 -1
- package/templates/_shared/apply-skills/harness-apply-skills-main.md +19 -109
- package/templates/svharness.config.example.yaml +2 -1
package/dist/commands/apply.js
CHANGED
|
@@ -10,11 +10,30 @@ const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
|
10
10
|
const prompts_1 = __importDefault(require("prompts"));
|
|
11
11
|
const adapters_1 = require("../adapters");
|
|
12
12
|
const apply_project_entry_1 = require("../core/apply-project-entry");
|
|
13
|
-
const reference_apply_skills_1 = require("../core/reference-apply-skills");
|
|
14
13
|
const validate_args_1 = require("../utils/validate-args");
|
|
15
14
|
const logger_1 = require("../utils/logger");
|
|
16
15
|
const version_1 = require("../utils/version");
|
|
17
16
|
const DISPATCHER_SKILL_NAME = 'harness-apply-skills-main';
|
|
17
|
+
const BUILD_SKILL_TEMPLATE_FILES = [
|
|
18
|
+
'harness-build-skills-main.md',
|
|
19
|
+
'harness-build-skill-orchestrator.md',
|
|
20
|
+
'harness-build-skill-spec-builder.md',
|
|
21
|
+
'harness-build-skill-references-intake.md',
|
|
22
|
+
'harness-build-skill-agent-env-merge.md',
|
|
23
|
+
'harness-build-skill-knowledge-builder.md',
|
|
24
|
+
'harness-build-skill-wiki-writer.md',
|
|
25
|
+
];
|
|
26
|
+
const PATH_KEYS = [
|
|
27
|
+
'agent-env/',
|
|
28
|
+
'baseline/',
|
|
29
|
+
'specs/',
|
|
30
|
+
'tasks/',
|
|
31
|
+
'harness.yaml',
|
|
32
|
+
'.harness-build-state.yaml',
|
|
33
|
+
'AGENTS_BUILD.md',
|
|
34
|
+
'AGENTS_APPLY.md',
|
|
35
|
+
'VERSION',
|
|
36
|
+
];
|
|
18
37
|
/**
|
|
19
38
|
* Resolve the absolute path to the bundled templates/ directory.
|
|
20
39
|
* At runtime __dirname is dist/commands, so templates are at ../../templates.
|
|
@@ -45,6 +64,27 @@ async function readAgentFromState(harnessRoot) {
|
|
|
45
64
|
return undefined;
|
|
46
65
|
}
|
|
47
66
|
}
|
|
67
|
+
async function readArchFromState(harnessRoot) {
|
|
68
|
+
const stateFile = node_path_1.default.join(harnessRoot, '.harness-build-state.yaml');
|
|
69
|
+
if (!(await fs_extra_1.default.pathExists(stateFile)))
|
|
70
|
+
return undefined;
|
|
71
|
+
try {
|
|
72
|
+
const raw = await fs_extra_1.default.readFile(stateFile, 'utf8');
|
|
73
|
+
const parsed = js_yaml_1.default.load(raw);
|
|
74
|
+
const arch = parsed?.arch?.trim();
|
|
75
|
+
if (arch === 'android-compose' ||
|
|
76
|
+
arch === 'android-xml' ||
|
|
77
|
+
arch === 'cpp' ||
|
|
78
|
+
arch === 'web-react' ||
|
|
79
|
+
arch === 'python') {
|
|
80
|
+
return arch;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
48
88
|
/**
|
|
49
89
|
* Read `<harness>/VERSION` if present. Falls back to 'unknown'.
|
|
50
90
|
*/
|
|
@@ -68,6 +108,322 @@ function deriveHarnessName(harnessRoot) {
|
|
|
68
108
|
const base = node_path_1.default.basename(harnessRoot);
|
|
69
109
|
return base.endsWith('-harness') ? base.slice(0, -'-harness'.length) : base;
|
|
70
110
|
}
|
|
111
|
+
function normalizeHarnessPrefix(harnessRootRel) {
|
|
112
|
+
if (!harnessRootRel || harnessRootRel === '.')
|
|
113
|
+
return './';
|
|
114
|
+
const normalized = harnessRootRel.replace(/\\/g, '/');
|
|
115
|
+
return normalized.endsWith('/') ? normalized : `${normalized}/`;
|
|
116
|
+
}
|
|
117
|
+
function toPosix(p) {
|
|
118
|
+
return p.replace(/\\/g, '/');
|
|
119
|
+
}
|
|
120
|
+
function isLikelyPathToken(token) {
|
|
121
|
+
const t = token.trim();
|
|
122
|
+
if (!t)
|
|
123
|
+
return false;
|
|
124
|
+
if (t.includes(' '))
|
|
125
|
+
return false;
|
|
126
|
+
if (t.startsWith('http://') ||
|
|
127
|
+
t.startsWith('https://') ||
|
|
128
|
+
t.startsWith('mailto:') ||
|
|
129
|
+
t.startsWith('#')) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (t.includes('<') || t.includes('>') || t.includes('*'))
|
|
133
|
+
return false;
|
|
134
|
+
if (t.includes('/'))
|
|
135
|
+
return true;
|
|
136
|
+
if (t.startsWith('./') || t.startsWith('../'))
|
|
137
|
+
return true;
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
function toHarnessScopedRef(raw, harnessDirName) {
|
|
141
|
+
const ref = toPosix(raw.trim());
|
|
142
|
+
if (ref.startsWith('http://') ||
|
|
143
|
+
ref.startsWith('https://') ||
|
|
144
|
+
ref.startsWith('mailto:') ||
|
|
145
|
+
ref.startsWith('#')) {
|
|
146
|
+
return raw;
|
|
147
|
+
}
|
|
148
|
+
const normalized = ref.replace(/^\.\//, '');
|
|
149
|
+
const needRewrite = PATH_KEYS.some((k) => normalized.startsWith(k) || normalized === k);
|
|
150
|
+
if (!needRewrite)
|
|
151
|
+
return raw;
|
|
152
|
+
return `./${harnessDirName}/${normalized}`.replace(/\/+/g, '/');
|
|
153
|
+
}
|
|
154
|
+
function rewriteHarnessReferences(content, harnessDirName) {
|
|
155
|
+
const withLinks = content.replace(/\]\(([^)]+)\)/g, (_m, linkTarget) => {
|
|
156
|
+
const [pathPart, ...hashParts] = linkTarget.split('#');
|
|
157
|
+
const rewritten = toHarnessScopedRef(pathPart, harnessDirName);
|
|
158
|
+
if (hashParts.length > 0) {
|
|
159
|
+
return `](${rewritten}#${hashParts.join('#')})`;
|
|
160
|
+
}
|
|
161
|
+
return `](${rewritten})`;
|
|
162
|
+
});
|
|
163
|
+
return withLinks.replace(/`([^`\n]+)`/g, (_m, token) => {
|
|
164
|
+
if (!isLikelyPathToken(token))
|
|
165
|
+
return `\`${token}\``;
|
|
166
|
+
return `\`${toHarnessScopedRef(token, harnessDirName)}\``;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function collectReferencedPaths(content) {
|
|
170
|
+
const refs = [];
|
|
171
|
+
const linkRegex = /\]\(([^)]+)\)/g;
|
|
172
|
+
let match;
|
|
173
|
+
while ((match = linkRegex.exec(content)) !== null) {
|
|
174
|
+
const clean = match[1].split('#')[0].split('?')[0].trim();
|
|
175
|
+
if (isLikelyPathToken(clean))
|
|
176
|
+
refs.push(clean);
|
|
177
|
+
}
|
|
178
|
+
const backtickRegex = /`([^`\n]+)`/g;
|
|
179
|
+
while ((match = backtickRegex.exec(content)) !== null) {
|
|
180
|
+
const token = match[1].trim();
|
|
181
|
+
if (isLikelyPathToken(token))
|
|
182
|
+
refs.push(token);
|
|
183
|
+
}
|
|
184
|
+
return refs;
|
|
185
|
+
}
|
|
186
|
+
function resolveRefCandidate(fromFile, ref, targetRoot, harnessDirName) {
|
|
187
|
+
const normalized = toPosix(ref).trim();
|
|
188
|
+
if (normalized === `./${harnessDirName}` ||
|
|
189
|
+
normalized === harnessDirName ||
|
|
190
|
+
normalized.startsWith(`./${harnessDirName}/`) ||
|
|
191
|
+
normalized.startsWith(`${harnessDirName}/`)) {
|
|
192
|
+
const withoutDot = normalized.replace(/^\.\//, '');
|
|
193
|
+
return node_path_1.default.resolve(targetRoot, withoutDot);
|
|
194
|
+
}
|
|
195
|
+
if (/^[A-Za-z]:[\\/]/.test(ref))
|
|
196
|
+
return node_path_1.default.resolve(ref);
|
|
197
|
+
if (normalized.startsWith('/')) {
|
|
198
|
+
return node_path_1.default.resolve(targetRoot, `.${normalized}`);
|
|
199
|
+
}
|
|
200
|
+
if (normalized.startsWith('./') || normalized.startsWith('../')) {
|
|
201
|
+
return node_path_1.default.resolve(node_path_1.default.dirname(fromFile), normalized);
|
|
202
|
+
}
|
|
203
|
+
return node_path_1.default.resolve(targetRoot, normalized);
|
|
204
|
+
}
|
|
205
|
+
async function resolveSkillSourceFile(skillDir) {
|
|
206
|
+
const preferred = [node_path_1.default.join(skillDir, 'SKILL.md'), node_path_1.default.join(skillDir, 'SKILL.mdc')];
|
|
207
|
+
for (const p of preferred) {
|
|
208
|
+
if (await fs_extra_1.default.pathExists(p))
|
|
209
|
+
return p;
|
|
210
|
+
}
|
|
211
|
+
const entries = await fs_extra_1.default.readdir(skillDir);
|
|
212
|
+
const fallback = entries.find((e) => /^SKILL\./i.test(e));
|
|
213
|
+
return fallback ? node_path_1.default.join(skillDir, fallback) : undefined;
|
|
214
|
+
}
|
|
215
|
+
async function copyRuntimeRules(input) {
|
|
216
|
+
if (!input.adapter.rulesDir) {
|
|
217
|
+
logger_1.logger.info(`agent ${input.adapter.name} 未声明 rulesDir,跳过运行期 rules 注入`);
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const srcDir = node_path_1.default.join(input.harnessRoot, 'agent-env', 'rules');
|
|
221
|
+
if (!(await fs_extra_1.default.pathExists(srcDir))) {
|
|
222
|
+
logger_1.logger.info(`harness 中未找到 agent-env/rules,跳过运行期 rules 注入`);
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
const dstDir = node_path_1.default.join(input.targetRoot, input.adapter.rulesDir);
|
|
226
|
+
await fs_extra_1.default.ensureDir(dstDir);
|
|
227
|
+
const ruleExt = input.adapter.ruleExt ?? '.md';
|
|
228
|
+
const written = [];
|
|
229
|
+
const entries = await fs_extra_1.default.readdir(srcDir);
|
|
230
|
+
for (const name of entries) {
|
|
231
|
+
const srcPath = node_path_1.default.join(srcDir, name);
|
|
232
|
+
const stat = await fs_extra_1.default.stat(srcPath);
|
|
233
|
+
if (!stat.isFile())
|
|
234
|
+
continue;
|
|
235
|
+
if (!name.toLowerCase().endsWith('.md') && !name.toLowerCase().endsWith('.mdc')) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const raw = await fs_extra_1.default.readFile(srcPath, 'utf8');
|
|
239
|
+
const rewritten = rewriteHarnessReferences(raw, input.harnessDirName);
|
|
240
|
+
const content = input.adapter.ruleTransform
|
|
241
|
+
? input.adapter.ruleTransform(rewritten, name)
|
|
242
|
+
: rewritten;
|
|
243
|
+
const dstName = node_path_1.default.parse(name).name + ruleExt;
|
|
244
|
+
const dstPath = node_path_1.default.join(dstDir, dstName);
|
|
245
|
+
if ((await fs_extra_1.default.pathExists(dstPath)) && !input.force) {
|
|
246
|
+
logger_1.logger.warn(`运行期 rule 已存在,跳过:${node_path_1.default.relative(input.targetRoot, dstPath)}`);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
await fs_extra_1.default.outputFile(dstPath, content, 'utf8');
|
|
250
|
+
written.push(node_path_1.default.relative(input.targetRoot, dstPath));
|
|
251
|
+
}
|
|
252
|
+
return written;
|
|
253
|
+
}
|
|
254
|
+
async function copyRuntimeSkills(input) {
|
|
255
|
+
const srcDir = node_path_1.default.join(input.harnessRoot, 'agent-env', 'skills');
|
|
256
|
+
if (!(await fs_extra_1.default.pathExists(srcDir))) {
|
|
257
|
+
logger_1.logger.info(`harness 中未找到 agent-env/skills,跳过运行期 skills 注入`);
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
const dstDir = node_path_1.default.join(input.targetRoot, input.adapter.skillsDir);
|
|
261
|
+
await fs_extra_1.default.ensureDir(dstDir);
|
|
262
|
+
const written = [];
|
|
263
|
+
const entries = await fs_extra_1.default.readdir(srcDir);
|
|
264
|
+
for (const skillName of entries) {
|
|
265
|
+
if (input.skipSkills?.has(skillName))
|
|
266
|
+
continue;
|
|
267
|
+
const srcSkillDir = node_path_1.default.join(srcDir, skillName);
|
|
268
|
+
const stat = await fs_extra_1.default.stat(srcSkillDir);
|
|
269
|
+
if (!stat.isDirectory())
|
|
270
|
+
continue;
|
|
271
|
+
const srcSkillFile = await resolveSkillSourceFile(srcSkillDir);
|
|
272
|
+
if (!srcSkillFile) {
|
|
273
|
+
logger_1.logger.warn(`运行期 skill 缺少 SKILL 文件,跳过:${skillName}`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const raw = await fs_extra_1.default.readFile(srcSkillFile, 'utf8');
|
|
277
|
+
const rewritten = rewriteHarnessReferences(raw, input.harnessDirName);
|
|
278
|
+
const content = input.adapter.transform
|
|
279
|
+
? input.adapter.transform(rewritten, node_path_1.default.basename(srcSkillFile))
|
|
280
|
+
: rewritten;
|
|
281
|
+
const dstSkillDir = node_path_1.default.join(dstDir, skillName);
|
|
282
|
+
const dstSkillFile = node_path_1.default.join(dstSkillDir, 'SKILL' + input.adapter.skillExt);
|
|
283
|
+
if ((await fs_extra_1.default.pathExists(dstSkillFile)) && !input.force) {
|
|
284
|
+
logger_1.logger.warn(`运行期 skill 已存在,跳过:${node_path_1.default.relative(input.targetRoot, dstSkillFile)}`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
await fs_extra_1.default.ensureDir(dstSkillDir);
|
|
288
|
+
await fs_extra_1.default.outputFile(dstSkillFile, content, 'utf8');
|
|
289
|
+
written.push(node_path_1.default.relative(input.targetRoot, dstSkillFile));
|
|
290
|
+
}
|
|
291
|
+
return written;
|
|
292
|
+
}
|
|
293
|
+
async function injectThinMainSkill(input) {
|
|
294
|
+
const templatePath = node_path_1.default.join(input.templatesRoot, '_shared', 'apply-skills', `${DISPATCHER_SKILL_NAME}.md`);
|
|
295
|
+
if (!(await fs_extra_1.default.pathExists(templatePath))) {
|
|
296
|
+
throw new Error(`apply-main 模板缺失:${templatePath}`);
|
|
297
|
+
}
|
|
298
|
+
const dstDir = node_path_1.default.join(input.targetRoot, input.adapter.skillsDir, DISPATCHER_SKILL_NAME);
|
|
299
|
+
const dstFile = node_path_1.default.join(dstDir, 'SKILL' + input.adapter.skillExt);
|
|
300
|
+
if (await fs_extra_1.default.pathExists(dstDir)) {
|
|
301
|
+
if (!input.force) {
|
|
302
|
+
throw new Error(`目标 skill 目录已存在:${dstDir}\n 使用 --force 覆盖,或先清理目标目录。`);
|
|
303
|
+
}
|
|
304
|
+
await fs_extra_1.default.remove(dstDir);
|
|
305
|
+
}
|
|
306
|
+
const raw = await fs_extra_1.default.readFile(templatePath, 'utf8');
|
|
307
|
+
const rendered = raw
|
|
308
|
+
.replace(/__HARNESS_ROOT_REL__/g, `./${input.harnessDirName}`)
|
|
309
|
+
.replace(/__HARNESS_DIR_NAME__/g, input.harnessDirName);
|
|
310
|
+
const content = input.adapter.transform
|
|
311
|
+
? input.adapter.transform(rendered, `${DISPATCHER_SKILL_NAME}.md`)
|
|
312
|
+
: rendered;
|
|
313
|
+
await fs_extra_1.default.ensureDir(dstDir);
|
|
314
|
+
await fs_extra_1.default.outputFile(dstFile, content, 'utf8');
|
|
315
|
+
return dstFile;
|
|
316
|
+
}
|
|
317
|
+
async function updateGitIgnore(input) {
|
|
318
|
+
const gitignorePath = node_path_1.default.join(input.targetRoot, '.gitignore');
|
|
319
|
+
const raw = (await fs_extra_1.default.pathExists(gitignorePath))
|
|
320
|
+
? await fs_extra_1.default.readFile(gitignorePath, 'utf8')
|
|
321
|
+
: '';
|
|
322
|
+
const existing = new Set(raw
|
|
323
|
+
.split(/\r?\n/)
|
|
324
|
+
.map((line) => line.trim())
|
|
325
|
+
.filter((line) => line.length > 0));
|
|
326
|
+
const candidates = new Set();
|
|
327
|
+
candidates.add(`/${input.harnessDirName}/`);
|
|
328
|
+
candidates.add(`/${toPosix(input.adapter.skillsDir).replace(/^\/+/, '').replace(/\/?$/, '/')}`);
|
|
329
|
+
if (input.adapter.rulesDir) {
|
|
330
|
+
candidates.add(`/${toPosix(input.adapter.rulesDir).replace(/^\/+/, '').replace(/\/?$/, '/')}`);
|
|
331
|
+
}
|
|
332
|
+
const added = [];
|
|
333
|
+
for (const entry of candidates) {
|
|
334
|
+
if (!existing.has(entry)) {
|
|
335
|
+
existing.add(entry);
|
|
336
|
+
added.push(entry);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (added.length === 0)
|
|
340
|
+
return [];
|
|
341
|
+
const appendix = '\n# svharness apply injected assets\n' + added.map((line) => `${line}\n`).join('');
|
|
342
|
+
const next = raw.endsWith('\n') || raw.length === 0 ? raw + appendix : raw + '\n' + appendix;
|
|
343
|
+
await fs_extra_1.default.outputFile(gitignorePath, next, 'utf8');
|
|
344
|
+
return added;
|
|
345
|
+
}
|
|
346
|
+
async function validateInjectedReferences(input) {
|
|
347
|
+
const failures = [];
|
|
348
|
+
for (const file of input.files) {
|
|
349
|
+
if (!(await fs_extra_1.default.pathExists(file)))
|
|
350
|
+
continue;
|
|
351
|
+
const content = await fs_extra_1.default.readFile(file, 'utf8');
|
|
352
|
+
const refs = collectReferencedPaths(content);
|
|
353
|
+
for (const ref of refs) {
|
|
354
|
+
const normalized = toPosix(ref).replace(/^\.\//, '');
|
|
355
|
+
const shouldValidate = normalized === input.harnessDirName ||
|
|
356
|
+
normalized.startsWith(`${input.harnessDirName}/`) ||
|
|
357
|
+
PATH_KEYS.some((k) => normalized.startsWith(k) || normalized === k);
|
|
358
|
+
if (!shouldValidate)
|
|
359
|
+
continue;
|
|
360
|
+
const resolved = resolveRefCandidate(file, ref, input.targetRoot, input.harnessDirName);
|
|
361
|
+
if (!(await fs_extra_1.default.pathExists(resolved))) {
|
|
362
|
+
failures.push(`${node_path_1.default.relative(input.targetRoot, file)} -> ${ref} (missing: ${resolved})`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return failures;
|
|
367
|
+
}
|
|
368
|
+
async function saveBuildSkillsToBuildAgentEnv(input) {
|
|
369
|
+
const destRoot = node_path_1.default.join(input.targetRoot, 'build-agent-env', 'skills');
|
|
370
|
+
await fs_extra_1.default.ensureDir(destRoot);
|
|
371
|
+
const sourceSkillsRoot = node_path_1.default.join(node_path_1.default.dirname(input.harnessRoot), input.adapter.skillsDir);
|
|
372
|
+
const harnessPrefix = normalizeHarnessPrefix(input.harnessRootRel);
|
|
373
|
+
const written = [];
|
|
374
|
+
for (const templateName of BUILD_SKILL_TEMPLATE_FILES) {
|
|
375
|
+
const skillName = templateName.replace(/\.md$/i, '');
|
|
376
|
+
const dstSkillDir = node_path_1.default.join(destRoot, skillName);
|
|
377
|
+
const dstSkillFile = node_path_1.default.join(dstSkillDir, 'SKILL' + input.adapter.skillExt);
|
|
378
|
+
if ((await fs_extra_1.default.pathExists(dstSkillFile)) && !input.force) {
|
|
379
|
+
logger_1.logger.warn(`build skill 已存在,跳过:${node_path_1.default.relative(input.targetRoot, dstSkillFile)}`);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
let raw;
|
|
383
|
+
const harnessSkillDir = node_path_1.default.join(sourceSkillsRoot, skillName);
|
|
384
|
+
if (await fs_extra_1.default.pathExists(harnessSkillDir)) {
|
|
385
|
+
const srcSkillFile = await resolveSkillSourceFile(harnessSkillDir);
|
|
386
|
+
if (srcSkillFile) {
|
|
387
|
+
raw = await fs_extra_1.default.readFile(srcSkillFile, 'utf8');
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (!raw) {
|
|
391
|
+
const templatePath = node_path_1.default.join(input.templatesRoot, '_shared', 'build-skills', templateName);
|
|
392
|
+
if (!(await fs_extra_1.default.pathExists(templatePath))) {
|
|
393
|
+
logger_1.logger.warn(`build skill 模板缺失,跳过:${templateName}`);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
raw = await fs_extra_1.default.readFile(templatePath, 'utf8');
|
|
397
|
+
raw = raw
|
|
398
|
+
.replace(/__HARNESS_ROOT__/g, harnessPrefix)
|
|
399
|
+
.replace(/__HARNESS_BUILD_RULES_DIR__/g, input.adapter.rulesDir ?? '')
|
|
400
|
+
.replace(/__HARNESS_BUILD_SKILLS_DIR__/g, 'build-agent-env/skills');
|
|
401
|
+
if (skillName === 'harness-build-skills-main') {
|
|
402
|
+
const binding = js_yaml_1.default
|
|
403
|
+
.dump({
|
|
404
|
+
harness_root_rel: harnessPrefix,
|
|
405
|
+
harness_name: input.harnessName,
|
|
406
|
+
harness_version: input.harnessVersion,
|
|
407
|
+
arch: input.harnessArch ?? 'unknown',
|
|
408
|
+
agent: input.agent,
|
|
409
|
+
build_rules_dir: input.adapter.rulesDir ?? '',
|
|
410
|
+
build_skills_dir: 'build-agent-env/skills',
|
|
411
|
+
generated_at: input.generatedAt,
|
|
412
|
+
generated_by: `svharness@${input.cliVersion}`,
|
|
413
|
+
}, { lineWidth: 120, noRefs: true })
|
|
414
|
+
.trimEnd();
|
|
415
|
+
raw = raw.replace('{{BINDING_YAML_BLOCK}}', binding);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const content = input.adapter.transform
|
|
419
|
+
? input.adapter.transform(raw, templateName)
|
|
420
|
+
: raw;
|
|
421
|
+
await fs_extra_1.default.ensureDir(dstSkillDir);
|
|
422
|
+
await fs_extra_1.default.outputFile(dstSkillFile, content, 'utf8');
|
|
423
|
+
written.push(node_path_1.default.relative(input.targetRoot, dstSkillFile));
|
|
424
|
+
}
|
|
425
|
+
return written;
|
|
426
|
+
}
|
|
71
427
|
/**
|
|
72
428
|
* Entry point for `svharnessbuild apply`.
|
|
73
429
|
*
|
|
@@ -106,23 +462,22 @@ async function runApply(opts) {
|
|
|
106
462
|
if (!(await fs_extra_1.default.pathExists(targetRoot))) {
|
|
107
463
|
throw new Error(`目标项目目录不存在:${targetRoot}`);
|
|
108
464
|
}
|
|
109
|
-
// 2b.
|
|
110
|
-
|
|
111
|
-
|
|
465
|
+
// 2b. Apply now defaults to copy harness into target.
|
|
466
|
+
if (opts.clone) {
|
|
467
|
+
logger_1.logger.warn('`--clone` 已退化为兼容参数:apply 默认执行拷贝。');
|
|
468
|
+
}
|
|
112
469
|
let effectiveHarnessRoot = harnessRoot;
|
|
113
|
-
const cloneMode =
|
|
470
|
+
const cloneMode = true;
|
|
114
471
|
const harnessDirName = node_path_1.default.basename(harnessRoot);
|
|
115
472
|
const cloneDest = node_path_1.default.join(targetRoot, harnessDirName);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
effectiveHarnessRoot = cloneDest;
|
|
125
|
-
}
|
|
473
|
+
// Guard: never copy a directory onto itself (would recurse forever).
|
|
474
|
+
const sameDir = node_path_1.default.resolve(cloneDest).toLowerCase() ===
|
|
475
|
+
node_path_1.default.resolve(harnessRoot).toLowerCase();
|
|
476
|
+
if (sameDir) {
|
|
477
|
+
logger_1.logger.warn(`源 harness 与拷贝目标路径相同,跳过拷贝:${cloneDest}`);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
effectiveHarnessRoot = cloneDest;
|
|
126
481
|
}
|
|
127
482
|
// 3. Resolve agent (explicit > state file).
|
|
128
483
|
let agent = opts.agent;
|
|
@@ -150,6 +505,8 @@ async function runApply(opts) {
|
|
|
150
505
|
}
|
|
151
506
|
const harnessName = deriveHarnessName(harnessRoot);
|
|
152
507
|
const harnessVersion = await readHarnessVersion(harnessRoot);
|
|
508
|
+
const harnessArch = await readArchFromState(harnessRoot);
|
|
509
|
+
const templatesRoot = resolveTemplatesRoot();
|
|
153
510
|
// harness_root / harness_root_rel are always computed against the
|
|
154
511
|
// effective (possibly cloned) location, so agents always resolve to the
|
|
155
512
|
// on-disk harness the user actually has.
|
|
@@ -163,12 +520,12 @@ async function runApply(opts) {
|
|
|
163
520
|
{ label: 'harness 版本', value: harnessVersion },
|
|
164
521
|
{ label: '目标项目', value: targetRoot },
|
|
165
522
|
{ label: 'agent', value: agent },
|
|
166
|
-
{ label: '模式', value:
|
|
523
|
+
{ label: '模式', value: 'copy(默认强制拷贝 harness)' },
|
|
167
524
|
];
|
|
168
525
|
if (cloneMode && effectiveHarnessRoot !== harnessRoot) {
|
|
169
526
|
configRows.push({ label: '克隆目标', value: effectiveHarnessRoot });
|
|
170
527
|
}
|
|
171
|
-
configRows.push({ label: '注入 skill', value: node_path_1.default.relative(targetRoot, skillFile) }, { label: '
|
|
528
|
+
configRows.push({ label: '注入 skill', value: node_path_1.default.relative(targetRoot, skillFile) }, { label: '运行期 skills 注入', value: adapter.skillsDir }, { label: '运行期 rules 注入', value: adapter.rulesDir ?? '(agent 未声明 rulesDir)' }, { label: '.gitignore', value: '将追加注入路径(幂等)' }, { label: '项目根 AI 入口', value: `${adapter.projectEntryFile}(由 AGENTS_APPLY.md 重命名)` }, { label: '工具版本', value: `svharness@${cliVersion}` });
|
|
172
529
|
logger_1.logger.configBox('apply 配置确认', configRows);
|
|
173
530
|
if (!opts.yes) {
|
|
174
531
|
const { ok } = await (0, prompts_1.default)({
|
|
@@ -191,11 +548,11 @@ async function runApply(opts) {
|
|
|
191
548
|
await fs_extra_1.default.remove(dispatcherDir);
|
|
192
549
|
}
|
|
193
550
|
// 7b. Clone step — copy the whole harness into the target project first,
|
|
194
|
-
// so subsequent
|
|
195
|
-
const totalSteps =
|
|
551
|
+
// so subsequent injections resolve to the local copy.
|
|
552
|
+
const totalSteps = 5;
|
|
196
553
|
let stepNo = 1;
|
|
197
554
|
if (cloneMode && effectiveHarnessRoot !== harnessRoot) {
|
|
198
|
-
logger_1.logger.section(`步骤 ${stepNo}/${totalSteps} -
|
|
555
|
+
logger_1.logger.section(`步骤 ${stepNo}/${totalSteps} - 拷贝 harness 到目标项目`);
|
|
199
556
|
if (await fs_extra_1.default.pathExists(cloneDest)) {
|
|
200
557
|
if (!opts.force) {
|
|
201
558
|
throw new Error(`克隆目标已存在:${cloneDest}\n 使用 --force 覆盖,或改用 bind-only(去掉 --clone)。`);
|
|
@@ -207,110 +564,93 @@ async function runApply(opts) {
|
|
|
207
564
|
overwrite: true,
|
|
208
565
|
errorOnExist: false,
|
|
209
566
|
});
|
|
210
|
-
logger_1.logger.success(
|
|
567
|
+
logger_1.logger.success(`已拷贝 harness:${node_path_1.default.relative(targetRoot, cloneDest)}`);
|
|
211
568
|
stepNo++;
|
|
212
569
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
logger_1.logger.section(`步骤 ${stepNo}/${totalSteps} - 注入 dispatcher skill`);
|
|
240
|
-
const rawSkill = await fs_extra_1.default.readFile(templatePath, 'utf8');
|
|
241
|
-
const bindingPlaceholder = '{{BINDING_YAML_BLOCK}}';
|
|
242
|
-
if (!rawSkill.includes(bindingPlaceholder)) {
|
|
243
|
-
throw new Error(`dispatcher skill 模板缺少占位符 ${bindingPlaceholder}:${templatePath}\n` +
|
|
244
|
-
' 请升级 svharness 到同时包含内联 binding 机制的版本。');
|
|
245
|
-
}
|
|
246
|
-
const bindingBlock = '```yaml\n' +
|
|
247
|
-
js_yaml_1.default.dump(binding, { lineWidth: 120, noRefs: true }).trimEnd() +
|
|
248
|
-
'\n```';
|
|
249
|
-
const renderedSkill = rawSkill.replace(bindingPlaceholder, bindingBlock);
|
|
250
|
-
const skillContent = adapter.transform
|
|
251
|
-
? adapter.transform(renderedSkill, `${DISPATCHER_SKILL_NAME}.md`)
|
|
252
|
-
: renderedSkill;
|
|
253
|
-
await fs_extra_1.default.ensureDir(dispatcherDir);
|
|
254
|
-
await fs_extra_1.default.outputFile(skillFile, skillContent, 'utf8');
|
|
255
|
-
logger_1.logger.success(`已写入 ${node_path_1.default.relative(targetRoot, skillFile)}`);
|
|
570
|
+
logger_1.logger.section(`步骤 ${stepNo}/${totalSteps} - 注入薄入口与运行期 skills/rules`);
|
|
571
|
+
const thinMainPath = await injectThinMainSkill({
|
|
572
|
+
templatesRoot,
|
|
573
|
+
targetRoot,
|
|
574
|
+
adapter,
|
|
575
|
+
harnessDirName,
|
|
576
|
+
force: !!opts.force,
|
|
577
|
+
});
|
|
578
|
+
const runtimeRules = await copyRuntimeRules({
|
|
579
|
+
harnessRoot: effectiveHarnessRoot,
|
|
580
|
+
targetRoot,
|
|
581
|
+
adapter,
|
|
582
|
+
harnessDirName,
|
|
583
|
+
force: !!opts.force,
|
|
584
|
+
});
|
|
585
|
+
const runtimeSkills = await copyRuntimeSkills({
|
|
586
|
+
harnessRoot: effectiveHarnessRoot,
|
|
587
|
+
targetRoot,
|
|
588
|
+
adapter,
|
|
589
|
+
harnessDirName,
|
|
590
|
+
force: !!opts.force,
|
|
591
|
+
skipSkills: new Set([DISPATCHER_SKILL_NAME]),
|
|
592
|
+
});
|
|
593
|
+
logger_1.logger.success(`已写入薄入口:${node_path_1.default.relative(targetRoot, thinMainPath)}`);
|
|
594
|
+
logger_1.logger.success(`已注入运行期 rules ${runtimeRules.length} 条`);
|
|
595
|
+
logger_1.logger.success(`已注入运行期 skills ${runtimeSkills.length} 条`);
|
|
256
596
|
stepNo++;
|
|
257
|
-
|
|
258
|
-
// detach / status / re-apply detection). Runtime dispatcher does NOT
|
|
259
|
-
// read this file — the authoritative source is the inlined block
|
|
260
|
-
// written in step 8b.
|
|
261
|
-
logger_1.logger.section(`步骤 ${stepNo}/${totalSteps} - 写入 harness 绑定元数据(冗余副本)`);
|
|
262
|
-
await fs_extra_1.default.outputFile(bindingFile, bindingYaml, 'utf8');
|
|
263
|
-
logger_1.logger.success(`已写入 ${node_path_1.default.relative(targetRoot, bindingFile)}(冗余副本)`);
|
|
264
|
-
if (applySkillRegistry.length > 0) {
|
|
265
|
-
logger_1.logger.success(`已内联 apply_skill_registry ${applySkillRegistry.length} 条(references 内容由 apply-main 路由)`);
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
logger_1.logger.info('未找到 references/apply-skills-registry.yaml,apply_skill_registry 为空');
|
|
269
|
-
logger_1.logger.info(' 请在 S60 为「内容引用」登记 registry 后重新 apply');
|
|
270
|
-
}
|
|
271
|
-
// 9b. Inject AGENTS_APPLY.md into target project root (if present in harness).
|
|
272
|
-
const agentsApplySrc = node_path_1.default.join(harnessRoot, 'AGENTS_APPLY.md');
|
|
273
|
-
if (await fs_extra_1.default.pathExists(agentsApplySrc)) {
|
|
274
|
-
const agentsApplyDest = node_path_1.default.join(targetRoot, 'AGENTS_APPLY.md');
|
|
275
|
-
let shouldCopy = true;
|
|
276
|
-
if (await fs_extra_1.default.pathExists(agentsApplyDest)) {
|
|
277
|
-
if (!opts.force) {
|
|
278
|
-
logger_1.logger.warn(`目标项目已存在 AGENTS_APPLY.md,跳过(使用 --force 覆盖)`);
|
|
279
|
-
shouldCopy = false;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
if (shouldCopy) {
|
|
283
|
-
await fs_extra_1.default.copy(agentsApplySrc, agentsApplyDest, { overwrite: true });
|
|
284
|
-
logger_1.logger.success(`已写入 ${node_path_1.default.relative(targetRoot, agentsApplyDest)}`);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
else {
|
|
288
|
-
logger_1.logger.debug('harness 中未找到 AGENTS_APPLY.md,跳过注入');
|
|
289
|
-
}
|
|
597
|
+
logger_1.logger.section(`步骤 ${stepNo}/${totalSteps} - 写入项目根 AI 入口`);
|
|
290
598
|
await (0, apply_project_entry_1.writeApplyProjectEntry)({
|
|
291
599
|
projectRoot: targetRoot,
|
|
600
|
+
harnessRoot: effectiveHarnessRoot,
|
|
601
|
+
harnessDirName,
|
|
292
602
|
adapter,
|
|
293
|
-
harnessRootRel,
|
|
294
|
-
harnessName,
|
|
295
|
-
harnessVersion,
|
|
296
|
-
agent,
|
|
297
|
-
mode: cloneMode ? 'clone' : 'bind-only',
|
|
298
603
|
force: !!opts.force,
|
|
299
604
|
});
|
|
605
|
+
const entryFile = node_path_1.default.join(targetRoot, adapter.projectEntryFile);
|
|
606
|
+
stepNo++;
|
|
607
|
+
logger_1.logger.section(`步骤 ${stepNo}/${totalSteps} - 更新 .gitignore`);
|
|
608
|
+
const ignoreAdded = await updateGitIgnore({
|
|
609
|
+
targetRoot,
|
|
610
|
+
harnessDirName,
|
|
611
|
+
adapter,
|
|
612
|
+
});
|
|
613
|
+
if (ignoreAdded.length === 0) {
|
|
614
|
+
logger_1.logger.info('.gitignore 无新增条目(已是最新)');
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
logger_1.logger.success(`.gitignore 已新增 ${ignoreAdded.length} 条注入路径`);
|
|
618
|
+
}
|
|
619
|
+
stepNo++;
|
|
620
|
+
logger_1.logger.section(`步骤 ${stepNo}/${totalSteps} - 校验注入文件引用关系`);
|
|
621
|
+
const filesToCheck = [
|
|
622
|
+
entryFile,
|
|
623
|
+
thinMainPath,
|
|
624
|
+
...runtimeRules.map((rel) => node_path_1.default.join(targetRoot, rel)),
|
|
625
|
+
...runtimeSkills.map((rel) => node_path_1.default.join(targetRoot, rel)),
|
|
626
|
+
];
|
|
627
|
+
const refFailures = await validateInjectedReferences({
|
|
628
|
+
targetRoot,
|
|
629
|
+
harnessDirName,
|
|
630
|
+
files: filesToCheck,
|
|
631
|
+
});
|
|
632
|
+
if (refFailures.length > 0) {
|
|
633
|
+
const preview = refFailures.slice(0, 20).join('\n');
|
|
634
|
+
logger_1.logger.warn(`引用校验发现 ${refFailures.length} 条潜在问题(请人工复核):\n${preview}\n` +
|
|
635
|
+
(refFailures.length > 20 ? '...(其余省略)' : ''));
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
logger_1.logger.success('引用关系校验通过');
|
|
639
|
+
}
|
|
300
640
|
// 10. Next steps hint.
|
|
301
641
|
logger_1.logger.plain('');
|
|
302
642
|
logger_1.logger.plain('✨ harness 已绑定到当前项目');
|
|
303
643
|
logger_1.logger.plain('');
|
|
304
644
|
logger_1.logger.plain(' 下一步:在 agent 中输入');
|
|
305
645
|
logger_1.logger.plain(` 应用 ${DISPATCHER_SKILL_NAME} 完成 <你的功能> 功能开发`);
|
|
306
|
-
logger_1.logger.plain(` 或阅读 ${adapter.projectEntryFile}
|
|
646
|
+
logger_1.logger.plain(` 或阅读 ${adapter.projectEntryFile} 了解完整的 harness 应用工作流`);
|
|
307
647
|
logger_1.logger.plain('');
|
|
308
|
-
if (
|
|
309
|
-
logger_1.logger.plain(` harness
|
|
310
|
-
logger_1.logger.plain(` 源 harness
|
|
648
|
+
if (effectiveHarnessRoot !== harnessRoot) {
|
|
649
|
+
logger_1.logger.plain(` harness 内容已拷贝至:${node_path_1.default.relative(targetRoot, effectiveHarnessRoot)}/(已纳入目标项目)`);
|
|
650
|
+
logger_1.logger.plain(` 源 harness 保留在:${harnessRoot}`);
|
|
311
651
|
}
|
|
312
652
|
else {
|
|
313
|
-
logger_1.logger.plain(` harness
|
|
653
|
+
logger_1.logger.plain(` harness 内容位于:${harnessRoot}(已在目标目录内,跳过拷贝)`);
|
|
314
654
|
}
|
|
315
655
|
logger_1.logger.plain(` 解绑命令:手动删除 ${node_path_1.default.relative(targetRoot, dispatcherDir)}`);
|
|
316
656
|
logger_1.logger.plain('');
|