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.
@@ -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. Optional clone: copy the entire harness directory into the target
110
- // project root. After cloning, `effectiveHarnessRoot` points to the
111
- // cloned copy so binding.yaml resolves to the local path.
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 = !!opts.clone;
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
- if (cloneMode) {
117
- // Guard: never copy a directory onto itself (would recurse forever).
118
- const sameDir = node_path_1.default.resolve(cloneDest).toLowerCase() ===
119
- node_path_1.default.resolve(harnessRoot).toLowerCase();
120
- if (sameDir) {
121
- logger_1.logger.warn(`源 harness 与克隆目标路径相同,跳过拷贝:${cloneDest}`);
122
- }
123
- else {
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: cloneMode ? 'clone(拷贝整个 harness 到目标项目)' : 'bind-only(仅绑定,不拷贝)' },
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: '绑定元数据(冗余副本)', value: node_path_1.default.relative(targetRoot, bindingFile) }, { label: '项目根 AI 入口', value: adapter.projectEntryFile }, { label: '工具版本', value: `svharness@${cliVersion}` });
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 skill injection and binding resolve to the local copy.
195
- const totalSteps = cloneMode && effectiveHarnessRoot !== harnessRoot ? 3 : 2;
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} - 克隆 harness 到目标项目`);
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(`已克隆 harness:${node_path_1.default.relative(targetRoot, cloneDest)}`);
567
+ logger_1.logger.success(`已拷贝 harness:${node_path_1.default.relative(targetRoot, cloneDest)}`);
211
568
  stepNo++;
212
569
  }
213
- // 8. Build binding metadata first (needed for both inline rendering into
214
- // the skill body and the redundant sidecar file).
215
- const skillRegistry = await (0, reference_apply_skills_1.loadApplySkillRegistry)(effectiveHarnessRoot);
216
- const applySkillRegistry = skillRegistry
217
- ? (0, reference_apply_skills_1.registryEntriesForBinding)(skillRegistry)
218
- : [];
219
- const binding = {
220
- harness_name: harnessName,
221
- harness_root: effectiveHarnessRoot,
222
- harness_root_rel: harnessRootRel,
223
- harness_version: harnessVersion,
224
- target_project: targetRoot,
225
- agent,
226
- mode: cloneMode ? 'clone' : 'bind-only',
227
- applied_at: appliedAt,
228
- applied_by: `svharness@${cliVersion}`,
229
- apply_skill_registry: applySkillRegistry,
230
- };
231
- const bindingYaml = '# Generated by `svharness apply`. DO NOT edit by hand.\n' +
232
- '# Re-run `svharness apply --force` to refresh.\n' +
233
- js_yaml_1.default.dump(binding, { lineWidth: 120, noRefs: true });
234
- // 8b. Write skill content with binding inlined into the template body
235
- // (agent-specific transform keeps frontmatter rules consistent across
236
- // IDEs). The inlined `## 绑定元数据` block is the runtime source of
237
- // truth agents parse it from the skill content itself, never from
238
- // the sidecar file.
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
- // 9. Write binding.yaml as a redundant sidecar copy (scheme B tooling:
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} 与 AGENTS_APPLY.md 了解完整的 harness 应用工作流`);
646
+ logger_1.logger.plain(` 或阅读 ${adapter.projectEntryFile} 了解完整的 harness 应用工作流`);
307
647
  logger_1.logger.plain('');
308
- if (cloneMode && effectiveHarnessRoot !== harnessRoot) {
309
- logger_1.logger.plain(` harness 内容已克隆至:${node_path_1.default.relative(targetRoot, effectiveHarnessRoot)}/(已纳入目标项目)`);
310
- logger_1.logger.plain(` 源 harness 仍保留在:${harnessRoot}(可独立演进后再次 apply --clone --force 更新)`);
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 内容仍位于:${harnessRoot}(未拷贝;如需完整内聚请加 --clone)`);
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('');