@tenphi/harnesspm 0.0.0-snapshot.7984e7b

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -0
  4. package/dist/added.js +189 -0
  5. package/dist/check.js +160 -0
  6. package/dist/cli.js +139 -0
  7. package/dist/commands.js +414 -0
  8. package/dist/config.js +43 -0
  9. package/dist/exec-outputs.js +360 -0
  10. package/dist/exec-units.js +144 -0
  11. package/dist/fetch.js +93 -0
  12. package/dist/frontmatter.js +35 -0
  13. package/dist/homes.js +17 -0
  14. package/dist/lock.js +102 -0
  15. package/dist/manifest.js +154 -0
  16. package/dist/new.js +223 -0
  17. package/dist/outputs.js +453 -0
  18. package/dist/render.js +119 -0
  19. package/dist/resolve.js +396 -0
  20. package/dist/run.js +83 -0
  21. package/dist/sections.js +121 -0
  22. package/dist/user-outputs.js +195 -0
  23. package/dist/util.js +140 -0
  24. package/dist/workspaces.js +61 -0
  25. package/docs/spec/01-overview.md +130 -0
  26. package/docs/spec/02-package-format.md +238 -0
  27. package/docs/spec/03-resolution.md +104 -0
  28. package/docs/spec/04-outputs.md +109 -0
  29. package/docs/spec/05-executables.md +158 -0
  30. package/docs/spec/06-trust.md +72 -0
  31. package/docs/spec/07-added.md +104 -0
  32. package/docs/spec/08-improving.md +77 -0
  33. package/docs/spec/09-cloud.md +47 -0
  34. package/docs/spec/10-cli.md +60 -0
  35. package/docs/spec/11-standards.md +49 -0
  36. package/docs/spec/12-dogfooding.md +51 -0
  37. package/docs/spec/13-open-questions.md +85 -0
  38. package/docs/spec/README.md +33 -0
  39. package/harness/dev.harnesspm/rules/generated-files.md +4 -0
  40. package/harness/dev.harnesspm/rules/harnesspm-config.md +11 -0
  41. package/harness/plugin.json +13 -0
  42. package/harness/skills/harnesspm-export/SKILL.md +39 -0
  43. package/harness/skills/harnesspm-import/SKILL.md +35 -0
  44. package/harness/skills/harnesspm-improve/SKILL.md +38 -0
  45. package/package.json +60 -0
  46. package/schema/harnesspm.schema.json +31 -0
package/dist/lock.js ADDED
@@ -0,0 +1,102 @@
1
+ // harnesspm.lock.json (spec 6.3). The lock holds no per-machine state.
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { hash, isFile, isRecord, readJsonIfExists, readText, relPosix } from './util.js';
5
+ export const LOCK_FILE = 'harnesspm.lock.json';
6
+ function hashDir(dir) {
7
+ const parts = [];
8
+ const walk = (current) => {
9
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
10
+ const full = path.join(current, entry.name);
11
+ if (entry.isDirectory())
12
+ walk(full);
13
+ else if (entry.isFile())
14
+ parts.push(`${relPosix(dir, full)}\n${readText(full)}`);
15
+ }
16
+ };
17
+ walk(dir);
18
+ return hash(parts.join('\n\0\n'));
19
+ }
20
+ function packageLockEntry(projectDir, name) {
21
+ const lock = readJsonIfExists(path.join(projectDir, 'package-lock.json'));
22
+ if (!isRecord(lock) || !isRecord(lock['packages']))
23
+ return {};
24
+ const entry = lock['packages'][`node_modules/${name}`];
25
+ if (!isRecord(entry))
26
+ return {};
27
+ return {
28
+ ...(typeof entry['resolved'] === 'string' ? { resolved: entry['resolved'] } : {}),
29
+ ...(typeof entry['integrity'] === 'string' ? { integrity: entry['integrity'] } : {}),
30
+ };
31
+ }
32
+ export function buildLock(res, previous) {
33
+ const packages = {};
34
+ for (const pkg of res.packages) {
35
+ if (pkg.layer === 'project') {
36
+ packages[pkg.name] = { version: pkg.version, plugin: pkg.plugin, project: true };
37
+ continue;
38
+ }
39
+ const entry = { version: pkg.version, plugin: pkg.plugin, ...packageLockEntry(res.projectDir, pkg.name) };
40
+ if (res.config.trust[pkg.name] === 'pin') {
41
+ const approved = previous?.packages[pkg.name]?.approved;
42
+ entry.approved = approved ?? pkg.version;
43
+ }
44
+ packages[pkg.name] = entry;
45
+ }
46
+ const units = {};
47
+ for (const rule of res.rules)
48
+ units[rule.id] = { kind: 'rules', hash: hash(rule.text) };
49
+ for (const skill of res.skills)
50
+ units[skill.id] = { kind: 'skills', hash: hashDir(skill.dir) };
51
+ for (const docs of res.docs)
52
+ units[docs.id] = { kind: 'docs', hash: hashDir(docs.dir) };
53
+ for (const script of res.scripts)
54
+ units[script.id] = { kind: 'scripts', hash: isFile(script.file) ? hash(readText(script.file)) : 'missing' };
55
+ for (const hook of res.hooks)
56
+ units[hook.id] = { kind: 'hooks', hash: hash(JSON.stringify([hook.event, hook.matchers, hook.run, hook.timeout ?? null])) };
57
+ for (const server of res.mcp)
58
+ units[server.id] = { kind: 'mcp', hash: hash(JSON.stringify(server.config)) };
59
+ for (const perms of res.permissions)
60
+ units[perms.id] = { kind: 'permissions', hash: hash(JSON.stringify([perms.deny, perms.ask])) };
61
+ for (const agent of res.agents)
62
+ units[agent.id] = { kind: 'agents', hash: hash(readText(agent.file)) };
63
+ const patches = {};
64
+ for (const rule of res.rules) {
65
+ for (const applied of rule.patches) {
66
+ if (applied.sections.length === 0)
67
+ continue;
68
+ const byPatch = (patches[rule.id] ??= {});
69
+ byPatch[applied.patch] = Object.fromEntries(applied.sections.map((s) => [s.heading, s.hash]));
70
+ }
71
+ }
72
+ return { lockfileVersion: 1, packages: sortKeys(packages), units: sortKeys(units), patches: sortKeys(patches) };
73
+ }
74
+ function sortKeys(record) {
75
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
76
+ }
77
+ export function serializeLock(lock) {
78
+ return `${JSON.stringify(lock, null, 2)}\n`;
79
+ }
80
+ export function readLock(projectDir) {
81
+ const raw = readJsonIfExists(path.join(projectDir, LOCK_FILE));
82
+ if (!isRecord(raw) || raw['lockfileVersion'] !== 1)
83
+ return undefined;
84
+ return raw;
85
+ }
86
+ /** Warnings for upstream changes to sections that a patch replaces or removes (spec 3.4). */
87
+ export function patchDrift(previous, next) {
88
+ if (!previous)
89
+ return [];
90
+ const warnings = [];
91
+ for (const [ruleId, byPatch] of Object.entries(next.patches)) {
92
+ for (const [patch, sections] of Object.entries(byPatch)) {
93
+ for (const [heading, sectionHash] of Object.entries(sections)) {
94
+ const before = previous.patches[ruleId]?.[patch]?.[heading];
95
+ if (before !== undefined && before !== sectionHash) {
96
+ warnings.push(`${ruleId}: upstream changed the section "${heading}" that ${patch} changes. Check that the patch is still right.`);
97
+ }
98
+ }
99
+ }
100
+ }
101
+ return warnings;
102
+ }
@@ -0,0 +1,154 @@
1
+ // Reading one harness package (spec chapter 2).
2
+ import * as path from 'node:path';
3
+ import { parseFrontmatter, stringList } from './frontmatter.js';
4
+ import { HarnessError, isDir, isFile, isRecord, listDir, readJson, readJsonIfExists, readText } from './util.js';
5
+ export const NS = 'dev.harnesspm';
6
+ export const PLUGIN_SCHEMA = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json';
7
+ const CORE_FIELDS = new Set(['$schema', 'name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords', 'extensions']);
8
+ const EXT_FIELDS = new Set(['extends', 'exclude', 'replace', 'patch', 'docs', 'scripts', 'projects']);
9
+ const PLUGIN_NAME = /^[a-z0-9][a-z0-9.-]*$/;
10
+ function stringMap(value, field, problems) {
11
+ if (value === undefined)
12
+ return {};
13
+ if (isRecord(value) && Object.values(value).every((v) => typeof v === 'string'))
14
+ return value;
15
+ problems.push(`"${field}" must map strings to strings`);
16
+ return {};
17
+ }
18
+ function repositoryUrl(value) {
19
+ if (typeof value === 'string')
20
+ return value;
21
+ if (isRecord(value) && typeof value['url'] === 'string')
22
+ return value['url'];
23
+ return undefined;
24
+ }
25
+ /** Reads `<dir>/harness/plugin.json`. Returns undefined when the folder has no harness. */
26
+ export function loadHarnessPackage(dir, layer) {
27
+ const harnessDir = path.join(dir, 'harness');
28
+ const manifestFile = path.join(harnessDir, 'plugin.json');
29
+ if (!isFile(manifestFile))
30
+ return undefined;
31
+ const manifest = readJson(manifestFile);
32
+ if (!isRecord(manifest))
33
+ throw new HarnessError(`${manifestFile} must be a JSON object`);
34
+ const pkgJson = readJsonIfExists(path.join(dir, 'package.json'));
35
+ const pkg = isRecord(pkgJson) ? pkgJson : {};
36
+ const problems = [];
37
+ if (manifest['$schema'] !== PLUGIN_SCHEMA)
38
+ problems.push(`"$schema" must be "${PLUGIN_SCHEMA}"`);
39
+ for (const key of Object.keys(manifest))
40
+ if (!CORE_FIELDS.has(key))
41
+ problems.push(`unknown field "${key}" in plugin.json`);
42
+ const plugin = manifest['name'];
43
+ if (typeof plugin !== 'string' || !PLUGIN_NAME.test(plugin)) {
44
+ throw new HarnessError(`${manifestFile}: "name" must be lowercase letters, digits, "-" and "."`);
45
+ }
46
+ const ext = parseExtension(manifest, problems);
47
+ const version = typeof manifest['version'] === 'string' ? manifest['version'] : typeof pkg['version'] === 'string' ? pkg['version'] : '0.0.0';
48
+ const description = typeof manifest['description'] === 'string' ? manifest['description'] : undefined;
49
+ const repository = repositoryUrl(manifest['repository']) ?? repositoryUrl(pkg['repository']);
50
+ return {
51
+ name: typeof pkg['name'] === 'string' ? pkg['name'] : path.basename(dir),
52
+ version,
53
+ plugin,
54
+ ...(description !== undefined ? { description } : {}),
55
+ ...(repository !== undefined ? { repository } : {}),
56
+ dir,
57
+ harnessDir,
58
+ ext,
59
+ layer,
60
+ problems,
61
+ };
62
+ }
63
+ /** Reads `extensions["dev.harnesspm"]` of a manifest. Problems go into `problems`. */
64
+ export function parseExtension(manifest, problems) {
65
+ const extensions = manifest['extensions'];
66
+ const rawExt = isRecord(extensions) && isRecord(extensions[NS]) ? extensions[NS] : {};
67
+ for (const key of Object.keys(rawExt))
68
+ if (!EXT_FIELDS.has(key))
69
+ problems.push(`unknown field "${key}" in extensions["${NS}"]`);
70
+ const list = (field) => {
71
+ const value = rawExt[field];
72
+ if (value === undefined)
73
+ return [];
74
+ const result = stringList(value);
75
+ if (!result || typeof value === 'string') {
76
+ problems.push(`"${field}" must be an array of strings`);
77
+ return [];
78
+ }
79
+ return result;
80
+ };
81
+ const scripts = {};
82
+ if (rawExt['scripts'] !== undefined) {
83
+ if (!isRecord(rawExt['scripts']))
84
+ problems.push('"scripts" must be an object');
85
+ else {
86
+ for (const [name, def] of Object.entries(rawExt['scripts'])) {
87
+ if (!isRecord(def) || typeof def['file'] !== 'string') {
88
+ problems.push(`script "${name}" needs a "file"`);
89
+ continue;
90
+ }
91
+ scripts[name] = {
92
+ file: def['file'],
93
+ ...(typeof def['description'] === 'string' ? { description: def['description'] } : {}),
94
+ ...(def['allow'] === true ? { allow: true } : {}),
95
+ };
96
+ }
97
+ }
98
+ }
99
+ let docs;
100
+ if (rawExt['docs'] !== undefined) {
101
+ if (typeof rawExt['docs'] === 'string')
102
+ docs = rawExt['docs'];
103
+ else
104
+ problems.push('"docs" must be a path');
105
+ }
106
+ return {
107
+ extends: list('extends'),
108
+ exclude: list('exclude'),
109
+ replace: stringMap(rawExt['replace'], 'replace', problems),
110
+ patch: stringMap(rawExt['patch'], 'patch', problems),
111
+ ...(docs !== undefined ? { docs } : {}),
112
+ scripts,
113
+ projects: stringMap(rawExt['projects'], 'projects', problems),
114
+ };
115
+ }
116
+ /** Finds a package's units. Files in `skip` (replacements and patches) are not units. */
117
+ export function discoverUnits(pkg, skip) {
118
+ const rulesDir = path.join(pkg.harnessDir, NS, 'rules');
119
+ const rules = listDir(rulesDir)
120
+ .filter((f) => f.endsWith('.md') && !skip.has(path.join(rulesDir, f)))
121
+ .map((f) => {
122
+ const name = f.slice(0, -3);
123
+ return { kind: 'rules', id: `${pkg.plugin}:rules/${name}`, name, pkg, file: path.join(rulesDir, f) };
124
+ });
125
+ const skillsDir = path.join(pkg.harnessDir, 'skills');
126
+ const skills = listDir(skillsDir)
127
+ .filter((f) => isFile(path.join(skillsDir, f, 'SKILL.md')))
128
+ .map((name) => ({ kind: 'skills', id: `${pkg.plugin}:skills/${name}`, name, pkg, dir: path.join(skillsDir, name) }));
129
+ const units = { rules, skills };
130
+ if (pkg.ext.docs !== undefined) {
131
+ const dir = path.resolve(pkg.harnessDir, pkg.ext.docs);
132
+ if (isDir(dir))
133
+ units.docs = { kind: 'docs', id: `${pkg.plugin}:docs`, pkg, dir };
134
+ }
135
+ return units;
136
+ }
137
+ /** Reads a rule's activation from its frontmatter (spec 2.5). */
138
+ export function ruleMeta(text, fallbackTitle, file) {
139
+ const { data, body } = parseFrontmatter(text, file);
140
+ const description = typeof data['description'] === 'string' ? data['description'] : undefined;
141
+ const paths = stringList(data['paths']);
142
+ const activation = paths ? 'file' : description !== undefined ? 'demand' : 'always';
143
+ const titleMatch = /^#\s+(.+?)\s*$/m.exec(body);
144
+ return {
145
+ activation,
146
+ ...(description !== undefined ? { description } : {}),
147
+ ...(paths ? { paths } : {}),
148
+ title: titleMatch ? titleMatch[1] : fallbackTitle,
149
+ body,
150
+ };
151
+ }
152
+ export function readRule(file) {
153
+ return readText(file);
154
+ }
package/dist/new.js ADDED
@@ -0,0 +1,223 @@
1
+ // harnesspm new: units from templates, and patches (spec 10.2).
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { loadAdded, remoteKey } from './added.js';
5
+ import { CONFIG_FILE } from './config.js';
6
+ import { NS, PLUGIN_SCHEMA } from './manifest.js';
7
+ import { headings } from './sections.js';
8
+ import { parseFrontmatter } from './frontmatter.js';
9
+ import { HarnessError, ensureDir, exists, isFile, isRecord, readJson, readJsonIfExists, relPosix } from './util.js';
10
+ export const KINDS = ['rule', 'skill', 'agent', 'script', 'hook', 'mcp'];
11
+ const MCP_TEMPLATE = `// A minimal MCP server over stdio. Replace the tool with your own.
12
+ const send = (message) => process.stdout.write(JSON.stringify(message) + '\\n');
13
+ let buffer = '';
14
+ process.stdin.setEncoding('utf8');
15
+ process.stdin.on('data', (chunk) => {
16
+ buffer += chunk;
17
+ let index;
18
+ while ((index = buffer.indexOf('\\n')) >= 0) {
19
+ const line = buffer.slice(0, index).trim();
20
+ buffer = buffer.slice(index + 1);
21
+ if (!line) continue;
22
+ const message = JSON.parse(line);
23
+ if (message.method === 'initialize') {
24
+ send({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: message.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'NAME', version: '0.1.0' } } });
25
+ } else if (message.method === 'tools/list') {
26
+ send({ jsonrpc: '2.0', id: message.id, result: { tools: [{ name: 'hello', description: 'Says hello', inputSchema: { type: 'object', properties: {} } }] } });
27
+ } else if (message.method === 'tools/call') {
28
+ send({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: 'Hello from NAME' }] } });
29
+ } else if (message.id !== undefined) {
30
+ send({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' } });
31
+ }
32
+ }
33
+ });
34
+ `;
35
+ const title = (name) => name.replace(/[-_]+/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
36
+ function writeNew(file, content) {
37
+ if (exists(file))
38
+ throw new HarnessError(`${file} already exists`);
39
+ ensureDir(path.dirname(file));
40
+ fs.writeFileSync(file, content);
41
+ }
42
+ function editManifest(file, edit, plugin) {
43
+ const raw = readJsonIfExists(file);
44
+ const manifest = isRecord(raw) ? raw : { $schema: PLUGIN_SCHEMA, name: plugin };
45
+ const extensions = isRecord(manifest['extensions']) ? manifest['extensions'] : {};
46
+ const ext = isRecord(extensions[NS]) ? extensions[NS] : {};
47
+ edit(ext);
48
+ extensions[NS] = ext;
49
+ manifest['extensions'] = extensions;
50
+ ensureDir(path.dirname(file));
51
+ fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
52
+ }
53
+ function pluginNameFor(projectDir) {
54
+ const pkg = readJsonIfExists(path.join(projectDir, 'package.json'));
55
+ const name = isRecord(pkg) && typeof pkg['name'] === 'string' ? pkg['name'] : path.basename(projectDir);
56
+ return name.replace(/^@[^/]+\//, '').toLowerCase().replace(/[^a-z0-9.-]+/g, '-').replace(/^[^a-z0-9]+/, '') || 'project';
57
+ }
58
+ /** Where `new` writes: the project's harness/, an added harness (`--to`), or its entry (`--entry`). */
59
+ export function resolveTarget(projectDir, options) {
60
+ if (options.to === undefined) {
61
+ if (options.entry)
62
+ throw new HarnessError('--entry needs --to <plugin>');
63
+ const dir = path.join(projectDir, 'harness');
64
+ const manifest = path.join(dir, 'plugin.json');
65
+ let plugin = pluginNameFor(projectDir);
66
+ if (isFile(manifest)) {
67
+ const raw = readJson(manifest);
68
+ if (isRecord(raw) && typeof raw['name'] === 'string')
69
+ plugin = raw['name'];
70
+ }
71
+ else {
72
+ writeNew(manifest, `${JSON.stringify({ $schema: PLUGIN_SCHEMA, name: plugin }, null, 2)}\n`);
73
+ }
74
+ return { dir, plugin, manifest, entry: false, label: `the project harness (${relPosix(projectDir, dir)})` };
75
+ }
76
+ const warnings = [];
77
+ const harness = loadAdded(projectDir, warnings).find((h) => h.pkg.plugin === options.to);
78
+ if (!harness)
79
+ throw new HarnessError(`No added harness with the plugin name "${options.to}". See \`npx harnesspm status\`.`);
80
+ if (!harness.source.editable)
81
+ throw new HarnessError(`${harness.source.spec} comes from npm and cannot be edited here. Change it in its own repository.`);
82
+ const manifest = path.join(harness.pkg.harnessDir, 'plugin.json');
83
+ if (!options.entry)
84
+ return { dir: harness.pkg.harnessDir, plugin: harness.pkg.plugin, manifest, entry: false, label: `${harness.pkg.plugin} (${harness.source.dir})` };
85
+ const key = remoteKey(projectDir);
86
+ if (key === undefined)
87
+ throw new HarnessError('--entry needs a repository with an "origin" remote');
88
+ let rel = harness.pkg.ext.projects[key];
89
+ if (rel === undefined) {
90
+ rel = `./projects/${key.split('/').pop()}`;
91
+ editManifest(manifest, (ext) => {
92
+ const projects = isRecord(ext['projects']) ? ext['projects'] : {};
93
+ projects[key] = rel;
94
+ ext['projects'] = projects;
95
+ }, harness.pkg.plugin);
96
+ }
97
+ const dir = path.resolve(harness.pkg.harnessDir, rel);
98
+ ensureDir(dir);
99
+ return { dir, plugin: harness.pkg.plugin, manifest: path.join(dir, 'plugin.json'), entry: true, label: `the entry of ${harness.pkg.plugin} for ${key} (${dir})` };
100
+ }
101
+ /** Creates a unit. Returns the files it wrote. */
102
+ export function createUnit(target, kind, name) {
103
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name))
104
+ throw new HarnessError('Use lowercase letters, digits and "-" in names');
105
+ if (target.entry && (kind === 'script' || kind === 'hook' || kind === 'mcp')) {
106
+ throw new HarnessError(`A project entry holds rules, skills, sub-agents and docs. Add the ${kind} to the harness itself (without --entry).`);
107
+ }
108
+ const files = [];
109
+ const add = (file, content) => {
110
+ writeNew(file, content);
111
+ files.push(file);
112
+ };
113
+ const scriptFile = (script) => path.join(target.dir, NS, 'scripts', `${script}.mjs`);
114
+ const declareScript = (script, description) => {
115
+ editManifest(target.manifest, (ext) => {
116
+ const scripts = isRecord(ext['scripts']) ? ext['scripts'] : {};
117
+ if (scripts[script] !== undefined)
118
+ throw new HarnessError(`The script "${script}" already exists`);
119
+ scripts[script] = { file: `./${NS}/scripts/${script}.mjs`, ...(description !== undefined ? { description } : {}) };
120
+ ext['scripts'] = scripts;
121
+ }, target.plugin);
122
+ files.push(target.manifest);
123
+ };
124
+ switch (kind) {
125
+ case 'rule':
126
+ add(path.join(target.dir, NS, 'rules', `${name}.md`), `# ${title(name)}\n\n- Write the rule here. Keep an always-on rule short: it is in every session.\n`);
127
+ break;
128
+ case 'skill': {
129
+ const skill = name.startsWith(`${target.plugin}-`) ? name : `${target.plugin}-${name}`;
130
+ add(path.join(target.dir, 'skills', skill, 'SKILL.md'), `---\nname: ${skill}\ndescription: What the skill does. Use when the user asks to … or mentions ….\n---\n\n# ${title(name)}\n\n1. First step.\n2. Next step.\n`);
131
+ break;
132
+ }
133
+ case 'agent':
134
+ add(path.join(target.dir, NS, 'agents', `${name}.md`), `---\nname: ${name}\ndescription: What the sub-agent does. Use when ….\n---\n\nInstructions for the sub-agent.\n`);
135
+ break;
136
+ case 'script':
137
+ add(scriptFile(name), `// Run it with \`npx harnesspm run ${target.plugin}:${name} -- <args>\`.\n// PLUGIN_ROOT, PLUGIN_DATA, HARNESSPM_PROJECT_DIR and HARNESSPM_AGENT are set by the runner.\nconsole.log('${name}', process.argv.slice(2).join(' '));\n`);
138
+ declareScript(name, `What ${name} does`);
139
+ break;
140
+ case 'hook': {
141
+ add(scriptFile(name), `// A hook script: the agent's JSON event is on stdin. Exit 0 to continue, 2 to block (reason on stderr).\nimport { readFileSync } from 'node:fs';\nconst event = JSON.parse(readFileSync(0, 'utf8') || '{}');\nvoid event;\nprocess.exit(0);\n`);
142
+ declareScript(name);
143
+ const hooksFile = path.join(target.dir, NS, 'hooks.json');
144
+ const raw = readJsonIfExists(hooksFile);
145
+ const data = isRecord(raw) ? raw : {};
146
+ const hooks = isRecord(data['hooks']) ? data['hooks'] : {};
147
+ const list = Array.isArray(hooks['PostToolUse']) ? hooks['PostToolUse'] : [];
148
+ list.push({ id: name, matcher: { claude: 'Edit|Write', codex: 'apply_patch' }, run: name, timeout: 30 });
149
+ hooks['PostToolUse'] = list;
150
+ data['hooks'] = hooks;
151
+ fs.writeFileSync(hooksFile, `${JSON.stringify(data, null, 2)}\n`);
152
+ files.push(hooksFile);
153
+ break;
154
+ }
155
+ case 'mcp': {
156
+ const script = `${name}-mcp`;
157
+ add(scriptFile(script), MCP_TEMPLATE.replaceAll('NAME', `${target.plugin}-${name}`));
158
+ const mcpFile = path.join(target.dir, 'mcp.json');
159
+ const raw = readJsonIfExists(mcpFile);
160
+ const data = isRecord(raw) ? raw : { $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json' };
161
+ const servers = isRecord(data['mcpServers']) ? data['mcpServers'] : {};
162
+ if (servers[name] !== undefined)
163
+ throw new HarnessError(`The MCP server "${name}" already exists`);
164
+ servers[name] = { type: 'stdio', command: 'node', args: [`\${PLUGIN_ROOT}/${NS}/scripts/${script}.mjs`] };
165
+ data['mcpServers'] = servers;
166
+ fs.writeFileSync(mcpFile, `${JSON.stringify(data, null, 2)}\n`);
167
+ files.push(mcpFile);
168
+ break;
169
+ }
170
+ }
171
+ return files;
172
+ }
173
+ /** The sections of a rule below its title: what a patch can replace. */
174
+ function patchTemplate(body) {
175
+ const lines = body.split('\n');
176
+ const hs = headings(lines);
177
+ const top = hs[0]?.level === 1 ? 1 : 0;
178
+ const first = hs.find((h) => h.level > top);
179
+ if (first)
180
+ return `${lines.slice(first.line).join('\n').trim()}\n`;
181
+ // Without sections below the title, the title's section is the whole rule: a patch of it replaces the rule.
182
+ return hs[0] ? `${lines.slice(hs[0].line).join('\n').trim()}\n` : '';
183
+ }
184
+ /**
185
+ * Writes a patch with a copy of the rule's sections and registers it (spec 10.2): in harness/plugin.json
186
+ * when the rule reaches the project through its own harness's `extends`, otherwise in harnesspm.json;
187
+ * with `--to`, in that added harness or its entry.
188
+ */
189
+ export function createPatch(projectDir, res, ruleId, options) {
190
+ const rule = res.rules.find((r) => r.id === ruleId);
191
+ if (!rule)
192
+ throw new HarnessError(`No rule ${ruleId}. Find IDs in the AGENTS.md block or with \`npx harnesspm why <ID>\`.`);
193
+ const fileName = `${ruleId.replace(':rules/', '-').replace(/[^a-z0-9.-]+/gi, '-')}.patch.md`;
194
+ const body = parseFrontmatter(rule.text, rule.source).body;
195
+ const content = patchTemplate(body) || `Text to add at the end of ${ruleId}.\n`;
196
+ const register = (manifest, baseDir, file, plugin) => {
197
+ writeNew(file, content);
198
+ editManifest(manifest, (ext) => {
199
+ const patch = isRecord(ext['patch']) ? ext['patch'] : {};
200
+ patch[ruleId] = `./${relPosix(baseDir, file)}`;
201
+ ext['patch'] = patch;
202
+ }, plugin);
203
+ return { file, registeredIn: manifest };
204
+ };
205
+ if (options.to !== undefined) {
206
+ const target = resolveTarget(projectDir, options);
207
+ return register(target.manifest, target.dir, path.join(target.dir, NS, 'patches', fileName), target.plugin);
208
+ }
209
+ const own = res.packages.find((p) => p.layer === 'project');
210
+ if (own && own.closure.has(rule.pkg.name)) {
211
+ return register(path.join(own.harnessDir, 'plugin.json'), own.harnessDir, path.join(own.harnessDir, NS, 'patches', fileName), own.plugin);
212
+ }
213
+ const file = path.join(projectDir, 'harnesspm', 'patches', fileName);
214
+ writeNew(file, content);
215
+ const configFile = path.join(projectDir, CONFIG_FILE);
216
+ const raw = readJsonIfExists(configFile);
217
+ const config = isRecord(raw) ? raw : {};
218
+ const patch = isRecord(config['patch']) ? config['patch'] : {};
219
+ patch[ruleId] = `./${relPosix(projectDir, file)}`;
220
+ config['patch'] = patch;
221
+ fs.writeFileSync(configFile, `${JSON.stringify(config, null, 2)}\n`);
222
+ return { file, registeredIn: configFile };
223
+ }