@tenphi/harnesspm 0.0.0-stage → 0.1.0
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 +87 -2
- 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 +53 -4
- package/schema/harnesspm.schema.json +31 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// User-level outputs of added harnesses with user scope (spec 4.4), and the user-level hook.
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { caller, harnesspmHome } from './added.js';
|
|
5
|
+
import { applyEntries, desiredEntries, tomlTables } from './exec-outputs.js';
|
|
6
|
+
import { agentsHome, claudeHome, claudeJsonFile, codexHome } from './homes.js';
|
|
7
|
+
import { pathTools } from './outputs.js';
|
|
8
|
+
import { removeBlock, renderSections, sectionsFor, upsertBlock, wrapBlock } from './render.js';
|
|
9
|
+
import { resolve } from './resolve.js';
|
|
10
|
+
import { NPX_ANY, ensureDir, exists, isDir, isRecord, isSymlink, linkIfChanged, readJsonIfExists, readTextIfExists, realpath, writeIfChanged } from './util.js';
|
|
11
|
+
export const HOOK_COMMAND = `${NPX_ANY} sync`;
|
|
12
|
+
export { claudeHome, codexHome };
|
|
13
|
+
const stateFile = () => path.join(harnesspmHome(), 'state.json');
|
|
14
|
+
function readState() {
|
|
15
|
+
const raw = readJsonIfExists(stateFile());
|
|
16
|
+
const data = isRecord(raw) ? raw : {};
|
|
17
|
+
const list = (key) => (Array.isArray(data[key]) ? data[key].filter((v) => typeof v === 'string') : []);
|
|
18
|
+
return { userLinks: list('userLinks'), claudeDirs: list('claudeDirs') };
|
|
19
|
+
}
|
|
20
|
+
function writeState(state) {
|
|
21
|
+
// Other parts of harnesspm keep their own keys in the same file.
|
|
22
|
+
const raw = readJsonIfExists(stateFile());
|
|
23
|
+
const data = isRecord(raw) ? raw : {};
|
|
24
|
+
ensureDir(path.dirname(stateFile()));
|
|
25
|
+
writeIfChanged(stateFile(), `${JSON.stringify({ ...data, ...state }, null, 2)}\n`);
|
|
26
|
+
}
|
|
27
|
+
const CODEX_BEGIN = '# BEGIN harnesspm (generated by `harnesspm sync`; do not edit)';
|
|
28
|
+
const CODEX_END = '# END harnesspm';
|
|
29
|
+
/** Puts MCP servers into a managed block at the end of ~/.codex/config.toml. */
|
|
30
|
+
function writeCodexBlock(servers) {
|
|
31
|
+
const file = path.join(codexHome(), 'config.toml');
|
|
32
|
+
const text = readTextIfExists(file) ?? '';
|
|
33
|
+
const lines = text.split('\n');
|
|
34
|
+
const start = lines.indexOf(CODEX_BEGIN);
|
|
35
|
+
const end = start >= 0 ? lines.indexOf(CODEX_END, start) : -1;
|
|
36
|
+
const kept = start >= 0 && end >= 0 ? [...lines.slice(0, start), ...lines.slice(end + 1)] : lines;
|
|
37
|
+
while (kept.length > 0 && kept[kept.length - 1] === '')
|
|
38
|
+
kept.pop();
|
|
39
|
+
const block = Object.keys(servers).length > 0 ? [CODEX_BEGIN, tomlTables('mcp_servers', servers), CODEX_END] : [];
|
|
40
|
+
const next = [...kept, ...(kept.length > 0 && block.length > 0 ? [''] : []), ...block].join('\n');
|
|
41
|
+
if (next.trim() === '') {
|
|
42
|
+
if (text === '')
|
|
43
|
+
return false;
|
|
44
|
+
fs.rmSync(file, { force: true });
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return writeIfChanged(file, `${next}\n`);
|
|
48
|
+
}
|
|
49
|
+
/** Writes rules, docs, skills and executables of user-scope harnesses into the agents' user folders. */
|
|
50
|
+
export function writeUserOutputs(added, warnings, report = { pending: [], held: [] }) {
|
|
51
|
+
const user = added.filter((h) => h.scope === 'user').map(({ entry: _entry, ...rest }) => rest);
|
|
52
|
+
const home = harnesspmHome();
|
|
53
|
+
ensureDir(home);
|
|
54
|
+
const res = resolve(home, { team: false, added: user });
|
|
55
|
+
warnings.push(...res.warnings);
|
|
56
|
+
const generatedRoot = path.join(home, 'generated', 'user');
|
|
57
|
+
const generated = new Map();
|
|
58
|
+
const tools = pathTools(res, () => generatedRoot, generated);
|
|
59
|
+
for (const rule of res.rules)
|
|
60
|
+
if (rule.activation !== 'always' && rule.patches.length > 0)
|
|
61
|
+
generated.set(tools.generatedRule(rule), rule.text);
|
|
62
|
+
const content = renderSections(sectionsFor(res, res.packages, tools.paths, () => NPX_ANY), tools.paths);
|
|
63
|
+
const changed = [];
|
|
64
|
+
fs.rmSync(generatedRoot, { recursive: true, force: true });
|
|
65
|
+
for (const [file, text] of generated)
|
|
66
|
+
writeIfChanged(file, text);
|
|
67
|
+
for (const file of [path.join(claudeHome(), 'CLAUDE.md'), path.join(codexHome(), 'AGENTS.md')]) {
|
|
68
|
+
const existing = readTextIfExists(file);
|
|
69
|
+
const next = content !== '' ? upsertBlock(existing, wrapBlock(content)) : removeBlock(existing);
|
|
70
|
+
if (next === undefined) {
|
|
71
|
+
if (existing !== undefined && existing.includes('BEGIN:harnesspm')) {
|
|
72
|
+
fs.rmSync(file);
|
|
73
|
+
changed.push(file);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (writeIfChanged(file, next))
|
|
77
|
+
changed.push(file);
|
|
78
|
+
}
|
|
79
|
+
// Skills: ~/.claude/skills and ~/.agents/skills, once when one links to the other.
|
|
80
|
+
const state = readState();
|
|
81
|
+
const links = new Map();
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
for (const root of [path.join(claudeHome(), 'skills'), path.join(agentsHome(), 'skills')]) {
|
|
84
|
+
const real = isDir(root) ? realpath(root) : root;
|
|
85
|
+
if (seen.has(real))
|
|
86
|
+
continue;
|
|
87
|
+
seen.add(real);
|
|
88
|
+
for (const skill of res.skills) {
|
|
89
|
+
const link = path.join(root, skill.name);
|
|
90
|
+
if (exists(link) && !isSymlink(link))
|
|
91
|
+
warnings.push(`${link} exists and is not a harnesspm link; left as it is`);
|
|
92
|
+
else
|
|
93
|
+
links.set(link, skill.dir);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const [link, target] of links)
|
|
97
|
+
if (linkIfChanged(link, target))
|
|
98
|
+
changed.push(link);
|
|
99
|
+
for (const old of state.userLinks) {
|
|
100
|
+
if (!links.has(old) && isSymlink(old)) {
|
|
101
|
+
fs.unlinkSync(old);
|
|
102
|
+
changed.push(old);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
writeState({ ...state, userLinks: [...links.keys()].sort() });
|
|
106
|
+
// Hooks, permissions, MCP servers and sub-agents of user-scope harnesses (spec 4.4). Adding a harness trusts it.
|
|
107
|
+
const entries = desiredEntries(res, new Set(res.packages), () => ({ trusted: true }), {
|
|
108
|
+
claudeAgents: path.join(claudeHome(), 'agents'),
|
|
109
|
+
codexAgents: path.join(codexHome(), 'agents'),
|
|
110
|
+
npx: () => NPX_ANY,
|
|
111
|
+
});
|
|
112
|
+
warnings.push(...entries.warnings);
|
|
113
|
+
const applied = applyEntries('added', entries, {
|
|
114
|
+
claudeSettings: path.join(claudeHome(), 'settings.json'),
|
|
115
|
+
claudeJson: claudeJsonFile(),
|
|
116
|
+
codexHooks: path.join(codexHome(), 'hooks.json'),
|
|
117
|
+
codexRules: path.join(codexHome(), 'rules', 'harnesspm.rules'),
|
|
118
|
+
stateFile: stateFile(),
|
|
119
|
+
}, caller() !== 'model');
|
|
120
|
+
changed.push(...applied.changed);
|
|
121
|
+
if (writeCodexBlock(applied.codexMcp))
|
|
122
|
+
changed.push(path.join(codexHome(), 'config.toml'));
|
|
123
|
+
report.pending.push(...entries.pending);
|
|
124
|
+
report.held.push(...applied.held);
|
|
125
|
+
return changed;
|
|
126
|
+
}
|
|
127
|
+
function editJson(file, edit) {
|
|
128
|
+
const raw = readJsonIfExists(file);
|
|
129
|
+
const data = isRecord(raw) ? raw : {};
|
|
130
|
+
const before = JSON.stringify(data);
|
|
131
|
+
edit(data);
|
|
132
|
+
if (JSON.stringify(data) === before)
|
|
133
|
+
return false;
|
|
134
|
+
// A settings file that held only harnesspm's entries goes away with them.
|
|
135
|
+
if (Object.keys(data).length === 0 || (Object.keys(data).length === 1 && isRecord(data['hooks']) && Object.keys(data['hooks']).length === 0)) {
|
|
136
|
+
fs.rmSync(file, { force: true });
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
ensureDir(path.dirname(file));
|
|
140
|
+
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
function setSessionHook(hooks, on) {
|
|
144
|
+
const groups = (Array.isArray(hooks['SessionStart']) ? hooks['SessionStart'] : []);
|
|
145
|
+
const ours = (g) => (g.hooks ?? []).some((h) => h.command === HOOK_COMMAND);
|
|
146
|
+
const rest = groups.filter((g) => !ours(g));
|
|
147
|
+
const next = on ? [...rest, { hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: 120 }] }] : rest;
|
|
148
|
+
if (next.length > 0)
|
|
149
|
+
hooks['SessionStart'] = next;
|
|
150
|
+
else
|
|
151
|
+
delete hooks['SessionStart'];
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The user-level SessionStart hook and write access to editable working copies (spec 7.5, 8.5).
|
|
155
|
+
* These widen what agents do, so callers check rule 4 first.
|
|
156
|
+
*/
|
|
157
|
+
export function writeUserSettings(hook, writableDirs) {
|
|
158
|
+
const changed = [];
|
|
159
|
+
const state = readState();
|
|
160
|
+
const claudeSettings = path.join(claudeHome(), 'settings.json');
|
|
161
|
+
const didClaude = editJson(claudeSettings, (data) => {
|
|
162
|
+
const hooks = isRecord(data['hooks']) ? data['hooks'] : {};
|
|
163
|
+
setSessionHook(hooks, hook);
|
|
164
|
+
if (Object.keys(hooks).length > 0)
|
|
165
|
+
data['hooks'] = hooks;
|
|
166
|
+
else
|
|
167
|
+
delete data['hooks'];
|
|
168
|
+
const permissions = isRecord(data['permissions']) ? data['permissions'] : {};
|
|
169
|
+
const current = Array.isArray(permissions['additionalDirectories']) ? permissions['additionalDirectories'].filter((d) => typeof d === 'string') : [];
|
|
170
|
+
const kept = current.filter((d) => !state.claudeDirs.includes(d) || writableDirs.includes(d));
|
|
171
|
+
const dirs = [...new Set([...kept, ...writableDirs])];
|
|
172
|
+
if (dirs.length > 0)
|
|
173
|
+
permissions['additionalDirectories'] = dirs;
|
|
174
|
+
else
|
|
175
|
+
delete permissions['additionalDirectories'];
|
|
176
|
+
if (Object.keys(permissions).length > 0)
|
|
177
|
+
data['permissions'] = permissions;
|
|
178
|
+
else
|
|
179
|
+
delete data['permissions'];
|
|
180
|
+
});
|
|
181
|
+
if (didClaude)
|
|
182
|
+
changed.push(claudeSettings);
|
|
183
|
+
const codexHooks = path.join(codexHome(), 'hooks.json');
|
|
184
|
+
if (hook || exists(codexHooks)) {
|
|
185
|
+
const didCodex = editJson(codexHooks, (data) => {
|
|
186
|
+
const hooks = isRecord(data['hooks']) ? data['hooks'] : {};
|
|
187
|
+
setSessionHook(hooks, hook);
|
|
188
|
+
data['hooks'] = hooks;
|
|
189
|
+
});
|
|
190
|
+
if (didCodex)
|
|
191
|
+
changed.push(codexHooks);
|
|
192
|
+
}
|
|
193
|
+
writeState({ ...state, claudeDirs: writableDirs });
|
|
194
|
+
return changed;
|
|
195
|
+
}
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
/** An error that the CLI prints without a stack trace. */
|
|
6
|
+
/** The npm package of harnesspm. The command it installs is `harnesspm` (spec 10.1). */
|
|
7
|
+
export const PACKAGE_NAME = '@tenphi/harnesspm';
|
|
8
|
+
/** Runs harnesspm in a project that depends on it: npx finds the installed command. */
|
|
9
|
+
export const NPX_LOCAL = 'npx harnesspm';
|
|
10
|
+
/** Runs harnesspm anywhere: npx downloads the package by its full name when it is not installed. */
|
|
11
|
+
export const NPX_ANY = `npx -y ${PACKAGE_NAME}`;
|
|
12
|
+
export class HarnessError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export function exists(p) {
|
|
15
|
+
try {
|
|
16
|
+
fs.lstatSync(p);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function isFile(p) {
|
|
24
|
+
try {
|
|
25
|
+
return fs.statSync(p).isFile();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function isDir(p) {
|
|
32
|
+
try {
|
|
33
|
+
return fs.statSync(p).isDirectory();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function readText(file) {
|
|
40
|
+
return fs.readFileSync(file, 'utf8');
|
|
41
|
+
}
|
|
42
|
+
export function readTextIfExists(file) {
|
|
43
|
+
return isFile(file) ? readText(file) : undefined;
|
|
44
|
+
}
|
|
45
|
+
export function readJson(file) {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readText(file));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new HarnessError(`Cannot read ${file}: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function readJsonIfExists(file) {
|
|
54
|
+
return isFile(file) ? readJson(file) : undefined;
|
|
55
|
+
}
|
|
56
|
+
export function isRecord(value) {
|
|
57
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
export function hash(text) {
|
|
60
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
61
|
+
}
|
|
62
|
+
/** A forward-slash path, for outputs that must be the same on every machine. */
|
|
63
|
+
export function toPosix(p) {
|
|
64
|
+
return p.split(path.sep).join('/');
|
|
65
|
+
}
|
|
66
|
+
export function relPosix(from, to) {
|
|
67
|
+
return toPosix(path.relative(from, to)) || '.';
|
|
68
|
+
}
|
|
69
|
+
export function ensureDir(dir) {
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
/** Writes a file only when its content changes. Returns true when it wrote. */
|
|
73
|
+
export function writeIfChanged(file, content) {
|
|
74
|
+
if (readTextIfExists(file) === content)
|
|
75
|
+
return false;
|
|
76
|
+
ensureDir(path.dirname(file));
|
|
77
|
+
fs.writeFileSync(file, content);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/** Creates or updates a relative symbolic link. Returns true when it changed the link. */
|
|
81
|
+
export function linkIfChanged(linkPath, target) {
|
|
82
|
+
const relTarget = path.relative(path.dirname(linkPath), target) || '.';
|
|
83
|
+
try {
|
|
84
|
+
if (fs.lstatSync(linkPath).isSymbolicLink() && fs.readlinkSync(linkPath) === relTarget)
|
|
85
|
+
return false;
|
|
86
|
+
fs.rmSync(linkPath, { recursive: false, force: true });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// The link does not exist yet.
|
|
90
|
+
}
|
|
91
|
+
ensureDir(path.dirname(linkPath));
|
|
92
|
+
fs.symlinkSync(relTarget, linkPath, 'dir');
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
export function isSymlink(p) {
|
|
96
|
+
try {
|
|
97
|
+
return fs.lstatSync(p).isSymbolicLink();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function realpath(p) {
|
|
104
|
+
return fs.realpathSync(p);
|
|
105
|
+
}
|
|
106
|
+
/** Compares versions like 1.2.10 and 1.10.0 by their numeric parts. */
|
|
107
|
+
export function compareVersions(a, b) {
|
|
108
|
+
const pa = a.split(/[.+-]/);
|
|
109
|
+
const pb = b.split(/[.+-]/);
|
|
110
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
111
|
+
const x = pa[i] ?? '';
|
|
112
|
+
const y = pb[i] ?? '';
|
|
113
|
+
const nx = Number(x);
|
|
114
|
+
const ny = Number(y);
|
|
115
|
+
if (!Number.isNaN(nx) && !Number.isNaN(ny) && x !== '' && y !== '') {
|
|
116
|
+
if (nx !== ny)
|
|
117
|
+
return nx - ny;
|
|
118
|
+
}
|
|
119
|
+
else if (x !== y) {
|
|
120
|
+
return x < y ? -1 : 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
/** The file that holds this clone's git excludes, or undefined outside a git repository. */
|
|
126
|
+
export function gitExcludeFile(projectDir) {
|
|
127
|
+
const result = spawnSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: projectDir, encoding: 'utf8' });
|
|
128
|
+
if (result.status !== 0)
|
|
129
|
+
return undefined;
|
|
130
|
+
const p = result.stdout.trim();
|
|
131
|
+
return path.isAbsolute(p) ? p : path.join(projectDir, p);
|
|
132
|
+
}
|
|
133
|
+
export function listDir(dir) {
|
|
134
|
+
try {
|
|
135
|
+
return fs.readdirSync(dir).sort();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Workspaces of a monorepo (spec 3.6): package.json "workspaces" or pnpm-workspace.yaml.
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
import { isFile, isRecord, readJsonIfExists, readTextIfExists } from './util.js';
|
|
6
|
+
function patternsOf(projectDir) {
|
|
7
|
+
const pkg = readJsonIfExists(path.join(projectDir, 'package.json'));
|
|
8
|
+
const field = isRecord(pkg) ? pkg['workspaces'] : undefined;
|
|
9
|
+
const fromPackage = Array.isArray(field) ? field : isRecord(field) && Array.isArray(field['packages']) ? field['packages'] : [];
|
|
10
|
+
const pnpmText = readTextIfExists(path.join(projectDir, 'pnpm-workspace.yaml'));
|
|
11
|
+
const pnpm = pnpmText !== undefined ? parse(pnpmText) : undefined;
|
|
12
|
+
const fromPnpm = isRecord(pnpm) && Array.isArray(pnpm['packages']) ? pnpm['packages'] : [];
|
|
13
|
+
return [...fromPackage, ...fromPnpm].filter((p) => typeof p === 'string');
|
|
14
|
+
}
|
|
15
|
+
function segmentRegex(segment) {
|
|
16
|
+
return new RegExp(`^${segment.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`);
|
|
17
|
+
}
|
|
18
|
+
/** Folders that match a pattern such as `packages/*` or `apps/**`. */
|
|
19
|
+
function expand(root, pattern) {
|
|
20
|
+
const segments = pattern.replace(/^\.\//, '').replace(/\/$/, '').split('/').filter(Boolean);
|
|
21
|
+
const found = [];
|
|
22
|
+
const walk = (dir, index) => {
|
|
23
|
+
if (index === segments.length) {
|
|
24
|
+
found.push(dir);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const segment = segments[index];
|
|
28
|
+
let entries = [];
|
|
29
|
+
try {
|
|
30
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const dirs = entries.filter((e) => e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.'));
|
|
36
|
+
if (segment === '**') {
|
|
37
|
+
walk(dir, index + 1);
|
|
38
|
+
for (const entry of dirs)
|
|
39
|
+
walk(path.join(dir, entry.name), index);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const regex = segmentRegex(segment);
|
|
43
|
+
for (const entry of dirs)
|
|
44
|
+
if (regex.test(entry.name))
|
|
45
|
+
walk(path.join(dir, entry.name), index + 1);
|
|
46
|
+
};
|
|
47
|
+
walk(root, 0);
|
|
48
|
+
return found;
|
|
49
|
+
}
|
|
50
|
+
/** Absolute folders of the workspaces, sorted, without the root. Empty for a single package. */
|
|
51
|
+
export function findWorkspaces(projectDir) {
|
|
52
|
+
const patterns = patternsOf(projectDir);
|
|
53
|
+
const include = new Set();
|
|
54
|
+
for (const pattern of patterns.filter((p) => !p.startsWith('!')))
|
|
55
|
+
for (const dir of expand(projectDir, pattern))
|
|
56
|
+
include.add(dir);
|
|
57
|
+
for (const pattern of patterns.filter((p) => p.startsWith('!')))
|
|
58
|
+
for (const dir of expand(projectDir, pattern.slice(1)))
|
|
59
|
+
include.delete(dir);
|
|
60
|
+
return [...include].filter((dir) => dir !== projectDir && isFile(path.join(dir, 'package.json'))).sort();
|
|
61
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# 1. Overview
|
|
2
|
+
|
|
3
|
+
**In short**
|
|
4
|
+
|
|
5
|
+
- A library ships a `harness/` folder in its npm package. A project installs harnesspm. After every install, Claude Code and Codex see the harness of the versions that are installed.
|
|
6
|
+
- There are three layers: **dependencies**, **project** and **added** harnesses. Each layer can add units, and replace, patch or drop units of the layers below.
|
|
7
|
+
- An agent can improve any harness: a dependency's (upstream), the project's (in the repository), or a person's (in that person's own repository). The owner's own rules decide how a change is saved.
|
|
8
|
+
|
|
9
|
+
## 1.1 Problem
|
|
10
|
+
|
|
11
|
+
A library knows how it should be used. An agent that works in a project using that library does not, unless someone tells it. Today that knowledge travels by hand:
|
|
12
|
+
|
|
13
|
+
- Rules are copied between repositories and drift apart.
|
|
14
|
+
- Docs are reached through hand-made symlinks and hand-written indexes that fall behind.
|
|
15
|
+
- A library that builds on another library restates or vendors the upstream material.
|
|
16
|
+
- The same rule is written once per agent.
|
|
17
|
+
- A developer's own workflow and context live in places that cloud sessions and other projects cannot see.
|
|
18
|
+
|
|
19
|
+
[research/motivating-case.md](../research/motivating-case.md) shows these problems in a real chain of packages.
|
|
20
|
+
|
|
21
|
+
## 1.2 How it works
|
|
22
|
+
|
|
23
|
+
### Share (export)
|
|
24
|
+
|
|
25
|
+
A library adds a folder and publishes as usual.
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
my-lib/
|
|
29
|
+
├── package.json "files": [..., "harness"], "prepack": "harnesspm check"
|
|
30
|
+
└── harness/
|
|
31
|
+
├── plugin.json name, and which packages it builds on ("extends")
|
|
32
|
+
├── skills/… Agent Skills
|
|
33
|
+
└── dev.harnesspm/
|
|
34
|
+
└── rules/… Markdown rules
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Use (import)
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm i -D @tenphi/harnesspm
|
|
41
|
+
npx harnesspm init
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Every full install, such as `npm install` or `pnpm install`, then runs `harnesspm sync`. It finds the harness of each dependency, follows `extends` chains, applies the project's changes, and writes each agent's native files. The project commits the generated block in `AGENTS.md` and `harnesspm.lock.json`, so upgrades show up in pull requests.
|
|
45
|
+
|
|
46
|
+
### Add on top
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx harnesspm add github:alex/harness
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The person's workflow rules, context notes and skills now apply to every project on this machine, and project-specific entries to their projects. The project repository does not change.
|
|
53
|
+
|
|
54
|
+
### Improve
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx harnesspm why tasty:rules/state-maps
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`why` names the unit's owner and source. The agent edits the source and proposes the change the way the owner's rules say. The default is a branch and a pull request.
|
|
61
|
+
|
|
62
|
+
## 1.3 Goals
|
|
63
|
+
|
|
64
|
+
1. **One folder to share.** Standard formats. No new runtime dependency for the library.
|
|
65
|
+
2. **Harness passes through dependency chains**, and each package can change what it passes on.
|
|
66
|
+
3. **Install once, stay current**, at the installed versions.
|
|
67
|
+
4. **One source, every agent.** Native files for Claude Code and Codex first.
|
|
68
|
+
5. **Executables are first-class and safe:** scripts, hooks, MCP servers and permissions behind an explicit trust model.
|
|
69
|
+
6. **Added harnesses**, such as a team's or a person's own, that never change the project's repository and also work in Claude Code cloud sessions.
|
|
70
|
+
7. **Every harness can be improved by an agent**, under its owner's rules.
|
|
71
|
+
8. **Standards first. harnesspm uses itself.**
|
|
72
|
+
|
|
73
|
+
## 1.4 Non-goals
|
|
74
|
+
|
|
75
|
+
- **A registry.** npm, git and folders are the sources.
|
|
76
|
+
- **A new rule language.** Rules are Markdown. Skills follow Agent Skills.
|
|
77
|
+
- **The agent's own memory.** Notes that Claude Code or Codex save by themselves stay with the agent. A person's workflow and context belong in their own added harness.
|
|
78
|
+
- **Git hosting.** harnesspm does not call GitHub, GitLab or any other host. Agents use their own tools to push and open pull requests.
|
|
79
|
+
- **Sandboxing.** Agents keep their own sandboxes and approval prompts.
|
|
80
|
+
- **Codex Cloud, in v1.** Codex is supported on a person's computer. See [13.12](13-open-questions.md#1312-removed-in-review).
|
|
81
|
+
|
|
82
|
+
## 1.5 Terms
|
|
83
|
+
|
|
84
|
+
| Term | Meaning |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| **Harness** | Everything an agent needs for a codebase: rules, docs, skills, sub-agents, scripts, hooks, MCP servers, permissions. |
|
|
87
|
+
| **Harness package** | An npm package, git repository or folder that has a `harness/` folder. |
|
|
88
|
+
| **Plugin name** | The `name` in a harness's `plugin.json`. It starts every unit ID of that harness. |
|
|
89
|
+
| **Unit** | One item in a harness, such as one rule or one script. Each unit has an ID, such as `uikit:rules/forms`. |
|
|
90
|
+
| **Patch** | A Markdown file that replaces, adds or removes sections of a rule and keeps the rest. |
|
|
91
|
+
| **Layer** | Where units come from: dependencies, project or added harnesses. A higher layer can drop, replace or patch units of the layers below. |
|
|
92
|
+
| **Added harness** | A harness that a person adds on their machine, on top of the repository's, such as a team's or their own. Never committed. Several can be stacked. |
|
|
93
|
+
| **Scope** | Where an added harness applies: **user** (every repository on the machine) or **local** (one repository). |
|
|
94
|
+
| **Owner** | The harness a unit comes from: a dependency, the project, or an added harness. The owner's rules decide how its units change. |
|
|
95
|
+
| **Source** | Where something is read from: the file of a unit, or the git repository, folder or npm package of an added harness. Outputs are generated from sources. |
|
|
96
|
+
| **Project entry** | A folder in an added harness with units for one repository, matched by its git remote. |
|
|
97
|
+
| **Output** | A file or block harnesspm writes for an agent. Never edited by hand. |
|
|
98
|
+
| **Managed block** | The part of a Markdown file that harnesspm writes, between `BEGIN:harnesspm` and `END:harnesspm` markers. |
|
|
99
|
+
| **Lock** | `harnesspm.lock.json`: the committed record of harness packages and unit hashes. |
|
|
100
|
+
| **Executable unit** | A script, hook, MCP server or allow permission. Needs trust. |
|
|
101
|
+
| **Trust** | A project's consent, in `harnesspm.json`, for a package's executable units to run. |
|
|
102
|
+
| **Pending** | An executable unit that harnesspm does not write yet: its package is not trusted, a `"pin"` package has a new version, or the agent's model started the run. |
|
|
103
|
+
|
|
104
|
+
## 1.6 Base scenarios
|
|
105
|
+
|
|
106
|
+
Every feature serves at least one of these. Each must take as few steps as possible.
|
|
107
|
+
|
|
108
|
+
| # | Scenario | What the person does |
|
|
109
|
+
| --- | --- | --- |
|
|
110
|
+
| S1 | A library shares its harness | Adds `harness/` and lists it in `files`. |
|
|
111
|
+
| S2 | A library builds on upstream harness | Lists the upstream packages in `extends`, adds its own units. |
|
|
112
|
+
| S3 | A project uses the harness of its dependencies | `npm i -D @tenphi/harnesspm` and `npx harnesspm init`, once. |
|
|
113
|
+
| S4 | A project changes one upstream rule | One `patch` for a few sections, or one `replace` or `exclude` entry. |
|
|
114
|
+
| S5 | A project upgrades a dependency | Nothing after a full install. After `npm install <pkg>` or `pnpm add`, `npx harnesspm sync`. The pull request shows what changed. |
|
|
115
|
+
| S6 | Several repositories share a policy | A harness-only package, added as a dev dependency. |
|
|
116
|
+
| S7 | A monorepo | Nothing. Each workspace gets the harness of its own dependencies. |
|
|
117
|
+
| S8 | A library ships a tool for agents | A script in the harness. Projects that trust the package can run it. |
|
|
118
|
+
| S9 | A person adds a harness on top, such as their team's or their own | `npx harnesspm add <source>`, once per machine. |
|
|
119
|
+
| S10 | A person keeps workflow and context outside project repositories | Rules and notes in their own added harness, with project entries. |
|
|
120
|
+
| S11 | An agent improves a harness | Edits the source that `harnesspm why` names, then saves under the owner's rules. |
|
|
121
|
+
| S12 | A person works in a Claude Code cloud session | The same `add` in the environment's setup script, or asked of the agent. |
|
|
122
|
+
| S13 | A job reads the harness without an install, such as a review bot | `npx -y @tenphi/harnesspm fetch`. |
|
|
123
|
+
|
|
124
|
+
## 1.7 What is committed
|
|
125
|
+
|
|
126
|
+
| Committed | Per machine (excluded through `.git/info/exclude`) |
|
|
127
|
+
| --- | --- |
|
|
128
|
+
| `harnesspm.json`, `harnesspm.lock.json`, the managed blocks in `AGENTS.md`, the `@AGENTS.md` lines in `CLAUDE.md` | Everything else: links, agent settings, `.codex/`, outputs of added harnesses |
|
|
129
|
+
|
|
130
|
+
Committed files depend only on the dependency and project layers, so every teammate and CI produce the same result. harnesspm never edits `.gitignore`.
|