svharness 0.8.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 +290 -61
- package/dist/adapters/claude-code.js +1 -0
- package/dist/adapters/codechat.js +1 -0
- package/dist/adapters/cursor.js +1 -0
- package/dist/adapters/generic.js +1 -0
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/opencode.js +17 -0
- package/dist/adapters/qoder.js +1 -0
- package/dist/commands/apply.js +456 -71
- package/dist/commands/convert.js +371 -0
- package/dist/commands/init.js +156 -11
- package/dist/commands/references-apply-skills.js +47 -0
- package/dist/commands/wizard.js +442 -0
- package/dist/config/constants.js +7 -0
- package/dist/config/index.js +18 -0
- package/dist/config/load-config.js +54 -0
- package/dist/config/merge-options.js +115 -0
- package/dist/config/normalize.js +165 -0
- package/dist/config/save-config.js +40 -0
- package/dist/config/types.js +2 -0
- package/dist/core/agent-injector.js +58 -9
- package/dist/core/apply-project-entry.js +66 -0
- package/dist/core/build-project-entry.js +98 -0
- package/dist/core/doc-intake-paths.js +155 -0
- package/dist/core/extra-assets-intake.js +254 -0
- package/dist/core/markitdown-client.js +156 -0
- package/dist/core/next-steps.js +33 -22
- package/dist/core/project-ignore.js +53 -0
- package/dist/core/reference-apply-skills.js +35 -0
- package/dist/core/render-meta.js +2 -1
- package/dist/core/repomix-pack.js +3 -3
- package/dist/core/state.js +44 -24
- package/dist/index.js +211 -140
- package/dist/utils/harness-name.js +41 -0
- package/dist/utils/validate-args.js +147 -6
- package/dist/wiki/wikiTasksWriter.js +5 -6
- package/package.json +2 -1
- package/templates/_shared/apply-skills/harness-apply-skills-main.md +19 -78
- package/templates/_shared/build-rules/harness-build-rule-agent-agnostic.md +5 -5
- package/templates/_shared/build-rules/harness-build-rule-chinese-only.md +5 -5
- package/templates/_shared/build-rules/harness-build-rule-convert-check.md +46 -0
- package/templates/_shared/build-rules/harness-build-rule-memory-write.md +1 -1
- package/templates/_shared/build-rules/harness-build-rule-orchestrator-flow.md +36 -10
- package/templates/_shared/build-rules/harness-build-rule-skills-tasks-output.md +3 -2
- package/templates/_shared/build-rules/harness-build-rule-specs-schema.md +3 -3
- package/templates/_shared/build-rules/harness-build-rule-user-interaction.md +36 -16
- package/templates/_shared/build-skills/harness-build-skill-agent-env-merge.md +75 -0
- package/templates/_shared/build-skills/harness-build-skill-knowledge-builder.md +49 -85
- package/templates/_shared/build-skills/harness-build-skill-orchestrator.md +35 -18
- package/templates/_shared/build-skills/harness-build-skill-references-intake.md +91 -0
- package/templates/_shared/build-skills/harness-build-skill-spec-builder.md +19 -9
- package/templates/_shared/build-skills/harness-build-skill-wiki-writer.md +24 -24
- package/templates/_shared/build-skills/harness-build-skills-main.md +83 -0
- package/templates/_shared/meta/AGENTS_APPLY.md.ejs +139 -0
- package/templates/_shared/meta/{AGENTS.md.ejs → AGENTS_BUILD.md.ejs} +7 -5
- package/templates/_shared/meta/CHANGELOG.md.ejs +3 -3
- package/templates/_shared/meta/README.md.ejs +11 -9
- package/templates/_shared/meta/harness.yaml.ejs +28 -7
- package/templates/_shared/skeleton/baseline/code/.gitkeep +1 -0
- package/templates/_shared/skeleton/baseline/wiki/.gitkeep +1 -0
- package/templates/_shared/skeleton/references/apply-skills-registry.example.yaml +11 -0
- package/templates/_shared/skeleton/references/md/.gitkeep +1 -0
- package/templates/_shared/skeleton/references/raw/.gitkeep +1 -0
- package/templates/_shared/skeleton/references/yaml/.gitkeep +1 -0
- package/templates/_shared/skeleton/requirements/md/.gitkeep +1 -0
- package/templates/_shared/skeleton/requirements/raw/.gitkeep +1 -0
- package/templates/_shared/skeleton/requirements/yaml/.gitkeep +1 -0
- package/templates/android-xml/skeleton/agent-env/skills/harness-android-cli/SKILL.md +88 -0
- package/templates/android-xml/skeleton/agent-env/skills/harness-android-service-patterns/SKILL.md +205 -0
- package/templates/android-xml/skeleton/agent-env/skills/harness-android-xml-architecture/SKILL.md +138 -0
- package/templates/android-xml/skeleton/agent-env/skills/harness-lifecycle-management/SKILL.md +158 -0
- package/templates/android-xml/skeleton/agent-env/skills/harness-xml-ui/SKILL.md +112 -0
- package/templates/cpp/skeleton/agent-env/skills/harness-cmake-build/SKILL.md +163 -0
- package/templates/cpp/skeleton/agent-env/skills/harness-cpp-architecture/SKILL.md +157 -0
- package/templates/cpp/skeleton/agent-env/skills/harness-cpp-concurrency/SKILL.md +180 -0
- package/templates/cpp/skeleton/agent-env/skills/harness-memory-safety/SKILL.md +163 -0
- package/templates/cpp/skeleton/agent-env/skills/harness-modern-cpp/SKILL.md +149 -0
- package/templates/python/skeleton/agent-env/skills/harness-async-patterns/SKILL.md +162 -0
- package/templates/python/skeleton/agent-env/skills/harness-python-architecture/SKILL.md +160 -0
- package/templates/python/skeleton/agent-env/skills/harness-python-package-structure/SKILL.md +210 -0
- package/templates/python/skeleton/agent-env/skills/harness-python-performance/SKILL.md +207 -0
- package/templates/python/skeleton/agent-env/skills/harness-python-testing/SKILL.md +198 -0
- package/templates/svharness.config.example.yaml +40 -0
- package/templates/web-react/skeleton/agent-env/skills/harness-react-architecture/SKILL.md +177 -0
- package/templates/web-react/skeleton/agent-env/skills/harness-react-performance/SKILL.md +177 -0
- package/templates/web-react/skeleton/agent-env/skills/harness-react-testing/SKILL.md +193 -0
- package/templates/web-react/skeleton/agent-env/skills/harness-react-ui-patterns/SKILL.md +257 -0
- package/templates/web-react/skeleton/agent-env/skills/harness-state-management/SKILL.md +189 -0
- package/templates/_shared/skeleton/assets/baseline/code/.gitkeep +0 -1
- package/templates/_shared/skeleton/assets/baseline/wiki/.gitkeep +0 -1
- package/templates/_shared/skeleton/assets/raw/.gitkeep +0 -1
- package/templates/_shared/skeleton/assets/requirements/.gitkeep +0 -1
- /package/templates/_shared/skeleton/{assets/baseline/repomix → agent-env/_incoming/skills}/.gitkeep +0 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveConfigHarnessName = resolveConfigHarnessName;
|
|
4
|
+
exports.normalizeConfig = normalizeConfig;
|
|
5
|
+
const logger_1 = require("../utils/logger");
|
|
6
|
+
const constants_1 = require("./constants");
|
|
7
|
+
function isPlainObject(v) {
|
|
8
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
9
|
+
}
|
|
10
|
+
function normalizeStringArray(v, field) {
|
|
11
|
+
if (v === undefined || v === null)
|
|
12
|
+
return undefined;
|
|
13
|
+
if (Array.isArray(v)) {
|
|
14
|
+
return v.map((x) => String(x).trim()).filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
if (typeof v === 'string' && v.trim())
|
|
17
|
+
return [v.trim()];
|
|
18
|
+
throw new Error(`配置字段 ${field} 必须是字符串或字符串数组`);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolve harnessName vs legacy `name`; error if both differ.
|
|
22
|
+
*/
|
|
23
|
+
function resolveConfigHarnessName(section) {
|
|
24
|
+
const hn = section.harnessName?.trim();
|
|
25
|
+
const legacy = section.name?.trim();
|
|
26
|
+
if (hn && legacy && hn !== legacy) {
|
|
27
|
+
throw new Error('配置 build 中 harnessName 与 name 取值不同,请只保留 harnessName');
|
|
28
|
+
}
|
|
29
|
+
if (hn)
|
|
30
|
+
return hn;
|
|
31
|
+
if (legacy) {
|
|
32
|
+
logger_1.logger.warn('⚠️ 配置 build.name 已重命名为 harnessName,旧键将在下个 minor 版本移除。');
|
|
33
|
+
return legacy;
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
function pickBuildSection(raw) {
|
|
38
|
+
const s = {};
|
|
39
|
+
const str = (k) => {
|
|
40
|
+
const v = raw[k];
|
|
41
|
+
if (v !== undefined && v !== null)
|
|
42
|
+
s[k] = String(v).trim();
|
|
43
|
+
};
|
|
44
|
+
str('harnessName');
|
|
45
|
+
str('name');
|
|
46
|
+
str('arch');
|
|
47
|
+
str('agent');
|
|
48
|
+
str('baseline');
|
|
49
|
+
str('requirements');
|
|
50
|
+
str('requirementsNote');
|
|
51
|
+
str('references');
|
|
52
|
+
str('referencesNote');
|
|
53
|
+
str('extraSkillsNote');
|
|
54
|
+
str('baselineBranch');
|
|
55
|
+
str('convertEndpoint');
|
|
56
|
+
str('wikiLang');
|
|
57
|
+
str('wikiModel');
|
|
58
|
+
str('wikiBaseUrl');
|
|
59
|
+
str('wikiApiKey');
|
|
60
|
+
str('wikiSource');
|
|
61
|
+
if (raw.baselineMaxFileKb !== undefined) {
|
|
62
|
+
s.baselineMaxFileKb = Number(raw.baselineMaxFileKb);
|
|
63
|
+
}
|
|
64
|
+
for (const k of ['convertConcurrency', 'convertMaxFileMb', 'convertTimeoutSec']) {
|
|
65
|
+
if (raw[k] !== undefined) {
|
|
66
|
+
s[k] = Number(raw[k]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const k of [
|
|
70
|
+
'convertForce',
|
|
71
|
+
'force',
|
|
72
|
+
'yes',
|
|
73
|
+
'verbose',
|
|
74
|
+
'generateWiki',
|
|
75
|
+
'wikiTasksOnly',
|
|
76
|
+
]) {
|
|
77
|
+
if (raw[k] !== undefined)
|
|
78
|
+
s[k] = Boolean(raw[k]);
|
|
79
|
+
}
|
|
80
|
+
const extra = normalizeStringArray(raw.extraSkills, 'build.extraSkills');
|
|
81
|
+
if (extra)
|
|
82
|
+
s.extraSkills = extra;
|
|
83
|
+
return s;
|
|
84
|
+
}
|
|
85
|
+
function pickApplySection(raw) {
|
|
86
|
+
const s = {};
|
|
87
|
+
for (const k of ['harness', 'target', 'agent']) {
|
|
88
|
+
if (raw[k] !== undefined && raw[k] !== null)
|
|
89
|
+
s[k] = String(raw[k]).trim();
|
|
90
|
+
}
|
|
91
|
+
for (const k of ['force', 'clone', 'yes', 'verbose']) {
|
|
92
|
+
if (raw[k] !== undefined)
|
|
93
|
+
s[k] = Boolean(raw[k]);
|
|
94
|
+
}
|
|
95
|
+
return s;
|
|
96
|
+
}
|
|
97
|
+
function pickConvertSection(raw) {
|
|
98
|
+
const s = {};
|
|
99
|
+
const input = normalizeStringArray(raw.input, 'convert.input');
|
|
100
|
+
if (input)
|
|
101
|
+
s.input = input;
|
|
102
|
+
for (const k of ['harness', 'output', 'endpoint', 'type']) {
|
|
103
|
+
if (raw[k] !== undefined && raw[k] !== null)
|
|
104
|
+
s[k] = String(raw[k]).trim();
|
|
105
|
+
}
|
|
106
|
+
for (const k of ['concurrency', 'maxFileMb', 'timeoutSec']) {
|
|
107
|
+
if (raw[k] !== undefined)
|
|
108
|
+
s[k] = Number(raw[k]);
|
|
109
|
+
}
|
|
110
|
+
for (const k of ['force', 'yes', 'verbose']) {
|
|
111
|
+
if (raw[k] !== undefined)
|
|
112
|
+
s[k] = Boolean(raw[k]);
|
|
113
|
+
}
|
|
114
|
+
return s;
|
|
115
|
+
}
|
|
116
|
+
function pickDefaults(raw) {
|
|
117
|
+
const s = {};
|
|
118
|
+
if (raw.arch !== undefined)
|
|
119
|
+
s.arch = String(raw.arch).trim();
|
|
120
|
+
if (raw.agent !== undefined)
|
|
121
|
+
s.agent = String(raw.agent).trim();
|
|
122
|
+
for (const k of ['yes', 'verbose', 'force']) {
|
|
123
|
+
if (raw[k] !== undefined)
|
|
124
|
+
s[k] = Boolean(raw[k]);
|
|
125
|
+
}
|
|
126
|
+
return s;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Parse and normalize raw YAML/JSON object into SvharnessConfig.
|
|
130
|
+
*/
|
|
131
|
+
function normalizeConfig(raw, configPath) {
|
|
132
|
+
if (!isPlainObject(raw)) {
|
|
133
|
+
throw new Error(`配置文件格式无效(需为对象):${configPath}`);
|
|
134
|
+
}
|
|
135
|
+
const version = raw.version;
|
|
136
|
+
if (version !== undefined && version !== constants_1.CONFIG_SCHEMA_VERSION) {
|
|
137
|
+
throw new Error(`不支持的配置 version: ${version}(当前仅支持 ${constants_1.CONFIG_SCHEMA_VERSION})`);
|
|
138
|
+
}
|
|
139
|
+
const cfg = { version: constants_1.CONFIG_SCHEMA_VERSION };
|
|
140
|
+
if (raw.defaults !== undefined) {
|
|
141
|
+
if (!isPlainObject(raw.defaults)) {
|
|
142
|
+
throw new Error(`配置 defaults 必须是对象:${configPath}`);
|
|
143
|
+
}
|
|
144
|
+
cfg.defaults = pickDefaults(raw.defaults);
|
|
145
|
+
}
|
|
146
|
+
if (raw.build !== undefined) {
|
|
147
|
+
if (!isPlainObject(raw.build)) {
|
|
148
|
+
throw new Error(`配置 build 必须是对象:${configPath}`);
|
|
149
|
+
}
|
|
150
|
+
cfg.build = pickBuildSection(raw.build);
|
|
151
|
+
}
|
|
152
|
+
if (raw.apply !== undefined) {
|
|
153
|
+
if (!isPlainObject(raw.apply)) {
|
|
154
|
+
throw new Error(`配置 apply 必须是对象:${configPath}`);
|
|
155
|
+
}
|
|
156
|
+
cfg.apply = pickApplySection(raw.apply);
|
|
157
|
+
}
|
|
158
|
+
if (raw.convert !== undefined) {
|
|
159
|
+
if (!isPlainObject(raw.convert)) {
|
|
160
|
+
throw new Error(`配置 convert 必须是对象:${configPath}`);
|
|
161
|
+
}
|
|
162
|
+
cfg.convert = pickConvertSection(raw.convert);
|
|
163
|
+
}
|
|
164
|
+
return cfg;
|
|
165
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.saveConfigSection = saveConfigSection;
|
|
7
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
10
|
+
const constants_1 = require("./constants");
|
|
11
|
+
/**
|
|
12
|
+
* Write or merge a command section into svharness.config.yaml.
|
|
13
|
+
*/
|
|
14
|
+
async function saveConfigSection(options) {
|
|
15
|
+
const cwd = options.cwd ?? process.cwd();
|
|
16
|
+
const outPath = node_path_1.default.resolve(options.outPath ?? node_path_1.default.join(cwd, constants_1.DEFAULT_CONFIG_FILENAME));
|
|
17
|
+
let existing = { version: constants_1.CONFIG_SCHEMA_VERSION };
|
|
18
|
+
if (await fs_extra_1.default.pathExists(outPath)) {
|
|
19
|
+
const raw = js_yaml_1.default.load(await fs_extra_1.default.readFile(outPath, 'utf8'));
|
|
20
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
21
|
+
existing = { version: constants_1.CONFIG_SCHEMA_VERSION, ...raw };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (options.build) {
|
|
25
|
+
existing.build = { ...existing.build, ...options.build };
|
|
26
|
+
}
|
|
27
|
+
if (options.apply) {
|
|
28
|
+
existing.apply = { ...existing.apply, ...options.apply };
|
|
29
|
+
}
|
|
30
|
+
if (options.convert) {
|
|
31
|
+
existing.convert = { ...existing.convert, ...options.convert };
|
|
32
|
+
}
|
|
33
|
+
existing.version = constants_1.CONFIG_SCHEMA_VERSION;
|
|
34
|
+
const body = '# svharness 配置文件 —— 由 wizard 或 --save-config 生成\n' +
|
|
35
|
+
'# 优先级:CLI 参数 > 本文件 > 代码默认值\n' +
|
|
36
|
+
js_yaml_1.default.dump(existing, { lineWidth: 100, noRefs: true });
|
|
37
|
+
await fs_extra_1.default.ensureDir(node_path_1.default.dirname(outPath));
|
|
38
|
+
await fs_extra_1.default.writeFile(outPath, body, 'utf8');
|
|
39
|
+
return outPath;
|
|
40
|
+
}
|
|
@@ -8,13 +8,50 @@ exports.injectSkillsLayered = injectSkillsLayered;
|
|
|
8
8
|
exports.injectRulesLayered = injectRulesLayered;
|
|
9
9
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
10
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
11
12
|
const logger_1 = require("../utils/logger");
|
|
13
|
+
const BINDING_PLACEHOLDER = '{{BINDING_YAML_BLOCK}}';
|
|
12
14
|
const BUILD_SKILLS = [
|
|
15
|
+
'harness-build-skills-main.md',
|
|
13
16
|
'harness-build-skill-orchestrator.md',
|
|
14
17
|
'harness-build-skill-spec-builder.md',
|
|
18
|
+
'harness-build-skill-references-intake.md',
|
|
19
|
+
'harness-build-skill-agent-env-merge.md',
|
|
15
20
|
'harness-build-skill-knowledge-builder.md',
|
|
16
21
|
'harness-build-skill-wiki-writer.md',
|
|
17
22
|
];
|
|
23
|
+
function buildSkillsBindingYaml(input, adapter) {
|
|
24
|
+
const harness_root_rel = `./${input.harnessDirName.replace(/\\/g, '/')}/`;
|
|
25
|
+
const obj = {
|
|
26
|
+
harness_root_rel,
|
|
27
|
+
harness_name: input.harnessName,
|
|
28
|
+
arch: input.arch,
|
|
29
|
+
agent: input.agent,
|
|
30
|
+
build_rules_dir: adapter.rulesDir ?? '',
|
|
31
|
+
build_skills_dir: adapter.skillsDir,
|
|
32
|
+
generated_at: input.generatedAt,
|
|
33
|
+
generated_by: `svharness@${input.cliVersion}`,
|
|
34
|
+
};
|
|
35
|
+
return js_yaml_1.default.dump(obj, { lineWidth: 120, noRefs: true }).trimEnd();
|
|
36
|
+
}
|
|
37
|
+
function applyBuildSkillReplacements(raw, adapter, harnessPrefix, opts) {
|
|
38
|
+
let s = raw.replace(/__HARNESS_ROOT__/g, harnessPrefix);
|
|
39
|
+
s = s.replace(/__HARNESS_BUILD_RULES_DIR__/g, adapter.rulesDir ?? '');
|
|
40
|
+
s = s.replace(/__HARNESS_BUILD_SKILLS_DIR__/g, adapter.skillsDir);
|
|
41
|
+
if (opts.srcName === 'harness-build-skills-main.md') {
|
|
42
|
+
if (!opts.binding) {
|
|
43
|
+
throw new Error('harness-build-skills-main.md requires BuildSkillsBindingInput (use injectSkillsLayered with binding)');
|
|
44
|
+
}
|
|
45
|
+
if (!s.includes(BINDING_PLACEHOLDER)) {
|
|
46
|
+
throw new Error(`harness-build-skills-main.md template missing ${BINDING_PLACEHOLDER}`);
|
|
47
|
+
}
|
|
48
|
+
s = s.replace(BINDING_PLACEHOLDER, buildSkillsBindingYaml(opts.binding, adapter));
|
|
49
|
+
}
|
|
50
|
+
else if (s.includes(BINDING_PLACEHOLDER)) {
|
|
51
|
+
throw new Error(`unexpected ${BINDING_PLACEHOLDER} in ${opts.srcName}`);
|
|
52
|
+
}
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
18
55
|
/**
|
|
19
56
|
* Copy build-helper skills into the target project's agent skills directory,
|
|
20
57
|
* transforming content via the adapter when needed.
|
|
@@ -28,25 +65,35 @@ const BUILD_SKILLS = [
|
|
|
28
65
|
* @param skillsRoot Base directory for the agent skills folder.
|
|
29
66
|
* Defaults to targetRoot (legacy). Pass cwd to inject
|
|
30
67
|
* beside the harness folder instead of inside it.
|
|
68
|
+
* @param binding Metadata for `harness-build-skills-main` (inlined YAML).
|
|
31
69
|
*/
|
|
32
|
-
async function injectSkills(skillsSrcDir, targetRoot, adapter, skillsRoot) {
|
|
70
|
+
async function injectSkills(skillsSrcDir, targetRoot, adapter, skillsRoot, binding) {
|
|
33
71
|
if (!(await fs_extra_1.default.pathExists(skillsSrcDir))) {
|
|
34
72
|
throw new Error(`build-skills source directory missing: ${skillsSrcDir}`);
|
|
35
73
|
}
|
|
36
74
|
const base = skillsRoot ?? targetRoot;
|
|
37
75
|
const dstDir = node_path_1.default.join(base, adapter.skillsDir);
|
|
38
76
|
await fs_extra_1.default.ensureDir(dstDir);
|
|
77
|
+
const harnessPrefix = `./${node_path_1.default.basename(targetRoot)}/`;
|
|
39
78
|
const injected = [];
|
|
40
79
|
for (const srcName of BUILD_SKILLS) {
|
|
80
|
+
if (srcName === 'harness-build-skills-main.md' && !binding) {
|
|
81
|
+
logger_1.logger.debug('skip harness-build-skills-main.md: injectSkills called without binding context');
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
41
84
|
const srcPath = node_path_1.default.join(skillsSrcDir, srcName);
|
|
42
85
|
if (!(await fs_extra_1.default.pathExists(srcPath))) {
|
|
43
86
|
logger_1.logger.warn(`build-skill missing, skipped: ${srcName}`);
|
|
44
87
|
continue;
|
|
45
88
|
}
|
|
46
89
|
const raw = await fs_extra_1.default.readFile(srcPath, 'utf8');
|
|
90
|
+
const withPlaceholders = applyBuildSkillReplacements(raw, adapter, harnessPrefix, {
|
|
91
|
+
srcName,
|
|
92
|
+
binding,
|
|
93
|
+
});
|
|
47
94
|
const content = adapter.transform
|
|
48
|
-
? adapter.transform(
|
|
49
|
-
:
|
|
95
|
+
? adapter.transform(withPlaceholders, srcName)
|
|
96
|
+
: withPlaceholders;
|
|
50
97
|
const baseName = srcName.replace(/\.md$/, '');
|
|
51
98
|
// Each skill lives in its own subdirectory named after the skill,
|
|
52
99
|
// with the content file always named SKILL<ext> (e.g. SKILL.md).
|
|
@@ -77,10 +124,10 @@ async function injectSkills(skillsSrcDir, targetRoot, adapter, skillsRoot) {
|
|
|
77
124
|
* @param skillsRoot Base directory for the agent skills folder.
|
|
78
125
|
* Defaults to targetRoot (legacy). Pass cwd to inject
|
|
79
126
|
* beside the harness folder instead of inside it.
|
|
80
|
-
* @param
|
|
81
|
-
*
|
|
127
|
+
* @param binding Metadata for `__HARNESS_ROOT__` and inlined YAML in
|
|
128
|
+
* `harness-build-skills-main` (`harnessDirName` inside binding).
|
|
82
129
|
*/
|
|
83
|
-
async function injectSkillsLayered(skillsSrcDirs, targetRoot, adapter, skillsRoot,
|
|
130
|
+
async function injectSkillsLayered(skillsSrcDirs, targetRoot, adapter, skillsRoot, binding) {
|
|
84
131
|
const existing = [];
|
|
85
132
|
for (const d of skillsSrcDirs) {
|
|
86
133
|
if (await fs_extra_1.default.pathExists(d))
|
|
@@ -92,7 +139,6 @@ async function injectSkillsLayered(skillsSrcDirs, targetRoot, adapter, skillsRoo
|
|
|
92
139
|
const base = skillsRoot ?? targetRoot;
|
|
93
140
|
const dstDir = node_path_1.default.join(base, adapter.skillsDir);
|
|
94
141
|
await fs_extra_1.default.ensureDir(dstDir);
|
|
95
|
-
const harnessPrefix = harnessDirName ? `./${harnessDirName}/` : './';
|
|
96
142
|
const injected = [];
|
|
97
143
|
for (const srcName of BUILD_SKILLS) {
|
|
98
144
|
let picked = null;
|
|
@@ -108,8 +154,11 @@ async function injectSkillsLayered(skillsSrcDirs, targetRoot, adapter, skillsRoo
|
|
|
108
154
|
continue;
|
|
109
155
|
}
|
|
110
156
|
const raw = await fs_extra_1.default.readFile(picked, 'utf8');
|
|
111
|
-
|
|
112
|
-
const resolved = raw
|
|
157
|
+
const harnessPrefix = `./${binding.harnessDirName.replace(/\\/g, '/')}/`;
|
|
158
|
+
const resolved = applyBuildSkillReplacements(raw, adapter, harnessPrefix, {
|
|
159
|
+
srcName,
|
|
160
|
+
binding,
|
|
161
|
+
});
|
|
113
162
|
const content = adapter.transform
|
|
114
163
|
? adapter.transform(resolved, srcName)
|
|
115
164
|
: resolved;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.writeApplyProjectEntry = writeApplyProjectEntry;
|
|
7
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const logger_1 = require("../utils/logger");
|
|
10
|
+
function toPosix(p) {
|
|
11
|
+
return p.replace(/\\/g, '/');
|
|
12
|
+
}
|
|
13
|
+
function toHarnessScopedRef(raw, harnessDirName) {
|
|
14
|
+
const ref = toPosix(raw.trim());
|
|
15
|
+
if (ref.startsWith('http://') ||
|
|
16
|
+
ref.startsWith('https://') ||
|
|
17
|
+
ref.startsWith('mailto:') ||
|
|
18
|
+
ref.startsWith('#')) {
|
|
19
|
+
return raw;
|
|
20
|
+
}
|
|
21
|
+
const normalized = ref.replace(/^\.\//, '');
|
|
22
|
+
const coreMatch = /^(agent-env|baseline|specs|references|tasks)\//.test(normalized) ||
|
|
23
|
+
/^(harness\.yaml|VERSION|\.harness-build-state\.yaml|AGENTS_BUILD\.md|AGENTS_APPLY\.md)$/.test(normalized);
|
|
24
|
+
if (!coreMatch)
|
|
25
|
+
return raw;
|
|
26
|
+
return `./${harnessDirName}/${normalized}`;
|
|
27
|
+
}
|
|
28
|
+
function rewriteEntryReferences(content, harnessDirName) {
|
|
29
|
+
const withMarkdownLinks = content.replace(/\]\(([^)]+)\)/g, (_m, linkTarget) => {
|
|
30
|
+
const [pathPart, ...rest] = linkTarget.split('#');
|
|
31
|
+
const rewritten = toHarnessScopedRef(pathPart, harnessDirName);
|
|
32
|
+
if (rest.length > 0) {
|
|
33
|
+
return `](${rewritten}#${rest.join('#')})`;
|
|
34
|
+
}
|
|
35
|
+
return `](${rewritten})`;
|
|
36
|
+
});
|
|
37
|
+
return withMarkdownLinks.replace(/`([^`\n]+)`/g, (_m, token) => {
|
|
38
|
+
return `\`${toHarnessScopedRef(token, harnessDirName)}\``;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Write `AGENTS.md` or `CLAUDE.md` at the target project root by copying
|
|
43
|
+
* `<harness>/AGENTS_APPLY.md` and renaming it to the adapter's entry file.
|
|
44
|
+
*
|
|
45
|
+
* @returns relative path written, or undefined when skipped
|
|
46
|
+
*/
|
|
47
|
+
async function writeApplyProjectEntry(input) {
|
|
48
|
+
const src = node_path_1.default.join(input.harnessRoot, 'AGENTS_APPLY.md');
|
|
49
|
+
if (!(await fs_extra_1.default.pathExists(src))) {
|
|
50
|
+
throw new Error(`harness 中缺少 AGENTS_APPLY.md:${src}\n 请先重新执行 svharness build 生成应用指南。`);
|
|
51
|
+
}
|
|
52
|
+
const fileName = input.adapter.projectEntryFile;
|
|
53
|
+
const dest = node_path_1.default.join(input.projectRoot, fileName);
|
|
54
|
+
const rel = fileName;
|
|
55
|
+
if (await fs_extra_1.default.pathExists(dest)) {
|
|
56
|
+
if (!input.force) {
|
|
57
|
+
logger_1.logger.warn(`项目根已存在 ${rel},跳过写入(如需覆盖请使用 svharness apply --force)`);
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const raw = await fs_extra_1.default.readFile(src, 'utf8');
|
|
62
|
+
const rewritten = rewriteEntryReferences(raw, input.harnessDirName);
|
|
63
|
+
await fs_extra_1.default.outputFile(dest, rewritten, 'utf8');
|
|
64
|
+
logger_1.logger.success(`已写入项目根 AI 入口:${rel}(由 AGENTS_APPLY.md 重命名)`);
|
|
65
|
+
return rel;
|
|
66
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.renderBuildProjectEntryMarkdown = renderBuildProjectEntryMarkdown;
|
|
7
|
+
exports.writeBuildProjectEntry = writeBuildProjectEntry;
|
|
8
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const logger_1 = require("../utils/logger");
|
|
11
|
+
function entryHeading(projectEntryFile) {
|
|
12
|
+
if (projectEntryFile === 'CLAUDE.md') {
|
|
13
|
+
return '# CLAUDE.md';
|
|
14
|
+
}
|
|
15
|
+
return '# AGENTS.md';
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Render the markdown body for the project-root AI entry file
|
|
19
|
+
* (`AGENTS.md` or `CLAUDE.md`) created by `svharness build`.
|
|
20
|
+
*/
|
|
21
|
+
function renderBuildProjectEntryMarkdown(input) {
|
|
22
|
+
const relHarness = `./${input.harnessDirName.replace(/\\/g, '/')}/`;
|
|
23
|
+
const relAgentsBuild = `${relHarness}AGENTS_BUILD.md`.replace(/\/+/g, '/');
|
|
24
|
+
const rulesLine = input.adapter.rulesDir
|
|
25
|
+
? `- **构建规则(强约束)**:\`${input.adapter.rulesDir}/\`(harness-build-rule-*)`
|
|
26
|
+
: '- **构建规则**:本 Agent 未配置 rules 注入目录(跳过)';
|
|
27
|
+
const title = entryHeading(input.adapter.projectEntryFile);
|
|
28
|
+
return [
|
|
29
|
+
title,
|
|
30
|
+
'',
|
|
31
|
+
'> 本文件由 `svharness build` 根据所选 Agent 自动生成,用于指引 AI **载入整个 harness-build 工作流**。',
|
|
32
|
+
'> 若内容与你的习惯冲突,可改 harness 内契约后重新执行 `svharness build --force` 覆盖本文件。',
|
|
33
|
+
'',
|
|
34
|
+
'## 1. 必读(harness 内)',
|
|
35
|
+
'',
|
|
36
|
+
`1. **Harness 根目录(相对项目根)**:\`${relHarness}\``,
|
|
37
|
+
`2. **协作协议**:[\`AGENTS_BUILD.md\`](${relAgentsBuild})`,
|
|
38
|
+
`3. **状态机**:\`${relHarness}.harness-build-state.yaml\``,
|
|
39
|
+
`4. **清单**:\`${relHarness}harness.yaml\``,
|
|
40
|
+
'',
|
|
41
|
+
'## 2. harness-build 总清单 skill(二级调度,优先入口)',
|
|
42
|
+
'',
|
|
43
|
+
'在支持 skill 的 Agent IDE 中,**优先**加载并触发:',
|
|
44
|
+
'',
|
|
45
|
+
`- **\`harness-build-skills-main\`** — 解析 skill 内「绑定元数据」→ 装载构建规则 → 路由到 orchestrator / spec-builder / references-intake / agent-env-merge / knowledge-builder / wiki-writer。`,
|
|
46
|
+
`- 磁盘路径(相对项目根):\`${input.adapter.skillsDir}/harness-build-skills-main/\`(主文件名为 \`SKILL${input.adapter.skillExt}\`)`,
|
|
47
|
+
'',
|
|
48
|
+
'### 子 skill 速查',
|
|
49
|
+
'',
|
|
50
|
+
'| 子 skill | 用途 |',
|
|
51
|
+
'|----------|------|',
|
|
52
|
+
'| `harness-build-skill-orchestrator` | 阶段调度、更新 `.harness-build-state.yaml` |',
|
|
53
|
+
'| `harness-build-skill-spec-builder` | raw → requirements → specs |',
|
|
54
|
+
'| `harness-build-skill-references-intake` | S60 references 转换与结构化索引 |',
|
|
55
|
+
'| `harness-build-skill-agent-env-merge` | S61 baseline 自动提取确认 + S65 合并写入 |',
|
|
56
|
+
'| `harness-build-skill-knowledge-builder` | S10 baseline 样本 + S70 skills/tasks 索引 |',
|
|
57
|
+
'| `harness-build-skill-wiki-writer` | 按 `TASKS.md` 撰写 baseline wiki |',
|
|
58
|
+
'',
|
|
59
|
+
'## 3. 构建规则目录(与 harness 分离,位于项目根)',
|
|
60
|
+
'',
|
|
61
|
+
rulesLine,
|
|
62
|
+
'',
|
|
63
|
+
'## 4. 元数据(供人类核对)',
|
|
64
|
+
'',
|
|
65
|
+
`- harness 名称:\`${input.harnessName}\``,
|
|
66
|
+
`- 目录名:\`${input.harnessDirName}\``,
|
|
67
|
+
`- 架构:\`${input.arch}\``,
|
|
68
|
+
`- Agent:\`${input.agent}\``,
|
|
69
|
+
'',
|
|
70
|
+
].join('\n');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Write `AGENTS.md` or `CLAUDE.md` at the project root when absent,
|
|
74
|
+
* or when `force` is true.
|
|
75
|
+
*
|
|
76
|
+
* @returns relative path written, or undefined when skipped
|
|
77
|
+
*/
|
|
78
|
+
async function writeBuildProjectEntry(input) {
|
|
79
|
+
const fileName = input.adapter.projectEntryFile;
|
|
80
|
+
const dest = node_path_1.default.join(input.projectRoot, fileName);
|
|
81
|
+
const rel = fileName;
|
|
82
|
+
if (await fs_extra_1.default.pathExists(dest)) {
|
|
83
|
+
if (!input.force) {
|
|
84
|
+
logger_1.logger.warn(`项目根已存在 ${rel},跳过写入(如需覆盖请对 harness 使用 --force 并确保允许覆盖根入口文件)`);
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const body = renderBuildProjectEntryMarkdown({
|
|
89
|
+
adapter: input.adapter,
|
|
90
|
+
harnessDirName: input.harnessDirName,
|
|
91
|
+
harnessName: input.harnessName,
|
|
92
|
+
arch: input.arch,
|
|
93
|
+
agent: input.agent,
|
|
94
|
+
});
|
|
95
|
+
await fs_extra_1.default.outputFile(dest, body, 'utf8');
|
|
96
|
+
logger_1.logger.success(`已写入项目根 AI 入口:${rel}`);
|
|
97
|
+
return rel;
|
|
98
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.RAW_SKIP_DIR_NAMES = void 0;
|
|
7
|
+
exports.shouldSkipRawSubdir = shouldSkipRawSubdir;
|
|
8
|
+
exports.collectSeedSourceFiles = collectSeedSourceFiles;
|
|
9
|
+
exports.copySeedInputToRaw = copySeedInputToRaw;
|
|
10
|
+
exports.relocateMisplacedConvertedMd = relocateMisplacedConvertedMd;
|
|
11
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
/** Subdirs under `<type>/raw/` that must never be scanned or copied as source trees. */
|
|
14
|
+
exports.RAW_SKIP_DIR_NAMES = new Set([
|
|
15
|
+
'converted_md',
|
|
16
|
+
'md',
|
|
17
|
+
'yaml',
|
|
18
|
+
'node_modules',
|
|
19
|
+
'.git',
|
|
20
|
+
'dist',
|
|
21
|
+
]);
|
|
22
|
+
function shouldSkipRawSubdir(name) {
|
|
23
|
+
return exports.RAW_SKIP_DIR_NAMES.has(name);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Recursively list source files for seed intake, skipping harness artifact dirs.
|
|
27
|
+
*/
|
|
28
|
+
async function collectSeedSourceFiles(rootDir) {
|
|
29
|
+
const out = [];
|
|
30
|
+
async function walk(dir) {
|
|
31
|
+
const entries = await fs_extra_1.default.readdir(dir, { withFileTypes: true });
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (entry.name === '.gitkeep')
|
|
34
|
+
continue;
|
|
35
|
+
const abs = node_path_1.default.join(dir, entry.name);
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
if (shouldSkipRawSubdir(entry.name))
|
|
38
|
+
continue;
|
|
39
|
+
await walk(abs);
|
|
40
|
+
}
|
|
41
|
+
else if (entry.isFile()) {
|
|
42
|
+
out.push(abs);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
await walk(rootDir);
|
|
47
|
+
return out.sort();
|
|
48
|
+
}
|
|
49
|
+
async function allocateFlatName(targetDir, baseName, used) {
|
|
50
|
+
if (!used.has(baseName) && !(await fs_extra_1.default.pathExists(node_path_1.default.join(targetDir, baseName)))) {
|
|
51
|
+
return baseName;
|
|
52
|
+
}
|
|
53
|
+
const ext = node_path_1.default.extname(baseName);
|
|
54
|
+
const stem = ext ? baseName.slice(0, -ext.length) : baseName;
|
|
55
|
+
for (let i = 1; i < 1000; i++) {
|
|
56
|
+
const next = ext ? `${stem}-${i}${ext}` : `${stem}-${i}`;
|
|
57
|
+
if (!used.has(next) && !(await fs_extra_1.default.pathExists(node_path_1.default.join(targetDir, next)))) {
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`无法为 raw 目录分配唯一文件名: ${baseName}`);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Copy a file or directory of source documents into `<harness>/<type>/raw/` as a flat list.
|
|
65
|
+
* Nested legacy folders such as `assets/raw/converted_md/` are not preserved.
|
|
66
|
+
*/
|
|
67
|
+
async function copySeedInputToRaw(inputPath, rawDir, label) {
|
|
68
|
+
const absInput = node_path_1.default.resolve(inputPath);
|
|
69
|
+
if (!(await fs_extra_1.default.pathExists(absInput))) {
|
|
70
|
+
throw new Error(`${label} 输入路径不存在: ${absInput}`);
|
|
71
|
+
}
|
|
72
|
+
await fs_extra_1.default.ensureDir(rawDir);
|
|
73
|
+
const stat = await fs_extra_1.default.stat(absInput);
|
|
74
|
+
if (stat.isFile()) {
|
|
75
|
+
const dest = node_path_1.default.join(rawDir, node_path_1.default.basename(absInput));
|
|
76
|
+
await fs_extra_1.default.copy(absInput, dest, { overwrite: true, errorOnExist: false });
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
if (!stat.isDirectory()) {
|
|
80
|
+
throw new Error(`${label} 输入路径必须是文件或目录: ${absInput}`);
|
|
81
|
+
}
|
|
82
|
+
const files = await collectSeedSourceFiles(absInput);
|
|
83
|
+
const used = new Set();
|
|
84
|
+
let copied = 0;
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
const destName = await allocateFlatName(rawDir, node_path_1.default.basename(file), used);
|
|
87
|
+
used.add(destName);
|
|
88
|
+
await fs_extra_1.default.copy(file, node_path_1.default.join(rawDir, destName), {
|
|
89
|
+
overwrite: true,
|
|
90
|
+
errorOnExist: false,
|
|
91
|
+
});
|
|
92
|
+
copied += 1;
|
|
93
|
+
}
|
|
94
|
+
return copied;
|
|
95
|
+
}
|
|
96
|
+
/** Move misplaced converted_md markdown files under raw into harness type/md. */
|
|
97
|
+
async function relocateMisplacedConvertedMd(harnessRoot, type) {
|
|
98
|
+
const rawDir = node_path_1.default.join(harnessRoot, type, 'raw');
|
|
99
|
+
const mdDir = node_path_1.default.join(harnessRoot, type, 'md');
|
|
100
|
+
if (!(await fs_extra_1.default.pathExists(rawDir)))
|
|
101
|
+
return 0;
|
|
102
|
+
await fs_extra_1.default.ensureDir(mdDir);
|
|
103
|
+
const misplaced = await findMisplacedConvertedMd(rawDir);
|
|
104
|
+
if (misplaced.length === 0)
|
|
105
|
+
return 0;
|
|
106
|
+
const used = new Set();
|
|
107
|
+
for (const abs of misplaced) {
|
|
108
|
+
const base = node_path_1.default.basename(abs);
|
|
109
|
+
const destName = await allocateFlatName(mdDir, base, used);
|
|
110
|
+
used.add(destName);
|
|
111
|
+
await fs_extra_1.default.move(abs, node_path_1.default.join(mdDir, destName), { overwrite: true });
|
|
112
|
+
}
|
|
113
|
+
await removeEmptyConvertedMdDirs(rawDir);
|
|
114
|
+
return misplaced.length;
|
|
115
|
+
}
|
|
116
|
+
async function findMisplacedConvertedMd(rawDir) {
|
|
117
|
+
const out = [];
|
|
118
|
+
async function walk(dir) {
|
|
119
|
+
const entries = await fs_extra_1.default.readdir(dir, { withFileTypes: true });
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const abs = node_path_1.default.join(dir, entry.name);
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
if (entry.name === 'converted_md') {
|
|
124
|
+
for (const f of await fs_extra_1.default.readdir(abs)) {
|
|
125
|
+
if (f.toLowerCase().endsWith('.md')) {
|
|
126
|
+
out.push(node_path_1.default.join(abs, f));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (shouldSkipRawSubdir(entry.name))
|
|
132
|
+
continue;
|
|
133
|
+
await walk(abs);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
await walk(rawDir);
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
async function removeEmptyConvertedMdDirs(rawDir) {
|
|
141
|
+
async function walk(dir) {
|
|
142
|
+
const entries = await fs_extra_1.default.readdir(dir, { withFileTypes: true });
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (!entry.isDirectory())
|
|
145
|
+
continue;
|
|
146
|
+
const abs = node_path_1.default.join(dir, entry.name);
|
|
147
|
+
if (entry.name === 'converted_md') {
|
|
148
|
+
await fs_extra_1.default.remove(abs);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
await walk(abs);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
await walk(rawDir);
|
|
155
|
+
}
|