@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.
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
@@ -0,0 +1,453 @@
1
+ // Repository outputs (spec 4.2, 4.3, 4.6): a plan, then apply it or compare it.
2
+ import { createHash } from 'node:crypto';
3
+ import { spawnSync } from 'node:child_process';
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import { caller, harnesspmHome } from './added.js';
7
+ import { applyEntries, desiredEntries, tomlTables, trustOf } from './exec-outputs.js';
8
+ import { claudeJsonFile } from './homes.js';
9
+ import { parseFrontmatter } from './frontmatter.js';
10
+ import { buildLock, LOCK_FILE, patchDrift, readLock, serializeLock } from './lock.js';
11
+ import { alwaysOnText, extractBlock, hasClaudeImport, removeBlock, renderBlock, renderSections, sectionsFor, tomlString, upsertBlock, withClaudeImport, wrapBlock, } from './render.js';
12
+ import { headings } from './sections.js';
13
+ import { NPX_ANY, NPX_LOCAL, ensureDir, exists, gitExcludeFile, isDir, isFile, isSymlink, linkIfChanged, readText, readTextIfExists, realpath, relPosix, writeIfChanged, } from './util.js';
14
+ export const STATE_DIR = path.join('node_modules', '.harnesspm');
15
+ const TEAM_SECTION = ['# BEGIN harnesspm (generated by `harnesspm sync`; per-machine outputs)', '# END harnesspm'];
16
+ const ADDED_SECTION = ['# BEGIN harnesspm: added harnesses (generated by `harnesspm sync`; per-machine outputs)', '# END harnesspm: added harnesses'];
17
+ const CODEX_MARKER = '# Generated by `harnesspm sync` for added harnesses. Do not edit.';
18
+ function repoSlug(projectDir) {
19
+ return `${path.basename(projectDir)}-${createHash('sha256').update(projectDir).digest('hex').slice(0, 8)}`;
20
+ }
21
+ function isTracked(projectDir, rel) {
22
+ return spawnSync('git', ['ls-files', '--error-unmatch', rel], { cwd: projectDir, encoding: 'utf8' }).status === 0;
23
+ }
24
+ function docsIndexText(dir, display) {
25
+ const lines = ['# Docs', ''];
26
+ const walk = (current) => {
27
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
28
+ const full = path.join(current, entry.name);
29
+ if (entry.isDirectory())
30
+ walk(full);
31
+ else if (/\.mdx?$/.test(entry.name)) {
32
+ const { data, body } = parseFrontmatter(readText(full), full);
33
+ const first = headings(body.split('\n')).find((h) => h.level === 1);
34
+ const title = typeof data['title'] === 'string' ? data['title'] : (first?.text ?? entry.name.replace(/\.mdx?$/, ''));
35
+ const description = typeof data['description'] === 'string' ? `: ${data['description']}` : '';
36
+ lines.push(`- [${title}](${display(full)})${description}`);
37
+ }
38
+ }
39
+ };
40
+ walk(dir);
41
+ return `${lines.join('\n')}\n`;
42
+ }
43
+ const isRepoAdded = (pkg) => pkg.layer === 'added' && (pkg.scope === 'local' || pkg.entry === true);
44
+ export const isUserLevel = (pkg) => pkg.layer === 'added' && pkg.scope === 'user' && !pkg.entry;
45
+ /**
46
+ * Paths that outputs show: stable package paths for dependencies, project-relative paths inside the
47
+ * project, absolute paths elsewhere. Generated docs indexes are added to `generated`.
48
+ */
49
+ export function pathTools(res, generatedRoot, generated) {
50
+ const projectDir = res.projectDir;
51
+ const show = (abs) => (abs.startsWith(projectDir + path.sep) ? relPosix(projectDir, abs) : abs);
52
+ const display = (file) => {
53
+ let best;
54
+ for (const pkg of res.packages) {
55
+ if (pkg.layer !== 'dependencies')
56
+ continue;
57
+ if ((file === pkg.dir || file.startsWith(pkg.dir + path.sep)) && (!best || pkg.dir.length > best.dir.length))
58
+ best = pkg;
59
+ }
60
+ if (best)
61
+ return relPosix(projectDir, path.join(projectDir, STATE_DIR, 'pkgs', best.name, path.relative(best.dir, file)));
62
+ return show(file);
63
+ };
64
+ const generatedRule = (rule) => path.join(generatedRoot(rule.pkg), 'rules', rule.pkg.plugin, `${rule.name}.md`);
65
+ const paths = {
66
+ rule: (rule) => (rule.patches.length > 0 ? show(generatedRule(rule)) : display(rule.source)),
67
+ docsIndex: (pkg) => {
68
+ const docs = res.docs.find((d) => d.pkg === pkg);
69
+ if (!docs)
70
+ return undefined;
71
+ const llms = path.join(docs.dir, 'llms.txt');
72
+ if (isFile(llms))
73
+ return display(llms);
74
+ const file = path.join(generatedRoot(pkg), 'docs', pkg.plugin, 'llms.txt');
75
+ generated.set(file, docsIndexText(docs.dir, display));
76
+ return show(file);
77
+ },
78
+ };
79
+ return { show, display, generatedRule, paths };
80
+ }
81
+ /** The changes that added harnesses make to the repository's rules (spec 4.3, note 4). */
82
+ function changesSection(team, full, rulePath) {
83
+ const added = new Set(full.packages.filter((p) => p.layer === 'added').map((p) => p.name));
84
+ const lines = [];
85
+ const texts = [];
86
+ for (const rule of team.rules) {
87
+ const changed = full.rules.find((r) => r.id === rule.id);
88
+ if (!changed) {
89
+ const by = full.excludedUnits.find((u) => u.id === rule.id)?.by;
90
+ if (by !== undefined && added.has(by))
91
+ lines.push(`- Ignore the rule ${rule.id} in AGENTS.md: ${by} turns it off on this machine.`);
92
+ continue;
93
+ }
94
+ if (changed.text === rule.text)
95
+ continue;
96
+ const by = [changed.replacedBy?.by, ...changed.patches.map((p) => p.by)].filter((b) => b !== undefined && added.has(b));
97
+ const who = [...new Set(by)].join(', ');
98
+ if (changed.activation === 'always')
99
+ texts.push(alwaysOnText(changed, `${rule.id}, changed by ${who}; use this text instead of the one in AGENTS.md`));
100
+ else
101
+ lines.push(`- Instead of the rule ${rule.id} in AGENTS.md, read ${rulePath(changed)} (changed by ${who})`);
102
+ }
103
+ if (lines.length === 0 && texts.length === 0)
104
+ return undefined;
105
+ return ["## Changes to this repository's rules", ...texts, ...(lines.length > 0 ? [lines.join('\n')] : [])].join('\n\n');
106
+ }
107
+ /**
108
+ * `team` is the dependency and project layers alone, for committed outputs. `full` adds the added
109
+ * harnesses, for per-machine outputs. Without `team`, only the outputs of added harnesses change.
110
+ */
111
+ export function computePlan(team, full) {
112
+ const projectDir = full.projectDir;
113
+ const warnings = [];
114
+ const generated = new Map();
115
+ const links = new Map();
116
+ const teamExcludes = new Set([`/${STATE_DIR.split(path.sep).join('/')}/`]);
117
+ const addedExcludes = new Set();
118
+ const teamGen = path.join(projectDir, STATE_DIR);
119
+ const addedGen = path.join(harnesspmHome(), 'generated', repoSlug(projectDir));
120
+ const tools = pathTools(full, (pkg) => (pkg.layer === 'added' ? addedGen : teamGen), generated);
121
+ if (team)
122
+ for (const pkg of full.packages)
123
+ if (pkg.layer === 'dependencies')
124
+ links.set(path.join(projectDir, STATE_DIR, 'pkgs', pkg.name), pkg.dir);
125
+ const stable = (file) => path.resolve(projectDir, tools.display(file));
126
+ const seenRoots = new Set();
127
+ const roots = [path.join(projectDir, '.claude', 'skills'), path.join(projectDir, '.agents', 'skills')].filter((root) => {
128
+ const real = isDir(root) ? realpath(root) : root;
129
+ if (seenRoots.has(real))
130
+ return false;
131
+ seenRoots.add(real);
132
+ return true;
133
+ });
134
+ const addLink = (linkPath, target, into) => {
135
+ if (exists(linkPath) && !isSymlink(linkPath)) {
136
+ warnings.push(`${relPosix(projectDir, linkPath)} exists and is not a harnesspm link; left as it is`);
137
+ return;
138
+ }
139
+ links.set(linkPath, target);
140
+ into.add(`/${relPosix(projectDir, linkPath)}`);
141
+ };
142
+ for (const skill of full.skills) {
143
+ if (isUserLevel(skill.pkg))
144
+ continue;
145
+ const added = skill.pkg.layer === 'added';
146
+ if (!added && !team)
147
+ continue;
148
+ for (const root of roots)
149
+ addLink(path.join(root, skill.name), added ? skill.dir : stable(skill.dir), added ? addedExcludes : teamExcludes);
150
+ }
151
+ // Generated rule files: patched rules that agents read from a file, with this machine's text.
152
+ for (const rule of full.rules) {
153
+ if (rule.activation === 'always' || isUserLevel(rule.pkg))
154
+ continue;
155
+ if (rule.pkg.layer !== 'added' && !team)
156
+ continue;
157
+ const teamPatched = (team?.rules.find((r) => r.id === rule.id)?.patches.length ?? 0) > 0;
158
+ if (rule.patches.length > 0 || teamPatched)
159
+ generated.set(tools.generatedRule(rule), rule.text);
160
+ }
161
+ // File-scoped team rules also get a native Claude Code rule (spec 4.3, note 3).
162
+ if (team) {
163
+ for (const rule of full.rules.filter((r) => r.activation === 'file' && r.pkg.layer !== 'added')) {
164
+ const changed = rule.text !== readText(rule.original);
165
+ const target = changed ? tools.generatedRule(rule) : stable(rule.source);
166
+ if (changed)
167
+ generated.set(target, rule.text);
168
+ addLink(path.join(projectDir, '.claude', 'rules', 'harnesspm', rule.pkg.plugin, `${rule.name}.md`), target, teamExcludes);
169
+ }
170
+ }
171
+ let committed;
172
+ let lock = readLock(projectDir);
173
+ if (team) {
174
+ const previous = lock;
175
+ lock = buildLock(team, previous);
176
+ warnings.push(...patchDrift(previous, lock));
177
+ const teamGenerated = new Map();
178
+ const teamLock = lock;
179
+ const teamTools = pathTools(team, () => teamGen, teamGenerated);
180
+ const prefix = (pkg) => (trustOf(pkg, team, teamLock).trusted ? NPX_LOCAL : undefined);
181
+ // The root block holds what the root reaches and what every workspace shares (spec 3.6, 4.5).
182
+ const keys = team.workspaces.map((ws) => ws.key);
183
+ const shared = (pkg) => pkg.members.has('') || (keys.length > 0 && keys.every((k) => pkg.members.has(k)));
184
+ const rootContent = renderSections(sectionsFor(team, team.packages.filter(shared), teamTools.paths, prefix), teamTools.paths);
185
+ const block = wrapBlock(rootContent !== '' ? rootContent : 'No harness units yet.');
186
+ const workspaces = [];
187
+ for (const ws of team.workspaces) {
188
+ // Paths in a workspace's block are relative to its folder.
189
+ const toWs = (p) => (path.isAbsolute(p) ? p : relPosix(ws.dir, path.join(projectDir, p)));
190
+ const wsPaths = {
191
+ rule: (rule) => toWs(teamTools.paths.rule(rule)),
192
+ docsIndex: (pkg) => {
193
+ const index = teamTools.paths.docsIndex(pkg);
194
+ return index === undefined ? undefined : toWs(index);
195
+ },
196
+ };
197
+ const content = renderSections(sectionsFor(team, team.packages.filter((p) => !shared(p) && p.members.has(ws.key)), wsPaths, prefix), wsPaths);
198
+ const agentsFile = path.join(ws.dir, 'AGENTS.md');
199
+ const existing = readTextIfExists(agentsFile);
200
+ const agentsMd = content !== '' ? upsertBlock(existing, wrapBlock(content)) : extractBlock(existing) !== undefined ? removeBlock(existing) : existing;
201
+ const claudeFile = path.join(ws.dir, 'CLAUDE.md');
202
+ const claudeMd = content !== '' ? withClaudeImport(readTextIfExists(claudeFile)) : readTextIfExists(claudeFile);
203
+ workspaces.push({ dir: ws.dir, agentsMd, claudeMd });
204
+ }
205
+ for (const [file, content] of teamGenerated)
206
+ if (!generated.has(file))
207
+ generated.set(file, content);
208
+ committed = {
209
+ agentsMd: upsertBlock(readTextIfExists(path.join(projectDir, 'AGENTS.md')), block),
210
+ claudeMd: withClaudeImport(readTextIfExists(path.join(projectDir, 'CLAUDE.md'))),
211
+ lock: serializeLock(lock),
212
+ workspaces,
213
+ };
214
+ }
215
+ // Added harnesses with local scope, and every harness's entry for this repository (spec 4.3).
216
+ let content = renderSections(sectionsFor(full, full.packages.filter(isRepoAdded), tools.paths, () => NPX_ANY), tools.paths);
217
+ const changes = team ? changesSection(team, full, (rule) => tools.paths.rule(rule)) : undefined;
218
+ if (changes !== undefined)
219
+ content = content !== '' ? `${content}\n\n${changes}` : changes;
220
+ const localFile = path.join(projectDir, 'CLAUDE.local.md');
221
+ let localMd;
222
+ if (content !== '' && isTracked(projectDir, 'CLAUDE.local.md')) {
223
+ warnings.push('CLAUDE.local.md is tracked by git, so harnesspm does not write added harnesses into it');
224
+ localMd = readTextIfExists(localFile);
225
+ }
226
+ else if (content !== '') {
227
+ // CLAUDE.local.md counts as a CLAUDE.md: without one, Claude Code would stop reading AGENTS.md (spec 4.3, note 1).
228
+ const needsImport = !isFile(path.join(projectDir, 'CLAUDE.md')) && isFile(path.join(projectDir, 'AGENTS.md'));
229
+ localMd = upsertBlock(readTextIfExists(localFile), wrapBlock(content));
230
+ if (needsImport && !hasClaudeImport(localMd))
231
+ localMd = `@AGENTS.md\n\n${localMd}`;
232
+ addedExcludes.add('/CLAUDE.local.md');
233
+ }
234
+ else {
235
+ // Remove only harnesspm's own block. A file without one stays as it is.
236
+ const existing = readTextIfExists(localFile);
237
+ localMd = extractBlock(existing) === undefined ? existing : removeBlock(existing, /^\s*(@AGENTS\.md)?\s*$/);
238
+ }
239
+ // Codex files are per machine unless the repository commits its own .codex/ (spec 4.3, note 2).
240
+ const codexFile = path.join(projectDir, '.codex', 'config.toml');
241
+ const codexTracked = ['.codex/config.toml', '.codex/hooks.json', '.codex/rules'].some((rel) => isTracked(projectDir, rel));
242
+ const codexOurs = !codexTracked && (readTextIfExists(codexFile) ?? CODEX_MARKER).startsWith(CODEX_MARKER);
243
+ if (!codexOurs)
244
+ warnings.push('.codex/ belongs to the repository, so harnesspm writes nothing there and Codex misses some units. See `harnesspm status`.');
245
+ const claudeOurs = !isTracked(projectDir, '.claude/settings.local.json');
246
+ if (!claudeOurs)
247
+ warnings.push('.claude/settings.local.json is tracked by git, so harnesspm writes no hooks or permissions there');
248
+ // Executable units and sub-agents (spec 4.3, chapter 5), with trust (spec 6.2).
249
+ const trust = (pkg) => trustOf(pkg, full, lock);
250
+ const agentDirs = { claudeAgents: path.join(projectDir, '.claude', 'agents'), codexAgents: path.join(projectDir, '.codex', 'agents') };
251
+ const teamExec = team ? desiredEntries(full, new Set(full.packages.filter((p) => p.layer !== 'added')), trust, { ...agentDirs, npx: () => NPX_LOCAL }) : undefined;
252
+ const addedExec = desiredEntries(full, new Set(full.packages.filter(isRepoAdded)), trust, { ...agentDirs, npx: () => NPX_ANY });
253
+ warnings.push(...(teamExec?.warnings ?? []), ...addedExec.warnings);
254
+ return {
255
+ ...(committed ? { committed } : {}),
256
+ generated,
257
+ generatedRoots: [...(team ? [path.join(teamGen, 'rules'), path.join(teamGen, 'docs')] : []), addedGen],
258
+ links,
259
+ ...(team ? { teamExcludes: [...teamExcludes].sort() } : {}),
260
+ addedExcludes: [...addedExcludes].sort(),
261
+ localMd,
262
+ codexInstructions: content,
263
+ codexOurs,
264
+ claudeOurs,
265
+ exec: { ...(teamExec ? { team: teamExec } : {}), added: addedExec },
266
+ warnings,
267
+ };
268
+ }
269
+ function sectionRange(lines, marks) {
270
+ const start = lines.indexOf(marks[0]);
271
+ if (start < 0)
272
+ return undefined;
273
+ const end = lines.indexOf(marks[1], start);
274
+ return end < 0 ? undefined : [start, end];
275
+ }
276
+ function readSection(text, marks) {
277
+ const lines = (text ?? '').split('\n');
278
+ const range = sectionRange(lines, marks);
279
+ return range ? lines.slice(range[0] + 1, range[1]).filter((l) => l.startsWith('/')) : [];
280
+ }
281
+ function writeSection(text, marks, entries) {
282
+ const lines = (text ?? '').split('\n');
283
+ const range = sectionRange(lines, marks);
284
+ const section = entries.length > 0 ? [marks[0], ...entries, marks[1]] : [];
285
+ if (range)
286
+ lines.splice(range[0], range[1] - range[0] + 1, ...section);
287
+ else if (section.length > 0) {
288
+ while (lines.length > 0 && lines[lines.length - 1] === '')
289
+ lines.pop();
290
+ if (lines.length > 0)
291
+ lines.push('');
292
+ lines.push(...section);
293
+ }
294
+ while (lines.length > 0 && lines[lines.length - 1] === '')
295
+ lines.pop();
296
+ return lines.length > 0 ? `${lines.join('\n')}\n` : '';
297
+ }
298
+ function unlinkAndPrune(target, stopAt) {
299
+ fs.unlinkSync(target);
300
+ for (let dir = path.dirname(target); dir.startsWith(stopAt + path.sep); dir = path.dirname(dir)) {
301
+ try {
302
+ fs.rmdirSync(dir);
303
+ }
304
+ catch {
305
+ break;
306
+ }
307
+ }
308
+ }
309
+ export function repoStateFile(projectDir) {
310
+ const result = spawnSync('git', ['rev-parse', '--git-path', 'harnesspm/state.json'], { cwd: projectDir, encoding: 'utf8' });
311
+ const p = result.status === 0 ? result.stdout.trim() : path.join('.git', 'harnesspm', 'state.json');
312
+ return path.isAbsolute(p) ? p : path.join(projectDir, p);
313
+ }
314
+ export function repoTargets(projectDir, plan) {
315
+ return {
316
+ claudeSettings: path.join(projectDir, '.claude', 'settings.local.json'),
317
+ claudeJson: claudeJsonFile(),
318
+ claudeProject: projectDir,
319
+ codexHooks: path.join(projectDir, '.codex', 'hooks.json'),
320
+ codexRules: path.join(projectDir, '.codex', 'rules', 'harnesspm.rules'),
321
+ stateFile: repoStateFile(projectDir),
322
+ writeClaude: plan.claudeOurs,
323
+ writeCodex: plan.codexOurs,
324
+ };
325
+ }
326
+ /** Writes the plan. Returns the project-relative paths that changed and the entries held back. */
327
+ export function applyPlan(projectDir, plan) {
328
+ const changed = [];
329
+ const held = [];
330
+ const note = (file, did) => {
331
+ if (did)
332
+ changed.push(relPosix(projectDir, file));
333
+ };
334
+ for (const root of plan.generatedRoots)
335
+ fs.rmSync(root, { recursive: true, force: true });
336
+ for (const [file, content] of plan.generated)
337
+ writeIfChanged(file, content);
338
+ for (const [linkPath, target] of plan.links)
339
+ note(linkPath, linkIfChanged(linkPath, target));
340
+ // Executable entries in shared files, before the excludes so that every written file is listed.
341
+ const widen = caller() !== 'model';
342
+ const targets = repoTargets(projectDir, plan);
343
+ const teamExcludes = plan.teamExcludes ? [...plan.teamExcludes] : undefined;
344
+ const addedExcludes = [...plan.addedExcludes];
345
+ let codexMcp = {};
346
+ for (const [group, entries, into] of [['team', plan.exec.team, teamExcludes], ['added', plan.exec.added, addedExcludes]]) {
347
+ if (!entries)
348
+ continue;
349
+ const applied = applyEntries(group, entries, targets, widen);
350
+ for (const file of applied.changed)
351
+ changed.push(file.startsWith(projectDir + path.sep) ? relPosix(projectDir, file) : file);
352
+ held.push(...applied.held);
353
+ codexMcp = applied.codexMcp;
354
+ for (const file of applied.files)
355
+ if (file.startsWith(projectDir + path.sep))
356
+ into?.push(`/${relPosix(projectDir, file)}`);
357
+ }
358
+ // .codex/config.toml: developer_instructions and MCP servers.
359
+ const codexFile = path.join(projectDir, '.codex', 'config.toml');
360
+ if (plan.codexOurs) {
361
+ const parts = [
362
+ ...(plan.codexInstructions !== '' ? [`developer_instructions = ${tomlString(plan.codexInstructions)}`] : []),
363
+ ...(Object.keys(codexMcp).length > 0 ? [tomlTables('mcp_servers', codexMcp)] : []),
364
+ ];
365
+ if (parts.length > 0) {
366
+ note(codexFile, writeIfChanged(codexFile, `${CODEX_MARKER}\n${parts.join('\n\n')}\n`));
367
+ addedExcludes.push('/.codex/config.toml');
368
+ }
369
+ else if (isFile(codexFile)) {
370
+ fs.rmSync(codexFile);
371
+ changed.push('.codex/config.toml');
372
+ }
373
+ }
374
+ for (const dir of [path.join(projectDir, '.codex', 'rules'), path.join(projectDir, '.codex', 'agents'), path.join(projectDir, '.codex'), path.join(projectDir, '.claude', 'agents')]) {
375
+ try {
376
+ fs.rmdirSync(dir);
377
+ }
378
+ catch {
379
+ // Not empty, or not there.
380
+ }
381
+ }
382
+ const excludeFile = gitExcludeFile(projectDir);
383
+ if (excludeFile) {
384
+ const before = readTextIfExists(excludeFile);
385
+ const prevTeam = readSection(before, TEAM_SECTION);
386
+ const prevAdded = readSection(before, ADDED_SECTION);
387
+ const keep = new Set([...(teamExcludes ?? prevTeam), ...addedExcludes]);
388
+ // Remove links from the last sync that this sync no longer writes.
389
+ for (const entry of new Set([...prevTeam, ...prevAdded])) {
390
+ const target = path.join(projectDir, entry);
391
+ if (keep.has(entry) || !isSymlink(target))
392
+ continue;
393
+ unlinkAndPrune(target, projectDir);
394
+ changed.push(entry.slice(1));
395
+ }
396
+ let text = before;
397
+ const sorted = (list) => [...new Set(list)].sort();
398
+ if (teamExcludes)
399
+ text = writeSection(text, TEAM_SECTION, sorted(teamExcludes));
400
+ text = writeSection(text, ADDED_SECTION, sorted(addedExcludes));
401
+ ensureDir(path.dirname(excludeFile));
402
+ writeIfChanged(excludeFile, text);
403
+ }
404
+ if (plan.committed) {
405
+ note(path.join(projectDir, 'AGENTS.md'), writeIfChanged(path.join(projectDir, 'AGENTS.md'), plan.committed.agentsMd));
406
+ note(path.join(projectDir, 'CLAUDE.md'), writeIfChanged(path.join(projectDir, 'CLAUDE.md'), plan.committed.claudeMd));
407
+ note(path.join(projectDir, LOCK_FILE), writeIfChanged(path.join(projectDir, LOCK_FILE), plan.committed.lock));
408
+ for (const ws of plan.committed.workspaces) {
409
+ const agentsFile = path.join(ws.dir, 'AGENTS.md');
410
+ if (ws.agentsMd !== undefined)
411
+ note(agentsFile, writeIfChanged(agentsFile, ws.agentsMd));
412
+ else if (isFile(agentsFile)) {
413
+ fs.rmSync(agentsFile);
414
+ changed.push(relPosix(projectDir, agentsFile));
415
+ }
416
+ if (ws.claudeMd !== undefined)
417
+ note(path.join(ws.dir, 'CLAUDE.md'), writeIfChanged(path.join(ws.dir, 'CLAUDE.md'), ws.claudeMd));
418
+ }
419
+ }
420
+ const localFile = path.join(projectDir, 'CLAUDE.local.md');
421
+ if (plan.localMd !== undefined)
422
+ note(localFile, writeIfChanged(localFile, plan.localMd));
423
+ else if (isFile(localFile)) {
424
+ fs.rmSync(localFile);
425
+ changed.push('CLAUDE.local.md');
426
+ }
427
+ return { changed, held };
428
+ }
429
+ /** Compares the committed outputs with the plan (spec 6.4). Returns the differences. */
430
+ export function checkPlan(projectDir, plan) {
431
+ if (!plan.committed)
432
+ return ['the dependencies are not installed, so the committed outputs cannot be checked'];
433
+ const problems = [];
434
+ const agents = readTextIfExists(path.join(projectDir, 'AGENTS.md'));
435
+ if (extractBlock(agents) !== extractBlock(plan.committed.agentsMd))
436
+ problems.push('AGENTS.md: the harnesspm block is out of date');
437
+ if (!hasClaudeImport(readTextIfExists(path.join(projectDir, 'CLAUDE.md'))))
438
+ problems.push('CLAUDE.md: the @AGENTS.md line is missing');
439
+ if (readTextIfExists(path.join(projectDir, LOCK_FILE)) !== plan.committed.lock)
440
+ problems.push(`${LOCK_FILE} is out of date`);
441
+ for (const ws of plan.committed.workspaces) {
442
+ const rel = relPosix(projectDir, path.join(ws.dir, 'AGENTS.md'));
443
+ if (extractBlock(readTextIfExists(path.join(ws.dir, 'AGENTS.md'))) !== extractBlock(ws.agentsMd))
444
+ problems.push(`${rel}: the harnesspm block is out of date`);
445
+ }
446
+ const lock = JSON.parse(plan.committed.lock);
447
+ for (const [name, entry] of Object.entries(lock.packages)) {
448
+ if (entry.approved !== undefined && entry.approved !== entry.version) {
449
+ problems.push(`${name} ${entry.version} is pinned and not approved (approved: ${entry.approved}). Run \`npx harnesspm approve ${name}\`.`);
450
+ }
451
+ }
452
+ return problems;
453
+ }
package/dist/render.js ADDED
@@ -0,0 +1,119 @@
1
+ // Managed blocks (spec 4.6): AGENTS.md for the team, CLAUDE.local.md and user-level files for added harnesses.
2
+ import { demoteHeadings, headings } from './sections.js';
3
+ export const BLOCK_BEGIN = '<!-- BEGIN:harnesspm — generated by `harnesspm sync`. Do not edit. To change a rule, run `npx harnesspm why <ID>`. -->';
4
+ export const BLOCK_END = '<!-- END:harnesspm -->';
5
+ const BEGIN_PREFIX = '<!-- BEGIN:harnesspm';
6
+ function withoutTitle(body) {
7
+ const lines = body.split('\n');
8
+ const first = headings(lines)[0];
9
+ if (first && first.level === 1)
10
+ lines.splice(first.line, 1);
11
+ return lines.join('\n').trim();
12
+ }
13
+ export function packageHeading(pkg) {
14
+ if (pkg.layer === 'project')
15
+ return `## ${pkg.name} (this project)`;
16
+ if (pkg.layer === 'added') {
17
+ const spec = pkg.added?.source.spec ?? pkg.name;
18
+ if (pkg.entry)
19
+ return `## ${pkg.plugin}: entry for this repository (${spec})`;
20
+ return `## ${pkg.plugin} (${spec}, ${pkg.scope} scope)`;
21
+ }
22
+ return `## ${pkg.name} ${pkg.version}${pkg.via !== undefined ? ` (via ${pkg.via})` : ''}`;
23
+ }
24
+ export function alwaysOnText(rule, label = rule.id) {
25
+ const body = demoteHeadings(withoutTitle(rule.body), 2);
26
+ return `### ${rule.title} (${label})${body !== '' ? `\n\n${body}` : ''}`;
27
+ }
28
+ export function pointerLine(rule, file) {
29
+ if (rule.activation === 'file') {
30
+ const globs = (rule.paths ?? []).map((p) => `\`${p}\``).join(', ');
31
+ const description = rule.description !== undefined ? ` — ${rule.description}` : '';
32
+ return `- When editing ${globs}, read ${file}${description} (${rule.id})`;
33
+ }
34
+ return `- ${rule.description}: read ${file} (${rule.id})`;
35
+ }
36
+ export function renderSections(sections, paths) {
37
+ const out = [];
38
+ for (const section of sections) {
39
+ const parts = [section.heading, ...(section.intro ?? [])];
40
+ for (const rule of section.rules.filter((r) => r.activation === 'always'))
41
+ parts.push(alwaysOnText(rule));
42
+ const pointers = section.rules.filter((r) => r.activation !== 'always').map((r) => pointerLine(r, paths.rule(r)));
43
+ if (section.docsIndex)
44
+ pointers.push(`- Docs: ${section.docsIndex.path} (${section.docsIndex.id})`);
45
+ pointers.push(...(section.scripts ?? []));
46
+ if (pointers.length > 0)
47
+ parts.push(pointers.join('\n'));
48
+ if (parts.length > 1)
49
+ out.push(parts.join('\n\n'));
50
+ }
51
+ return out.join('\n\n');
52
+ }
53
+ /** `runPrefix` returns the command prefix for a trusted package's scripts, or undefined to list none. */
54
+ export function sectionsFor(res, packages, paths, runPrefix = () => undefined) {
55
+ return packages.map((pkg) => {
56
+ const index = paths.docsIndex(pkg);
57
+ const prefix = runPrefix(pkg);
58
+ const scripts = prefix === undefined
59
+ ? []
60
+ : res.scripts.filter((s) => s.pkg === pkg && s.description !== undefined).map((s) => `- Script: \`${prefix} run ${pkg.plugin}:${s.name}\` — ${s.description} (${s.id})`);
61
+ return {
62
+ heading: packageHeading(pkg),
63
+ rules: res.rules.filter((r) => r.pkg === pkg),
64
+ ...(index !== undefined ? { docsIndex: { path: index, id: `${pkg.plugin}:docs` } } : {}),
65
+ ...(scripts.length > 0 ? { scripts } : {}),
66
+ };
67
+ });
68
+ }
69
+ export function wrapBlock(content) {
70
+ return `${BLOCK_BEGIN}\n${content}\n${BLOCK_END}`;
71
+ }
72
+ /** The committed block: the dependency and project layers. */
73
+ export function renderBlock(res, paths, runPrefix) {
74
+ const content = renderSections(sectionsFor(res, res.packages, paths, runPrefix), paths);
75
+ return wrapBlock(content !== '' ? content : 'No harness units yet.');
76
+ }
77
+ /** Puts the block into a Markdown file. Text outside the block never changes. */
78
+ export function upsertBlock(existing, block) {
79
+ if (existing === undefined || existing.trim() === '')
80
+ return `${block}\n`;
81
+ const start = existing.indexOf(BEGIN_PREFIX);
82
+ const end = existing.indexOf(BLOCK_END, start);
83
+ if (start >= 0 && end > start)
84
+ return existing.slice(0, start) + block + existing.slice(end + BLOCK_END.length);
85
+ return `${existing.replace(/\s+$/, '')}\n\n${block}\n`;
86
+ }
87
+ /** Removes the block. Returns undefined when nothing else is left in the file. */
88
+ export function removeBlock(existing, leftover = /^\s*$/) {
89
+ if (existing === undefined)
90
+ return undefined;
91
+ const start = existing.indexOf(BEGIN_PREFIX);
92
+ const end = existing.indexOf(BLOCK_END, start);
93
+ const rest = start >= 0 && end > start ? existing.slice(0, start).replace(/\s+$/, '') + existing.slice(end + BLOCK_END.length) : existing;
94
+ if (leftover.test(rest))
95
+ return undefined;
96
+ return `${rest.replace(/\s+$/, '')}\n`;
97
+ }
98
+ export function extractBlock(text) {
99
+ if (text === undefined)
100
+ return undefined;
101
+ const start = text.indexOf(BEGIN_PREFIX);
102
+ const end = text.indexOf(BLOCK_END, start);
103
+ return start >= 0 && end > start ? text.slice(start, end + BLOCK_END.length) : undefined;
104
+ }
105
+ export const CLAUDE_IMPORT = '@AGENTS.md';
106
+ export function hasClaudeImport(text) {
107
+ return text !== undefined && /^@AGENTS\.md[ \t]*$/m.test(text);
108
+ }
109
+ export function withClaudeImport(existing) {
110
+ if (existing === undefined || existing.trim() === '')
111
+ return `${CLAUDE_IMPORT}\n`;
112
+ if (hasClaudeImport(existing))
113
+ return existing;
114
+ return `${CLAUDE_IMPORT}\n\n${existing}`;
115
+ }
116
+ /** TOML basic multi-line string. */
117
+ export function tomlString(text) {
118
+ return `"""\n${text.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"')}\n"""`;
119
+ }