@hydraharness/harness-hooks-registry 0.1.1-rc.10
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 +40 -0
- package/lib/index.js +428 -0
- package/lib/invariant.js +32 -0
- package/lib/types/index.d.ts +134 -0
- package/lib/types/index.js +482 -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 +72 -0
|
@@ -0,0 +1,482 @@
|
|
|
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, resolve } 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, cwd) {
|
|
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) && cwd === undefined) {
|
|
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
|
+
cwd: z.string(),
|
|
147
|
+
});
|
|
148
|
+
settings;
|
|
149
|
+
live = new Map();
|
|
150
|
+
/** Stored records this registry refuses to mount, with the refusal reason. */
|
|
151
|
+
invalid = new Map();
|
|
152
|
+
/** Directory holding the documents materialized from inline definitions. */
|
|
153
|
+
inlineRoot;
|
|
154
|
+
cwd;
|
|
155
|
+
tail = Promise.resolve();
|
|
156
|
+
stopped = false;
|
|
157
|
+
constructor(ctx, config = {}) {
|
|
158
|
+
super(ctx, 'hookRecords');
|
|
159
|
+
if (config.cwd !== undefined && !isAbsolute(config.cwd))
|
|
160
|
+
throw new Error('hookRecords: cwd must be absolute');
|
|
161
|
+
this.owner = ctx;
|
|
162
|
+
this.cwd = config.cwd;
|
|
163
|
+
this.inlineRoot = join(resolveHydraHome(config.hydraHome), 'hooks');
|
|
164
|
+
this.settings = ctx.settings.register(HOOKS_SETTINGS_NAMESPACE, HooksSettingsSchema, {
|
|
165
|
+
// Refuse a colliding section where it is written: two records claiming
|
|
166
|
+
// one name would make which definitions run depend on mount order.
|
|
167
|
+
validate: assertUniqueNames,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async [Service.init]() {
|
|
171
|
+
this.ctx.inject(['optionalPlugins'], (pctx) => {
|
|
172
|
+
pctx.effect(() => pctx.optionalPlugins.subscribe((enabled) => {
|
|
173
|
+
void this.enqueue(async () => {
|
|
174
|
+
if (!enabled)
|
|
175
|
+
await pctx.optionalPlugins.drain();
|
|
176
|
+
if (pctx.optionalPlugins.enabled === enabled)
|
|
177
|
+
await this.reconcile();
|
|
178
|
+
}).catch((error) => { this.ctx.logger.error(error); });
|
|
179
|
+
}), 'hooks-registry: optional policy');
|
|
180
|
+
});
|
|
181
|
+
this.ctx.effect(() => () => this.shutdown(), 'hookRecords.dispose');
|
|
182
|
+
this.ctx.effect(() => this.settings.watch(() => this.enqueue(() => this.reconcile())), 'hookRecords.settingsWatch');
|
|
183
|
+
await this.enqueue(() => this.reconcile());
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Project every stored record with its live state.
|
|
187
|
+
* @param settings - optional layer whose desired records to project.
|
|
188
|
+
* @returns the current record projection, in stored order.
|
|
189
|
+
*/
|
|
190
|
+
list(settings) {
|
|
191
|
+
const records = settings === undefined ? this.settings.get().records : this.writeSource(settings).records;
|
|
192
|
+
return { records: records.map(record => this.view(record)) };
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Reconcile the current effective settings after a layer or trust change.
|
|
196
|
+
* @returns completion after earlier queued work and the current mounts settle.
|
|
197
|
+
*/
|
|
198
|
+
settle() {
|
|
199
|
+
return this.enqueue(() => this.reconcile());
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Create or replace one complete definition as requested, then
|
|
203
|
+
* converge the mounted set. A definition this registry could not mount is
|
|
204
|
+
* refused before anything persists.
|
|
205
|
+
* @param request - complete record definition.
|
|
206
|
+
* @param settings - selected write layer; omitted uses this registry's provider.
|
|
207
|
+
* @returns the refreshed projection.
|
|
208
|
+
* @throws If create names an existing record or replace names a missing record.
|
|
209
|
+
*/
|
|
210
|
+
define(request, settings = this.owner.settings) {
|
|
211
|
+
return this.enqueue(async () => {
|
|
212
|
+
const { revision, records } = this.writeSource(settings);
|
|
213
|
+
const index = records.findIndex(candidate => candidate.name === request.name.trim());
|
|
214
|
+
if (request.mode === 'create' && index !== -1)
|
|
215
|
+
throw new Error(`hookRecords.define: record ${request.name} already exists`);
|
|
216
|
+
if (request.mode === 'replace' && index === -1)
|
|
217
|
+
throw new Error(`hookRecords.define: record ${request.name} is not configured`);
|
|
218
|
+
const record = foldDefinition(request, index === -1 ? undefined : records[index]);
|
|
219
|
+
assertMountable(record, 'hookRecords.define', this.cwd);
|
|
220
|
+
// The dialect parser is the authority on whether these definitions run;
|
|
221
|
+
// refusing here means a stored record can never be silently inert.
|
|
222
|
+
summarize(record.dialect, await this.readDefinitions(record));
|
|
223
|
+
const next = index === -1
|
|
224
|
+
? [...records, record]
|
|
225
|
+
: records.map((candidate, position) => position === index ? record : candidate);
|
|
226
|
+
if (next.length > MAX_RECORDS)
|
|
227
|
+
throw new Error(`hookRecords.define: at most ${String(MAX_RECORDS)} records are supported`);
|
|
228
|
+
await settings.update(HOOKS_SETTINGS_NAMESPACE, { records: next }, revision);
|
|
229
|
+
await this.reconcile();
|
|
230
|
+
return this.list();
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Change one stored record's desired state.
|
|
235
|
+
* @param request - target record and desired state.
|
|
236
|
+
* @param settings - selected write layer; omitted uses this registry's provider.
|
|
237
|
+
* @returns the refreshed projection.
|
|
238
|
+
*/
|
|
239
|
+
setEnabled(request, settings = this.owner.settings) {
|
|
240
|
+
return this.enqueue(async () => {
|
|
241
|
+
const { revision, records } = this.writeSource(settings);
|
|
242
|
+
const index = records.findIndex(candidate => candidate.name === request.name);
|
|
243
|
+
if (index === -1)
|
|
244
|
+
throw new Error(`hookRecords.setEnabled: record ${request.name} is not configured`);
|
|
245
|
+
const next = records.map((candidate, position) => position === index
|
|
246
|
+
? { ...candidate, enabled: request.enabled }
|
|
247
|
+
: candidate);
|
|
248
|
+
await settings.update(HOOKS_SETTINGS_NAMESPACE, { records: next }, revision);
|
|
249
|
+
await this.reconcile();
|
|
250
|
+
return this.list();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Remove one stored record, unmount it, and delete any document this registry
|
|
255
|
+
* materialized for it.
|
|
256
|
+
* @param name - stored record name.
|
|
257
|
+
* @param settings - selected write layer; omitted uses this registry's provider.
|
|
258
|
+
* @returns the refreshed projection.
|
|
259
|
+
*/
|
|
260
|
+
remove(name, settings = this.owner.settings) {
|
|
261
|
+
return this.enqueue(async () => {
|
|
262
|
+
const { revision, records } = this.writeSource(settings);
|
|
263
|
+
if (!records.some(candidate => candidate.name === name)) {
|
|
264
|
+
throw new Error(`hookRecords.remove: record ${name} is not configured`);
|
|
265
|
+
}
|
|
266
|
+
await settings.replace(HOOKS_SETTINGS_NAMESPACE, { records: records.filter(candidate => candidate.name !== name) }, revision);
|
|
267
|
+
await this.reconcile();
|
|
268
|
+
if (!this.settings.get().records.some(record => record.name === name)) {
|
|
269
|
+
await rm(this.inlinePath(name), { force: true });
|
|
270
|
+
}
|
|
271
|
+
return this.list();
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/** Read the selected layer's desired records, including overrides suppressed by trust. */
|
|
275
|
+
writeSource(settings) {
|
|
276
|
+
const descriptor = settings.describe().find(section => section.ns === HOOKS_SETTINGS_NAMESPACE);
|
|
277
|
+
if (descriptor === undefined)
|
|
278
|
+
throw new Error('hookRecords: selected settings provider has no hooks namespace');
|
|
279
|
+
return {
|
|
280
|
+
...HooksSettingsSchema({
|
|
281
|
+
records: descriptor.user?.records
|
|
282
|
+
?? descriptor.base?.records ?? [],
|
|
283
|
+
}),
|
|
284
|
+
revision: descriptor.revision,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/* jscpd:ignore-start -- hook and MCP registries intentionally share serialized reconciliation. */
|
|
288
|
+
/** Serialize every mutation and reconciliation on one chain. */
|
|
289
|
+
enqueue(task) {
|
|
290
|
+
const run = this.tail.then(task, task);
|
|
291
|
+
this.tail = run.then(() => undefined, () => undefined);
|
|
292
|
+
return run;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Converge the mounted set on the stored one: unmount every record that is
|
|
296
|
+
* gone, disabled, or redefined, then mount every enabled record that is not
|
|
297
|
+
* already live from the same definition. A record whose document is missing,
|
|
298
|
+
* unparseable, or refused by its dialect is recorded as `invalid` with the
|
|
299
|
+
* diagnostic and does not stop the others.
|
|
300
|
+
*/
|
|
301
|
+
async reconcile() {
|
|
302
|
+
if (this.stopped)
|
|
303
|
+
return;
|
|
304
|
+
const records = new Map(this.settings.get().records.map(record => [record.name, record]));
|
|
305
|
+
this.invalid.clear();
|
|
306
|
+
for (const [name, mount] of [...this.live]) {
|
|
307
|
+
const record = records.get(name);
|
|
308
|
+
if (this.ctx.get('optionalPlugins')?.enabled !== false && record?.enabled === true && recordDigest(record) === mount.digest)
|
|
309
|
+
continue;
|
|
310
|
+
this.live.delete(name);
|
|
311
|
+
await disposeQuietly(mount.fiber);
|
|
312
|
+
}
|
|
313
|
+
for (const record of records.values()) {
|
|
314
|
+
if (this.ctx.get('optionalPlugins')?.enabled === false || !record.enabled || this.live.has(record.name))
|
|
315
|
+
continue;
|
|
316
|
+
await this.mount(record);
|
|
317
|
+
}
|
|
318
|
+
// The projection is published only after every mount settled, so an
|
|
319
|
+
// observer cannot read a record whose bridge is still loading.
|
|
320
|
+
this.ctx.emit('hooks-registry/reconciled', this.list());
|
|
321
|
+
}
|
|
322
|
+
/* jscpd:ignore-end */
|
|
323
|
+
/**
|
|
324
|
+
* Materialize the record's document when it is inline, then start its
|
|
325
|
+
* dialect's bridge. A refusal is stored as `invalid` rather than thrown: a
|
|
326
|
+
* hand-edited document must not stop every other record from mounting.
|
|
327
|
+
*/
|
|
328
|
+
async mount(record) {
|
|
329
|
+
let configPath;
|
|
330
|
+
let summary;
|
|
331
|
+
try {
|
|
332
|
+
assertMountable(record, 'hookRecords', this.cwd);
|
|
333
|
+
configPath = await this.materialize(record);
|
|
334
|
+
summary = summarize(record.dialect, await this.readDefinitions(record));
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
/* v8 ignore next -- validation, JSON parsing, and local filesystem operations throw Error instances. */
|
|
338
|
+
this.invalid.set(record.name, error instanceof Error ? error.message : String(error));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const fiber = record.dialect === 'claude-code'
|
|
342
|
+
? this.owner.plugin({
|
|
343
|
+
name: `hook-record:${record.name}`,
|
|
344
|
+
inject: claudeCodeInject,
|
|
345
|
+
apply: applyClaudeCodeHooks,
|
|
346
|
+
}, {
|
|
347
|
+
configPath,
|
|
348
|
+
defaultTimeoutMs: record.defaultTimeoutMs,
|
|
349
|
+
...this.cwd === undefined ? {} : { cwd: this.cwd },
|
|
350
|
+
...record.pluginRoot === '' ? {} : { pluginRoot: this.documentPath(record.pluginRoot) },
|
|
351
|
+
...record.projectDir === '' ? {} : { projectDir: this.documentPath(record.projectDir) },
|
|
352
|
+
})
|
|
353
|
+
: this.owner.plugin({
|
|
354
|
+
name: `hook-record:${record.name}`,
|
|
355
|
+
inject: codexInject,
|
|
356
|
+
apply: applyCodexHooks,
|
|
357
|
+
}, {
|
|
358
|
+
configPath,
|
|
359
|
+
defaultTimeoutMs: record.defaultTimeoutMs,
|
|
360
|
+
...this.cwd === undefined ? {} : { cwd: this.cwd },
|
|
361
|
+
});
|
|
362
|
+
const mount = {
|
|
363
|
+
fiber,
|
|
364
|
+
digest: recordDigest(record),
|
|
365
|
+
events: summary.events,
|
|
366
|
+
hookCount: summary.hookCount,
|
|
367
|
+
status: 'started',
|
|
368
|
+
};
|
|
369
|
+
this.live.set(record.name, mount);
|
|
370
|
+
try {
|
|
371
|
+
await fiber;
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
mount.status = 'failed';
|
|
375
|
+
/* v8 ignore next -- Cordis normalizes plugin failures to Error before fiber.await rejects. */
|
|
376
|
+
mount.detail = error instanceof Error ? error.message : String(error);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Resolve the document path the bridge reads: a `file` record's own path, or
|
|
381
|
+
* the harness-home document this registry writes from inline definitions.
|
|
382
|
+
* Materialization is idempotent and re-runs per mount, so an edited inline
|
|
383
|
+
* section always reaches the bridge.
|
|
384
|
+
*/
|
|
385
|
+
async materialize(record) {
|
|
386
|
+
if (sourceKind(record) === 'file')
|
|
387
|
+
return this.documentPath(record.configPath);
|
|
388
|
+
const path = this.inlinePath(record.name);
|
|
389
|
+
await mkdir(this.inlineRoot, { recursive: true, mode: 0o700 });
|
|
390
|
+
await writeFileAtomic(path, `${JSON.stringify(record.config, undefined, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
|
|
391
|
+
return path;
|
|
392
|
+
}
|
|
393
|
+
/** Read one record's raw definitions, from its file or its inline section. */
|
|
394
|
+
async readDefinitions(record) {
|
|
395
|
+
if (sourceKind(record) === 'inline')
|
|
396
|
+
return record.config;
|
|
397
|
+
let text;
|
|
398
|
+
try {
|
|
399
|
+
text = await readFile(this.documentPath(record.configPath), 'utf8');
|
|
400
|
+
}
|
|
401
|
+
catch (cause) {
|
|
402
|
+
throw new Error(`hookRecords: record ${record.name} cannot read ${record.configPath}`, { cause });
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
return JSON.parse(text);
|
|
406
|
+
}
|
|
407
|
+
catch (cause) {
|
|
408
|
+
throw new Error(`hookRecords: record ${record.name} document is not valid JSON`, { cause });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/** Path of the document this registry materializes for one inline record. */
|
|
412
|
+
inlinePath(name) {
|
|
413
|
+
return join(this.inlineRoot, `${name}.json`);
|
|
414
|
+
}
|
|
415
|
+
/** Resolve a read-only document or substitution path inside the owning cwd. */
|
|
416
|
+
documentPath(path) {
|
|
417
|
+
return this.cwd === undefined ? path : resolve(this.cwd, path);
|
|
418
|
+
}
|
|
419
|
+
/** Project one stored record with its live state. */
|
|
420
|
+
view(record) {
|
|
421
|
+
const candidate = this.live.get(record.name);
|
|
422
|
+
const digest = recordDigest(record);
|
|
423
|
+
const mount = candidate?.digest === digest ? candidate : undefined;
|
|
424
|
+
const effective = this.settings.get().records.find(hook => hook.name === record.name);
|
|
425
|
+
const invalid = effective !== undefined && recordDigest(effective) === digest ? this.invalid.get(record.name) : undefined;
|
|
426
|
+
const status = invalid !== undefined
|
|
427
|
+
? 'invalid'
|
|
428
|
+
: !record.enabled ? 'stopped' : mount?.status ?? 'stopped';
|
|
429
|
+
const detail = invalid ?? mount?.detail;
|
|
430
|
+
return {
|
|
431
|
+
name: record.name,
|
|
432
|
+
dialect: record.dialect,
|
|
433
|
+
source: sourceKind(record),
|
|
434
|
+
enabled: record.enabled,
|
|
435
|
+
status,
|
|
436
|
+
...detail === undefined || status === 'stopped' ? {} : { detail },
|
|
437
|
+
...sourceKind(record) === 'file' ? { configPath: record.configPath } : {},
|
|
438
|
+
events: mount?.events ?? [],
|
|
439
|
+
hookCount: mount?.hookCount ?? 0,
|
|
440
|
+
...record.pluginRoot === '' ? {} : { pluginRoot: record.pluginRoot },
|
|
441
|
+
...record.projectDir === '' ? {} : { projectDir: record.projectDir },
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/* jscpd:ignore-start -- hook and MCP shutdowns intentionally share teardown semantics. */
|
|
445
|
+
/** Refuse new work, then unmount every live record. */
|
|
446
|
+
async shutdown() {
|
|
447
|
+
this.stopped = true;
|
|
448
|
+
/* v8 ignore next -- enqueue stores a fulfilled tail after either task outcome. */
|
|
449
|
+
await this.tail.catch(() => undefined);
|
|
450
|
+
this.invalid.clear();
|
|
451
|
+
for (const [name, mount] of [...this.live]) {
|
|
452
|
+
this.live.delete(name);
|
|
453
|
+
await disposeQuietly(mount.fiber);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Digest of everything a mount is started from, so reconciliation can tell a
|
|
459
|
+
* redefined record from an untouched one without restarting a live bridge on
|
|
460
|
+
* every unrelated document commit. The desired state is excluded: enablement is
|
|
461
|
+
* handled by mount and unmount, not by a restart.
|
|
462
|
+
*/
|
|
463
|
+
function recordDigest(record) {
|
|
464
|
+
const { enabled: _desired, ...definition } = record;
|
|
465
|
+
return JSON.stringify(definition);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Unmount one bridge and swallow its teardown failure. The record it served is
|
|
469
|
+
* already out of the live map, so a disposal error cannot restore it, and
|
|
470
|
+
* letting it escape would strand every later record in the same reconcile.
|
|
471
|
+
* @param fiber - the mount to tear down.
|
|
472
|
+
*/
|
|
473
|
+
async function disposeQuietly(fiber) {
|
|
474
|
+
try {
|
|
475
|
+
await fiber.dispose();
|
|
476
|
+
}
|
|
477
|
+
catch (_hookMountTeardownFailure) {
|
|
478
|
+
// The fiber is inactive either way; nothing here can act on the failure.
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
export default HookRecordRegistry;
|
|
482
|
+
//# 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
|