@tenphi/harnesspm 0.0.0-snapshot.376310a
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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/added.js +189 -0
- package/dist/check.js +160 -0
- package/dist/cli.js +139 -0
- package/dist/commands.js +414 -0
- package/dist/config.js +43 -0
- package/dist/exec-outputs.js +360 -0
- package/dist/exec-units.js +144 -0
- package/dist/fetch.js +93 -0
- package/dist/frontmatter.js +35 -0
- package/dist/homes.js +17 -0
- package/dist/lock.js +102 -0
- package/dist/manifest.js +154 -0
- package/dist/new.js +223 -0
- package/dist/outputs.js +453 -0
- package/dist/render.js +119 -0
- package/dist/resolve.js +396 -0
- package/dist/run.js +83 -0
- package/dist/sections.js +121 -0
- package/dist/user-outputs.js +195 -0
- package/dist/util.js +140 -0
- package/dist/workspaces.js +61 -0
- package/docs/spec/01-overview.md +130 -0
- package/docs/spec/02-package-format.md +238 -0
- package/docs/spec/03-resolution.md +104 -0
- package/docs/spec/04-outputs.md +109 -0
- package/docs/spec/05-executables.md +158 -0
- package/docs/spec/06-trust.md +72 -0
- package/docs/spec/07-added.md +104 -0
- package/docs/spec/08-improving.md +77 -0
- package/docs/spec/09-cloud.md +47 -0
- package/docs/spec/10-cli.md +60 -0
- package/docs/spec/11-standards.md +49 -0
- package/docs/spec/12-dogfooding.md +51 -0
- package/docs/spec/13-open-questions.md +85 -0
- package/docs/spec/README.md +33 -0
- package/harness/dev.harnesspm/rules/generated-files.md +4 -0
- package/harness/dev.harnesspm/rules/harnesspm-config.md +11 -0
- package/harness/plugin.json +13 -0
- package/harness/skills/harnesspm-export/SKILL.md +39 -0
- package/harness/skills/harnesspm-import/SKILL.md +35 -0
- package/harness/skills/harnesspm-improve/SKILL.md +38 -0
- package/package.json +60 -0
- package/schema/harnesspm.schema.json +31 -0
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Resolution of the dependency and project layers (spec chapter 3).
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { CONFIG_FILE, emptyConfig, readConfig } from './config.js';
|
|
4
|
+
import { discoverUnits, loadHarnessPackage, ruleMeta, } from './manifest.js';
|
|
5
|
+
import { discoverExecUnits } from './exec-units.js';
|
|
6
|
+
import { applyPatch } from './sections.js';
|
|
7
|
+
import { findWorkspaces } from './workspaces.js';
|
|
8
|
+
import { HarnessError, compareVersions, isFile, isRecord, readJsonIfExists, readText, realpath, relPosix } from './util.js';
|
|
9
|
+
/** Finds `<name>` in node_modules from `fromDir` upward, the way Node.js does. */
|
|
10
|
+
export function resolvePackageDir(name, fromDir) {
|
|
11
|
+
let dir = fromDir;
|
|
12
|
+
for (;;) {
|
|
13
|
+
const candidate = path.join(dir, 'node_modules', name, 'package.json');
|
|
14
|
+
if (isFile(candidate))
|
|
15
|
+
return realpath(path.dirname(candidate));
|
|
16
|
+
const parent = path.dirname(dir);
|
|
17
|
+
if (parent === dir)
|
|
18
|
+
return undefined;
|
|
19
|
+
dir = parent;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function dependencyNames(pkgJson) {
|
|
23
|
+
const names = new Set();
|
|
24
|
+
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
25
|
+
const deps = pkgJson[field];
|
|
26
|
+
if (isRecord(deps))
|
|
27
|
+
for (const name of Object.keys(deps))
|
|
28
|
+
names.add(name);
|
|
29
|
+
}
|
|
30
|
+
return [...names].sort();
|
|
31
|
+
}
|
|
32
|
+
const isUnitId = (entry) => entry.includes(':');
|
|
33
|
+
export function resolve(projectDirInput, options = {}) {
|
|
34
|
+
const projectDir = realpath(projectDirInput);
|
|
35
|
+
const team = options.team ?? true;
|
|
36
|
+
const warnings = [];
|
|
37
|
+
const config = team ? readConfig(projectDir) : emptyConfig();
|
|
38
|
+
warnings.push(...config.warnings);
|
|
39
|
+
const pkgJsonRaw = readJsonIfExists(path.join(projectDir, 'package.json'));
|
|
40
|
+
const pkgJson = isRecord(pkgJsonRaw) ? pkgJsonRaw : {};
|
|
41
|
+
const projectName = typeof pkgJson['name'] === 'string' ? pkgJson['name'] : path.basename(projectDir);
|
|
42
|
+
const cache = new Map();
|
|
43
|
+
const load = (dir, layer) => {
|
|
44
|
+
if (!cache.has(dir))
|
|
45
|
+
cache.set(dir, loadHarnessPackage(dir, layer) ?? null);
|
|
46
|
+
return cache.get(dir) ?? undefined;
|
|
47
|
+
};
|
|
48
|
+
const occurrences = new Map();
|
|
49
|
+
const members = new Map();
|
|
50
|
+
const addMember = (name, key) => {
|
|
51
|
+
const set = members.get(name) ?? new Set();
|
|
52
|
+
set.add(key);
|
|
53
|
+
members.set(name, set);
|
|
54
|
+
};
|
|
55
|
+
const edges = new Map();
|
|
56
|
+
const excludedPackages = [];
|
|
57
|
+
const addOccurrence = (name, occurrence) => {
|
|
58
|
+
const list = occurrences.get(name) ?? [];
|
|
59
|
+
list.push(occurrence);
|
|
60
|
+
occurrences.set(name, list);
|
|
61
|
+
};
|
|
62
|
+
const addEdge = (from, to) => {
|
|
63
|
+
const set = edges.get(from) ?? new Set();
|
|
64
|
+
set.add(to);
|
|
65
|
+
edges.set(from, set);
|
|
66
|
+
};
|
|
67
|
+
/** `excluded` maps a package name to whoever excluded it on this path. */
|
|
68
|
+
const visit = (pkg, depth, excluded, stack, root) => {
|
|
69
|
+
const childExcluded = new Map(excluded);
|
|
70
|
+
for (const name of pkg.ext.exclude.filter((e) => !isUnitId(e)))
|
|
71
|
+
childExcluded.set(name, pkg.name);
|
|
72
|
+
for (const name of pkg.ext.extends) {
|
|
73
|
+
const by = childExcluded.get(name);
|
|
74
|
+
if (by !== undefined) {
|
|
75
|
+
excludedPackages.push({ name, by });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const dir = resolvePackageDir(name, pkg.dir) ?? options.locate?.(name);
|
|
79
|
+
if (!dir) {
|
|
80
|
+
warnings.push(`${pkg.name} extends ${name}, which is not installed`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (stack.includes(dir)) {
|
|
84
|
+
throw new HarnessError(`extends cycle: ${[...stack.map((d) => cache.get(d)?.name ?? d), name].join(' → ')}`);
|
|
85
|
+
}
|
|
86
|
+
const child = load(dir, 'dependencies');
|
|
87
|
+
if (!child) {
|
|
88
|
+
warnings.push(`${pkg.name} extends ${name}, which has no harness/ folder`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
addEdge(pkg.name, name);
|
|
92
|
+
addOccurrence(name, { dir, depth: depth + 1, direct: false, via: pkg.name });
|
|
93
|
+
addMember(name, root);
|
|
94
|
+
visit(child, depth + 1, childExcluded, [...stack, dir], root);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
// Each workspace is resolved on its own dependencies, from its own folder (spec 3.6).
|
|
98
|
+
const workspaces = team ? findWorkspaces(projectDir).map((dir) => ({ key: relPosix(projectDir, dir), dir: realpath(dir) })) : [];
|
|
99
|
+
const roots = [{ key: '', dir: projectDir, deps: dependencyNames(pkgJson) }, ...workspaces.map((ws) => {
|
|
100
|
+
const raw = readJsonIfExists(path.join(ws.dir, 'package.json'));
|
|
101
|
+
return { key: ws.key, dir: ws.dir, deps: dependencyNames(isRecord(raw) ? raw : {}) };
|
|
102
|
+
})];
|
|
103
|
+
const rootExcluded = new Map(config.exclude.filter((e) => !isUnitId(e)).map((name) => [name, CONFIG_FILE]));
|
|
104
|
+
for (const root of team ? roots : []) {
|
|
105
|
+
for (const name of root.deps) {
|
|
106
|
+
if (rootExcluded.has(name)) {
|
|
107
|
+
excludedPackages.push({ name, by: CONFIG_FILE });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const dir = resolvePackageDir(name, root.dir) ?? options.locate?.(name);
|
|
111
|
+
if (!dir)
|
|
112
|
+
continue;
|
|
113
|
+
const pkg = load(dir, 'dependencies');
|
|
114
|
+
if (!pkg)
|
|
115
|
+
continue;
|
|
116
|
+
addOccurrence(name, { dir, depth: 0, direct: true });
|
|
117
|
+
addMember(name, root.key);
|
|
118
|
+
visit(pkg, 0, rootExcluded, [dir], root.key);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const projectHarness = team ? loadHarnessPackage(projectDir, 'project') : undefined;
|
|
122
|
+
if (projectHarness) {
|
|
123
|
+
projectHarness.name = projectName;
|
|
124
|
+
visit(projectHarness, 0, rootExcluded, [projectDir], '');
|
|
125
|
+
}
|
|
126
|
+
// A workspace's own harness belongs to that workspace, unless another workspace already reaches it as a dependency.
|
|
127
|
+
const workspaceHarnesses = [];
|
|
128
|
+
for (const ws of workspaces) {
|
|
129
|
+
const harness = loadHarnessPackage(ws.dir, 'project');
|
|
130
|
+
if (!harness)
|
|
131
|
+
continue;
|
|
132
|
+
workspaceHarnesses.push({ pkg: harness, key: ws.key });
|
|
133
|
+
visit(harness, 0, rootExcluded, [ws.dir], ws.key);
|
|
134
|
+
}
|
|
135
|
+
// One version per package in a repository (spec 3.5).
|
|
136
|
+
const chosen = new Map();
|
|
137
|
+
for (const [name, list] of occurrences) {
|
|
138
|
+
const sorted = [...list].sort((a, b) => {
|
|
139
|
+
if (a.direct !== b.direct)
|
|
140
|
+
return a.direct ? -1 : 1;
|
|
141
|
+
if (a.depth !== b.depth)
|
|
142
|
+
return a.depth - b.depth;
|
|
143
|
+
return compareVersions(load(b.dir, 'dependencies').version, load(a.dir, 'dependencies').version);
|
|
144
|
+
});
|
|
145
|
+
const first = sorted[0];
|
|
146
|
+
const versions = [...new Set(list.map((o) => load(o.dir, 'dependencies').version))];
|
|
147
|
+
if (new Set(list.map((o) => o.dir)).size > 1 && versions.length > 1) {
|
|
148
|
+
warnings.push(`${name}: found versions ${versions.join(', ')}; using ${load(first.dir, 'dependencies').version}`);
|
|
149
|
+
}
|
|
150
|
+
chosen.set(name, { pkg: load(first.dir, 'dependencies'), occurrence: first });
|
|
151
|
+
}
|
|
152
|
+
const closureOf = (name) => {
|
|
153
|
+
const result = new Set();
|
|
154
|
+
const stack = [...(edges.get(name) ?? [])];
|
|
155
|
+
while (stack.length > 0) {
|
|
156
|
+
const next = stack.pop();
|
|
157
|
+
if (result.has(next) || !chosen.has(next))
|
|
158
|
+
continue;
|
|
159
|
+
result.add(next);
|
|
160
|
+
stack.push(...(edges.get(next) ?? []));
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
};
|
|
164
|
+
// Upstream first: a package comes after the packages it extends. Unrelated packages by name.
|
|
165
|
+
const order = [];
|
|
166
|
+
const seen = new Set();
|
|
167
|
+
const walk = (name) => {
|
|
168
|
+
if (seen.has(name) || !chosen.has(name))
|
|
169
|
+
return;
|
|
170
|
+
seen.add(name);
|
|
171
|
+
for (const child of [...(edges.get(name) ?? [])].sort())
|
|
172
|
+
walk(child);
|
|
173
|
+
order.push(name);
|
|
174
|
+
};
|
|
175
|
+
for (const [name, { occurrence }] of [...chosen].sort(([a], [b]) => a.localeCompare(b)))
|
|
176
|
+
if (occurrence.direct)
|
|
177
|
+
walk(name);
|
|
178
|
+
for (const child of [...(edges.get(projectName) ?? [])].sort())
|
|
179
|
+
walk(child);
|
|
180
|
+
for (const name of [...chosen.keys()].sort())
|
|
181
|
+
walk(name);
|
|
182
|
+
const workspaceDirs = new Map(workspaces.map((ws) => [ws.dir, ws.key]));
|
|
183
|
+
const packages = order.map((name) => {
|
|
184
|
+
const { pkg, occurrence } = chosen.get(name);
|
|
185
|
+
const own = workspaceDirs.get(pkg.dir);
|
|
186
|
+
const set = new Set(members.get(name) ?? []);
|
|
187
|
+
if (own !== undefined)
|
|
188
|
+
set.add(own);
|
|
189
|
+
return {
|
|
190
|
+
...pkg,
|
|
191
|
+
// A workspace that another workspace depends on is still this repository's own code.
|
|
192
|
+
...(own !== undefined ? { layer: 'project' } : {}),
|
|
193
|
+
direct: occurrence.direct,
|
|
194
|
+
...(occurrence.via !== undefined ? { via: occurrence.via } : {}),
|
|
195
|
+
closure: closureOf(name),
|
|
196
|
+
members: set,
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
if (projectHarness)
|
|
200
|
+
packages.push({ ...projectHarness, direct: true, closure: closureOf(projectName), members: new Set(['']) });
|
|
201
|
+
for (const { pkg, key } of workspaceHarnesses) {
|
|
202
|
+
if (packages.some((p) => p.dir === pkg.dir))
|
|
203
|
+
continue;
|
|
204
|
+
packages.push({ ...pkg, direct: true, closure: closureOf(pkg.name), members: new Set([key]) });
|
|
205
|
+
}
|
|
206
|
+
const everywhere = new Set(['', ...workspaces.map((ws) => ws.key)]);
|
|
207
|
+
for (const added of options.added ?? []) {
|
|
208
|
+
packages.push({ ...added.pkg, layer: 'added', direct: true, closure: new Set(), scope: added.scope, added, members: everywhere });
|
|
209
|
+
if (added.entry)
|
|
210
|
+
packages.push({ ...added.entry, layer: 'added', direct: true, closure: new Set(), scope: added.scope, added, entry: true, members: everywhere });
|
|
211
|
+
}
|
|
212
|
+
// Plugin names must be unique (spec 2.9). An entry shares its harness's name.
|
|
213
|
+
const byPlugin = new Map();
|
|
214
|
+
for (const pkg of packages) {
|
|
215
|
+
if (pkg.entry)
|
|
216
|
+
continue;
|
|
217
|
+
const other = byPlugin.get(pkg.plugin);
|
|
218
|
+
if (other !== undefined) {
|
|
219
|
+
throw new HarnessError(`${other} and ${pkg.name} both use the plugin name "${pkg.plugin}". Exclude one of them in ${CONFIG_FILE}.`);
|
|
220
|
+
}
|
|
221
|
+
byPlugin.set(pkg.plugin, pkg.name);
|
|
222
|
+
}
|
|
223
|
+
for (const pkg of packages)
|
|
224
|
+
for (const problem of pkg.problems)
|
|
225
|
+
warnings.push(`${pkg.name}: ${problem}`);
|
|
226
|
+
// Overrides, lowest first: upstream packages, then downstream ones, then the project's harness, then harnesspm.json.
|
|
227
|
+
const overrides = [];
|
|
228
|
+
const add = (by, scope, exclude, replace, patch, baseDir) => {
|
|
229
|
+
for (const target of exclude.filter(isUnitId))
|
|
230
|
+
overrides.push({ kind: 'exclude', target, by, order: overrides.length, scope });
|
|
231
|
+
for (const [target, file] of Object.entries(replace))
|
|
232
|
+
overrides.push({ kind: 'replace', target, file: path.resolve(baseDir, file), by, order: overrides.length, scope });
|
|
233
|
+
for (const [target, file] of Object.entries(patch))
|
|
234
|
+
overrides.push({ kind: 'patch', target, file: path.resolve(baseDir, file), by, order: overrides.length, scope });
|
|
235
|
+
};
|
|
236
|
+
for (const pkg of packages.filter((p) => p.layer !== 'added'))
|
|
237
|
+
add(pkg.name, pkg.closure, pkg.ext.exclude, pkg.ext.replace, pkg.ext.patch, pkg.harnessDir);
|
|
238
|
+
add(CONFIG_FILE, 'all', config.exclude, config.replace, config.patch, projectDir);
|
|
239
|
+
// Added harnesses sit above everything, so their changes reach every unit below them.
|
|
240
|
+
for (const pkg of packages.filter((p) => p.layer === 'added'))
|
|
241
|
+
add(pkg.name, 'all', pkg.ext.exclude, pkg.ext.replace, pkg.ext.patch, pkg.harnessDir);
|
|
242
|
+
const overrideFiles = new Set(overrides.flatMap((o) => (o.file !== undefined ? [o.file] : [])));
|
|
243
|
+
const inScope = (o, pkgName) => o.scope === 'all' || o.scope.has(pkgName);
|
|
244
|
+
const allRules = [];
|
|
245
|
+
const allSkills = [];
|
|
246
|
+
const allDocs = [];
|
|
247
|
+
const allScripts = [];
|
|
248
|
+
const allHooks = [];
|
|
249
|
+
const allMcp = [];
|
|
250
|
+
const allPermissions = [];
|
|
251
|
+
const allAgents = [];
|
|
252
|
+
for (const pkg of packages) {
|
|
253
|
+
const units = discoverUnits(pkg, overrideFiles);
|
|
254
|
+
for (const source of units.rules)
|
|
255
|
+
allRules.push({ source, pkg });
|
|
256
|
+
for (const skill of units.skills)
|
|
257
|
+
allSkills.push({ ...skill, pkg });
|
|
258
|
+
if (units.docs)
|
|
259
|
+
allDocs.push({ ...units.docs, pkg });
|
|
260
|
+
const problems = [];
|
|
261
|
+
const exec = discoverExecUnits(pkg, problems);
|
|
262
|
+
for (const problem of problems)
|
|
263
|
+
warnings.push(`${pkg.name}: ${problem}`);
|
|
264
|
+
allScripts.push(...exec.scripts.map((u) => ({ ...u, pkg })));
|
|
265
|
+
allHooks.push(...exec.hooks.map((u) => ({ ...u, pkg })));
|
|
266
|
+
allMcp.push(...exec.mcp.map((u) => ({ ...u, pkg })));
|
|
267
|
+
if (exec.permissions)
|
|
268
|
+
allPermissions.push({ ...exec.permissions, pkg });
|
|
269
|
+
allAgents.push(...exec.agents.map((u) => ({ ...u, pkg })));
|
|
270
|
+
}
|
|
271
|
+
const knownIds = new Map();
|
|
272
|
+
for (const { source, pkg } of allRules)
|
|
273
|
+
knownIds.set(source.id, pkg.name);
|
|
274
|
+
for (const skill of allSkills)
|
|
275
|
+
knownIds.set(skill.id, skill.pkg.name);
|
|
276
|
+
for (const docs of allDocs)
|
|
277
|
+
knownIds.set(docs.id, docs.pkg.name);
|
|
278
|
+
for (const unit of [...allScripts, ...allHooks, ...allMcp, ...allPermissions, ...allAgents])
|
|
279
|
+
knownIds.set(unit.id, unit.pkg.name);
|
|
280
|
+
const addedNames = new Set(packages.filter((p) => p.layer === 'added').map((p) => p.name));
|
|
281
|
+
for (const o of overrides) {
|
|
282
|
+
const owner = knownIds.get(o.target);
|
|
283
|
+
// Without the team layers, an added harness's change to a team unit has nothing to point at yet.
|
|
284
|
+
if (owner === undefined && !team && addedNames.has(o.by))
|
|
285
|
+
continue;
|
|
286
|
+
if (owner === undefined)
|
|
287
|
+
warnings.push(`${o.by}: ${o.kind} names ${o.target}, which does not exist. It may have been renamed upstream.`);
|
|
288
|
+
else if (!inScope(o, owner))
|
|
289
|
+
warnings.push(`${o.by}: ${o.kind} names ${o.target}, which ${o.by} does not pass on`);
|
|
290
|
+
else if (o.kind !== 'exclude' && !o.target.includes(':rules/'))
|
|
291
|
+
warnings.push(`${o.by}: ${o.kind} works on rules only: ${o.target}`);
|
|
292
|
+
if (o.file !== undefined && !isFile(o.file))
|
|
293
|
+
warnings.push(`${o.by}: ${o.kind} file not found: ${relPosix(projectDir, o.file)}`);
|
|
294
|
+
}
|
|
295
|
+
const excludedUnits = [];
|
|
296
|
+
const excludedBy = (id, pkgName) => overrides.find((o) => o.kind === 'exclude' && o.target === id && inScope(o, pkgName));
|
|
297
|
+
// An entry's unit wins over a unit with the same ID in its own harness (spec 7.2).
|
|
298
|
+
const entryIds = new Set(allRules.filter((r) => r.pkg.entry).map((r) => r.source.id));
|
|
299
|
+
const rules = [];
|
|
300
|
+
for (const { source, pkg } of allRules) {
|
|
301
|
+
if (!pkg.entry && pkg.layer === 'added' && entryIds.has(source.id))
|
|
302
|
+
continue;
|
|
303
|
+
const exclusion = excludedBy(source.id, pkg.name);
|
|
304
|
+
if (exclusion) {
|
|
305
|
+
excludedUnits.push({ id: source.id, by: exclusion.by });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const relevant = overrides.filter((o) => o.target === source.id && inScope(o, pkg.name) && (o.file === undefined || isFile(o.file)));
|
|
309
|
+
const replace = relevant.filter((o) => o.kind === 'replace').at(-1);
|
|
310
|
+
const from = replace?.file ?? source.file;
|
|
311
|
+
let text = readText(from);
|
|
312
|
+
const patches = [];
|
|
313
|
+
for (const p of relevant.filter((o) => o.kind === 'patch')) {
|
|
314
|
+
if (replace && p.order < replace.order) {
|
|
315
|
+
warnings.push(`${p.by}: the patch of ${source.id} is dropped, because ${replace.by} replaces the rule`);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const patchName = relPosix(projectDir, p.file);
|
|
319
|
+
const result = applyPatch(text, readText(p.file), patchName);
|
|
320
|
+
text = result.text;
|
|
321
|
+
warnings.push(...result.warnings);
|
|
322
|
+
patches.push({ patch: patchName, by: p.by, sections: result.touched });
|
|
323
|
+
}
|
|
324
|
+
rules.push({
|
|
325
|
+
...ruleMeta(text, source.name, from),
|
|
326
|
+
id: source.id,
|
|
327
|
+
name: source.name,
|
|
328
|
+
pkg,
|
|
329
|
+
original: source.file,
|
|
330
|
+
source: from,
|
|
331
|
+
text,
|
|
332
|
+
...(replace ? { replacedBy: replace } : {}),
|
|
333
|
+
patches,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
// Skills: a later package wins on the same name, with a warning (spec 2.9).
|
|
337
|
+
const skillsByName = new Map();
|
|
338
|
+
for (const skill of allSkills) {
|
|
339
|
+
const exclusion = excludedBy(skill.id, skill.pkg.name);
|
|
340
|
+
if (exclusion) {
|
|
341
|
+
excludedUnits.push({ id: skill.id, by: exclusion.by });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const previous = skillsByName.get(skill.name);
|
|
345
|
+
if (previous && !(skill.pkg.entry && previous.pkg.plugin === skill.pkg.plugin))
|
|
346
|
+
warnings.push(`skill "${skill.name}" comes from ${previous.pkg.name} and ${skill.pkg.name}; using ${skill.pkg.name}`);
|
|
347
|
+
skillsByName.set(skill.name, skill);
|
|
348
|
+
}
|
|
349
|
+
const docs = [];
|
|
350
|
+
for (const unit of allDocs) {
|
|
351
|
+
const exclusion = excludedBy(unit.id, unit.pkg.name);
|
|
352
|
+
if (exclusion)
|
|
353
|
+
excludedUnits.push({ id: unit.id, by: exclusion.by });
|
|
354
|
+
else
|
|
355
|
+
docs.push(unit);
|
|
356
|
+
}
|
|
357
|
+
const keep = (units) => units.filter((unit) => {
|
|
358
|
+
const exclusion = excludedBy(unit.id, unit.pkg.name);
|
|
359
|
+
if (exclusion)
|
|
360
|
+
excludedUnits.push({ id: unit.id, by: exclusion.by });
|
|
361
|
+
return !exclusion;
|
|
362
|
+
});
|
|
363
|
+
const scripts = keep(allScripts);
|
|
364
|
+
const hooks = keep(allHooks).filter((hook) => {
|
|
365
|
+
const ok = scripts.some((s) => s.pkg === hook.pkg && s.name === hook.run);
|
|
366
|
+
if (!ok)
|
|
367
|
+
warnings.push(`${hook.id} is dropped: its script ${hook.pkg.plugin}:${hook.run} is excluded or missing`);
|
|
368
|
+
return ok;
|
|
369
|
+
});
|
|
370
|
+
const agentsByName = new Map();
|
|
371
|
+
for (const agent of keep(allAgents)) {
|
|
372
|
+
const previous = agentsByName.get(agent.name);
|
|
373
|
+
if (previous && !(agent.pkg.entry && previous.pkg.plugin === agent.pkg.plugin))
|
|
374
|
+
warnings.push(`sub-agent "${agent.name}" comes from ${previous.pkg.name} and ${agent.pkg.name}; using ${agent.pkg.name}`);
|
|
375
|
+
agentsByName.set(agent.name, agent);
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
projectDir,
|
|
379
|
+
projectName,
|
|
380
|
+
workspaces,
|
|
381
|
+
config,
|
|
382
|
+
packages,
|
|
383
|
+
rules,
|
|
384
|
+
skills: [...skillsByName.values()],
|
|
385
|
+
docs,
|
|
386
|
+
scripts,
|
|
387
|
+
hooks,
|
|
388
|
+
mcp: keep(allMcp),
|
|
389
|
+
permissions: keep(allPermissions),
|
|
390
|
+
agents: [...agentsByName.values()],
|
|
391
|
+
overrides,
|
|
392
|
+
excludedUnits,
|
|
393
|
+
excludedPackages,
|
|
394
|
+
warnings,
|
|
395
|
+
};
|
|
396
|
+
}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// harnesspm run and harnesspm mcp (spec 5.1, 5.3).
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { harnesspmHome } from './added.js';
|
|
5
|
+
import { trustOf } from './exec-outputs.js';
|
|
6
|
+
import { readLock } from './lock.js';
|
|
7
|
+
import { HarnessError, ensureDir, isFile } from './util.js';
|
|
8
|
+
/** Thrown when the runner refuses a script. The CLI exits with code 2 (spec 10.4). */
|
|
9
|
+
export class Refused extends HarnessError {
|
|
10
|
+
}
|
|
11
|
+
function parseRef(ref) {
|
|
12
|
+
const index = ref.indexOf(':');
|
|
13
|
+
if (index <= 0 || index === ref.length - 1)
|
|
14
|
+
throw new HarnessError(`Use <plugin>:<name>, not "${ref}"`);
|
|
15
|
+
return { plugin: ref.slice(0, index), name: ref.slice(index + 1) };
|
|
16
|
+
}
|
|
17
|
+
/** Checks trust and the installed version (spec 5.1, step 2). */
|
|
18
|
+
function checkPackage(pkg, res, what) {
|
|
19
|
+
const lock = readLock(res.projectDir);
|
|
20
|
+
const trust = trustOf(pkg, res, lock);
|
|
21
|
+
if (!trust.trusted)
|
|
22
|
+
throw new Refused(`${what} of ${pkg.name} is refused: ${trust.reason}`);
|
|
23
|
+
if (pkg.layer === 'dependencies') {
|
|
24
|
+
const locked = lock?.packages[pkg.name]?.version;
|
|
25
|
+
if (locked !== pkg.version)
|
|
26
|
+
throw new Refused(`${what} is refused: ${pkg.name} ${pkg.version} is installed, but the lock has ${locked ?? 'no entry'}. Run \`npx harnesspm sync\`.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function environment(pkg, res, agent) {
|
|
30
|
+
const data = path.join(harnesspmHome(), 'data', pkg.plugin);
|
|
31
|
+
ensureDir(data);
|
|
32
|
+
return {
|
|
33
|
+
...process.env,
|
|
34
|
+
PLUGIN_ROOT: pkg.harnessDir,
|
|
35
|
+
PLUGIN_DATA: data,
|
|
36
|
+
HARNESSPM_PROJECT_DIR: res.projectDir,
|
|
37
|
+
HARNESSPM_AGENT: agent ?? (process.env['AI_AGENT']?.startsWith('claude-code') ? 'claude' : ''),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function interpreter(file) {
|
|
41
|
+
if (/\.(m|c)?js$/.test(file))
|
|
42
|
+
return [process.execPath, [file]];
|
|
43
|
+
if (file.endsWith('.sh'))
|
|
44
|
+
return ['sh', [file]];
|
|
45
|
+
return [file, []];
|
|
46
|
+
}
|
|
47
|
+
export function runScript(res, ref, args, agent) {
|
|
48
|
+
const { plugin, name } = parseRef(ref);
|
|
49
|
+
const script = res.scripts.find((s) => s.pkg.plugin === plugin && s.name === name);
|
|
50
|
+
if (!script) {
|
|
51
|
+
const known = res.scripts.filter((s) => s.pkg.plugin === plugin).map((s) => `${plugin}:${s.name}`);
|
|
52
|
+
throw new HarnessError(`No script ${ref}.${known.length > 0 ? ` Scripts of ${plugin}: ${known.join(', ')}` : ''}`);
|
|
53
|
+
}
|
|
54
|
+
checkPackage(script.pkg, res, `Script ${ref}`);
|
|
55
|
+
if (!isFile(script.file))
|
|
56
|
+
throw new HarnessError(`Script ${ref}: ${script.file} does not exist`);
|
|
57
|
+
const [command, prefix] = interpreter(script.file);
|
|
58
|
+
const result = spawnSync(command, [...prefix, ...args], { stdio: 'inherit', cwd: process.cwd(), env: environment(script.pkg, res, agent) });
|
|
59
|
+
if (result.error)
|
|
60
|
+
throw new HarnessError(`Script ${ref} could not start: ${result.error.message}`);
|
|
61
|
+
return result.status ?? 1;
|
|
62
|
+
}
|
|
63
|
+
export function runMcp(res, ref, agent) {
|
|
64
|
+
const { plugin, name } = parseRef(ref);
|
|
65
|
+
const server = res.mcp.find((s) => s.pkg.plugin === plugin && s.name === name);
|
|
66
|
+
if (!server)
|
|
67
|
+
throw new HarnessError(`No MCP server ${ref}`);
|
|
68
|
+
checkPackage(server.pkg, res, `MCP server ${ref}`);
|
|
69
|
+
if (server.config['type'] !== 'stdio')
|
|
70
|
+
throw new HarnessError(`MCP server ${ref} is remote; agents connect to its URL directly`);
|
|
71
|
+
const env = environment(server.pkg, res, agent);
|
|
72
|
+
const expand = (value) => value.replaceAll('${PLUGIN_ROOT}', env['PLUGIN_ROOT']).replaceAll('${PLUGIN_DATA}', env['PLUGIN_DATA']);
|
|
73
|
+
const command = expand(String(server.config['command']));
|
|
74
|
+
const args = Array.isArray(server.config['args']) ? server.config['args'].map((a) => expand(String(a))) : [];
|
|
75
|
+
const extra = server.config['env'];
|
|
76
|
+
if (extra !== null && typeof extra === 'object')
|
|
77
|
+
for (const [k, v] of Object.entries(extra))
|
|
78
|
+
env[k] = expand(String(v));
|
|
79
|
+
const result = spawnSync(command, args, { stdio: 'inherit', env });
|
|
80
|
+
if (result.error)
|
|
81
|
+
throw new HarnessError(`MCP server ${ref} could not start: ${result.error.message}`);
|
|
82
|
+
return result.status ?? 1;
|
|
83
|
+
}
|
package/dist/sections.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Markdown sections and patches (spec 2.5).
|
|
2
|
+
import { parseFrontmatter, stringList, withFrontmatter } from './frontmatter.js';
|
|
3
|
+
import { hash } from './util.js';
|
|
4
|
+
/** ATX headings outside fenced code blocks. */
|
|
5
|
+
export function headings(lines) {
|
|
6
|
+
const result = [];
|
|
7
|
+
let fence;
|
|
8
|
+
lines.forEach((line, index) => {
|
|
9
|
+
const fenceMatch = /^\s*(```+|~~~+)/.exec(line);
|
|
10
|
+
if (fenceMatch) {
|
|
11
|
+
const marker = fenceMatch[1];
|
|
12
|
+
if (fence === undefined)
|
|
13
|
+
fence = marker[0];
|
|
14
|
+
else if (marker[0] === fence)
|
|
15
|
+
fence = undefined;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (fence !== undefined)
|
|
19
|
+
return;
|
|
20
|
+
const match = /^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/.exec(line);
|
|
21
|
+
if (match)
|
|
22
|
+
result.push({ line: index, level: match[1].length, text: match[2].trim() });
|
|
23
|
+
});
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
/** A section is a heading and everything up to the next heading of the same or a higher level. */
|
|
27
|
+
function sectionEnd(lines, hs, index) {
|
|
28
|
+
const own = hs[index];
|
|
29
|
+
for (let i = index + 1; i < hs.length; i++) {
|
|
30
|
+
if (hs[i].level <= own.level)
|
|
31
|
+
return hs[i].line;
|
|
32
|
+
}
|
|
33
|
+
return lines.length;
|
|
34
|
+
}
|
|
35
|
+
export function parsePatch(text, file = 'patch') {
|
|
36
|
+
const { data, body } = parseFrontmatter(text, file);
|
|
37
|
+
const lines = body.split('\n');
|
|
38
|
+
const hs = headings(lines);
|
|
39
|
+
const first = hs[0]?.line ?? lines.length;
|
|
40
|
+
const sections = [];
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < hs.length) {
|
|
43
|
+
const end = sectionEnd(lines, hs, i);
|
|
44
|
+
sections.push({ level: hs[i].level, text: hs[i].text, lines: trimBlank(lines.slice(hs[i].line, end)) });
|
|
45
|
+
// Skip headings nested in this section: they are part of it.
|
|
46
|
+
i++;
|
|
47
|
+
while (i < hs.length && hs[i].line < end)
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
return { remove: stringList(data['remove']) ?? [], preamble: trimBlank(lines.slice(0, first)), sections };
|
|
51
|
+
}
|
|
52
|
+
function trimBlank(lines) {
|
|
53
|
+
let start = 0;
|
|
54
|
+
let end = lines.length;
|
|
55
|
+
while (start < end && lines[start].trim() === '')
|
|
56
|
+
start++;
|
|
57
|
+
while (end > start && lines[end - 1].trim() === '')
|
|
58
|
+
end--;
|
|
59
|
+
return lines.slice(start, end);
|
|
60
|
+
}
|
|
61
|
+
/** Applies a patch to a rule. The rule's frontmatter is kept. */
|
|
62
|
+
export function applyPatch(ruleText, patchText, patchFile = 'patch') {
|
|
63
|
+
const patch = parsePatch(patchText, patchFile);
|
|
64
|
+
const rule = parseFrontmatter(ruleText);
|
|
65
|
+
const lines = rule.body.split('\n');
|
|
66
|
+
const hs = headings(lines);
|
|
67
|
+
const warnings = [];
|
|
68
|
+
const touched = [];
|
|
69
|
+
const ops = [];
|
|
70
|
+
const additions = [];
|
|
71
|
+
const find = (predicate) => {
|
|
72
|
+
const index = hs.findIndex(predicate);
|
|
73
|
+
if (index < 0)
|
|
74
|
+
return undefined;
|
|
75
|
+
return { start: hs[index].line, end: sectionEnd(lines, hs, index) };
|
|
76
|
+
};
|
|
77
|
+
for (const section of patch.sections) {
|
|
78
|
+
const target = find((h) => h.level === section.level && h.text === section.text);
|
|
79
|
+
if (target)
|
|
80
|
+
ops.push({ ...target, replacement: [...section.lines, ''], heading: section.text });
|
|
81
|
+
else
|
|
82
|
+
additions.push(section.lines);
|
|
83
|
+
}
|
|
84
|
+
for (const heading of patch.remove) {
|
|
85
|
+
const target = find((h) => h.text === heading);
|
|
86
|
+
if (target)
|
|
87
|
+
ops.push({ ...target, replacement: [], heading });
|
|
88
|
+
else
|
|
89
|
+
warnings.push(`${patchFile}: the rule has no section "${heading}" to remove`);
|
|
90
|
+
}
|
|
91
|
+
ops.sort((a, b) => b.start - a.start);
|
|
92
|
+
let limit = Number.POSITIVE_INFINITY;
|
|
93
|
+
for (const op of ops) {
|
|
94
|
+
if (op.end > limit) {
|
|
95
|
+
warnings.push(`${patchFile}: section "${op.heading}" overlaps another change and was skipped`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
touched.push({ heading: op.heading, hash: hash(lines.slice(op.start, op.end).join('\n')) });
|
|
99
|
+
lines.splice(op.start, op.end - op.start, ...op.replacement);
|
|
100
|
+
limit = op.start;
|
|
101
|
+
}
|
|
102
|
+
const tail = [...additions, ...(patch.preamble.length > 0 ? [patch.preamble] : [])];
|
|
103
|
+
let body = trimBlank(lines).join('\n');
|
|
104
|
+
for (const block of tail)
|
|
105
|
+
body += `\n\n${block.join('\n')}`;
|
|
106
|
+
body += '\n';
|
|
107
|
+
return {
|
|
108
|
+
text: rule.hasFrontmatter ? withFrontmatter(rule.data, body) : body,
|
|
109
|
+
touched: touched.sort((a, b) => a.heading.localeCompare(b.heading)),
|
|
110
|
+
warnings,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Raises every heading level by `by`, outside code blocks, so a rule fits under a block heading. */
|
|
114
|
+
export function demoteHeadings(text, by) {
|
|
115
|
+
const lines = text.split('\n');
|
|
116
|
+
for (const h of headings(lines)) {
|
|
117
|
+
const level = Math.min(6, h.level + by);
|
|
118
|
+
lines[h.line] = `${'#'.repeat(level)} ${h.text}`;
|
|
119
|
+
}
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|