harnery 0.5.0 → 0.7.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.
Files changed (176) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +29 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +4 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +94 -33
  7. package/dist/commands/browse-ai.js +1 -1
  8. package/dist/commands/browse.d.ts.map +1 -1
  9. package/dist/commands/browse.js +41 -9
  10. package/dist/commands/cookies.js +1 -1
  11. package/dist/commands/decision.d.ts +4 -0
  12. package/dist/commands/decision.d.ts.map +1 -0
  13. package/dist/commands/decision.js +354 -0
  14. package/dist/commands/deinit.d.ts.map +1 -1
  15. package/dist/commands/deinit.js +4 -0
  16. package/dist/commands/devtools.d.ts +4 -0
  17. package/dist/commands/devtools.d.ts.map +1 -0
  18. package/dist/commands/devtools.js +239 -0
  19. package/dist/commands/docs.d.ts.map +1 -1
  20. package/dist/commands/docs.js +74 -2
  21. package/dist/commands/doctor.js +12 -4
  22. package/dist/commands/env.d.ts.map +1 -1
  23. package/dist/commands/env.js +3 -63
  24. package/dist/commands/fetch.js +1 -1
  25. package/dist/commands/init.d.ts +1 -0
  26. package/dist/commands/init.d.ts.map +1 -1
  27. package/dist/commands/init.js +54 -14
  28. package/dist/commands/scratch.js +1 -1
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +273 -62
  31. package/dist/commands/web-fetch.js +1 -1
  32. package/dist/core/agents/coord-client.d.ts.map +1 -1
  33. package/dist/core/agents/coord-client.js +32 -8
  34. package/dist/core/agents/events/consume.d.ts +25 -2
  35. package/dist/core/agents/events/consume.d.ts.map +1 -1
  36. package/dist/core/agents/events/consume.js +55 -7
  37. package/dist/core/agents/events/emit.d.ts +2 -1
  38. package/dist/core/agents/events/emit.d.ts.map +1 -1
  39. package/dist/core/agents/events/emit.js +6 -1
  40. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  41. package/dist/core/agents/rules/claim-conflict.js +16 -5
  42. package/dist/core/agents/state/scratch.d.ts +1 -1
  43. package/dist/core/agents/state/scratch.js +2 -2
  44. package/dist/core/config.d.ts +10 -0
  45. package/dist/core/config.d.ts.map +1 -1
  46. package/dist/core/config.js +13 -0
  47. package/dist/core/hooks/cli.js +3 -3
  48. package/dist/core/hooks/effects/index.d.ts +11 -7
  49. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  50. package/dist/core/hooks/effects/index.js +15 -17
  51. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  52. package/dist/core/hooks/events/emit.js +4 -0
  53. package/dist/core/hooks/events/rotate.d.ts +43 -0
  54. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  55. package/dist/core/hooks/events/rotate.js +142 -0
  56. package/dist/core/hooks/harness/events.d.ts +11 -1
  57. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  58. package/dist/core/hooks/harness/events.js +22 -3
  59. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  60. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  61. package/dist/core/hooks/harness/wiring.js +34 -5
  62. package/dist/core/scratch/index.d.ts.map +1 -0
  63. package/dist/{lib → core}/scratch/index.js +2 -2
  64. package/dist/lib/agent-browser/client.js +1 -1
  65. package/dist/lib/browser/client.d.ts +14 -0
  66. package/dist/lib/browser/client.d.ts.map +1 -1
  67. package/dist/lib/browser/client.js +20 -0
  68. package/dist/lib/browser/index.d.ts +1 -0
  69. package/dist/lib/browser/index.d.ts.map +1 -1
  70. package/dist/lib/browser/runts.d.ts +44 -0
  71. package/dist/lib/browser/runts.d.ts.map +1 -0
  72. package/dist/lib/browser/runts.js +193 -0
  73. package/dist/lib/completion/walk.js +1 -1
  74. package/dist/lib/cookies/client.d.ts +1 -1
  75. package/dist/lib/cookies/client.d.ts.map +1 -1
  76. package/dist/lib/cookies/client.js +1 -1
  77. package/dist/lib/decision/index.d.ts +212 -0
  78. package/dist/lib/decision/index.d.ts.map +1 -0
  79. package/dist/lib/decision/index.js +523 -0
  80. package/dist/lib/devtools.d.ts +178 -0
  81. package/dist/lib/devtools.d.ts.map +1 -0
  82. package/dist/lib/devtools.js +1328 -0
  83. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  84. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  85. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  86. package/dist/lib/docs-frontmatter.d.ts +33 -0
  87. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  88. package/dist/lib/docs-frontmatter.js +130 -0
  89. package/dist/lib/docs-index.d.ts +1 -0
  90. package/dist/lib/docs-index.d.ts.map +1 -1
  91. package/dist/lib/docs-index.js +4 -5
  92. package/dist/lib/docs-lint.d.ts +3 -0
  93. package/dist/lib/docs-lint.d.ts.map +1 -1
  94. package/dist/lib/docs-lint.js +67 -12
  95. package/dist/lib/docs-meta.d.ts +14 -0
  96. package/dist/lib/docs-meta.d.ts.map +1 -0
  97. package/dist/lib/docs-meta.js +34 -0
  98. package/dist/lib/docs-sweep.d.ts +12 -0
  99. package/dist/lib/docs-sweep.d.ts.map +1 -1
  100. package/dist/lib/docs-sweep.js +98 -103
  101. package/dist/lib/format.js +2 -2
  102. package/dist/lib/http/index.d.ts +1 -0
  103. package/dist/lib/http/index.d.ts.map +1 -1
  104. package/dist/lib/http/index.js +1 -0
  105. package/dist/lib/http/request.d.ts +77 -0
  106. package/dist/lib/http/request.d.ts.map +1 -0
  107. package/dist/lib/http/request.js +105 -0
  108. package/dist/lib/instructions/apply.d.ts +63 -0
  109. package/dist/lib/instructions/apply.d.ts.map +1 -0
  110. package/dist/lib/instructions/apply.js +255 -0
  111. package/dist/lib/instructions/splice.d.ts +73 -0
  112. package/dist/lib/instructions/splice.d.ts.map +1 -0
  113. package/dist/lib/instructions/splice.js +118 -0
  114. package/dist/lib/instructions/templates.d.ts +45 -0
  115. package/dist/lib/instructions/templates.d.ts.map +1 -0
  116. package/dist/lib/instructions/templates.js +258 -0
  117. package/dist/lib/tunnel/gate.d.ts +1 -0
  118. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  119. package/dist/lib/tunnel/gate.js +15 -10
  120. package/dist/lib/tunnel/state.d.ts +11 -1
  121. package/dist/lib/tunnel/state.d.ts.map +1 -1
  122. package/dist/lib/tunnel/state.js +8 -3
  123. package/package.json +9 -6
  124. package/src/commander.ts +35 -0
  125. package/src/commands/agents.ts +97 -29
  126. package/src/commands/browse-ai.ts +1 -1
  127. package/src/commands/browse.ts +63 -8
  128. package/src/commands/cookies.ts +1 -1
  129. package/src/commands/decision.ts +438 -0
  130. package/src/commands/deinit.ts +5 -0
  131. package/src/commands/devtools.ts +284 -0
  132. package/src/commands/docs.ts +86 -2
  133. package/src/commands/doctor.ts +13 -4
  134. package/src/commands/env.ts +11 -77
  135. package/src/commands/fetch.ts +1 -1
  136. package/src/commands/init.ts +66 -15
  137. package/src/commands/scratch.ts +1 -1
  138. package/src/commands/tunnel.ts +316 -65
  139. package/src/commands/web-fetch.ts +1 -1
  140. package/src/core/agents/coord-client.ts +34 -7
  141. package/src/core/agents/events/consume.ts +65 -7
  142. package/src/core/agents/events/emit.ts +7 -1
  143. package/src/core/agents/rules/claim-conflict.ts +17 -6
  144. package/src/core/agents/state/scratch.ts +2 -2
  145. package/src/core/config.ts +15 -1
  146. package/src/core/hooks/cli.ts +3 -3
  147. package/src/core/hooks/effects/index.ts +23 -16
  148. package/src/core/hooks/events/emit.ts +5 -0
  149. package/src/core/hooks/events/rotate.ts +151 -0
  150. package/src/core/hooks/harness/events.ts +30 -3
  151. package/src/core/hooks/harness/wiring.ts +46 -5
  152. package/src/{lib → core}/scratch/index.ts +2 -2
  153. package/src/lib/agent-browser/client.ts +1 -1
  154. package/src/lib/browser/client.ts +28 -0
  155. package/src/lib/browser/index.ts +4 -0
  156. package/src/lib/browser/runts.ts +218 -0
  157. package/src/lib/completion/walk.ts +1 -1
  158. package/src/lib/cookies/client.ts +2 -2
  159. package/src/lib/decision/index.ts +685 -0
  160. package/src/lib/devtools.ts +1653 -0
  161. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  162. package/src/lib/docs-frontmatter.ts +151 -0
  163. package/src/lib/docs-index.ts +4 -5
  164. package/src/lib/docs-lint.ts +61 -11
  165. package/src/lib/docs-meta.ts +44 -0
  166. package/src/lib/docs-sweep.ts +104 -102
  167. package/src/lib/format.ts +2 -2
  168. package/src/lib/http/index.ts +1 -0
  169. package/src/lib/http/request.ts +154 -0
  170. package/src/lib/instructions/apply.ts +318 -0
  171. package/src/lib/instructions/splice.ts +148 -0
  172. package/src/lib/instructions/templates.ts +295 -0
  173. package/src/lib/tunnel/gate.ts +15 -10
  174. package/src/lib/tunnel/state.ts +19 -4
  175. package/dist/lib/scratch/index.d.ts.map +0 -1
  176. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Retrying JSON-API request — the toolkit-tier HTTP primitive for host CLIs'
3
+ * vendor clients.
4
+ *
5
+ * Extracted from the first embedding host, where ten vendor clients carried
6
+ * byte-similar copies of the same loop: abort-controller timeout, retry on
7
+ * 429/5xx with exponential backoff + jitter, `Retry-After` honored when sane,
8
+ * vendor-specific error taxonomy applied by the caller. This module owns the
9
+ * loop; callers keep their auth headers and error classes:
10
+ *
11
+ * const r = await requestWithRetries(url, {
12
+ * method, body,
13
+ * headers: { Authorization: `ApiKey ${key}`, Accept: "application/json" },
14
+ * timeoutMs: this.timeoutMs,
15
+ * maxRetries: this.maxRetries,
16
+ * onResponse: ({ status }) => log(`${method} ${url} → ${status}`),
17
+ * networkError: (msg) => new VendorError("network_error", msg),
18
+ * });
19
+ * if (!r.ok) throw makeHttpError(r.status, url, r.text, r.headers);
20
+ *
21
+ * Design choices, so they survive review:
22
+ * - Terminal non-2xx responses RETURN (`ok: false`) rather than throw — the
23
+ * vendor error taxonomy belongs to the caller, not this module.
24
+ * - Only terminal NETWORK failures throw (after retries), because there is
25
+ * no response to hand back; `networkError` lets the caller keep its class.
26
+ * - The response body is always read (even on retried statuses) so keep-alive
27
+ * sockets are released.
28
+ */
29
+
30
+ export interface RetryingResponse {
31
+ /** `status` in the 2xx range. */
32
+ ok: boolean;
33
+ status: number;
34
+ /** Response body as text; callers handle JSON parsing. */
35
+ text: string;
36
+ url: string;
37
+ headers: Headers;
38
+ }
39
+
40
+ export interface RequestWithRetriesOptions {
41
+ /** HTTP method. Default GET. */
42
+ method?: string;
43
+ /** Extra headers. Content-Type defaults to application/json when a non-string body is given. */
44
+ headers?: Record<string, string>;
45
+ /**
46
+ * Request body. Strings, Uint8Array, FormData, Blob, and ReadableStream pass
47
+ * through untouched; any other value is JSON.stringify'd (with a
48
+ * Content-Type: application/json default).
49
+ */
50
+ body?: unknown;
51
+ /** Per-attempt timeout (AbortController). Default 30s. */
52
+ timeoutMs?: number;
53
+ /** Retries after the first attempt. Default 3. */
54
+ maxRetries?: number;
55
+ /** Which statuses to retry. Default: 429 and 5xx. */
56
+ shouldRetry?: (status: number) => boolean;
57
+ /** Delay before retry `attempt` (0-based). Default `backoffDelayMs`. */
58
+ delayMs?: (attempt: number, retryAfterSeconds?: number | null) => number;
59
+ /** Observability hook — fires once per received response (every attempt). */
60
+ onResponse?: (info: { method: string; url: string; status: number; attempt: number }) => void;
61
+ /**
62
+ * Wrap a terminal network failure (fetch threw on the last attempt) in the
63
+ * caller's error class. Default: a plain Error with the message.
64
+ */
65
+ networkError?: (message: string, url: string, retries: number) => Error;
66
+ }
67
+
68
+ /**
69
+ * Exponential backoff with jitter: 500ms · 2^attempt, capped at 30s, plus up
70
+ * to 250ms of jitter. A sane `Retry-After` (0 < s < 60) short-circuits the
71
+ * curve — the server knows better than the guess.
72
+ */
73
+ export function backoffDelayMs(attempt: number, retryAfterSeconds?: number | null): number {
74
+ if (retryAfterSeconds && retryAfterSeconds > 0 && retryAfterSeconds < 60) {
75
+ return retryAfterSeconds * 1000 + Math.floor(Math.random() * 250);
76
+ }
77
+ const base = Math.min(30_000, 500 * 2 ** attempt);
78
+ return base + Math.floor(Math.random() * 250);
79
+ }
80
+
81
+ function sleep(ms: number): Promise<void> {
82
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
83
+ }
84
+
85
+ export async function requestWithRetries(
86
+ url: string,
87
+ opts: RequestWithRetriesOptions = {},
88
+ ): Promise<RetryingResponse> {
89
+ const method = opts.method ?? "GET";
90
+ const timeoutMs = opts.timeoutMs ?? 30_000;
91
+ const maxRetries = opts.maxRetries ?? 3;
92
+ const shouldRetry = opts.shouldRetry ?? ((status: number) => status === 429 || status >= 500);
93
+ const delayMs = opts.delayMs ?? backoffDelayMs;
94
+
95
+ let attempt = 0;
96
+ for (;;) {
97
+ const controller = new AbortController();
98
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
99
+
100
+ const headers: Record<string, string> = { ...(opts.headers ?? {}) };
101
+ const init: RequestInit = { method, headers, signal: controller.signal };
102
+ if (opts.body !== undefined && opts.body !== null) {
103
+ const passthrough =
104
+ typeof opts.body === "string" ||
105
+ opts.body instanceof Uint8Array ||
106
+ opts.body instanceof FormData ||
107
+ opts.body instanceof Blob ||
108
+ opts.body instanceof ReadableStream;
109
+ if (passthrough) {
110
+ init.body = opts.body as BodyInit;
111
+ } else {
112
+ if (!Object.keys(headers).some((k) => k.toLowerCase() === "content-type")) {
113
+ headers["Content-Type"] = "application/json";
114
+ }
115
+ init.body = JSON.stringify(opts.body);
116
+ }
117
+ }
118
+
119
+ let res: Response;
120
+ try {
121
+ res = await fetch(url, init);
122
+ } catch (err: unknown) {
123
+ clearTimeout(timer);
124
+ if (attempt < maxRetries) {
125
+ await sleep(delayMs(attempt));
126
+ attempt++;
127
+ continue;
128
+ }
129
+ const msg = err instanceof Error ? err.message : String(err);
130
+ const message = `request failed after ${maxRetries} retries: ${msg} (URL: ${url})`;
131
+ throw opts.networkError ? opts.networkError(msg, url, maxRetries) : new Error(message);
132
+ }
133
+ clearTimeout(timer);
134
+
135
+ opts.onResponse?.({ method, url, status: res.status, attempt });
136
+
137
+ if (shouldRetry(res.status) && attempt < maxRetries) {
138
+ const retryAfter = Number(res.headers.get("retry-after"));
139
+ // Drain the body so the socket is reusable before we sleep.
140
+ await res.text().catch(() => undefined);
141
+ await sleep(delayMs(attempt, Number.isFinite(retryAfter) ? retryAfter : null));
142
+ attempt++;
143
+ continue;
144
+ }
145
+
146
+ return {
147
+ ok: res.ok,
148
+ status: res.status,
149
+ text: await res.text(),
150
+ url,
151
+ headers: res.headers,
152
+ };
153
+ }
154
+ }
@@ -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
+ }