@hydraharness/harness-hooks-registry 0.1.1-rc.5
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/LICENSE +21 -0
- package/README.md +36 -0
- package/lib/index.js +386 -0
- package/lib/invariant.js +32 -0
- package/lib/types/index.d.ts +117 -0
- package/lib/types/index.js +434 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +34 -0
- package/lib/types/types.d.ts +89 -0
- package/lib/types/types.js +3 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @hydraharness/harness-hooks-registry
|
|
2
|
+
|
|
3
|
+
`HookRecordRegistry` (`ctx.hookRecords`) owns the user's own hook records. The records live in the `hooks` settings namespace, so one added from a configuration surface is written to `$HYDRA_HOME/settings.yaml` and mounts again on the next start; one added by hand-editing that document mounts without a restart. Each enabled record is mounted on its dialect's bridge — `@hydraharness/harness-hooks-claude-code` or `@hydraharness/harness-hooks-codex` — which owns the payloads, matcher semantics, and extension-point mapping.
|
|
4
|
+
|
|
5
|
+
## Service API
|
|
6
|
+
|
|
7
|
+
`list()` projects every stored record with its live status, the events its definitions cover, and how many command hooks they declare. `define(request)` stores one complete definition and converges the mounted set: `mode: 'create'` requires an unused name, and `mode: 'replace'` requires an existing record. These conditions are checked inside the serialized mutation chain, so concurrent creates cannot overwrite one another and an edit cannot recreate a removed record. `setEnabled({ name, enabled })` changes only the desired state. `remove(name)` deletes the record, unmounts it, and deletes any document this registry materialized for it. Every mutation and every reconciliation runs on one serialized chain.
|
|
8
|
+
|
|
9
|
+
On replacement, omitted `enabled` and `defaultTimeoutMs` retain their stored values. Other omitted optional fields clear the stored values; callers must supply the complete document source, since inline definitions are not returned by `list()`.
|
|
10
|
+
|
|
11
|
+
## Stored records
|
|
12
|
+
|
|
13
|
+
Registry writes carry the revision read before constructing the next record list. A concurrent settings change rejects the write with `SettingsConflictError` and refreshes the stored projection; retry against that projection. This prevents stale lists from restoring deleted records or discarding another writer's changes.
|
|
14
|
+
|
|
15
|
+
`hooks.records` is an ordered list of at most 50 records. A record carries `name` (`[A-Za-z0-9_-]{1,64}`, unique across records), `dialect` (`claude-code` or `codex`), exactly one document source, `defaultTimeoutMs`, and `enabled` (default `false`, so a new record is inert until switched on). `claude-code` records may also carry `pluginRoot` and `projectDir`, which the bridge substitutes into command strings.
|
|
16
|
+
|
|
17
|
+
The document source is either `configPath` — an absolute path to a hook document the harness reads as-is — or `config`, the definitions stored inline in the settings document as a bare event map or a `{ hooks: … }` wrapper. Inline definitions are capped at 256 KiB and materialized to `$HYDRA_HOME/hooks/<name>.json` on every mount, because both bridges read one file path at load; editing the inline section is therefore enough to change what runs.
|
|
18
|
+
|
|
19
|
+
`define` parses the definitions with the record's own dialect parser before it persists, so a stored record can never be silently inert. At mount time the same parse decides the record's projection: a record whose document is missing, is not valid JSON, or declares nothing its dialect can run is reported with status `invalid` and the reason instead of failing the process — a hand-edited document must not stop every other record from mounting.
|
|
20
|
+
|
|
21
|
+
`hooks-registry/reconciled` is emitted after each reconciliation settles, carrying the projection. The package invariant checks the registry's one contract on that event: an accepted enabled record is live and a disabled one is not.
|
|
22
|
+
|
|
23
|
+
Mounts belong to the registry's Host context, so they retain their declared services and survive disposal of the API caller. Definition validation requires at least one supported synchronous command hook, and the inline-document limit measures UTF-8 bytes.
|
|
24
|
+
|
|
25
|
+
## Model Experience
|
|
26
|
+
|
|
27
|
+
Indirectly, through the bridge a record mounts. `@hydraharness/harness-hooks-claude-code` and `@hydraharness/harness-hooks-codex` own everything a hook injects into a request — `SessionStart` and `UserPromptSubmit` context, `PostToolUse` feedback, `Stop` continuation messages — and this package decides only which of their configurations are live. It assembles no model input of its own.
|
|
28
|
+
|
|
29
|
+
#### KV Cache effect
|
|
30
|
+
|
|
31
|
+
Independent of this package's own operations, and consequential through them: mounting a record makes its hooks able to inject context into later requests, which the bridges document. Mounting or unmounting between requests does not itself rewrite an existing prefix; a hook that injects a message appends to the conversation like any other message.
|
|
32
|
+
|
|
33
|
+
## Known Limitations and Deferred Work
|
|
34
|
+
|
|
35
|
+
- **Process-wide records only** — each bridge reads one document at load, so a record applies to every session in the process. Per-session or project-local hook discovery stays the bridges' own `TODO(per-session-hook-config)`.
|
|
36
|
+
- **No dialect conversion** — a record is mounted by the dialect it declares; the registry never translates a Claude Code document into Codex spelling or the reverse, and each dialect silently drops events the other supports.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { Service } from "@hydraharness/cordis";
|
|
4
|
+
import z from "@hydraharness/schemastery";
|
|
5
|
+
import { writeFileAtomic } from "@hydraharness/harness-atomic-write";
|
|
6
|
+
import { resolveHydraHome } from "@hydraharness/harness-home-paths";
|
|
7
|
+
import { settingsNamespace } from "@hydraharness/harness-settings";
|
|
8
|
+
import { apply, inject as inject$1 } from "@hydraharness/harness-hooks-claude-code";
|
|
9
|
+
import { apply as apply$1, inject as inject$2 } from "@hydraharness/harness-hooks-codex";
|
|
10
|
+
import { parseClaudeCodeConfig } from "@hydraharness/harness-hooks-claude-code/config";
|
|
11
|
+
import { parseCodexConfig } from "@hydraharness/harness-hooks-codex/config";
|
|
12
|
+
//#region lib/types/index.js
|
|
13
|
+
/**
|
|
14
|
+
* User-declared hook records: one settings-backed registry that mounts every
|
|
15
|
+
* enabled record on its dialect's bridge (`@hydraharness/harness-hooks-claude-code` or
|
|
16
|
+
* `@hydraharness/harness-hooks-codex`) and unmounts it when the record is disabled,
|
|
17
|
+
* redefined, or removed. The stored document (`hooks.records` in the harness
|
|
18
|
+
* settings file) is the only source of truth, so a hook added from a
|
|
19
|
+
* configuration surface survives a restart and one added by hand-editing that
|
|
20
|
+
* document mounts without one.
|
|
21
|
+
*
|
|
22
|
+
* A record either points at an existing hook document (`configPath`) or carries
|
|
23
|
+
* its definitions inline. Inline definitions are materialized under the harness
|
|
24
|
+
* home, because both bridges read one file path at load.
|
|
25
|
+
* @module @hydraharness/harness-hooks-registry
|
|
26
|
+
*/
|
|
27
|
+
/** Settings namespace holding every user-declared hook record. */
|
|
28
|
+
const HOOKS_SETTINGS_NAMESPACE = settingsNamespace("hooks");
|
|
29
|
+
/** Accepted record name. */
|
|
30
|
+
const RECORD_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/u;
|
|
31
|
+
const MAX_RECORDS = 50;
|
|
32
|
+
const MAX_INLINE_BYTES = 256 * 1024;
|
|
33
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 6e5;
|
|
34
|
+
const StoredRecordSchema = z.object({
|
|
35
|
+
name: z.string().required(),
|
|
36
|
+
dialect: z.union([z.const("claude-code"), z.const("codex")]).default("claude-code"),
|
|
37
|
+
configPath: z.string().default(""),
|
|
38
|
+
config: z.dict(z.any()).default({}),
|
|
39
|
+
pluginRoot: z.string().default(""),
|
|
40
|
+
projectDir: z.string().default(""),
|
|
41
|
+
defaultTimeoutMs: z.number().step(1).min(1).default(DEFAULT_HOOK_TIMEOUT_MS),
|
|
42
|
+
enabled: z.boolean().default(false)
|
|
43
|
+
});
|
|
44
|
+
const HooksSettingsSchema = z.object({ records: z.array(StoredRecordSchema).max(MAX_RECORDS).default([]) });
|
|
45
|
+
/** Which document source a stored record declares. */
|
|
46
|
+
function sourceKind(record) {
|
|
47
|
+
return record.configPath === "" ? "inline" : "file";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Reject a definition this registry could not mount. Every check answers a
|
|
51
|
+
* constraint the schema cannot express: the name grammar, that exactly one
|
|
52
|
+
* document source is declared, that a path is absolute, and that inline
|
|
53
|
+
* definitions stay inside the document budget.
|
|
54
|
+
* @param record - one stored record to judge.
|
|
55
|
+
* @param label - operation name used in the diagnostic.
|
|
56
|
+
*/
|
|
57
|
+
function assertMountable(record, label) {
|
|
58
|
+
if (!RECORD_NAME_PATTERN.test(record.name)) throw new Error(`${label}: record name must match ${String(RECORD_NAME_PATTERN)}`);
|
|
59
|
+
const inline = Object.keys(record.config).length > 0;
|
|
60
|
+
if (record.configPath === "" && !inline) throw new Error(`${label}: record ${record.name} needs either a configPath or inline hook definitions`);
|
|
61
|
+
if (record.configPath !== "" && inline) throw new Error(`${label}: record ${record.name} declares both a configPath and inline hook definitions`);
|
|
62
|
+
if (record.configPath !== "" && !isAbsolute(record.configPath)) throw new Error(`${label}: record ${record.name} configPath must be absolute`);
|
|
63
|
+
if (inline && Buffer.byteLength(JSON.stringify(record.config), "utf8") > MAX_INLINE_BYTES) throw new Error(`${label}: record ${record.name} inline hooks exceed ${String(MAX_INLINE_BYTES)} bytes`);
|
|
64
|
+
if (record.dialect === "codex" && (record.pluginRoot !== "" || record.projectDir !== "")) throw new Error(`${label}: record ${record.name} substitution roots apply to claude-code records only`);
|
|
65
|
+
}
|
|
66
|
+
/** Reject a stored section whose records collide, so no mount silently wins. */
|
|
67
|
+
function assertUniqueNames(settings) {
|
|
68
|
+
const seen = /* @__PURE__ */ new Set();
|
|
69
|
+
for (const record of settings.records) {
|
|
70
|
+
if (seen.has(record.name)) throw new Error(`hookRecords: record name ${JSON.stringify(record.name)} is declared more than once`);
|
|
71
|
+
seen.add(record.name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Parse one document with its dialect's parser, so what the registry reports is
|
|
76
|
+
* exactly what the bridge will run.
|
|
77
|
+
* @param dialect - the record's config dialect.
|
|
78
|
+
* @param raw - parsed JSON document.
|
|
79
|
+
* @returns the event names covered and the total command-hook count.
|
|
80
|
+
*/
|
|
81
|
+
function summarize(dialect, raw) {
|
|
82
|
+
const parsed = dialect === "claude-code" ? parseClaudeCodeConfig(raw) : parseCodexConfig(raw);
|
|
83
|
+
const events = Object.keys(parsed.config).sort();
|
|
84
|
+
let hookCount = 0;
|
|
85
|
+
for (const groups of Object.values(parsed.config)) for (const group of groups) hookCount += group.hooks.length;
|
|
86
|
+
if (hookCount === 0) throw new Error(`hookRecords: ${dialect} document contains no supported synchronous command hooks`);
|
|
87
|
+
return {
|
|
88
|
+
events,
|
|
89
|
+
hookCount
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Fold one write request onto the record it replaces. Every field is restated
|
|
94
|
+
* by the caller: unlike a credential, nothing here is withheld from a read, so
|
|
95
|
+
* an omission is a deliberate clear.
|
|
96
|
+
* @param request - complete definition supplied by the caller.
|
|
97
|
+
* @param previous - the record being replaced, when one exists.
|
|
98
|
+
* @returns the next stored record.
|
|
99
|
+
*/
|
|
100
|
+
function foldDefinition(request, previous) {
|
|
101
|
+
return {
|
|
102
|
+
name: request.name.trim(),
|
|
103
|
+
dialect: request.dialect,
|
|
104
|
+
configPath: (request.configPath ?? "").trim(),
|
|
105
|
+
config: { ...request.config },
|
|
106
|
+
pluginRoot: (request.pluginRoot ?? "").trim(),
|
|
107
|
+
projectDir: (request.projectDir ?? "").trim(),
|
|
108
|
+
defaultTimeoutMs: request.defaultTimeoutMs ?? previous?.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
|
|
109
|
+
enabled: request.enabled ?? previous?.enabled ?? false
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Services required by the registry; the bridges themselves need `shell`. */
|
|
113
|
+
const inject = ["settings", "shell"];
|
|
114
|
+
/**
|
|
115
|
+
* Settings-backed hook records with their live bridge mounts. Reconciliation is
|
|
116
|
+
* idempotent and runs on one serialized chain, so a document commit from any
|
|
117
|
+
* source — this service's own write, another process, or a hand edit —
|
|
118
|
+
* converges the mounted set on the stored one.
|
|
119
|
+
* Mutations reject with `SettingsConflictError` if another writer changes the
|
|
120
|
+
* stored section after the read; retry uses the refreshed projection.
|
|
121
|
+
*/
|
|
122
|
+
var HookRecordRegistry = class extends Service {
|
|
123
|
+
static inject = inject;
|
|
124
|
+
/** Bridge mounts belong to this Host service, not to a traced API caller. */
|
|
125
|
+
owner;
|
|
126
|
+
static Config = z.object({ hydraHome: z.string() });
|
|
127
|
+
settings;
|
|
128
|
+
live = /* @__PURE__ */ new Map();
|
|
129
|
+
/** Stored records this registry refuses to mount, with the refusal reason. */
|
|
130
|
+
invalid = /* @__PURE__ */ new Map();
|
|
131
|
+
/** Directory holding the documents materialized from inline definitions. */
|
|
132
|
+
inlineRoot;
|
|
133
|
+
tail = Promise.resolve();
|
|
134
|
+
stopped = false;
|
|
135
|
+
constructor(ctx, config = {}) {
|
|
136
|
+
super(ctx, "hookRecords");
|
|
137
|
+
this.owner = ctx;
|
|
138
|
+
this.inlineRoot = join(resolveHydraHome(config.hydraHome), "hooks");
|
|
139
|
+
this.settings = ctx.settings.register(HOOKS_SETTINGS_NAMESPACE, HooksSettingsSchema, { validate: assertUniqueNames });
|
|
140
|
+
}
|
|
141
|
+
async [Service.init]() {
|
|
142
|
+
this.ctx.effect(() => () => this.shutdown(), "hookRecords.dispose");
|
|
143
|
+
this.ctx.effect(() => this.settings.watch(() => this.enqueue(() => this.reconcile())), "hookRecords.settingsWatch");
|
|
144
|
+
await this.enqueue(() => this.reconcile());
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Project every stored record with its live state.
|
|
148
|
+
* @returns the current record projection, in stored order.
|
|
149
|
+
*/
|
|
150
|
+
list() {
|
|
151
|
+
return { records: this.settings.get().records.map((record) => this.view(record)) };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Create or replace one complete definition as requested, then
|
|
155
|
+
* converge the mounted set. A definition this registry could not mount is
|
|
156
|
+
* refused before anything persists.
|
|
157
|
+
* @param request - complete record definition.
|
|
158
|
+
* @returns the refreshed projection.
|
|
159
|
+
* @throws If create names an existing record or replace names a missing record.
|
|
160
|
+
*/
|
|
161
|
+
define(request) {
|
|
162
|
+
return this.enqueue(async () => {
|
|
163
|
+
const revision = this.settings.revision;
|
|
164
|
+
const records = this.settings.get().records;
|
|
165
|
+
const index = records.findIndex((candidate) => candidate.name === request.name.trim());
|
|
166
|
+
if (request.mode === "create" && index !== -1) throw new Error(`hookRecords.define: record ${request.name} already exists`);
|
|
167
|
+
if (request.mode === "replace" && index === -1) throw new Error(`hookRecords.define: record ${request.name} is not configured`);
|
|
168
|
+
const record = foldDefinition(request, index === -1 ? void 0 : records[index]);
|
|
169
|
+
assertMountable(record, "hookRecords.define");
|
|
170
|
+
summarize(record.dialect, await this.readDefinitions(record));
|
|
171
|
+
const next = index === -1 ? [...records, record] : records.map((candidate, position) => position === index ? record : candidate);
|
|
172
|
+
if (next.length > MAX_RECORDS) throw new Error(`hookRecords.define: at most ${String(MAX_RECORDS)} records are supported`);
|
|
173
|
+
await this.ctx.settings.update(HOOKS_SETTINGS_NAMESPACE, { records: next }, revision);
|
|
174
|
+
await this.reconcile();
|
|
175
|
+
return this.list();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Change one stored record's desired state.
|
|
180
|
+
* @param request - target record and desired state.
|
|
181
|
+
* @returns the refreshed projection.
|
|
182
|
+
*/
|
|
183
|
+
setEnabled(request) {
|
|
184
|
+
return this.enqueue(async () => {
|
|
185
|
+
const revision = this.settings.revision;
|
|
186
|
+
const records = this.settings.get().records;
|
|
187
|
+
const index = records.findIndex((candidate) => candidate.name === request.name);
|
|
188
|
+
if (index === -1) throw new Error(`hookRecords.setEnabled: record ${request.name} is not configured`);
|
|
189
|
+
const next = records.map((candidate, position) => position === index ? {
|
|
190
|
+
...candidate,
|
|
191
|
+
enabled: request.enabled
|
|
192
|
+
} : candidate);
|
|
193
|
+
await this.ctx.settings.update(HOOKS_SETTINGS_NAMESPACE, { records: next }, revision);
|
|
194
|
+
await this.reconcile();
|
|
195
|
+
return this.list();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Remove one stored record, unmount it, and delete any document this registry
|
|
200
|
+
* materialized for it.
|
|
201
|
+
* @param name - stored record name.
|
|
202
|
+
* @returns the refreshed projection.
|
|
203
|
+
*/
|
|
204
|
+
remove(name) {
|
|
205
|
+
return this.enqueue(async () => {
|
|
206
|
+
const revision = this.settings.revision;
|
|
207
|
+
const records = this.settings.get().records;
|
|
208
|
+
if (!records.some((candidate) => candidate.name === name)) throw new Error(`hookRecords.remove: record ${name} is not configured`);
|
|
209
|
+
await this.ctx.settings.replace(HOOKS_SETTINGS_NAMESPACE, { records: records.filter((candidate) => candidate.name !== name) }, revision);
|
|
210
|
+
await this.reconcile();
|
|
211
|
+
await rm(this.inlinePath(name), { force: true });
|
|
212
|
+
return this.list();
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/** Serialize every mutation and reconciliation on one chain. */
|
|
216
|
+
enqueue(task) {
|
|
217
|
+
const run = this.tail.then(task, task);
|
|
218
|
+
this.tail = run.then(() => void 0, () => void 0);
|
|
219
|
+
return run;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Converge the mounted set on the stored one: unmount every record that is
|
|
223
|
+
* gone, disabled, or redefined, then mount every enabled record that is not
|
|
224
|
+
* already live from the same definition. A record whose document is missing,
|
|
225
|
+
* unparseable, or refused by its dialect is recorded as `invalid` with the
|
|
226
|
+
* diagnostic and does not stop the others.
|
|
227
|
+
*/
|
|
228
|
+
async reconcile() {
|
|
229
|
+
if (this.stopped) return;
|
|
230
|
+
const records = new Map(this.settings.get().records.map((record) => [record.name, record]));
|
|
231
|
+
this.invalid.clear();
|
|
232
|
+
for (const [name, mount] of [...this.live]) {
|
|
233
|
+
const record = records.get(name);
|
|
234
|
+
if (record?.enabled === true && recordDigest(record) === mount.digest) continue;
|
|
235
|
+
this.live.delete(name);
|
|
236
|
+
await disposeQuietly(mount.fiber);
|
|
237
|
+
}
|
|
238
|
+
for (const record of records.values()) {
|
|
239
|
+
if (!record.enabled || this.live.has(record.name)) continue;
|
|
240
|
+
await this.mount(record);
|
|
241
|
+
}
|
|
242
|
+
this.ctx.emit("hooks-registry/reconciled", this.list());
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Materialize the record's document when it is inline, then start its
|
|
246
|
+
* dialect's bridge. A refusal is stored as `invalid` rather than thrown: a
|
|
247
|
+
* hand-edited document must not stop every other record from mounting.
|
|
248
|
+
*/
|
|
249
|
+
async mount(record) {
|
|
250
|
+
let configPath;
|
|
251
|
+
let summary;
|
|
252
|
+
try {
|
|
253
|
+
assertMountable(record, "hookRecords");
|
|
254
|
+
configPath = await this.materialize(record);
|
|
255
|
+
summary = summarize(record.dialect, await this.readDefinitions(record));
|
|
256
|
+
} catch (error) {
|
|
257
|
+
/* v8 ignore next -- validation, JSON parsing, and local filesystem operations throw Error instances. */
|
|
258
|
+
this.invalid.set(record.name, error instanceof Error ? error.message : String(error));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const fiber = record.dialect === "claude-code" ? this.owner.plugin({
|
|
262
|
+
name: `hook-record:${record.name}`,
|
|
263
|
+
inject: inject$1,
|
|
264
|
+
apply
|
|
265
|
+
}, {
|
|
266
|
+
configPath,
|
|
267
|
+
defaultTimeoutMs: record.defaultTimeoutMs,
|
|
268
|
+
...record.pluginRoot === "" ? {} : { pluginRoot: record.pluginRoot },
|
|
269
|
+
...record.projectDir === "" ? {} : { projectDir: record.projectDir }
|
|
270
|
+
}) : this.owner.plugin({
|
|
271
|
+
name: `hook-record:${record.name}`,
|
|
272
|
+
inject: inject$2,
|
|
273
|
+
apply: apply$1
|
|
274
|
+
}, {
|
|
275
|
+
configPath,
|
|
276
|
+
defaultTimeoutMs: record.defaultTimeoutMs
|
|
277
|
+
});
|
|
278
|
+
const mount = {
|
|
279
|
+
fiber,
|
|
280
|
+
digest: recordDigest(record),
|
|
281
|
+
events: summary.events,
|
|
282
|
+
hookCount: summary.hookCount,
|
|
283
|
+
status: "started"
|
|
284
|
+
};
|
|
285
|
+
this.live.set(record.name, mount);
|
|
286
|
+
try {
|
|
287
|
+
await fiber;
|
|
288
|
+
} catch (error) {
|
|
289
|
+
mount.status = "failed";
|
|
290
|
+
/* v8 ignore next -- Cordis normalizes plugin failures to Error before fiber.await rejects. */
|
|
291
|
+
mount.detail = error instanceof Error ? error.message : String(error);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Resolve the document path the bridge reads: a `file` record's own path, or
|
|
296
|
+
* the harness-home document this registry writes from inline definitions.
|
|
297
|
+
* Materialization is idempotent and re-runs per mount, so an edited inline
|
|
298
|
+
* section always reaches the bridge.
|
|
299
|
+
*/
|
|
300
|
+
async materialize(record) {
|
|
301
|
+
if (sourceKind(record) === "file") return record.configPath;
|
|
302
|
+
const path = this.inlinePath(record.name);
|
|
303
|
+
await mkdir(this.inlineRoot, {
|
|
304
|
+
recursive: true,
|
|
305
|
+
mode: 448
|
|
306
|
+
});
|
|
307
|
+
await writeFileAtomic(path, `${JSON.stringify(record.config, void 0, 2)}\n`, {
|
|
308
|
+
mode: 384,
|
|
309
|
+
dirMode: 448
|
|
310
|
+
});
|
|
311
|
+
return path;
|
|
312
|
+
}
|
|
313
|
+
/** Read one record's raw definitions, from its file or its inline section. */
|
|
314
|
+
async readDefinitions(record) {
|
|
315
|
+
if (sourceKind(record) === "inline") return record.config;
|
|
316
|
+
let text;
|
|
317
|
+
try {
|
|
318
|
+
text = await readFile(record.configPath, "utf8");
|
|
319
|
+
} catch (cause) {
|
|
320
|
+
throw new Error(`hookRecords: record ${record.name} cannot read ${record.configPath}`, { cause });
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
return JSON.parse(text);
|
|
324
|
+
} catch (cause) {
|
|
325
|
+
throw new Error(`hookRecords: record ${record.name} document is not valid JSON`, { cause });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** Path of the document this registry materializes for one inline record. */
|
|
329
|
+
inlinePath(name) {
|
|
330
|
+
return join(this.inlineRoot, `${name}.json`);
|
|
331
|
+
}
|
|
332
|
+
/** Project one stored record with its live state. */
|
|
333
|
+
view(record) {
|
|
334
|
+
const mount = this.live.get(record.name);
|
|
335
|
+
const invalid = this.invalid.get(record.name);
|
|
336
|
+
const status = invalid !== void 0 ? "invalid" : !record.enabled ? "stopped" : mount?.status ?? "stopped";
|
|
337
|
+
const detail = invalid ?? mount?.detail;
|
|
338
|
+
return {
|
|
339
|
+
name: record.name,
|
|
340
|
+
dialect: record.dialect,
|
|
341
|
+
source: sourceKind(record),
|
|
342
|
+
enabled: record.enabled,
|
|
343
|
+
status,
|
|
344
|
+
...detail === void 0 || status === "stopped" ? {} : { detail },
|
|
345
|
+
...sourceKind(record) === "file" ? { configPath: record.configPath } : {},
|
|
346
|
+
events: mount?.events ?? [],
|
|
347
|
+
hookCount: mount?.hookCount ?? 0,
|
|
348
|
+
...record.pluginRoot === "" ? {} : { pluginRoot: record.pluginRoot },
|
|
349
|
+
...record.projectDir === "" ? {} : { projectDir: record.projectDir }
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/** Refuse new work, then unmount every live record. */
|
|
353
|
+
async shutdown() {
|
|
354
|
+
this.stopped = true;
|
|
355
|
+
/* v8 ignore next -- enqueue stores a fulfilled tail after either task outcome. */
|
|
356
|
+
await this.tail.catch(() => void 0);
|
|
357
|
+
this.invalid.clear();
|
|
358
|
+
for (const [name, mount] of [...this.live]) {
|
|
359
|
+
this.live.delete(name);
|
|
360
|
+
await disposeQuietly(mount.fiber);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
/**
|
|
365
|
+
* Digest of everything a mount is started from, so reconciliation can tell a
|
|
366
|
+
* redefined record from an untouched one without restarting a live bridge on
|
|
367
|
+
* every unrelated document commit. The desired state is excluded: enablement is
|
|
368
|
+
* handled by mount and unmount, not by a restart.
|
|
369
|
+
*/
|
|
370
|
+
function recordDigest(record) {
|
|
371
|
+
const { enabled: _desired, ...definition } = record;
|
|
372
|
+
return JSON.stringify(definition);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Unmount one bridge and swallow its teardown failure. The record it served is
|
|
376
|
+
* already out of the live map, so a disposal error cannot restore it, and
|
|
377
|
+
* letting it escape would strand every later record in the same reconcile.
|
|
378
|
+
* @param fiber - the mount to tear down.
|
|
379
|
+
*/
|
|
380
|
+
async function disposeQuietly(fiber) {
|
|
381
|
+
try {
|
|
382
|
+
await fiber.dispose();
|
|
383
|
+
} catch (_hookMountTeardownFailure) {}
|
|
384
|
+
}
|
|
385
|
+
//#endregion
|
|
386
|
+
export { HOOKS_SETTINGS_NAMESPACE, HookRecordRegistry, HookRecordRegistry as default, inject };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@hydraharness/harness-hooks-registry`.
|
|
4
|
+
* @module @hydraharness/harness-hooks-registry/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@hydraharness/harness-hooks-registry";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "hooks-registry-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* The registry's contract is that the mounted bridge set follows the stored
|
|
13
|
+
* records once reconciliation settles: an enabled record it accepted is live,
|
|
14
|
+
* and a disabled one is not. Both sides ride the reconciliation event, so the
|
|
15
|
+
* check observes the authoritative projection rather than re-deriving it.
|
|
16
|
+
*/
|
|
17
|
+
const install = (ctx, fail) => {
|
|
18
|
+
ctx.on("hooks-registry/reconciled", (snapshot) => {
|
|
19
|
+
for (const record of snapshot.records) {
|
|
20
|
+
if (record.enabled && record.status === "stopped") fail(`enabled hook record ${JSON.stringify(record.name)} has no live bridge after reconciliation`);
|
|
21
|
+
if (!record.enabled && record.status !== "stopped") fail(`disabled hook record ${JSON.stringify(record.name)} still reports ${record.status}`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Register this package's invariant companion.
|
|
27
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
28
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
29
|
+
*/
|
|
30
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
31
|
+
//#endregion
|
|
32
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-declared hook records: one settings-backed registry that mounts every
|
|
3
|
+
* enabled record on its dialect's bridge (`@hydraharness/harness-hooks-claude-code` or
|
|
4
|
+
* `@hydraharness/harness-hooks-codex`) and unmounts it when the record is disabled,
|
|
5
|
+
* redefined, or removed. The stored document (`hooks.records` in the harness
|
|
6
|
+
* settings file) is the only source of truth, so a hook added from a
|
|
7
|
+
* configuration surface survives a restart and one added by hand-editing that
|
|
8
|
+
* document mounts without one.
|
|
9
|
+
*
|
|
10
|
+
* A record either points at an existing hook document (`configPath`) or carries
|
|
11
|
+
* its definitions inline. Inline definitions are materialized under the harness
|
|
12
|
+
* home, because both bridges read one file path at load.
|
|
13
|
+
* @module @hydraharness/harness-hooks-registry
|
|
14
|
+
*/
|
|
15
|
+
import { Service, type Context } from '@hydraharness/cordis';
|
|
16
|
+
import z from '@hydraharness/schemastery';
|
|
17
|
+
import type { HookRecordDefinitionRequest, HookRecordEnablementRequest, HookRecordSnapshot } from './types.ts';
|
|
18
|
+
export type * from './types.ts';
|
|
19
|
+
declare module '@hydraharness/cordis' {
|
|
20
|
+
interface Context {
|
|
21
|
+
/** User-declared hook records and their live bridge mounts. */
|
|
22
|
+
hookRecords: HookRecordRegistry;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Settings namespace holding every user-declared hook record. */
|
|
26
|
+
export declare const HOOKS_SETTINGS_NAMESPACE: import("@hydraharness/harness-settings").SettingsNamespace;
|
|
27
|
+
/** Registry configuration. */
|
|
28
|
+
export interface Config {
|
|
29
|
+
/** Override the resolved harness home that holds materialized inline documents. */
|
|
30
|
+
hydraHome?: string;
|
|
31
|
+
}
|
|
32
|
+
/** Services required by the registry; the bridges themselves need `shell`. */
|
|
33
|
+
export declare const inject: string[];
|
|
34
|
+
/**
|
|
35
|
+
* Settings-backed hook records with their live bridge mounts. Reconciliation is
|
|
36
|
+
* idempotent and runs on one serialized chain, so a document commit from any
|
|
37
|
+
* source — this service's own write, another process, or a hand edit —
|
|
38
|
+
* converges the mounted set on the stored one.
|
|
39
|
+
* Mutations reject with `SettingsConflictError` if another writer changes the
|
|
40
|
+
* stored section after the read; retry uses the refreshed projection.
|
|
41
|
+
*/
|
|
42
|
+
export declare class HookRecordRegistry extends Service {
|
|
43
|
+
static inject: string[];
|
|
44
|
+
/** Bridge mounts belong to this Host service, not to a traced API caller. */
|
|
45
|
+
private readonly owner;
|
|
46
|
+
static Config: z<Config>;
|
|
47
|
+
private readonly settings;
|
|
48
|
+
private readonly live;
|
|
49
|
+
/** Stored records this registry refuses to mount, with the refusal reason. */
|
|
50
|
+
private readonly invalid;
|
|
51
|
+
/** Directory holding the documents materialized from inline definitions. */
|
|
52
|
+
private readonly inlineRoot;
|
|
53
|
+
private tail;
|
|
54
|
+
private stopped;
|
|
55
|
+
constructor(ctx: Context, config?: Config);
|
|
56
|
+
protected [Service.init](): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Project every stored record with its live state.
|
|
59
|
+
* @returns the current record projection, in stored order.
|
|
60
|
+
*/
|
|
61
|
+
list(): HookRecordSnapshot;
|
|
62
|
+
/**
|
|
63
|
+
* Create or replace one complete definition as requested, then
|
|
64
|
+
* converge the mounted set. A definition this registry could not mount is
|
|
65
|
+
* refused before anything persists.
|
|
66
|
+
* @param request - complete record definition.
|
|
67
|
+
* @returns the refreshed projection.
|
|
68
|
+
* @throws If create names an existing record or replace names a missing record.
|
|
69
|
+
*/
|
|
70
|
+
define(request: HookRecordDefinitionRequest): Promise<HookRecordSnapshot>;
|
|
71
|
+
/**
|
|
72
|
+
* Change one stored record's desired state.
|
|
73
|
+
* @param request - target record and desired state.
|
|
74
|
+
* @returns the refreshed projection.
|
|
75
|
+
*/
|
|
76
|
+
setEnabled(request: HookRecordEnablementRequest): Promise<HookRecordSnapshot>;
|
|
77
|
+
/**
|
|
78
|
+
* Remove one stored record, unmount it, and delete any document this registry
|
|
79
|
+
* materialized for it.
|
|
80
|
+
* @param name - stored record name.
|
|
81
|
+
* @returns the refreshed projection.
|
|
82
|
+
*/
|
|
83
|
+
remove(name: string): Promise<HookRecordSnapshot>;
|
|
84
|
+
/** Serialize every mutation and reconciliation on one chain. */
|
|
85
|
+
private enqueue;
|
|
86
|
+
/**
|
|
87
|
+
* Converge the mounted set on the stored one: unmount every record that is
|
|
88
|
+
* gone, disabled, or redefined, then mount every enabled record that is not
|
|
89
|
+
* already live from the same definition. A record whose document is missing,
|
|
90
|
+
* unparseable, or refused by its dialect is recorded as `invalid` with the
|
|
91
|
+
* diagnostic and does not stop the others.
|
|
92
|
+
*/
|
|
93
|
+
private reconcile;
|
|
94
|
+
/**
|
|
95
|
+
* Materialize the record's document when it is inline, then start its
|
|
96
|
+
* dialect's bridge. A refusal is stored as `invalid` rather than thrown: a
|
|
97
|
+
* hand-edited document must not stop every other record from mounting.
|
|
98
|
+
*/
|
|
99
|
+
private mount;
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the document path the bridge reads: a `file` record's own path, or
|
|
102
|
+
* the harness-home document this registry writes from inline definitions.
|
|
103
|
+
* Materialization is idempotent and re-runs per mount, so an edited inline
|
|
104
|
+
* section always reaches the bridge.
|
|
105
|
+
*/
|
|
106
|
+
private materialize;
|
|
107
|
+
/** Read one record's raw definitions, from its file or its inline section. */
|
|
108
|
+
private readDefinitions;
|
|
109
|
+
/** Path of the document this registry materializes for one inline record. */
|
|
110
|
+
private inlinePath;
|
|
111
|
+
/** Project one stored record with its live state. */
|
|
112
|
+
private view;
|
|
113
|
+
/** Refuse new work, then unmount every live record. */
|
|
114
|
+
private shutdown;
|
|
115
|
+
}
|
|
116
|
+
export default HookRecordRegistry;
|
|
117
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-declared hook records: one settings-backed registry that mounts every
|
|
3
|
+
* enabled record on its dialect's bridge (`@hydraharness/harness-hooks-claude-code` or
|
|
4
|
+
* `@hydraharness/harness-hooks-codex`) and unmounts it when the record is disabled,
|
|
5
|
+
* redefined, or removed. The stored document (`hooks.records` in the harness
|
|
6
|
+
* settings file) is the only source of truth, so a hook added from a
|
|
7
|
+
* configuration surface survives a restart and one added by hand-editing that
|
|
8
|
+
* document mounts without one.
|
|
9
|
+
*
|
|
10
|
+
* A record either points at an existing hook document (`configPath`) or carries
|
|
11
|
+
* its definitions inline. Inline definitions are materialized under the harness
|
|
12
|
+
* home, because both bridges read one file path at load.
|
|
13
|
+
* @module @hydraharness/harness-hooks-registry
|
|
14
|
+
*/
|
|
15
|
+
import { mkdir, readFile, rm } from 'node:fs/promises';
|
|
16
|
+
import { isAbsolute, join } from 'node:path';
|
|
17
|
+
import { Service } from '@hydraharness/cordis';
|
|
18
|
+
import z from '@hydraharness/schemastery';
|
|
19
|
+
import { writeFileAtomic } from '@hydraharness/harness-atomic-write';
|
|
20
|
+
import { resolveHydraHome } from '@hydraharness/harness-home-paths';
|
|
21
|
+
import { settingsNamespace } from '@hydraharness/harness-settings';
|
|
22
|
+
import { apply as applyClaudeCodeHooks, inject as claudeCodeInject, } from '@hydraharness/harness-hooks-claude-code';
|
|
23
|
+
import { apply as applyCodexHooks, inject as codexInject } from '@hydraharness/harness-hooks-codex';
|
|
24
|
+
import { parseClaudeCodeConfig } from '@hydraharness/harness-hooks-claude-code/config';
|
|
25
|
+
import { parseCodexConfig } from '@hydraharness/harness-hooks-codex/config';
|
|
26
|
+
/** Settings namespace holding every user-declared hook record. */
|
|
27
|
+
export const HOOKS_SETTINGS_NAMESPACE = settingsNamespace('hooks');
|
|
28
|
+
/** Accepted record name. */
|
|
29
|
+
const RECORD_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/u;
|
|
30
|
+
const MAX_RECORDS = 50;
|
|
31
|
+
const MAX_INLINE_BYTES = 256 * 1024;
|
|
32
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 600_000;
|
|
33
|
+
const StoredRecordSchema = z.object({
|
|
34
|
+
name: z.string().required(),
|
|
35
|
+
dialect: z.union([z.const('claude-code'), z.const('codex')]).default('claude-code'),
|
|
36
|
+
configPath: z.string().default(''),
|
|
37
|
+
// Inline definitions stay opaque to the schema: each dialect owns its own
|
|
38
|
+
// document grammar, and both parsers already refuse what they cannot run.
|
|
39
|
+
config: z.dict(z.any()).default({}),
|
|
40
|
+
pluginRoot: z.string().default(''),
|
|
41
|
+
projectDir: z.string().default(''),
|
|
42
|
+
defaultTimeoutMs: z.number().step(1).min(1).default(DEFAULT_HOOK_TIMEOUT_MS),
|
|
43
|
+
enabled: z.boolean().default(false),
|
|
44
|
+
});
|
|
45
|
+
const HooksSettingsSchema = z.object({
|
|
46
|
+
records: z.array(StoredRecordSchema).max(MAX_RECORDS).default([]),
|
|
47
|
+
});
|
|
48
|
+
/** Which document source a stored record declares. */
|
|
49
|
+
function sourceKind(record) {
|
|
50
|
+
return record.configPath === '' ? 'inline' : 'file';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Reject a definition this registry could not mount. Every check answers a
|
|
54
|
+
* constraint the schema cannot express: the name grammar, that exactly one
|
|
55
|
+
* document source is declared, that a path is absolute, and that inline
|
|
56
|
+
* definitions stay inside the document budget.
|
|
57
|
+
* @param record - one stored record to judge.
|
|
58
|
+
* @param label - operation name used in the diagnostic.
|
|
59
|
+
*/
|
|
60
|
+
function assertMountable(record, label) {
|
|
61
|
+
if (!RECORD_NAME_PATTERN.test(record.name)) {
|
|
62
|
+
throw new Error(`${label}: record name must match ${String(RECORD_NAME_PATTERN)}`);
|
|
63
|
+
}
|
|
64
|
+
const inline = Object.keys(record.config).length > 0;
|
|
65
|
+
if (record.configPath === '' && !inline) {
|
|
66
|
+
throw new Error(`${label}: record ${record.name} needs either a configPath or inline hook definitions`);
|
|
67
|
+
}
|
|
68
|
+
if (record.configPath !== '' && inline) {
|
|
69
|
+
throw new Error(`${label}: record ${record.name} declares both a configPath and inline hook definitions`);
|
|
70
|
+
}
|
|
71
|
+
if (record.configPath !== '' && !isAbsolute(record.configPath)) {
|
|
72
|
+
throw new Error(`${label}: record ${record.name} configPath must be absolute`);
|
|
73
|
+
}
|
|
74
|
+
if (inline && Buffer.byteLength(JSON.stringify(record.config), 'utf8') > MAX_INLINE_BYTES) {
|
|
75
|
+
throw new Error(`${label}: record ${record.name} inline hooks exceed ${String(MAX_INLINE_BYTES)} bytes`);
|
|
76
|
+
}
|
|
77
|
+
if (record.dialect === 'codex' && (record.pluginRoot !== '' || record.projectDir !== '')) {
|
|
78
|
+
throw new Error(`${label}: record ${record.name} substitution roots apply to claude-code records only`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Reject a stored section whose records collide, so no mount silently wins. */
|
|
82
|
+
function assertUniqueNames(settings) {
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const record of settings.records) {
|
|
85
|
+
if (seen.has(record.name)) {
|
|
86
|
+
throw new Error(`hookRecords: record name ${JSON.stringify(record.name)} is declared more than once`);
|
|
87
|
+
}
|
|
88
|
+
seen.add(record.name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Parse one document with its dialect's parser, so what the registry reports is
|
|
93
|
+
* exactly what the bridge will run.
|
|
94
|
+
* @param dialect - the record's config dialect.
|
|
95
|
+
* @param raw - parsed JSON document.
|
|
96
|
+
* @returns the event names covered and the total command-hook count.
|
|
97
|
+
*/
|
|
98
|
+
function summarize(dialect, raw) {
|
|
99
|
+
const parsed = dialect === 'claude-code' ? parseClaudeCodeConfig(raw) : parseCodexConfig(raw);
|
|
100
|
+
const events = Object.keys(parsed.config).sort();
|
|
101
|
+
let hookCount = 0;
|
|
102
|
+
for (const groups of Object.values(parsed.config)) {
|
|
103
|
+
for (const group of groups)
|
|
104
|
+
hookCount += group.hooks.length;
|
|
105
|
+
}
|
|
106
|
+
if (hookCount === 0)
|
|
107
|
+
throw new Error(`hookRecords: ${dialect} document contains no supported synchronous command hooks`);
|
|
108
|
+
return { events, hookCount };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Fold one write request onto the record it replaces. Every field is restated
|
|
112
|
+
* by the caller: unlike a credential, nothing here is withheld from a read, so
|
|
113
|
+
* an omission is a deliberate clear.
|
|
114
|
+
* @param request - complete definition supplied by the caller.
|
|
115
|
+
* @param previous - the record being replaced, when one exists.
|
|
116
|
+
* @returns the next stored record.
|
|
117
|
+
*/
|
|
118
|
+
function foldDefinition(request, previous) {
|
|
119
|
+
return {
|
|
120
|
+
name: request.name.trim(),
|
|
121
|
+
dialect: request.dialect,
|
|
122
|
+
configPath: (request.configPath ?? '').trim(),
|
|
123
|
+
config: { ...request.config },
|
|
124
|
+
pluginRoot: (request.pluginRoot ?? '').trim(),
|
|
125
|
+
projectDir: (request.projectDir ?? '').trim(),
|
|
126
|
+
defaultTimeoutMs: request.defaultTimeoutMs ?? previous?.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
|
|
127
|
+
enabled: request.enabled ?? previous?.enabled ?? false,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** Services required by the registry; the bridges themselves need `shell`. */
|
|
131
|
+
export const inject = ['settings', 'shell'];
|
|
132
|
+
/**
|
|
133
|
+
* Settings-backed hook records with their live bridge mounts. Reconciliation is
|
|
134
|
+
* idempotent and runs on one serialized chain, so a document commit from any
|
|
135
|
+
* source — this service's own write, another process, or a hand edit —
|
|
136
|
+
* converges the mounted set on the stored one.
|
|
137
|
+
* Mutations reject with `SettingsConflictError` if another writer changes the
|
|
138
|
+
* stored section after the read; retry uses the refreshed projection.
|
|
139
|
+
*/
|
|
140
|
+
export class HookRecordRegistry extends Service {
|
|
141
|
+
static inject = inject;
|
|
142
|
+
/** Bridge mounts belong to this Host service, not to a traced API caller. */
|
|
143
|
+
owner;
|
|
144
|
+
static Config = z.object({
|
|
145
|
+
hydraHome: z.string(),
|
|
146
|
+
});
|
|
147
|
+
settings;
|
|
148
|
+
live = new Map();
|
|
149
|
+
/** Stored records this registry refuses to mount, with the refusal reason. */
|
|
150
|
+
invalid = new Map();
|
|
151
|
+
/** Directory holding the documents materialized from inline definitions. */
|
|
152
|
+
inlineRoot;
|
|
153
|
+
tail = Promise.resolve();
|
|
154
|
+
stopped = false;
|
|
155
|
+
constructor(ctx, config = {}) {
|
|
156
|
+
super(ctx, 'hookRecords');
|
|
157
|
+
this.owner = ctx;
|
|
158
|
+
this.inlineRoot = join(resolveHydraHome(config.hydraHome), 'hooks');
|
|
159
|
+
this.settings = ctx.settings.register(HOOKS_SETTINGS_NAMESPACE, HooksSettingsSchema, {
|
|
160
|
+
// Refuse a colliding section where it is written: two records claiming
|
|
161
|
+
// one name would make which definitions run depend on mount order.
|
|
162
|
+
validate: assertUniqueNames,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async [Service.init]() {
|
|
166
|
+
this.ctx.effect(() => () => this.shutdown(), 'hookRecords.dispose');
|
|
167
|
+
this.ctx.effect(() => this.settings.watch(() => this.enqueue(() => this.reconcile())), 'hookRecords.settingsWatch');
|
|
168
|
+
await this.enqueue(() => this.reconcile());
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Project every stored record with its live state.
|
|
172
|
+
* @returns the current record projection, in stored order.
|
|
173
|
+
*/
|
|
174
|
+
list() {
|
|
175
|
+
return { records: this.settings.get().records.map(record => this.view(record)) };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Create or replace one complete definition as requested, then
|
|
179
|
+
* converge the mounted set. A definition this registry could not mount is
|
|
180
|
+
* refused before anything persists.
|
|
181
|
+
* @param request - complete record definition.
|
|
182
|
+
* @returns the refreshed projection.
|
|
183
|
+
* @throws If create names an existing record or replace names a missing record.
|
|
184
|
+
*/
|
|
185
|
+
define(request) {
|
|
186
|
+
return this.enqueue(async () => {
|
|
187
|
+
const revision = this.settings.revision;
|
|
188
|
+
const records = this.settings.get().records;
|
|
189
|
+
const index = records.findIndex(candidate => candidate.name === request.name.trim());
|
|
190
|
+
if (request.mode === 'create' && index !== -1)
|
|
191
|
+
throw new Error(`hookRecords.define: record ${request.name} already exists`);
|
|
192
|
+
if (request.mode === 'replace' && index === -1)
|
|
193
|
+
throw new Error(`hookRecords.define: record ${request.name} is not configured`);
|
|
194
|
+
const record = foldDefinition(request, index === -1 ? undefined : records[index]);
|
|
195
|
+
assertMountable(record, 'hookRecords.define');
|
|
196
|
+
// The dialect parser is the authority on whether these definitions run;
|
|
197
|
+
// refusing here means a stored record can never be silently inert.
|
|
198
|
+
summarize(record.dialect, await this.readDefinitions(record));
|
|
199
|
+
const next = index === -1
|
|
200
|
+
? [...records, record]
|
|
201
|
+
: records.map((candidate, position) => position === index ? record : candidate);
|
|
202
|
+
if (next.length > MAX_RECORDS)
|
|
203
|
+
throw new Error(`hookRecords.define: at most ${String(MAX_RECORDS)} records are supported`);
|
|
204
|
+
await this.ctx.settings.update(HOOKS_SETTINGS_NAMESPACE, { records: next }, revision);
|
|
205
|
+
await this.reconcile();
|
|
206
|
+
return this.list();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Change one stored record's desired state.
|
|
211
|
+
* @param request - target record and desired state.
|
|
212
|
+
* @returns the refreshed projection.
|
|
213
|
+
*/
|
|
214
|
+
setEnabled(request) {
|
|
215
|
+
return this.enqueue(async () => {
|
|
216
|
+
const revision = this.settings.revision;
|
|
217
|
+
const records = this.settings.get().records;
|
|
218
|
+
const index = records.findIndex(candidate => candidate.name === request.name);
|
|
219
|
+
if (index === -1)
|
|
220
|
+
throw new Error(`hookRecords.setEnabled: record ${request.name} is not configured`);
|
|
221
|
+
const next = records.map((candidate, position) => position === index
|
|
222
|
+
? { ...candidate, enabled: request.enabled }
|
|
223
|
+
: candidate);
|
|
224
|
+
await this.ctx.settings.update(HOOKS_SETTINGS_NAMESPACE, { records: next }, revision);
|
|
225
|
+
await this.reconcile();
|
|
226
|
+
return this.list();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Remove one stored record, unmount it, and delete any document this registry
|
|
231
|
+
* materialized for it.
|
|
232
|
+
* @param name - stored record name.
|
|
233
|
+
* @returns the refreshed projection.
|
|
234
|
+
*/
|
|
235
|
+
remove(name) {
|
|
236
|
+
return this.enqueue(async () => {
|
|
237
|
+
const revision = this.settings.revision;
|
|
238
|
+
const records = this.settings.get().records;
|
|
239
|
+
if (!records.some(candidate => candidate.name === name)) {
|
|
240
|
+
throw new Error(`hookRecords.remove: record ${name} is not configured`);
|
|
241
|
+
}
|
|
242
|
+
await this.ctx.settings.replace(HOOKS_SETTINGS_NAMESPACE, { records: records.filter(candidate => candidate.name !== name) }, revision);
|
|
243
|
+
await this.reconcile();
|
|
244
|
+
await rm(this.inlinePath(name), { force: true });
|
|
245
|
+
return this.list();
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/* jscpd:ignore-start -- hook and MCP registries intentionally share serialized reconciliation. */
|
|
249
|
+
/** Serialize every mutation and reconciliation on one chain. */
|
|
250
|
+
enqueue(task) {
|
|
251
|
+
const run = this.tail.then(task, task);
|
|
252
|
+
this.tail = run.then(() => undefined, () => undefined);
|
|
253
|
+
return run;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Converge the mounted set on the stored one: unmount every record that is
|
|
257
|
+
* gone, disabled, or redefined, then mount every enabled record that is not
|
|
258
|
+
* already live from the same definition. A record whose document is missing,
|
|
259
|
+
* unparseable, or refused by its dialect is recorded as `invalid` with the
|
|
260
|
+
* diagnostic and does not stop the others.
|
|
261
|
+
*/
|
|
262
|
+
async reconcile() {
|
|
263
|
+
if (this.stopped)
|
|
264
|
+
return;
|
|
265
|
+
const records = new Map(this.settings.get().records.map(record => [record.name, record]));
|
|
266
|
+
this.invalid.clear();
|
|
267
|
+
for (const [name, mount] of [...this.live]) {
|
|
268
|
+
const record = records.get(name);
|
|
269
|
+
if (record?.enabled === true && recordDigest(record) === mount.digest)
|
|
270
|
+
continue;
|
|
271
|
+
this.live.delete(name);
|
|
272
|
+
await disposeQuietly(mount.fiber);
|
|
273
|
+
}
|
|
274
|
+
for (const record of records.values()) {
|
|
275
|
+
if (!record.enabled || this.live.has(record.name))
|
|
276
|
+
continue;
|
|
277
|
+
await this.mount(record);
|
|
278
|
+
}
|
|
279
|
+
// The projection is published only after every mount settled, so an
|
|
280
|
+
// observer cannot read a record whose bridge is still loading.
|
|
281
|
+
this.ctx.emit('hooks-registry/reconciled', this.list());
|
|
282
|
+
}
|
|
283
|
+
/* jscpd:ignore-end */
|
|
284
|
+
/**
|
|
285
|
+
* Materialize the record's document when it is inline, then start its
|
|
286
|
+
* dialect's bridge. A refusal is stored as `invalid` rather than thrown: a
|
|
287
|
+
* hand-edited document must not stop every other record from mounting.
|
|
288
|
+
*/
|
|
289
|
+
async mount(record) {
|
|
290
|
+
let configPath;
|
|
291
|
+
let summary;
|
|
292
|
+
try {
|
|
293
|
+
assertMountable(record, 'hookRecords');
|
|
294
|
+
configPath = await this.materialize(record);
|
|
295
|
+
summary = summarize(record.dialect, await this.readDefinitions(record));
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
/* v8 ignore next -- validation, JSON parsing, and local filesystem operations throw Error instances. */
|
|
299
|
+
this.invalid.set(record.name, error instanceof Error ? error.message : String(error));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const fiber = record.dialect === 'claude-code'
|
|
303
|
+
? this.owner.plugin({
|
|
304
|
+
name: `hook-record:${record.name}`,
|
|
305
|
+
inject: claudeCodeInject,
|
|
306
|
+
apply: applyClaudeCodeHooks,
|
|
307
|
+
}, {
|
|
308
|
+
configPath,
|
|
309
|
+
defaultTimeoutMs: record.defaultTimeoutMs,
|
|
310
|
+
...record.pluginRoot === '' ? {} : { pluginRoot: record.pluginRoot },
|
|
311
|
+
...record.projectDir === '' ? {} : { projectDir: record.projectDir },
|
|
312
|
+
})
|
|
313
|
+
: this.owner.plugin({
|
|
314
|
+
name: `hook-record:${record.name}`,
|
|
315
|
+
inject: codexInject,
|
|
316
|
+
apply: applyCodexHooks,
|
|
317
|
+
}, {
|
|
318
|
+
configPath,
|
|
319
|
+
defaultTimeoutMs: record.defaultTimeoutMs,
|
|
320
|
+
});
|
|
321
|
+
const mount = {
|
|
322
|
+
fiber,
|
|
323
|
+
digest: recordDigest(record),
|
|
324
|
+
events: summary.events,
|
|
325
|
+
hookCount: summary.hookCount,
|
|
326
|
+
status: 'started',
|
|
327
|
+
};
|
|
328
|
+
this.live.set(record.name, mount);
|
|
329
|
+
try {
|
|
330
|
+
await fiber;
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
mount.status = 'failed';
|
|
334
|
+
/* v8 ignore next -- Cordis normalizes plugin failures to Error before fiber.await rejects. */
|
|
335
|
+
mount.detail = error instanceof Error ? error.message : String(error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Resolve the document path the bridge reads: a `file` record's own path, or
|
|
340
|
+
* the harness-home document this registry writes from inline definitions.
|
|
341
|
+
* Materialization is idempotent and re-runs per mount, so an edited inline
|
|
342
|
+
* section always reaches the bridge.
|
|
343
|
+
*/
|
|
344
|
+
async materialize(record) {
|
|
345
|
+
if (sourceKind(record) === 'file')
|
|
346
|
+
return record.configPath;
|
|
347
|
+
const path = this.inlinePath(record.name);
|
|
348
|
+
await mkdir(this.inlineRoot, { recursive: true, mode: 0o700 });
|
|
349
|
+
await writeFileAtomic(path, `${JSON.stringify(record.config, undefined, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
|
|
350
|
+
return path;
|
|
351
|
+
}
|
|
352
|
+
/** Read one record's raw definitions, from its file or its inline section. */
|
|
353
|
+
async readDefinitions(record) {
|
|
354
|
+
if (sourceKind(record) === 'inline')
|
|
355
|
+
return record.config;
|
|
356
|
+
let text;
|
|
357
|
+
try {
|
|
358
|
+
text = await readFile(record.configPath, 'utf8');
|
|
359
|
+
}
|
|
360
|
+
catch (cause) {
|
|
361
|
+
throw new Error(`hookRecords: record ${record.name} cannot read ${record.configPath}`, { cause });
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
return JSON.parse(text);
|
|
365
|
+
}
|
|
366
|
+
catch (cause) {
|
|
367
|
+
throw new Error(`hookRecords: record ${record.name} document is not valid JSON`, { cause });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/** Path of the document this registry materializes for one inline record. */
|
|
371
|
+
inlinePath(name) {
|
|
372
|
+
return join(this.inlineRoot, `${name}.json`);
|
|
373
|
+
}
|
|
374
|
+
/** Project one stored record with its live state. */
|
|
375
|
+
view(record) {
|
|
376
|
+
const mount = this.live.get(record.name);
|
|
377
|
+
const invalid = this.invalid.get(record.name);
|
|
378
|
+
const status = invalid !== undefined
|
|
379
|
+
? 'invalid'
|
|
380
|
+
: !record.enabled ? 'stopped' : mount?.status ?? 'stopped';
|
|
381
|
+
const detail = invalid ?? mount?.detail;
|
|
382
|
+
return {
|
|
383
|
+
name: record.name,
|
|
384
|
+
dialect: record.dialect,
|
|
385
|
+
source: sourceKind(record),
|
|
386
|
+
enabled: record.enabled,
|
|
387
|
+
status,
|
|
388
|
+
...detail === undefined || status === 'stopped' ? {} : { detail },
|
|
389
|
+
...sourceKind(record) === 'file' ? { configPath: record.configPath } : {},
|
|
390
|
+
events: mount?.events ?? [],
|
|
391
|
+
hookCount: mount?.hookCount ?? 0,
|
|
392
|
+
...record.pluginRoot === '' ? {} : { pluginRoot: record.pluginRoot },
|
|
393
|
+
...record.projectDir === '' ? {} : { projectDir: record.projectDir },
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
/* jscpd:ignore-start -- hook and MCP shutdowns intentionally share teardown semantics. */
|
|
397
|
+
/** Refuse new work, then unmount every live record. */
|
|
398
|
+
async shutdown() {
|
|
399
|
+
this.stopped = true;
|
|
400
|
+
/* v8 ignore next -- enqueue stores a fulfilled tail after either task outcome. */
|
|
401
|
+
await this.tail.catch(() => undefined);
|
|
402
|
+
this.invalid.clear();
|
|
403
|
+
for (const [name, mount] of [...this.live]) {
|
|
404
|
+
this.live.delete(name);
|
|
405
|
+
await disposeQuietly(mount.fiber);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Digest of everything a mount is started from, so reconciliation can tell a
|
|
411
|
+
* redefined record from an untouched one without restarting a live bridge on
|
|
412
|
+
* every unrelated document commit. The desired state is excluded: enablement is
|
|
413
|
+
* handled by mount and unmount, not by a restart.
|
|
414
|
+
*/
|
|
415
|
+
function recordDigest(record) {
|
|
416
|
+
const { enabled: _desired, ...definition } = record;
|
|
417
|
+
return JSON.stringify(definition);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Unmount one bridge and swallow its teardown failure. The record it served is
|
|
421
|
+
* already out of the live map, so a disposal error cannot restore it, and
|
|
422
|
+
* letting it escape would strand every later record in the same reconcile.
|
|
423
|
+
* @param fiber - the mount to tear down.
|
|
424
|
+
*/
|
|
425
|
+
async function disposeQuietly(fiber) {
|
|
426
|
+
try {
|
|
427
|
+
await fiber.dispose();
|
|
428
|
+
}
|
|
429
|
+
catch (_hookMountTeardownFailure) {
|
|
430
|
+
// The fiber is inactive either way; nothing here can act on the failure.
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
export default HookRecordRegistry;
|
|
434
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hydraharness/harness-hooks-registry`.
|
|
3
|
+
* @module @hydraharness/harness-hooks-registry/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@hydraharness/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "hooks-registry-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hydraharness/harness-hooks-registry`.
|
|
3
|
+
* @module @hydraharness/harness-hooks-registry/invariant
|
|
4
|
+
*/
|
|
5
|
+
const PACKAGE_NAME = '@hydraharness/harness-hooks-registry';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export const name = 'hooks-registry-invariant';
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export const inject = ['invariants'];
|
|
10
|
+
/**
|
|
11
|
+
* The registry's contract is that the mounted bridge set follows the stored
|
|
12
|
+
* records once reconciliation settles: an enabled record it accepted is live,
|
|
13
|
+
* and a disabled one is not. Both sides ride the reconciliation event, so the
|
|
14
|
+
* check observes the authoritative projection rather than re-deriving it.
|
|
15
|
+
*/
|
|
16
|
+
const install = (ctx, fail) => {
|
|
17
|
+
ctx.on('hooks-registry/reconciled', (snapshot) => {
|
|
18
|
+
for (const record of snapshot.records) {
|
|
19
|
+
if (record.enabled && record.status === 'stopped') {
|
|
20
|
+
fail(`enabled hook record ${JSON.stringify(record.name)} has no live bridge after reconciliation`);
|
|
21
|
+
}
|
|
22
|
+
if (!record.enabled && record.status !== 'stopped') {
|
|
23
|
+
fail(`disabled hook record ${JSON.stringify(record.name)} still reports ${record.status}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Register this package's invariant companion.
|
|
30
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
31
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
32
|
+
*/
|
|
33
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
34
|
+
//# sourceMappingURL=invariant.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Public types for the user-declared hook registry. */
|
|
2
|
+
import type { JsonValue } from '@hydraharness/harness-session/types';
|
|
3
|
+
/** Hook config dialect a record is written in, selecting its bridge. */
|
|
4
|
+
export type HookDialect = 'claude-code' | 'codex';
|
|
5
|
+
/** Where a record's hook definitions come from. */
|
|
6
|
+
export type HookSourceKind = 'file' | 'inline';
|
|
7
|
+
/**
|
|
8
|
+
* Live state of one stored record. `invalid` is a stored record this registry
|
|
9
|
+
* refuses to mount — an unreadable path, an unparseable document, or a
|
|
10
|
+
* definition the dialect rejects — never a hook execution failure.
|
|
11
|
+
*/
|
|
12
|
+
export type HookRecordStatus = 'stopped' | 'started' | 'failed' | 'invalid';
|
|
13
|
+
/** One stored hook record as a configuration surface sees it. */
|
|
14
|
+
export interface HookRecordView {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly dialect: HookDialect;
|
|
17
|
+
readonly source: HookSourceKind;
|
|
18
|
+
/** Desired state stored in the document, independent of {@link status}. */
|
|
19
|
+
readonly enabled: boolean;
|
|
20
|
+
readonly status: HookRecordStatus;
|
|
21
|
+
/** Why a record is `invalid`, or the mount failure summary. */
|
|
22
|
+
readonly detail?: string;
|
|
23
|
+
/** Absolute document path of a `file` record. */
|
|
24
|
+
readonly configPath?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Event names the mounted definitions cover, in dialect spelling. Empty until
|
|
27
|
+
* the record's definitions are read, and for a record this registry refuses.
|
|
28
|
+
*/
|
|
29
|
+
readonly events: readonly string[];
|
|
30
|
+
/** How many command hooks the definitions declare across every event. */
|
|
31
|
+
readonly hookCount: number;
|
|
32
|
+
/** Substitution root a `claude-code` record supplies for `${CLAUDE_PLUGIN_ROOT}`. */
|
|
33
|
+
readonly pluginRoot?: string;
|
|
34
|
+
/** Substitution root a `claude-code` record supplies for `${CLAUDE_PROJECT_DIR}`. */
|
|
35
|
+
readonly projectDir?: string;
|
|
36
|
+
}
|
|
37
|
+
/** Complete immutable projection returned by every registry operation. */
|
|
38
|
+
export interface HookRecordSnapshot {
|
|
39
|
+
readonly records: readonly HookRecordView[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Complete definition of one hook record. A write replaces the named record
|
|
43
|
+
* wholesale, so an omitted optional field clears the stored one. Exactly one of
|
|
44
|
+
* `configPath` and `config` must be supplied.
|
|
45
|
+
*/
|
|
46
|
+
export interface HookRecordDefinitionRequest {
|
|
47
|
+
/** Create requires an unused name; replace requires an existing record. */
|
|
48
|
+
readonly mode: 'create' | 'replace';
|
|
49
|
+
/** Stable record name; `[A-Za-z0-9_-]{1,64}`. */
|
|
50
|
+
readonly name: string;
|
|
51
|
+
readonly dialect: HookDialect;
|
|
52
|
+
/** Absolute path to an existing hook document the harness reads as-is. */
|
|
53
|
+
readonly configPath?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Hook definitions stored inline in the settings document: either a bare
|
|
56
|
+
* event map or a `{ hooks: … }` wrapper, in the record's dialect. The registry
|
|
57
|
+
* materializes them into a file for the bridge to read. Each dialect owns its
|
|
58
|
+
* own document grammar, so the values stay lossless JSON rather than a shape
|
|
59
|
+
* this package restates.
|
|
60
|
+
*/
|
|
61
|
+
readonly config?: Readonly<Record<string, JsonValue>>;
|
|
62
|
+
/** Replaces `${CLAUDE_PLUGIN_ROOT}` in `claude-code` commands. */
|
|
63
|
+
readonly pluginRoot?: string;
|
|
64
|
+
/** Replaces `${CLAUDE_PROJECT_DIR}` in `claude-code` commands and env. */
|
|
65
|
+
readonly projectDir?: string;
|
|
66
|
+
/** Default per-hook timeout in milliseconds when a hook declares none. */
|
|
67
|
+
readonly defaultTimeoutMs?: number;
|
|
68
|
+
/** Desired state; a new record defaults to disabled. */
|
|
69
|
+
readonly enabled?: boolean;
|
|
70
|
+
}
|
|
71
|
+
/** Change one stored record's desired state without touching its definition. */
|
|
72
|
+
export interface HookRecordEnablementRequest {
|
|
73
|
+
readonly name: string;
|
|
74
|
+
readonly enabled: boolean;
|
|
75
|
+
}
|
|
76
|
+
declare module '@hydraharness/cordis' {
|
|
77
|
+
interface Events {
|
|
78
|
+
/**
|
|
79
|
+
* The mounted bridge set now matches the stored records. Emitted after each
|
|
80
|
+
* reconciliation settles — including the one at startup and the ones a
|
|
81
|
+
* document change triggers — so an observer never reads the projection
|
|
82
|
+
* between a committed record change and the mount that follows it.
|
|
83
|
+
* @param snapshot - the projection as of this reconciliation.
|
|
84
|
+
* @mode emit
|
|
85
|
+
*/
|
|
86
|
+
'hooks-registry/reconciled'(snapshot: HookRecordSnapshot): void;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=types.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hydraharness/harness-hooks-registry",
|
|
3
|
+
"description": "Settings-backed hook records that mount and unmount Claude Code and Codex hook bridges",
|
|
4
|
+
"hydra": {
|
|
5
|
+
"plugin": {
|
|
6
|
+
"application": "Save hook definitions and enable or disable their execution bridges."
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"version": "0.1.1-rc.5",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/MaiHongPhong1902/Hydra-Harness.git",
|
|
16
|
+
"directory": "packages/hooks/hooks-registry"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "lib/index.js",
|
|
20
|
+
"types": "lib/types/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./lib/types/index.d.ts",
|
|
24
|
+
"default": "./lib/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./invariant": {
|
|
27
|
+
"types": "./lib/types/invariant.d.ts",
|
|
28
|
+
"default": "./lib/invariant.js"
|
|
29
|
+
},
|
|
30
|
+
"./types": {
|
|
31
|
+
"types": "./lib/types/types.d.ts",
|
|
32
|
+
"default": "./lib/types/types.js"
|
|
33
|
+
},
|
|
34
|
+
"./src/*": "./src/*",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"lib/index.js",
|
|
39
|
+
"lib/invariant.js",
|
|
40
|
+
"lib/types/**/*.js",
|
|
41
|
+
"lib/types/**/*.d.ts"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@hydraharness/harness-atomic-write": "^0.1.1-rc.5",
|
|
46
|
+
"@hydraharness/schemastery": "^3.18.2",
|
|
47
|
+
"@hydraharness/harness-home-paths": "^0.1.1-rc.5"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@hydraharness/harness-hooks-claude-code": "^0.1.1-rc.5",
|
|
51
|
+
"@hydraharness/harness-session": "^0.1.1-rc.5",
|
|
52
|
+
"@hydraharness/harness-settings": "^0.1.1-rc.5",
|
|
53
|
+
"@hydraharness/harness-hooks-codex": "^0.1.1-rc.5",
|
|
54
|
+
"@hydraharness/cordis": "^4.0.2",
|
|
55
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.5"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@hydraharness/harness-hooks-codex": "^0.1.1-rc.5",
|
|
59
|
+
"@hydraharness/harness-hooks-claude-code": "^0.1.1-rc.5",
|
|
60
|
+
"@hydraharness/harness-invariants": "^0.1.1-rc.5",
|
|
61
|
+
"@hydraharness/harness-session": "^0.1.1-rc.5",
|
|
62
|
+
"@hydraharness/harness-settings": "^0.1.1-rc.5",
|
|
63
|
+
"@hydraharness/harness-settings-file": "^0.1.1-rc.5",
|
|
64
|
+
"@hydraharness/cordis-plugin-loader": "^1.0.3",
|
|
65
|
+
"@hydraharness/cordis": "^4.0.2"
|
|
66
|
+
}
|
|
67
|
+
}
|