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.
- package/README.md +16 -6
- package/dist/commander.d.ts +29 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +4 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +94 -33
- package/dist/commands/browse-ai.js +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +41 -9
- package/dist/commands/cookies.js +1 -1
- package/dist/commands/decision.d.ts +4 -0
- package/dist/commands/decision.d.ts.map +1 -0
- package/dist/commands/decision.js +354 -0
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/devtools.d.ts +4 -0
- package/dist/commands/devtools.d.ts.map +1 -0
- package/dist/commands/devtools.js +239 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +74 -2
- package/dist/commands/doctor.js +12 -4
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +3 -63
- package/dist/commands/fetch.js +1 -1
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +54 -14
- package/dist/commands/scratch.js +1 -1
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +273 -62
- package/dist/commands/web-fetch.js +1 -1
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +32 -8
- package/dist/core/agents/events/consume.d.ts +25 -2
- package/dist/core/agents/events/consume.d.ts.map +1 -1
- package/dist/core/agents/events/consume.js +55 -7
- package/dist/core/agents/events/emit.d.ts +2 -1
- package/dist/core/agents/events/emit.d.ts.map +1 -1
- package/dist/core/agents/events/emit.js +6 -1
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +16 -5
- package/dist/core/agents/state/scratch.d.ts +1 -1
- package/dist/core/agents/state/scratch.js +2 -2
- package/dist/core/config.d.ts +10 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +13 -0
- package/dist/core/hooks/cli.js +3 -3
- package/dist/core/hooks/effects/index.d.ts +11 -7
- package/dist/core/hooks/effects/index.d.ts.map +1 -1
- package/dist/core/hooks/effects/index.js +15 -17
- package/dist/core/hooks/events/emit.d.ts.map +1 -1
- package/dist/core/hooks/events/emit.js +4 -0
- package/dist/core/hooks/events/rotate.d.ts +43 -0
- package/dist/core/hooks/events/rotate.d.ts.map +1 -0
- package/dist/core/hooks/events/rotate.js +142 -0
- package/dist/core/hooks/harness/events.d.ts +11 -1
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +22 -3
- package/dist/core/hooks/harness/wiring.d.ts +8 -0
- package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
- package/dist/core/hooks/harness/wiring.js +34 -5
- package/dist/core/scratch/index.d.ts.map +1 -0
- package/dist/{lib → core}/scratch/index.js +2 -2
- package/dist/lib/agent-browser/client.js +1 -1
- package/dist/lib/browser/client.d.ts +14 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +20 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/runts.d.ts +44 -0
- package/dist/lib/browser/runts.d.ts.map +1 -0
- package/dist/lib/browser/runts.js +193 -0
- package/dist/lib/completion/walk.js +1 -1
- package/dist/lib/cookies/client.d.ts +1 -1
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +1 -1
- package/dist/lib/decision/index.d.ts +212 -0
- package/dist/lib/decision/index.d.ts.map +1 -0
- package/dist/lib/decision/index.js +523 -0
- package/dist/lib/devtools.d.ts +178 -0
- package/dist/lib/devtools.d.ts.map +1 -0
- package/dist/lib/devtools.js +1328 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter-migrate.js +364 -0
- package/dist/lib/docs-frontmatter.d.ts +33 -0
- package/dist/lib/docs-frontmatter.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter.js +130 -0
- package/dist/lib/docs-index.d.ts +1 -0
- package/dist/lib/docs-index.d.ts.map +1 -1
- package/dist/lib/docs-index.js +4 -5
- package/dist/lib/docs-lint.d.ts +3 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +67 -12
- package/dist/lib/docs-meta.d.ts +14 -0
- package/dist/lib/docs-meta.d.ts.map +1 -0
- package/dist/lib/docs-meta.js +34 -0
- package/dist/lib/docs-sweep.d.ts +12 -0
- package/dist/lib/docs-sweep.d.ts.map +1 -1
- package/dist/lib/docs-sweep.js +98 -103
- package/dist/lib/format.js +2 -2
- package/dist/lib/http/index.d.ts +1 -0
- package/dist/lib/http/index.d.ts.map +1 -1
- package/dist/lib/http/index.js +1 -0
- package/dist/lib/http/request.d.ts +77 -0
- package/dist/lib/http/request.d.ts.map +1 -0
- package/dist/lib/http/request.js +105 -0
- package/dist/lib/instructions/apply.d.ts +63 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -0
- package/dist/lib/instructions/apply.js +255 -0
- package/dist/lib/instructions/splice.d.ts +73 -0
- package/dist/lib/instructions/splice.d.ts.map +1 -0
- package/dist/lib/instructions/splice.js +118 -0
- package/dist/lib/instructions/templates.d.ts +45 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -0
- package/dist/lib/instructions/templates.js +258 -0
- package/dist/lib/tunnel/gate.d.ts +1 -0
- package/dist/lib/tunnel/gate.d.ts.map +1 -1
- package/dist/lib/tunnel/gate.js +15 -10
- package/dist/lib/tunnel/state.d.ts +11 -1
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +8 -3
- package/package.json +9 -6
- package/src/commander.ts +35 -0
- package/src/commands/agents.ts +97 -29
- package/src/commands/browse-ai.ts +1 -1
- package/src/commands/browse.ts +63 -8
- package/src/commands/cookies.ts +1 -1
- package/src/commands/decision.ts +438 -0
- package/src/commands/deinit.ts +5 -0
- package/src/commands/devtools.ts +284 -0
- package/src/commands/docs.ts +86 -2
- package/src/commands/doctor.ts +13 -4
- package/src/commands/env.ts +11 -77
- package/src/commands/fetch.ts +1 -1
- package/src/commands/init.ts +66 -15
- package/src/commands/scratch.ts +1 -1
- package/src/commands/tunnel.ts +316 -65
- package/src/commands/web-fetch.ts +1 -1
- package/src/core/agents/coord-client.ts +34 -7
- package/src/core/agents/events/consume.ts +65 -7
- package/src/core/agents/events/emit.ts +7 -1
- package/src/core/agents/rules/claim-conflict.ts +17 -6
- package/src/core/agents/state/scratch.ts +2 -2
- package/src/core/config.ts +15 -1
- package/src/core/hooks/cli.ts +3 -3
- package/src/core/hooks/effects/index.ts +23 -16
- package/src/core/hooks/events/emit.ts +5 -0
- package/src/core/hooks/events/rotate.ts +151 -0
- package/src/core/hooks/harness/events.ts +30 -3
- package/src/core/hooks/harness/wiring.ts +46 -5
- package/src/{lib → core}/scratch/index.ts +2 -2
- package/src/lib/agent-browser/client.ts +1 -1
- package/src/lib/browser/client.ts +28 -0
- package/src/lib/browser/index.ts +4 -0
- package/src/lib/browser/runts.ts +218 -0
- package/src/lib/completion/walk.ts +1 -1
- package/src/lib/cookies/client.ts +2 -2
- package/src/lib/decision/index.ts +685 -0
- package/src/lib/devtools.ts +1653 -0
- package/src/lib/docs-frontmatter-migrate.ts +427 -0
- package/src/lib/docs-frontmatter.ts +151 -0
- package/src/lib/docs-index.ts +4 -5
- package/src/lib/docs-lint.ts +61 -11
- package/src/lib/docs-meta.ts +44 -0
- package/src/lib/docs-sweep.ts +104 -102
- package/src/lib/format.ts +2 -2
- package/src/lib/http/index.ts +1 -0
- package/src/lib/http/request.ts +154 -0
- package/src/lib/instructions/apply.ts +318 -0
- package/src/lib/instructions/splice.ts +148 -0
- package/src/lib/instructions/templates.ts +295 -0
- package/src/lib/tunnel/gate.ts +15 -10
- package/src/lib/tunnel/state.ts +19 -4
- package/dist/lib/scratch/index.d.ts.map +0 -1
- /package/dist/{lib → core}/scratch/index.d.ts +0 -0
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
* Exponential backoff with jitter: 500ms · 2^attempt, capped at 30s, plus up
|
|
31
|
+
* to 250ms of jitter. A sane `Retry-After` (0 < s < 60) short-circuits the
|
|
32
|
+
* curve — the server knows better than the guess.
|
|
33
|
+
*/
|
|
34
|
+
export function backoffDelayMs(attempt, retryAfterSeconds) {
|
|
35
|
+
if (retryAfterSeconds && retryAfterSeconds > 0 && retryAfterSeconds < 60) {
|
|
36
|
+
return retryAfterSeconds * 1000 + Math.floor(Math.random() * 250);
|
|
37
|
+
}
|
|
38
|
+
const base = Math.min(30_000, 500 * 2 ** attempt);
|
|
39
|
+
return base + Math.floor(Math.random() * 250);
|
|
40
|
+
}
|
|
41
|
+
function sleep(ms) {
|
|
42
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
43
|
+
}
|
|
44
|
+
export async function requestWithRetries(url, opts = {}) {
|
|
45
|
+
const method = opts.method ?? "GET";
|
|
46
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
47
|
+
const maxRetries = opts.maxRetries ?? 3;
|
|
48
|
+
const shouldRetry = opts.shouldRetry ?? ((status) => status === 429 || status >= 500);
|
|
49
|
+
const delayMs = opts.delayMs ?? backoffDelayMs;
|
|
50
|
+
let attempt = 0;
|
|
51
|
+
for (;;) {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
54
|
+
const headers = { ...(opts.headers ?? {}) };
|
|
55
|
+
const init = { method, headers, signal: controller.signal };
|
|
56
|
+
if (opts.body !== undefined && opts.body !== null) {
|
|
57
|
+
const passthrough = typeof opts.body === "string" ||
|
|
58
|
+
opts.body instanceof Uint8Array ||
|
|
59
|
+
opts.body instanceof FormData ||
|
|
60
|
+
opts.body instanceof Blob ||
|
|
61
|
+
opts.body instanceof ReadableStream;
|
|
62
|
+
if (passthrough) {
|
|
63
|
+
init.body = opts.body;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
if (!Object.keys(headers).some((k) => k.toLowerCase() === "content-type")) {
|
|
67
|
+
headers["Content-Type"] = "application/json";
|
|
68
|
+
}
|
|
69
|
+
init.body = JSON.stringify(opts.body);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let res;
|
|
73
|
+
try {
|
|
74
|
+
res = await fetch(url, init);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
if (attempt < maxRetries) {
|
|
79
|
+
await sleep(delayMs(attempt));
|
|
80
|
+
attempt++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
84
|
+
const message = `request failed after ${maxRetries} retries: ${msg} (URL: ${url})`;
|
|
85
|
+
throw opts.networkError ? opts.networkError(msg, url, maxRetries) : new Error(message);
|
|
86
|
+
}
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
opts.onResponse?.({ method, url, status: res.status, attempt });
|
|
89
|
+
if (shouldRetry(res.status) && attempt < maxRetries) {
|
|
90
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
91
|
+
// Drain the body so the socket is reusable before we sleep.
|
|
92
|
+
await res.text().catch(() => undefined);
|
|
93
|
+
await sleep(delayMs(attempt, Number.isFinite(retryAfter) ? retryAfter : null));
|
|
94
|
+
attempt++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
ok: res.ok,
|
|
99
|
+
status: res.status,
|
|
100
|
+
text: await res.text(),
|
|
101
|
+
url,
|
|
102
|
+
headers: res.headers,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
/** Read `skills.exclude` from `.harnery/config.jsonc` (absent/unparseable → none). */
|
|
21
|
+
export declare function readSkillsExclude(projectRoot: string): Set<string>;
|
|
22
|
+
interface ApplyOpts {
|
|
23
|
+
binName: string;
|
|
24
|
+
harness: string;
|
|
25
|
+
dryRun: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface ApplyResult {
|
|
28
|
+
actions: string[];
|
|
29
|
+
warnings: string[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Inject / refresh the instructions block, the CLAUDE.md import shim (claude-code),
|
|
33
|
+
* and the shipped skills (claude-code). Idempotent: a re-run on current content
|
|
34
|
+
* writes nothing. `dryRun` reports without touching the fs.
|
|
35
|
+
*/
|
|
36
|
+
export declare function applyInstructions(projectRoot: string, opts: ApplyOpts): ApplyResult;
|
|
37
|
+
interface RemoveOpts {
|
|
38
|
+
harness: string;
|
|
39
|
+
dryRun: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Reverse {@link applyInstructions}: strip the AGENTS.md block, the CLAUDE.md
|
|
43
|
+
* import shim, and delete the shipped skill files (only ones harnery generated).
|
|
44
|
+
* A file that becomes empty once our region is gone is deleted (init created it);
|
|
45
|
+
* a hand-edited skill (no ownership marker) is left with a warning.
|
|
46
|
+
*/
|
|
47
|
+
export declare function removeInstructions(projectRoot: string, opts: RemoveOpts): ApplyResult;
|
|
48
|
+
export interface CheckResult {
|
|
49
|
+
status: "fresh" | "drift" | "error";
|
|
50
|
+
issues: string[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Read-only drift report for `init --check`: the AGENTS.md block and each
|
|
54
|
+
* shipped skill (claude-code). Fresh → exit 0; any stale / missing / hand-edit
|
|
55
|
+
* → drift (exit 2); an unreadable file → error (exit 1). Mirrors the wiki-theme
|
|
56
|
+
* `--check-only` contract the first host wires into pre-commit.
|
|
57
|
+
*/
|
|
58
|
+
export declare function checkInstructions(projectRoot: string, opts: {
|
|
59
|
+
binName: string;
|
|
60
|
+
harness: string;
|
|
61
|
+
}): CheckResult;
|
|
62
|
+
export {};
|
|
63
|
+
//# sourceMappingURL=apply.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../../src/lib/instructions/apply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AA4CH,sFAAsF;AACtF,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAYlE;AAiBD,UAAU,SAAS;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,WAAW,CA4EnF;AAED,UAAU,UAAU;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,WAAW,CAmErF;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC;IACpC,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACzC,WAAW,CAqCb"}
|
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync, } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { stripJsonComments } from "../../core/config.js";
|
|
23
|
+
import { checkOwnedSkill, checkRegion, isOwnedFile, removeRegion, spliceRegion, } from "./splice.js";
|
|
24
|
+
import { IMPORT_REGION, INSTRUCTIONS_REGION, renderInstructionsBlock, SKILLS, } from "./templates.js";
|
|
25
|
+
const AGENTS_FILE = "AGENTS.md";
|
|
26
|
+
const CLAUDE_FILE = "CLAUDE.md";
|
|
27
|
+
const CLAUDE_SKILLS_DIR = join(".claude", "skills");
|
|
28
|
+
/** CLAUDE.md import-shim body: points Claude Code (which reads CLAUDE.md, not AGENTS.md) at AGENTS.md. */
|
|
29
|
+
function importBody() {
|
|
30
|
+
return "This project's agent instructions live in AGENTS.md.\n@AGENTS.md";
|
|
31
|
+
}
|
|
32
|
+
/** The body harnery expects for a skill's file (everything after the ownership marker). */
|
|
33
|
+
function skillBody(render, binName) {
|
|
34
|
+
const content = render(binName);
|
|
35
|
+
return content.slice(content.indexOf("-->") + 3).trim();
|
|
36
|
+
}
|
|
37
|
+
/** Read `skills.exclude` from `.harnery/config.jsonc` (absent/unparseable → none). */
|
|
38
|
+
export function readSkillsExclude(projectRoot) {
|
|
39
|
+
const p = join(projectRoot, ".harnery", "config.jsonc");
|
|
40
|
+
try {
|
|
41
|
+
const cfg = JSON.parse(stripJsonComments(readFileSync(p, "utf8")));
|
|
42
|
+
const ex = cfg?.skills?.exclude;
|
|
43
|
+
if (Array.isArray(ex))
|
|
44
|
+
return new Set(ex.filter((x) => typeof x === "string"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* absent / unparseable → no exclusions */
|
|
48
|
+
}
|
|
49
|
+
return new Set();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Which shipped skills exist for this project, so the block references only the
|
|
53
|
+
* ones actually present: claude-code writes skills (unless excluded); cursor and
|
|
54
|
+
* codex get the block but no skill files, so both read false there. Kept in one
|
|
55
|
+
* place so `applyInstructions` and `checkInstructions` render byte-identical blocks.
|
|
56
|
+
*/
|
|
57
|
+
function blockSkills(projectRoot, harness) {
|
|
58
|
+
const claudeCode = harness === "claude-code";
|
|
59
|
+
const exclude = readSkillsExclude(projectRoot);
|
|
60
|
+
return {
|
|
61
|
+
decide: claudeCode && !exclude.has("harn-decide"),
|
|
62
|
+
council: claudeCode && !exclude.has("harn-council"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Inject / refresh the instructions block, the CLAUDE.md import shim (claude-code),
|
|
67
|
+
* and the shipped skills (claude-code). Idempotent: a re-run on current content
|
|
68
|
+
* writes nothing. `dryRun` reports without touching the fs.
|
|
69
|
+
*/
|
|
70
|
+
export function applyInstructions(projectRoot, opts) {
|
|
71
|
+
const actions = [];
|
|
72
|
+
const warnings = [];
|
|
73
|
+
const claudeCode = opts.harness === "claude-code";
|
|
74
|
+
// dry-run narrates the future ("would create"); a real run narrates the past.
|
|
75
|
+
const verbed = (base, past) => (opts.dryRun ? `would ${base}` : past);
|
|
76
|
+
// ── AGENTS.md orientation block ─────────────────────────────────────────
|
|
77
|
+
const agentsPath = join(projectRoot, AGENTS_FILE);
|
|
78
|
+
const agentsExisted = existsSync(agentsPath);
|
|
79
|
+
const agentsBefore = agentsExisted ? readFileSync(agentsPath, "utf8") : "";
|
|
80
|
+
const body = renderInstructionsBlock(opts.binName, blockSkills(projectRoot, opts.harness));
|
|
81
|
+
const spliced = spliceRegion(agentsBefore, INSTRUCTIONS_REGION, body);
|
|
82
|
+
if (!spliced.changed) {
|
|
83
|
+
actions.push(`· ${AGENTS_FILE} instructions block already current`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
if (!opts.dryRun)
|
|
87
|
+
writeFileSync(agentsPath, spliced.text);
|
|
88
|
+
if (!agentsExisted)
|
|
89
|
+
actions.push(`+ ${verbed("create", "created")} ${AGENTS_FILE} with the instructions block`);
|
|
90
|
+
else if (!spliced.had)
|
|
91
|
+
actions.push(`+ ${verbed("inject", "injected")} the instructions block into ${AGENTS_FILE}`);
|
|
92
|
+
else
|
|
93
|
+
actions.push(`~ ${verbed("update", "updated")} the instructions block in ${AGENTS_FILE}`);
|
|
94
|
+
}
|
|
95
|
+
// ── CLAUDE.md import shim (claude-code only) ────────────────────────────
|
|
96
|
+
if (claudeCode) {
|
|
97
|
+
const claudePath = join(projectRoot, CLAUDE_FILE);
|
|
98
|
+
if (!existsSync(claudePath)) {
|
|
99
|
+
const shim = spliceRegion("", IMPORT_REGION, importBody());
|
|
100
|
+
if (!opts.dryRun)
|
|
101
|
+
writeFileSync(claudePath, shim.text);
|
|
102
|
+
actions.push(`+ ${verbed("create", "created")} ${CLAUDE_FILE} importing @AGENTS.md`);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
const claude = readFileSync(claudePath, "utf8");
|
|
106
|
+
const sees = claude.includes("@AGENTS.md") ||
|
|
107
|
+
claude.includes(`harnery:begin ${IMPORT_REGION}`) ||
|
|
108
|
+
claude.includes(`harnery:begin ${INSTRUCTIONS_REGION}`);
|
|
109
|
+
if (sees) {
|
|
110
|
+
actions.push(`· ${CLAUDE_FILE} already reaches AGENTS.md (left untouched)`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
warnings.push(`${CLAUDE_FILE} exists but neither imports @AGENTS.md nor carries the block; left ` +
|
|
114
|
+
`untouched. For Claude Code to see the orientation, add \`@AGENTS.md\` to ${CLAUDE_FILE} ` +
|
|
115
|
+
`(or generate ${CLAUDE_FILE} from ${AGENTS_FILE}).`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// ── shipped skills (claude-code only) ───────────────────────────────────
|
|
120
|
+
if (claudeCode) {
|
|
121
|
+
const exclude = readSkillsExclude(projectRoot);
|
|
122
|
+
for (const skill of SKILLS) {
|
|
123
|
+
if (exclude.has(skill.id)) {
|
|
124
|
+
actions.push(`· skipped skill ${skill.id} (skills.exclude)`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const skillPath = join(projectRoot, CLAUDE_SKILLS_DIR, skill.relPath);
|
|
128
|
+
const content = skill.render(opts.binName);
|
|
129
|
+
const before = existsSync(skillPath) ? readFileSync(skillPath, "utf8") : null;
|
|
130
|
+
if (before === content) {
|
|
131
|
+
actions.push(`· skill ${skill.id} already current`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!opts.dryRun) {
|
|
135
|
+
mkdirSync(dirname(skillPath), { recursive: true });
|
|
136
|
+
writeFileSync(skillPath, content);
|
|
137
|
+
}
|
|
138
|
+
const fresh = before === null;
|
|
139
|
+
actions.push(`${fresh ? "+" : "~"} ${verbed(fresh ? "write" : "update", fresh ? "wrote" : "updated")} skill ${skill.id}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { actions, warnings };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Reverse {@link applyInstructions}: strip the AGENTS.md block, the CLAUDE.md
|
|
146
|
+
* import shim, and delete the shipped skill files (only ones harnery generated).
|
|
147
|
+
* A file that becomes empty once our region is gone is deleted (init created it);
|
|
148
|
+
* a hand-edited skill (no ownership marker) is left with a warning.
|
|
149
|
+
*/
|
|
150
|
+
export function removeInstructions(projectRoot, opts) {
|
|
151
|
+
const actions = [];
|
|
152
|
+
const warnings = [];
|
|
153
|
+
const claudeCode = opts.harness === "claude-code";
|
|
154
|
+
// ── AGENTS.md block ─────────────────────────────────────────────────────
|
|
155
|
+
const agentsPath = join(projectRoot, AGENTS_FILE);
|
|
156
|
+
if (existsSync(agentsPath)) {
|
|
157
|
+
const { text, removed } = removeRegion(readFileSync(agentsPath, "utf8"), INSTRUCTIONS_REGION);
|
|
158
|
+
if (!removed) {
|
|
159
|
+
actions.push(`· no instructions block in ${AGENTS_FILE}`);
|
|
160
|
+
}
|
|
161
|
+
else if (text === "") {
|
|
162
|
+
if (!opts.dryRun)
|
|
163
|
+
rmSync(agentsPath);
|
|
164
|
+
actions.push(`+ ${opts.dryRun ? "would remove" : "removed"} ${AGENTS_FILE} (was block-only)`);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
if (!opts.dryRun)
|
|
168
|
+
writeFileSync(agentsPath, text);
|
|
169
|
+
actions.push(`+ ${opts.dryRun ? "would remove" : "removed"} the instructions block from ${AGENTS_FILE}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// ── CLAUDE.md import shim (claude-code) ─────────────────────────────────
|
|
173
|
+
if (claudeCode) {
|
|
174
|
+
const claudePath = join(projectRoot, CLAUDE_FILE);
|
|
175
|
+
if (existsSync(claudePath)) {
|
|
176
|
+
const { text, removed } = removeRegion(readFileSync(claudePath, "utf8"), IMPORT_REGION);
|
|
177
|
+
if (removed) {
|
|
178
|
+
if (text === "") {
|
|
179
|
+
if (!opts.dryRun)
|
|
180
|
+
rmSync(claudePath);
|
|
181
|
+
actions.push(`+ ${opts.dryRun ? "would remove" : "removed"} ${CLAUDE_FILE} (was shim-only)`);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
if (!opts.dryRun)
|
|
185
|
+
writeFileSync(claudePath, text);
|
|
186
|
+
actions.push(`+ ${opts.dryRun ? "would remove" : "removed"} the import shim from ${CLAUDE_FILE}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// ── shipped skills (claude-code) ────────────────────────────────────────
|
|
192
|
+
if (claudeCode) {
|
|
193
|
+
for (const skill of SKILLS) {
|
|
194
|
+
const skillPath = join(projectRoot, CLAUDE_SKILLS_DIR, skill.relPath);
|
|
195
|
+
if (!existsSync(skillPath))
|
|
196
|
+
continue;
|
|
197
|
+
if (!isOwnedFile(readFileSync(skillPath, "utf8"))) {
|
|
198
|
+
warnings.push(`left ${skill.relPath} (hand-edited; no harnery ownership marker)`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (!opts.dryRun) {
|
|
202
|
+
rmSync(skillPath);
|
|
203
|
+
// drop the now-empty skill dir (harn-decide/), leaving .claude/skills/ intact
|
|
204
|
+
const dir = dirname(skillPath);
|
|
205
|
+
try {
|
|
206
|
+
if (readdirSync(dir).length === 0)
|
|
207
|
+
rmdirSync(dir);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
/* dir not empty or gone → leave it */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
actions.push(`+ ${opts.dryRun ? "would delete" : "deleted"} skill ${skill.id}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { actions, warnings };
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Read-only drift report for `init --check`: the AGENTS.md block and each
|
|
220
|
+
* shipped skill (claude-code). Fresh → exit 0; any stale / missing / hand-edit
|
|
221
|
+
* → drift (exit 2); an unreadable file → error (exit 1). Mirrors the wiki-theme
|
|
222
|
+
* `--check-only` contract the first host wires into pre-commit.
|
|
223
|
+
*/
|
|
224
|
+
export function checkInstructions(projectRoot, opts) {
|
|
225
|
+
const issues = [];
|
|
226
|
+
let errored = false;
|
|
227
|
+
const note = (label, status) => {
|
|
228
|
+
if (status === "missing")
|
|
229
|
+
issues.push(`${label}: missing`);
|
|
230
|
+
else if (status === "stale")
|
|
231
|
+
issues.push(`${label}: stale (re-run init)`);
|
|
232
|
+
};
|
|
233
|
+
try {
|
|
234
|
+
const agentsPath = join(projectRoot, AGENTS_FILE);
|
|
235
|
+
const content = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : "";
|
|
236
|
+
note(`${AGENTS_FILE} block`, checkRegion(content, INSTRUCTIONS_REGION, renderInstructionsBlock(opts.binName, blockSkills(projectRoot, opts.harness))));
|
|
237
|
+
if (opts.harness === "claude-code") {
|
|
238
|
+
const exclude = readSkillsExclude(projectRoot);
|
|
239
|
+
for (const skill of SKILLS) {
|
|
240
|
+
if (exclude.has(skill.id))
|
|
241
|
+
continue;
|
|
242
|
+
const skillPath = join(projectRoot, CLAUDE_SKILLS_DIR, skill.relPath);
|
|
243
|
+
const c = existsSync(skillPath) ? readFileSync(skillPath, "utf8") : "";
|
|
244
|
+
note(`skill ${skill.id}`, checkOwnedSkill(c, skillBody(skill.render, opts.binName)));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
errored = true;
|
|
250
|
+
issues.push(`error reading instructions state: ${err.message}`);
|
|
251
|
+
}
|
|
252
|
+
if (errored)
|
|
253
|
+
return { status: "error", issues };
|
|
254
|
+
return { status: issues.length === 0 ? "fresh" : "drift", issues };
|
|
255
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
/** 8-hex-char content hash stamped into every managed marker. */
|
|
24
|
+
export declare function shortHash(s: string): string;
|
|
25
|
+
/** Canonical region block: begin-marker, body flanked by newlines, end-marker. */
|
|
26
|
+
export declare function regionBlock(region: string, body: string): string;
|
|
27
|
+
export type ManagedStatus = "fresh" | "stale" | "missing";
|
|
28
|
+
export interface SpliceResult {
|
|
29
|
+
text: string;
|
|
30
|
+
changed: boolean;
|
|
31
|
+
/** the region was already present before this splice */
|
|
32
|
+
had: boolean;
|
|
33
|
+
/** present-but-differs (hash or body); only meaningful when `had` is true */
|
|
34
|
+
stale: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Re-splice (or first-time append) a managed region into `content`. Idempotent:
|
|
38
|
+
* applying twice yields identical bytes. Content outside the markers is never
|
|
39
|
+
* touched; a re-splice replaces the region wherever the consumer moved it. When
|
|
40
|
+
* absent, the block is appended after existing content (blank-line separated);
|
|
41
|
+
* an empty/whitespace-only `content` becomes just the block.
|
|
42
|
+
*/
|
|
43
|
+
export declare function spliceRegion(content: string, region: string, body: string): SpliceResult;
|
|
44
|
+
/**
|
|
45
|
+
* Remove a managed region, collapsing the blank lines it leaves behind. Returns
|
|
46
|
+
* `removed: false` (content unchanged) when the region is absent. When the region
|
|
47
|
+
* was the file's only content, the result is the empty string — the caller
|
|
48
|
+
* decides whether to delete the file.
|
|
49
|
+
*/
|
|
50
|
+
export declare function removeRegion(content: string, region: string): {
|
|
51
|
+
text: string;
|
|
52
|
+
removed: boolean;
|
|
53
|
+
};
|
|
54
|
+
/** Region freshness: missing, stale (hash or body drifted), or fresh. */
|
|
55
|
+
export declare function checkRegion(content: string, region: string, body: string): ManagedStatus;
|
|
56
|
+
/** True when a file carries harnery's ownership header (deinit may delete it). */
|
|
57
|
+
export declare function isOwnedFile(content: string): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Wrap a skill file: frontmatter, then a hash-stamped ownership header comment,
|
|
60
|
+
* then the body. The hash covers the trimmed body so `--check` catches a
|
|
61
|
+
* hand-edit even if the marker was left alone. `binName` renders the regenerate
|
|
62
|
+
* / remove hint in the host's own bin.
|
|
63
|
+
*/
|
|
64
|
+
export declare function buildOwnedSkill(opts: {
|
|
65
|
+
name: string;
|
|
66
|
+
description: string;
|
|
67
|
+
argumentHint?: string;
|
|
68
|
+
binName: string;
|
|
69
|
+
body: string;
|
|
70
|
+
}): string;
|
|
71
|
+
/** Owned-skill freshness against a freshly-rendered body (trimmed compare). */
|
|
72
|
+
export declare function checkOwnedSkill(content: string, freshBody: string): ManagedStatus;
|
|
73
|
+
//# sourceMappingURL=splice.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"splice.d.ts","sourceRoot":"","sources":["../../../src/lib/instructions/splice.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAIH,iEAAiE;AACjE,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAE3C;AAcD,kFAAkF;AAClF,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,OAAO,GAAG,SAAS,CAAC;AAE1D,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,wDAAwD;IACxD,GAAG,EAAE,OAAO,CAAC;IACb,6EAA6E;IAC7E,KAAK,EAAE,OAAO,CAAC;CAChB;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,CAaxF;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAShG;AAED,yEAAyE;AACzE,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa,CAIxF;AAMD,kFAAkF;AAClF,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,MAAM,CAcT;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,aAAa,CAKjF"}
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
/** 8-hex-char content hash stamped into every managed marker. */
|
|
25
|
+
export function shortHash(s) {
|
|
26
|
+
return createHash("sha256").update(s).digest("hex").slice(0, 8);
|
|
27
|
+
}
|
|
28
|
+
function escapeRe(s) {
|
|
29
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
30
|
+
}
|
|
31
|
+
/** Capture regex for a named managed region: begin-marker, body, end-marker. */
|
|
32
|
+
function regionRe(region) {
|
|
33
|
+
const r = escapeRe(region);
|
|
34
|
+
return new RegExp(`(<!--\\s*harnery:begin ${r}(?:\\s+v=([0-9a-f]*))?\\s*-->)([\\s\\S]*?)(<!--\\s*harnery:end ${r}\\s*-->)`);
|
|
35
|
+
}
|
|
36
|
+
/** Canonical region block: begin-marker, body flanked by newlines, end-marker. */
|
|
37
|
+
export function regionBlock(region, body) {
|
|
38
|
+
return `<!-- harnery:begin ${region} v=${shortHash(body)} -->\n${body}\n<!-- harnery:end ${region} -->`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Re-splice (or first-time append) a managed region into `content`. Idempotent:
|
|
42
|
+
* applying twice yields identical bytes. Content outside the markers is never
|
|
43
|
+
* touched; a re-splice replaces the region wherever the consumer moved it. When
|
|
44
|
+
* absent, the block is appended after existing content (blank-line separated);
|
|
45
|
+
* an empty/whitespace-only `content` becomes just the block.
|
|
46
|
+
*/
|
|
47
|
+
export function spliceRegion(content, region, body) {
|
|
48
|
+
const re = regionRe(region);
|
|
49
|
+
const m = content.match(re);
|
|
50
|
+
const fresh = regionBlock(region, body);
|
|
51
|
+
if (m) {
|
|
52
|
+
const stale = m[2] !== shortHash(body) || m[3] !== `\n${body}\n`;
|
|
53
|
+
// Replacer fn avoids `$`-in-body being read as a capture reference.
|
|
54
|
+
const text = content.replace(re, () => fresh);
|
|
55
|
+
return { text, changed: text !== content, had: true, stale };
|
|
56
|
+
}
|
|
57
|
+
const trimmed = content.replace(/\s+$/, "");
|
|
58
|
+
const text = trimmed ? `${trimmed}\n\n${fresh}\n` : `${fresh}\n`;
|
|
59
|
+
return { text, changed: true, had: false, stale: false };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Remove a managed region, collapsing the blank lines it leaves behind. Returns
|
|
63
|
+
* `removed: false` (content unchanged) when the region is absent. When the region
|
|
64
|
+
* was the file's only content, the result is the empty string — the caller
|
|
65
|
+
* decides whether to delete the file.
|
|
66
|
+
*/
|
|
67
|
+
export function removeRegion(content, region) {
|
|
68
|
+
const re = regionRe(region);
|
|
69
|
+
if (!re.test(content))
|
|
70
|
+
return { text: content, removed: false };
|
|
71
|
+
const stripped = content
|
|
72
|
+
.replace(re, "")
|
|
73
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
74
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
75
|
+
.trim();
|
|
76
|
+
return { text: stripped ? `${stripped}\n` : "", removed: true };
|
|
77
|
+
}
|
|
78
|
+
/** Region freshness: missing, stale (hash or body drifted), or fresh. */
|
|
79
|
+
export function checkRegion(content, region, body) {
|
|
80
|
+
const m = content.match(regionRe(region));
|
|
81
|
+
if (!m)
|
|
82
|
+
return "missing";
|
|
83
|
+
return m[2] === shortHash(body) && m[3] === `\n${body}\n` ? "fresh" : "stale";
|
|
84
|
+
}
|
|
85
|
+
// ── Fully-owned files (shipped skills) ──────────────────────────────────────
|
|
86
|
+
const OWNED_RE = /<!--\s*harnery:generated\s+(\S+)\s+v=([0-9a-f]*)[\s\S]*?-->/;
|
|
87
|
+
/** True when a file carries harnery's ownership header (deinit may delete it). */
|
|
88
|
+
export function isOwnedFile(content) {
|
|
89
|
+
return OWNED_RE.test(content);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Wrap a skill file: frontmatter, then a hash-stamped ownership header comment,
|
|
93
|
+
* then the body. The hash covers the trimmed body so `--check` catches a
|
|
94
|
+
* hand-edit even if the marker was left alone. `binName` renders the regenerate
|
|
95
|
+
* / remove hint in the host's own bin.
|
|
96
|
+
*/
|
|
97
|
+
export function buildOwnedSkill(opts) {
|
|
98
|
+
const fm = [
|
|
99
|
+
"---",
|
|
100
|
+
`name: ${opts.name}`,
|
|
101
|
+
`description: ${opts.description}`,
|
|
102
|
+
...(opts.argumentHint ? [`argument-hint: ${JSON.stringify(opts.argumentHint)}`] : []),
|
|
103
|
+
"---",
|
|
104
|
+
].join("\n");
|
|
105
|
+
const body = opts.body.trim();
|
|
106
|
+
const marker = `<!-- harnery:generated ${opts.name} v=${shortHash(body)} — machine-owned; ` +
|
|
107
|
+
`regenerated by \`${opts.binName} init\`, removed by \`${opts.binName} deinit\`. ` +
|
|
108
|
+
`Edit the harnery template, not this file. -->`;
|
|
109
|
+
return `${fm}\n${marker}\n\n${body}\n`;
|
|
110
|
+
}
|
|
111
|
+
/** Owned-skill freshness against a freshly-rendered body (trimmed compare). */
|
|
112
|
+
export function checkOwnedSkill(content, freshBody) {
|
|
113
|
+
const m = OWNED_RE.exec(content);
|
|
114
|
+
if (!m)
|
|
115
|
+
return "missing";
|
|
116
|
+
const body = content.slice(m.index + m[0].length).trim();
|
|
117
|
+
return m[2] === shortHash(freshBody.trim()) && body === freshBody.trim() ? "fresh" : "stale";
|
|
118
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent-facing content harnery ships into a consumer: one orientation block
|
|
3
|
+
* for `AGENTS.md` and the generic skills. Everything here is engine mechanics
|
|
4
|
+
* only — no triage rubric, no escalation targets, no host doc-layout policy
|
|
5
|
+
* (those stay host-authored, per ADR 0007's portability split). Every command
|
|
6
|
+
* string renders through `binName`; a template that only reads for `harn` is a
|
|
7
|
+
* bug the portability guard exists to catch.
|
|
8
|
+
*
|
|
9
|
+
* Content, not paths: templates are TS string builders (not shipped `.md`
|
|
10
|
+
* files), so they compile into `dist/` and resolve identically under Bun and
|
|
11
|
+
* Node — no `files`-field copy or package-path guesswork.
|
|
12
|
+
*/
|
|
13
|
+
/** Managed-region name for the AGENTS.md orientation block. */
|
|
14
|
+
export declare const INSTRUCTIONS_REGION = "instructions";
|
|
15
|
+
/** Managed-region name for the CLAUDE.md `@AGENTS.md` import shim. */
|
|
16
|
+
export declare const IMPORT_REGION = "import";
|
|
17
|
+
/** Which shipped skills exist in the project the block is rendered for. */
|
|
18
|
+
export interface BlockSkills {
|
|
19
|
+
/** the `harn-decide` skill file is present (claude-code, not excluded) */
|
|
20
|
+
decide: boolean;
|
|
21
|
+
/** the `harn-council` skill file is present */
|
|
22
|
+
council: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The always-on orientation spliced into `AGENTS.md`. Target ≤ 80 rendered
|
|
26
|
+
* lines: it costs every agent context on every turn, so it states that each
|
|
27
|
+
* surface *exists* and gives one line of *when* — the *how* lives in the skills
|
|
28
|
+
* and each command's `--help`. Skill names are fixed (`harn-decide`,
|
|
29
|
+
* `harn-council`) even for a renamed bin; only command strings track `binName`.
|
|
30
|
+
*
|
|
31
|
+
* The block only points at a skill that actually exists here: a host that
|
|
32
|
+
* excludes one via `skills.exclude`, or a harness with no skill primitive
|
|
33
|
+
* (cursor/codex get the block but no skill files), gets a `--help` pointer
|
|
34
|
+
* instead of a dangling reference to a skill it doesn't have.
|
|
35
|
+
*/
|
|
36
|
+
export declare function renderInstructionsBlock(binName: string, skills?: BlockSkills): string;
|
|
37
|
+
/** A shipped skill: its harness-relative file path + a bin-name-aware renderer. */
|
|
38
|
+
export interface SkillTemplate {
|
|
39
|
+
id: string;
|
|
40
|
+
/** path under the harness skill dir, e.g. `harn-decide/SKILL.md` */
|
|
41
|
+
relPath: string;
|
|
42
|
+
render: (binName: string) => string;
|
|
43
|
+
}
|
|
44
|
+
export declare const SKILLS: SkillTemplate[];
|
|
45
|
+
//# sourceMappingURL=templates.d.ts.map
|