@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
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,40 @@
|
|
|
1
|
+
# @hydraharness/harness-hooks-registry
|
|
2
|
+
|
|
3
|
+
`HookRecordRegistry` (`ctx.hookRecords`) owns the declared hook records in its settings provider's `hooks` namespace. User and workspace providers choose the persistence file; changes from a configuration surface or a hand-edited document reconcile without a restart. Each enabled record mounts its dialect's bridge — `@hydraharness/harness-hooks-claude-code` or `@hydraharness/harness-hooks-codex` — which owns payloads, matchers, 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
|
+
`define`, `setEnabled`, and `remove` accept an optional `SettingsProvider` selecting a write layer. They derive records and revisions from that layer's base and stored override, including overrides suppressed by trust; shared writes preserve shared definitions without copying local overrides. Their result remains the effective runtime projection. Removing a shared record retains its materialized document while a local record still runs. `list(settings)` projects desired records, reporting live state only when the running definition matches; `list()` reads effective records. `settle()` awaits queued reconciliation after a settings-layer or trust change.
|
|
12
|
+
|
|
13
|
+
## Stored records
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
`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.
|
|
18
|
+
|
|
19
|
+
The document source is either `configPath` — an absolute path, or a path relative to registry `Config.cwd` when supplied — or `config`, inline definitions as a bare event map or a `{ hooks: … }` wrapper. Inline definitions are capped at 256 KiB and materialized to `<hydraHome>/hooks/<name>.json` on every mount, because both bridges read one file path at load; editing the inline section is enough to change what runs.
|
|
20
|
+
|
|
21
|
+
`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.
|
|
22
|
+
|
|
23
|
+
`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.
|
|
24
|
+
|
|
25
|
+
Mounts and settings writes use the registry's owning context and registration scope, including calls through an API context. Hooks receive descendant agents' events, including subagent lifecycle events; sibling workspace hooks remain separate. Mounts survive disposal of the API caller. Each workspace registry requires a separate Host-private `hydraHome` for its materialized documents. Definition validation requires at least one supported synchronous command hook, and the inline-document limit measures UTF-8 bytes.
|
|
26
|
+
|
|
27
|
+
Registry `Config.cwd` is an optional absolute workspace directory. Relative file and substitution paths resolve from it, and the bridge runs commands there. Without it, file paths must be absolute and commands use the agent's session cwd. Unmounting aborts and drains every active hook before reconciliation settles, including blocking prompt/tool hooks; materialized inline files remain under the Host-private home.
|
|
28
|
+
|
|
29
|
+
## Model Experience
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
#### KV Cache effect
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
## Known Limitations and Deferred Work
|
|
38
|
+
|
|
39
|
+
- **One document per bridge mount** — file records are read when the bridge mounts; editing an external hook document requires redefining or toggling the record to reload it.
|
|
40
|
+
- **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,428 @@
|
|
|
1
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, resolve } 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, cwd) {
|
|
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) && cwd === void 0) 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({
|
|
127
|
+
hydraHome: z.string(),
|
|
128
|
+
cwd: z.string()
|
|
129
|
+
});
|
|
130
|
+
settings;
|
|
131
|
+
live = /* @__PURE__ */ new Map();
|
|
132
|
+
/** Stored records this registry refuses to mount, with the refusal reason. */
|
|
133
|
+
invalid = /* @__PURE__ */ new Map();
|
|
134
|
+
/** Directory holding the documents materialized from inline definitions. */
|
|
135
|
+
inlineRoot;
|
|
136
|
+
cwd;
|
|
137
|
+
tail = Promise.resolve();
|
|
138
|
+
stopped = false;
|
|
139
|
+
constructor(ctx, config = {}) {
|
|
140
|
+
super(ctx, "hookRecords");
|
|
141
|
+
if (config.cwd !== void 0 && !isAbsolute(config.cwd)) throw new Error("hookRecords: cwd must be absolute");
|
|
142
|
+
this.owner = ctx;
|
|
143
|
+
this.cwd = config.cwd;
|
|
144
|
+
this.inlineRoot = join(resolveHydraHome(config.hydraHome), "hooks");
|
|
145
|
+
this.settings = ctx.settings.register(HOOKS_SETTINGS_NAMESPACE, HooksSettingsSchema, { validate: assertUniqueNames });
|
|
146
|
+
}
|
|
147
|
+
async [Service.init]() {
|
|
148
|
+
this.ctx.inject(["optionalPlugins"], (pctx) => {
|
|
149
|
+
pctx.effect(() => pctx.optionalPlugins.subscribe((enabled) => {
|
|
150
|
+
this.enqueue(async () => {
|
|
151
|
+
if (!enabled) await pctx.optionalPlugins.drain();
|
|
152
|
+
if (pctx.optionalPlugins.enabled === enabled) await this.reconcile();
|
|
153
|
+
}).catch((error) => {
|
|
154
|
+
this.ctx.logger.error(error);
|
|
155
|
+
});
|
|
156
|
+
}), "hooks-registry: optional policy");
|
|
157
|
+
});
|
|
158
|
+
this.ctx.effect(() => () => this.shutdown(), "hookRecords.dispose");
|
|
159
|
+
this.ctx.effect(() => this.settings.watch(() => this.enqueue(() => this.reconcile())), "hookRecords.settingsWatch");
|
|
160
|
+
await this.enqueue(() => this.reconcile());
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Project every stored record with its live state.
|
|
164
|
+
* @param settings - optional layer whose desired records to project.
|
|
165
|
+
* @returns the current record projection, in stored order.
|
|
166
|
+
*/
|
|
167
|
+
list(settings) {
|
|
168
|
+
return { records: (settings === void 0 ? this.settings.get().records : this.writeSource(settings).records).map((record) => this.view(record)) };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Reconcile the current effective settings after a layer or trust change.
|
|
172
|
+
* @returns completion after earlier queued work and the current mounts settle.
|
|
173
|
+
*/
|
|
174
|
+
settle() {
|
|
175
|
+
return this.enqueue(() => this.reconcile());
|
|
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
|
+
* @param settings - selected write layer; omitted uses this registry's provider.
|
|
183
|
+
* @returns the refreshed projection.
|
|
184
|
+
* @throws If create names an existing record or replace names a missing record.
|
|
185
|
+
*/
|
|
186
|
+
define(request, settings = this.owner.settings) {
|
|
187
|
+
return this.enqueue(async () => {
|
|
188
|
+
const { revision, records } = this.writeSource(settings);
|
|
189
|
+
const index = records.findIndex((candidate) => candidate.name === request.name.trim());
|
|
190
|
+
if (request.mode === "create" && index !== -1) throw new Error(`hookRecords.define: record ${request.name} already exists`);
|
|
191
|
+
if (request.mode === "replace" && index === -1) throw new Error(`hookRecords.define: record ${request.name} is not configured`);
|
|
192
|
+
const record = foldDefinition(request, index === -1 ? void 0 : records[index]);
|
|
193
|
+
assertMountable(record, "hookRecords.define", this.cwd);
|
|
194
|
+
summarize(record.dialect, await this.readDefinitions(record));
|
|
195
|
+
const next = index === -1 ? [...records, record] : records.map((candidate, position) => position === index ? record : candidate);
|
|
196
|
+
if (next.length > MAX_RECORDS) throw new Error(`hookRecords.define: at most ${String(MAX_RECORDS)} records are supported`);
|
|
197
|
+
await settings.update(HOOKS_SETTINGS_NAMESPACE, { records: next }, revision);
|
|
198
|
+
await this.reconcile();
|
|
199
|
+
return this.list();
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Change one stored record's desired state.
|
|
204
|
+
* @param request - target record and desired state.
|
|
205
|
+
* @param settings - selected write layer; omitted uses this registry's provider.
|
|
206
|
+
* @returns the refreshed projection.
|
|
207
|
+
*/
|
|
208
|
+
setEnabled(request, settings = this.owner.settings) {
|
|
209
|
+
return this.enqueue(async () => {
|
|
210
|
+
const { revision, records } = this.writeSource(settings);
|
|
211
|
+
const index = records.findIndex((candidate) => candidate.name === request.name);
|
|
212
|
+
if (index === -1) throw new Error(`hookRecords.setEnabled: record ${request.name} is not configured`);
|
|
213
|
+
const next = records.map((candidate, position) => position === index ? {
|
|
214
|
+
...candidate,
|
|
215
|
+
enabled: request.enabled
|
|
216
|
+
} : candidate);
|
|
217
|
+
await settings.update(HOOKS_SETTINGS_NAMESPACE, { records: next }, revision);
|
|
218
|
+
await this.reconcile();
|
|
219
|
+
return this.list();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Remove one stored record, unmount it, and delete any document this registry
|
|
224
|
+
* materialized for it.
|
|
225
|
+
* @param name - stored record name.
|
|
226
|
+
* @param settings - selected write layer; omitted uses this registry's provider.
|
|
227
|
+
* @returns the refreshed projection.
|
|
228
|
+
*/
|
|
229
|
+
remove(name, settings = this.owner.settings) {
|
|
230
|
+
return this.enqueue(async () => {
|
|
231
|
+
const { revision, records } = this.writeSource(settings);
|
|
232
|
+
if (!records.some((candidate) => candidate.name === name)) throw new Error(`hookRecords.remove: record ${name} is not configured`);
|
|
233
|
+
await settings.replace(HOOKS_SETTINGS_NAMESPACE, { records: records.filter((candidate) => candidate.name !== name) }, revision);
|
|
234
|
+
await this.reconcile();
|
|
235
|
+
if (!this.settings.get().records.some((record) => record.name === name)) await rm(this.inlinePath(name), { force: true });
|
|
236
|
+
return this.list();
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/** Read the selected layer's desired records, including overrides suppressed by trust. */
|
|
240
|
+
writeSource(settings) {
|
|
241
|
+
const descriptor = settings.describe().find((section) => section.ns === HOOKS_SETTINGS_NAMESPACE);
|
|
242
|
+
if (descriptor === void 0) throw new Error("hookRecords: selected settings provider has no hooks namespace");
|
|
243
|
+
return {
|
|
244
|
+
...HooksSettingsSchema({ records: descriptor.user?.records ?? descriptor.base?.records ?? [] }),
|
|
245
|
+
revision: descriptor.revision
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** Serialize every mutation and reconciliation on one chain. */
|
|
249
|
+
enqueue(task) {
|
|
250
|
+
const run = this.tail.then(task, task);
|
|
251
|
+
this.tail = run.then(() => void 0, () => void 0);
|
|
252
|
+
return run;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Converge the mounted set on the stored one: unmount every record that is
|
|
256
|
+
* gone, disabled, or redefined, then mount every enabled record that is not
|
|
257
|
+
* already live from the same definition. A record whose document is missing,
|
|
258
|
+
* unparseable, or refused by its dialect is recorded as `invalid` with the
|
|
259
|
+
* diagnostic and does not stop the others.
|
|
260
|
+
*/
|
|
261
|
+
async reconcile() {
|
|
262
|
+
if (this.stopped) return;
|
|
263
|
+
const records = new Map(this.settings.get().records.map((record) => [record.name, record]));
|
|
264
|
+
this.invalid.clear();
|
|
265
|
+
for (const [name, mount] of [...this.live]) {
|
|
266
|
+
const record = records.get(name);
|
|
267
|
+
if (this.ctx.get("optionalPlugins")?.enabled !== false && record?.enabled === true && recordDigest(record) === mount.digest) continue;
|
|
268
|
+
this.live.delete(name);
|
|
269
|
+
await disposeQuietly(mount.fiber);
|
|
270
|
+
}
|
|
271
|
+
for (const record of records.values()) {
|
|
272
|
+
if (this.ctx.get("optionalPlugins")?.enabled === false || !record.enabled || this.live.has(record.name)) continue;
|
|
273
|
+
await this.mount(record);
|
|
274
|
+
}
|
|
275
|
+
this.ctx.emit("hooks-registry/reconciled", this.list());
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Materialize the record's document when it is inline, then start its
|
|
279
|
+
* dialect's bridge. A refusal is stored as `invalid` rather than thrown: a
|
|
280
|
+
* hand-edited document must not stop every other record from mounting.
|
|
281
|
+
*/
|
|
282
|
+
async mount(record) {
|
|
283
|
+
let configPath;
|
|
284
|
+
let summary;
|
|
285
|
+
try {
|
|
286
|
+
assertMountable(record, "hookRecords", this.cwd);
|
|
287
|
+
configPath = await this.materialize(record);
|
|
288
|
+
summary = summarize(record.dialect, await this.readDefinitions(record));
|
|
289
|
+
} catch (error) {
|
|
290
|
+
/* v8 ignore next -- validation, JSON parsing, and local filesystem operations throw Error instances. */
|
|
291
|
+
this.invalid.set(record.name, error instanceof Error ? error.message : String(error));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const fiber = record.dialect === "claude-code" ? this.owner.plugin({
|
|
295
|
+
name: `hook-record:${record.name}`,
|
|
296
|
+
inject: inject$1,
|
|
297
|
+
apply
|
|
298
|
+
}, {
|
|
299
|
+
configPath,
|
|
300
|
+
defaultTimeoutMs: record.defaultTimeoutMs,
|
|
301
|
+
...this.cwd === void 0 ? {} : { cwd: this.cwd },
|
|
302
|
+
...record.pluginRoot === "" ? {} : { pluginRoot: this.documentPath(record.pluginRoot) },
|
|
303
|
+
...record.projectDir === "" ? {} : { projectDir: this.documentPath(record.projectDir) }
|
|
304
|
+
}) : this.owner.plugin({
|
|
305
|
+
name: `hook-record:${record.name}`,
|
|
306
|
+
inject: inject$2,
|
|
307
|
+
apply: apply$1
|
|
308
|
+
}, {
|
|
309
|
+
configPath,
|
|
310
|
+
defaultTimeoutMs: record.defaultTimeoutMs,
|
|
311
|
+
...this.cwd === void 0 ? {} : { cwd: this.cwd }
|
|
312
|
+
});
|
|
313
|
+
const mount = {
|
|
314
|
+
fiber,
|
|
315
|
+
digest: recordDigest(record),
|
|
316
|
+
events: summary.events,
|
|
317
|
+
hookCount: summary.hookCount,
|
|
318
|
+
status: "started"
|
|
319
|
+
};
|
|
320
|
+
this.live.set(record.name, mount);
|
|
321
|
+
try {
|
|
322
|
+
await fiber;
|
|
323
|
+
} catch (error) {
|
|
324
|
+
mount.status = "failed";
|
|
325
|
+
/* v8 ignore next -- Cordis normalizes plugin failures to Error before fiber.await rejects. */
|
|
326
|
+
mount.detail = error instanceof Error ? error.message : String(error);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Resolve the document path the bridge reads: a `file` record's own path, or
|
|
331
|
+
* the harness-home document this registry writes from inline definitions.
|
|
332
|
+
* Materialization is idempotent and re-runs per mount, so an edited inline
|
|
333
|
+
* section always reaches the bridge.
|
|
334
|
+
*/
|
|
335
|
+
async materialize(record) {
|
|
336
|
+
if (sourceKind(record) === "file") return this.documentPath(record.configPath);
|
|
337
|
+
const path = this.inlinePath(record.name);
|
|
338
|
+
await mkdir(this.inlineRoot, {
|
|
339
|
+
recursive: true,
|
|
340
|
+
mode: 448
|
|
341
|
+
});
|
|
342
|
+
await writeFileAtomic(path, `${JSON.stringify(record.config, void 0, 2)}\n`, {
|
|
343
|
+
mode: 384,
|
|
344
|
+
dirMode: 448
|
|
345
|
+
});
|
|
346
|
+
return path;
|
|
347
|
+
}
|
|
348
|
+
/** Read one record's raw definitions, from its file or its inline section. */
|
|
349
|
+
async readDefinitions(record) {
|
|
350
|
+
if (sourceKind(record) === "inline") return record.config;
|
|
351
|
+
let text;
|
|
352
|
+
try {
|
|
353
|
+
text = await readFile(this.documentPath(record.configPath), "utf8");
|
|
354
|
+
} catch (cause) {
|
|
355
|
+
throw new Error(`hookRecords: record ${record.name} cannot read ${record.configPath}`, { cause });
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
return JSON.parse(text);
|
|
359
|
+
} catch (cause) {
|
|
360
|
+
throw new Error(`hookRecords: record ${record.name} document is not valid JSON`, { cause });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/** Path of the document this registry materializes for one inline record. */
|
|
364
|
+
inlinePath(name) {
|
|
365
|
+
return join(this.inlineRoot, `${name}.json`);
|
|
366
|
+
}
|
|
367
|
+
/** Resolve a read-only document or substitution path inside the owning cwd. */
|
|
368
|
+
documentPath(path) {
|
|
369
|
+
return this.cwd === void 0 ? path : resolve(this.cwd, path);
|
|
370
|
+
}
|
|
371
|
+
/** Project one stored record with its live state. */
|
|
372
|
+
view(record) {
|
|
373
|
+
const candidate = this.live.get(record.name);
|
|
374
|
+
const digest = recordDigest(record);
|
|
375
|
+
const mount = candidate?.digest === digest ? candidate : void 0;
|
|
376
|
+
const effective = this.settings.get().records.find((hook) => hook.name === record.name);
|
|
377
|
+
const invalid = effective !== void 0 && recordDigest(effective) === digest ? this.invalid.get(record.name) : void 0;
|
|
378
|
+
const status = invalid !== void 0 ? "invalid" : !record.enabled ? "stopped" : mount?.status ?? "stopped";
|
|
379
|
+
const detail = invalid ?? mount?.detail;
|
|
380
|
+
return {
|
|
381
|
+
name: record.name,
|
|
382
|
+
dialect: record.dialect,
|
|
383
|
+
source: sourceKind(record),
|
|
384
|
+
enabled: record.enabled,
|
|
385
|
+
status,
|
|
386
|
+
...detail === void 0 || status === "stopped" ? {} : { detail },
|
|
387
|
+
...sourceKind(record) === "file" ? { configPath: record.configPath } : {},
|
|
388
|
+
events: mount?.events ?? [],
|
|
389
|
+
hookCount: mount?.hookCount ?? 0,
|
|
390
|
+
...record.pluginRoot === "" ? {} : { pluginRoot: record.pluginRoot },
|
|
391
|
+
...record.projectDir === "" ? {} : { projectDir: record.projectDir }
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/** Refuse new work, then unmount every live record. */
|
|
395
|
+
async shutdown() {
|
|
396
|
+
this.stopped = true;
|
|
397
|
+
/* v8 ignore next -- enqueue stores a fulfilled tail after either task outcome. */
|
|
398
|
+
await this.tail.catch(() => void 0);
|
|
399
|
+
this.invalid.clear();
|
|
400
|
+
for (const [name, mount] of [...this.live]) {
|
|
401
|
+
this.live.delete(name);
|
|
402
|
+
await disposeQuietly(mount.fiber);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
/**
|
|
407
|
+
* Digest of everything a mount is started from, so reconciliation can tell a
|
|
408
|
+
* redefined record from an untouched one without restarting a live bridge on
|
|
409
|
+
* every unrelated document commit. The desired state is excluded: enablement is
|
|
410
|
+
* handled by mount and unmount, not by a restart.
|
|
411
|
+
*/
|
|
412
|
+
function recordDigest(record) {
|
|
413
|
+
const { enabled: _desired, ...definition } = record;
|
|
414
|
+
return JSON.stringify(definition);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Unmount one bridge and swallow its teardown failure. The record it served is
|
|
418
|
+
* already out of the live map, so a disposal error cannot restore it, and
|
|
419
|
+
* letting it escape would strand every later record in the same reconcile.
|
|
420
|
+
* @param fiber - the mount to tear down.
|
|
421
|
+
*/
|
|
422
|
+
async function disposeQuietly(fiber) {
|
|
423
|
+
try {
|
|
424
|
+
await fiber.dispose();
|
|
425
|
+
} catch (_hookMountTeardownFailure) {}
|
|
426
|
+
}
|
|
427
|
+
//#endregion
|
|
428
|
+
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,134 @@
|
|
|
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 SettingsProvider } from '@hydraharness/harness-settings';
|
|
18
|
+
import type { HookRecordDefinitionRequest, HookRecordEnablementRequest, HookRecordSnapshot } from './types.ts';
|
|
19
|
+
export type * from './types.ts';
|
|
20
|
+
declare module '@hydraharness/cordis' {
|
|
21
|
+
interface Context {
|
|
22
|
+
/** User-declared hook records and their live bridge mounts. */
|
|
23
|
+
hookRecords: HookRecordRegistry;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Settings namespace holding every user-declared hook record. */
|
|
27
|
+
export declare const HOOKS_SETTINGS_NAMESPACE: import("@hydraharness/harness-settings").SettingsNamespace;
|
|
28
|
+
/** Registry configuration. */
|
|
29
|
+
export interface Config {
|
|
30
|
+
/** Override the resolved harness home that holds materialized inline documents. */
|
|
31
|
+
hydraHome?: string;
|
|
32
|
+
/** Absolute workspace directory for relative document paths and hook commands. */
|
|
33
|
+
cwd?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Services required by the registry; the bridges themselves need `shell`. */
|
|
36
|
+
export declare const inject: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Settings-backed hook records with their live bridge mounts. Reconciliation is
|
|
39
|
+
* idempotent and runs on one serialized chain, so a document commit from any
|
|
40
|
+
* source — this service's own write, another process, or a hand edit —
|
|
41
|
+
* converges the mounted set on the stored one.
|
|
42
|
+
* Mutations reject with `SettingsConflictError` if another writer changes the
|
|
43
|
+
* stored section after the read; retry uses the refreshed projection.
|
|
44
|
+
*/
|
|
45
|
+
export declare class HookRecordRegistry extends Service {
|
|
46
|
+
static inject: string[];
|
|
47
|
+
/** Bridge mounts belong to this Host service, not to a traced API caller. */
|
|
48
|
+
private readonly owner;
|
|
49
|
+
static Config: z<Config>;
|
|
50
|
+
private readonly settings;
|
|
51
|
+
private readonly live;
|
|
52
|
+
/** Stored records this registry refuses to mount, with the refusal reason. */
|
|
53
|
+
private readonly invalid;
|
|
54
|
+
/** Directory holding the documents materialized from inline definitions. */
|
|
55
|
+
private readonly inlineRoot;
|
|
56
|
+
private readonly cwd;
|
|
57
|
+
private tail;
|
|
58
|
+
private stopped;
|
|
59
|
+
constructor(ctx: Context, config?: Config);
|
|
60
|
+
protected [Service.init](): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Project every stored record with its live state.
|
|
63
|
+
* @param settings - optional layer whose desired records to project.
|
|
64
|
+
* @returns the current record projection, in stored order.
|
|
65
|
+
*/
|
|
66
|
+
list(settings?: SettingsProvider): HookRecordSnapshot;
|
|
67
|
+
/**
|
|
68
|
+
* Reconcile the current effective settings after a layer or trust change.
|
|
69
|
+
* @returns completion after earlier queued work and the current mounts settle.
|
|
70
|
+
*/
|
|
71
|
+
settle(): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Create or replace one complete definition as requested, then
|
|
74
|
+
* converge the mounted set. A definition this registry could not mount is
|
|
75
|
+
* refused before anything persists.
|
|
76
|
+
* @param request - complete record definition.
|
|
77
|
+
* @param settings - selected write layer; omitted uses this registry's provider.
|
|
78
|
+
* @returns the refreshed projection.
|
|
79
|
+
* @throws If create names an existing record or replace names a missing record.
|
|
80
|
+
*/
|
|
81
|
+
define(request: HookRecordDefinitionRequest, settings?: SettingsProvider): Promise<HookRecordSnapshot>;
|
|
82
|
+
/**
|
|
83
|
+
* Change one stored record's desired state.
|
|
84
|
+
* @param request - target record and desired state.
|
|
85
|
+
* @param settings - selected write layer; omitted uses this registry's provider.
|
|
86
|
+
* @returns the refreshed projection.
|
|
87
|
+
*/
|
|
88
|
+
setEnabled(request: HookRecordEnablementRequest, settings?: SettingsProvider): Promise<HookRecordSnapshot>;
|
|
89
|
+
/**
|
|
90
|
+
* Remove one stored record, unmount it, and delete any document this registry
|
|
91
|
+
* materialized for it.
|
|
92
|
+
* @param name - stored record name.
|
|
93
|
+
* @param settings - selected write layer; omitted uses this registry's provider.
|
|
94
|
+
* @returns the refreshed projection.
|
|
95
|
+
*/
|
|
96
|
+
remove(name: string, settings?: SettingsProvider): Promise<HookRecordSnapshot>;
|
|
97
|
+
/** Read the selected layer's desired records, including overrides suppressed by trust. */
|
|
98
|
+
private writeSource;
|
|
99
|
+
/** Serialize every mutation and reconciliation on one chain. */
|
|
100
|
+
private enqueue;
|
|
101
|
+
/**
|
|
102
|
+
* Converge the mounted set on the stored one: unmount every record that is
|
|
103
|
+
* gone, disabled, or redefined, then mount every enabled record that is not
|
|
104
|
+
* already live from the same definition. A record whose document is missing,
|
|
105
|
+
* unparseable, or refused by its dialect is recorded as `invalid` with the
|
|
106
|
+
* diagnostic and does not stop the others.
|
|
107
|
+
*/
|
|
108
|
+
private reconcile;
|
|
109
|
+
/**
|
|
110
|
+
* Materialize the record's document when it is inline, then start its
|
|
111
|
+
* dialect's bridge. A refusal is stored as `invalid` rather than thrown: a
|
|
112
|
+
* hand-edited document must not stop every other record from mounting.
|
|
113
|
+
*/
|
|
114
|
+
private mount;
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the document path the bridge reads: a `file` record's own path, or
|
|
117
|
+
* the harness-home document this registry writes from inline definitions.
|
|
118
|
+
* Materialization is idempotent and re-runs per mount, so an edited inline
|
|
119
|
+
* section always reaches the bridge.
|
|
120
|
+
*/
|
|
121
|
+
private materialize;
|
|
122
|
+
/** Read one record's raw definitions, from its file or its inline section. */
|
|
123
|
+
private readDefinitions;
|
|
124
|
+
/** Path of the document this registry materializes for one inline record. */
|
|
125
|
+
private inlinePath;
|
|
126
|
+
/** Resolve a read-only document or substitution path inside the owning cwd. */
|
|
127
|
+
private documentPath;
|
|
128
|
+
/** Project one stored record with its live state. */
|
|
129
|
+
private view;
|
|
130
|
+
/** Refuse new work, then unmount every live record. */
|
|
131
|
+
private shutdown;
|
|
132
|
+
}
|
|
133
|
+
export default HookRecordRegistry;
|
|
134
|
+
//# sourceMappingURL=index.d.ts.map
|