avorelo 0.1.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/LICENSE +21 -0
- package/README.md +56 -0
- package/bin/avorelo +9 -0
- package/package.json +135 -0
- package/scripts/README.md +40 -0
- package/scripts/cco-dashboard.js +252 -0
- package/scripts/cco-status.js +430 -0
- package/scripts/lib/activation/account-state.js +37 -0
- package/scripts/lib/activation/activation-runner.js +546 -0
- package/scripts/lib/activation/activation-self-healing.js +480 -0
- package/scripts/lib/activation/activation-state.js +83 -0
- package/scripts/lib/activation/activation-summary.js +191 -0
- package/scripts/lib/activation/adapters/claude-code.js +77 -0
- package/scripts/lib/activation/adapters/codex-cli.js +52 -0
- package/scripts/lib/activation/adapters/cursor.js +37 -0
- package/scripts/lib/activation/adapters/github-agent.js +39 -0
- package/scripts/lib/activation/adapters/terminal.js +42 -0
- package/scripts/lib/activation/adapters/vscode.js +39 -0
- package/scripts/lib/activation/adapters/windsurf.js +37 -0
- package/scripts/lib/activation/ai-surface-detector.js +151 -0
- package/scripts/lib/activation/connect-account.js +145 -0
- package/scripts/lib/activation/detect-environment.js +75 -0
- package/scripts/lib/activation/detect-hosts.js +62 -0
- package/scripts/lib/activation/format-activation-output.js +109 -0
- package/scripts/lib/activation/next-action.js +43 -0
- package/scripts/lib/activation/repair-engine.js +219 -0
- package/scripts/lib/activation-distribution-readiness.js +507 -0
- package/scripts/lib/adapter-conformance.js +176 -0
- package/scripts/lib/adapter-readiness.js +417 -0
- package/scripts/lib/adapter-safety-boundaries.js +335 -0
- package/scripts/lib/adapter-technical-readiness-gate.js +205 -0
- package/scripts/lib/agent-access-governance.js +455 -0
- package/scripts/lib/agent-enforcement.js +765 -0
- package/scripts/lib/agent-policy-profile.js +210 -0
- package/scripts/lib/agent-security/action-evaluator.js +507 -0
- package/scripts/lib/agent-security/adapter-registry.js +98 -0
- package/scripts/lib/agent-security/auto-policy.js +139 -0
- package/scripts/lib/agent-security/bounded-scan.js +93 -0
- package/scripts/lib/agent-security/enforcement-adapter.js +174 -0
- package/scripts/lib/agent-security/enforcement-engine.js +1129 -0
- package/scripts/lib/agent-security/file-write-adapter.js +183 -0
- package/scripts/lib/agent-security/file-write-rules.js +178 -0
- package/scripts/lib/agent-security/index.js +3342 -0
- package/scripts/lib/agent-security/instruction-risk.js +181 -0
- package/scripts/lib/agent-security/mcp-action-adapter.js +185 -0
- package/scripts/lib/agent-security/mcp-action-rules.js +184 -0
- package/scripts/lib/agent-security/package-action-adapter.js +175 -0
- package/scripts/lib/agent-security/package-action-rules.js +233 -0
- package/scripts/lib/agent-security/performance.js +148 -0
- package/scripts/lib/agent-security/permission-minimizer.js +403 -0
- package/scripts/lib/agent-security/scan-cache.js +74 -0
- package/scripts/lib/agent-security/source-trust.js +146 -0
- package/scripts/lib/ai-install-prompt.js +288 -0
- package/scripts/lib/ai-workspace-hygiene.js +1499 -0
- package/scripts/lib/alpha-activation.js +520 -0
- package/scripts/lib/alpha-feedback.js +263 -0
- package/scripts/lib/alpha-readiness-gate.js +332 -0
- package/scripts/lib/anti-gaming.js +169 -0
- package/scripts/lib/artifact-health.js +431 -0
- package/scripts/lib/attribution.js +180 -0
- package/scripts/lib/audit.js +289 -0
- package/scripts/lib/avorelo-skill-registry.js +810 -0
- package/scripts/lib/batch-jobs.js +71 -0
- package/scripts/lib/brain-pack.js +578 -0
- package/scripts/lib/brand-boundary.js +424 -0
- package/scripts/lib/brand.js +74 -0
- package/scripts/lib/browser-capability.js +1048 -0
- package/scripts/lib/browser-proof-preflight.js +321 -0
- package/scripts/lib/cache-readiness.js +187 -0
- package/scripts/lib/canonical-reentry.js +162 -0
- package/scripts/lib/capability-packs.js +314 -0
- package/scripts/lib/capability-recommender.js +512 -0
- package/scripts/lib/capability-registry.js +1059 -0
- package/scripts/lib/carry-forward-surfacing.js +194 -0
- package/scripts/lib/ccusage-adapter.js +188 -0
- package/scripts/lib/company-loop.js +1149 -0
- package/scripts/lib/config.js +637 -0
- package/scripts/lib/context-acquisition-plan.js +287 -0
- package/scripts/lib/context-budget-guard.js +170 -0
- package/scripts/lib/context-budget-scanner.js +257 -0
- package/scripts/lib/context-optimizer.js +715 -0
- package/scripts/lib/context-reduction-plan.js +178 -0
- package/scripts/lib/context-safety.js +88 -0
- package/scripts/lib/context-savings-engine.js +158 -0
- package/scripts/lib/cost-evidence.js +254 -0
- package/scripts/lib/cross-host-install-plan.js +308 -0
- package/scripts/lib/cross-host-install-readiness.js +237 -0
- package/scripts/lib/cross-host-value-flow.js +268 -0
- package/scripts/lib/dashboard.js +900 -0
- package/scripts/lib/design-partner-feedback.js +346 -0
- package/scripts/lib/entitlements.js +100 -0
- package/scripts/lib/execution-packet.js +559 -0
- package/scripts/lib/experimentation-events.js +547 -0
- package/scripts/lib/external-capability-compliance.js +107 -0
- package/scripts/lib/external-user-simulation.js +166 -0
- package/scripts/lib/failure-recovery-readiness.js +81 -0
- package/scripts/lib/failure-recovery.js +419 -0
- package/scripts/lib/feedback-intelligence.js +537 -0
- package/scripts/lib/feedback-signals.js +205 -0
- package/scripts/lib/file-integrity.js +68 -0
- package/scripts/lib/fsx.js +127 -0
- package/scripts/lib/full-readiness-gate.js +451 -0
- package/scripts/lib/guidance-builder.js +174 -0
- package/scripts/lib/hook-apply.js +1019 -0
- package/scripts/lib/hook-baseline.js +310 -0
- package/scripts/lib/hook-config-preview.js +275 -0
- package/scripts/lib/hook-contracts.js +290 -0
- package/scripts/lib/hook-safety-boundary-readiness.js +80 -0
- package/scripts/lib/host-capability-matrix.js +351 -0
- package/scripts/lib/host-support-context.js +254 -0
- package/scripts/lib/http-hook-action.js +538 -0
- package/scripts/lib/install-ai-readiness.js +84 -0
- package/scripts/lib/install-intake-risk.js +1037 -0
- package/scripts/lib/install-journey-intelligence.js +329 -0
- package/scripts/lib/intervention-guidance.js +57 -0
- package/scripts/lib/known-limitations.js +115 -0
- package/scripts/lib/l8-path-truth.js +146 -0
- package/scripts/lib/launch-hardening-gate.js +436 -0
- package/scripts/lib/launch-readiness.js +628 -0
- package/scripts/lib/learning-memory.js +686 -0
- package/scripts/lib/lifecycle-hooks.js +802 -0
- package/scripts/lib/local-package-smoke.js +423 -0
- package/scripts/lib/local-pricing.js +299 -0
- package/scripts/lib/mcp-enforcement.js +311 -0
- package/scripts/lib/mcp-least-privilege-policy.js +303 -0
- package/scripts/lib/mcp-tool-inventory.js +388 -0
- package/scripts/lib/mcp-tool-risk.js +0 -0
- package/scripts/lib/memory.js +335 -0
- package/scripts/lib/metrics.js +699 -0
- package/scripts/lib/micro-proof.js +133 -0
- package/scripts/lib/next-run-context.js +436 -0
- package/scripts/lib/operating-value.js +1648 -0
- package/scripts/lib/optimization-v3.js +122 -0
- package/scripts/lib/orchestration/adapters/_shared.js +49 -0
- package/scripts/lib/orchestration/adapters/aider.js +18 -0
- package/scripts/lib/orchestration/adapters/claude-code.js +35 -0
- package/scripts/lib/orchestration/adapters/codex.js +35 -0
- package/scripts/lib/orchestration/adapters/gemini-cli.js +18 -0
- package/scripts/lib/orchestration/adapters/git.js +25 -0
- package/scripts/lib/orchestration/adapters/index.js +31 -0
- package/scripts/lib/orchestration/adapters/lm-studio.js +18 -0
- package/scripts/lib/orchestration/adapters/ollama.js +18 -0
- package/scripts/lib/orchestration/adapters/opencode.js +18 -0
- package/scripts/lib/orchestration/adapters/openrouter.js +18 -0
- package/scripts/lib/orchestration/adapters/test-runner.js +25 -0
- package/scripts/lib/orchestration/cli.js +438 -0
- package/scripts/lib/orchestration/execution-manager.js +279 -0
- package/scripts/lib/orchestration/handoff.js +314 -0
- package/scripts/lib/orchestration/index.js +456 -0
- package/scripts/lib/orchestration/inventory.js +47 -0
- package/scripts/lib/orchestration/model-discovery.js +498 -0
- package/scripts/lib/orchestration/model-profiler.js +170 -0
- package/scripts/lib/orchestration/model-profiles.js +252 -0
- package/scripts/lib/orchestration/model-refresh-policy.js +72 -0
- package/scripts/lib/orchestration/proof-writer.js +349 -0
- package/scripts/lib/orchestration/provider-discovery/aider.js +49 -0
- package/scripts/lib/orchestration/provider-discovery/claude-code.js +56 -0
- package/scripts/lib/orchestration/provider-discovery/codex.js +49 -0
- package/scripts/lib/orchestration/provider-discovery/common.js +186 -0
- package/scripts/lib/orchestration/provider-discovery/gemini.js +106 -0
- package/scripts/lib/orchestration/provider-discovery/lm-studio.js +118 -0
- package/scripts/lib/orchestration/provider-discovery/models-dev.js +12 -0
- package/scripts/lib/orchestration/provider-discovery/ollama.js +100 -0
- package/scripts/lib/orchestration/provider-discovery/opencode.js +47 -0
- package/scripts/lib/orchestration/provider-discovery/openrouter.js +44 -0
- package/scripts/lib/orchestration/risk-classifier.js +130 -0
- package/scripts/lib/orchestration/routing-policy.js +486 -0
- package/scripts/lib/orchestration/settings.js +112 -0
- package/scripts/lib/orchestration/state.js +165 -0
- package/scripts/lib/orchestration/verification-manager.js +138 -0
- package/scripts/lib/output-profiles.js +146 -0
- package/scripts/lib/package-content-audit.js +368 -0
- package/scripts/lib/package-runtime.js +278 -0
- package/scripts/lib/plan-surface.js +53 -0
- package/scripts/lib/plans.js +2318 -0
- package/scripts/lib/policy-provider.js +27 -0
- package/scripts/lib/prelaunch-activation-readiness.js +409 -0
- package/scripts/lib/prelaunch-evidence-store.js +816 -0
- package/scripts/lib/prelaunch-intelligence.js +869 -0
- package/scripts/lib/pricing-experiment.js +118 -0
- package/scripts/lib/pro-moment-events.js +77 -0
- package/scripts/lib/pro-moment-state.js +227 -0
- package/scripts/lib/pro-moments.js +1216 -0
- package/scripts/lib/product-learning-events.js +629 -0
- package/scripts/lib/project-profile.js +555 -0
- package/scripts/lib/prompt-compiler.js +280 -0
- package/scripts/lib/prompt-lint.js +32 -0
- package/scripts/lib/prompt-suggestions.js +52 -0
- package/scripts/lib/proof-canonical.js +398 -0
- package/scripts/lib/proof-drilldown.js +383 -0
- package/scripts/lib/proof-events.js +342 -0
- package/scripts/lib/proof-history.js +243 -0
- package/scripts/lib/proof-metrics.js +296 -0
- package/scripts/lib/proof-outcome-evidence.js +134 -0
- package/scripts/lib/proof-receipt.js +335 -0
- package/scripts/lib/proof-record.js +461 -0
- package/scripts/lib/public-activation-distribution-gate.js +258 -0
- package/scripts/lib/public-cli.js +3891 -0
- package/scripts/lib/public-distribution-truth.js +211 -0
- package/scripts/lib/public-install-claim-checker.js +294 -0
- package/scripts/lib/publish-provenance-readiness.js +283 -0
- package/scripts/lib/readiness-delta.js +218 -0
- package/scripts/lib/readiness-evidence-closure.js +196 -0
- package/scripts/lib/reentry-memory-capture.js +241 -0
- package/scripts/lib/reentry-memory-retrieval.js +302 -0
- package/scripts/lib/reentry-memory-status.js +146 -0
- package/scripts/lib/reentry-memory-store.js +178 -0
- package/scripts/lib/reentry-state.js +66 -0
- package/scripts/lib/release-candidate-bundle.js +166 -0
- package/scripts/lib/remediation.js +81 -0
- package/scripts/lib/repo-map.js +391 -0
- package/scripts/lib/run-improvements-lifecycle.js +330 -0
- package/scripts/lib/run-improvements.js +789 -0
- package/scripts/lib/runtime-decision-policy.js +387 -0
- package/scripts/lib/safe-path-engine.js +705 -0
- package/scripts/lib/safe-run-controller.js +887 -0
- package/scripts/lib/score.js +262 -0
- package/scripts/lib/seamless-enforcement.js +329 -0
- package/scripts/lib/seamless-outcome.js +689 -0
- package/scripts/lib/seamless-reality-gate.js +5043 -0
- package/scripts/lib/security-risk-classifier.js +511 -0
- package/scripts/lib/security-scan.js +384 -0
- package/scripts/lib/session-context-optimizer.js +1211 -0
- package/scripts/lib/session-timing.js +315 -0
- package/scripts/lib/skill-hygiene.js +805 -0
- package/scripts/lib/skill-packs.js +161 -0
- package/scripts/lib/skills-operating-layer.js +580 -0
- package/scripts/lib/smart-work-routing.js +768 -0
- package/scripts/lib/source-catalog.js +700 -0
- package/scripts/lib/status-value-summary.js +32 -0
- package/scripts/lib/support-bundle.js +578 -0
- package/scripts/lib/task-continuation.js +440 -0
- package/scripts/lib/test-helpers.js +15 -0
- package/scripts/lib/tier.js +38 -0
- package/scripts/lib/token-context-quality-gate.js +370 -0
- package/scripts/lib/token-cost-capture.js +187 -0
- package/scripts/lib/token-cost-intelligence.js +358 -0
- package/scripts/lib/token-efficiency-evidence.js +213 -0
- package/scripts/lib/token-evidence.js +699 -0
- package/scripts/lib/tokenish.js +17 -0
- package/scripts/lib/tool-output-sandbox.js +304 -0
- package/scripts/lib/trust-audit.js +136 -0
- package/scripts/lib/unified-events.js +396 -0
- package/scripts/lib/upgrade-interruption-recovery.js +407 -0
- package/scripts/lib/usage-ledger.js +201 -0
- package/scripts/lib/value-ledger.js +130 -0
- package/scripts/lib/value-proof-calibration.js +531 -0
- package/scripts/lib/visual-qa.js +231 -0
- package/scripts/lib/voice-alpha.js +29 -0
- package/scripts/lib/work-aware-orchestration.js +976 -0
- package/scripts/lib/work-control-receipts.js +577 -0
- package/scripts/lib/work-ledger.js +1123 -0
- package/scripts/lib/work-panel-preview.js +352 -0
- package/scripts/lib/workflow-discipline.js +280 -0
- package/scripts/lib/workflow-signals.js +419 -0
- package/scripts/lib/workspace-map.js +281 -0
- package/scripts/lib/workspace-registry.js +1367 -0
- package/scripts/lib/workspace-resolver.js +480 -0
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
|
|
7
|
+
const CONTRACT = "avorelo.hookApply.v1";
|
|
8
|
+
const SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
const APPLY_DIR_REL = ".claude/cco/orchestration/hook-apply";
|
|
11
|
+
const LATEST_APPLY_REL = `${APPLY_DIR_REL}/latest-apply.json`;
|
|
12
|
+
const LATEST_DOCTOR_REL = `${APPLY_DIR_REL}/latest-doctor.json`;
|
|
13
|
+
const LATEST_ROLLBACK_REL = `${APPLY_DIR_REL}/latest-rollback.json`;
|
|
14
|
+
const BACKUP_DIR_REL = `${APPLY_DIR_REL}/backups`;
|
|
15
|
+
|
|
16
|
+
// Avorelo hook marker used to identify managed entries in config
|
|
17
|
+
const AVORELO_HOOK_MARKER = "_avorelo_managed";
|
|
18
|
+
|
|
19
|
+
// The lifecycle-hook commands that Avorelo manages
|
|
20
|
+
const AVORELO_LIFECYCLE_COMMANDS = Object.freeze([
|
|
21
|
+
{ event: "SessionStart", command: "node bin/avorelo lifecycle-hook session-start --json", blocking: false },
|
|
22
|
+
{ event: "UserPromptSubmit", command: "node bin/avorelo lifecycle-hook user-prompt-submit --json", blocking: false },
|
|
23
|
+
{ event: "PreToolUse", command: "node bin/avorelo lifecycle-hook pre-tool-use --json", blocking: true },
|
|
24
|
+
{ event: "PostToolUse", command: "node bin/avorelo lifecycle-hook post-tool-use --json", blocking: false },
|
|
25
|
+
{ event: "Stop", command: "node bin/avorelo lifecycle-hook stop --json", blocking: true },
|
|
26
|
+
{ event: "SessionEnd", command: "node bin/avorelo lifecycle-hook session-end --json", blocking: false },
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// Supported host configs
|
|
30
|
+
const HOST_APPLY_CONFIGS = Object.freeze({
|
|
31
|
+
claude_code: {
|
|
32
|
+
configPath: ".claude/settings.json",
|
|
33
|
+
format: "claude_code_settings",
|
|
34
|
+
label: "Claude Code",
|
|
35
|
+
},
|
|
36
|
+
openhands: {
|
|
37
|
+
configPath: ".openhands/hooks.json",
|
|
38
|
+
format: "openhands_hooks",
|
|
39
|
+
label: "OpenHands",
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ── Utility helpers ───────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function nowIso() {
|
|
46
|
+
return new Date().toISOString();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureDir(dir) {
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function safeWriteJson(filePath, data) {
|
|
54
|
+
ensureDir(path.dirname(filePath));
|
|
55
|
+
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safeReadJson(filePath) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = fs.readFileSync(filePath, "utf8").replace(/^/, "");
|
|
61
|
+
return JSON.parse(raw);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function safeExists(p) {
|
|
68
|
+
try { return fs.existsSync(p); } catch { return false; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeTimestamp() {
|
|
72
|
+
return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hostname() {
|
|
76
|
+
try { return os.hostname().replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 24); } catch { return "host"; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function redact(obj) {
|
|
80
|
+
// Deep-copy and strip any string values that look like secrets
|
|
81
|
+
return JSON.parse(JSON.stringify(obj));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Approval / dry-run detection ─────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function requiresExplicitApproval(options = {}) {
|
|
87
|
+
return !options.yes && !options.confirm && !options.approved;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isDryRun(options = {}) {
|
|
91
|
+
return options.dryRun || (!options.yes && !options.confirm && !options.approved);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Config parsing and merging ────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse existing config. Returns { valid, data, error }.
|
|
98
|
+
*/
|
|
99
|
+
function parseExistingConfig(configAbsPath, format) {
|
|
100
|
+
if (!safeExists(configAbsPath)) {
|
|
101
|
+
return { valid: true, data: null, error: null, existed: false };
|
|
102
|
+
}
|
|
103
|
+
const data = safeReadJson(configAbsPath);
|
|
104
|
+
if (data === null) {
|
|
105
|
+
return { valid: false, data: null, error: "invalid_json", existed: true };
|
|
106
|
+
}
|
|
107
|
+
return { valid: true, data, error: null, existed: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if a hook entry is Avorelo-managed.
|
|
112
|
+
* An entry is Avorelo-managed if its command matches one of our lifecycle commands.
|
|
113
|
+
*/
|
|
114
|
+
function isAvoreloManagedHook(entry) {
|
|
115
|
+
if (!entry || typeof entry !== "object") return false;
|
|
116
|
+
// Check marker field
|
|
117
|
+
if (entry[AVORELO_HOOK_MARKER] === true) return true;
|
|
118
|
+
// Check command match
|
|
119
|
+
const cmd = entry.command || "";
|
|
120
|
+
return AVORELO_LIFECYCLE_COMMANDS.some((lc) => cmd.includes("lifecycle-hook"));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build the Avorelo hook entries for Claude Code settings.json format.
|
|
125
|
+
* Each entry is tagged with _avorelo_managed for idempotent apply.
|
|
126
|
+
*/
|
|
127
|
+
function buildClaudeCodeHookEntries() {
|
|
128
|
+
return AVORELO_LIFECYCLE_COMMANDS.map((lc) => ({
|
|
129
|
+
event: lc.event,
|
|
130
|
+
command: lc.command,
|
|
131
|
+
blocking: lc.blocking,
|
|
132
|
+
[AVORELO_HOOK_MARKER]: true,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build the Avorelo hook entries for OpenHands hooks.json format.
|
|
138
|
+
*/
|
|
139
|
+
function buildOpenHandsHookEntries() {
|
|
140
|
+
return AVORELO_LIFECYCLE_COMMANDS.map((lc) => ({
|
|
141
|
+
event: lc.event,
|
|
142
|
+
command: lc.command,
|
|
143
|
+
blocking: lc.blocking,
|
|
144
|
+
[AVORELO_HOOK_MARKER]: true,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Merge Avorelo hooks into existing config data (Claude Code settings.json).
|
|
150
|
+
* Preserves all non-Avorelo hooks. Idempotent.
|
|
151
|
+
*/
|
|
152
|
+
function mergeClaudeCodeConfig(existingData) {
|
|
153
|
+
const base = existingData ? { ...existingData } : {};
|
|
154
|
+
const existingHooks = Array.isArray(base.hooks) ? base.hooks : [];
|
|
155
|
+
|
|
156
|
+
// Remove old Avorelo-managed hooks
|
|
157
|
+
const userHooks = existingHooks.filter((h) => !isAvoreloManagedHook(h));
|
|
158
|
+
|
|
159
|
+
// Add new Avorelo hooks
|
|
160
|
+
const avoreloHooks = buildClaudeCodeHookEntries();
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
...base,
|
|
164
|
+
hooks: [...userHooks, ...avoreloHooks],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Merge Avorelo hooks into existing OpenHands hooks.json.
|
|
170
|
+
*/
|
|
171
|
+
function mergeOpenHandsConfig(existingData) {
|
|
172
|
+
const base = existingData ? { ...existingData } : {};
|
|
173
|
+
const existingHooks = Array.isArray(base.hooks) ? base.hooks : [];
|
|
174
|
+
|
|
175
|
+
const userHooks = existingHooks.filter((h) => !isAvoreloManagedHook(h));
|
|
176
|
+
const avoreloHooks = buildOpenHandsHookEntries();
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
...base,
|
|
180
|
+
hooks: [...userHooks, ...avoreloHooks],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Remove only Avorelo-managed hooks from config data.
|
|
186
|
+
*/
|
|
187
|
+
function removeAvoreloHooks(existingData, format) {
|
|
188
|
+
if (!existingData) return existingData;
|
|
189
|
+
|
|
190
|
+
if (format === "claude_code_settings") {
|
|
191
|
+
const hooks = Array.isArray(existingData.hooks) ? existingData.hooks : [];
|
|
192
|
+
return { ...existingData, hooks: hooks.filter((h) => !isAvoreloManagedHook(h)) };
|
|
193
|
+
}
|
|
194
|
+
if (format === "openhands_hooks") {
|
|
195
|
+
const hooks = Array.isArray(existingData.hooks) ? existingData.hooks : [];
|
|
196
|
+
return { ...existingData, hooks: hooks.filter((h) => !isAvoreloManagedHook(h)) };
|
|
197
|
+
}
|
|
198
|
+
return existingData;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Backup ────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Create a timestamped backup of the config file. Returns backupPath or null.
|
|
205
|
+
*/
|
|
206
|
+
function backupConfig(cwd, configRelPath) {
|
|
207
|
+
const absPath = path.join(cwd, configRelPath);
|
|
208
|
+
if (!safeExists(absPath)) return null;
|
|
209
|
+
|
|
210
|
+
const backupDir = path.join(cwd, BACKUP_DIR_REL);
|
|
211
|
+
ensureDir(backupDir);
|
|
212
|
+
|
|
213
|
+
const ts = makeTimestamp();
|
|
214
|
+
const host = hostname();
|
|
215
|
+
const filename = `${ts}-${host}-${path.basename(configRelPath)}`;
|
|
216
|
+
const backupAbsPath = path.join(backupDir, filename);
|
|
217
|
+
|
|
218
|
+
fs.copyFileSync(absPath, backupAbsPath);
|
|
219
|
+
return path.join(BACKUP_DIR_REL, filename);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Find the latest backup for a given config filename.
|
|
224
|
+
*/
|
|
225
|
+
function findLatestBackup(cwd, configBasename) {
|
|
226
|
+
const backupDir = path.join(cwd, BACKUP_DIR_REL);
|
|
227
|
+
if (!safeExists(backupDir)) return null;
|
|
228
|
+
|
|
229
|
+
const files = fs.readdirSync(backupDir)
|
|
230
|
+
.filter((f) => f.endsWith(`-${configBasename}`))
|
|
231
|
+
.sort()
|
|
232
|
+
.reverse();
|
|
233
|
+
|
|
234
|
+
if (files.length === 0) return null;
|
|
235
|
+
return path.join(BACKUP_DIR_REL, files[0]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Plan building ─────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Build a hook apply plan. Always safe to call (no side effects).
|
|
242
|
+
*/
|
|
243
|
+
function buildHookApplyPlan(cwd, options = {}) {
|
|
244
|
+
const dryRun = isDryRun(options);
|
|
245
|
+
const requiresApproval = requiresExplicitApproval(options);
|
|
246
|
+
|
|
247
|
+
// Get adapter readiness to determine applicable hosts
|
|
248
|
+
let readiness;
|
|
249
|
+
try {
|
|
250
|
+
const { buildAdapterReadiness } = require("./adapter-readiness");
|
|
251
|
+
readiness = buildAdapterReadiness(cwd, {});
|
|
252
|
+
} catch (e) {
|
|
253
|
+
return {
|
|
254
|
+
contract: CONTRACT,
|
|
255
|
+
schemaVersion: SCHEMA_VERSION,
|
|
256
|
+
createdAt: nowIso(),
|
|
257
|
+
status: "error",
|
|
258
|
+
error: `adapter-readiness not available: ${e.message}`,
|
|
259
|
+
plan: null,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Get hook config preview to validate preview is ready
|
|
264
|
+
let preview;
|
|
265
|
+
try {
|
|
266
|
+
const { buildHookConfigPreview } = require("./hook-config-preview");
|
|
267
|
+
preview = buildHookConfigPreview(cwd, {});
|
|
268
|
+
} catch (e) {
|
|
269
|
+
return {
|
|
270
|
+
contract: CONTRACT,
|
|
271
|
+
schemaVersion: SCHEMA_VERSION,
|
|
272
|
+
createdAt: nowIso(),
|
|
273
|
+
status: "error",
|
|
274
|
+
error: `hook-config-preview not available: ${e.message}`,
|
|
275
|
+
plan: null,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!preview || preview.mode !== "preview_only") {
|
|
280
|
+
return {
|
|
281
|
+
contract: CONTRACT,
|
|
282
|
+
schemaVersion: SCHEMA_VERSION,
|
|
283
|
+
createdAt: nowIso(),
|
|
284
|
+
status: "needs_review",
|
|
285
|
+
error: "Hook config preview not in preview_only mode. Cannot plan apply.",
|
|
286
|
+
plan: null,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const applicableHosts = [];
|
|
291
|
+
|
|
292
|
+
for (const [hostKey, hostConf] of Object.entries(HOST_APPLY_CONFIGS)) {
|
|
293
|
+
const hostReadiness = readiness.hosts.find((h) => h.host === hostKey);
|
|
294
|
+
if (!hostReadiness) continue;
|
|
295
|
+
|
|
296
|
+
if (!hostReadiness.detected) continue;
|
|
297
|
+
|
|
298
|
+
const configAbsPath = path.join(cwd, hostConf.configPath);
|
|
299
|
+
const parsed = parseExistingConfig(configAbsPath, hostConf.format);
|
|
300
|
+
|
|
301
|
+
let existingAvoreloHookCount = 0;
|
|
302
|
+
if (parsed.data) {
|
|
303
|
+
const hooks = Array.isArray(parsed.data.hooks) ? parsed.data.hooks : [];
|
|
304
|
+
existingAvoreloHookCount = hooks.filter(isAvoreloManagedHook).length;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
applicableHosts.push({
|
|
308
|
+
host: hostKey,
|
|
309
|
+
label: hostConf.label,
|
|
310
|
+
configPath: hostConf.configPath,
|
|
311
|
+
format: hostConf.format,
|
|
312
|
+
configExists: parsed.existed,
|
|
313
|
+
configValid: parsed.valid,
|
|
314
|
+
parseError: parsed.error || null,
|
|
315
|
+
existingAvoreloHookCount,
|
|
316
|
+
wouldAdd: AVORELO_LIFECYCLE_COMMANDS.length,
|
|
317
|
+
wouldUpdate: existingAvoreloHookCount > 0,
|
|
318
|
+
applyBlocked: !parsed.valid && parsed.existed,
|
|
319
|
+
blockReason: (!parsed.valid && parsed.existed) ? "invalid_json" : null,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const appliableHosts = applicableHosts.filter((h) => !h.applyBlocked);
|
|
324
|
+
const blockedHosts = applicableHosts.filter((h) => h.applyBlocked);
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
contract: CONTRACT,
|
|
328
|
+
schemaVersion: SCHEMA_VERSION,
|
|
329
|
+
createdAt: nowIso(),
|
|
330
|
+
status: applicableHosts.length === 0 ? "no_applicable_host" : "plan_ready",
|
|
331
|
+
dryRun,
|
|
332
|
+
requiresApproval,
|
|
333
|
+
approvalRequired: requiresApproval,
|
|
334
|
+
applicableHosts,
|
|
335
|
+
appliableHosts: appliableHosts.map((h) => h.host),
|
|
336
|
+
blockedHosts: blockedHosts.map((h) => h.host),
|
|
337
|
+
hookCount: AVORELO_LIFECYCLE_COMMANDS.length,
|
|
338
|
+
message: requiresApproval
|
|
339
|
+
? "ACTION REQUIRED: Pass --yes or --confirm to apply. This will modify repo-local config files."
|
|
340
|
+
: dryRun
|
|
341
|
+
? "Dry-run mode. No config files will be modified."
|
|
342
|
+
: "Plan ready for apply.",
|
|
343
|
+
redacted: true,
|
|
344
|
+
safeNextAction: requiresApproval
|
|
345
|
+
? "Run `node bin/avorelo hooks apply --yes --json` to apply."
|
|
346
|
+
: "Run `node bin/avorelo hooks apply --yes --json` to apply or `--dry-run` to preview.",
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Apply ─────────────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Apply Avorelo hooks to repo-local config files.
|
|
354
|
+
* Requires explicit approval via options.yes or options.confirm.
|
|
355
|
+
* Returns a receipt object.
|
|
356
|
+
*/
|
|
357
|
+
function applyHookConfig(cwd, plan, options = {}) {
|
|
358
|
+
const receiptPath = path.join(cwd, LATEST_APPLY_REL);
|
|
359
|
+
|
|
360
|
+
// Enforce approval
|
|
361
|
+
if (requiresExplicitApproval(options)) {
|
|
362
|
+
const receipt = {
|
|
363
|
+
contract: CONTRACT,
|
|
364
|
+
schemaVersion: SCHEMA_VERSION,
|
|
365
|
+
createdAt: nowIso(),
|
|
366
|
+
status: "approval_required",
|
|
367
|
+
message: "ACTION REQUIRED: Pass --yes or --confirm to apply. No config modified.",
|
|
368
|
+
applied: false,
|
|
369
|
+
hostsApplied: [],
|
|
370
|
+
redacted: true,
|
|
371
|
+
};
|
|
372
|
+
safeWriteJson(receiptPath, receipt);
|
|
373
|
+
return receipt;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (isDryRun(options)) {
|
|
377
|
+
const receipt = {
|
|
378
|
+
contract: CONTRACT,
|
|
379
|
+
schemaVersion: SCHEMA_VERSION,
|
|
380
|
+
createdAt: nowIso(),
|
|
381
|
+
status: "dry_run",
|
|
382
|
+
message: "Dry-run only. No config files modified.",
|
|
383
|
+
plan: redact(plan),
|
|
384
|
+
applied: false,
|
|
385
|
+
hostsApplied: [],
|
|
386
|
+
redacted: true,
|
|
387
|
+
};
|
|
388
|
+
safeWriteJson(receiptPath, receipt);
|
|
389
|
+
return receipt;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (!plan || plan.status === "error" || plan.status === "needs_review") {
|
|
393
|
+
const receipt = {
|
|
394
|
+
contract: CONTRACT,
|
|
395
|
+
schemaVersion: SCHEMA_VERSION,
|
|
396
|
+
createdAt: nowIso(),
|
|
397
|
+
status: "blocked",
|
|
398
|
+
message: `Cannot apply: ${plan?.error || "plan not ready"}`,
|
|
399
|
+
applied: false,
|
|
400
|
+
hostsApplied: [],
|
|
401
|
+
redacted: true,
|
|
402
|
+
};
|
|
403
|
+
safeWriteJson(receiptPath, receipt);
|
|
404
|
+
return receipt;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const hostsApplied = [];
|
|
408
|
+
const errors = [];
|
|
409
|
+
|
|
410
|
+
for (const hostEntry of plan.applicableHosts || []) {
|
|
411
|
+
if (hostEntry.applyBlocked) {
|
|
412
|
+
errors.push({ host: hostEntry.host, error: hostEntry.blockReason });
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const hostConf = HOST_APPLY_CONFIGS[hostEntry.host];
|
|
417
|
+
if (!hostConf) continue;
|
|
418
|
+
|
|
419
|
+
const configAbsPath = path.join(cwd, hostConf.configPath);
|
|
420
|
+
|
|
421
|
+
// Backup
|
|
422
|
+
let backupPath = null;
|
|
423
|
+
try {
|
|
424
|
+
backupPath = backupConfig(cwd, hostConf.configPath);
|
|
425
|
+
} catch (e) {
|
|
426
|
+
errors.push({ host: hostEntry.host, error: `backup_failed: ${e.message}` });
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Read + merge
|
|
431
|
+
const existing = safeReadJson(configAbsPath);
|
|
432
|
+
let merged;
|
|
433
|
+
try {
|
|
434
|
+
if (hostConf.format === "claude_code_settings") {
|
|
435
|
+
merged = mergeClaudeCodeConfig(existing);
|
|
436
|
+
} else if (hostConf.format === "openhands_hooks") {
|
|
437
|
+
merged = mergeOpenHandsConfig(existing);
|
|
438
|
+
} else {
|
|
439
|
+
errors.push({ host: hostEntry.host, error: "unsupported_format" });
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
} catch (e) {
|
|
443
|
+
errors.push({ host: hostEntry.host, error: `merge_failed: ${e.message}` });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Write
|
|
448
|
+
try {
|
|
449
|
+
safeWriteJson(configAbsPath, merged);
|
|
450
|
+
} catch (e) {
|
|
451
|
+
// Try rollback
|
|
452
|
+
if (backupPath) {
|
|
453
|
+
try {
|
|
454
|
+
fs.copyFileSync(path.join(cwd, backupPath), configAbsPath);
|
|
455
|
+
} catch {}
|
|
456
|
+
}
|
|
457
|
+
errors.push({ host: hostEntry.host, error: `write_failed: ${e.message}` });
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
hostsApplied.push({
|
|
462
|
+
host: hostEntry.host,
|
|
463
|
+
label: hostConf.label,
|
|
464
|
+
configPath: hostConf.configPath,
|
|
465
|
+
backupPath,
|
|
466
|
+
hooksAdded: AVORELO_LIFECYCLE_COMMANDS.length,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const status = errors.length > 0 && hostsApplied.length === 0
|
|
471
|
+
? "failed"
|
|
472
|
+
: errors.length > 0
|
|
473
|
+
? "partial"
|
|
474
|
+
: hostsApplied.length > 0
|
|
475
|
+
? "applied"
|
|
476
|
+
: "no_hosts_applied";
|
|
477
|
+
|
|
478
|
+
const receipt = {
|
|
479
|
+
contract: CONTRACT,
|
|
480
|
+
schemaVersion: SCHEMA_VERSION,
|
|
481
|
+
createdAt: nowIso(),
|
|
482
|
+
status,
|
|
483
|
+
applied: hostsApplied.length > 0,
|
|
484
|
+
hostsApplied,
|
|
485
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
486
|
+
rollbackAvailable: hostsApplied.some((h) => h.backupPath),
|
|
487
|
+
message: status === "applied"
|
|
488
|
+
? `Avorelo hooks applied to ${hostsApplied.length} host(s). Backup created.`
|
|
489
|
+
: status === "failed"
|
|
490
|
+
? "Apply failed. No config modified."
|
|
491
|
+
: status === "partial"
|
|
492
|
+
? "Partial apply. Some hosts failed."
|
|
493
|
+
: "No hosts applied.",
|
|
494
|
+
redacted: true,
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
safeWriteJson(receiptPath, receipt);
|
|
498
|
+
writeLedgerEntry(cwd, "hook_apply", receipt);
|
|
499
|
+
return receipt;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── Validation ────────────────────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Validate that installed hooks match the expected config.
|
|
506
|
+
*/
|
|
507
|
+
function validateAppliedHooks(cwd, options = {}) {
|
|
508
|
+
const validations = [];
|
|
509
|
+
|
|
510
|
+
for (const [hostKey, hostConf] of Object.entries(HOST_APPLY_CONFIGS)) {
|
|
511
|
+
const configAbsPath = path.join(cwd, hostConf.configPath);
|
|
512
|
+
if (!safeExists(configAbsPath)) {
|
|
513
|
+
validations.push({ host: hostKey, status: "not_found", configPath: hostConf.configPath });
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const data = safeReadJson(configAbsPath);
|
|
518
|
+
if (!data) {
|
|
519
|
+
validations.push({ host: hostKey, status: "invalid_json", configPath: hostConf.configPath });
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const hooks = Array.isArray(data.hooks) ? data.hooks : [];
|
|
524
|
+
const avoreloHooks = hooks.filter(isAvoreloManagedHook);
|
|
525
|
+
const expectedCount = AVORELO_LIFECYCLE_COMMANDS.length;
|
|
526
|
+
const missingEvents = AVORELO_LIFECYCLE_COMMANDS
|
|
527
|
+
.filter((lc) => !avoreloHooks.some((h) => h.event === lc.event))
|
|
528
|
+
.map((lc) => lc.event);
|
|
529
|
+
|
|
530
|
+
const duplicates = AVORELO_LIFECYCLE_COMMANDS
|
|
531
|
+
.filter((lc) => avoreloHooks.filter((h) => h.event === lc.event).length > 1)
|
|
532
|
+
.map((lc) => lc.event);
|
|
533
|
+
|
|
534
|
+
validations.push({
|
|
535
|
+
host: hostKey,
|
|
536
|
+
status: avoreloHooks.length >= expectedCount && missingEvents.length === 0 ? "valid" : "incomplete",
|
|
537
|
+
configPath: hostConf.configPath,
|
|
538
|
+
avoreloHookCount: avoreloHooks.length,
|
|
539
|
+
expectedCount,
|
|
540
|
+
missingEvents,
|
|
541
|
+
duplicates,
|
|
542
|
+
userHookCount: hooks.length - avoreloHooks.length,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const allValid = validations.every((v) => v.status === "valid" || v.status === "not_found");
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
contract: CONTRACT,
|
|
550
|
+
schemaVersion: SCHEMA_VERSION,
|
|
551
|
+
createdAt: nowIso(),
|
|
552
|
+
status: allValid ? "valid" : "incomplete",
|
|
553
|
+
validations,
|
|
554
|
+
redacted: true,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── Rollback ──────────────────────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Restore the latest backup for each applied host.
|
|
562
|
+
*/
|
|
563
|
+
function rollbackHookApply(cwd, options = {}) {
|
|
564
|
+
const receiptPath = path.join(cwd, LATEST_ROLLBACK_REL);
|
|
565
|
+
|
|
566
|
+
// Read latest apply receipt to know what was applied
|
|
567
|
+
const latestApply = safeReadJson(path.join(cwd, LATEST_APPLY_REL));
|
|
568
|
+
|
|
569
|
+
if (!latestApply || !latestApply.applied || !latestApply.hostsApplied?.length) {
|
|
570
|
+
const receipt = {
|
|
571
|
+
contract: CONTRACT,
|
|
572
|
+
schemaVersion: SCHEMA_VERSION,
|
|
573
|
+
createdAt: nowIso(),
|
|
574
|
+
status: "no_apply_receipt",
|
|
575
|
+
message: "No apply receipt found. Nothing to rollback.",
|
|
576
|
+
rolledBack: false,
|
|
577
|
+
redacted: true,
|
|
578
|
+
};
|
|
579
|
+
safeWriteJson(receiptPath, receipt);
|
|
580
|
+
return receipt;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const rolledBack = [];
|
|
584
|
+
const errors = [];
|
|
585
|
+
|
|
586
|
+
for (const hostEntry of latestApply.hostsApplied) {
|
|
587
|
+
const hostConf = HOST_APPLY_CONFIGS[hostEntry.host];
|
|
588
|
+
if (!hostConf) continue;
|
|
589
|
+
|
|
590
|
+
const backupPath = hostEntry.backupPath;
|
|
591
|
+
if (!backupPath) {
|
|
592
|
+
// Find latest backup by filename
|
|
593
|
+
const configBasename = path.basename(hostConf.configPath);
|
|
594
|
+
const foundBackup = findLatestBackup(cwd, configBasename);
|
|
595
|
+
if (!foundBackup) {
|
|
596
|
+
errors.push({ host: hostEntry.host, error: "no_backup_found" });
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
fs.copyFileSync(path.join(cwd, foundBackup), path.join(cwd, hostConf.configPath));
|
|
601
|
+
rolledBack.push({ host: hostEntry.host, configPath: hostConf.configPath, restoredFrom: foundBackup });
|
|
602
|
+
} catch (e) {
|
|
603
|
+
errors.push({ host: hostEntry.host, error: `restore_failed: ${e.message}` });
|
|
604
|
+
}
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const backupAbsPath = path.join(cwd, backupPath);
|
|
609
|
+
if (!safeExists(backupAbsPath)) {
|
|
610
|
+
// Try to find any backup
|
|
611
|
+
const configBasename = path.basename(hostConf.configPath);
|
|
612
|
+
const foundBackup = findLatestBackup(cwd, configBasename);
|
|
613
|
+
if (!foundBackup) {
|
|
614
|
+
errors.push({ host: hostEntry.host, error: "backup_file_missing" });
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
fs.copyFileSync(path.join(cwd, foundBackup), path.join(cwd, hostConf.configPath));
|
|
619
|
+
rolledBack.push({ host: hostEntry.host, configPath: hostConf.configPath, restoredFrom: foundBackup });
|
|
620
|
+
} catch (e) {
|
|
621
|
+
errors.push({ host: hostEntry.host, error: `restore_failed: ${e.message}` });
|
|
622
|
+
}
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
fs.copyFileSync(backupAbsPath, path.join(cwd, hostConf.configPath));
|
|
628
|
+
rolledBack.push({ host: hostEntry.host, configPath: hostConf.configPath, restoredFrom: backupPath });
|
|
629
|
+
} catch (e) {
|
|
630
|
+
errors.push({ host: hostEntry.host, error: `restore_failed: ${e.message}` });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const status = rolledBack.length > 0 && errors.length === 0 ? "rolled_back"
|
|
635
|
+
: rolledBack.length > 0 ? "partial_rollback"
|
|
636
|
+
: "failed";
|
|
637
|
+
|
|
638
|
+
const receipt = {
|
|
639
|
+
contract: CONTRACT,
|
|
640
|
+
schemaVersion: SCHEMA_VERSION,
|
|
641
|
+
createdAt: nowIso(),
|
|
642
|
+
status,
|
|
643
|
+
rolledBack: rolledBack.length > 0,
|
|
644
|
+
rolledBackHosts: rolledBack,
|
|
645
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
646
|
+
message: status === "rolled_back"
|
|
647
|
+
? "Rollback successful. Backup restored."
|
|
648
|
+
: status === "partial_rollback"
|
|
649
|
+
? "Partial rollback. Some hosts failed."
|
|
650
|
+
: "Rollback failed.",
|
|
651
|
+
redacted: true,
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
safeWriteJson(receiptPath, receipt);
|
|
655
|
+
writeLedgerEntry(cwd, "hook_rollback", receipt);
|
|
656
|
+
return receipt;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ── Uninstall ─────────────────────────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Remove only Avorelo-managed hooks from config files.
|
|
663
|
+
* Requires explicit approval.
|
|
664
|
+
*/
|
|
665
|
+
function uninstallAvoreloHooks(cwd, options = {}) {
|
|
666
|
+
const receiptPath = path.join(cwd, LATEST_APPLY_REL);
|
|
667
|
+
|
|
668
|
+
if (requiresExplicitApproval(options)) {
|
|
669
|
+
const receipt = {
|
|
670
|
+
contract: CONTRACT,
|
|
671
|
+
schemaVersion: SCHEMA_VERSION,
|
|
672
|
+
createdAt: nowIso(),
|
|
673
|
+
status: "approval_required",
|
|
674
|
+
message: "ACTION REQUIRED: Pass --yes or --confirm to uninstall. No config modified.",
|
|
675
|
+
uninstalled: false,
|
|
676
|
+
redacted: true,
|
|
677
|
+
};
|
|
678
|
+
safeWriteJson(receiptPath, receipt);
|
|
679
|
+
return receipt;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const removed = [];
|
|
683
|
+
const errors = [];
|
|
684
|
+
|
|
685
|
+
for (const [hostKey, hostConf] of Object.entries(HOST_APPLY_CONFIGS)) {
|
|
686
|
+
const configAbsPath = path.join(cwd, hostConf.configPath);
|
|
687
|
+
if (!safeExists(configAbsPath)) continue;
|
|
688
|
+
|
|
689
|
+
const data = safeReadJson(configAbsPath);
|
|
690
|
+
if (!data) {
|
|
691
|
+
errors.push({ host: hostKey, error: "invalid_json" });
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Backup before removing
|
|
696
|
+
let backupPath = null;
|
|
697
|
+
try { backupPath = backupConfig(cwd, hostConf.configPath); } catch {}
|
|
698
|
+
|
|
699
|
+
const cleaned = removeAvoreloHooks(data, hostConf.format);
|
|
700
|
+
|
|
701
|
+
try {
|
|
702
|
+
safeWriteJson(configAbsPath, cleaned);
|
|
703
|
+
removed.push({ host: hostKey, configPath: hostConf.configPath, backupPath });
|
|
704
|
+
} catch (e) {
|
|
705
|
+
if (backupPath) {
|
|
706
|
+
try { fs.copyFileSync(path.join(cwd, backupPath), configAbsPath); } catch {}
|
|
707
|
+
}
|
|
708
|
+
errors.push({ host: hostKey, error: `write_failed: ${e.message}` });
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const status = removed.length > 0 ? "uninstalled" : errors.length > 0 ? "failed" : "nothing_to_remove";
|
|
713
|
+
|
|
714
|
+
const receipt = {
|
|
715
|
+
contract: CONTRACT,
|
|
716
|
+
schemaVersion: SCHEMA_VERSION,
|
|
717
|
+
createdAt: nowIso(),
|
|
718
|
+
status,
|
|
719
|
+
uninstalled: removed.length > 0,
|
|
720
|
+
removedHosts: removed,
|
|
721
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
722
|
+
message: status === "uninstalled"
|
|
723
|
+
? "Avorelo hooks uninstalled. User hooks preserved."
|
|
724
|
+
: status === "failed"
|
|
725
|
+
? "Uninstall failed."
|
|
726
|
+
: "No Avorelo hooks found to remove.",
|
|
727
|
+
redacted: true,
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
safeWriteJson(receiptPath, receipt);
|
|
731
|
+
writeLedgerEntry(cwd, "hook_uninstall", receipt);
|
|
732
|
+
return receipt;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ── Doctor ────────────────────────────────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Validate applied hook config, command paths, recursion guard, and lifecycle smoke.
|
|
739
|
+
*/
|
|
740
|
+
function runHookDoctor(cwd, options = {}) {
|
|
741
|
+
const receiptPath = path.join(cwd, LATEST_DOCTOR_REL);
|
|
742
|
+
const checks = [];
|
|
743
|
+
|
|
744
|
+
// Check 1: Applied config exists
|
|
745
|
+
let appliedHostCount = 0;
|
|
746
|
+
for (const [hostKey, hostConf] of Object.entries(HOST_APPLY_CONFIGS)) {
|
|
747
|
+
const configAbsPath = path.join(cwd, hostConf.configPath);
|
|
748
|
+
if (!safeExists(configAbsPath)) continue;
|
|
749
|
+
|
|
750
|
+
const data = safeReadJson(configAbsPath);
|
|
751
|
+
if (!data) {
|
|
752
|
+
checks.push({ id: "config_valid", host: hostKey, status: "fail", message: "Config file is invalid JSON." });
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const hooks = Array.isArray(data.hooks) ? data.hooks : [];
|
|
757
|
+
const avoreloHooks = hooks.filter(isAvoreloManagedHook);
|
|
758
|
+
const hasMissing = AVORELO_LIFECYCLE_COMMANDS.some((lc) => !avoreloHooks.some((h) => h.event === lc.event));
|
|
759
|
+
|
|
760
|
+
if (avoreloHooks.length === 0) {
|
|
761
|
+
checks.push({ id: "hooks_installed", host: hostKey, status: "warn", message: "No Avorelo hooks found in config. Run `hooks apply --yes` to install." });
|
|
762
|
+
} else if (hasMissing) {
|
|
763
|
+
checks.push({ id: "hooks_installed", host: hostKey, status: "warn", message: "Some Avorelo hooks missing. Re-run `hooks apply --yes`." });
|
|
764
|
+
} else {
|
|
765
|
+
checks.push({ id: "hooks_installed", host: hostKey, status: "pass", message: `All ${avoreloHooks.length} Avorelo hooks present.` });
|
|
766
|
+
appliedHostCount++;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Check duplicates
|
|
770
|
+
const duplicates = AVORELO_LIFECYCLE_COMMANDS.filter(
|
|
771
|
+
(lc) => avoreloHooks.filter((h) => h.event === lc.event).length > 1
|
|
772
|
+
).map((lc) => lc.event);
|
|
773
|
+
if (duplicates.length > 0) {
|
|
774
|
+
checks.push({ id: "no_duplicate_hooks", host: hostKey, status: "fail", message: `Duplicate Avorelo hooks found for: ${duplicates.join(", ")}. Re-run apply to fix.` });
|
|
775
|
+
} else if (avoreloHooks.length > 0) {
|
|
776
|
+
checks.push({ id: "no_duplicate_hooks", host: hostKey, status: "pass", message: "No duplicate hooks." });
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Check user hooks preserved
|
|
780
|
+
const userHooks = hooks.filter((h) => !isAvoreloManagedHook(h));
|
|
781
|
+
checks.push({ id: "user_hooks_preserved", host: hostKey, status: "pass", message: `${userHooks.length} user hook(s) preserved.` });
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Check 2: Lifecycle handler commands work
|
|
785
|
+
try {
|
|
786
|
+
const { handleLifecycleHook } = require("./lifecycle-hooks");
|
|
787
|
+
const testResult = handleLifecycleHook(cwd, "session-start", {}, {});
|
|
788
|
+
if (testResult && testResult.status === "ok") {
|
|
789
|
+
checks.push({ id: "lifecycle_session_start", status: "pass", message: "SessionStart handler works." });
|
|
790
|
+
} else {
|
|
791
|
+
checks.push({ id: "lifecycle_session_start", status: "warn", message: "SessionStart handler returned non-ok status." });
|
|
792
|
+
}
|
|
793
|
+
} catch (e) {
|
|
794
|
+
checks.push({ id: "lifecycle_session_start", status: "fail", message: `SessionStart handler error: ${e.message}` });
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Check 3: PreToolUse destructive blocks
|
|
798
|
+
try {
|
|
799
|
+
const { handleLifecycleHook } = require("./lifecycle-hooks");
|
|
800
|
+
const blockResult = handleLifecycleHook(cwd, "pre-tool-use", { toolInput: { command: "rm -rf /prod" } }, {});
|
|
801
|
+
if (blockResult && blockResult.decision === "block") {
|
|
802
|
+
checks.push({ id: "pre_tool_use_blocks_destructive", status: "pass", message: "PreToolUse blocks destructive commands." });
|
|
803
|
+
} else {
|
|
804
|
+
checks.push({ id: "pre_tool_use_blocks_destructive", status: "warn", message: "PreToolUse did not block destructive sample. Check guard logic." });
|
|
805
|
+
}
|
|
806
|
+
} catch (e) {
|
|
807
|
+
checks.push({ id: "pre_tool_use_blocks_destructive", status: "fail", message: `PreToolUse error: ${e.message}` });
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Check 4: Stop proof gate
|
|
811
|
+
try {
|
|
812
|
+
const { handleLifecycleHook } = require("./lifecycle-hooks");
|
|
813
|
+
const stopResult = handleLifecycleHook(cwd, "stop", {}, {});
|
|
814
|
+
if (stopResult && (stopResult.decision === "allow" || stopResult.decision === "block")) {
|
|
815
|
+
checks.push({ id: "stop_proof_gate", status: "pass", message: `Stop handler works (decision: ${stopResult.decision}).` });
|
|
816
|
+
} else {
|
|
817
|
+
checks.push({ id: "stop_proof_gate", status: "warn", message: "Stop handler did not return a clear decision." });
|
|
818
|
+
}
|
|
819
|
+
} catch (e) {
|
|
820
|
+
checks.push({ id: "stop_proof_gate", status: "fail", message: `Stop handler error: ${e.message}` });
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Check 5: Recursion guard
|
|
824
|
+
const guardEnv = process.env.AVORELO_HOOK_ACTIVE;
|
|
825
|
+
checks.push({
|
|
826
|
+
id: "recursion_guard",
|
|
827
|
+
status: "pass",
|
|
828
|
+
message: `Recursion guard (AVORELO_HOOK_ACTIVE) is ${guardEnv ? "ACTIVE — would skip re-entry" : "inactive (normal — guard is active only when hook runs)."}`,
|
|
829
|
+
guardActive: !!guardEnv,
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
// Check 6: Rollback available
|
|
833
|
+
const latestApply = safeReadJson(path.join(cwd, LATEST_APPLY_REL));
|
|
834
|
+
const hasBackup = latestApply?.hostsApplied?.some((h) => h.backupPath);
|
|
835
|
+
checks.push({
|
|
836
|
+
id: "rollback_available",
|
|
837
|
+
status: hasBackup ? "pass" : "warn",
|
|
838
|
+
message: hasBackup
|
|
839
|
+
? "Rollback available (backup exists from last apply)."
|
|
840
|
+
: "No backup found. Apply hooks first to enable rollback.",
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// Check 7: No global config modification
|
|
844
|
+
checks.push({
|
|
845
|
+
id: "no_global_config_modified",
|
|
846
|
+
status: "pass",
|
|
847
|
+
message: "Hook apply only modifies repo-local config. Global config untouched.",
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
const failCount = checks.filter((c) => c.status === "fail").length;
|
|
851
|
+
const warnCount = checks.filter((c) => c.status === "warn").length;
|
|
852
|
+
const passCount = checks.filter((c) => c.status === "pass").length;
|
|
853
|
+
|
|
854
|
+
const doctorStatus = failCount > 0 ? "fail" : warnCount > 0 ? "warn" : "pass";
|
|
855
|
+
|
|
856
|
+
const nextAction = failCount > 0
|
|
857
|
+
? checks.find((c) => c.status === "fail")?.message
|
|
858
|
+
: warnCount > 0
|
|
859
|
+
? "Run `node bin/avorelo hooks apply --yes --json` to install hooks."
|
|
860
|
+
: "All checks pass. Lifecycle is live.";
|
|
861
|
+
|
|
862
|
+
const receipt = {
|
|
863
|
+
contract: CONTRACT,
|
|
864
|
+
schemaVersion: SCHEMA_VERSION,
|
|
865
|
+
createdAt: nowIso(),
|
|
866
|
+
status: doctorStatus,
|
|
867
|
+
checks,
|
|
868
|
+
summary: { pass: passCount, warn: warnCount, fail: failCount },
|
|
869
|
+
nextAction,
|
|
870
|
+
rollbackAvailable: hasBackup || false,
|
|
871
|
+
hooksApplied: appliedHostCount > 0,
|
|
872
|
+
redacted: true,
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
safeWriteJson(receiptPath, receipt);
|
|
876
|
+
writeLedgerEntry(cwd, "hook_doctor", receipt);
|
|
877
|
+
return receipt;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ── Receipt writer ────────────────────────────────────────────────────────────
|
|
881
|
+
|
|
882
|
+
function writeHookApplyReceipt(cwd, receipt) {
|
|
883
|
+
safeWriteJson(path.join(cwd, LATEST_APPLY_REL), receipt);
|
|
884
|
+
return LATEST_APPLY_REL;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// ── Surface / text formatters ─────────────────────────────────────────────────
|
|
888
|
+
|
|
889
|
+
function buildHookApplySurface(cwd, options = {}) {
|
|
890
|
+
const plan = buildHookApplyPlan(cwd, options);
|
|
891
|
+
const latestApply = safeReadJson(path.join(cwd, LATEST_APPLY_REL));
|
|
892
|
+
const latestDoctor = safeReadJson(path.join(cwd, LATEST_DOCTOR_REL));
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
plan,
|
|
896
|
+
latestApply: latestApply || null,
|
|
897
|
+
latestDoctor: latestDoctor || null,
|
|
898
|
+
rollbackAvailable: !!(latestApply?.hostsApplied?.some((h) => h.backupPath)),
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function formatHookApplyText(receipt, options = {}) {
|
|
903
|
+
const lines = [];
|
|
904
|
+
|
|
905
|
+
if (!receipt) {
|
|
906
|
+
lines.push("No hook apply receipt available. Run `node bin/avorelo hooks apply --dry-run --json`.");
|
|
907
|
+
return lines.join("\n");
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
lines.push(`Hook Apply status=${receipt.status}`);
|
|
911
|
+
|
|
912
|
+
if (receipt.message) lines.push(` ${receipt.message}`);
|
|
913
|
+
|
|
914
|
+
if (receipt.hostsApplied?.length) {
|
|
915
|
+
for (const h of receipt.hostsApplied) {
|
|
916
|
+
lines.push(` Applied: ${h.host} config=${h.configPath} backup=${h.backupPath || "none"}`);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (receipt.errors?.length) {
|
|
920
|
+
for (const e of receipt.errors) {
|
|
921
|
+
lines.push(` Error: ${e.host} ${e.error}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (receipt.safeNextAction) {
|
|
925
|
+
lines.push(` Next: ${receipt.safeNextAction}`);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return lines.join("\n");
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function formatHookDoctorText(receipt, options = {}) {
|
|
932
|
+
const lines = [];
|
|
933
|
+
|
|
934
|
+
if (!receipt) {
|
|
935
|
+
lines.push("No doctor receipt. Run `node bin/avorelo hooks doctor --json`.");
|
|
936
|
+
return lines.join("\n");
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
lines.push(`Hook Doctor status=${receipt.status} pass=${receipt.summary?.pass} warn=${receipt.summary?.warn} fail=${receipt.summary?.fail}`);
|
|
940
|
+
|
|
941
|
+
if (options.debug) {
|
|
942
|
+
for (const c of receipt.checks || []) {
|
|
943
|
+
lines.push(` [${c.status}] ${c.id}: ${c.message}`);
|
|
944
|
+
}
|
|
945
|
+
} else {
|
|
946
|
+
const problems = (receipt.checks || []).filter((c) => c.status !== "pass");
|
|
947
|
+
for (const c of problems) {
|
|
948
|
+
lines.push(` [${c.status}] ${c.id}: ${c.message}`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (receipt.nextAction) lines.push(` Next: ${receipt.nextAction}`);
|
|
953
|
+
|
|
954
|
+
return lines.join("\n");
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ── Ledger integration ────────────────────────────────────────────────────────
|
|
958
|
+
|
|
959
|
+
function writeLedgerEntry(cwd, type, receipt) {
|
|
960
|
+
try {
|
|
961
|
+
const { appendProductLearningEvent } = require("./product-learning-events");
|
|
962
|
+
const eventName = {
|
|
963
|
+
hook_apply: "hook_config_applied",
|
|
964
|
+
hook_rollback: "hook_rollback_performed",
|
|
965
|
+
hook_uninstall: "hook_uninstall_performed",
|
|
966
|
+
hook_doctor: "hook_doctor_run",
|
|
967
|
+
}[type];
|
|
968
|
+
|
|
969
|
+
if (eventName) {
|
|
970
|
+
appendProductLearningEvent(cwd, {
|
|
971
|
+
eventName,
|
|
972
|
+
payload: {
|
|
973
|
+
type,
|
|
974
|
+
status: receipt.status,
|
|
975
|
+
hostsApplied: receipt.hostsApplied?.map((h) => h.host) || [],
|
|
976
|
+
rollbackAvailable: receipt.rollbackAvailable || false,
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
} catch {
|
|
981
|
+
// non-fatal
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ── Exports ───────────────────────────────────────────────────────────────────
|
|
986
|
+
|
|
987
|
+
module.exports = {
|
|
988
|
+
CONTRACT,
|
|
989
|
+
SCHEMA_VERSION,
|
|
990
|
+
LATEST_APPLY_REL,
|
|
991
|
+
LATEST_DOCTOR_REL,
|
|
992
|
+
LATEST_ROLLBACK_REL,
|
|
993
|
+
BACKUP_DIR_REL,
|
|
994
|
+
AVORELO_HOOK_MARKER,
|
|
995
|
+
AVORELO_LIFECYCLE_COMMANDS,
|
|
996
|
+
HOST_APPLY_CONFIGS,
|
|
997
|
+
// Core API
|
|
998
|
+
buildHookApplyPlan,
|
|
999
|
+
applyHookConfig,
|
|
1000
|
+
validateAppliedHooks,
|
|
1001
|
+
rollbackHookApply,
|
|
1002
|
+
uninstallAvoreloHooks,
|
|
1003
|
+
runHookDoctor,
|
|
1004
|
+
writeHookApplyReceipt,
|
|
1005
|
+
buildHookApplySurface,
|
|
1006
|
+
// Formatters
|
|
1007
|
+
formatHookApplyText,
|
|
1008
|
+
formatHookDoctorText,
|
|
1009
|
+
// Helpers (exported for testing)
|
|
1010
|
+
isAvoreloManagedHook,
|
|
1011
|
+
mergeClaudeCodeConfig,
|
|
1012
|
+
mergeOpenHandsConfig,
|
|
1013
|
+
removeAvoreloHooks,
|
|
1014
|
+
backupConfig,
|
|
1015
|
+
findLatestBackup,
|
|
1016
|
+
parseExistingConfig,
|
|
1017
|
+
requiresExplicitApproval,
|
|
1018
|
+
isDryRun,
|
|
1019
|
+
};
|