@oisincoveney/pipeline 3.7.2 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,10 +43,10 @@ Initialize package-owned pipeline support:
43
43
  moka init
44
44
  ```
45
45
 
46
- `moka init` vendors the package's default project skills, then writes generated
47
- OpenCode command surfaces plus the singleton `pipeline-gateway` MCP entry.
48
- OpenCode is the package default runtime. The command does not create repo-local
49
- `.pipeline` config files.
46
+ `moka init` installs the package's default skills, generated host command
47
+ surfaces, the singleton `pipeline-gateway` MCP entry, and copied hook files from
48
+ the private `oisin-ee/agent-hooks` repository. OpenCode is the package default
49
+ runtime. The command does not create repo-local `.pipeline` config files.
50
50
 
51
51
  The default MCP gateway can run locally or point at the hosted Momokaya gateway.
52
52
  Set `PIPELINE_MCP_GATEWAY_AUTHORIZATION` to the full HTTP `Authorization` header
@@ -62,6 +62,12 @@ Check or refresh generated host files after package upgrades:
62
62
  moka install-commands --host all --check
63
63
  ```
64
64
 
65
+ Check or refresh copied agent hooks after editing `oisin-ee/agent-hooks`:
66
+
67
+ ```shell
68
+ moka install-hooks --scope global --check
69
+ ```
70
+
65
71
  Check local prerequisites and config health:
66
72
 
67
73
  ```shell
@@ -98,6 +104,7 @@ Canonical commands:
98
104
  - `moka export <run-id> --sanitize`: print a portable evidence bundle.
99
105
  - `moka doctor`: check local prerequisites and config health.
100
106
  - `moka init`: install package-owned host resources for a repository.
107
+ - `moka install-hooks`: copy manually authored hooks from `oisin-ee/agent-hooks`.
101
108
  - `moka refresh-harnesses`: force-refresh generated agent harnesses and commit
102
109
  owned resource changes.
103
110
 
@@ -21,6 +21,7 @@ declare const submitRunnerArgoWorkflowOptionsSchema: z.ZodObject<{
21
21
  name: z.ZodOptional<z.ZodString>;
22
22
  namespace: z.ZodString;
23
23
  opencodeAuthSecretName: z.ZodOptional<z.ZodString>;
24
+ opencodeOpenaiAccountsSecretName: z.ZodOptional<z.ZodString>;
24
25
  payloadJson: z.ZodString;
25
26
  scheduleYaml: z.ZodString;
26
27
  serviceAccountName: z.ZodOptional<z.ZodString>;
@@ -39,6 +39,7 @@ const submitRunnerArgoWorkflowOptionsSchema = z.object({
39
39
  name: z.string().min(1).optional(),
40
40
  namespace: z.string().min(1),
41
41
  opencodeAuthSecretName: z.string().min(1).optional(),
42
+ opencodeOpenaiAccountsSecretName: z.string().min(1).optional(),
42
43
  payloadJson: z.string().min(1),
43
44
  scheduleYaml: z.string().min(1),
44
45
  serviceAccountName: z.string().min(1).optional()
@@ -89,6 +90,7 @@ function submitRunnerArgoWorkflowEffect(rawOptions, dependencies) {
89
90
  name: options.name,
90
91
  namespace: options.namespace,
91
92
  opencodeAuthSecretName: options.opencodeAuthSecretName,
93
+ opencodeOpenaiAccountsSecret: options.opencodeOpenaiAccountsSecretName ? { name: options.opencodeOpenaiAccountsSecretName } : void 0,
92
94
  payloadConfigMapName,
93
95
  plan: compiled.plan,
94
96
  scheduleConfigMapName: scheduleArtifactConfigMapName,
@@ -296,6 +296,26 @@ function runnerWorkflowStorage(options, tasks) {
296
296
  subPath: "auth.json"
297
297
  });
298
298
  }
299
+ if (options.opencodeOpenaiAccountsSecret) {
300
+ const accountsKey = options.opencodeOpenaiAccountsSecret.key ?? "accounts.json";
301
+ volumes.push({
302
+ name: "opencode-openai-accounts",
303
+ secret: {
304
+ defaultMode: 256,
305
+ items: [{
306
+ key: accountsKey,
307
+ path: "accounts.json"
308
+ }],
309
+ secretName: options.opencodeOpenaiAccountsSecret.name
310
+ }
311
+ });
312
+ volumeMounts.push({
313
+ mountPath: "/root/.opencode/oc-codex-multi-auth-accounts.json",
314
+ name: "opencode-openai-accounts",
315
+ readOnly: true,
316
+ subPath: "accounts.json"
317
+ });
318
+ }
299
319
  if (options.gitCredentialsSecretName) {
300
320
  volumes.push({
301
321
  name: "runner-git-credentials",
@@ -16,6 +16,7 @@ import { MOKA_RUN_EFFORTS, MOKA_RUN_TARGETS, resolveMokaRun } from "./run-resolv
16
16
  import { registerTicketCommand } from "../commands/ticket-command.js";
17
17
  import { formatConfigLintWarning, lintPipelineConfig } from "../config/lint.js";
18
18
  import { formatInstallCommandsResult, installCommands, parseCommandHost } from "../install-commands.js";
19
+ import { formatInstallHooksResult, installHooks } from "../install-hooks.js";
19
20
  import { formatPipelineInitResult, formatRefreshAgentHarnessesResult, initPipelineProject, refreshAgentHarnesses } from "../pipeline-init.js";
20
21
  import { createRun, runControlStatusPaths, updateRunController } from "../run-control/store.js";
21
22
  import { registerRunControlCommands } from "../run-control/commands.js";
@@ -357,6 +358,13 @@ function createCliProgram(options = {}) {
357
358
  });
358
359
  console.log(formatInstallCommandsResult(result));
359
360
  });
361
+ program.command("install-hooks").description("Install agent hooks from oisin-ee/agent-hooks (global per-machine by default)").addOption(new Option$1("--scope <scope>", "where to install: global (~/.claude, ~/.config/opencode, ~/.codex) or project (repo-local)").choices(["global", "project"]).default("global")).option("--dry-run", "show planned changes without writing files").option("--check", "fail if installed hook files are missing or stale").option("--force", "overwrite manually edited hook files").action(async (flags) => {
362
+ const result = await installHooks({
363
+ ...flags,
364
+ cwd: process.env.PIPELINE_TARGET_PATH ?? process.cwd()
365
+ });
366
+ console.log(formatInstallHooksResult(result));
367
+ });
360
368
  program.command("codex-auth").description("Manage local Codex multi-auth integration").command("sync-local").description("Use one local oc-codex account pool and declare the plugin in dev repos").option("--root <path>", "directory containing repositories to sync").option("--dry-run", "show planned changes without writing files").option("--check", "fail if local Codex auth config is not synced").action((flags) => {
361
369
  const result = syncLocalCodexAuth({
362
370
  check: flags.check,
@@ -49,6 +49,7 @@ function mokaCommonSubmitOptions(input) {
49
49
  name: input.flags.name,
50
50
  namespace: input.flags.namespace ?? momokaya?.kubernetes.namespace,
51
51
  opencodeAuthSecretName: momokaya?.submit.opencodeAuthSecretName,
52
+ opencodeOpenaiAccountsSecretName: momokaya?.submit.opencodeOpenaiAccountsSecretName,
52
53
  serviceAccountName: input.flags.serviceAccount ?? momokaya?.submit.serviceAccountName,
53
54
  worktreePath: input.cwd
54
55
  };
@@ -226,8 +226,8 @@ declare const configSchema: z.ZodObject<{
226
226
  policy: z.ZodOptional<z.ZodObject<{
227
227
  commands: z.ZodOptional<z.ZodEnum<{
228
228
  allow: "allow";
229
- "trusted-only": "trusted-only";
230
229
  deny: "deny";
230
+ "trusted-only": "trusted-only";
231
231
  }>>;
232
232
  modules: z.ZodOptional<z.ZodEnum<{
233
233
  allow: "allow";
@@ -255,8 +255,8 @@ declare const configSchema: z.ZodObject<{
255
255
  global: "global";
256
256
  }>>;
257
257
  mode: z.ZodEnum<{
258
- hosted: "hosted";
259
258
  local: "local";
259
+ hosted: "hosted";
260
260
  }>;
261
261
  provider: z.ZodLiteral<"toolhive">;
262
262
  authorization_env: z.ZodDefault<z.ZodString>;
@@ -299,10 +299,10 @@ declare const configSchema: z.ZodObject<{
299
299
  }, z.core.$strict>>;
300
300
  output: z.ZodOptional<z.ZodObject<{
301
301
  format: z.ZodEnum<{
302
+ json_schema: "json_schema";
302
303
  text: "text";
303
304
  json: "json";
304
305
  jsonl: "jsonl";
305
- json_schema: "json_schema";
306
306
  }>;
307
307
  repair: z.ZodOptional<z.ZodObject<{
308
308
  enabled: z.ZodOptional<z.ZodBoolean>;
@@ -371,10 +371,10 @@ declare const configSchema: z.ZodObject<{
371
371
  disabled: "disabled";
372
372
  }>>>;
373
373
  output_formats: z.ZodOptional<z.ZodArray<z.ZodEnum<{
374
+ json_schema: "json_schema";
374
375
  text: "text";
375
376
  json: "json";
376
377
  jsonl: "jsonl";
377
- json_schema: "json_schema";
378
378
  }>>>;
379
379
  rules: z.ZodOptional<z.ZodBoolean>;
380
380
  skills: z.ZodOptional<z.ZodBoolean>;
@@ -0,0 +1,223 @@
1
+ import { resolveHarnessTarget } from "./install-commands/shared.js";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { execa } from "execa";
4
+ import { dirname, join, relative } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { createHash } from "node:crypto";
7
+ import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
8
+ //#region src/install-hooks.ts
9
+ const DEFAULT_HOOK_INSTALL_SOURCE = "oisin-ee/agent-hooks";
10
+ const HOOK_HOSTS = [
11
+ "claude-code",
12
+ "codex",
13
+ "opencode"
14
+ ];
15
+ const MANIFEST_FILE = ".moka-agent-hooks.json";
16
+ const HOST_TARGET_ROOT = {
17
+ "claude-code": ".claude",
18
+ codex: ".codex",
19
+ opencode: ".opencode"
20
+ };
21
+ function hashContent(content) {
22
+ return createHash("sha256").update(content).digest("hex");
23
+ }
24
+ async function cloneHookRepository(targetDir) {
25
+ await execa("gh", [
26
+ "repo",
27
+ "clone",
28
+ DEFAULT_HOOK_INSTALL_SOURCE,
29
+ targetDir,
30
+ "--",
31
+ "--depth=1"
32
+ ], { stdio: "inherit" });
33
+ }
34
+ async function withHookSource(useSource) {
35
+ const parent = await mkdtemp(join(tmpdir(), "moka-agent-hooks-"));
36
+ const source = join(parent, "agent-hooks");
37
+ try {
38
+ await cloneHookRepository(source);
39
+ return await useSource(source);
40
+ } finally {
41
+ await rm(parent, {
42
+ force: true,
43
+ recursive: true
44
+ });
45
+ }
46
+ }
47
+ async function listFiles(root) {
48
+ if (!existsSync(root)) return [];
49
+ if (statSync(root).isFile()) return [root];
50
+ const entries = await readdir(root, { withFileTypes: true });
51
+ return (await Promise.all(entries.map((entry) => {
52
+ const path = join(root, entry.name);
53
+ return entry.isDirectory() ? listFiles(path) : [path];
54
+ }))).flat();
55
+ }
56
+ async function sourceHookFiles(source) {
57
+ return (await Promise.all(HOOK_HOSTS.map(async (host) => {
58
+ const hostRoot = join(source, host);
59
+ return (await listFiles(hostRoot)).map((file) => {
60
+ const relativePath = relative(hostRoot, file).replaceAll("\\", "/");
61
+ const content = readFileSync(file);
62
+ return {
63
+ content,
64
+ hash: hashContent(content),
65
+ host,
66
+ path: `${HOST_TARGET_ROOT[host]}/${relativePath}`
67
+ };
68
+ });
69
+ }))).flat().sort((a, b) => a.path.localeCompare(b.path));
70
+ }
71
+ function manifestPath(scope, cwd, host) {
72
+ return resolveHarnessTarget(scope, cwd, `${HOST_TARGET_ROOT[host]}/${MANIFEST_FILE}`);
73
+ }
74
+ function emptyManifest() {
75
+ return {
76
+ files: {},
77
+ repository: DEFAULT_HOOK_INSTALL_SOURCE,
78
+ version: 1
79
+ };
80
+ }
81
+ function readManifest(scope, cwd, host) {
82
+ const path = manifestPath(scope, cwd, host);
83
+ if (!existsSync(path)) return emptyManifest();
84
+ try {
85
+ return normalizeManifest(JSON.parse(readFileSync(path, "utf8")));
86
+ } catch {
87
+ return emptyManifest();
88
+ }
89
+ }
90
+ function isRecord(value) {
91
+ return typeof value === "object" && value !== null && !Array.isArray(value);
92
+ }
93
+ function normalizeManifest(value) {
94
+ const files = {};
95
+ const manifestFiles = isRecord(value) ? value.files : void 0;
96
+ if (!isRecord(manifestFiles)) return {
97
+ files,
98
+ repository: DEFAULT_HOOK_INSTALL_SOURCE,
99
+ version: 1
100
+ };
101
+ for (const [path, entry] of Object.entries(manifestFiles)) if (isRecord(entry) && typeof entry.hash === "string") files[path] = { hash: entry.hash };
102
+ return {
103
+ files,
104
+ repository: DEFAULT_HOOK_INSTALL_SOURCE,
105
+ version: 1
106
+ };
107
+ }
108
+ function targetPath(scope, cwd, path) {
109
+ return resolveHarnessTarget(scope, cwd, path);
110
+ }
111
+ function actionForFile(file, scope, cwd, force, manifests) {
112
+ const target = targetPath(scope, cwd, file.path);
113
+ if (!existsSync(target)) return "create";
114
+ const currentHash = hashContent(readFileSync(target));
115
+ if (currentHash === file.hash) return "unchanged";
116
+ if (force) return "update";
117
+ return (manifests.get(file.host)?.files[file.path])?.hash === currentHash ? "update" : "conflict";
118
+ }
119
+ function planFiles(files, scope, cwd, force, manifests) {
120
+ return files.map((file) => ({
121
+ ...file,
122
+ action: actionForFile(file, scope, cwd, force, manifests)
123
+ }));
124
+ }
125
+ function planObsoleteFiles(desiredPaths, scope, cwd, force, manifests) {
126
+ const obsolete = [];
127
+ for (const [host, manifest] of manifests) for (const [path, entry] of Object.entries(manifest.files)) {
128
+ if (desiredPaths.has(path)) continue;
129
+ const target = targetPath(scope, cwd, path);
130
+ if (!existsSync(target)) continue;
131
+ const currentHash = hashContent(readFileSync(target));
132
+ obsolete.push({
133
+ action: force || currentHash === entry.hash ? "delete" : "conflict",
134
+ host,
135
+ path
136
+ });
137
+ }
138
+ return obsolete.sort((a, b) => a.path.localeCompare(b.path));
139
+ }
140
+ async function writePlannedFile(file, scope, cwd) {
141
+ if (file.action === "conflict" || file.action === "unchanged") return;
142
+ const target = targetPath(scope, cwd, file.path);
143
+ await mkdir(dirname(target), { recursive: true });
144
+ await writeFile(target, file.content);
145
+ }
146
+ function itemFor(file) {
147
+ return {
148
+ action: file.action,
149
+ host: file.host,
150
+ path: file.path
151
+ };
152
+ }
153
+ function itemForObsolete(file) {
154
+ return {
155
+ action: file.action,
156
+ host: file.host,
157
+ path: file.path
158
+ };
159
+ }
160
+ async function removeObsoleteFile(file, scope, cwd) {
161
+ if (file.action !== "delete") return;
162
+ await rm(targetPath(scope, cwd, file.path), { force: true });
163
+ }
164
+ function assertNoConflicts(items, dryRun) {
165
+ if (dryRun) return;
166
+ const conflicts = items.filter((item) => item.action === "conflict");
167
+ if (conflicts.length === 0) return;
168
+ throw new Error([
169
+ "Refusing to overwrite manually edited hook files.",
170
+ ...conflicts.map((item) => `- ${item.path}`),
171
+ "Re-run with --force to overwrite them."
172
+ ].join("\n"));
173
+ }
174
+ function assertCheckCurrent(items, check) {
175
+ if (!check) return;
176
+ const changed = items.filter((item) => item.action !== "unchanged");
177
+ if (changed.length === 0) return;
178
+ throw new Error(["Installed hook files are not up to date.", ...changed.map((item) => `- ${item.path}: ${item.action}`)].join("\n"));
179
+ }
180
+ async function writeManifests(files, scope, cwd) {
181
+ const byHost = /* @__PURE__ */ new Map();
182
+ for (const host of HOOK_HOSTS) byHost.set(host, emptyManifest());
183
+ for (const file of files) {
184
+ const manifest = byHost.get(file.host);
185
+ if (manifest) manifest.files[file.path] = { hash: file.hash };
186
+ }
187
+ await Promise.all([...byHost.entries()].map(async ([host, manifest]) => {
188
+ const path = manifestPath(scope, cwd, host);
189
+ if (Object.keys(manifest.files).length === 0) {
190
+ await rm(path, { force: true });
191
+ return;
192
+ }
193
+ await mkdir(dirname(path), { recursive: true });
194
+ await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`);
195
+ }));
196
+ }
197
+ function installHooks(options = {}) {
198
+ const cwd = options.cwd ?? process.cwd();
199
+ const scope = options.scope ?? "global";
200
+ return withHookSource(async (source) => {
201
+ const files = await sourceHookFiles(source);
202
+ const manifests = new Map(HOOK_HOSTS.map((host) => [host, readManifest(scope, cwd, host)]));
203
+ const planned = planFiles(files, scope, cwd, Boolean(options.force), manifests);
204
+ const obsolete = planObsoleteFiles(new Set(files.map((file) => file.path)), scope, cwd, Boolean(options.force), manifests);
205
+ const items = [...planned.map(itemFor), ...obsolete.map(itemForObsolete)];
206
+ assertCheckCurrent(items, Boolean(options.check));
207
+ assertNoConflicts(items, Boolean(options.dryRun));
208
+ if (!(options.check || options.dryRun)) {
209
+ for (const file of planned) await writePlannedFile(file, scope, cwd);
210
+ for (const file of obsolete) await removeObsoleteFile(file, scope, cwd);
211
+ await writeManifests(planned, scope, cwd);
212
+ }
213
+ return {
214
+ items,
215
+ source: DEFAULT_HOOK_INSTALL_SOURCE
216
+ };
217
+ });
218
+ }
219
+ function formatInstallHooksResult(result) {
220
+ return result.items.map((item) => `${item.action} ${item.host}: ${item.path}`).join("\n");
221
+ }
222
+ //#endregion
223
+ export { formatInstallHooksResult, installHooks };
@@ -16,6 +16,7 @@ declare const mokaGlobalConfigSchema: z.ZodObject<{
16
16
  githubAuthSecretName: z.ZodString;
17
17
  imagePullSecretName: z.ZodString;
18
18
  opencodeAuthSecretName: z.ZodString;
19
+ opencodeOpenaiAccountsSecretName: z.ZodOptional<z.ZodString>;
19
20
  serviceAccountName: z.ZodString;
20
21
  }, z.core.$strict>;
21
22
  }, z.core.$strict>;
@@ -15,6 +15,7 @@ const mokaSubmitGlobalConfigSchema = z.object({
15
15
  githubAuthSecretName: z.string().min(1),
16
16
  imagePullSecretName: z.string().min(1),
17
17
  opencodeAuthSecretName: z.string().min(1),
18
+ opencodeOpenaiAccountsSecretName: z.string().min(1).optional(),
18
19
  serviceAccountName: z.string().min(1)
19
20
  }).strict();
20
21
  const mokaKubernetesGlobalConfigSchema = z.object({
@@ -148,6 +148,7 @@ declare const mokaSubmitOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
148
148
  name: z.ZodOptional<z.ZodString>;
149
149
  namespace: z.ZodOptional<z.ZodString>;
150
150
  opencodeAuthSecretName: z.ZodOptional<z.ZodString>;
151
+ opencodeOpenaiAccountsSecretName: z.ZodOptional<z.ZodString>;
151
152
  repository: z.ZodOptional<z.ZodObject<{
152
153
  baseBranch: z.ZodString;
153
154
  sha: z.ZodOptional<z.ZodString>;
@@ -260,6 +261,7 @@ declare const mokaSubmitOptionsSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
260
261
  name: z.ZodOptional<z.ZodString>;
261
262
  namespace: z.ZodOptional<z.ZodString>;
262
263
  opencodeAuthSecretName: z.ZodOptional<z.ZodString>;
264
+ opencodeOpenaiAccountsSecretName: z.ZodOptional<z.ZodString>;
263
265
  repository: z.ZodOptional<z.ZodObject<{
264
266
  baseBranch: z.ZodString;
265
267
  sha: z.ZodOptional<z.ZodString>;
@@ -324,6 +326,7 @@ interface MokaWorkflowSubmitOptions {
324
326
  name?: string;
325
327
  namespace: string;
326
328
  opencodeAuthSecretName?: string;
329
+ opencodeOpenaiAccountsSecretName?: string;
327
330
  payloadJson: string;
328
331
  scheduleYaml: string;
329
332
  serviceAccountName?: string;
@@ -78,6 +78,7 @@ const mokaSubmitBaseOptionsSchema = z.object({
78
78
  name: z.string().min(1).optional(),
79
79
  namespace: z.string().min(1).optional(),
80
80
  opencodeAuthSecretName: z.string().min(1).optional(),
81
+ opencodeOpenaiAccountsSecretName: z.string().min(1).optional(),
81
82
  repository: runnerRepositoryContextSchema.optional(),
82
83
  run: runnerRunIdentitySchema.optional(),
83
84
  serviceAccountName: z.string().min(1).optional()
@@ -300,6 +301,7 @@ function workflowSubmitOptions(options) {
300
301
  name: options.name,
301
302
  namespace: requireSubmitOption(options.namespace, "namespace"),
302
303
  opencodeAuthSecretName: options.opencodeAuthSecretName,
304
+ opencodeOpenaiAccountsSecretName: options.opencodeOpenaiAccountsSecretName,
303
305
  serviceAccountName: options.serviceAccountName
304
306
  };
305
307
  }
@@ -1,5 +1,6 @@
1
1
  import "./install-commands/shared.js";
2
2
  import { installCommands } from "./install-commands.js";
3
+ import { installHooks } from "./install-hooks.js";
3
4
  import { existsSync } from "node:fs";
4
5
  import { execa } from "execa";
5
6
  import { join } from "node:path";
@@ -33,8 +34,12 @@ const OWNED_HARNESS_PATHS = [
33
34
  ".agents/skills",
34
35
  ".claude/agents",
35
36
  ".claude/commands",
37
+ ".claude/hooks",
38
+ ".claude/.moka-agent-hooks.json",
36
39
  ".claude/settings.json",
37
40
  ".claude/skills",
41
+ ".codex/hooks",
42
+ ".codex/.moka-agent-hooks.json",
38
43
  ".codex/skills",
39
44
  ".opencode",
40
45
  "AGENTS.md",
@@ -57,17 +62,30 @@ async function installDefaultSkills(cwd, scope) {
57
62
  throw new Error(`Failed to install default skills from ${DEFAULT_SKILL_INSTALL_SOURCE}${cause}. If this is a private repository, authenticate GitHub access for npx skills add and rerun \`moka init\`.`);
58
63
  }
59
64
  }
65
+ function installDefaultHooks(cwd, scope) {
66
+ return installHooks({
67
+ cwd,
68
+ scope
69
+ });
70
+ }
71
+ function hookInstallerFiles(result) {
72
+ return "items" in result ? result.items.map((item) => item.path) : result.files;
73
+ }
60
74
  async function initPipelineProject(options = {}) {
61
75
  const cwd = options.cwd ?? process.cwd();
62
76
  const scope = options.scope ?? "global";
63
- await (options.skillInstaller ?? ((target) => installDefaultSkills(target, skillScopeFor(scope))))(cwd);
77
+ const skillInstaller = options.skillInstaller ?? ((target) => installDefaultSkills(target, skillScopeFor(scope)));
78
+ const hookInstaller = options.hookInstaller ?? installDefaultHooks;
79
+ await skillInstaller(cwd);
80
+ const result = await installCommands({
81
+ cwd,
82
+ force: true,
83
+ host: "all",
84
+ scope
85
+ });
86
+ const hooks = await hookInstaller(cwd, scope);
64
87
  return {
65
- files: (await installCommands({
66
- cwd,
67
- force: true,
68
- host: "all",
69
- scope
70
- })).items.map((item) => item.path),
88
+ files: [...result.items.map((item) => item.path), ...hookInstallerFiles(hooks)],
71
89
  scope
72
90
  };
73
91
  }
@@ -75,6 +93,7 @@ async function refreshAgentHarnesses(options = {}) {
75
93
  const context = refreshAgentHarnessesContext(options);
76
94
  const init = await initPipelineProject({
77
95
  cwd: context.cwd,
96
+ hookInstaller: options.hookInstaller,
78
97
  scope: options.scope,
79
98
  skillInstaller: options.skillInstaller
80
99
  });
@@ -324,18 +324,7 @@ function unwrap(response) {
324
324
  return response.data;
325
325
  }
326
326
  function errorMessage(error) {
327
- if (!(error instanceof Error)) return String(error);
328
- const messages = [];
329
- const seen = /* @__PURE__ */ new Set();
330
- let current = error;
331
- while (current instanceof Error && !seen.has(current)) {
332
- seen.add(current);
333
- const code = current.code;
334
- const codeSuffix = typeof code === "string" ? ` (${code})` : "";
335
- messages.push(`${current.message}${codeSuffix}`);
336
- current = current.cause;
337
- }
338
- return messages.join(": ");
327
+ return error instanceof Error ? error.message : String(error);
339
328
  }
340
329
  //#endregion
341
330
  export { createOpencodeExecutor, createOpencodeSessionRegistry };
@@ -164,6 +164,22 @@ Project-authored skill and rule paths resolve from the project root and must
164
164
  exist for runtime use. If default skill files are missing, run `moka init` to
165
165
  install them before executing workflows.
166
166
 
167
+ Default agent hooks are copied by `moka init` and `moka install-hooks` from the
168
+ private `oisin-ee/agent-hooks` repository. That source repository has one
169
+ canonical host-level layout:
170
+
171
+ ```text
172
+ claude-code/
173
+ codex/
174
+ opencode/
175
+ ```
176
+
177
+ Moka overlays those folders onto `.claude`, `.codex`, and `.opencode` for
178
+ project scope, or the corresponding per-machine host config directories for
179
+ global scope. Moka tracks installed hashes in host-local manifests so it can
180
+ update unchanged owned hook files, delete owned files removed from the hook
181
+ repository, and reject manual drift unless `--force` is used.
182
+
167
183
  `moka init --skill-scope` (PIPE-83.12) chooses how the default set is installed:
168
184
  `project` (default) vendors a repo-local copy (`skills add … --copy`,
169
185
  `skills-lock.json`); `personal` installs once at user/global scope
@@ -198,6 +214,8 @@ OpenCode host resources are generated from the same profile registry:
198
214
  resolved model, explicit permissions, and task access to generated agents only.
199
215
  - `.opencode/skills/*/SKILL.md` is installed by `skills add`; Moka only
200
216
  generates agents, commands, plugins, and project config.
217
+ - Additional manually authored OpenCode hook plugins can be copied from
218
+ `oisin-ee/agent-hooks/opencode/` by `moka install-hooks`.
201
219
  - `.opencode/plugins/pipeline-goal-context.ts` projects package-owned
202
220
  continuation context into OpenCode compaction.
203
221
  - `.opencode/opencode.json` contains the gateway MCP config, enables LSP, and
@@ -175,8 +175,8 @@ OpenBao, publish Secret values, or mutate ESO resources from this package.
175
175
 
176
176
  `moka init`
177
177
 
178
- Vendors the package's default project skills and generated OpenCode host
179
- resources, including the singleton `pipeline-gateway` MCP entry. OpenCode is the
178
+ Installs the package's default skills, generated host resources, and copied
179
+ agent hooks from the private `oisin-ee/agent-hooks` repository. OpenCode is the
180
180
  package default runtime. `moka init` does not create repo-local `.pipeline`
181
181
  config files.
182
182
 
@@ -196,11 +196,28 @@ moka install-commands --host all --force
196
196
 
197
197
  Host choices are `all` and `opencode`.
198
198
 
199
+ `moka install-hooks`
200
+
201
+ Copies manually authored hook files from the private `oisin-ee/agent-hooks`
202
+ repository into the selected harness scope. The hook source repository has only
203
+ host folders: `claude-code/`, `codex/`, and `opencode/`. Install scope controls
204
+ the destination; it is not represented in the source repository.
205
+
206
+ ```shell
207
+ moka install-hooks --scope global --check
208
+ moka install-hooks --scope project --force
209
+ ```
210
+
211
+ There is no source override flag and no symlink mode. Moka clones
212
+ `oisin-ee/agent-hooks`, copies files, and tracks installed hashes so later runs
213
+ can update unchanged owned files, delete removed owned files, and refuse to
214
+ overwrite manually edited hook files unless `--force` is supplied.
215
+
199
216
  `moka refresh-harnesses`
200
217
 
201
- Force-refreshes package-owned skills and generated agent harnesses, stages only
202
- owned harness/resource paths, and commits them with `--no-verify`. The default
203
- commit message is `chore: update agent harnesses`.
218
+ Force-refreshes package-owned skills, generated agent harnesses, and copied hook
219
+ files, stages only owned harness/resource paths, and commits them with
220
+ `--no-verify`. The default commit message is `chore: update agent harnesses`.
204
221
 
205
222
  ```shell
206
223
  moka refresh-harnesses
@@ -392,6 +409,11 @@ Claude Code: /moka-quick, /moka-execute, /moka-inspect
392
409
  - `.opencode/opencode.json` with LSP, the singleton `pipeline-gateway` MCP
393
410
  server, and pinned package-selected plugins
394
411
 
412
+ `moka init` and `moka install-hooks` also copy hook files from
413
+ `oisin-ee/agent-hooks` by overlaying `opencode/`, `claude-code/`, and `codex/`
414
+ onto the selected host config roots. Hook files are authored in the hook repo,
415
+ not generated by Moka.
416
+
395
417
  `moka install-commands --host claude-code` generates `.claude/commands/moka-<entrypoint>.md`
396
418
  slash commands for Claude Code.
397
419
 
package/package.json CHANGED
@@ -126,7 +126,7 @@
126
126
  "prepack": "bun run build:cli"
127
127
  },
128
128
  "type": "module",
129
- "version": "3.7.2",
129
+ "version": "3.8.0",
130
130
  "description": "Config-driven multi-agent pipeline runner for repository work",
131
131
  "main": "./dist/index.js",
132
132
  "types": "./dist/index.d.ts",