harnery 0.6.0 → 0.7.1

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 (137) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +19 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +2 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +51 -4
  7. package/dist/commands/deinit.d.ts.map +1 -1
  8. package/dist/commands/deinit.js +4 -0
  9. package/dist/commands/devtools.d.ts +4 -0
  10. package/dist/commands/devtools.d.ts.map +1 -0
  11. package/dist/commands/devtools.js +239 -0
  12. package/dist/commands/docs.d.ts.map +1 -1
  13. package/dist/commands/docs.js +69 -1
  14. package/dist/commands/doctor.js +12 -4
  15. package/dist/commands/env.d.ts.map +1 -1
  16. package/dist/commands/env.js +3 -63
  17. package/dist/commands/init.d.ts +1 -0
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +54 -14
  20. package/dist/commands/scratch.js +1 -1
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +273 -62
  23. package/dist/commands/web-fetch.js +1 -1
  24. package/dist/core/agents/cli.js +48 -0
  25. package/dist/core/agents/coord-client.d.ts.map +1 -1
  26. package/dist/core/agents/coord-client.js +32 -8
  27. package/dist/core/agents/events/emit.d.ts.map +1 -1
  28. package/dist/core/agents/events/emit.js +4 -0
  29. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  30. package/dist/core/agents/rules/claim-conflict.js +16 -5
  31. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  32. package/dist/core/agents/state/heartbeat-projector.js +10 -3
  33. package/dist/core/config.d.ts +10 -0
  34. package/dist/core/config.d.ts.map +1 -1
  35. package/dist/core/config.js +13 -0
  36. package/dist/core/hooks/cli.js +3 -3
  37. package/dist/core/hooks/effects/index.d.ts +11 -7
  38. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  39. package/dist/core/hooks/effects/index.js +15 -18
  40. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  41. package/dist/core/hooks/events/emit.js +4 -0
  42. package/dist/core/hooks/events/rotate.d.ts +43 -0
  43. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  44. package/dist/core/hooks/events/rotate.js +142 -0
  45. package/dist/core/hooks/harness/events.d.ts +11 -1
  46. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  47. package/dist/core/hooks/harness/events.js +22 -3
  48. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  49. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  50. package/dist/core/hooks/harness/wiring.js +34 -5
  51. package/dist/core/scratch/index.d.ts.map +1 -0
  52. package/dist/{lib → core}/scratch/index.js +2 -2
  53. package/dist/lib/devtools.d.ts +178 -0
  54. package/dist/lib/devtools.d.ts.map +1 -0
  55. package/dist/lib/devtools.js +1328 -0
  56. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  57. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  58. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  59. package/dist/lib/docs-frontmatter.d.ts +33 -0
  60. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  61. package/dist/lib/docs-frontmatter.js +130 -0
  62. package/dist/lib/docs-index.d.ts +1 -0
  63. package/dist/lib/docs-index.d.ts.map +1 -1
  64. package/dist/lib/docs-index.js +4 -5
  65. package/dist/lib/docs-lint.d.ts +2 -0
  66. package/dist/lib/docs-lint.d.ts.map +1 -1
  67. package/dist/lib/docs-lint.js +18 -12
  68. package/dist/lib/docs-meta.d.ts +14 -0
  69. package/dist/lib/docs-meta.d.ts.map +1 -0
  70. package/dist/lib/docs-meta.js +34 -0
  71. package/dist/lib/docs-sweep.d.ts +12 -0
  72. package/dist/lib/docs-sweep.d.ts.map +1 -1
  73. package/dist/lib/docs-sweep.js +98 -103
  74. package/dist/lib/format.js +2 -2
  75. package/dist/lib/http/index.d.ts +1 -0
  76. package/dist/lib/http/index.d.ts.map +1 -1
  77. package/dist/lib/http/index.js +1 -0
  78. package/dist/lib/http/request.d.ts +77 -0
  79. package/dist/lib/http/request.d.ts.map +1 -0
  80. package/dist/lib/http/request.js +105 -0
  81. package/dist/lib/instructions/apply.d.ts +63 -0
  82. package/dist/lib/instructions/apply.d.ts.map +1 -0
  83. package/dist/lib/instructions/apply.js +255 -0
  84. package/dist/lib/instructions/splice.d.ts +73 -0
  85. package/dist/lib/instructions/splice.d.ts.map +1 -0
  86. package/dist/lib/instructions/splice.js +118 -0
  87. package/dist/lib/instructions/templates.d.ts +45 -0
  88. package/dist/lib/instructions/templates.d.ts.map +1 -0
  89. package/dist/lib/instructions/templates.js +258 -0
  90. package/dist/lib/tunnel/gate.d.ts +1 -0
  91. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  92. package/dist/lib/tunnel/gate.js +14 -9
  93. package/dist/lib/tunnel/state.d.ts +11 -1
  94. package/dist/lib/tunnel/state.d.ts.map +1 -1
  95. package/dist/lib/tunnel/state.js +8 -3
  96. package/package.json +7 -6
  97. package/src/commander.ts +23 -0
  98. package/src/commands/agents.ts +50 -3
  99. package/src/commands/deinit.ts +5 -0
  100. package/src/commands/devtools.ts +284 -0
  101. package/src/commands/docs.ts +81 -1
  102. package/src/commands/doctor.ts +13 -4
  103. package/src/commands/env.ts +11 -77
  104. package/src/commands/init.ts +66 -15
  105. package/src/commands/scratch.ts +1 -1
  106. package/src/commands/tunnel.ts +316 -65
  107. package/src/commands/web-fetch.ts +1 -1
  108. package/src/core/agents/cli.ts +55 -0
  109. package/src/core/agents/coord-client.ts +34 -7
  110. package/src/core/agents/events/emit.ts +5 -0
  111. package/src/core/agents/rules/claim-conflict.ts +17 -6
  112. package/src/core/agents/state/heartbeat-projector.ts +11 -3
  113. package/src/core/config.ts +14 -0
  114. package/src/core/hooks/cli.ts +3 -3
  115. package/src/core/hooks/effects/index.ts +23 -17
  116. package/src/core/hooks/events/emit.ts +5 -0
  117. package/src/core/hooks/events/rotate.ts +151 -0
  118. package/src/core/hooks/harness/events.ts +30 -3
  119. package/src/core/hooks/harness/wiring.ts +46 -5
  120. package/src/{lib → core}/scratch/index.ts +2 -2
  121. package/src/lib/devtools.ts +1653 -0
  122. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  123. package/src/lib/docs-frontmatter.ts +151 -0
  124. package/src/lib/docs-index.ts +4 -5
  125. package/src/lib/docs-lint.ts +17 -11
  126. package/src/lib/docs-meta.ts +44 -0
  127. package/src/lib/docs-sweep.ts +104 -102
  128. package/src/lib/format.ts +2 -2
  129. package/src/lib/http/index.ts +1 -0
  130. package/src/lib/http/request.ts +154 -0
  131. package/src/lib/instructions/apply.ts +318 -0
  132. package/src/lib/instructions/splice.ts +148 -0
  133. package/src/lib/instructions/templates.ts +295 -0
  134. package/src/lib/tunnel/gate.ts +14 -9
  135. package/src/lib/tunnel/state.ts +19 -4
  136. package/dist/lib/scratch/index.d.ts.map +0 -1
  137. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * The fs side of ADR 0008: apply / remove / check harnery's machine-owned
3
+ * agent-facing content in a consumer repo. `init` calls `applyInstructions`,
4
+ * `deinit` calls `removeInstructions`, and `init --check` calls
5
+ * `checkInstructions`. The pure splice mechanics live in `splice.ts` and the
6
+ * rendered content in `templates.ts`; this module only sequences the reads,
7
+ * writes, and deletes so init/deinit stay thin and this stays integration-
8
+ * testable against a temp dir.
9
+ *
10
+ * Two files, one dir:
11
+ * - `AGENTS.md` — the always-on orientation block (a managed region; the
12
+ * consumer owns the rest of the file).
13
+ * - `CLAUDE.md` — claude-code only; Claude Code reads CLAUDE.md, not AGENTS.md,
14
+ * so a fresh consumer gets a CLAUDE.md whose managed region imports
15
+ * `@AGENTS.md`. A CLAUDE.md that already imports AGENTS.md or already carries
16
+ * the block (a host that generates CLAUDE.md from AGENTS.md) is left alone.
17
+ * - `.claude/skills/<skill>/SKILL.md` — claude-code only; fully-owned files,
18
+ * honoring `skills.exclude` in `.harnery/config.jsonc`.
19
+ */
20
+
21
+ import {
22
+ existsSync,
23
+ mkdirSync,
24
+ readdirSync,
25
+ readFileSync,
26
+ rmdirSync,
27
+ rmSync,
28
+ writeFileSync,
29
+ } from "node:fs";
30
+ import { dirname, join } from "node:path";
31
+ import { stripJsonComments } from "../../core/config.ts";
32
+ import {
33
+ checkOwnedSkill,
34
+ checkRegion,
35
+ isOwnedFile,
36
+ type ManagedStatus,
37
+ removeRegion,
38
+ spliceRegion,
39
+ } from "./splice.ts";
40
+ import {
41
+ type BlockSkills,
42
+ IMPORT_REGION,
43
+ INSTRUCTIONS_REGION,
44
+ renderInstructionsBlock,
45
+ SKILLS,
46
+ } from "./templates.ts";
47
+
48
+ const AGENTS_FILE = "AGENTS.md";
49
+ const CLAUDE_FILE = "CLAUDE.md";
50
+ const CLAUDE_SKILLS_DIR = join(".claude", "skills");
51
+
52
+ /** CLAUDE.md import-shim body: points Claude Code (which reads CLAUDE.md, not AGENTS.md) at AGENTS.md. */
53
+ function importBody(): string {
54
+ return "This project's agent instructions live in AGENTS.md.\n@AGENTS.md";
55
+ }
56
+
57
+ /** The body harnery expects for a skill's file (everything after the ownership marker). */
58
+ function skillBody(render: (bin: string) => string, binName: string): string {
59
+ const content = render(binName);
60
+ return content.slice(content.indexOf("-->") + 3).trim();
61
+ }
62
+
63
+ /** Read `skills.exclude` from `.harnery/config.jsonc` (absent/unparseable → none). */
64
+ export function readSkillsExclude(projectRoot: string): Set<string> {
65
+ const p = join(projectRoot, ".harnery", "config.jsonc");
66
+ try {
67
+ const cfg = JSON.parse(stripJsonComments(readFileSync(p, "utf8"))) as {
68
+ skills?: { exclude?: unknown };
69
+ } | null;
70
+ const ex = cfg?.skills?.exclude;
71
+ if (Array.isArray(ex)) return new Set(ex.filter((x): x is string => typeof x === "string"));
72
+ } catch {
73
+ /* absent / unparseable → no exclusions */
74
+ }
75
+ return new Set();
76
+ }
77
+
78
+ /**
79
+ * Which shipped skills exist for this project, so the block references only the
80
+ * ones actually present: claude-code writes skills (unless excluded); cursor and
81
+ * codex get the block but no skill files, so both read false there. Kept in one
82
+ * place so `applyInstructions` and `checkInstructions` render byte-identical blocks.
83
+ */
84
+ function blockSkills(projectRoot: string, harness: string): BlockSkills {
85
+ const claudeCode = harness === "claude-code";
86
+ const exclude = readSkillsExclude(projectRoot);
87
+ return {
88
+ decide: claudeCode && !exclude.has("harn-decide"),
89
+ council: claudeCode && !exclude.has("harn-council"),
90
+ };
91
+ }
92
+
93
+ interface ApplyOpts {
94
+ binName: string;
95
+ harness: string;
96
+ dryRun: boolean;
97
+ }
98
+
99
+ export interface ApplyResult {
100
+ actions: string[];
101
+ warnings: string[];
102
+ }
103
+
104
+ /**
105
+ * Inject / refresh the instructions block, the CLAUDE.md import shim (claude-code),
106
+ * and the shipped skills (claude-code). Idempotent: a re-run on current content
107
+ * writes nothing. `dryRun` reports without touching the fs.
108
+ */
109
+ export function applyInstructions(projectRoot: string, opts: ApplyOpts): ApplyResult {
110
+ const actions: string[] = [];
111
+ const warnings: string[] = [];
112
+ const claudeCode = opts.harness === "claude-code";
113
+ // dry-run narrates the future ("would create"); a real run narrates the past.
114
+ const verbed = (base: string, past: string) => (opts.dryRun ? `would ${base}` : past);
115
+
116
+ // ── AGENTS.md orientation block ─────────────────────────────────────────
117
+ const agentsPath = join(projectRoot, AGENTS_FILE);
118
+ const agentsExisted = existsSync(agentsPath);
119
+ const agentsBefore = agentsExisted ? readFileSync(agentsPath, "utf8") : "";
120
+ const body = renderInstructionsBlock(opts.binName, blockSkills(projectRoot, opts.harness));
121
+ const spliced = spliceRegion(agentsBefore, INSTRUCTIONS_REGION, body);
122
+ if (!spliced.changed) {
123
+ actions.push(`· ${AGENTS_FILE} instructions block already current`);
124
+ } else {
125
+ if (!opts.dryRun) writeFileSync(agentsPath, spliced.text);
126
+ if (!agentsExisted)
127
+ actions.push(`+ ${verbed("create", "created")} ${AGENTS_FILE} with the instructions block`);
128
+ else if (!spliced.had)
129
+ actions.push(`+ ${verbed("inject", "injected")} the instructions block into ${AGENTS_FILE}`);
130
+ else actions.push(`~ ${verbed("update", "updated")} the instructions block in ${AGENTS_FILE}`);
131
+ }
132
+
133
+ // ── CLAUDE.md import shim (claude-code only) ────────────────────────────
134
+ if (claudeCode) {
135
+ const claudePath = join(projectRoot, CLAUDE_FILE);
136
+ if (!existsSync(claudePath)) {
137
+ const shim = spliceRegion("", IMPORT_REGION, importBody());
138
+ if (!opts.dryRun) writeFileSync(claudePath, shim.text);
139
+ actions.push(`+ ${verbed("create", "created")} ${CLAUDE_FILE} importing @AGENTS.md`);
140
+ } else {
141
+ const claude = readFileSync(claudePath, "utf8");
142
+ const sees =
143
+ claude.includes("@AGENTS.md") ||
144
+ claude.includes(`harnery:begin ${IMPORT_REGION}`) ||
145
+ claude.includes(`harnery:begin ${INSTRUCTIONS_REGION}`);
146
+ if (sees) {
147
+ actions.push(`· ${CLAUDE_FILE} already reaches AGENTS.md (left untouched)`);
148
+ } else {
149
+ warnings.push(
150
+ `${CLAUDE_FILE} exists but neither imports @AGENTS.md nor carries the block; left ` +
151
+ `untouched. For Claude Code to see the orientation, add \`@AGENTS.md\` to ${CLAUDE_FILE} ` +
152
+ `(or generate ${CLAUDE_FILE} from ${AGENTS_FILE}).`,
153
+ );
154
+ }
155
+ }
156
+ }
157
+
158
+ // ── shipped skills (claude-code only) ───────────────────────────────────
159
+ if (claudeCode) {
160
+ const exclude = readSkillsExclude(projectRoot);
161
+ for (const skill of SKILLS) {
162
+ if (exclude.has(skill.id)) {
163
+ actions.push(`· skipped skill ${skill.id} (skills.exclude)`);
164
+ continue;
165
+ }
166
+ const skillPath = join(projectRoot, CLAUDE_SKILLS_DIR, skill.relPath);
167
+ const content = skill.render(opts.binName);
168
+ const before = existsSync(skillPath) ? readFileSync(skillPath, "utf8") : null;
169
+ if (before === content) {
170
+ actions.push(`· skill ${skill.id} already current`);
171
+ continue;
172
+ }
173
+ if (!opts.dryRun) {
174
+ mkdirSync(dirname(skillPath), { recursive: true });
175
+ writeFileSync(skillPath, content);
176
+ }
177
+ const fresh = before === null;
178
+ actions.push(
179
+ `${fresh ? "+" : "~"} ${verbed(fresh ? "write" : "update", fresh ? "wrote" : "updated")} skill ${skill.id}`,
180
+ );
181
+ }
182
+ }
183
+
184
+ return { actions, warnings };
185
+ }
186
+
187
+ interface RemoveOpts {
188
+ harness: string;
189
+ dryRun: boolean;
190
+ }
191
+
192
+ /**
193
+ * Reverse {@link applyInstructions}: strip the AGENTS.md block, the CLAUDE.md
194
+ * import shim, and delete the shipped skill files (only ones harnery generated).
195
+ * A file that becomes empty once our region is gone is deleted (init created it);
196
+ * a hand-edited skill (no ownership marker) is left with a warning.
197
+ */
198
+ export function removeInstructions(projectRoot: string, opts: RemoveOpts): ApplyResult {
199
+ const actions: string[] = [];
200
+ const warnings: string[] = [];
201
+ const claudeCode = opts.harness === "claude-code";
202
+
203
+ // ── AGENTS.md block ─────────────────────────────────────────────────────
204
+ const agentsPath = join(projectRoot, AGENTS_FILE);
205
+ if (existsSync(agentsPath)) {
206
+ const { text, removed } = removeRegion(readFileSync(agentsPath, "utf8"), INSTRUCTIONS_REGION);
207
+ if (!removed) {
208
+ actions.push(`· no instructions block in ${AGENTS_FILE}`);
209
+ } else if (text === "") {
210
+ if (!opts.dryRun) rmSync(agentsPath);
211
+ actions.push(`+ ${opts.dryRun ? "would remove" : "removed"} ${AGENTS_FILE} (was block-only)`);
212
+ } else {
213
+ if (!opts.dryRun) writeFileSync(agentsPath, text);
214
+ actions.push(
215
+ `+ ${opts.dryRun ? "would remove" : "removed"} the instructions block from ${AGENTS_FILE}`,
216
+ );
217
+ }
218
+ }
219
+
220
+ // ── CLAUDE.md import shim (claude-code) ─────────────────────────────────
221
+ if (claudeCode) {
222
+ const claudePath = join(projectRoot, CLAUDE_FILE);
223
+ if (existsSync(claudePath)) {
224
+ const { text, removed } = removeRegion(readFileSync(claudePath, "utf8"), IMPORT_REGION);
225
+ if (removed) {
226
+ if (text === "") {
227
+ if (!opts.dryRun) rmSync(claudePath);
228
+ actions.push(
229
+ `+ ${opts.dryRun ? "would remove" : "removed"} ${CLAUDE_FILE} (was shim-only)`,
230
+ );
231
+ } else {
232
+ if (!opts.dryRun) writeFileSync(claudePath, text);
233
+ actions.push(
234
+ `+ ${opts.dryRun ? "would remove" : "removed"} the import shim from ${CLAUDE_FILE}`,
235
+ );
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ // ── shipped skills (claude-code) ────────────────────────────────────────
242
+ if (claudeCode) {
243
+ for (const skill of SKILLS) {
244
+ const skillPath = join(projectRoot, CLAUDE_SKILLS_DIR, skill.relPath);
245
+ if (!existsSync(skillPath)) continue;
246
+ if (!isOwnedFile(readFileSync(skillPath, "utf8"))) {
247
+ warnings.push(`left ${skill.relPath} (hand-edited; no harnery ownership marker)`);
248
+ continue;
249
+ }
250
+ if (!opts.dryRun) {
251
+ rmSync(skillPath);
252
+ // drop the now-empty skill dir (harn-decide/), leaving .claude/skills/ intact
253
+ const dir = dirname(skillPath);
254
+ try {
255
+ if (readdirSync(dir).length === 0) rmdirSync(dir);
256
+ } catch {
257
+ /* dir not empty or gone → leave it */
258
+ }
259
+ }
260
+ actions.push(`+ ${opts.dryRun ? "would delete" : "deleted"} skill ${skill.id}`);
261
+ }
262
+ }
263
+
264
+ return { actions, warnings };
265
+ }
266
+
267
+ export interface CheckResult {
268
+ status: "fresh" | "drift" | "error";
269
+ issues: string[];
270
+ }
271
+
272
+ /**
273
+ * Read-only drift report for `init --check`: the AGENTS.md block and each
274
+ * shipped skill (claude-code). Fresh → exit 0; any stale / missing / hand-edit
275
+ * → drift (exit 2); an unreadable file → error (exit 1). Mirrors the wiki-theme
276
+ * `--check-only` contract the first host wires into pre-commit.
277
+ */
278
+ export function checkInstructions(
279
+ projectRoot: string,
280
+ opts: { binName: string; harness: string },
281
+ ): CheckResult {
282
+ const issues: string[] = [];
283
+ let errored = false;
284
+
285
+ const note = (label: string, status: ManagedStatus) => {
286
+ if (status === "missing") issues.push(`${label}: missing`);
287
+ else if (status === "stale") issues.push(`${label}: stale (re-run init)`);
288
+ };
289
+
290
+ try {
291
+ const agentsPath = join(projectRoot, AGENTS_FILE);
292
+ const content = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : "";
293
+ note(
294
+ `${AGENTS_FILE} block`,
295
+ checkRegion(
296
+ content,
297
+ INSTRUCTIONS_REGION,
298
+ renderInstructionsBlock(opts.binName, blockSkills(projectRoot, opts.harness)),
299
+ ),
300
+ );
301
+
302
+ if (opts.harness === "claude-code") {
303
+ const exclude = readSkillsExclude(projectRoot);
304
+ for (const skill of SKILLS) {
305
+ if (exclude.has(skill.id)) continue;
306
+ const skillPath = join(projectRoot, CLAUDE_SKILLS_DIR, skill.relPath);
307
+ const c = existsSync(skillPath) ? readFileSync(skillPath, "utf8") : "";
308
+ note(`skill ${skill.id}`, checkOwnedSkill(c, skillBody(skill.render, opts.binName)));
309
+ }
310
+ }
311
+ } catch (err) {
312
+ errored = true;
313
+ issues.push(`error reading instructions state: ${(err as Error).message}`);
314
+ }
315
+
316
+ if (errored) return { status: "error", issues };
317
+ return { status: issues.length === 0 ? "fresh" : "drift", issues };
318
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Pure splicer for harnery's machine-owned content in a consumer's repo.
3
+ *
4
+ * Two shapes of machine-owned content, both hash-versioned so drift is a
5
+ * byte-compare and a re-splice is idempotent (applying twice = identical bytes):
6
+ *
7
+ * 1. A **managed region** inside a larger file the consumer also edits
8
+ * (`AGENTS.md`, `CLAUDE.md`), delimited by sentinel comments:
9
+ * <!-- harnery:begin <region> v=<hash> -->
10
+ * …rendered body…
11
+ * <!-- harnery:end <region> -->
12
+ * Everything outside the sentinels is never touched.
13
+ *
14
+ * 2. A **fully-owned file** harnery creates whole (a shipped skill's
15
+ * `SKILL.md`), carrying an ownership header comment so `deinit` deletes
16
+ * only files harnery generated and `--check` flags a hand-edit:
17
+ * <!-- harnery:generated <name> v=<hash> — machine-owned … -->
18
+ *
19
+ * Modeled on the first host's HTML-theme splicer (regenerate + byte-compare,
20
+ * sha256-8 hash, content outside the region untouchable). Pure (no fs) so it's
21
+ * unit-testable like `wireHooks`/`unwireHooks`.
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+
26
+ /** 8-hex-char content hash stamped into every managed marker. */
27
+ export function shortHash(s: string): string {
28
+ return createHash("sha256").update(s).digest("hex").slice(0, 8);
29
+ }
30
+
31
+ function escapeRe(s: string): string {
32
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+ }
34
+
35
+ /** Capture regex for a named managed region: begin-marker, body, end-marker. */
36
+ function regionRe(region: string): RegExp {
37
+ const r = escapeRe(region);
38
+ return new RegExp(
39
+ `(<!--\\s*harnery:begin ${r}(?:\\s+v=([0-9a-f]*))?\\s*-->)([\\s\\S]*?)(<!--\\s*harnery:end ${r}\\s*-->)`,
40
+ );
41
+ }
42
+
43
+ /** Canonical region block: begin-marker, body flanked by newlines, end-marker. */
44
+ export function regionBlock(region: string, body: string): string {
45
+ return `<!-- harnery:begin ${region} v=${shortHash(body)} -->\n${body}\n<!-- harnery:end ${region} -->`;
46
+ }
47
+
48
+ export type ManagedStatus = "fresh" | "stale" | "missing";
49
+
50
+ export interface SpliceResult {
51
+ text: string;
52
+ changed: boolean;
53
+ /** the region was already present before this splice */
54
+ had: boolean;
55
+ /** present-but-differs (hash or body); only meaningful when `had` is true */
56
+ stale: boolean;
57
+ }
58
+
59
+ /**
60
+ * Re-splice (or first-time append) a managed region into `content`. Idempotent:
61
+ * applying twice yields identical bytes. Content outside the markers is never
62
+ * touched; a re-splice replaces the region wherever the consumer moved it. When
63
+ * absent, the block is appended after existing content (blank-line separated);
64
+ * an empty/whitespace-only `content` becomes just the block.
65
+ */
66
+ export function spliceRegion(content: string, region: string, body: string): SpliceResult {
67
+ const re = regionRe(region);
68
+ const m = content.match(re);
69
+ const fresh = regionBlock(region, body);
70
+ if (m) {
71
+ const stale = m[2] !== shortHash(body) || m[3] !== `\n${body}\n`;
72
+ // Replacer fn avoids `$`-in-body being read as a capture reference.
73
+ const text = content.replace(re, () => fresh);
74
+ return { text, changed: text !== content, had: true, stale };
75
+ }
76
+ const trimmed = content.replace(/\s+$/, "");
77
+ const text = trimmed ? `${trimmed}\n\n${fresh}\n` : `${fresh}\n`;
78
+ return { text, changed: true, had: false, stale: false };
79
+ }
80
+
81
+ /**
82
+ * Remove a managed region, collapsing the blank lines it leaves behind. Returns
83
+ * `removed: false` (content unchanged) when the region is absent. When the region
84
+ * was the file's only content, the result is the empty string — the caller
85
+ * decides whether to delete the file.
86
+ */
87
+ export function removeRegion(content: string, region: string): { text: string; removed: boolean } {
88
+ const re = regionRe(region);
89
+ if (!re.test(content)) return { text: content, removed: false };
90
+ const stripped = content
91
+ .replace(re, "")
92
+ .replace(/[ \t]+\n/g, "\n")
93
+ .replace(/\n{3,}/g, "\n\n")
94
+ .trim();
95
+ return { text: stripped ? `${stripped}\n` : "", removed: true };
96
+ }
97
+
98
+ /** Region freshness: missing, stale (hash or body drifted), or fresh. */
99
+ export function checkRegion(content: string, region: string, body: string): ManagedStatus {
100
+ const m = content.match(regionRe(region));
101
+ if (!m) return "missing";
102
+ return m[2] === shortHash(body) && m[3] === `\n${body}\n` ? "fresh" : "stale";
103
+ }
104
+
105
+ // ── Fully-owned files (shipped skills) ──────────────────────────────────────
106
+
107
+ const OWNED_RE = /<!--\s*harnery:generated\s+(\S+)\s+v=([0-9a-f]*)[\s\S]*?-->/;
108
+
109
+ /** True when a file carries harnery's ownership header (deinit may delete it). */
110
+ export function isOwnedFile(content: string): boolean {
111
+ return OWNED_RE.test(content);
112
+ }
113
+
114
+ /**
115
+ * Wrap a skill file: frontmatter, then a hash-stamped ownership header comment,
116
+ * then the body. The hash covers the trimmed body so `--check` catches a
117
+ * hand-edit even if the marker was left alone. `binName` renders the regenerate
118
+ * / remove hint in the host's own bin.
119
+ */
120
+ export function buildOwnedSkill(opts: {
121
+ name: string;
122
+ description: string;
123
+ argumentHint?: string;
124
+ binName: string;
125
+ body: string;
126
+ }): string {
127
+ const fm = [
128
+ "---",
129
+ `name: ${opts.name}`,
130
+ `description: ${opts.description}`,
131
+ ...(opts.argumentHint ? [`argument-hint: ${JSON.stringify(opts.argumentHint)}`] : []),
132
+ "---",
133
+ ].join("\n");
134
+ const body = opts.body.trim();
135
+ const marker =
136
+ `<!-- harnery:generated ${opts.name} v=${shortHash(body)} — machine-owned; ` +
137
+ `regenerated by \`${opts.binName} init\`, removed by \`${opts.binName} deinit\`. ` +
138
+ `Edit the harnery template, not this file. -->`;
139
+ return `${fm}\n${marker}\n\n${body}\n`;
140
+ }
141
+
142
+ /** Owned-skill freshness against a freshly-rendered body (trimmed compare). */
143
+ export function checkOwnedSkill(content: string, freshBody: string): ManagedStatus {
144
+ const m = OWNED_RE.exec(content);
145
+ if (!m) return "missing";
146
+ const body = content.slice(m.index + m[0].length).trim();
147
+ return m[2] === shortHash(freshBody.trim()) && body === freshBody.trim() ? "fresh" : "stale";
148
+ }