Skip to content
This repository was archived by the owner on Mar 30, 2026. It is now read-only.

feat: generalize hooks to swarm, identity, and plugin levels#175

Merged
stepandel merged 3 commits intomainfrom
feat/multi-level-hooks
Mar 1, 2026
Merged

feat: generalize hooks to swarm, identity, and plugin levels#175
stepandel merged 3 commits intomainfrom
feat/multi-level-hooks

Conversation

@stepandel
Copy link
Copy Markdown
Owner

@stepandel stepandel commented Mar 1, 2026

Summary

  • Generalized hooks to three levels: swarm (clawup.yaml), identity (identity.yaml), and plugin (plugins/<name>.yaml) — same HooksSchema shape (resolve, postProvision, preStart, onboard) at all levels
  • Execution order is broadest → most specific: swarm → identity → plugin for lifecycle/onboard hooks; most-specific-wins for resolve hook key conflicts
  • Comprehensive test coverage: 37 new tests covering schema validation, cloud-init pipeline merging/ordering, and onboard hooks multi-level execution + error propagation

Test plan

  • pnpm build — all 4 packages compile (core, cli, pulumi, web)
  • pnpm test — 341 tests pass across 19 test files (37 new)
  • Schema tests: hooks accepted/rejected on manifest, identity, and standalone HooksSchema
  • Pipeline tests: extraHooks merge order, plugin prefix, phase ordering in bash output
  • Onboard tests: swarm → identity → plugin ordering, multi-agent, skip, error short-circuit

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Swarm- and identity-level lifecycle hooks (onboard, resolve, postProvision, preStart) now supported and run before plugin hooks; onboarding and resolve phases include multi-level merging and labeled sources.
    • Cloud-init and provisioning now include swarm/identity hooks (prepended ahead of plugin hooks).
  • Tests

    • Added extensive tests covering multi-level hook ordering, execution, skip/error scenarios, and manifest validation for hooks.

stepandel and others added 2 commits March 1, 2026 12:48
Extract HooksSchema as a shared shape reused at all three levels:
swarm (clawup.yaml), identity (identity.yaml), and plugin manifests.
Execution order is broadest-first (swarm → identity → plugin) for
lifecycle and onboard hooks, with most-specific-wins for resolve hooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Schema tests: HooksSchema standalone, hooks on ClawupManifestSchema
  and IdentityManifestSchema (validation, backward compat, empty scripts)
- Cloud-init pipeline tests: extraHooks merging order (swarm → identity
  → plugin), extraHooks-only, mixed with plugins, no hooks, multiple
  plugins, phase ordering in generated script
- Onboard hooks unit tests: execution order across all three levels,
  multi-agent scenarios, skip behavior, error propagation (swarm failure
  prevents identity/plugin), instructions labeling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 1, 2026

📝 Walkthrough

Walkthrough

Adds multi-level lifecycle hooks (swarm and identity) to schemas, types, CLI onboarding/resolution orchestration, and Pulumi cloud-init generation; threads swarm/identity hooks before plugin hooks with labeling, env resolution, and extensive tests validating ordering and behavior.

Changes

Cohort / File(s) Summary
Core Schemas & Types
packages/core/src/schemas/plugin-manifest.ts, packages/core/src/schemas/manifest.ts, packages/core/src/schemas/identity.ts, packages/core/src/schemas/index.ts, packages/core/src/types.ts, packages/core/src/index.ts
Add HooksSchema (resolve, postProvision, preStart, onboard). Export Hooks type. Add optional hooks to ClawupManifest and IdentityManifest. Alias PluginHooksSchema = HooksSchema.
Core Tests
packages/core/src/__tests__/schemas.test.ts
Add tests covering HooksSchema for swarm-level, identity-level, and standalone contexts (back-compat, full/partial coverage, rejection cases).
CLI Onboard & Resolve Orchestration
packages/cli/lib/onboard-hooks.ts, packages/cli/lib/__tests__/onboard-hooks.test.ts, packages/cli/commands/onboard.ts, packages/cli/lib/setup.ts
Thread swarmOnboard into runOnboardHooks; execute swarm-level then identity-level onboard hooks before plugin hooks; add 7a resolve phase to run swarm/identity resolve hooks (preferring identity values), redact logging, and add comprehensive tests for multi-level onboard ordering, skip behavior, and errors.
Pulumi Types & Cloud-Init
packages/pulumi/src/components/types.ts, packages/pulumi/src/components/cloud-init.ts, packages/pulumi/src/components/shared.ts, packages/pulumi/src/components/provisioner-config.ts, packages/pulumi/src/index.ts
Introduce extraHooks (swarm/identity-level) in agent args and CloudInitConfig. Gather swarm/identity hooks into extraHooks, label sources (swarm:, identity:), and prepend them to plugin hooks for postProvision/preStart; thread through cloud-init generation.
Pulumi Tests
packages/pulumi/src/components/__tests__/cloud-init-pipeline.test.ts
Expand tests for multi-level hooks: merging and ordering (swarm → identity → plugin), various presence/absence scenarios, labeling, base64 scripts, and script generation ordering.

Sequence Diagram(s)

sequenceDiagram
    participant Manifest as Manifest (swarm + identity)
    participant CLI as CLI Setup
    participant Resolve as Resolve Orchestrator
    participant Onboard as Onboard Orchestrator
    participant Pulumi as Pulumi Provisioner
    participant CloudInit as Cloud-Init Generator

    Manifest->>CLI: load manifest.hooks + identity.manifest.hooks
    CLI->>Resolve: run resolve hooks (swarm → identity)
    Resolve->>Resolve: execute swarm resolve
    Resolve->>Resolve: execute identity resolve (prefer identity)
    Resolve-->>CLI: resolved env vars

    CLI->>Onboard: run onboard hooks (swarm → identity → plugins)
    Onboard->>Onboard: run swarm onboard
    Onboard->>Onboard: run identity onboard
    Onboard->>Onboard: run plugin onboard hooks
    Onboard-->>CLI: combined instructions / results

    CLI->>Pulumi: pass extraHooks (swarm + identity) + plugin metadata
    Pulumi->>CloudInit: build cloud-init config (extraHooks + plugin hooks)
    CloudInit->>CloudInit: merge hooks: swarm → identity → plugin
    CloudInit-->>Pulumi: generated cloud-init script with ordered hooks
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PR #151: Introduced core hook execution model and foundational hook types that this change extends to multi-level orchestration.
  • PR #152: Implemented onboard hook schema and runOnboardHook primitives that are now invoked at swarm/identity levels.
  • PR #160: Worked on multi-level lifecycle hook wiring across CLI and Pulumi, overlapping in ordering/labeling and cloud-init integration.

Poem

🐰 I nibble code where hooks entwine,

Swarm then identity then plugins align.
Env and onboard steps hop in a row,
Scripts labeled, ordered — off we go! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: generalize hooks to swarm, identity, and plugin levels' directly and clearly summarizes the main change: extending hooks support across three organizational levels with a unified schema.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/multi-level-hooks

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/cli/lib/setup.ts (1)

421-427: Handle duplicate identity resolve keys explicitly.

Right now, if two identities define the same resolve env var, the later one silently overwrites the earlier one. That makes behavior order-dependent and hard to diagnose.

💡 Proposed conflict handling
     for (const fi of fetchedIdentities) {
       if (fi.manifest.hooks?.resolve) {
         for (const [envVar, script] of Object.entries(fi.manifest.hooks.resolve)) {
-          nonPluginResolve[envVar] = { script, label: `identity:${fi.manifest.name}` };
+          const next = { script, label: `identity:${fi.manifest.name}` };
+          const prev = nonPluginResolve[envVar];
+          if (prev && prev.script !== next.script) {
+            return {
+              ok: false,
+              error: `Conflicting identity resolve hooks for ${envVar}: ${prev.label} vs ${next.label}`,
+            };
+          }
+          nonPluginResolve[envVar] = next;
         }
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/lib/setup.ts` around lines 421 - 427, The loop that builds
nonPluginResolve from fetchedIdentities currently lets later identities
overwrite earlier keys (see fetchedIdentities, fi.manifest.hooks.resolve,
nonPluginResolve); change it to detect duplicates and handle them explicitly by
checking if nonPluginResolve[envVar] already exists and then either (a) throw an
informative error (including both labels / identity names and the conflicting
envVar) or (b) log a clear conflict and skip/abort as your policy requires;
update the code that assigns nonPluginResolve[envVar] to perform this existence
check and produce the conflict message referencing fi.manifest.name and the
existing entry's label so conflicts are not silently overwritten.
packages/cli/lib/onboard-hooks.ts (1)

69-89: Extract shared swarm/identity hook execution flow into a helper.

The two blocks duplicate the same orchestration (run, print instructions, fail). A small helper would reduce drift risk and keep future changes safer.

Also applies to: 92-115

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/lib/onboard-hooks.ts` around lines 69 - 89, The swarm and
identity onboard blocks duplicate orchestration logic (call runOnboardHook,
redact and print instructions, handle failure via exitWithError), so extract
that flow into a helper like runAndReportOnboardHook(hook: OnboardHook |
undefined, label: string, env: Record<string,string>) which calls
runOnboardHook({ script: hook.script, env }), checks result.ok, on success
redacts result.instructions with redactSecretsFromString and logs via
p.log.info/console, and on failure logs via p.log.error and calls exitWithError;
replace the duplicated blocks that reference swarmOnboard and identityOnboard to
call this helper with appropriate label strings and env (e.g., "swarm onboard" /
"identity onboard").
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cli/lib/onboard-hooks.ts`:
- Line 84: The code is logging raw hook errors (p.log.error(`Swarm onboard hook
failed: ${result.error}`) and the similar call at the other occurrence), which
may contain secrets; replace direct logging of result.error with a
sanitized/redacted message. Implement or call a sanitizer (e.g., redactError or
maskSecrets) to strip tokens/URLs/keys from result.error before passing it to
p.log.error, or log a generic message ("Swarm onboard hook failed: <redacted
error>") plus a non-sensitive error type/ID if needed; update both places that
reference result.error to use the sanitizer and avoid printing raw error
details.

In `@packages/cli/lib/setup.ts`:
- Around line 440-442: The warning currently logs raw resolve-hook output
(progress.log.warn(`Non-plugin resolve hook failed for ${envVar}:
${result.error}`)), which may include sensitive stdout/stderr; change it to a
sanitized/generic message instead. Update the block around s.stop and
progress.log.warn so you still call s.stop(`Failed to resolve ${envVar}
(${label})`) but replace logging of result.error with a non-sensitive message
like `progress.log.warn(\`Non-plugin resolve hook failed for ${envVar}: hook
returned an error\`)` and, if you must capture metadata, only log non-secret
fields (e.g. an error code or truncated/hash of the output) from result (not raw
stderr/stdout). Ensure references: s.stop, progress.log.warn, result.error,
envVar, label are updated accordingly.

---

Nitpick comments:
In `@packages/cli/lib/onboard-hooks.ts`:
- Around line 69-89: The swarm and identity onboard blocks duplicate
orchestration logic (call runOnboardHook, redact and print instructions, handle
failure via exitWithError), so extract that flow into a helper like
runAndReportOnboardHook(hook: OnboardHook | undefined, label: string, env:
Record<string,string>) which calls runOnboardHook({ script: hook.script, env }),
checks result.ok, on success redacts result.instructions with
redactSecretsFromString and logs via p.log.info/console, and on failure logs via
p.log.error and calls exitWithError; replace the duplicated blocks that
reference swarmOnboard and identityOnboard to call this helper with appropriate
label strings and env (e.g., "swarm onboard" / "identity onboard").

In `@packages/cli/lib/setup.ts`:
- Around line 421-427: The loop that builds nonPluginResolve from
fetchedIdentities currently lets later identities overwrite earlier keys (see
fetchedIdentities, fi.manifest.hooks.resolve, nonPluginResolve); change it to
detect duplicates and handle them explicitly by checking if
nonPluginResolve[envVar] already exists and then either (a) throw an informative
error (including both labels / identity names and the conflicting envVar) or (b)
log a clear conflict and skip/abort as your policy requires; update the code
that assigns nonPluginResolve[envVar] to perform this existence check and
produce the conflict message referencing fi.manifest.name and the existing
entry's label so conflicts are not silently overwritten.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8361e9d and 794df43.

📒 Files selected for processing (17)
  • packages/cli/commands/onboard.ts
  • packages/cli/lib/__tests__/onboard-hooks.test.ts
  • packages/cli/lib/onboard-hooks.ts
  • packages/cli/lib/setup.ts
  • packages/core/src/__tests__/schemas.test.ts
  • packages/core/src/index.ts
  • packages/core/src/schemas/identity.ts
  • packages/core/src/schemas/index.ts
  • packages/core/src/schemas/manifest.ts
  • packages/core/src/schemas/plugin-manifest.ts
  • packages/core/src/types.ts
  • packages/pulumi/src/components/__tests__/cloud-init-pipeline.test.ts
  • packages/pulumi/src/components/cloud-init.ts
  • packages/pulumi/src/components/provisioner-config.ts
  • packages/pulumi/src/components/shared.ts
  • packages/pulumi/src/components/types.ts
  • packages/pulumi/src/index.ts

Hook stderr output may contain sensitive values. Apply
redactSecretsFromString to all error/warning log messages from swarm,
identity, and non-plugin resolve hooks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/cli/lib/onboard-hooks.ts`:
- Around line 91-115: The identity-level onboard hook in the loop over
fetchedIdentities runs for every agent entry causing duplicates; modify the loop
to only run each identity's onboard once by tracking processed identity names
(use identityResult.manifest.name) in a Set and skip if already seen before
calling runOnboardHook; keep existing behavior for logging and error handling
(p.log.info/p.log.error, redactSecretsFromString, exitWithError) but only invoke
it when the identity name is not yet in the Set, then add the name to the Set
after running the hook.

In `@packages/cli/lib/setup.ts`:
- Around line 421-428: The current loop over fetchedIdentities writes
identity-level resolve hooks into nonPluginResolve and silently overwrites
existing keys; update the loop that processes fi.manifest.hooks.resolve so that
before assigning nonPluginResolve[envVar] you check whether that envVar already
exists and if so throw or return a clear error including the conflicting envVar
and both identity names (use fi.manifest.name and the existing entry's label) to
fail-fast on collisions; apply the same conflict check logic to the analogous
swarm-level resolve handling (the block around lines 430-444) so neither
identity- nor swarm-level duplicate resolve keys are silently overwritten.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 794df43 and 4203872.

📒 Files selected for processing (2)
  • packages/cli/lib/onboard-hooks.ts
  • packages/cli/lib/setup.ts

Comment on lines 91 to +115
for (const fi of fetchedIdentities) {
// --- Identity-level onboard hook (before plugin hooks) ---
const identityOnboard = fi.identityResult.manifest.hooks?.onboard;
if (identityOnboard) {
p.log.info(
`Running identity onboard hook for ${fi.agent.displayName}: ${identityOnboard.description}`
);

const hookEnv: Record<string, string> = { ...envDict };
const result = await runOnboardHook({ script: identityOnboard.script, env: hookEnv });
if (result.ok) {
if (result.instructions) {
const redacted = redactSecretsFromString(result.instructions);
console.log();
p.log.info(`Follow-up instructions (identity:${fi.identityResult.manifest.name}):`);
console.log(redacted);
console.log();
}
} else {
p.log.error(`Identity onboard hook for ${fi.agent.displayName} failed: ${redactSecretsFromString(result.error)}`);
exitWithError(
"Identity onboard hook failed. Fix the issue and run `clawup setup --onboard` again, or run `clawup onboard` separately."
);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Run identity-level onboard once per identity, not once per agent entry.

If multiple agents share the same identity manifest, this block executes the same identity onboard hook repeatedly, which can duplicate prompts and side effects.

🔧 Suggested fix (dedupe by identity name)
 export async function runOnboardHooks(args: RunOnboardHooksArgs): Promise<void> {
@@
   if (swarmOnboard) {
@@
   }

+  const executedIdentityOnboard = new Set<string>();
   for (const fi of fetchedIdentities) {
     // --- Identity-level onboard hook (before plugin hooks) ---
+    const identityName = fi.identityResult.manifest.name;
+    if (executedIdentityOnboard.has(identityName)) {
+      continue;
+    }
     const identityOnboard = fi.identityResult.manifest.hooks?.onboard;
     if (identityOnboard) {
@@
       } else {
         p.log.error(`Identity onboard hook for ${fi.agent.displayName} failed: ${redactSecretsFromString(result.error)}`);
         exitWithError(
           "Identity onboard hook failed. Fix the issue and run `clawup setup --onboard` again, or run `clawup onboard` separately."
         );
       }
+      executedIdentityOnboard.add(identityName);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/lib/onboard-hooks.ts` around lines 91 - 115, The identity-level
onboard hook in the loop over fetchedIdentities runs for every agent entry
causing duplicates; modify the loop to only run each identity's onboard once by
tracking processed identity names (use identityResult.manifest.name) in a Set
and skip if already seen before calling runOnboardHook; keep existing behavior
for logging and error handling (p.log.info/p.log.error, redactSecretsFromString,
exitWithError) but only invoke it when the identity name is not yet in the Set,
then add the name to the Set after running the hook.

Comment on lines +421 to +428
// Identity-level resolve hooks (override swarm for same key)
for (const fi of fetchedIdentities) {
if (fi.manifest.hooks?.resolve) {
for (const [envVar, script] of Object.entries(fi.manifest.hooks.resolve)) {
nonPluginResolve[envVar] = { script, label: `identity:${fi.manifest.name}` };
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle identity-level resolve key collisions explicitly.

When two identities define hooks.resolve for the same env var, the later one silently overwrites the earlier one. This makes outcomes depend on agent ordering and can resolve to the wrong value without any signal.

🔧 Suggested fix (fail fast on conflicting identity hooks for same key)
   // Collect resolve scripts: most-specific-wins on key conflicts (identity > swarm)
   const nonPluginResolve: Record<string, { script: string; label: string }> = {};

   // Swarm-level resolve hooks (lowest priority)
   if (manifest.hooks?.resolve) {
     for (const [envVar, script] of Object.entries(manifest.hooks.resolve)) {
       nonPluginResolve[envVar] = { script, label: "swarm" };
     }
   }

   // Identity-level resolve hooks (override swarm for same key)
   for (const fi of fetchedIdentities) {
     if (fi.manifest.hooks?.resolve) {
       for (const [envVar, script] of Object.entries(fi.manifest.hooks.resolve)) {
+        const existing = nonPluginResolve[envVar];
+        if (existing && existing.label.startsWith("identity:") && existing.script !== script) {
+          return {
+            ok: false,
+            error: `Conflicting identity resolve hooks for ${envVar}: ${existing.label} vs identity:${fi.manifest.name}`,
+          };
+        }
         nonPluginResolve[envVar] = { script, label: `identity:${fi.manifest.name}` };
       }
     }
   }

Also applies to: 430-444

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/lib/setup.ts` around lines 421 - 428, The current loop over
fetchedIdentities writes identity-level resolve hooks into nonPluginResolve and
silently overwrites existing keys; update the loop that processes
fi.manifest.hooks.resolve so that before assigning nonPluginResolve[envVar] you
check whether that envVar already exists and if so throw or return a clear error
including the conflicting envVar and both identity names (use fi.manifest.name
and the existing entry's label) to fail-fast on collisions; apply the same
conflict check logic to the analogous swarm-level resolve handling (the block
around lines 430-444) so neither identity- nor swarm-level duplicate resolve
keys are silently overwritten.

@stepandel stepandel merged commit b4814e8 into main Mar 1, 2026
5 checks passed
@stepandel stepandel deleted the feat/multi-level-hooks branch March 1, 2026 21:33
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant